commit ecb3dc63edbd6a16657f47cab33f2b9796c789bf Author: biss Date: Wed Jul 22 18:22:52 2026 +0800 项目迁移 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8f1d274 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..9405a02 --- /dev/null +++ b/.env.docker.example @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7397fdd --- /dev/null +++ b/.env.example @@ -0,0 +1,60 @@ +# 本地开发(默认) +NODE_ENV=development +DATABASE_CLIENT=sqlite +SQLITE_PATH=./data/exam.sqlite +HOST=127.0.0.1 +PORT=4173 + +# 可选 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 +INITIAL_ADMIN_PASSWORD=Admin123! +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 +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=本平台展示数据仅用于系统演示 + +# MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。 +# NODE_ENV=production +# DATABASE_CLIENT=mysql +# MYSQL_HOST=127.0.0.1 +# MYSQL_PORT=3306 +# MYSQL_USER=exam_app +# MYSQL_PASSWORD=replace-with-a-strong-password +# MYSQL_DATABASE=exam_information +# MYSQL_CONNECTION_LIMIT=10 +# HOST=0.0.0.0 + +# 也可以用单个连接地址替代全部 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 diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..a67668b --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -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 }} + ``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d316597 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +data/db.json +data/test-db.json +data/*.sqlite +data/*.sqlite-shm +data/*.sqlite-wal +node_modules/ +.env +.env.docker +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f3b319 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c6da99f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BISS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9438a37 --- /dev/null +++ b/README.md @@ -0,0 +1,371 @@ +# 衡准 · 考试信息管理系统 + +一个完整可运行的分级权限考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。 + +## 已实现功能 + +### 公开服务首页 + +- 通知公告首页展示与详情阅读 +- 已发布考试、报名时间、考试时间和科目展示 +- 考试服务办理流程说明 +- 考生注册与双角色登录入口 + +### 考生中心 + +- 报名号即考生账户,同一考生参加不同考试始终使用同一个号码 +- 学校管理员按一个或多个班级填写人数并提交批量申领(单人也按 1 人批次走审批) +- 首次登录强制修改初始密码,完成后才能补全个人信息 +- 完整维护姓名、性别、证件号码、籍贯、出生日期、民族、家庭住址、手机号、邮箱、学校、班级、监护人和紧急联系人 +- 自主注册可由超级管理员随时开启或关闭;开启后系统直接生成固定报名号 +- 资料审核状态与管理员审核意见 +- 查看开放考试并自主选择多个报考科目 +- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录 +- 准考证生成状态、开放时间与下载 +- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论 +- 下载带 HMAC 防伪查询码和二维码的 PDF 成绩单 +- 查看正式录取结果、录取通知书编号,并下载招生学校自定义样式的 PDF 录取通知书 +- 通知公告中心 + +### 管理后台 + +- 超级、校级、班级三级管理员,同一级支持多个账号 +- 超级管理员管理全局事务,并可监督、修改、退回全部审批流程 +- 校级管理员管理本校班级、班级管理员、考生和报名流程,按班级批量申领报名号,并提交本校考点、考场档案变更 +- 班级管理员可查看并审批本班考生、报名与成绩复议流程,并维护本班考生缴费状态;成绩录入仍仅限超级管理员 +- 报名终审与缴费确认相互独立,系统不接入支付 SDK;超级、校级、班级管理员均可在各自数据范围内修改缴费状态,确认缴费时记录办理人和时间 +- 超级、校级、班级管理员均可按各自数据范围筛选、查看及导出 Excel 缴费名单 +- 考生信息修改、考试报名、成绩复议、批量报名号申领、考点考场变更使用可配置的多步骤审批流程 +- 班级和校级审批自动限定到考生所属班级、学校;同范围多名管理员按当前待办与历史分配量自动均分 +- 当前处理人可将流程转交给同范围的同级管理员 +- 自定义报名号生成规则,可组合年份、学校代码、性别、固定值和流水号 +- 报名号在创建考生账户时只生成一次,后续考试报名自动复用 +- 超级管理员只维护号码规则;校级批量申请最终批准后,系统原子生成固定报名号、随机初始密码和待补录账户 +- 批次结果按班级返回校级管理员,并可导出 Excel 安全下发 +- 结构化考点与考场档案,包含代码、负责人、应急电话、开放时间、交通、楼栋、楼层、容量、座位编排说明、类型和状态 +- 考点新增及考点/考场修改先形成申请快照,审批通过后才整体更新正式档案 +- 班级、班级管理员、报名号班级配额、考生资料、考点考场和成绩均提供 Excel 模板、导入与当前数据导出;只读角色保留对应导出能力 +- Excel 导入逐行校验并返回具体行号;考生资料和考点考场的批量修改仍必须经过配置好的审批流程 +- 考务指标与审计日志 +- 考生资料审核、通过或退回修改 +- 考试报名及科目审核 +- 创建考试并结构化配置科目日期、时间、费用和满分;每科可独立选择固定分、排名前百分比或不设单科线,并汇总总分 +- 支持固定总分线、总成绩排名前百分比、单科均达线及不判定四类整场合格策略 +- 通知发布、草稿、撤回及首页置顶 +- 按整场考试预检并批量编排准考证,支持班内、校内、县区内、市内和省内五级混编 +- 预置“县区编号+考场号+座位号”“县区号+考场号+流水号”“考点学校代码+考场号+座位号”“考生学校代码+考场号+座位号”四种号码规则 +- 多科目考生固定在同一考点,各科独立分配考场和座位;同科目组合优先相邻编排 +- 编排前校验科目时间冲突、考点容量、档案完整性和号码唯一性,默认保留备用考场并支持稳定种子复现 +- 每科可独立设置固定及格分、排名前百分比或不设单科线;成绩等级按同场同科排名百分位自动计算 +- 默认排名等级区间为前 10% A+、前 25% A、前 50% B+、前 70% B、前 90% C、其余 D;同分共享名次 +- 成绩管理中心按多场考试切换,展示录入/发布进度、成绩出齐人数、整场合格率、缺失科次与复议数量,并提供可筛选成绩台账 +- 成绩复议终审表单展示考试、科目、原分、当前排名和达线规则;批准后在同一事务内更新成绩并只重算该考生的排名区间结论 +- 成绩 Excel 采用“上传解析与逐行校验—页面暂存预览—确认后原子批量写库”的两阶段流程,预览不会修改数据库 +- 超级管理员可将整场考试不可逆归档;归档后手工录入、Excel 导入、复议改分和考试配置全部锁定,历史报名、准考证与成绩默认折叠展示 +- 管理员与考生接口权限隔离 + +### 系统能力 + +- PBKDF2 加盐密码哈希 +- 可选 TOTP 二次验证,支持验证器扫码绑定、一次性恢复码与登录防重放 +- TOTP 密钥使用 AES-256-GCM 加密存储,恢复码仅保存带服务端密钥的哈希 +- HttpOnly、SameSite 登录 Cookie +- 服务端角色权限校验 +- SQLite / MySQL 8.4 双数据库持久化 +- 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源 +- Redis 登录状态存储,和普通接口缓存使用不同逻辑数据库,支持多实例共享会话 +- 规范关系模型、外键、唯一约束和业务索引 +- 业务写入与审计日志使用原子事务提交 +- 关键管理操作审计日志 +- 组织、学校、班级三级数据范围在服务端强制过滤 +- 审批实例、当前责任人、转交和监督操作全程留痕 +- 桌面端与移动端响应式布局 +- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小 +- 成绩单与录取通知书以 PDF 下载,使用服务端 HMAC 防伪码支持公开验真 + +## 运行 + +需要 Node.js 22.5 或更高版本(SQLite 使用 Node.js 内置驱动)。 + +```powershell +npm install +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 的开发库会提示重建。 + +### Docker + +项目根目录包含生产镜像和 Docker Compose 配置。默认使用 SQLite,数据库保存在命名卷 `exam-information-data` 中,因此重建容器不会丢失数据。 + +先创建容器环境文件,并将其中的 TOTP 主密钥、文书防伪签名密钥和初始管理员密码替换为相互独立的安全随机值: + +```powershell +Copy-Item .env.docker.example .env.docker +``` + +然后构建并启动: + +```powershell +docker compose up --build --detach +``` + +启动完成后访问 。查看状态和日志可运行: + +```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 src/data/china-regions.mjs`。 + +## 数据库配置 + +应用启动时会自动读取项目根目录的 `.env`,可先运行 `Copy-Item .env.example .env` 创建配置文件。命令行或部署平台已经注入的进程环境变量优先于 `.env`。应用根据 `DATABASE_CLIENT` 使用不同数据库;未设置时,开发/测试环境默认 `sqlite`,`NODE_ENV=production` 默认 `mysql`。 + +公开首页的机构名称、机构代码、电话、地址、邮箱、主标语和页脚提示分别由 `PUBLIC_SITE_NAME`、`PUBLIC_SITE_CODE`、`PUBLIC_SITE_PHONE`、`PUBLIC_SITE_ADDRESS`、`PUBLIC_SITE_EMAIL`、`PUBLIC_SITE_HERO_*`、`PUBLIC_SITE_FOOTER_NOTICE` 配置。修改 `.env` 后需要重启应用;这些配置会覆盖数据库中的演示机构信息,且只通过公开首页接口返回非敏感展示字段。 + +### 本地 SQLite + +```powershell +$env:DATABASE_CLIENT = 'sqlite' +$env:SQLITE_PATH = './data/exam.sqlite' +npm start +``` + +`SQLITE_PATH` 可省略,默认路径就是 `./data/exam.sqlite`。 + +### 生产 MySQL 8.4 + +先在 MySQL 8.4 中创建数据库和最小权限账号: + +```sql +CREATE DATABASE exam_information CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +CREATE USER 'exam_app'@'%' IDENTIFIED BY 'replace-with-a-strong-password'; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'exam_app'@'%'; +``` + +启动应用时设置连接信息,应用会自动创建以下关系表和系统基础配置,但不会自动写入测试业务数据: + +- `schools`、`school_classes`、`users`、`candidate_profiles` +- `school_student_partitions`(学校学生专属表登记) +- `exams`、`exam_subjects` +- `exam_data_partitions`(考试专属表登记) +- `registrations`、`registration_subjects` +- `admission_number_rules`、`exam_arrangement_plans`、`admit_cards`、`admit_card_subjects` +- `results`、`notices`、`audit_logs` +- `test_centers`、`test_rooms`、`center_change_requests`、`center_change_rooms` +- `number_rules`、`number_rule_segments` +- `candidate_account_batches`、`candidate_account_batch_items` +- `workflow_definitions`、`workflow_steps`、`workflow_instances`、`workflow_actions` +- `organization`、`schema_metadata` + +所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。 + +系统采用“总表索引 + 独立物理分表”存储:创建每场考试时,会立即创建该场考试专用的 +`exam_<分区键>_candidates`、`exam_<分区键>_admissions`、`exam_<分区键>_results`、 +`exam_<分区键>_centers` 四张表;创建每所学校时,会创建 `school_<分区键>_students` +学生专属表。分区键由业务 ID 的 SHA-256 摘要生成,不直接拼接用户输入。报名、缴费、准考证编排、 +成绩和学生资料发生变化后,专属表会自动同步;总表继续承担跨考试、跨学校查询和外键完整性约束。 + +```powershell +$env:NODE_ENV = 'production' +$env:DATABASE_CLIENT = 'mysql' +$env:MYSQL_HOST = '127.0.0.1' +$env:MYSQL_PORT = '3306' +$env:MYSQL_USER = 'exam_app' +$env:MYSQL_PASSWORD = 'replace-with-a-strong-password' +$env:MYSQL_DATABASE = 'exam_information' +$env:HOST = '0.0.0.0' +npm start +``` + +也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。 + +### 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 Cluster 不支持非 0 逻辑 DB,请用 `REDIS_SESSION_URL` 为认证状态配置独立 Redis 实例。 + +### 导入服务器 MySQL 测试数据 + +先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数,然后执行: + +```powershell +npm run seed-test-data:mysql +``` + +脚本会读取 `.env`,校验当前连接的数据库名称、v15 表结构和已有数据。目标是新数据库时会自动建表并导入;目标只有首次启动生成的空业务结构时会在事务中替换为样例数据。若检测到学校、考生、考试、报名等业务数据,脚本默认拒绝覆盖。 + +仅在确认目标是可以完全覆盖的测试库时使用: + +```powershell +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`。 + +## 手动测试数据账号 + +测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建: + +为了便于临时联调,所有预置样例账号统一使用密码 `12345678`,且预置考生不会在首次登录时被要求改密。通过系统业务流程后续新建的账号仍按正式规则生成随机初始密码。 + +| 角色 | 账号 | 密码 | +| --- | --- | --- | +| 超级管理员 | `admin` | `12345678` | +| 超级管理员(监督演示) | `supervisor` | `12345678` | +| 校级管理员 | `school_admin` | `12345678` | +| 同校校级管理员(转交演示) | `school_admin_2` | `12345678` | +| 班级管理员 | `class_admin` | `12345678` | +| 同班班级管理员(均分演示) | `class_admin_2` | `12345678` | +| 考生 | `2026-HZ01-F-0001` | `12345678` | + +## 测试结束后初始化系统 + +先停止应用,然后运行以下命令。命令会读取 `.env` 并自动选择 SQLite 或 MySQL,删除样例学校、考生、考试、报名等业务数据,恢复系统基础配置和一个初始超级管理员: + +```powershell +npm run initialize-system +``` + +也可以明确指定数据库类型: + +```powershell +npm run initialize-system:sqlite +npm run initialize-system:mysql +``` + +MySQL 模式会自动识别由本项目生成的批量样例数据并清理。若目标包含无法识别为样例数据的业务记录,命令会拒绝执行;只有明确确认目标可完全清空时才可运行 `npm run initialize-system:mysql -- --force`。初始化完成后,初始管理员账号由 `.env` 中的 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 决定,然后再重新启动应用。 + +## 自动化测试 + +```powershell +npm test +``` + +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、三级管理员范围内缴费状态修改与名单导出、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、独立科目及格规则、成绩 Excel 预览后原子提交、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 + +## 项目结构 + +项目采用模块化单体架构:仍由一个 Node.js 进程部署,但 HTTP、权限、业务路由、数据库适配和前端页面按职责分开。 + +```text +index.html 页面入口 +styles.css 公共首页、考生端、管理端响应式样式 +app.js 前端路由、事件与表单控制器 +server.mjs HTTP 服务启动、模块装配与静态文件服务 +database.mjs 数据仓储与数据库模块装配 +excel.mjs Excel 模板、导入解析与导出工作簿 + +src/data/base.mjs 空业务库与系统基础配置 +src/data/seed.mjs 手动测试数据生成器 +scripts/import-test-data.mjs 独立测试数据导入脚本 +src/http/responses.mjs JSON、文件与请求体处理 +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 +src/routes/candidate.routes.mjs 考生业务 API +src/routes/admin.routes.mjs 管理业务 API + +src/database/schema.mjs SQLite / MySQL 关系模型 +src/database/sqlite-adapter.mjs SQLite 初始化、迁移与事务适配 +src/database/mysql-adapter.mjs MySQL 初始化、迁移与事务适配 + +src/client/state.mjs 前端共享状态 +src/client/api.mjs 浏览器 API 请求封装 +src/client/ui.mjs 格式化、图标与通用 UI 工具 +src/client/public-views.mjs 公共首页与登录注册视图 +src/client/candidate-views.mjs 考生中心视图 +src/client/admin-views.mjs 管理后台视图 + +tests/system.test.mjs 端到端系统测试 +data/exam.sqlite 本地运行后生成的 SQLite 数据库 +.env.example 开发与生产环境变量模板 +``` diff --git a/app.js b/app.js new file mode 100644 index 0000000..986ddde --- /dev/null +++ b/app.js @@ -0,0 +1,1634 @@ +import { api } from './src/client/api.mjs'; +import { createAdminViews, numberSegmentMeta } from './src/client/admin-views.mjs'; +import { createCandidateViews } from './src/client/candidate-views.mjs'; +import { createAdmissionViews } from './src/client/admission-views.mjs'; +import { admissionCategoryEditor, indicatorAllocationEditor } from './src/client/admission-plan-editor.mjs'; +import { createPublicViews } from './src/client/public-views.mjs'; +import { state } from './src/client/state.mjs'; +import { badge, dateRange, formatDate, h, icons, money, passPolicyText, statusLabels } from './src/client/ui.mjs'; +import { formatRegionAddress, mountRegionSelects, updateRegionSelects } from './src/client/region-select.mjs'; +import { specialtyCatalog } from './src/data/specialty-types.mjs'; +import { getTableControl, setTableControl } from './src/client/table-state.mjs'; +import { downloadAdmissionNotice, downloadScoreReport } from './src/client/pdf-export.mjs'; + +const app = document.querySelector('#app'); +const modalRoot = document.querySelector('#modalRoot'); +let toastTimer; +let noticeEditor; +let ckeditorModulePromise; +let tableSearchTimer; +let reportingCameraStream; +let reportingCameraFrame; +let reportingCameraToken = 0; +function toast(title, message = '') { + const element = document.querySelector('#toast'); + element.querySelector('strong').textContent = title; + element.querySelector('small').textContent = message; + element.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => element.classList.remove('show'), 2800); +} + +function setModal(content) { + document.body.classList.remove('review-subpage-open'); + modalRoot.innerHTML = ``; + setTimeout(() => modalRoot.querySelector('input,textarea,button')?.focus(), 30); +} + +function setReviewSubpage(content) { + document.body.classList.add('review-subpage-open'); + modalRoot.innerHTML = `
${content}
`; + setTimeout(() => modalRoot.querySelector('button,input,textarea,select')?.focus(), 30); +} + +function stopReportingCamera() { + reportingCameraToken += 1; + if (reportingCameraFrame) cancelAnimationFrame(reportingCameraFrame); + reportingCameraFrame = null; + reportingCameraStream?.getTracks().forEach(track => track.stop()); + reportingCameraStream = null; +} + +function closeModal() { + stopReportingCamera(); + const editor = noticeEditor; + noticeEditor = null; + if (editor) editor.destroy().catch(error => console.error('CKEditor cleanup failed', error)); + document.body.classList.remove('review-subpage-open'); + modalRoot.innerHTML = ''; +} + +function reportingScanConfirmation(preview, rawCode) { + stopReportingCamera(); + const row = preview.row; + setModal(`
核验通过
考生姓名
${h(row.name)}
报名号
${h(row.candidateNumber)}
通知书编号
${h(row.noticeNumber)}
录取类别
${h(row.categoryName || '—')}
报到确认结果
`); +} + +async function previewReportingScan(code, examId) { + const preview = await api('/api/admission/reporting/scan-preview', { method: 'POST', body: { code, examId } }); + reportingScanConfirmation(preview, code); + return preview; +} + +async function openReportingCamera(examId) { + stopReportingCamera(); + const token = reportingCameraToken; + setModal(`
正在等待二维码进入画面

正在请求相机权限…

`); + const video = modalRoot.querySelector('[data-reporting-camera]'); + const status = modalRoot.querySelector('[data-reporting-camera-status]'); + try { + if (!navigator.mediaDevices?.getUserMedia) throw new Error('当前浏览器未提供相机访问能力'); + reportingCameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); + if (token !== reportingCameraToken) { reportingCameraStream.getTracks().forEach(track => track.stop()); return; } + video.srcObject = reportingCameraStream; + await video.play(); + if (!('BarcodeDetector' in window)) throw new Error('相机已打开,但当前浏览器不支持自动识别二维码,请使用下方防伪码核验'); + const detector = new BarcodeDetector({ formats: ['qr_code'] }); + status.textContent = '相机已开启,请将二维码对准取景框'; + let detecting = false; + const scan = async () => { + if (token !== reportingCameraToken || !reportingCameraStream) return; + if (!detecting && video.readyState >= 2) { + detecting = true; + try { + const codes = await detector.detect(video); + const code = codes[0]?.rawValue; + if (code) { + status.textContent = '已识别二维码,正在核验…'; + await previewReportingScan(code, examId); + return; + } + } catch (error) { + if (token !== reportingCameraToken) return; + status.textContent = error.message || '二维码核验失败,请重新对准取景框'; + } finally { detecting = false; } + } + reportingCameraFrame = requestAnimationFrame(scan); + }; + scan(); + } catch (error) { + if (token !== reportingCameraToken) return; + status.textContent = `无法使用相机:${error.message}。可检查权限,或使用下方备用方式。`; + status.classList.add('error'); + } +} + +function loadCKEditor() { + if (!document.querySelector('link[data-ckeditor-styles]')) { + const stylesheet = document.createElement('link'); + stylesheet.rel = 'stylesheet'; + stylesheet.href = '/vendor/ckeditor5/ckeditor5.css'; + stylesheet.dataset.ckeditorStyles = ''; + document.head.append(stylesheet); + } + ckeditorModulePromise ||= Promise.all([ + import('/vendor/ckeditor5/ckeditor5.js'), + import('/vendor/ckeditor5/translations/zh-cn.js') + ]); + return ckeditorModulePromise; +} + +function emptyState(title, description, route, action) { + return `
${icons.ticket}

${h(title)}

${h(description)}

${route ? `` : ''}
`; +} + +function renderError(error) { + if (error?.status === 401) return requireLogin(); + console.error('Page failed to render', error); + const message = error?.status ? error.message : '请求未能完成,请稍后重试或返回首页。'; + app.innerHTML = `
!

页面暂时无法加载

${h(message)}

`; +} + +function requireLogin() { + state.user = null; + state.profile = null; + state.permissions = []; + state.scopeLabel = ''; + state.pageData = null; + state.resultExamFilter = ''; + state.resultSubjectFilter = ''; + state.resultExamCatalog = null; + state.authNotice = '登录状态已失效,请重新登录。'; + navigate('login'); +} + +const baseViewContext = { state, app, h, formatDate, dateRange, badge, money, passPolicyText, statusLabels, icons, api, renderError, requireLogin, emptyState }; +const { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification } = createPublicViews(baseViewContext); +const { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity } = createCandidateViews({ ...baseViewContext, brand }); +const { renderAdmin, workflowStepEditor } = createAdminViews({ ...baseViewContext, brand, portalShell, loadingPanel, adminNavForUser, accountSecurity }); +const { renderAdmission } = createAdmissionViews({ ...baseViewContext, brand }); + +function navigate(route) { + location.hash = route; + if (location.hash.slice(1) === route) renderRoute(); +} + +async function renderRoute() { + closeModal(); + window.scrollTo({ top: 0, behavior: 'instant' }); + const route = location.hash.slice(1) || 'home'; + const [section, page = 'dashboard'] = route.split('/'); + if (section !== 'login') state.authNotice = ''; + if (section === 'home') renderHome(); + else if (section === 'verify') { + if (!page || page === 'dashboard') renderVerification(); + else { + try { renderVerification(page, await api(`/api/public/verifications/${encodeURIComponent(page)}`)); } + catch (error) { renderVerification(page, null, error.message); } + } + } + else if (section === 'notices' || section === 'announcements') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements); } + else if (section === 'notice') { state.publicAnnouncements = await api('/api/public/announcements'); renderNoticeCenter(state.publicAnnouncements, page); } + else if (section === 'login' || section === 'register') renderAuth(section); + else if (section === 'candidate') await renderCandidate(page); + else if (section === 'admin') await renderAdmin(page); + else if (section === 'admission_school') await renderAdmission(page); + else navigate('home'); + restoreTableControls(); +} + +function restoreTableControls() { + document.querySelectorAll('[data-action="table-search"][data-target]').forEach(input => { input.value = getTableControl(state, input.dataset.target).query || ''; }); + document.querySelectorAll('[data-action="status-filter"][data-target]').forEach(button => button.classList.toggle('active', (getTableControl(state, button.dataset.target).status || 'all') === button.dataset.status)); + document.querySelectorAll('[data-table-filter][data-target]').forEach(select => { select.value = getTableControl(state, select.dataset.target).filters?.[select.dataset.tableFilter] || ''; }); +} + +function formObject(form) { + return Object.fromEntries(new FormData(form).entries()); +} + +function updateRegistrationSelection() { + const table = document.querySelector('#registrationTable'); + if (!table) return; + const selectable = [...table.querySelectorAll('[data-registration-select]:not(:disabled)')]; + const visible = selectable.filter(input => !input.closest('tr').hidden); + const selected = selectable.filter(input => input.checked); + const selectAll = table.querySelector('[data-registration-select-all]'); + if (selectAll) { + selectAll.checked = visible.length > 0 && visible.every(input => input.checked); + selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked; + } + const count = document.querySelector('[data-registration-selection-count]'); + if (count) count.textContent = selected.length; + document.querySelectorAll('[data-action="bulk-registration-review"]').forEach(button => { button.disabled = selected.length === 0; }); +} + +function updateCandidateSelection() { + const table = document.querySelector('#candidateTable'); + if (!table) return; + const selectable = [...table.querySelectorAll('[data-candidate-select]:not(:disabled)')]; + const visible = selectable.filter(input => !input.closest('tr').hidden); + const selected = selectable.filter(input => input.checked); + const selectAll = table.querySelector('[data-candidate-select-all]'); + if (selectAll) { + selectAll.checked = visible.length > 0 && visible.every(input => input.checked); + selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked; + } + const count = document.querySelector('[data-candidate-selection-count]'); + if (count) count.textContent = selected.length; + document.querySelectorAll('[data-action="bulk-candidate-review"]').forEach(button => { button.disabled = selected.length === 0; }); +} + +function updatePaymentSelection() { + const table = document.querySelector('#paymentTable'); + if (!table) return; + const selectable = [...table.querySelectorAll('[data-payment-select]:not(:disabled)')]; + const visible = selectable.filter(input => !input.closest('tr').hidden); + const selected = selectable.filter(input => input.checked); + const selectAll = table.querySelector('[data-payment-select-all]'); + if (selectAll) { + selectAll.checked = visible.length > 0 && visible.every(input => input.checked); + selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked; + } + const count = document.querySelector('[data-payment-selection-count]'); + if (count) count.textContent = selected.length; + document.querySelectorAll('[data-action="bulk-payment-update"]').forEach(button => { button.disabled = selected.length === 0; }); +} + +function updateQualificationSelection(container) { + if (!container) return; + const rows = [...container.querySelectorAll('[data-qualification-select]')]; + const visible = rows.filter(input => !input.closest('tr').hidden); + const selected = rows.filter(input => input.checked); + const selectAll = container.querySelector('[data-action="qualification-select-all"]'); + if (selectAll) { + selectAll.checked = visible.length > 0 && visible.every(input => input.checked); + selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked; + } + const count = container.querySelector('[data-qualification-selected-count]'); + if (count) count.textContent = `已选 ${selected.length} 人`; +} + +function updatePlacementSelection() { + const table = document.getElementById('placementReviewTable'); + if (!table) return; + const selectable = [...table.querySelectorAll('[data-placement-select]:not(:disabled)')]; + const visible = selectable.filter(input => !input.closest('tr').hidden); + const selected = selectable.filter(input => input.checked); + const selectAll = document.querySelector('[data-placement-select-all][data-target="placementReviewTable"]'); + if (selectAll) { + selectAll.checked = visible.length > 0 && visible.every(input => input.checked); + selectAll.indeterminate = visible.some(input => input.checked) && !selectAll.checked; + } + const count = document.querySelector('[data-placement-selected-count]'); + if (count) count.textContent = `已选 ${selected.length} 人`; +} + +function applyTableFilters(tableId) { + const table = document.getElementById(tableId); + if (!table) return; + const search = [...document.querySelectorAll('[data-action="table-search"]')].find(input => input.dataset.target === tableId); + const query = search?.value.trim().toLowerCase() || ''; + const status = [...document.querySelectorAll('[data-action="status-filter"]')].find(button => button.dataset.target === tableId && button.classList.contains('active'))?.dataset.status || 'all'; + const filters = [...document.querySelectorAll('[data-table-filter]')].filter(select => select.dataset.target === tableId && select.value); + table.querySelectorAll('[data-filter-row], tbody tr[data-status]').forEach(row => { + const matchesSearch = !query || row.textContent.toLowerCase().includes(query); + const matchesStatus = status === 'all' || String(row.dataset.status || '').split(/\s+/).includes(status); + const matchesFilters = filters.every(select => String(row.dataset[select.dataset.tableFilter] || '').split('|').includes(select.value)); + row.hidden = !(matchesSearch && matchesStatus && matchesFilters); + }); + if (tableId === 'registrationTable') updateRegistrationSelection(); + if (tableId === 'candidateTable') updateCandidateSelection(); + if (tableId === 'paymentTable') updatePaymentSelection(); + if (tableId === 'placementReviewTable') updatePlacementSelection(); + if (table.closest('.qualification-ledger')) updateQualificationSelection(table.closest('.qualification-ledger')); +} + +function updateResultScoreInput(input) { + const row = input.closest('tr'); + const error = row?.querySelector('[data-score-error]'); + const value = input.value.trim(); + const score = Number(value); + const max = Number(input.max); + const invalid = value !== '' && (!Number.isFinite(score) || score < 0 || score > max); + input.dataset.dirty = String(value !== input.defaultValue); + input.setAttribute('aria-invalid', String(invalid)); + row?.classList.toggle('score-row-invalid', invalid); + if (error) error.textContent = invalid ? `须在 0—${max} 之间` : ''; + const form = input.closest('form'); + const dirty = form?.querySelectorAll('[data-result-score][data-dirty="true"]').length || 0; + const count = form?.querySelector('[data-result-dirty-count]'); + if (count) count.textContent = dirty ? `${dirty} 条成绩尚未暂存` : '尚无未保存修改'; +} + +function updateFeatureScoreInput(input) { + const row = input.closest('tr'); + const error = row?.querySelector('[data-feature-score-error]'); + const value = input.value.trim(); + const score = Number(value); + const invalid = value === '' || !Number.isFinite(score) || score < 0 || score > 1000; + input.dataset.dirty = String(value !== input.defaultValue); + input.setAttribute('aria-invalid', String(invalid)); + row?.classList.toggle('score-row-invalid', invalid); + if (error) error.textContent = invalid ? '须在 0—1000 之间' : ''; + const form = input.closest('form'); + const dirty = form?.querySelectorAll('[data-feature-score][data-dirty="true"]').length || 0; + const count = form?.querySelector('[data-feature-dirty-count]'); + if (count) count.textContent = dirty ? `${dirty} 条特征分尚未保存` : '尚无未保存修改'; +} + +async function refreshPublic() { + state.publicData = await api('/api/public/home'); +} + +async function refreshSession() { + const session = await api('/api/auth/me'); + state.user = session.user; + state.profile = session.profile; + state.permissions = session.permissions || []; + state.scopeLabel = session.scopeLabel || ''; +} + +async function finishLogin(data) { + state.user = data.user; + state.authNotice = ''; + await refreshSession(); + closeModal(); + toast(data.usedRecoveryCode ? '已使用恢复码登录' : '登录成功', data.usedRecoveryCode ? '该恢复码已失效,请检查剩余恢复码' : `欢迎,${data.user.displayName}`); + navigate(data.user.role === 'candidate' && (state.user.mustChangePassword || !state.profile?.profileCompleted) ? 'candidate/onboarding' : `${data.user.role}/dashboard`); +} + +function showRecoveryCodes(codes) { + setModal(`
${codes.map(code => `${h(code)}`).join('')}
请立即复制并离线保存。关闭后系统不会再次显示这些恢复码。
`); +} + +document.addEventListener('click', async event => { + const routeTarget = event.target.closest('[data-route]'); + if (routeTarget) { + event.preventDefault(); + return navigate(routeTarget.dataset.route); + } + if (event.target.matches('[data-modal-backdrop]')) return closeModal(); + const target = event.target.closest('[data-action]'); + if (!target) return; + const action = target.dataset.action; + try { + if (action === 'close-modal') return closeModal(); + if (action === 'close-modal-refresh') { closeModal(); return renderRoute(); } + if (action === 'copy-recovery-codes') { + const codes = [...document.querySelectorAll('[data-recovery-codes] code')].map(item => item.textContent).join('\n'); + await navigator.clipboard.writeText(codes); + return toast('恢复码已复制', '请保存到可信的离线位置'); + } + if (action === 'copy-totp-secret') { + const secret = document.querySelector('[data-totp-secret]')?.textContent.replace(/\s/g, '') || ''; + await navigator.clipboard.writeText(secret); + return toast('手动密钥已复制'); + } + if (action === 'retry') return renderRoute(); + if (action === 'open-sidebar') return document.querySelector('#portalSidebar')?.classList.add('open'); + if (action === 'close-sidebar') return document.querySelector('#portalSidebar')?.classList.remove('open'); + if (action === 'toggle-public-nav') return document.querySelector('.public-header nav')?.classList.toggle('open'); + if (action === 'scroll-to') { + event.preventDefault(); + if (!document.querySelector(`#${target.dataset.target}`)) { navigate('home'); setTimeout(() => document.querySelector(`#${target.dataset.target}`)?.scrollIntoView({ behavior: 'smooth' }), 80); } + else document.querySelector(`#${target.dataset.target}`).scrollIntoView({ behavior: 'smooth' }); + return; + } + if (action === 'logout') { + await api('/api/auth/logout', { method: 'POST' }); + state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = ''; state.resultExamFilter = ''; state.resultSubjectFilter = ''; state.resultExamCatalog = null; + await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return; + } + if (action === 'open-notice') { + return navigate(`notice/${target.dataset.id}`); + } + if (action === 'notice-category') { + state.noticeCategory = target.dataset.category; + state.noticePage = 1; + renderNoticeCenter(state.publicAnnouncements); + return; + } + if (action === 'notice-page') { + if (target.disabled) return; + state.noticePage = Number(target.dataset.page || 1); + renderNoticeCenter(state.publicAnnouncements); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + if (action === 'download-admit') { window.location.href = `/api/candidate/registrations/${target.dataset.id}/admit-card`; return; } + if (action === 'download-score-report') { + const examId = target.dataset.examId; + const results = (state.pageData?.results || []).filter(item => item.examId === examId); + const summary = (state.pageData?.summaries || []).find(item => item.examId === examId); + if (!results.length || !summary?.verificationCode) return toast('成绩单暂不可下载', '请刷新页面后重试'); + await downloadScoreReport({ organization: state.publicData.organization, candidate: state.pageData.candidate || { name: state.user.displayName, candidateNumber: state.user.candidateNumber }, exam: { id: examId, name: results[0].examName, code: results[0].examCode }, results, summary: { ...summary, publishedAt: [...results].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0]?.publishedAt }, verificationCode: summary.verificationCode, verificationQr: summary.verificationQr, verificationUrl: `${location.origin}/#verify/${summary.verificationCode}` }); + return toast('PDF 成绩单已生成', '文件包含防伪查询码'); + } + if (action === 'download-admission-notice') { + const item = (state.pageData?.admissions || []).find(entry => entry.examId === target.dataset.examId); + if (!item?.placement || item.placement.status !== 'final' || !item.noticeVerificationCode) return toast('录取通知书暂不可下载', '只有正式录取后才能生成'); + await downloadAdmissionNotice({ organization: state.publicData.organization, candidate: { name: state.profile?.name || state.user.displayName }, exam: item.exam, placement: item.placement, school: item.placementSchool || { name: item.placement.schoolName || '招生学校' }, template: item.noticeTemplate || {}, verificationCode: item.noticeVerificationCode, verificationQr: item.noticeVerificationQr, noticeNumber: item.noticeNumber, verificationUrl: `${location.origin}/#verify/${item.noticeVerificationCode}` }); + return toast('录取通知书已生成', '请核对学校和录取类别'); + } + if (action === 'download-admitted-candidates') { + const examId = target.closest('.admission-export-bar')?.querySelector('[name="exportExamId"]')?.value; + if (!examId) return toast('请选择考试', '仅录取工作结束的考试可以下载'); + window.location.href = `/api/admission/placements/export?examId=${encodeURIComponent(examId)}`; + return; + } + if (action === 'admission-plan-review') { + const reviewNote = window.prompt(target.dataset.status === 'approved' ? '填写审核意见(可留空)' : '请填写退回原因', '') ?? null; + if (reviewNote == null) return; + await api(`/api/admin/admission-plans/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status, reviewNote } }); + toast(target.dataset.status === 'approved' ? '招生计划已通过并自动公示' : '招生计划已退回', target.dataset.status === 'approved' ? '公开通知公告已同步生成招生计划公示' : '招生学校可以修改后重新提交'); return renderRoute(); + } + if (action === 'admission-match') { + if (!window.confirm('确认按“分数优先、遵循志愿”执行本轮投档?填报顺序将锁定。')) return; + const data = await api(`/api/admin/admissions/${target.dataset.examId}/match`, { method: 'POST' }); + toast('投档完成', `${data.placementCount} 名考生已发送招生学校`); return renderRoute(); + } + if (action === 'admission-finalize') { + if (!window.confirm('确认结束本次录取?系统会向考生发送通知,并按设置自动公示。')) return; + const data = await api(`/api/admin/admissions/${target.dataset.examId}/finalize`, { method: 'POST' }); + await refreshPublic(); toast('录取工作已结束', `${data.admittedCount} 人正式录取`); return renderRoute(); + } + if (action === 'admission-supplementary') { + const preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-25T18:00)', ''); + if (!preferenceEnd) return; + await api(`/api/admin/admissions/${target.dataset.examId}/supplementary`, { method: 'POST', body: { preferenceEnd } }); + toast('补录已开启', '未录取考生可以填报新一轮志愿'); return renderRoute(); + } + if (action === 'withdrawal-review') { + const approved = target.dataset.approved === 'true'; + const reviewNote = window.prompt(approved ? '填写批准退档意见' : '填写驳回退档意见', ''); + if (reviewNote == null) return; + await api(`/api/admin/admission-withdrawals/${target.dataset.id}`, { method: 'PATCH', body: { approved, reviewNote } }); + toast(approved ? '退档已批准' : '退档申请已驳回'); return renderRoute(); + } + if (action === 'save-indicator-qualification') { + const row = target.closest('tr'); + const value = row?.querySelector('[data-indicator-eligible]')?.value; + if (!value) return toast('请选择资格结论', '必须明确选择有资格或无资格'); + const data = await api(`/api/admin/indicator-qualifications/${row.dataset.examId}/${row.dataset.userId}`, { method: 'PUT', body: { eligible: value === 'true' } }); + toast(data.published ? '资格已确认并自动公示' : '资格已确认', data.published ? '本校全部考生已确认完成' : '继续核对其他考生'); + return renderRoute(); + } + if (action === 'qualification-select-all') { + const container = target.closest('[data-qualification-bulk]')?.closest('.qualification-ledger'); + container?.querySelectorAll('[data-qualification-select]').forEach(input => { + if (!input.closest('tr').hidden) input.checked = target.checked; + }); + updateQualificationSelection(container); + return; + } + if (action === 'download-admission-reporting') { + window.location.href = `/api/admission/reporting/export?examId=${encodeURIComponent(target.dataset.examId)}`; + return; + } + if (action === 'open-reporting-camera') { + await openReportingCamera(target.dataset.examId); + return; + } + if (action === 'bulk-reporting-apply') { + const toolbar = target.closest('[data-reporting-bulk]'); + const form = toolbar?.closest('form[data-form="admission-reporting-draft"]'); + const table = document.getElementById(toolbar?.dataset.tableId || ''); + const selected = [...(table?.querySelectorAll('[data-reporting-select]:checked') || [])]; + if (!selected.length) return toast('请先选择考生', '可勾选单人或全选当前页'); + const status = toolbar.querySelector('[data-reporting-bulk-status]').value; + const note = toolbar.querySelector('[data-reporting-bulk-note]').value.trim(); + selected.forEach(input => { + form.querySelector(`[data-reporting-status][data-placement-id="${CSS.escape(input.value)}"]`).value = status; + if (note) form.querySelector(`[data-reporting-note][data-placement-id="${CSS.escape(input.value)}"]`).value = note; + }); + toast('批量修改已应用', `已修改 ${selected.length} 名考生,请点击“暂存当前页”保存`); + return; + } + if (action === 'submit-admission-reporting') { + if (!window.confirm('确认提交本轮全部报到情况?提交后需先完成补录决定,才能交由超级管理员审批。')) return; + await api('/api/admission/reporting/submit', { method: 'POST', body: { examId: target.dataset.examId } }); + toast('报到情况已提交', '请根据计划完成率确认是否申请补录'); + return renderRoute(); + } + if (action === 'review-admission-reporting') { + const approved = target.dataset.approved === 'true'; + const supplement = target.dataset.supplement === 'true'; + const approvalNote = window.prompt(approved ? '请输入审批意见(可选)' : '请输入退回原因', '') ?? ''; + if (!approved && approvalNote.trim().length < 2) return toast('需要退回原因', '请说明招生学校应修改的内容'); + let preferenceEnd = ''; + if (approved && supplement) { + preferenceEnd = window.prompt('请输入补录志愿截止时间(例如 2026-07-30T18:00)', '') ?? ''; + if (!preferenceEnd) return; + } + await api(`/api/admin/admission-reporting/${encodeURIComponent(target.dataset.id)}`, { method: 'PATCH', body: { approved, approvalNote, preferenceEnd } }); + toast(approved ? '审批完成并自动公开' : '已退回招生学校', approved ? (supplement ? '全部学校审批完成后将自动开启补录' : '报到统计已进入公开通知') : '招生学校可修改暂存数据后重新提交'); + return renderRoute(); + } + if (action === 'admission-ledger-export') { + const ledger = target.dataset.ledger; + if (!['preferences', 'placements'].includes(ledger)) return; + const query = new URLSearchParams(); + if (target.dataset.examId) query.set('examId', target.dataset.examId); + if (target.dataset.round) query.set('round', target.dataset.round); + if (target.dataset.table) { + const control = getTableControl(state, target.dataset.table); + if (String(control.query || '').trim()) query.set('q', String(control.query).trim()); + if (control.status && control.status !== 'all') query.set('status', control.status); + Object.entries(control.filters || {}).forEach(([key, value]) => { if (value) query.set(key, value); }); + } + window.location.href = `/api/admin/admissions/${ledger}/export?${query}`; + return; + } + if (action === 'clear-table-filters') { + const tableId = target.dataset.target; + state.tableFilters[tableId] = { query: '', status: 'all', filters: {} }; + document.querySelectorAll(`[data-table-filter][data-target="${tableId}"]`).forEach(select => { select.value = ''; }); + document.querySelectorAll(`[data-action="table-search"][data-target="${tableId}"]`).forEach(input => { input.value = ''; }); + document.querySelectorAll(`[data-action="status-filter"][data-target="${tableId}"]`).forEach((button, index) => button.classList.toggle('active', index === 0)); + return renderRoute(); + } + if (action === 'bulk-indicator-qualification') { + const toolbar = target.closest('[data-qualification-bulk]'); + const ledger = toolbar?.closest('.qualification-ledger'); + const userIds = [...ledger.querySelectorAll('[data-qualification-select]:checked')].map(input => input.closest('tr').dataset.userId); + const value = toolbar.querySelector('[data-bulk-eligible]').value; + if (!userIds.length) return toast('请先选择考生', '可使用左侧复选框或全选'); + if (!value) return toast('请选择批量资格结论'); + if (!window.confirm(`确认将所选 ${userIds.length} 名考生批量设为“${value === 'true' ? '有' : '无'}指标分配资格”?`)) return; + const data = await api(`/api/admin/indicator-qualifications/${toolbar.dataset.examId}/bulk`, { method: 'PUT', body: { userIds, eligible: value === 'true' } }); + toast(data.published ? '批量确认完成并自动公示' : '批量确认完成', `已更新 ${data.count} 名考生`); + return renderRoute(); + } + if (action === 'bulk-placement-review') { + const ids = [...document.querySelectorAll('#placementReviewTable [data-placement-select]:checked')].map(input => input.value); + const decision = target.dataset.decision; + if (!ids.length) return toast('请先选择待审核考生', '可勾选单人,或选择当前筛选结果'); + let note = ''; + if (decision === 'withdraw') { + note = window.prompt(`将为所选 ${ids.length} 名考生申请退档,请填写统一的特殊理由(至少 8 个字)`, '') ?? ''; + if (!note) return; + if (note.trim().length < 8) return toast('退档理由至少需要 8 个字'); + } else if (!window.confirm(`确认批量接收所选 ${ids.length} 名投档考生吗?`)) return; + const result = await api('/api/admission/placements/bulk', { method: 'POST', body: { ids, decision, note } }); + toast(decision === 'accept' ? '批量接收完成' : '批量退档申请已提交', `已处理 ${result.count} 名考生`); + return renderRoute(); + } + if (action === 'toggle-admin-account') { + const active = target.dataset.active === 'true'; + if (!active && !window.confirm('确认停用该管理员账户?其现有登录会话会立即失效,历史审批记录将保留。')) return; + await api(`/api/admin/admins/${target.dataset.id}`, { method: 'PATCH', body: { active } }); + toast(active ? '管理员账户已启用' : '管理员账户已停用', active ? '该账号可以重新登录' : '现有会话已结束,历史记录仍保留'); + return renderRoute(); + } + if (action === 'reset-admin-password') { + if (!window.confirm('确认重置该管理员密码?其现有登录会话会立即失效。')) return; + const data = await api(`/api/admin/admins/${target.dataset.id}/reset-password`, { method: 'POST' }); + setModal(`
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}

旧密码和现有登录会话均已失效。

`); + return; + } + if (action === 'toggle-admission-account') { + const active = target.dataset.active === 'true'; + if (!active && !window.confirm('确认停用该招生学校账户?现有登录会话会立即失效,历史审核记录继续保留。')) return; + await api(`/api/admin/admission-school-accounts/${target.dataset.id}`, { method: 'PATCH', body: { active } }); + toast(active ? '招生学校账户已启用' : '招生学校账户已停用'); + return renderRoute(); + } + if (action === 'reset-admission-account-password') { + if (!window.confirm('确认重置该招生学校账户密码?旧密码和现有登录会话会立即失效。')) return; + const data = await api(`/api/admin/admission-school-accounts/${target.dataset.id}/reset-password`, { method: 'POST' }); + setModal(`
登录账号${h(data.username)}临时密码${h(data.temporaryPassword)}

账户已同步启用,旧密码和现有会话均已失效。

`); + return; + } + if (action === 'add-admission-category') { + const sources = state.pageData?.sourceSchools || []; + target.closest('form')?.querySelector('[data-admission-categories]')?.insertAdjacentHTML('beforeend', admissionCategoryEditor(h, sources)); + return; + } + if (action === 'remove-admission-category') { + const list = target.closest('[data-admission-categories]'); + if (list?.children.length <= 1) return toast('至少保留一个招生类别'); + target.closest('.admission-category-editor')?.remove(); + return; + } + if (action === 'add-indicator-allocation') { + const sources = state.pageData?.sourceSchools || []; + target.closest('.indicator-allocation-editor')?.querySelector('[data-indicator-allocations]')?.insertAdjacentHTML('beforeend', indicatorAllocationEditor(h, sources)); + return; + } + if (action === 'remove-indicator-allocation') { + target.closest('.indicator-allocation-row')?.remove(); + return; + } + if (['batch-admit-download', 'admit-info-export', 'center-materials-export'].includes(action)) { + event.preventDefault(); + const examId = target.closest('.admission-export-panel')?.querySelector('[data-admission-export-exam]')?.value; + if (!examId) return toast('请选择考试', '没有可导出的考试范围'); + const type = { 'batch-admit-download': 'admit-cards', 'admit-info-export': 'info', 'center-materials-export': 'center-materials' }[action]; + window.location.href = `/api/admin/admission-exports/${type}?examId=${encodeURIComponent(examId)}`; + return; + } + if (action === 'new-notice') { await openNoticeForm(); return; } + if (action === 'edit-notice') { await openNoticeForm(state.pageData.notices.find(item => item.id === target.dataset.id)); return; } + if (action === 'new-exam') return openExamForm(); + if (action === 'new-admin') return openAdminForm(); + if (action === 'new-school') return openSchoolForm(); + if (action === 'edit-school') return openSchoolForm(state.pageData.schools.find(item => item.id === target.dataset.id)); + if (action === 'toggle-school') { + const active = target.dataset.active === 'true'; + await api(`/api/admin/schools/${target.dataset.id}`, { method: 'PATCH', body: { active } }); + toast(active ? '学校已启用' : '学校已停用', active ? '考生公开入口已恢复显示' : '公开入口已隐藏,班级、管理员和历史数据均已保留'); return renderRoute(); + } + if (action === 'new-school-class') return openSchoolClassForm(); + if (action === 'edit-school-class') return openSchoolClassForm(state.pageData.classes.find(item => item.id === target.dataset.id)); + if (action === 'toggle-school-class') { + await api(`/api/admin/classes/${target.dataset.id}`, { method: 'PATCH', body: { active: target.dataset.active === 'true' } }); + toast(target.dataset.active === 'true' ? '班级已启用' : '班级已停用', '班级管理员和历史数据仍会保留'); return renderRoute(); + } + if (action === 'new-class-admin') return openClassAdminForm(null, target.dataset.classId); + if (action === 'edit-class-admin') { + const schoolClass = state.pageData.classes.find(item => item.id === target.dataset.classId); + return openClassAdminForm(schoolClass?.admins.find(item => item.id === target.dataset.id), target.dataset.classId); + } + if (action === 'new-center') return openCenterForm(); + if (action === 'edit-center') return openCenterForm(state.pageData.centers.find(item => item.id === target.dataset.id)); + if (action === 'add-center-room') { + document.querySelector('[data-center-rooms]')?.insertAdjacentHTML('beforeend', centerRoomEditor()); + return; + } + if (action === 'remove-center-room') { + const list = target.closest('[data-center-rooms]'); + if (list.children.length <= 1) return toast('至少保留一个考场', '考点档案必须包含结构化考场'); + target.closest('.center-room-editor').remove(); return; + } + if (action === 'open-flow') return openFlowDetail(target.dataset.id); + if (action === 'add-workflow-step') { + const form = document.querySelector(`[data-form="workflow-design"][data-type="${target.dataset.type}"]`); + form?.querySelector('[data-workflow-steps]')?.insertAdjacentHTML('beforeend', workflowStepEditor()); + return; + } + if (action === 'remove-workflow-step') { + const list = target.closest('[data-workflow-steps]'); + if (list.children.length <= 1) return toast('至少保留一步', '审批流程不能为空'); + target.closest('.workflow-step-row').remove(); return; + } + if (action === 'add-exam-subject') { + const form = target.closest('form'); + const date = form?.examStart?.value?.slice(0, 10) || ''; + form?.querySelector('[data-exam-subjects]')?.insertAdjacentHTML('beforeend', examSubjectEditor({ date })); + refreshExamScoringForm(form); + return; + } + if (action === 'remove-exam-subject') { + const form = target.closest('form'); + const list = target.closest('[data-exam-subjects]'); + if (list.children.length <= 1) return toast('至少保留一个科目', '考试计划必须包含科目'); + target.closest('.exam-subject-editor').remove(); + refreshExamScoringForm(form); + return; + } + if (action === 'edit-exam') return openExamForm(state.pageData.exams.find(exam => exam.id === target.dataset.id)); + if (action === 'review-candidate') return openCandidateReview(target.dataset.id); + if (action === 'reset-candidate-password') return openCandidatePasswordReset(target.dataset.id); + if (action === 'candidate-archive') { + const scope = document.querySelector('[data-archive-scope]')?.value || ''; + const separator = scope.indexOf(':'); + if (separator < 1) return toast('请选择归档范围', '可以选择一个班级或整个年级'); + const archived = target.dataset.archived === 'true'; + const data = await api('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: scope.slice(0, separator), scopeValue: scope.slice(separator + 1), archived } }); + toast(archived ? '账户已归档' : '账户已恢复', `${data.scopeLabel} · ${data.count} 个账户状态已更新`); return renderRoute(); + } + if (action === 'review-registration') return openRegistrationReview(target.dataset.id); + if (action === 'bulk-registration-review') { + const ids = [...document.querySelectorAll('#registrationTable [data-registration-select]:checked')].map(input => input.value); + if (!ids.length) return toast('请先选择报名记录', '仅待审核记录可批量处理'); + const status = target.dataset.status; + const promptText = status === 'rejected' ? '请填写批量退回原因(必填)' : '填写批量审核意见(可留空)'; + const reviewNote = window.prompt(promptText, ''); + if (reviewNote === null) return; + if (status === 'rejected' && !reviewNote.trim()) return toast('请填写退回原因', '考生需要根据原因修改报名'); + let completed = 0; + const failures = []; + for (const id of ids) { + try { + await api(`/api/admin/registrations/${id}`, { method: 'PATCH', body: { status, reviewNote } }); + completed += 1; + } catch (error) { + failures.push(error.message); + } + } + toast(status === 'rejected' ? `已退回 ${completed} 条报名` : `已处理 ${completed} 条报名`, failures.length ? `${failures.length} 条未完成,请刷新后重试` : '报名状态与流程轨迹已同步更新'); + return renderRoute(); + } + if (action === 'bulk-candidate-review') { + const ids = [...document.querySelectorAll('#candidateTable [data-candidate-select]:checked')].map(input => input.value); + if (!ids.length) return toast('请先选择考生', '仅当前步骤可由你处理的资料可以勾选'); + const status = target.dataset.status; + const reviewNote = window.prompt(status === 'rejected' ? '请填写批量退回原因(必填)' : '填写批量审核意见(可留空)', ''); + if (reviewNote === null) return; + if (status === 'rejected' && !reviewNote.trim()) return toast('请填写退回原因', '考生需要根据原因补充或修改资料'); + let completed = 0; + const failures = []; + for (const id of ids) { + try { + await api(`/api/admin/candidates/${id}`, { method: 'PATCH', body: { status, reviewNote } }); + completed += 1; + } catch (error) { failures.push(error.message); } + } + toast(status === 'rejected' ? `已退回 ${completed} 名考生资料` : `已处理 ${completed} 名考生资料`, failures.length ? `${failures.length} 名未完成,请检查其流程责任人` : '资料状态与流程轨迹已同步更新'); + return renderRoute(); + } + if (action === 'confirm-payment') { + if (!window.confirm(`确认已收到 ${target.dataset.name} 的“${target.dataset.exam}”考试费用吗?`)) return; + await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH' }); + toast('缴费已确认', '考生端状态、确认人和确认时间已同步记录'); + return renderRoute(); + } + if (action === 'update-payment') { + const paid = target.dataset.status === 'paid'; + const message = paid + ? `确认将 ${target.dataset.name} 的“${target.dataset.exam}”标记为已缴费吗?` + : `确认撤销 ${target.dataset.name} 的“${target.dataset.exam}”缴费记录,并改为待缴费吗?`; + if (!window.confirm(message)) return; + await api(`/api/admin/payments/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } }); + toast(paid ? '已标记为已缴费' : '已改为待缴费', paid ? '确认人和确认时间已同步记录' : '原确认人和确认时间已清除'); + return renderRoute(); + } + if (action === 'bulk-payment-update') { + const selected = [...document.querySelectorAll('#paymentTable [data-payment-select]:checked')]; + if (!selected.length) return toast('请先选择缴费记录'); + const status = target.dataset.status; + const changed = selected.filter(input => input.dataset.status !== status); + const skipped = selected.length - changed.length; + if (!changed.length) return toast('所选记录无需更改', status === 'paid' ? '所选考生都已缴费' : '所选考生都处于待缴费状态'); + if (!window.confirm(`确认将 ${changed.length} 条缴费记录批量改为“${status === 'paid' ? '已缴费' : '待缴费'}”吗?`)) return; + let completed = 0; + const failures = []; + for (const input of changed) { + try { + await api(`/api/admin/payments/${input.value}`, { method: 'PATCH', body: { status } }); + completed += 1; + } catch (error) { failures.push(error.message); } + } + toast(`已更新 ${completed} 条缴费记录`, [skipped ? `${skipped} 条状态相同已跳过` : '', failures.length ? `${failures.length} 条未完成` : ''].filter(Boolean).join(';')); + return renderRoute(); + } + if (action === 'excel-download') { + const query = new URLSearchParams(); + if (target.dataset.template === '1') query.set('template', '1'); + if (target.dataset.batchId) query.set('batchId', target.dataset.batchId); + if (target.dataset.examId) query.set('examId', target.dataset.examId); + else if (target.dataset.resource === 'results' && state.resultExamFilter) query.set('examId', state.resultExamFilter); + window.location.href = `/api/admin/excel/${target.dataset.resource}?${query}`; + return; + } + if (action === 'excel-import') { + document.querySelector(`[data-excel-file="${target.dataset.resource}"]`)?.click(); + return; + } + if (action === 'result-exam-filter') { + state.resultExamFilter = target.dataset.id; + state.resultSubjectFilter = ''; + ['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => { + if (state.tablePages[key]) state.tablePages[key].page = 1; + }); + return renderRoute(); + } + if (action === 'table-page') { + const key = target.dataset.tableKey; + const current = state.tablePages[key] || { page: 1, pageSize: 50 }; + state.tablePages[key] = { ...current, page: Math.max(1, Number(target.dataset.page) || 1) }; + return renderRoute(); + } + if (action === 'cancel-result-import') { + state.resultImportPreview = null; + return renderRoute(); + } + if (action === 'refresh-results-cache') { + const result = await api('/api/admin/results/cache/refresh', { method: 'POST' }); + toast(result.refreshed ? '成绩缓存已刷新' : '成绩缓存未刷新', result.message); + return renderRoute(); + } + if (action === 'generate-admit') { + const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); + if (registration.admitCard) return openAdmitPreview(registration); + return toast('尚未编排', '请使用页面上方的整场编排控制台'); + } + if (action === 'preview-arrangement') { + const form = target.closest('form'); + const body = formObject(form); + const output = form.parentElement.querySelector('[data-arrangement-preview]'); + target.disabled = true; + target.textContent = '正在预检…'; + try { + const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement/preview`, { method: 'POST', body }); + const summary = data.summary; + output.innerHTML = `
${data.warnings.length ? '预检完成,有提示' : '预检通过,可以生成'}${summary.candidateCount} 人 · ${summary.centerCount} 个考点 · ${summary.subjectAssignmentCount} 个科次座位 · ${summary.subjectCombinationCount} 种科目组合${data.warnings.length ? `
    ${data.warnings.map(item => `
  • ${h(item)}
  • `).join('')}
` : '多科目同考点、容量、科目时间和号码唯一性检查均已通过。'}
`; + } finally { + target.disabled = false; + target.textContent = '仅预检,不写入'; + } + return; + } + if (action === 'toggle-exam') { + await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } }); + toast(target.dataset.status === 'published' ? '考试已发布' : '考试已撤回', '公开页面状态已同步'); return refreshPublic().then(renderRoute); + } + if (action === 'archive-exam') { + const confirmed = window.confirm(`确认归档“${target.dataset.name}”吗?\n\n归档不可撤销;本场成绩、Excel 导入和复议改分将永久锁定。`); + if (!confirmed) return; + const result = await api(`/api/admin/exams/${target.dataset.id}/archive`, { method: 'POST' }); + state.resultImportPreview = null; + toast('考试已归档', result.message || '全部成绩已永久锁定'); + return refreshPublic().then(renderRoute); + } + if (action === 'toggle-notice') { + await api(`/api/admin/notices/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } }); + toast(target.dataset.status === 'published' ? '通知已发布' : '通知已隐藏', '公开展示状态已更新'); return refreshPublic().then(renderRoute); + } + if (action === 'toggle-publication') { + const visible = target.dataset.visible === 'true'; + await api(`/api/admin/publications/${target.dataset.sourceType}/${target.dataset.id}`, { method: 'PATCH', body: { visible } }); + toast(visible ? '系统公示已显示' : '系统公示已隐藏', '只调整公开目录展示,不修改公示内容'); return refreshPublic().then(renderRoute); + } + if (action === 'status-filter') { + setTableControl(state, target.dataset.target, { status: target.dataset.status }); + return renderRoute(); + } + } catch (error) { toast('操作未完成', error.message); } +}); + +document.addEventListener('input', event => { + if (event.target.matches('[data-action="table-search"]')) { + setTableControl(state, event.target.dataset.target, { query: event.target.value }); + applyTableFilters(event.target.dataset.target); + clearTimeout(tableSearchTimer); + tableSearchTimer = setTimeout(() => renderRoute(), 260); + } + if (event.target.closest('[data-notice-template]')) { + const studio = event.target.closest('[data-notice-template]'); + const preview = studio.querySelector('.notice-template-preview'); + if (event.target.name === 'primaryColor') preview.style.setProperty('--template-primary', event.target.value); + if (event.target.name === 'accentColor') preview.style.setProperty('--template-accent', event.target.value); + const output = studio.querySelector(`[data-template-preview="${event.target.name}"]`); + if (output) output.textContent = event.target.value.replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}', state.pageData?.school?.name || '本校').replaceAll('{{录取类别}}','普通生'); + } + if (event.target.matches('[data-result-score]')) updateResultScoreInput(event.target); + if (event.target.matches('[data-feature-score]')) updateFeatureScoreInput(event.target); + if (event.target.matches('.subject-options input')) { + const form = event.target.closest('form'); + const checked = [...form.querySelectorAll('.subject-options input:checked')]; + form.querySelector('[data-subject-count]').textContent = checked.length; + const exam = state.pageData.exams.find(item => item.id === form.examId.value); + const fee = checked.reduce((sum, input) => sum + Number(exam.subjects.find(subject => subject.id === input.value)?.fee || 0), 0); + const fullScore = checked.reduce((sum, input) => sum + Number(exam.subjects.find(subject => subject.id === input.value)?.fullScore || 0), 0); + form.querySelector('[data-subject-fee]').textContent = `满分 ${fullScore} · ${money(fee)}`; + } + if (event.target.closest('[data-exam-subjects]') || event.target.matches('[name="passValue"]')) refreshExamScoringForm(event.target.closest('form')); +}); + +document.addEventListener('change', event => { + if (event.target.matches('[data-action="table-page-size"]')) { + const key = event.target.dataset.tableKey; + state.tablePages[key] = { page: 1, pageSize: Number(event.target.value) || 50 }; + renderRoute(); + return; + } + if (event.target.matches('[data-placement-select]')) updatePlacementSelection(); + if (event.target.matches('[data-placement-select-all]')) { + const table = document.getElementById(event.target.dataset.target); + table?.querySelectorAll('[data-placement-select]:not(:disabled)').forEach(input => { + if (!input.closest('tr').hidden) input.checked = event.target.checked; + }); + updatePlacementSelection(); + } + if (event.target.matches('[data-reporting-select]')) { + const toolbar = event.target.closest('form')?.querySelector('[data-reporting-bulk]'); + const table = event.target.closest('table'); + const selected = table ? table.querySelectorAll('[data-reporting-select]:checked').length : 0; + const total = table ? table.querySelectorAll('[data-reporting-select]').length : 0; + const count = toolbar?.querySelector('[data-reporting-selected-count]'); + const selectAll = toolbar?.querySelector('[data-reporting-select-all]'); + if (count) count.textContent = `已选 ${selected} 人`; + if (selectAll) { selectAll.checked = total > 0 && selected === total; selectAll.indeterminate = selected > 0 && selected < total; } + } + if (event.target.matches('[data-reporting-select-all]')) { + const table = document.getElementById(event.target.dataset.tableId); + table?.querySelectorAll('[data-reporting-select]').forEach(input => { input.checked = event.target.checked; }); + const count = event.target.closest('[data-reporting-bulk]')?.querySelector('[data-reporting-selected-count]'); + if (count) count.textContent = `已选 ${event.target.checked ? table?.querySelectorAll('[data-reporting-select]').length || 0 : 0} 人`; + } + if (event.target.matches('.reporting-decision-options input[type="radio"]')) { + event.target.closest('fieldset').querySelectorAll('label').forEach(label => label.classList.toggle('selected', label.contains(event.target))); + } + if (event.target.matches('[data-action="result-bulk-exam"]')) { + state.resultExamFilter = event.target.value; + state.resultSubjectFilter = ''; + ['resultEntryTable', 'featureScoreTable', 'resultTable'].forEach(key => { + if (state.tablePages[key]) state.tablePages[key].page = 1; + }); + renderRoute(); + } + if (event.target.matches('[data-action="result-bulk-subject"]')) { + state.resultSubjectFilter = event.target.value; + if (state.tablePages.resultEntryTable) state.tablePages.resultEntryTable.page = 1; + renderRoute(); + } + if (event.target.matches('[data-qualification-select]')) updateQualificationSelection(event.target.closest('.qualification-ledger')); + if (event.target.matches('[data-table-filter]')) { + setTableControl(state, event.target.dataset.target, { filters: { [event.target.dataset.tableFilter]: event.target.value } }); + renderRoute(); + } + if (event.target.matches('[data-registration-select]')) updateRegistrationSelection(); + if (event.target.matches('[data-candidate-select]')) updateCandidateSelection(); + if (event.target.matches('[data-payment-select]')) updatePaymentSelection(); + if (event.target.matches('[data-registration-select-all]')) { + const table = document.getElementById(event.target.dataset.target); + table?.querySelectorAll('[data-registration-select]:not(:disabled)').forEach(input => { + if (!input.closest('tr').hidden) input.checked = event.target.checked; + }); + updateRegistrationSelection(); + } + if (event.target.matches('[data-candidate-select-all]')) { + const table = document.getElementById(event.target.dataset.target); + table?.querySelectorAll('[data-candidate-select]:not(:disabled)').forEach(input => { + if (!input.closest('tr').hidden) input.checked = event.target.checked; + }); + updateCandidateSelection(); + } + if (event.target.matches('[data-payment-select-all]')) { + const table = document.getElementById(event.target.dataset.target); + table?.querySelectorAll('[data-payment-select]:not(:disabled)').forEach(input => { + if (!input.closest('tr').hidden) input.checked = event.target.checked; + }); + updatePaymentSelection(); + } + if (event.target.matches('[data-excel-file]')) { + const input = event.target; + const file = input.files?.[0]; + if (!file) return; + const resource = input.dataset.excelFile; + input.value = ''; + (async () => { + try { + toast(resource === 'results' ? '正在生成导入预览' : '正在导入 Excel', resource === 'results' ? `${file.name} · 当前不会写入数据库` : `${file.name} · 逐行校验并写入,错误会标出具体行`); + const result = await api(`/api/admin/excel/${resource}`, { + method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer() + }); + if (resource === 'results') { + state.resultImportPreview = { ...result, fileName: file.name }; + toast(result.summary.invalid ? '预览完成,发现错误' : '预览校验通过', `${result.summary.valid}/${result.summary.total} 行可提交,数据库尚未修改`); + renderRoute(); + } else if (resource === 'account_quotas') { + document.querySelectorAll('[data-class-id]').forEach(field => { field.value = '0'; }); + result.quotas.forEach(item => { const field = document.querySelector(`[data-class-id="${item.classId}"]`); if (field) field.value = item.count; }); + toast('班级配额已填入', `已读取 ${result.quotas.length} 个班级,请核对后提交审批`); + } else { + toast('Excel 导入完成', `已处理 ${result.count} 条数据`); renderRoute(); + } + } catch (error) { toast('Excel 导入失败', error.message); } + })(); + return; + } + if (event.target.matches('[data-action="school-select"]')) { + const form = event.target.closest('form'); + const classSelect = form?.querySelector('select[name="classId"]'); + if (classSelect) { + const classes = state.pageData?.classes || state.publicData.classes || []; + classSelect.innerHTML = `${classes.filter(item => item.schoolId === event.target.value).map(item => ``).join('')}`; + } + } + if (event.target.matches('[data-action="specialty-category"]')) { + const typeSelect = event.target.closest('.admission-category-editor')?.querySelector('[data-specialty-type]') || event.target.closest('form')?.querySelector('[data-specialty-type]'); + const category = specialtyCatalog.find(item => item.code === event.target.value); + if (typeSelect) { + typeSelect.disabled = !category; + typeSelect.innerHTML = `${(category?.types || []).map(item => ``).join('')}`; + } + } + if (event.target.matches('[data-action="plan-category-kind"]')) { + const editor = event.target.closest('.admission-category-editor'); + const specialtyFields = editor?.querySelector('[data-plan-specialty]'); + const enabled = event.target.value === 'specialty'; + specialtyFields?.classList.toggle('hidden', !enabled); + specialtyFields?.querySelectorAll('select').forEach(select => { select.disabled = !enabled || (select.hasAttribute('data-specialty-type') && !specialtyFields.querySelector('[name="categorySpecialtyCategory"]')?.value); }); + } + if (event.target.matches('[name="categoryName"]')) { + const title = event.target.closest('.admission-category-editor')?.querySelector('header strong'); + if (title) title.textContent = event.target.value.trim() || '新类别'; + } + if (event.target.matches('[data-action="preference-school"]')) { + const row = event.target.closest('.preference-choice-row'); + const categorySelect = row?.querySelector('[name="choiceCategory"]'); + const admission = state.pageData?.admissions?.find(item => item.examId === event.target.dataset.examId); + const plan = admission?.plans?.find(item => item.schoolId === event.target.value); + const preferenceType = row?.dataset.preferenceType || 'general'; + if (categorySelect) { + categorySelect.disabled = !plan; + categorySelect.innerHTML = `${(plan?.categories || []).filter(item => item.preferenceTypes?.includes(preferenceType) && Number(preferenceType === 'indicator' ? item.indicatorRemaining : item.generalRemaining) > 0).map(item => ``).join('')}`; + } + } + if (event.target.matches('[data-region-level]')) updateRegionSelects(event.target); + if (event.target.matches('[data-action="admin-level"]')) { + const form = event.target.closest('form'); + form?.querySelector('[data-admin-school]')?.classList.toggle('hidden', event.target.value === 'super'); + form?.querySelector('[data-admin-class]')?.classList.toggle('hidden', event.target.value !== 'class'); + } + if (event.target.matches('[data-action="result-exam"]')) { + const form = event.target.closest('form'); + const exam = state.pageData?.exams?.find(item => item.id === event.target.value); + const subjectSelect = form?.querySelector('[name="subjectId"]'); + const candidateSelect = form?.querySelector('[name="registrationId"]'); + if (subjectSelect) subjectSelect.innerHTML = `${(exam?.subjects || []).map(subject => ``).join('')}`; + if (candidateSelect) { candidateSelect.innerHTML = ''; candidateSelect.disabled = true; } + subjectSelect?.dispatchEvent(new Event('change', { bubbles: true })); + } + if (event.target.matches('[data-action="result-subject"]')) { + const form = event.target.closest('form'); + const option = event.target.selectedOptions[0]; + const examId = form?.querySelector('[name="examId"]')?.value; + const subjectId = event.target.value; + const fullScore = Number(option?.dataset.fullScore || 0); + const passText = option?.dataset.passText || ''; + const scoreInput = form?.querySelector('[name="score"]'); + if (scoreInput) scoreInput.max = fullScore || ''; + const candidateSelect = form?.querySelector('[name="registrationId"]'); + const registrations = (state.pageData?.registrations || []).filter(item => item.examId === examId && item.subjectIds.includes(subjectId)); + if (candidateSelect) { + candidateSelect.disabled = !subjectId; + candidateSelect.innerHTML = `${registrations.map(item => ``).join('')}`; + } + const label = form?.querySelector('[data-score-label]'); + const hint = form?.querySelector('[data-score-hint]'); + if (label) label.textContent = fullScore ? `成绩(0—${fullScore})` : '成绩'; + if (hint) hint.textContent = fullScore ? `本科满分 ${fullScore} 分;独立及格规则:${passText}。` : '选择科目后显示其独立及格规则。'; + } + if (event.target.matches('[data-action="result-candidate"]')) { + const form = event.target.closest('form'); + const result = state.pageData?.results?.find(item => item.registrationId === event.target.value && item.subjectId === form?.subjectId?.value); + if (form?.score) form.score.value = result?.score ?? ''; + if (form?.published) form.published.checked = result ? result.published : true; + const button = form?.querySelector('button[type="submit"]'); + if (button) button.textContent = result ? '更新这条成绩' : '保存成绩'; + } + if (event.target.matches('[name="subjectPassRule"]')) refreshSubjectPassRuleRow(event.target.closest('.exam-subject-editor')); + if (event.target.matches('[name="passPolicy"]')) { + refreshExamScoringForm(event.target.closest('form')); + } +}); + +document.addEventListener('submit', async event => { + const form = event.target.closest('form[data-form]'); + if (!form) return; + event.preventDefault(); + const submit = event.submitter || form.querySelector('button[type="submit"]'); + const original = submit?.innerHTML; + if (submit) { submit.disabled = true; submit.textContent = '正在处理…'; } + try { + const kind = form.dataset.form; + if (kind === 'login') { + const data = await api('/api/auth/login', { method: 'POST', body: formObject(form) }); + if (data.requiresTotp) { + setModal(``); + } else await finishLogin(data); + } else if (kind === 'totp-login') { + const data = await api('/api/auth/login/totp', { method: 'POST', body: formObject(form) }); + await finishLogin(data); + } else if (kind === 'register') { + const data = await api('/api/auth/register', { method: 'POST', body: formObject(form) }); + setModal(`
固定报名号${h(data.registrationNumber)}

以后报名不同考试仍使用这个号码。关闭窗口前请抄写或截图保存。

`); + } else if (kind === 'document-verification') { + const code = form.code.value.trim().toUpperCase(); + if (!code) throw new Error('请输入防伪查询码'); + navigate(`verify/${encodeURIComponent(code)}`); + } else if (kind === 'candidate-password') { + const body = formObject(form); + if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致'); + await api('/api/auth/change-password', { method: 'POST', body }); + await refreshSession(); toast('密码修改成功', '下一步请补全个人信息'); navigate('candidate/onboarding'); + } else if (kind === 'account-password') { + const body = formObject(form); + if (body.newPassword !== body.confirmPassword) throw new Error('两次输入的新密码不一致'); + await api('/api/auth/change-password', { method: 'POST', body }); + form.reset(); toast('密码修改成功', '下次登录请使用新密码'); + } else if (kind === 'totp-setup') { + const data = await api('/api/auth/totp/setup', { method: 'POST', body: formObject(form) }); + setModal(`
TOTP 绑定二维码
无法扫码?手动输入密钥${h(data.secret.match(/.{1,4}/g)?.join(' ') || data.secret)}类型:基于时间 · 6 位 · 每 30 秒更新
`); + } else if (kind === 'totp-enable') { + const data = await api('/api/auth/totp/enable', { method: 'POST', body: formObject(form) }); + state.user = data.user; + showRecoveryCodes(data.recoveryCodes); + } else if (kind === 'totp-recovery-codes') { + const data = await api('/api/auth/totp/recovery-codes', { method: 'POST', body: formObject(form) }); + showRecoveryCodes(data.recoveryCodes); + } else if (kind === 'totp-disable') { + await api('/api/auth/totp/disable', { method: 'POST', body: formObject(form) }); + await refreshSession(); + toast('二次验证已关闭', '账户现在仅使用密码登录'); + renderRoute(); + } else if (kind === 'candidate-password-reset') { + const body = formObject(form); + const data = await api(`/api/admin/candidates/${body.id}/reset-password`, { method: 'POST' }); + setModal(`
报名号${h(data.candidateNumber)}临时密码${h(data.temporaryPassword)}

原密码和现有登录会话均已失效,考生下次登录必须修改此密码。

`); + } else if (kind === 'candidate-profile') { + const data = await api('/api/candidate/profile', { method: 'PUT', body: formObject(form) }); + state.profile = data.profile; await refreshSession(); toast('资料已提交', '管理员审核后会更新状态'); navigate('candidate/dashboard'); + } else if (kind === 'volunteer-preference') { + const choices = [...form.querySelectorAll('.preference-choice-row')].map(row => ({ schoolId: row.querySelector('[name="choiceSchool"]').value, categoryCode: row.querySelector('[name="choiceCategory"]').value, preferenceType: row.dataset.preferenceType || 'general' })).filter(item => item.schoolId && item.categoryCode); + const data = await api(`/api/candidate/admissions/${form.examId.value}/preferences`, { method: 'PUT', body: { choices } }); + toast(data.locked ? '志愿已保存并锁定' : '志愿已保存', data.locked ? '已达到本轮提交次数上限' : `还可提交 ${data.remainingSubmissions} 次`); renderRoute(); + } else if (kind === 'exam-registration') { + const body = { examId: form.examId.value, subjectIds: [...form.querySelectorAll('input[name="subjectIds"]:checked')].map(input => input.value) }; + if (!body.subjectIds.length) throw new Error('请至少选择一个报考科目'); + await api('/api/candidate/registrations', { method: 'POST', body }); + toast('报名已提交', `已选择 ${body.subjectIds.length} 个科目`); renderRoute(); + } else if (kind === 'score-appeal') { + const body = formObject(form); + await api(`/api/candidate/results/${body.resultId}/appeals`, { method: 'POST', body: { reason: body.reason } }); + toast('成绩复议已提交', '系统已按班级、学校和考试中心流程自动分配'); renderRoute(); + } else if (kind === 'candidate-review') { + const body = formObject(form); + await api(`/api/admin/candidates/${body.id}`, { method: 'PATCH', body }); + closeModal(); toast(body.status === 'approved' ? '资料审核通过' : '资料已退回', '考生端状态已同步'); renderRoute(); + } else if (kind === 'registration-review') { + const body = formObject(form); + await api(`/api/admin/registrations/${body.id}`, { method: 'PATCH', body }); + closeModal(); toast(body.status === 'approved' ? '报名审核通过' : '报名已退回', '报名状态已更新'); renderRoute(); + } else if (kind === 'admin-form') { + const body = formObject(form); + await api('/api/admin/admins', { method: 'POST', body }); + closeModal(); toast('管理员已创建', '权限范围已按层级绑定'); renderRoute(); + } else if (kind === 'admission-setting') { + const body = formObject(form); body.enabled = form.enabled.checked; body.autoPublish = form.autoPublish.checked; body.maxChoices = Number(body.maxChoices || 5); body.maxSubmissions = Number(body.maxSubmissions || 3); + await api(`/api/admin/admissions/${body.examId}/setting`, { method: 'PUT', body }); + toast('志愿设置已保存', '考生端阶段与进度已同步'); renderRoute(); + } else if (kind === 'admission-account') { + await api('/api/admin/admission-school-accounts', { method: 'POST', body: formObject(form) }); + form.reset(); toast('招生学校账号已创建'); renderRoute(); + } else if (kind === 'admission-notice-template') { + await api('/api/admission/notice-template', { method: 'PUT', body: formObject(form) }); + toast('录取通知书模板已保存', '正式录取考生将使用该模板生成 PDF'); renderRoute(); + } else if (kind === 'admission-reporting-draft') { + const rows = [...form.querySelectorAll('[data-reporting-status]')].map(select => ({ + placementId: select.dataset.placementId, + status: select.value, + note: form.querySelector(`[data-reporting-note][data-placement-id="${CSS.escape(select.dataset.placementId)}"]`)?.value || '' + })); + if (!rows.length) return toast('当前页没有可暂存记录'); + await api('/api/admission/reporting/draft', { method: 'PUT', body: { examId: form.dataset.examId, rows } }); + toast('当前页已暂存', `${rows.length} 名考生的状态已保存,尚未正式提交`); renderRoute(); + } else if (kind === 'admission-reporting-scan-preview') { + const body = formObject(form); + await previewReportingScan(body.code, body.examId); + } else if (kind === 'admission-reporting-scan-confirm') { + const body = formObject(form); + const result = await api('/api/admission/reporting/scan', { method: 'POST', body }); + closeModal(); + const label = body.status === 'reported' ? '已报到' : body.status === 'not_reported' ? '未报到' : '待确认'; + toast('扫码结果已暂存', `${result.row.name} · ${label},尚未正式提交`); renderRoute(); + } else if (kind === 'admission-reporting-decision') { + const body = formObject(form); + body.supplement = body.supplement === 'true'; + await api('/api/admission/reporting/decision', { method: 'POST', body }); + toast('学校决定已提交', '等待超级管理员审批;审批后报到统计将自动公开'); renderRoute(); + } else if (kind === 'admission-plan' || kind === 'school-admission-plan') { + const body = formObject(form); + body.categories = [...form.querySelectorAll('.admission-category-editor')].map((editor, index) => { + const specialty = editor.querySelector('[name="categoryKind"]').value === 'specialty'; + const indicatorAllocations = [...editor.querySelectorAll('.indicator-allocation-row')].map(row => ({ sourceSchoolId: row.querySelector('[name="indicatorSchool"]').value, quota: Number(row.querySelector('[name="indicatorQuota"]').value || 0) })).filter(item => item.sourceSchoolId && item.quota > 0); + return { code: `category_${index + 1}`, name: editor.querySelector('[name="categoryName"]').value.trim(), quota: Number(editor.querySelector('[name="categoryQuota"]').value || 0), isSpecialty: specialty, specialtyCategory: specialty ? editor.querySelector('[name="categorySpecialtyCategory"]').value : '', specialtyType: specialty ? editor.querySelector('[name="categorySpecialtyType"]').value : '', indicatorAllocations }; + }).filter(item => item.name && item.quota > 0); + if (!body.categories.length) throw new Error('请至少添加一个有效招生类别'); + if (body.categories.some(item => item.isSpecialty && (!item.specialtyCategory || !item.specialtyType))) throw new Error('特长生类别必须同时选择对应的大类和小类'); + await api(kind === 'admission-plan' ? '/api/admin/admission-plans' : '/api/admission/plans', { method: 'POST', body }); + toast(kind === 'admission-plan' ? '招生计划已代上传并通过' : '招生计划已提交审核'); renderRoute(); + } else if (kind === 'placement-review') { + const body = formObject(form); + await api(`/api/admission/placements/${body.id}`, { method: 'PATCH', body }); + toast(body.decision === 'accept' ? '已接收投档考生' : '退档申请已提交超级管理员'); renderRoute(); + } else if (kind === 'school-form') { + const body = formObject(form); body.active = form.active.checked; body.isSourceSchool = form.isSourceSchool.checked; body.isAdmissionSchool = form.isAdmissionSchool.checked; + await api(body.id ? `/api/admin/schools/${body.id}` : '/api/admin/schools', { method: body.id ? 'PATCH' : 'POST', body }); + closeModal(); await refreshPublic(); toast(body.id ? '学校档案已更新' : '学校已创建', `${body.name} · ${body.code.toUpperCase()}`); renderRoute(); + } else if (kind === 'school-class') { + const body = formObject(form); body.active = form.active.checked; + await api(body.id ? `/api/admin/classes/${body.id}` : '/api/admin/classes', { method: body.id ? 'PATCH' : 'POST', body }); + closeModal(); toast(body.id ? '班级已更新' : '班级已创建', `${body.grade} · ${body.name}`); renderRoute(); + } else if (kind === 'class-admin') { + const body = formObject(form); body.active = form.active.checked; + if (body.id) await api(`/api/admin/admins/${body.id}`, { method: 'PATCH', body }); + else await api('/api/admin/admins', { method: 'POST', body: { ...body, adminLevel: 'class', schoolId: state.user.schoolId } }); + closeModal(); toast(body.id ? '班级管理员已更新' : '班级管理员已创建', '权限范围已绑定到指定班级'); renderRoute(); + } else if (kind === 'candidate-account-batch') { + const quotas = [...form.querySelectorAll('[data-class-id]')].map(input => ({ classId: input.dataset.classId, count: Number(input.value || 0) })).filter(item => item.count > 0); + const total = quotas.reduce((sum, item) => sum + item.count, 0); + if (!total) throw new Error('请至少为一个班级填写申领数量'); + await api('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas } }); + toast('批量申领已提交', `${total} 个账户将在最终批准后统一生成`); renderRoute(); + } else if (kind === 'self-registration-setting') { + const enabled = form.enabled.value === 'true'; + await api('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled } }); + await refreshPublic(); toast(enabled ? '自主注册已开放' : '自主注册已关闭', enabled ? '公开入口现在可以申请报名号' : '仅保留学校下发账户流程'); renderRoute(); + } else if (kind === 'center-form') { + const body = formObject(form); + const editing = Boolean(body.id); + body.rooms = [...form.querySelectorAll('.center-room-editor')].map(row => ({ + id: row.querySelector('[name="roomId"]').value || null, + code: row.querySelector('[name="roomCode"]').value, + name: row.querySelector('[name="roomName"]').value, + building: row.querySelector('[name="roomBuilding"]').value, + floor: row.querySelector('[name="roomFloor"]').value, + capacity: Number(row.querySelector('[name="roomCapacity"]').value), + seatPlan: row.querySelector('[name="roomSeatPlan"]').value, + roomType: row.querySelector('[name="roomType"]').value, + status: row.querySelector('[name="roomStatus"]').value, + notes: row.querySelector('[name="roomNotes"]').value + })); + await api(editing ? `/api/admin/centers/${body.id}` : '/api/admin/centers', { method: editing ? 'PATCH' : 'POST', body }); + closeModal(); toast(editing ? '考点变更已提交' : '新考点已提交', '审批通过后才会更新正式档案'); renderRoute(); + } else if (kind === 'number-rule') { + const raw = formObject(form); + const types = Object.keys(numberSegmentMeta); + const segments = types.filter(type => type === 'sequence' || form.querySelector(`[name="include_${type}"]`)?.checked).map(type => ({ + type, position: Number(raw[`position_${type}`] || 99), value: raw[`value_${type}`] || '', width: Number(raw[`width_${type}`] || 0) + })).sort((a, b) => a.position - b.position); + await api('/api/admin/number-rules', { method: 'POST', body: { id: raw.id, name: raw.name, separator: raw.separator, segments } }); + toast('报名号规则已启用', '后续创建的考生账户将按此规则生成固定号码'); renderRoute(); + } else if (kind === 'admission-arrangement') { + const body = formObject(form); + const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement`, { method: 'POST', body }); + toast('整场编排已生成', `${data.summary.candidateCount} 名考生 · ${data.summary.subjectAssignmentCount} 个科次座位`); renderRoute(); + } else if (kind === 'workflow-design') { + const names = [...form.querySelectorAll('[name="stepName"]')]; + const levels = [...form.querySelectorAll('[name="stepLevel"]')]; + const steps = names.map((input, index) => ({ name: input.value, adminLevel: levels[index].value })); + await api(`/api/admin/workflows/${form.dataset.type}`, { method: 'PUT', body: { name: form.name.value, steps } }); + toast('审批流程已保存', `${steps.length} 个步骤已启用`); renderRoute(); + } else if (kind === 'flow-process') { + const body = formObject(form); + const path = body.businessType === 'profile_change' + ? `/api/admin/candidates/${body.businessId}` + : body.businessType === 'registration_review' + ? `/api/admin/registrations/${body.businessId}` + : body.businessType === 'center_change' + ? `/api/admin/center-change-requests/${body.businessId}` + : body.businessType === 'candidate_account_batch' + ? `/api/admin/candidate-account-batches/${body.businessId}` + : `/api/admin/score-appeals/${body.businessId}`; + await api(path, { method: 'PATCH', body: { status: body.status, reviewNote: body.reviewNote, ...(body.reviewedScore == null || body.reviewedScore === '' ? {} : { reviewedScore: Number(body.reviewedScore) }) } }); + closeModal(); toast(body.status === 'approved' ? '流程已处理' : '流程已退回', '操作已写入流程轨迹'); renderRoute(); + } else if (kind === 'flow-transfer') { + const body = formObject(form); + await api(`/api/admin/workflow-instances/${body.id}/transfer`, { method: 'PATCH', body }); + closeModal(); toast('流程已转交', '新责任人已收到待办'); renderRoute(); + } else if (kind === 'flow-supervise') { + const body = formObject(form); + await api(`/api/admin/workflow-instances/${body.id}/supervise`, { method: 'PATCH', body }); + closeModal(); toast('流程已监督调整', '节点与责任人已更新并记录'); renderRoute(); + } else if (kind === 'notice-form') { + const body = formObject(form); + if (noticeEditor) body.content = noticeEditor.getData(); + body.pinned = form.pinned.checked; + const endpoint = body.id ? `/api/admin/notices/${body.id}` : '/api/admin/notices'; + await api(endpoint, { method: body.id ? 'PATCH' : 'POST', body }); + closeModal(); await refreshPublic(); toast(body.status === 'published' ? '通知已发布' : '草稿已保存', body.id ? '草稿内容已更新' : '公开首页状态已同步'); renderRoute(); + } else if (kind === 'exam-form') { + const body = formObject(form); + body.subjects = [...form.querySelectorAll('.exam-subject-editor')].map(row => ({ + name: row.querySelector('[name="subjectName"]').value.trim(), + fullScore: Number(row.querySelector('[name="subjectFullScore"]').value), + passRule: row.querySelector('[name="subjectPassRule"]').value, + passValue: Number(row.querySelector('[name="subjectPassValue"]').value || 0), + date: row.querySelector('[name="subjectDate"]').value, + start: row.querySelector('[name="subjectStart"]').value, + end: row.querySelector('[name="subjectEnd"]').value, + fee: Number(row.querySelector('[name="subjectFee"]').value || 0) + })); + body.passValue = ['subject_scores', 'none'].includes(body.passPolicy) ? 0 : Number(body.passValue); + ['registrationStart','registrationEnd','examStart','examEnd','admitDownloadStart','admitDownloadEnd'].forEach(field => body[field] = new Date(body[field]).toISOString()); + const editing = Boolean(body.id); + await api(editing ? `/api/admin/exams/${body.id}` : '/api/admin/exams', { method: editing ? 'PATCH' : 'POST', body }); + closeModal(); await refreshPublic(); toast(editing ? '考试草稿已更新' : '考试计划已创建', `${body.subjects.length} 个科目已配置`); renderRoute(); + } else if (kind === 'result-entry') { + const body = formObject(form); body.published = form.published.checked; + await api('/api/admin/results', { method: 'POST', body }); + toast(body.published ? '成绩已发布' : '成绩已保存', '考生端可见状态已更新'); renderRoute(); + } else if (kind === 'result-bulk-entry') { + const mode = event.submitter?.dataset.resultMode || 'draft'; + const inputs = [...form.querySelectorAll('[data-result-score]')]; + const invalid = inputs.find(input => input.value.trim() !== '' && (!Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > Number(input.max))); + if (invalid) { invalid.focus(); throw new Error(`成绩须在 0—${invalid.max} 之间`); } + const targets = mode === 'publish' + ? inputs.filter(input => input.value.trim() !== '') + : inputs.filter(input => input.dataset.dirty === 'true' && input.value.trim() !== ''); + if (mode === 'publish') { + const missing = inputs.filter(input => input.value.trim() === ''); + if (missing.length) { missing[0].focus(); throw new Error(`还有 ${missing.length} 名考生未录入成绩,补齐后才能整科发布`); } + if (!window.confirm(`确认发布本科学目 ${targets.length} 名考生的成绩吗?发布后考生可立即查询。`)) return; + } + if (!targets.length) throw new Error(mode === 'publish' ? '当前科目没有可发布的成绩' : '请先修改至少一条成绩'); + const body = { + examId: form.examId.value, + subjectId: form.subjectId.value, + published: mode === 'publish', + rows: targets.map(input => ({ registrationId: input.dataset.registrationId, score: Number(input.value) })) + }; + const result = await api('/api/admin/results/bulk', { method: 'POST', body }); + toast(mode === 'publish' ? '本科学目成绩已发布' : '成绩已暂存', `${result.count} 条成绩已在同一事务中保存`); + renderRoute(); + } else if (kind === 'feature-score-entry') { + const body = formObject(form); body.featureScore = Number(body.featureScore || 0); + await api(`/api/admin/registrations/${body.registrationId}/feature-score`, { method: 'PATCH', body }); + toast('特征分已登记', '该分数独立于考试科目,默认值为 0'); renderRoute(); + } else if (kind === 'feature-score-bulk') { + const inputs = [...form.querySelectorAll('[data-feature-score]')]; + const invalid = inputs.find(input => input.value.trim() === '' || !Number.isFinite(Number(input.value)) || Number(input.value) < 0 || Number(input.value) > 1000); + if (invalid) { invalid.focus(); throw new Error('特征分必须在 0—1000 之间;未参加时填写 0'); } + const targets = inputs.filter(input => input.dataset.dirty === 'true'); + if (!targets.length) throw new Error('请先修改至少一名考生的特征分'); + const result = await api('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: form.examId.value, rows: targets.map(input => ({ registrationId: input.dataset.registrationId, featureScore: Number(input.value) })) } }); + toast('特征分已保存', `${result.count} 名考生已更新;其余考生继续保持 0 分`); + renderRoute(); + } else if (kind === 'result-import-commit') { + const rows = state.resultImportPreview?.rows || []; + if (!rows.length) throw new Error('没有可提交的成绩预览'); + const result = await api('/api/admin/results/import', { method: 'POST', body: { rows } }); + state.resultImportPreview = null; + toast('批量成绩已写入', `${result.count} 条成绩已在同一事务中提交`); renderRoute(); + } + } catch (error) { toast('操作未完成', error.message); } + finally { if (submit && submit.isConnected) { submit.disabled = false; submit.innerHTML = original; } } +}); + +function openAdminForm() { + const { schools = [], classes = [] } = state.pageData; + setModal(``); +} + +function openSchoolForm(school = null) { + setModal(``); +} + +function openSchoolClassForm(schoolClass = null) { + setModal(``); +} + +function openClassAdminForm(admin = null, classId = '') { + const classes = state.pageData.classes || []; + setModal(``); +} + +function centerRoomEditor(room = {}) { + return `
结构化考场
`; +} + +function openCenterForm(center = null) { + const schools = state.pageData.schools || []; + const rooms = center?.rooms?.length ? center.rooms : [{}]; + setModal(``); + mountRegionSelects(modalRoot, center); +} + +function openFlowDetail(id) { + const instance = state.pageData.instances.find(item => item.id === id); + const currentLevel = instance.currentStepDetail?.adminLevel; + const available = state.pageData.availableAdmins.filter(item => { + if (item.adminLevel !== currentLevel) return false; + if (currentLevel === 'super') return true; + if (currentLevel === 'school') return item.schoolId === instance.assignee?.schoolId; + return item.schoolId === instance.assignee?.schoolId && item.classId === instance.assignee?.classId; + }); + const canProcess = instance.status === 'pending' && instance.assignee?.id === state.user.id; + const history = instance.actions.map(action => `
${h(action.actorName)} · ${h({submit:'提交',approve:'通过',reject:'退回',transfer:'转交',return:'退回节点',supervise:'监督调整'}[action.action] || action.action)}${h(action.note || '')}${action.toAssigneeName ? ` → ${h(action.toAssigneeName)}` : ''}
`).join(''); + const isCenter = instance.businessType === 'center_change'; + const isBatch = instance.businessType === 'candidate_account_batch'; + const isAppeal = instance.businessType === 'score_appeal'; + const finalBatchStep = isBatch && instance.currentStep >= instance.steps.length; + const finalAppealStep = isAppeal && instance.currentStep >= instance.steps.length; + const subject = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个报名号` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName; + const subjectDetail = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? '按班级批量申领 · 批准后生成账号' : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName}`; + const change = instance.centerChange ? { ...instance.centerChange, address: formatRegionAddress(instance.centerChange) } : null; + const changeSnapshot = change ? `
申请快照

${h(change.name)} · ${h(change.code)}

${change.rooms.length} 个考场
地址
${h(change.address)}
负责人
${h(change.managerName || '未填写')} · ${h(change.managerPhone || '未填写')}
开放时间
${h(change.gateOpenTime || '未填写')}
档案状态
${change.centerStatus === 'active' ? '启用' : '停用'}
${change.rooms.map(room => `${h(room.name)}${h(room.building)} · ${h(room.capacity)} 席 · ${h(room.seatPlan || '按现场座次表编排')}`).join('')}
` : ''; + const batch = instance.accountBatch; + const batchSnapshot = batch ? `
班级配额

${batch.totalCount} 个待建账户

${batch.quotas.length} 个班级
${batch.quotas.map(item => `${h(item.className)}${item.count} 人`).join('')}

最终批准时才生成固定报名号和随机初始密码。

` : ''; + const appeal = instance.appealResult; + const appealSnapshot = appeal ? `
复议成绩快照

${h(appeal.examName)} · ${h(appeal.subjectName)}

${h(appeal.score)} / ${h(appeal.fullScore)}
当前等级
${h(appeal.grade)}
本科排名
第 ${h(appeal.rank)} / ${h(appeal.cohortSize)} 名(前 ${h(appeal.rankPercent)}%)
单科规则
${h(appeal.passText)}
当前结论
${appeal.qualified == null ? '不判定' : appeal.qualified ? '达线' : '未达线'}
` : ''; + const reviewedScoreField = finalAppealStep ? `` : ''; + setModal(`${changeSnapshot}${batchSnapshot}
${instance.steps.map(step => `
${step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}

流程轨迹

${history || '

暂无操作

'}
${canProcess ? `` : '
当前流程未分配给你,只能查看轨迹。
'}${state.pageData.canSupervise ? `` : ''}`); + if (appealSnapshot) modalRoot.querySelector('.modal-head')?.insertAdjacentHTML('afterend', appealSnapshot); + if (finalAppealStep) { + const processForm = modalRoot.querySelector('[data-form="flow-process"]'); + const fieldRow = processForm?.querySelector('.field-row'); + if (fieldRow) fieldRow.insertAdjacentHTML('beforeend', reviewedScoreField); + const processButton = processForm?.querySelector('button[type="submit"]'); + if (processButton) processButton.textContent = '批准并更新成绩'; + } +} + +function openCandidateReview(id) { + const source = state.pageData.candidates.find(candidate => candidate.id === id); + const item = { ...source, address: formatRegionAddress(source) }; + const canReview = item.status === 'pending' && (item.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super'); + const registrations = item.registrations || []; + const examCards = registrations.map(registration => `
${h(registration.exam?.code || '')}

${h(registration.exam?.name || '考试信息异常')}

${badge(registration.exam?.archivedAt ? 'archived' : registration.status)}
考试时间
${dateRange(registration.exam?.examStart, registration.exam?.examEnd)}
考试地点
${h(registration.exam?.location || '待公布')}
报名科目
${registration.subjects?.map(subject => `${h(subject.name)}${h(subject.date || '')} ${h(subject.start || '')} · 满分 ${h(subject.fullScore)} · ${money(subject.fee || 0)}`).join('') || '未选择科目'}
报名 / 缴费
${badge(registration.status)} ${badge(registration.paymentStatus)} · 应缴 ${money(registration.amountDue || 0)}
`).join(''); + const decision = canReview ? `
当前审核步骤${h(item.workflow?.currentStepDetail?.name || '流程已结束')}责任人:${h(item.workflow?.assignee?.displayName || '—')}
` : ``; + setReviewSubpage(`
CANDIDATE DOSSIER

${h(item.name)} · 资料审核

${h(item.candidateNumber)}${h(item.school || '学校未填写')} · ${h(item.grade || '班级未填写')}更新于 ${formatDate(item.updatedAt, true)}

${badge(item.status)}
01

身份与学籍信息

核对实名、学籍范围以及联系方式。

证件号码
${h(item.idNumberMasked)}
性别 / 出生日期
${h(item.gender || '未填写')} · ${h(item.birthDate || '未填写')}
籍贯 / 民族
${h(item.nativePlace || '未填写')} · ${h(item.ethnicity || '未填写')}
就读学校 / 班级
${h(item.school || '未填写')} · ${h(item.grade || '未填写')}
联系电话
${h(item.phone || '未填写')}
电子邮箱
${h(item.email || '未填写')}
家庭住址
${h(item.address || '未填写')}
监护人
${h(item.guardianName || '未填写')} · ${h(item.guardianPhone || '电话未填写')}
紧急联系人
${h(item.emergencyContact || '未填写')} · ${h(item.emergencyPhone || '电话未填写')}
02

关联考试与报名科目

审核资料时同时查看该考生历次报名上下文。

${registrations.length} 场
${examCards || '
暂无考试报名

该考生当前尚未提交考试报名;资料审核通过后才能选择考试与科目。

'}
${decision}
`); +} + +function openCandidatePasswordReset(id) { + const item = state.pageData?.candidates?.find(candidate => candidate.id === id); + if (!item) return toast('考生不存在', '请刷新页面后重试'); + setModal(``); +} + +function openRegistrationReview(id) { + const reg = state.pageData.registrations.find(item => item.id === id); + const canReview = reg.status === 'pending' && (reg.workflow?.assignee?.id === state.user.id || state.user.adminLevel === 'super'); + const subjects = reg.subjects || []; + const subjectCards = subjects.map((subject, index) => `
${index + 1}
${h(subject.name)}${h(subject.date || '日期待定')} ${h(subject.start || '')}${subject.end ? `—${h(subject.end)}` : ''}
满分
${h(subject.fullScore)} 分
报名费
${money(subject.fee || 0)}
`).join(''); + const decision = canReview ? `
当前审核步骤${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}责任人:${h(reg.workflow?.assignee?.displayName || '—')}
` : ``; + setReviewSubpage(`
REGISTRATION DOSSIER

${h(reg.candidate?.name)} · 报名审核

${h(reg.registrationNumber || reg.candidate?.candidateNumber || '号码待同步')}${h(reg.schoolName || reg.candidate?.school || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}

${badge(reg.status)}
考试

${h(reg.exam.code)}

${h(reg.exam.name)}

报名时间
${dateRange(reg.exam.registrationStart, reg.exam.registrationEnd)}
考试时间
${dateRange(reg.exam.examStart, reg.exam.examEnd)}
考试地点
${h(reg.exam.location || '待公布')}
本次报名
${subjects.length} 个科目 · 应缴 ${money(reg.amountDue || 0)}
缴费状态
${badge(reg.paymentStatus)}
账户报名号
${h(reg.registrationNumber || reg.candidate?.candidateNumber || '待同步')}
科目

本次所报科目

逐科核对日期、时间、满分和报名费用。

${subjects.length} 科
${subjectCards || '
未选择任何科目

该报名记录数据异常,不应通过审核。

'}
费用合计${money(reg.amountDue || 0)}
考生

考生资料摘要

报名审核同时核验身份与学校范围。

姓名 / 性别
${h(reg.candidate?.name || '未填写')} · ${h(reg.candidate?.gender || '未填写')}
证件号码
${h(reg.candidate?.idNumber || '未填写')}
学校 / 班级
${h(reg.schoolName || '')} · ${h([reg.gradeName, reg.className].filter(Boolean).join(' · '))}
联系电话
${h(reg.candidate?.phone || '未填写')}
${decision}
`); +} + +async function openNoticeForm(notice = null) { + if (notice === undefined) return toast('草稿不存在', '请刷新页面后重试'); + const editing = Boolean(notice); + const categories = ['报名通知', '考试须知', '考点公告', '成绩通知', '系统公告']; + if (notice?.category && !categories.includes(notice.category)) categories.push(notice.category); + setModal(``); + const source = modalRoot.querySelector('[data-notice-editor]'); + try { + const [ckeditor, translation] = await loadCKEditor(); + const { + ClassicEditor, AutoImage, AutoLink, BlockQuote, Bold, Essentials, Heading, + Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar, + Italic, Link, List, Paragraph, Table, TableCaption, TableToolbar, Underline + } = ckeditor; + if (!source?.isConnected) return; + noticeEditor = await ClassicEditor.create(source, { + licenseKey: 'GPL', + language: 'zh-cn', + translations: [translation.default], + plugins: [ + Essentials, Paragraph, Heading, Bold, Italic, Underline, Link, AutoLink, List, BlockQuote, + Image, ImageCaption, ImageInsertViaUrl, ImageStyle, ImageTextAlternative, ImageToolbar, AutoImage, + Table, TableToolbar, TableCaption + ], + toolbar: { + items: ['heading', '|', 'bold', 'italic', 'underline', '|', 'bulletedList', 'numberedList', 'blockQuote', '|', 'link', 'insertImageViaUrl', 'insertTable', '|', 'undo', 'redo'], + shouldNotGroupWhenFull: true + }, + heading: { + options: [ + { model: 'paragraph', title: '正文', class: 'ck-heading_paragraph' }, + { model: 'heading2', view: 'h2', title: '二级标题', class: 'ck-heading_heading2' }, + { model: 'heading3', view: 'h3', title: '三级标题', class: 'ck-heading_heading3' } + ] + }, + link: { defaultProtocol: 'https://', addTargetToExternalLinks: true }, + image: { + toolbar: ['imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'toggleImageCaption', 'imageTextAlternative'] + }, + table: { + contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', '|', 'toggleTableCaption'] + }, + placeholder: '请输入完整通知内容;图片使用 URL 插入,文件使用超链接' + }); + } catch (error) { + console.error('CKEditor failed to load', error); + toast('富文本编辑器加载失败', '已保留普通文本输入,可检查网络后重试'); + } +} + +function dateTimeLocal(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const pad = number => String(number).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function examSubjectEditor(subject = {}) { + const passRule = subject.passRule === 'score_ratio' ? 'rank_percent' : (subject.passRule || 'fixed_score'); + const passValue = subject.passValue ?? subject.passScore ?? 60; + return `
科目明细

`; +} + +function refreshSubjectPassRuleRow(row) { + if (!row) return; + const ruleSelect = row.querySelector('[name="subjectPassRule"]'); + const legacyOption = ruleSelect?.querySelector('option[value="score_ratio"]'); + if (legacyOption) { legacyOption.value = 'rank_percent'; legacyOption.textContent = '按排名比例'; } + const rule = ruleSelect?.value || 'fixed_score'; + const fullScore = Number(row.querySelector('[name="subjectFullScore"]')?.value || 0); + const valueField = row.querySelector('[data-subject-pass-value]'); + const input = row.querySelector('[name="subjectPassValue"]'); + const label = row.querySelector('[data-subject-pass-label]'); + const unit = row.querySelector('[data-subject-pass-unit]'); + const preview = row.querySelector('[data-subject-pass-preview]'); + valueField?.classList.toggle('hidden', rule === 'none'); + if (input) { + input.disabled = rule === 'none'; + input.max = rule === 'rank_percent' ? '100' : String(fullScore || 1000); + input.min = rule === 'rank_percent' ? '0.1' : '0'; + } + if (label) label.textContent = rule === 'rank_percent' ? '排名比例 *' : '固定及格分 *'; + if (unit) unit.textContent = rule === 'rank_percent' ? '%' : '分'; + const value = Number(input?.value || 0); + if (preview) preview.textContent = rule === 'none' ? '本科只展示成绩,不单独判定达线。' : rule === 'rank_percent' ? `本科排名前 ${value}% 达线;分数边界随本次已发布成绩队列变化。` : `本科达到 ${value} 分视为单科达线。`; +} + +function refreshExamScoringForm(form) { + if (!form?.matches('[data-form="exam-form"]')) return; + const rows = [...form.querySelectorAll('.exam-subject-editor')]; + rows.forEach(refreshSubjectPassRuleRow); + const total = rows.reduce((sum, row) => sum + Number(row.querySelector('[name="subjectFullScore"]')?.value || 0), 0); + const totalElement = form.querySelector('[data-exam-total]'); + const countElement = form.querySelector('[data-exam-subject-count]'); + if (totalElement) totalElement.textContent = total; + if (countElement) countElement.textContent = rows.length; + const policySelect = form.querySelector('[name="passPolicy"]'); + policySelect?.querySelector('option[value="score_ratio"]')?.remove(); + const policy = policySelect?.value || 'rank_percent'; + const valueField = form.querySelector('[data-pass-value-field]'); + const valueInput = form.querySelector('[name="passValue"]'); + const unit = form.querySelector('[data-pass-unit]'); + const hint = form.querySelector('[data-pass-hint]'); + const hiddenValue = ['subject_scores', 'none'].includes(policy); + if (valueField) valueField.classList.toggle('hidden', hiddenValue); + if (valueInput) { + valueInput.disabled = hiddenValue; + valueInput.max = policy === 'fixed_score' ? String(total || 1000) : '100'; + valueInput.min = policy === 'fixed_score' ? '0' : '0.1'; + } + if (unit) unit.textContent = policy === 'fixed_score' ? '分' : '%'; + const hints = { + fixed_score: '按报考科目的成绩总和判断;适合所有考生科目组合一致的考试。', + rank_percent: '在相同报考科目组合且成绩完整的考生中排名,同分并列。', + subject_scores: '每个科目都必须达到上方配置的单科合格分。', + none: '只展示成绩、总分和排名,不显示合格或未合格。' + }; + if (hint) hint.textContent = hints[policy]; +} + +function openExamForm(exam = null) { + const editing = Boolean(exam); + if (editing && exam.status !== 'draft') return toast('无法编辑', '请先将已发布考试撤回为草稿'); + const subjects = exam?.subjects?.length ? exam.subjects : [{ date: String(exam?.examStart || '').slice(0, 10) }]; + const passPolicy = exam?.passPolicy === 'score_ratio' ? 'rank_percent' : (exam?.passPolicy || 'rank_percent'); + setModal(``); + refreshExamScoringForm(modalRoot.querySelector('[data-form="exam-form"]')); +} + +function openAdmitPreview(reg) { + const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item])); + const rows = reg.subjects.map(subject => { + const assignment = assignments.get(subject.id) || {}; + return `${h(subject.name)}${h(subject.date)} ${h(subject.start)}${h(assignment.examRoomCode || '待定')}${h(assignment.roomName || assignment.room || '待定')}场地代码 ${h(assignment.roomCode || '—')}${h(assignment.building || '楼栋待定')} · ${h(assignment.floor || '楼层待定')}${h(assignment.seat || '—')}`; + }).join(''); + setModal(`
${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
生成时间
${formatDate(reg.admitCard.generatedAt,true)}
${rows}
科目时间考试考场序号考场通用名称 / 场地代码楼栋 / 楼层座位

“考试考场序号”是本次考试编排编号,不等同于考场通用名称。考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。

`); +} + +window.addEventListener('hashchange', renderRoute); +window.addEventListener('keydown', event => { + if (event.key === 'Escape') closeModal(); + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + const form = document.querySelector('form[data-form="result-bulk-entry"]'); + if (form) { + event.preventDefault(); + form.requestSubmit(form.querySelector('[data-result-mode="draft"]')); + } + } +}); + +document.addEventListener('change', event => { + if (event.target.matches('[data-admission-reporting-file]')) { + const input = event.target; + const file = input.files?.[0]; + if (!file) return; + const examId = input.dataset.examId; + input.value = ''; + (async () => { + try { + toast('正在导入报到状态', `${file.name} · 导入结果只会暂存`); + const result = await api(`/api/admission/reporting/import?examId=${encodeURIComponent(examId)}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: await file.arrayBuffer() }); + state.reportingImportSummaries[examId] = result; + toast(result.changedCount ? 'Excel 已导入并暂存' : 'Excel 已读取,状态没有变化', result.changedCount ? `读取 ${result.count} 行,实际更新 ${result.changedCount} 人,${result.unchangedCount} 人未变化` : `读取 ${result.count} 行,内容与当前暂存状态一致`); + await renderRoute(); + } catch (error) { toast('Excel 导入失败', error.message); } + })(); + } + if (event.target.matches('[data-reporting-qr-file]')) { + const input = event.target; + const file = input.files?.[0]; + if (!file) return; + input.value = ''; + (async () => { + try { + if (!('BarcodeDetector' in window)) throw new Error('当前浏览器不支持图片二维码识别,请粘贴二维码中的核验链接'); + const detector = new BarcodeDetector({ formats: ['qr_code'] }); + const codes = await detector.detect(file); + const code = codes[0]?.rawValue || ''; + if (!code) throw new Error('图片中没有识别到二维码'); + await previewReportingScan(code, input.dataset.examId || ''); + } catch (error) { toast('二维码识别失败', error.message); } + })(); + } +}); + +try { + await Promise.all([refreshPublic(), refreshSession()]); + await renderRoute(); +} catch (error) { + renderError(error); +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..75d10ad --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/database.mjs b/database.mjs new file mode 100644 index 0000000..348c887 --- /dev/null +++ b/database.mjs @@ -0,0 +1,1596 @@ +import { mkdir } from 'node:fs/promises'; +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', + 'organization', + 'schools', + 'school_classes', + 'school_student_partitions', + 'users', + 'candidate_profiles', + 'notices', + 'exams', + 'exam_data_partitions', + 'exam_subjects', + 'registrations', + 'registration_subjects', + 'admission_number_rules', + 'exam_arrangement_plans', + 'admit_cards', + 'admit_card_subjects', + 'results', + 'test_centers', + 'test_rooms', + 'center_change_requests', + 'center_change_rooms', + 'number_rules', + 'number_rule_segments', + 'candidate_account_batches', + 'candidate_account_batch_items', + 'workflow_definitions', + 'workflow_steps', + 'workflow_instances', + 'workflow_actions', + 'admission_records', + 'audit_logs' +]; + +function validateState(state, source = '数据库') { + const collections = [ + 'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', + 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', + 'admissionNumberRules', 'arrangementPlans', + 'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems', + 'workflows', 'workflowInstances', 'workflowActions', 'admissionRecords', 'auditLogs' + ]; + if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { + throw new Error(`${source}中的应用数据格式无效`); + } + return state; +} + +export function buildSeedOperations(state) { + validateState(state); + const operations = []; + const add = (sql, ...params) => operations.push({ sql, params }); + const nullable = value => value == null || value === '' ? null : value; + + add( + `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() + ); + add( + 'INSERT INTO organization (id, name, code, phone, address) VALUES (1, ?, ?, ?, ?)', + state.organization?.name || '', state.organization?.code || '', state.organization?.phone || '', state.organization?.address || '' + ); + + for (const school of state.schools) { + add( + '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 + ); + } + + for (const schoolClass of state.classes) { + add( + 'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', + schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1 + ); + } + + for (const user of state.users) { + add( + `INSERT INTO users ( + id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, + must_change_password, archived_at, archived_by, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, nullable(user.candidateNumber), user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId), + nullable(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, nullable(user.archivedAt), + nullable(user.archivedBy), user.displayName, user.createdAt + ); + } + + for (const profile of state.candidateProfiles) { + add( + `INSERT INTO candidate_profiles ( + 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, 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), + nullable(profile.cityCode), nullable(profile.cityName), nullable(profile.districtCode), nullable(profile.districtName), + 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 + ); + } + + for (const notice of state.notices) { + add( + `INSERT INTO notices ( + id, title, summary, content, category, pinned, status, publish_at, created_at, author + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, + notice.status, nullable(notice.publishAt), nullable(notice.createdAt), notice.author + ); + } + + for (const exam of state.exams) { + add( + `INSERT INTO exams ( + id, code, name, description, registration_start, registration_end, exam_start, exam_end, + admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, + exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', + exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent'), Number(exam.passValue ?? 60), exam.status, + nullable(exam.archivedAt), nullable(exam.archivedBy), exam.createdAt + ); + exam.subjects.forEach((subject, index) => add( + `INSERT INTO exam_subjects ( + id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), + subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore || 150), + Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), + Number(subject.passValue ?? subject.passScore ?? Number(subject.fullScore || 150) * .6), Number(subject.order || index + 1) + )); + } + + for (const registration of state.registrations) { + 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, 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), Number(registration.featureScore || 0) + ); + for (const subjectId of registration.subjectIds) { + add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId); + } + } + + for (const result of state.results) { + add( + `INSERT INTO results ( + id, registration_id, subject_id, score, grade, published, updated_at, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + result.id, result.registrationId, result.subjectId, Number(result.score), result.grade, + result.published ? 1 : 0, nullable(result.updatedAt), nullable(result.publishedAt) + ); + } + + for (const center of state.testCenters) { + add( + `INSERT INTO test_centers ( + id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name, + address, contact, manager_name, manager_phone, emergency_phone, + gate_open_time, transport, status, notes, rooms, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + center.id, center.schoolId, center.code, center.name, + center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName, + center.address, nullable(center.contact), + nullable(center.managerName), nullable(center.managerPhone), nullable(center.emergencyPhone), + nullable(center.gateOpenTime), nullable(center.transport), center.status || 'active', nullable(center.notes), + center.rooms || '', center.updatedAt + ); + } + + for (const room of state.testRooms) { + add( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, room.centerId, room.code, room.name, room.building, nullable(room.floor), Number(room.capacity), + nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType, room.status || 'active', nullable(room.notes) + ); + } + + for (const rule of state.admissionNumberRules) { + add( + `INSERT INTO admission_number_rules ( + id, code, name, description, \`separator\`, segments_json, example, active, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []), + rule.example || '', rule.active === false ? 0 : 1, rule.createdAt + ); + } + + for (const plan of state.arrangementPlans) { + add( + `INSERT INTO exam_arrangement_plans ( + id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count, + subject_assignment_count, subject_combination_count, same_school_center_rate, warnings_json, generated_by, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed, + Number(plan.candidateCount || 0), Number(plan.centerCount || 0), Number(plan.subjectAssignmentCount || 0), + Number(plan.subjectCombinationCount || 0), Number(plan.sameSchoolCenterRate || 0), JSON.stringify(plan.warnings || []), + nullable(plan.generatedBy), plan.generatedAt + ); + } + + for (const registration of state.registrations.filter(item => item.admitCard)) { + const card = registration.admitCard; + add( + `INSERT INTO admit_cards ( + registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter, + card.centerCode || '', card.centerAddress || '', card.generatedAt + ); + for (const assignment of card.assignments || []) add( + `INSERT INTO admit_card_subjects ( + registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + registration.id, assignment.subjectId, nullable(assignment.roomId), assignment.roomName || assignment.room, + assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature || '' + ); + } + + for (const request of state.centerChangeRequests) { + add( + `INSERT INTO center_change_requests ( + id, center_id, school_id, request_type, code, name, + province_code, province_name, city_code, city_name, district_code, district_name, + address, contact, manager_name, manager_phone, + emergency_phone, gate_open_time, transport, center_status, notes, status, review_note, + requested_by, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request.id, nullable(request.centerId), request.schoolId, request.requestType, request.code, request.name, + request.provinceCode, request.provinceName, request.cityCode, request.cityName, request.districtCode, request.districtName, + request.address, nullable(request.contact), nullable(request.managerName), nullable(request.managerPhone), + nullable(request.emergencyPhone), nullable(request.gateOpenTime), nullable(request.transport), + request.centerStatus || 'active', nullable(request.notes), request.status, nullable(request.reviewNote), + nullable(request.requestedBy), request.createdAt, nullable(request.reviewedAt) + ); + } + + for (const room of state.centerChangeRooms) { + add( + `INSERT INTO center_change_rooms ( + id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, room.requestId, nullable(room.roomId), room.code, room.name, room.building, nullable(room.floor), + Number(room.capacity), nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType, + room.status || 'active', nullable(room.notes) + ); + } + + for (const rule of state.numberRules) { + add( + 'INSERT INTO number_rules (id, name, `separator`, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, nullable(rule.createdBy), rule.updatedAt + ); + rule.segments.forEach((segment, index) => add( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', + segment.id, rule.id, Number(segment.position || index + 1), segment.type, nullable(segment.value), Number(segment.width || 0) + )); + } + + for (const batch of state.candidateAccountBatches) { + add( + `INSERT INTO candidate_account_batches ( + id, school_id, requested_by, status, review_note, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + batch.id, batch.schoolId, nullable(batch.requestedBy), batch.status, nullable(batch.reviewNote), + batch.createdAt, nullable(batch.reviewedAt) + ); + } + + for (const item of state.candidateAccountBatchItems) { + add( + `INSERT INTO candidate_account_batch_items ( + id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + item.id, item.batchId, item.classId, Number(item.position), nullable(item.candidateNumber), + nullable(item.initialPassword), nullable(item.userId), nullable(item.createdAt) + ); + } + + for (const workflow of state.workflows) { + add( + `INSERT INTO workflow_definitions ( + id, business_type, name, active, updated_by, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, + nullable(workflow.updatedBy), workflow.updatedAt + ); + workflow.steps.forEach((step, index) => add( + `INSERT INTO workflow_steps ( + id, workflow_id, position, name, admin_level + ) VALUES (?, ?, ?, ?, ?)`, + step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel + )); + } + + for (const instance of state.workflowInstances) { + add( + `INSERT INTO workflow_instances ( + id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status, + Number(instance.currentStep || 1), nullable(instance.assigneeId), instance.createdAt, nullable(instance.completedAt) + ); + } + + for (const action of state.workflowActions) { + add( + `INSERT INTO workflow_actions ( + id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + action.id, action.instanceId, nullable(action.actorId), action.action, nullable(action.note), + nullable(action.fromAssigneeId), nullable(action.toAssigneeId), action.createdAt + ); + } + + 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 (?, ?, ?, ?, ?)', + log.id, nullable(log.actorId), log.action, log.detail, log.createdAt + ); + } + + return operations; +} + +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); + const passRule = row.pass_rule === 'score_ratio' ? 'rank_percent' : (row.pass_rule || 'fixed_score'); + const passValue = Number(row.pass_value ?? row.pass_score ?? fullScore * .6); + const subject = { + id: row.id, + name: row.name, + date: row.subject_date, + start: row.start_time, + end: row.end_time, + fee: Number(row.fee), + fullScore, + passRule, + passValue, + passScore: passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null, + order: Number(row.position) + }; + const subjects = subjectsByExam.get(row.exam_id) || []; + subjects.push(subject); + subjectsByExam.set(row.exam_id, subjects); + } + + const registrationSubjects = new Map(); + for (const row of rows.registrationSubjects) { + const subjectIds = registrationSubjects.get(row.registration_id) || []; + subjectIds.push(row.subject_id); + registrationSubjects.set(row.registration_id, subjectIds); + } + const subjectPositions = new Map(rows.subjects.map(row => [row.id, Number(row.position)])); + const admitAssignments = new Map(); + for (const row of rows.admitCardSubjects) { + const assignments = admitAssignments.get(row.registration_id) || []; + assignments.push({ + subjectId: row.subject_id, + roomId: row.room_id, + roomName: row.room, + room: row.room, + roomCode: row.room_code, + examRoomCode: row.exam_room_code, + building: row.building || '', + floor: row.floor || '', + seat: row.seat, + subjectSignature: row.subject_signature || '' + }); + admitAssignments.set(row.registration_id, assignments); + } + for (const assignments of admitAssignments.values()) assignments.sort((left, right) => + (subjectPositions.get(left.subjectId) || 0) - (subjectPositions.get(right.subjectId) || 0) + ); + const admitCards = new Map(rows.admitCards.map(row => { + const assignments = admitAssignments.get(row.registration_id) || []; + const primary = assignments[0] || {}; + return [row.registration_id, { + planId: row.plan_id, + number: row.card_number, + centerId: row.center_id, + testCenter: row.test_center, + centerCode: row.center_code || '', + centerAddress: row.center_address || '', + room: primary.roomName || '', + seat: primary.seat || '', + assignments, + generatedAt: row.generated_at + }]; + })); + const ruleSegments = new Map(); + for (const row of rows.numberRuleSegments) { + const segments = ruleSegments.get(row.rule_id) || []; + segments.push({ + id: row.id, + type: row.type, + value: row.value || '', + width: Number(row.width || 0), + position: Number(row.position) + }); + ruleSegments.set(row.rule_id, segments); + } + const workflowSteps = new Map(); + for (const row of rows.workflowSteps) { + const steps = workflowSteps.get(row.workflow_id) || []; + steps.push({ + id: row.id, + name: row.name, + adminLevel: row.admin_level, + position: Number(row.position) + }); + workflowSteps.set(row.workflow_id, steps); + } + + const organization = rows.organization; + const state = { + meta: { version: Number(rows.system.app_version), createdAt: rows.system.created_at }, + settings: { selfRegistrationEnabled: Boolean(rows.system.self_registration_enabled) }, + organization: { + name: organization.name, + code: organization.code, + phone: organization.phone, + address: organization.address + }, + schools: rows.schools.map(row => ({ + id: row.id, + 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 => ({ + id: row.id, + schoolId: row.school_id, + name: row.name, + grade: row.grade, + active: Boolean(row.active) + })), + users: rows.users.map(row => ({ + id: row.id, + username: row.username, + candidateNumber: row.candidate_number || '', + passwordHash: row.password_hash, + role: row.role, + adminLevel: row.admin_level || (row.role === 'admin' ? 'super' : null), + schoolId: row.school_id || null, + classId: row.class_id || null, + active: row.active == null ? true : Boolean(row.active), + mustChangePassword: Boolean(row.must_change_password), + totpEnabled: Boolean(row.totp_enabled), + totpSecretEncrypted: row.totp_secret_encrypted || null, + totpRecoveryCodes: (() => { try { return JSON.parse(row.totp_recovery_codes || '[]'); } catch { return []; } })(), + totpLastUsedStep: row.totp_last_used_step == null ? null : Number(row.totp_last_used_step), + archivedAt: row.archived_at || null, + archivedBy: row.archived_by || null, + displayName: row.display_name, + createdAt: row.created_at + })), + candidateProfiles: rows.profiles.map(row => ({ + id: row.id, + userId: row.user_id, + name: row.name, + gender: row.gender || '', + idNumber: row.id_number, + phone: row.phone, + email: row.email || '', + school: row.school || '', + grade: row.grade || '', + schoolId: row.school_id || null, + classId: row.class_id || null, + provinceCode: row.province_code || '', + provinceName: row.province_name || '', + cityCode: row.city_code || '', + cityName: row.city_name || '', + districtCode: row.district_code || '', + districtName: row.district_name || '', + address: row.address || '', + emergencyContact: row.emergency_contact || '', + emergencyPhone: row.emergency_phone || '', + nativePlace: row.native_place || '', + birthDate: row.birth_date || '', + ethnicity: row.ethnicity || '', + 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 || '', + reviewedAt: row.reviewed_at, + reviewerId: row.reviewer_id, + updatedAt: row.updated_at + })), + notices: rows.notices.map(row => ({ + id: row.id, + title: row.title, + summary: row.summary, + content: row.content, + category: row.category, + pinned: Boolean(row.pinned), + status: row.status, + publishAt: row.publish_at, + createdAt: row.created_at, + author: row.author + })), + exams: rows.exams.map(row => ({ + id: row.id, + code: row.code, + name: row.name, + description: row.description, + registrationStart: row.registration_start, + registrationEnd: row.registration_end, + examStart: row.exam_start, + examEnd: row.exam_end, + admitDownloadStart: row.admit_download_start, + admitDownloadEnd: row.admit_download_end, + location: row.location, + passPolicy: row.pass_policy === 'score_ratio' ? 'rank_percent' : (row.pass_policy || 'rank_percent'), + passValue: Number(row.pass_value ?? 60), + status: row.status, + archivedAt: row.archived_at || null, + archivedBy: row.archived_by || null, + createdAt: row.created_at, + subjects: subjectsByExam.get(row.id) || [] + })), + registrations: rows.registrations.map(row => ({ + id: row.id, + userId: row.user_id, + examId: row.exam_id, + subjectIds: registrationSubjects.get(row.id) || [], + status: row.status, + paymentStatus: row.payment_status, + paidAt: row.paid_at || null, + paidBy: row.paid_by || null, + createdAt: row.created_at, + reviewedAt: row.reviewed_at, + 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 => ({ + id: row.id, + registrationId: row.registration_id, + subjectId: row.subject_id, + score: Number(row.score), + grade: row.grade, + published: Boolean(row.published), + updatedAt: row.updated_at, + publishedAt: row.published_at + })), + testCenters: rows.testCenters.map(row => ({ + id: row.id, + schoolId: row.school_id, + code: row.code || '', + name: row.name, + provinceCode: row.province_code || '', + provinceName: row.province_name || '', + cityCode: row.city_code || '', + cityName: row.city_name || '', + districtCode: row.district_code || '', + districtName: row.district_name || '', + address: row.address, + contact: row.contact || '', + managerName: row.manager_name || '', + managerPhone: row.manager_phone || '', + emergencyPhone: row.emergency_phone || '', + gateOpenTime: row.gate_open_time || '', + transport: row.transport || '', + status: row.status || 'active', + notes: row.notes || '', + rooms: row.rooms || '', + updatedAt: row.updated_at + })), + testRooms: rows.testRooms.map(row => ({ + id: row.id, + centerId: row.center_id, + code: row.code, + name: row.name, + building: row.building, + floor: row.floor || '', + capacity: Number(row.capacity), + seatPlan: row.seat_plan || '', + seatStart: Number(row.seat_start), + seatEnd: Number(row.seat_end), + roomType: row.room_type, + status: row.status, + notes: row.notes || '' + })), + centerChangeRequests: rows.centerChangeRequests.map(row => ({ + id: row.id, + centerId: row.center_id, + schoolId: row.school_id, + requestType: row.request_type, + code: row.code, + name: row.name, + provinceCode: row.province_code || '', + provinceName: row.province_name || '', + cityCode: row.city_code || '', + cityName: row.city_name || '', + districtCode: row.district_code || '', + districtName: row.district_name || '', + address: row.address, + contact: row.contact || '', + managerName: row.manager_name || '', + managerPhone: row.manager_phone || '', + emergencyPhone: row.emergency_phone || '', + gateOpenTime: row.gate_open_time || '', + transport: row.transport || '', + centerStatus: row.center_status, + notes: row.notes || '', + status: row.status, + reviewNote: row.review_note || '', + requestedBy: row.requested_by, + createdAt: row.created_at, + reviewedAt: row.reviewed_at + })), + centerChangeRooms: rows.centerChangeRooms.map(row => ({ + id: row.id, + requestId: row.request_id, + roomId: row.room_id, + code: row.code, + name: row.name, + building: row.building, + floor: row.floor || '', + capacity: Number(row.capacity), + seatPlan: row.seat_plan || '', + seatStart: Number(row.seat_start), + seatEnd: Number(row.seat_end), + roomType: row.room_type, + status: row.status, + notes: row.notes || '' + })), + admissionNumberRules: rows.admissionNumberRules.map(row => ({ + id: row.id, + code: row.code, + name: row.name, + description: row.description, + separator: row.separator || '', + segments: Array.isArray(row.segments_json) ? row.segments_json : JSON.parse(row.segments_json || '[]'), + example: row.example || '', + active: Boolean(row.active), + createdAt: row.created_at + })), + arrangementPlans: rows.arrangementPlans.map(row => ({ + id: row.id, + examId: row.exam_id, + numberRuleId: row.number_rule_id, + mixingScope: row.mixing_scope, + randomSeed: row.random_seed, + candidateCount: Number(row.candidate_count), + centerCount: Number(row.center_count), + subjectAssignmentCount: Number(row.subject_assignment_count), + subjectCombinationCount: Number(row.subject_combination_count), + sameSchoolCenterRate: Number(row.same_school_center_rate), + warnings: Array.isArray(row.warnings_json) ? row.warnings_json : JSON.parse(row.warnings_json || '[]'), + generatedBy: row.generated_by, + generatedAt: row.generated_at + })), + numberRules: rows.numberRules.map(row => ({ + id: row.id, + name: row.name, + separator: row.separator, + active: Boolean(row.active), + createdBy: row.created_by, + updatedAt: row.updated_at, + segments: ruleSegments.get(row.id) || [] + })), + candidateAccountBatches: rows.candidateAccountBatches.map(row => ({ + id: row.id, + schoolId: row.school_id, + requestedBy: row.requested_by, + status: row.status, + reviewNote: row.review_note || '', + createdAt: row.created_at, + reviewedAt: row.reviewed_at + })), + candidateAccountBatchItems: rows.candidateAccountBatchItems.map(row => ({ + id: row.id, + batchId: row.batch_id, + classId: row.class_id, + position: Number(row.position), + candidateNumber: row.candidate_number || '', + initialPassword: row.initial_password || '', + userId: row.user_id, + createdAt: row.created_at + })), + workflows: rows.workflows.map(row => ({ + id: row.id, + businessType: row.business_type, + name: row.name, + active: Boolean(row.active), + updatedBy: row.updated_by, + updatedAt: row.updated_at, + steps: workflowSteps.get(row.id) || [] + })), + workflowInstances: rows.workflowInstances.map(row => ({ + id: row.id, + workflowId: row.workflow_id, + businessType: row.business_type, + businessId: row.business_id, + status: row.status, + currentStep: Number(row.current_step), + assigneeId: row.assignee_id, + createdAt: row.created_at, + completedAt: row.completed_at + })), + workflowActions: rows.workflowActions.map(row => ({ + id: row.id, + instanceId: row.instance_id, + actorId: row.actor_id, + action: row.action, + note: row.note || '', + fromAssigneeId: row.from_assignee_id, + 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, + action: row.action, + detail: row.detail, + createdAt: row.created_at + })) + }; + return state; +} + +function readSqliteRows(connection) { + return { + system: connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(), + organization: connection.prepare('SELECT * FROM organization WHERE id = 1').get(), + schools: connection.prepare('SELECT * FROM schools ORDER BY name, id').all(), + classes: connection.prepare('SELECT * FROM school_classes ORDER BY school_id, grade, name, id').all(), + users: connection.prepare('SELECT * FROM users ORDER BY created_at, id').all(), + profiles: connection.prepare('SELECT * FROM candidate_profiles ORDER BY updated_at, id').all(), + notices: connection.prepare('SELECT * FROM notices ORDER BY publish_at, created_at, id').all(), + exams: connection.prepare('SELECT * FROM exams ORDER BY created_at, id').all(), + subjects: connection.prepare('SELECT * FROM exam_subjects ORDER BY exam_id, position, id').all(), + registrations: connection.prepare('SELECT * FROM registrations ORDER BY created_at, id').all(), + registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(), + admissionNumberRules: connection.prepare('SELECT * FROM admission_number_rules ORDER BY created_at, id').all(), + arrangementPlans: connection.prepare('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id').all(), + admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(), + admitCardSubjects: connection.prepare('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id').all(), + results: connection.prepare('SELECT * FROM results ORDER BY id').all(), + testCenters: connection.prepare('SELECT * FROM test_centers ORDER BY school_id, name, id').all(), + testRooms: connection.prepare('SELECT * FROM test_rooms ORDER BY center_id, code, id').all(), + centerChangeRequests: connection.prepare('SELECT * FROM center_change_requests ORDER BY created_at DESC, id').all(), + centerChangeRooms: connection.prepare('SELECT * FROM center_change_rooms ORDER BY request_id, code, id').all(), + numberRules: connection.prepare('SELECT * FROM number_rules ORDER BY updated_at DESC, id').all(), + numberRuleSegments: connection.prepare('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id').all(), + candidateAccountBatches: connection.prepare('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id').all(), + candidateAccountBatchItems: connection.prepare('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id').all(), + workflows: connection.prepare('SELECT * FROM workflow_definitions ORDER BY business_type, id').all(), + 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() + }; +} + +async function readMysqlRows(connection) { + const query = async sql => (await connection.execute(sql))[0]; + const one = async sql => (await query(sql))[0]; + return { + system: await one('SELECT * FROM schema_metadata WHERE id = 1'), + organization: await one('SELECT * FROM organization WHERE id = 1'), + schools: await query('SELECT * FROM schools ORDER BY name, id'), + classes: await query('SELECT * FROM school_classes ORDER BY school_id, grade, name, id'), + users: await query('SELECT * FROM users ORDER BY created_at, id'), + profiles: await query('SELECT * FROM candidate_profiles ORDER BY updated_at, id'), + notices: await query('SELECT * FROM notices ORDER BY publish_at, created_at, id'), + exams: await query('SELECT * FROM exams ORDER BY created_at, id'), + subjects: await query('SELECT * FROM exam_subjects ORDER BY exam_id, position, id'), + registrations: await query('SELECT * FROM registrations ORDER BY created_at, id'), + registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'), + admissionNumberRules: await query('SELECT * FROM admission_number_rules ORDER BY created_at, id'), + arrangementPlans: await query('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id'), + admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'), + admitCardSubjects: await query('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id'), + results: await query('SELECT * FROM results ORDER BY id'), + testCenters: await query('SELECT * FROM test_centers ORDER BY school_id, name, id'), + testRooms: await query('SELECT * FROM test_rooms ORDER BY center_id, code, id'), + centerChangeRequests: await query('SELECT * FROM center_change_requests ORDER BY created_at DESC, id'), + centerChangeRooms: await query('SELECT * FROM center_change_rooms ORDER BY request_id, code, id'), + numberRules: await query('SELECT * FROM number_rules ORDER BY updated_at DESC, id'), + numberRuleSegments: await query('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id'), + candidateAccountBatches: await query('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id'), + candidateAccountBatchItems: await query('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id'), + workflows: await query('SELECT * FROM workflow_definitions ORDER BY business_type, id'), + 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') + }; +} + +function operation(sql, ...params) { + return { sql, params }; +} + +function optional(value) { + return value == null || value === '' ? null : value; +} + +function auditOperation(log) { + return operation( + 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', + log.id, optional(log.actorId), log.action, log.detail, log.createdAt + ); +} + +function workflowInstanceOperation(instance) { + return operation( + `INSERT INTO workflow_instances ( + id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status, + Number(instance.currentStep || 1), optional(instance.assigneeId), instance.createdAt, optional(instance.completedAt) + ); +} + +function workflowActionOperation(action) { + return operation( + `INSERT INTO workflow_actions ( + id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + action.id, action.instanceId, optional(action.actorId), action.action, optional(action.note), + optional(action.fromAssigneeId), optional(action.toAssigneeId), action.createdAt + ); +} + +function workflowCreateOperations(instance, action) { + return [workflowInstanceOperation(instance), workflowActionOperation(action)]; +} + +function createRepository({ client, location, read, transaction, close }) { + return { + client, + location, + read, + close, + async createCandidate(user, profile, instance, action, log = null) { + const operations = [ + operation( + `INSERT INTO users ( + id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, + must_change_password, archived_at, archived_by, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, optional(user.candidateNumber), user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId), + optional(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, optional(user.archivedAt), + optional(user.archivedBy), user.displayName, user.createdAt + ), + operation( + `INSERT INTO candidate_profiles ( + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, + optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.schoolId), + optional(profile.classId), optional(profile.provinceCode), optional(profile.provinceName), + optional(profile.cityCode), optional(profile.cityName), optional(profile.districtCode), optional(profile.districtName), + optional(profile.address), + optional(profile.emergencyContact), optional(profile.emergencyPhone), optional(profile.nativePlace), optional(profile.birthDate), + optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), + profile.profileCompleted ? 1 : 0, profile.status, optional(profile.reviewNote), + optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt + ) + ]; + if (instance && action) operations.push(...workflowCreateOperations(instance, action)); + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async createCandidateAccountBatch(batch, items, instance, action, log) { + const operations = [ + operation( + `INSERT INTO candidate_account_batches ( + id, school_id, requested_by, status, review_note, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + batch.id, batch.schoolId, optional(batch.requestedBy), batch.status, optional(batch.reviewNote), + batch.createdAt, optional(batch.reviewedAt) + ) + ]; + for (const item of items) operations.push(operation( + `INSERT INTO candidate_account_batch_items ( + id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + item.id, item.batchId, item.classId, Number(item.position), optional(item.candidateNumber), + optional(item.initialPassword), optional(item.userId), optional(item.createdAt) + )); + operations.push(...workflowCreateOperations(instance, action), auditOperation(log)); + await transaction(operations); + }, + async updateCandidateProfile(profile, displayName, instance, action) { + const operations = [ + operation( + `UPDATE candidate_profiles SET + name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, + 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), + optional(profile.school), optional(profile.grade), optional(profile.provinceCode), optional(profile.provinceName), + optional(profile.cityCode), optional(profile.cityName), optional(profile.districtCode), optional(profile.districtName), + 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), 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) + ]; + if (instance && action) operations.push(...workflowCreateOperations(instance, action)); + await transaction(operations); + }, + async changePassword(user, log) { + const operations = [operation( + 'UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?', + user.passwordHash, user.mustChangePassword ? 1 : 0, user.id + )]; + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async updateTotpSecurity(user, log = null) { + const operations = [operation( + `UPDATE users SET + totp_enabled = ?, totp_secret_encrypted = ?, totp_recovery_codes = ?, totp_last_used_step = ? + WHERE id = ?`, + user.totpEnabled ? 1 : 0, optional(user.totpSecretEncrypted), JSON.stringify(user.totpRecoveryCodes || []), + optional(user.totpLastUsedStep), user.id + )]; + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async updateCandidateArchives(users, log) { + const operations = users.map(user => operation( + 'UPDATE users SET archived_at = ?, archived_by = ? WHERE id = ?', + optional(user.archivedAt), optional(user.archivedBy), user.id + )); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async updateRegistrationSetting(enabled, log) { + await transaction([ + operation('UPDATE schema_metadata SET self_registration_enabled = ? WHERE id = 1', enabled ? 1 : 0), + auditOperation(log) + ]); + }, + async createRegistration(registration, instance, action) { + 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, 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), Number(registration.featureScore || 0) + )]; + for (const subjectId of registration.subjectIds) { + operations.push(operation( + 'INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', + registration.id, subjectId + )); + } + if (instance && action) operations.push(...workflowCreateOperations(instance, action)); + await transaction(operations); + }, + async reviewCandidate(profile, log) { + await transaction([ + operation( + `UPDATE candidate_profiles SET status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`, + profile.status, optional(profile.reviewNote), profile.reviewedAt, profile.reviewerId, profile.id + ), + auditOperation(log) + ]); + }, + async reviewRegistration(registration, log) { + await transaction([ + operation( + `UPDATE registrations SET status = ?, payment_status = ?, reviewed_at = ?, review_note = ? WHERE id = ?`, + registration.status, registration.paymentStatus, registration.reviewedAt, + optional(registration.reviewNote), registration.id + ), + auditOperation(log) + ]); + }, + async updateRegistrationPayment(registration, log) { + await transaction([ + operation( + 'UPDATE registrations SET payment_status = ?, paid_at = ?, paid_by = ? WHERE id = ?', + registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.id + ), + auditOperation(log) + ]); + }, + async processWorkflow(instance, action, business, log) { + const operations = [ + operation( + `UPDATE workflow_instances SET + status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`, + instance.status, Number(instance.currentStep), optional(instance.assigneeId), + optional(instance.completedAt), instance.id + ), + workflowActionOperation(action) + ]; + if (instance.businessType === 'profile_change') { + operations.push(operation( + `UPDATE candidate_profiles SET + status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`, + business.status, optional(business.reviewNote), optional(business.reviewedAt), + optional(business.reviewerId), business.id + )); + } else if (instance.businessType === 'registration_review') { + operations.push(operation( + `UPDATE registrations SET + status = ?, payment_status = ?, paid_at = ?, paid_by = ?, reviewed_at = ?, review_note = ?, + registration_number = ?, number_rule_id = ? WHERE id = ?`, + business.status, business.paymentStatus, optional(business.paidAt), optional(business.paidBy), + optional(business.reviewedAt), optional(business.reviewNote), + optional(business.registrationNumber), optional(business.numberRuleId), business.id + )); + } else if (instance.businessType === 'center_change') { + operations.push(operation( + `UPDATE center_change_requests SET + status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, + business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id + )); + } else if (instance.businessType === 'candidate_account_batch') { + operations.push(operation( + `UPDATE candidate_account_batches SET + status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, + business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id + )); + } else if (instance.businessType === 'score_appeal' && business) { + operations.push(operation( + `UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`, + Number(business.score), business.grade, business.published ? 1 : 0, + optional(business.updatedAt), optional(business.publishedAt), business.id + )); + } + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async completeCandidateAccountBatch(batch, items, users, profiles, instance, action, log) { + const operations = [ + operation( + `UPDATE workflow_instances SET + status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`, + instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id + ), + workflowActionOperation(action), + operation( + `UPDATE candidate_account_batches SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, + batch.status, optional(batch.reviewNote), optional(batch.reviewedAt), batch.id + ) + ]; + for (let index = 0; index < users.length; index += 1) { + const user = users[index]; + const profile = profiles[index]; + const item = items[index]; + operations.push( + operation( + `INSERT INTO users ( + id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active, + must_change_password, archived_at, archived_by, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.id, user.username, user.candidateNumber, user.passwordHash, user.role, optional(user.adminLevel), + user.schoolId, user.classId, 1, 1, null, null, user.displayName, user.createdAt + ), + operation( + `INSERT INTO candidate_profiles ( + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, + optional(profile.email), profile.school, profile.grade, profile.schoolId, profile.classId, + optional(profile.provinceCode), optional(profile.provinceName), optional(profile.cityCode), optional(profile.cityName), + optional(profile.districtCode), optional(profile.districtName), optional(profile.address), + optional(profile.emergencyContact), optional(profile.emergencyPhone), + optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), 0, profile.status, optional(profile.reviewNote), + optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt + ), + operation( + `UPDATE candidate_account_batch_items SET + candidate_number = ?, initial_password = ?, user_id = ?, created_at = ? WHERE id = ?`, + item.candidateNumber, item.initialPassword, item.userId, item.createdAt, item.id + ) + ); + } + operations.push(auditOperation(log)); + await transaction(operations); + }, + async transferWorkflow(instance, action, log) { + const operations = [ + operation('UPDATE workflow_instances SET assignee_id = ? WHERE id = ?', optional(instance.assigneeId), instance.id), + workflowActionOperation(action) + ]; + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async createWorkflow(instance, action, log = null) { + const operations = workflowCreateOperations(instance, action); + if (log) operations.push(auditOperation(log)); + await transaction(operations); + }, + async saveWorkflow(workflow, log) { + const operations = [ + operation( + `UPDATE workflow_definitions SET name = ?, active = ?, updated_by = ?, updated_at = ? WHERE id = ?`, + workflow.name, workflow.active ? 1 : 0, optional(workflow.updatedBy), workflow.updatedAt, workflow.id + ), + operation('DELETE FROM workflow_steps WHERE workflow_id = ?', workflow.id) + ]; + workflow.steps.forEach((step, index) => operations.push(operation( + `INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)`, + step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel + ))); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async saveNumberRule(rule, isNew, log) { + const operations = [operation('UPDATE number_rules SET active = 0 WHERE active = 1')]; + if (isNew) { + operations.push(operation( + 'INSERT INTO number_rules (id, name, `separator`, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + rule.id, rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt + )); + } else { + operations.push( + operation( + 'UPDATE number_rules SET name = ?, `separator` = ?, active = ?, created_by = ?, updated_at = ? WHERE id = ?', + rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt, rule.id + ), + operation('DELETE FROM number_rule_segments WHERE rule_id = ?', rule.id) + ); + } + rule.segments.forEach((segment, index) => operations.push(operation( + `INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)`, + segment.id, rule.id, Number(segment.position || index + 1), segment.type, + optional(segment.value), Number(segment.width || 0) + ))); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async createAdmin(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 (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, + user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), + user.active === false ? 0 : 1, user.displayName, user.createdAt + ), + 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, 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 = ?, 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)]); + }, + async saveSchoolClass(schoolClass, isNew, log) { + const change = isNew + ? operation( + 'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', + schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0 + ) + : operation( + 'UPDATE school_classes SET name = ?, grade = ?, active = ? WHERE id = ?', + schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0, schoolClass.id + ); + await transaction([change, auditOperation(log)]); + }, + async updateAdmin(user, passwordChanged, log) { + const sql = passwordChanged + ? 'UPDATE users SET display_name = ?, class_id = ?, active = ?, password_hash = ? WHERE id = ?' + : 'UPDATE users SET display_name = ?, class_id = ?, active = ? WHERE id = ?'; + const params = passwordChanged + ? [user.displayName, optional(user.classId), user.active ? 1 : 0, user.passwordHash, user.id] + : [user.displayName, optional(user.classId), user.active ? 1 : 0, user.id]; + await transaction([operation(sql, ...params), auditOperation(log)]); + }, + async saveTestCenter(center, isNew, log) { + const centerOperation = isNew + ? operation( + `INSERT INTO test_centers ( + id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name, + address, contact, rooms, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + center.id, center.schoolId, center.code, center.name, + center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName, + center.address, optional(center.contact), center.rooms, center.updatedAt + ) + : operation( + `UPDATE test_centers SET + code = ?, name = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?, + district_code = ?, district_name = ?, address = ?, contact = ?, rooms = ?, updated_at = ? WHERE id = ?`, + center.code, center.name, center.provinceCode, center.provinceName, center.cityCode, center.cityName, + center.districtCode, center.districtName, center.address, optional(center.contact), center.rooms, center.updatedAt, center.id + ); + await transaction([centerOperation, auditOperation(log)]); + }, + async createCenterChangeRequest(request, rooms, instance, action, log) { + const operations = [ + operation( + `INSERT INTO center_change_requests ( + id, center_id, school_id, request_type, code, name, + province_code, province_name, city_code, city_name, district_code, district_name, + address, contact, manager_name, manager_phone, + emergency_phone, gate_open_time, transport, center_status, notes, status, review_note, + requested_by, created_at, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request.id, optional(request.centerId), request.schoolId, request.requestType, request.code, request.name, + request.provinceCode, request.provinceName, request.cityCode, request.cityName, request.districtCode, request.districtName, + request.address, optional(request.contact), optional(request.managerName), optional(request.managerPhone), + optional(request.emergencyPhone), optional(request.gateOpenTime), optional(request.transport), + request.centerStatus, optional(request.notes), request.status, optional(request.reviewNote), + optional(request.requestedBy), request.createdAt, optional(request.reviewedAt) + ), + ...workflowCreateOperations(instance, action) + ]; + for (const room of rooms) operations.push(operation( + `INSERT INTO center_change_rooms ( + id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, request.id, optional(room.roomId), room.code, room.name, room.building, optional(room.floor), + Number(room.capacity), optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status, + optional(room.notes) + )); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async applyCenterChange(request, instance, action, center, rooms, log) { + const operations = [ + operation( + `UPDATE workflow_instances SET + status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`, + instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id + ), + workflowActionOperation(action), + operation( + `UPDATE center_change_requests SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`, + request.status, optional(request.reviewNote), optional(request.reviewedAt), request.id + ) + ]; + if (request.status === 'approved' && center) { + if (request.requestType === 'create') { + operations.push(operation( + `INSERT INTO test_centers ( + id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name, + address, contact, manager_name, manager_phone, emergency_phone, + gate_open_time, transport, status, notes, rooms, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + center.id, center.schoolId, center.code, center.name, + center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName, + center.address, optional(center.contact), + optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), center.status, optional(center.notes), + center.rooms || '', center.updatedAt + )); + } else { + operations.push(operation( + `UPDATE test_centers SET + code = ?, name = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?, + district_code = ?, district_name = ?, address = ?, contact = ?, manager_name = ?, manager_phone = ?, + emergency_phone = ?, gate_open_time = ?, transport = ?, status = ?, notes = ?, rooms = ?, updated_at = ? + WHERE id = ?`, + center.code, center.name, center.provinceCode, center.provinceName, center.cityCode, center.cityName, + center.districtCode, center.districtName, center.address, optional(center.contact), optional(center.managerName), + optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime), + optional(center.transport), center.status, optional(center.notes), center.rooms || '', center.updatedAt, center.id + )); + } + operations.push(operation('DELETE FROM test_rooms WHERE center_id = ?', center.id)); + for (const room of rooms) operations.push(operation( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room.id, center.id, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status, optional(room.notes) + )); + } + operations.push(auditOperation(log)); + await transaction(operations); + }, + async saveAdmissionArrangement(plan, cards, log) { + const operations = [ + operation('DELETE FROM exam_arrangement_plans WHERE exam_id = ?', plan.examId), + operation( + `INSERT INTO exam_arrangement_plans ( + id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count, + subject_assignment_count, subject_combination_count, same_school_center_rate, + warnings_json, generated_by, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed, + Number(plan.candidateCount), Number(plan.centerCount), Number(plan.subjectAssignmentCount), + Number(plan.subjectCombinationCount), Number(plan.sameSchoolCenterRate), JSON.stringify(plan.warnings || []), + optional(plan.generatedBy), plan.generatedAt + ) + ]; + for (const card of cards) { + operations.push(operation( + `INSERT INTO admit_cards ( + registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter, + card.centerCode || '', card.centerAddress || '', card.generatedAt + )); + for (const assignment of card.assignments) operations.push(operation( + `INSERT INTO admit_card_subjects ( + registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + card.registrationId, assignment.subjectId, optional(assignment.roomId), assignment.roomName, + assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature + )); + } + operations.push(auditOperation(log)); + await transaction(operations); + }, + async createExam(exam, log) { + const operations = [operation( + `INSERT INTO exams ( + id, code, name, description, registration_start, registration_end, exam_start, exam_end, + admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, + exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, + exam.location || '', exam.passPolicy, exam.passValue, exam.status, optional(exam.archivedAt), optional(exam.archivedBy), exam.createdAt + )]; + exam.subjects.forEach((subject, index) => operations.push(operation( + `INSERT INTO exam_subjects ( + id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), + subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore), + Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), Number(subject.passValue ?? subject.passScore ?? 0), Number(subject.order || index + 1) + ))); + operations.push(auditOperation(log)); + await transaction(operations); + }, + async updateExam(exam, log, replaceSubjects = false) { + const operations = [ + operation( + `UPDATE exams SET + code = ?, name = ?, description = ?, registration_start = ?, registration_end = ?, + exam_start = ?, exam_end = ?, admit_download_start = ?, admit_download_end = ?, + location = ?, pass_policy = ?, pass_value = ?, status = ? WHERE id = ?`, + exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, + exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, + exam.location || '', exam.passPolicy, exam.passValue, exam.status, exam.id + ) + ]; + if (replaceSubjects) { + operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id)); + exam.subjects.forEach((subject, index) => operations.push(operation( + `INSERT INTO exam_subjects ( + id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), + subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore), + Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), Number(subject.passValue ?? subject.passScore ?? 0), Number(subject.order || index + 1) + ))); + } + operations.push(auditOperation(log)); + await transaction(operations); + }, + async archiveExam(exam, log) { + await transaction([ + operation('UPDATE exams SET status = ?, archived_at = ?, archived_by = ? WHERE id = ? AND archived_at IS NULL', + exam.status, exam.archivedAt, exam.archivedBy, exam.id), + auditOperation(log) + ]); + }, + async createNotice(notice, log) { + await transaction([ + operation( + `INSERT INTO notices ( + id, title, summary, content, category, pinned, status, publish_at, created_at, author + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, + notice.status, optional(notice.publishAt), optional(notice.createdAt), notice.author + ), + auditOperation(log) + ]); + }, + async updateNotice(notice, log) { + await transaction([ + operation( + `UPDATE notices SET title = ?, summary = ?, content = ?, category = ?, pinned = ?, status = ?, publish_at = ? WHERE id = ?`, + notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, + notice.status, optional(notice.publishAt), notice.id + ), + auditOperation(log) + ]); + }, + async saveResult(result, isNew, log) { + const resultOperation = isNew + ? operation( + `INSERT INTO results ( + id, registration_id, subject_id, score, grade, published, updated_at, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + result.id, result.registrationId, result.subjectId, result.score, result.grade, + result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt) + ) + : operation( + `UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`, + result.score, result.grade, result.published ? 1 : 0, + optional(result.updatedAt), optional(result.publishedAt), result.id + ); + await transaction([resultOperation, auditOperation(log)]); + }, + async saveResults(entries) { + const operations = []; + for (const { result, isNew, log } of entries) { + operations.push(isNew + ? operation( + `INSERT INTO results ( + id, registration_id, subject_id, score, grade, published, updated_at, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + result.id, result.registrationId, result.subjectId, result.score, result.grade, + result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt) + ) + : operation( + `UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`, + result.score, result.grade, result.published ? 1 : 0, + optional(result.updatedAt), optional(result.publishedAt), result.id + )); + operations.push(auditOperation(log)); + } + await transaction(operations); + } + }; +} + +const adapterContext = { + mkdir, + dirname, + sqliteSchema, + mysqlSchema, + optional, + buildSeedOperations, + stateFromRows, + readSqliteRows, + readMysqlRows, + createRepository +}; +const createSqliteStore = createSqliteAdapter(adapterContext); +const createMysqlStore = createMysqlAdapter(adapterContext); + +export async function createDatabase({ root, seed }) { + const client = String(process.env.DATABASE_CLIENT || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase(); + if (!['sqlite', 'mysql'].includes(client)) { + throw new Error(`不支持的 DATABASE_CLIENT:${client}(可选 sqlite 或 mysql)`); + } + + if (client === 'mysql') return createMysqlStore({ seed }); + + const path = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite')); + return createSqliteStore({ path, seed }); +} diff --git a/excel.mjs b/excel.mjs new file mode 100644 index 0000000..ad9dad3 --- /dev/null +++ b/excel.mjs @@ -0,0 +1,401 @@ +import ExcelJS from 'exceljs'; + +const resourceSpecs = { + classes: { + title: '班级台账', sheet: '班级', + columns: [ + ['schoolCode', '学校代码*', 16, 'HZ01'], ['grade', '年级*', 14, '高三'], + ['name', '班级名称*', 22, '高三(3)班'], ['status', '状态*', 12, '启用'] + ], + validations: { status: ['启用', '停用'] } + }, + class_admins: { + title: '班级管理员台账', sheet: '班级管理员', + columns: [ + ['schoolCode', '学校代码*', 16, 'HZ01'], ['className', '班级名称*', 22, '高三(1)班'], + ['displayName', '管理员姓名*', 18, '张老师'], ['username', '登录账号*', 20, 'hz01_g301'], + ['initialPassword', '初始密码(新建必填)', 24, 'ChangeMe123!'], ['status', '状态*', 12, '启用'] + ], + validations: { status: ['启用', '停用'] } + }, + account_quotas: { + title: '批量报名号申领配额', sheet: '班级配额', + columns: [['className', '班级名称*', 24, '高三(1)班'], ['count', '申领数量*', 16, 30]], + numberColumns: ['count'] + }, + account_results: { + title: '报名号下发清单', sheet: '账号结果', + columns: [ + ['batchId', '批次编号', 30, ''], ['className', '班级', 22, ''], + ['candidateNumber', '固定报名号', 26, ''], ['initialPassword', '初始密码', 22, ''] + ] + }, + candidates: { + title: '考生资料台账', sheet: '考生资料', + columns: [ + ['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['name', '姓名*', 16, '李明'], + ['gender', '性别*', 10, '男'], ['idNumber', '证件号码*', 24, '320101200801011234'], + ['phone', '手机号*', 18, '13800138000'], ['email', '邮箱', 24, 'student@example.com'], + ['nativePlace', '籍贯', 18, '江苏海州'], + ['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'], + ['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'], + ['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 18, '海州区'], + ['address', '详细住址*', 32, '示例路 1 号'], + ['className', '班级*', 22, '高三(1)班'], ['ethnicity', '民族', 12, '汉族'], + ['birthDate', '出生日期', 16, '2008-01-01'], ['postalCode', '邮编', 14, '222000'], + ['guardianName', '监护人', 16, '李家长'], ['guardianPhone', '监护人电话', 18, '13900139000'] + ], + validations: { gender: ['男', '女'] } + }, + payments: { + title: '考试缴费名单', sheet: '缴费名单', + columns: [ + ['examCode', '考试代码', 18, ''], ['examName', '考试名称', 28, ''], + ['schoolName', '学校', 24, ''], ['className', '班级', 20, ''], + ['candidateNumber', '报名号', 26, ''], ['candidateName', '考生姓名', 16, ''], + ['subjectNames', '报考科目', 34, ''], ['amountDue', '应缴金额(元)', 18, ''], + ['paymentStatus', '缴费状态', 14, ''], ['paidAt', '确认时间', 24, ''], + ['paidByName', '确认人', 16, ''] + ], + numberColumns: ['amountDue'], + numberFormats: { amountDue: '0.00' } + }, + centers: { + title: '考点考场档案', sheet: '考点考场', + columns: [ + ['schoolCode', '学校代码*', 14, 'HZ01'], ['centerCode', '考点代码*', 18, 'HZ01-C02'], + ['centerName', '考点名称*', 24, '第一中学东区考点'], + ['provinceCode', '省级代码*', 14, '320000'], ['provinceName', '省份', 18, '江苏省'], + ['cityCode', '市级代码*', 14, '320700'], ['cityName', '城市', 18, '连云港市'], + ['districtCode', '区县代码*', 14, '320706'], ['districtName', '区县', 18, '海州区'], + ['address', '详细地址*', 30, '示例路 8 号'], + ['managerName', '负责人', 16, '王老师'], ['managerPhone', '负责人手机', 18, '13800138000'], + ['contact', '值班电话', 18, '0518-86020000'], ['emergencyPhone', '应急电话', 18, '0518-120'], + ['gateOpenTime', '开放时间', 14, '07:00'], ['transport', '交通提示', 30, '东门入场'], + ['centerStatus', '考点状态*', 14, '启用'], ['centerNotes', '考点备注', 26, ''], + ['roomCode', '考场代码*', 16, '001'], ['roomName', '考场名称*', 22, '第 001 考场'], + ['building', '楼栋*', 18, '教学楼 A'], ['floor', '楼层', 12, '1 层'], + ['capacity', '容量*', 12, 30], ['seatPlan', '座位编排说明', 28, '按教室现场座次表编排'], + ['roomType', '考场类型*', 16, '标准考场'], ['roomStatus', '考场状态*', 14, '启用'], + ['roomNotes', '考场备注', 26, ''] + ], + validations: { centerStatus: ['启用', '停用'], roomStatus: ['启用', '停用'], roomType: ['标准考场', '机考考场', '无障碍考场', '备用考场'] }, + numberColumns: ['capacity'] + }, + results: { + title: '考试成绩台账', sheet: '成绩', + columns: [ + ['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, ''], + ['score', '成绩*', 12, 120], ['rank', '本科排名(导出计算)', 18, ''], + ['rankPercent', '排名百分位(导出计算)', 20, ''], ['qualified', '单科达线(导出计算)', 16, ''], ['grade', '排名等级(自动计算)', 18, ''], + ['published', '发布状态*', 14, '发布'], ['updatedAt', '更新时间(只读参考)', 24, ''] + ], + validations: { published: ['发布', '不发布'] }, + numberColumns: ['fullScore', 'passScore', 'score', 'rank', 'rankPercent'], + numberFormats: { fullScore: '0.00', passScore: '0.00', score: '0.00', rank: '0', rankPercent: '0.00' } + }, + admit_cards: { + title: '准考证信息台账', sheet: '准考证信息', + columns: [ + ['schoolName', '考生学校', 24, ''], ['className', '班级', 20, ''], + ['candidateNumber', '报名号', 24, ''], ['candidateName', '姓名', 14, ''], ['idNumber', '证件号码', 22, ''], + ['examCode', '考试代码', 18, ''], ['examName', '考试名称', 28, ''], ['cardNumber', '准考证号', 22, ''], + ['centerCode', '考点代码', 16, ''], ['centerName', '考点名称', 26, ''], ['centerAddress', '考点详细地址', 38, ''], + ['subjectName', '科目', 14, ''], ['subjectDate', '日期', 14, ''], ['subjectTime', '时间', 16, ''], + ['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'] } + } +}; + +export function hasExcelResource(resource) { + return Boolean(resourceSpecs[resource]); +} + +function cellValue(cell) { + const value = cell.value; + if (value == null) return ''; + if (value instanceof Date) return value.toISOString().slice(0, 10); + if (typeof value === 'object') { + if ('text' in value) return String(value.text || '').trim(); + if ('result' in value) return String(value.result ?? '').trim(); + } + return typeof value === 'number' ? value : String(value).trim(); +} + +export async function parseWorkbook(resource, buffer) { + const spec = resourceSpecs[resource]; + if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 }); + const workbook = new ExcelJS.Workbook(); + try { + await workbook.xlsx.load(buffer); + } catch { + throw Object.assign(new Error('无法读取 Excel 文件,请使用系统下载的 .xlsx 模板'), { status: 400 }); + } + const sheet = workbook.getWorksheet(spec.sheet) || workbook.worksheets[0]; + if (!sheet) throw Object.assign(new Error('Excel 文件中没有可读取的工作表'), { status: 400 }); + const headerMap = new Map(); + sheet.getRow(2).eachCell((cell, col) => headerMap.set(String(cell.value || '').replace(/\*/g, '').trim(), col)); + const missing = spec.columns.filter(([, label]) => !headerMap.has(label.replace(/\*/g, '').trim())); + if (missing.length) throw Object.assign(new Error(`模板列不完整:缺少 ${missing.map(([, label]) => label).join('、')}`), { status: 400 }); + const rows = []; + for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) { + const row = sheet.getRow(rowNumber); + const item = { __row: rowNumber }; + let populated = false; + for (const [key, label] of spec.columns) { + const value = cellValue(row.getCell(headerMap.get(label.replace(/\*/g, '').trim()))); + item[key] = value; + if (value !== '') populated = true; + } + if (populated) rows.push(item); + } + if (!rows.length) throw Object.assign(new Error('Excel 中没有可导入的数据行'), { status: 400 }); + return rows; +} + +export async function buildWorkbook(resource, rows = [], { template = false, subtitle = '' } = {}) { + const spec = resourceSpecs[resource]; + if (!spec) throw Object.assign(new Error('不支持的 Excel 数据类型'), { status: 404 }); + const workbook = new ExcelJS.Workbook(); + workbook.creator = '衡准考试信息管理系统'; + workbook.created = new Date(); + const sheet = workbook.addWorksheet(spec.sheet, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] }); + const lastColumn = spec.columns.length; + sheet.columns = spec.columns.map(([key, , width]) => ({ key, width })); + spec.columns.forEach(([key], index) => { + sheet.getColumn(index + 1).numFmt = spec.numberFormats?.[key] || (spec.numberColumns?.includes(key) ? '0' : '@'); + }); + sheet.mergeCells(1, 1, 1, lastColumn); + const titleCell = sheet.getCell(1, 1); + titleCell.value = subtitle ? `${spec.title}|${subtitle}` : spec.title; + titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } }; + titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } }; + titleCell.alignment = { vertical: 'middle', horizontal: 'left' }; + sheet.getRow(1).height = 34; + const header = sheet.getRow(2); + header.values = spec.columns.map(([, label]) => label); + header.height = 25; + header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } }; + header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } }; + 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, 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' }; + row.eachCell(cell => { + 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; + for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) { + sheet.getCell(row, col).dataValidation = { type: 'list', allowBlank: false, formulae: [`"${values.join(',')}"`] }; + } + } + const requiredColumns = spec.columns.map(([, label], index) => label.includes('*') ? index + 1 : 0).filter(Boolean); + for (const col of requiredColumns) header.getCell(col).font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFE7A3' } }; + return workbook.xlsx.writeBuffer(); +} + +function addOperationalSheet(workbook, name, title, columns, rows, { landscape = true } = {}) { + const sheet = workbook.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 2, showGridLines: false }] }); + sheet.columns = columns.map(([key, , width]) => ({ key, width })); + sheet.mergeCells(1, 1, 1, columns.length); + const titleCell = sheet.getCell(1, 1); + titleCell.value = title; + titleCell.font = { name: '微软雅黑', size: 16, bold: true, color: { argb: 'FFFFFFFF' } }; + titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } }; + titleCell.alignment = { vertical: 'middle', horizontal: 'left' }; + sheet.getRow(1).height = 34; + const header = sheet.getRow(2); + header.values = columns.map(([, label]) => label); + header.height = 28; + header.font = { name: '微软雅黑', bold: true, color: { argb: 'FFFFFFFF' } }; + header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2C7080' } }; + header.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + rows.forEach((item, index) => { + const row = sheet.addRow(Object.fromEntries(columns.map(([key]) => [key, item[key] ?? '']))); + row.height = 28; + row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } }; + row.alignment = { vertical: 'middle', wrapText: true }; + if (index % 2 === 1) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F7F8' } }; + row.eachCell(cell => { cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } }; }); + }); + sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: columns.length } }; + sheet.pageSetup = { orientation: landscape ? 'landscape' : 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0, margins: { left: .25, right: .25, top: .4, bottom: .4, header: .2, footer: .2 } }; + sheet.printTitlesRow = '1:2'; + return sheet; +} + +function addDeskStickerSheet(workbook, rows) { + const sheet = workbook.addWorksheet('桌贴', { views: [{ showGridLines: false, zoomScale: 85 }] }); + const labelColumns = [14.5, 14.5, 14.5, 3, 14.5, 14.5, 14.5]; + labelColumns.forEach((width, index) => { sheet.getColumn(index + 1).width = width; }); + sheet.pageSetup = { + orientation: 'portrait', paperSize: 9, fitToPage: true, fitToWidth: 1, fitToHeight: 0, + margins: { left: .25, right: .25, top: .25, bottom: .25, header: 0, footer: 0 }, + horizontalCentered: true, verticalCentered: false + }; + sheet.headerFooter = { oddFooter: '&C第 &P / &N 页' }; + const rowsPerLabel = 8; + const labelsPerPage = 6; + const rowsPerPage = rowsPerLabel * 3; + const borderColor = { argb: 'FF173F60' }; + const paleBlue = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF1F5' } }; + const paleGold = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF1C7' } }; + const mergeLine = (row, startColumn) => { + sheet.mergeCells(row, startColumn, row, startColumn + 2); + return sheet.getCell(row, startColumn); + }; + const styleLine = (cell, { size = 10, bold = false, color = 'FF243B4A', fill = null } = {}) => { + cell.font = { name: '微软雅黑', size, bold, color: { argb: color } }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true, shrinkToFit: true }; + if (fill) cell.fill = fill; + }; + + rows.forEach((item, index) => { + const page = Math.floor(index / labelsPerPage); + const slot = index % labelsPerPage; + const startRow = page * rowsPerPage + Math.floor(slot / 2) * rowsPerLabel + 1; + const startColumn = slot % 2 === 0 ? 1 : 5; + for (let row = startRow; row < startRow + rowsPerLabel; row += 1) sheet.getRow(row).height = 32.5; + const cells = Array.from({ length: rowsPerLabel }, (_, offset) => mergeLine(startRow + offset, startColumn)); + cells[0].value = item.examName; + styleLine(cells[0], { size: 11, bold: true, color: 'FFFFFFFF', fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF173F60' } } }); + cells[1].value = `${item.subjectName}|${item.subjectDate} ${item.subjectTime}`; + styleLine(cells[1], { size: 9, bold: true, fill: paleBlue }); + cells[2].value = '考试考场序号'; + styleLine(cells[2], { size: 9, bold: true, color: 'FF2C7080' }); + cells[3].value = { richText: [{ text: String(item.examRoomCode || '') }] }; + cells[3].numFmt = '@'; + styleLine(cells[3], { size: 25, bold: true, color: 'FF173F60' }); + cells[4].value = `座位号 ${item.seat}`; + styleLine(cells[4], { size: 20, bold: true, color: 'FF8A4B08', fill: paleGold }); + cells[5].value = `${item.roomName}(场地 ${item.roomCode})|${item.building} ${item.floor}`; + styleLine(cells[5], { size: 9, bold: true }); + cells[6].value = `${item.candidateName}|${item.schoolName} ${item.className}`; + styleLine(cells[6], { size: 10, bold: true, fill: paleBlue }); + cells[7].value = `${item.centerName}|准考证号 ${item.cardNumber}`; + styleLine(cells[7], { size: 8, color: 'FF526576' }); + + for (let row = startRow; row < startRow + rowsPerLabel; row += 1) { + for (let column = startColumn; column < startColumn + 3; column += 1) { + const cell = sheet.getCell(row, column); + cell.border = { + top: { style: row === startRow ? 'medium' : 'hair', color: borderColor }, + bottom: { style: row === startRow + rowsPerLabel - 1 ? 'medium' : 'hair', color: borderColor }, + left: { style: column === startColumn ? 'medium' : 'hair', color: borderColor }, + right: { style: column === startColumn + 2 ? 'medium' : 'hair', color: borderColor } + }; + } + } + if ((index + 1) % labelsPerPage === 0 && index + 1 < rows.length) sheet.getRow(startRow + rowsPerLabel - 1).addPageBreak(); + }); + if (rows.length) sheet.pageSetup.printArea = `A1:G${Math.ceil(rows.length / labelsPerPage) * rowsPerPage}`; + return sheet; +} + +export async function buildCenterMaterialsWorkbook(rows, subtitle = '') { + const workbook = new ExcelJS.Workbook(); + workbook.creator = '衡准考试信息管理系统'; + workbook.created = new Date(); + const sorted = [...rows].sort((a, b) => String(a.centerCode).localeCompare(String(b.centerCode)) + || String(a.subjectDate).localeCompare(String(b.subjectDate)) || String(a.subjectTime).localeCompare(String(b.subjectTime)) + || String(a.examRoomCode).localeCompare(String(b.examRoomCode)) || String(a.seat).localeCompare(String(b.seat))); + const baseTitle = subtitle ? `|${subtitle}` : ''; + addDeskStickerSheet(workbook, sorted); + + const doorMap = new Map(); + for (const item of sorted) { + const key = [item.examCode, item.subjectName, item.centerCode, item.examRoomCode, item.roomCode].join('|'); + const current = doorMap.get(key) || { ...item, candidateCount: 0 }; + current.candidateCount += 1; + doorMap.set(key, current); + } + addOperationalSheet(workbook, '门贴', `考场门贴${baseTitle}`, [ + ['examName', '考试', 28], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16], + ['centerName', '考点', 24], ['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20], + ['roomCode', '场地代码', 14], ['building', '楼栋', 16], ['floor', '楼层', 10], ['candidateCount', '人数', 10] + ], [...doorMap.values()]); + + addOperationalSheet(workbook, '考场签名单', `考场考生签名单${baseTitle}`, [ + ['examName', '考试', 26], ['subjectName', '科目', 14], ['subjectDate', '日期', 14], ['subjectTime', '时间', 16], + ['examRoomCode', '考试考场序号', 16], ['roomName', '考场通用名称', 20], ['building', '楼栋', 16], ['floor', '楼层', 10], + ['seat', '座位号', 10], ['cardNumber', '准考证号', 22], ['candidateName', '姓名', 14], + ['schoolName', '考生学校', 24], ['className', '班级', 18], ['signature', '考生签名', 18] + ], sorted.map(item => ({ ...item, signature: '' }))); + return workbook.xlsx.writeBuffer(); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..88ddd8f --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + 衡准 · 考试信息管理系统 + + + +
+
+
+ 衡准 + 系统正在加载 +
+
+
+
+ +
操作成功更改已保存
+
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8b4fdef --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4355 @@ +{ + "name": "hengzhun-exam-system", + "version": "1.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hengzhun-exam-system", + "version": "1.1.0", + "dependencies": { + "ckeditor5": "^48.3.1", + "exceljs": "^4.4.0", + "mysql2": "^3.14.2", + "qrcode": "1.5.4", + "redis": "^5.12.1", + "sanitize-html": "^2.17.6" + }, + "engines": { + "node": ">=22.5" + } + }, + "node_modules/@ckeditor/ckeditor5-adapter-ckfinder": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-adapter-ckfinder/-/ckeditor5-adapter-ckfinder-48.3.1.tgz", + "integrity": "sha512-xv072kFznzCLzG6Kiro9Pwb6v3FNXMu6/NWQX+NCl0wlqit85hRbIMJW0WyWxuYY4XgcDn4cgB89vRJ5p0zKsQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-upload": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-alignment": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-alignment/-/ckeditor5-alignment-48.3.1.tgz", + "integrity": "sha512-ayiSLBtw4xvtMEPl6AhMX66Io6ajmq+2y2+FePfwu+9B8f8JblEApcWFLlj0HXxfWfJ470sEVMvwUJAwPLH9Jg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-autoformat": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-autoformat/-/ckeditor5-autoformat-48.3.1.tgz", + "integrity": "sha512-65TMkSDpfE63WquypME53ESV209U1iXuhX32x95nc45hSi4CMn25oxqansrCpxIlHWww3d7z3tUgSRFGXpFSag==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-heading": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-autosave": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-autosave/-/ckeditor5-autosave-48.3.1.tgz", + "integrity": "sha512-mc9UmTpyVBUn+V/pmzkP8PKJNuijytY8RuV7bckeq9OmxXOz2AD0lqlp+X48OcrgcefCA6e01D1hgElMhHQ/Hw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-basic-styles": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-basic-styles/-/ckeditor5-basic-styles-48.3.1.tgz", + "integrity": "sha512-uVbKZLNScqYyvj/Wg3uP2sWCHVuvYyFCGSNA1osnmI4DwKi3BS4TW/rm3IYUPrD6q2y/GGJK05OyHr3hyw4oig==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-block-quote": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-block-quote/-/ckeditor5-block-quote-48.3.1.tgz", + "integrity": "sha512-9T03V/VjWYu6dhyKWZVrlggeOumQXpUt4lygK6NQuSQ9lIMEnhFgzjqtZsGe9l90zvnPaUvV9IC3u1xUFZvzhg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-bookmark": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-bookmark/-/ckeditor5-bookmark-48.3.1.tgz", + "integrity": "sha512-EC5fmzUT5GKyIwQw+4tnHHJD4kUIBbiwP/+0gjHVZnHisRLfBOC3GAIczzq/AXDBIuaHqC9DZOAQQp+ltItHfQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-link": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-ckbox": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-ckbox/-/ckeditor5-ckbox-48.3.1.tgz", + "integrity": "sha512-ZaonwyuQjqjsho4iKDGSTn/M+SFs/OTl0WMkFU3tOhWpUikWKejNM94z+T6OR9oELWJVb58Jg6ubV6+ZYaLL7A==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-cloud-services": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-image": "48.3.1", + "@ckeditor/ckeditor5-link": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-upload": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "blurhash": "2.0.5", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-ckfinder": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-ckfinder/-/ckeditor5-ckfinder-48.3.1.tgz", + "integrity": "sha512-kZBp/eDhr8Y/CcE2UQZxFp3fZ+w4WYdYxmyuQ70mg4cOS1TMV9eh26Zz5khWAt+oAJsxVIDL5KNodak/hFpJ2w==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-adapter-ckfinder": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-image": "48.3.1", + "@ckeditor/ckeditor5-link": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-clipboard": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-clipboard/-/ckeditor5-clipboard-48.3.1.tgz", + "integrity": "sha512-PNI7yw9ese+fyQz0LzExRPY1CkjA6q9XqLDeMuLgp92Mkd7Vp1wftbB0ZLVyrzTIw8DEUudKziVsY3A4mT5aWA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-cloud-services": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-cloud-services/-/ckeditor5-cloud-services-48.3.1.tgz", + "integrity": "sha512-pHJAj5RRhTV7uyexs0ryFj82zZNex91VtmwTOB/z+VPXSHq4jzpxFE1fVuoHgkydUF5S5Dl6TiFru48yYlK4YA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-upload": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-code-block": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-code-block/-/ckeditor5-code-block-48.3.1.tgz", + "integrity": "sha512-9mwgtcrNCTK/06HXH+o8yNbrN/nCAflQtJ6LmAf1Gw/6Fed9gQslMcs5+ADO98Jer5OetpuKeqgmBACAuQUTmg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-core": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-core/-/ckeditor5-core-48.3.1.tgz", + "integrity": "sha512-wrAhYK5R8MkGAE0VrOMFQN9LSRpSATS7DFKr4CCJod9EgBT873SCu34Ey0fzSqGxeOqamlcXQJVh/77+3JP4SQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-watchdog": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-easy-image": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-easy-image/-/ckeditor5-easy-image-48.3.1.tgz", + "integrity": "sha512-A63AcmaAAGVUhALHVtwHrlBaWEaRjBhxWTwkQoy3RFPpSAXi1k0Cy55HhkVrnqVZSuKmljcX3FttpjOb6MQq8g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-cloud-services": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-image": "48.3.1", + "@ckeditor/ckeditor5-upload": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-editor-balloon": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-editor-balloon/-/ckeditor5-editor-balloon-48.3.1.tgz", + "integrity": "sha512-Unmzb3E+O83gIAvIT/RHyV1EzaxQGOzBiXGJhQahhQg++EvHUt4tmAJ6VzSLgJbtr8z3rMkA+lYpD5+OzpGT4w==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-editor-classic": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-editor-classic/-/ckeditor5-editor-classic-48.3.1.tgz", + "integrity": "sha512-7azS8ry8+c3H8S1RM1Aphq8wCig4mmLpK/kTmdF2/+JkPUszRoVK1HThSmBkstKCHDv4UuSVR+FcJ6Co6Tavog==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-editor-decoupled": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-editor-decoupled/-/ckeditor5-editor-decoupled-48.3.1.tgz", + "integrity": "sha512-S9xGZS7Hl2jWQrZFVwk3o7x9Wwl7tgbQl8TCujr0Bi0zxoM286WkHbBUej9oLVVtDHvq7xDSafmUURbL/K5ZXw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-editor-inline": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-editor-inline/-/ckeditor5-editor-inline-48.3.1.tgz", + "integrity": "sha512-uoXl+lfvGzH6HPHyjgcQcwK/WSDxchmkQvPrMHxTCyeimn4ffzFKzIH4WiOz39t1lXpMjI0hTy66eTB8F/+qvg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-editor-multi-root": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-editor-multi-root/-/ckeditor5-editor-multi-root-48.3.1.tgz", + "integrity": "sha512-5BUDEfVCsj4Al6lHWPDxUCRUymoWwmwwg9hZqd6iSkBtDOuZfiGC9IitMb6qYvrD2Q0MLRoiW0z9n6My4ZMHfQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-emoji": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-emoji/-/ckeditor5-emoji-48.3.1.tgz", + "integrity": "sha512-JQGLX9rMMnMY5/d8bJrF2htxKGxFfrW+E+rAy+Uc5FZ+cOCQCUfI0+kYpZYj/oyt5BH6VXn099e1Uu4xjicgAA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-mention": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1", + "fuzzysort": "3.1.0" + } + }, + "node_modules/@ckeditor/ckeditor5-engine": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-engine/-/ckeditor5-engine-48.3.1.tgz", + "integrity": "sha512-CbOuKrm3g8T2Df6WMEYtQMm4DJTAlmRutQQFj94zaXqDPyb8dSLpdPZ0Z89Qg2tagh9IEw0maG40ei2H4FETiQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-enter": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-enter/-/ckeditor5-enter-48.3.1.tgz", + "integrity": "sha512-+gs7yLyWSfYlBofKA1ce4O4fPBoMw4mSXNun9CZP2FgByvsnS/5sOE3c3UTJS2bSLYabNkZRH3SG4XWKKhunGg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-essentials": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-essentials/-/ckeditor5-essentials-48.3.1.tgz", + "integrity": "sha512-/yYMvcdYpwfQOumLNFccOnQvWfJoXN4Ny5a8m9OO/syGqeMtgpLy+Pn8be3nq4/pxdU6Qz0VnGVzU9OaHXxw4A==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-select-all": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-undo": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-find-and-replace": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-find-and-replace/-/ckeditor5-find-and-replace-48.3.1.tgz", + "integrity": "sha512-R4DPKGC5XmN68W2HuUBRo8VJJ1KCB5Y8gr6JbNDNGQ7aJWrgAjQU7H7d9EEwxU6wjxzNAT7MJqPOEX/WixUBhA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-font": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-font/-/ckeditor5-font-48.3.1.tgz", + "integrity": "sha512-Q8EVEjVix3HOleJ6XtJpXDqD7WGMU3WZR/9eK1hMvOHnvRWkHwXJve5IoaBseJXSSQopG98D1rvWuNvE+qsZNg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-fullscreen": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-fullscreen/-/ckeditor5-fullscreen-48.3.1.tgz", + "integrity": "sha512-/UQzJFhOMJrDBB+02jSNJVD6E0cjNZ43KgsR8croTDEPLQ2pRH50aUkBZTZn7TfSYx7X2aeaYbyegvSB+pfw6g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-editor-classic": "48.3.1", + "@ckeditor/ckeditor5-editor-decoupled": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-heading": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-heading/-/ckeditor5-heading-48.3.1.tgz", + "integrity": "sha512-CPlf4wSatQLqbgsj7DWPaOtjTgqchQX4Mv57NbV/2ZvDnqDOpjMi06d49yQngiPFv/6zqszr8U1raUX1OUhfYA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-paragraph": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-highlight": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-highlight/-/ckeditor5-highlight-48.3.1.tgz", + "integrity": "sha512-wlqHoOuHeA2qrE9rBeVHgVK75puzTFWn/V8IhAcVBCPB2AApLYNTO7fkfhOEpxqodE2It1lPpN4JfVRWUkoCIw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-horizontal-line": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-horizontal-line/-/ckeditor5-horizontal-line-48.3.1.tgz", + "integrity": "sha512-yl25cVmB5T+fsC2VqJvDLnmS9ymn+4LLaGSnue2D8Tg1WK/YTyWxKg3n0anPzMWBBLr1XcKAyVmnGFI66AKwvA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-html-embed": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-html-embed/-/ckeditor5-html-embed-48.3.1.tgz", + "integrity": "sha512-AuXaTHSnxR6chWIz9Z9UY2x4ANHN6Uf0jkfiAFcP6POWaD7YLsviTEYk+YGYw/kcXExMw3CQriDK5KdsjZtnkA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-html-support": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-html-support/-/ckeditor5-html-support-48.3.1.tgz", + "integrity": "sha512-aQ0ZHvvOATsUBykQJcGCFOTJjBDX3OULfeJWV1bqdVWAQt0BHC5xkq6d1IpbyKDaW9IkcB19GOS9nQgs1FasNg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-heading": "48.3.1", + "@ckeditor/ckeditor5-image": "48.3.1", + "@ckeditor/ckeditor5-list": "48.3.1", + "@ckeditor/ckeditor5-remove-format": "48.3.1", + "@ckeditor/ckeditor5-table": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-icons": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-icons/-/ckeditor5-icons-48.3.1.tgz", + "integrity": "sha512-a8mE5oTQ8TKf/325UmDixhhqGbDyzd749kcrANyzxwc89PtTMdUayl+D0Sj92Xp5fqMHgDEqipUcoohH9OSFBg==", + "license": "SEE LICENSE IN LICENSE.md" + }, + "node_modules/@ckeditor/ckeditor5-image": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-image/-/ckeditor5-image-48.3.1.tgz", + "integrity": "sha512-3lQY0LEpUNle2nUrONOtOXax62JQE9ZYltMDkOhMy9hcto0BoIngBebBsBBKZmpPugvMTf4qPYh1VkorOAdH0g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-undo": "48.3.1", + "@ckeditor/ckeditor5-upload": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-indent": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-indent/-/ckeditor5-indent-48.3.1.tgz", + "integrity": "sha512-hRuAPVDex46Ky86wuWNoPLpkPYQt0jGECKPnSYsjavm/il6rm57J/Y/jk7CKGJJVIovVLFftvWsI0yHLcsAP1w==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-heading": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-list": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-language": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-language/-/ckeditor5-language-48.3.1.tgz", + "integrity": "sha512-DGOxZyTvrXisV+3FhFEcagfSlTC7m2tdhD2CJJmBRkKX+2ZOdeP1D0QCD6ybFlO3D/wT57Po2QX2nCVI6U79dQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-link": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-link/-/ckeditor5-link-48.3.1.tgz", + "integrity": "sha512-E3pAhuNy77F55JUnX40apemSbsOC3RbUPIgPclNlh/1PXs1uN/bjkkyDVYQ/rT5MrophY3tw/bLZ/cRrbEuKBg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-image": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-list": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-list/-/ckeditor5-list-48.3.1.tgz", + "integrity": "sha512-KyuXH0aAiiQCOB+yJAru/C5GJqT7a+OoHoanjZMsFq5jjK9r5Tjrh6hUfqXpw+okU1x7Ow+/J4QLVI3QqSnluQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-font": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-markdown-gfm": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-markdown-gfm/-/ckeditor5-markdown-gfm-48.3.1.tgz", + "integrity": "sha512-y+aa3uPNwaTKxSXRcW3DNP4CfXBmXDmQk24mDVE4q7rl6lWkdnAUeEvUsaGYTRjs4szvY+HIaCts8OcSgKfvhA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@types/hast": "3.0.4", + "hast-util-from-dom": "5.0.1", + "hast-util-to-html": "9.0.5", + "hast-util-to-mdast": "10.1.2", + "hastscript": "9.0.1", + "rehype-dom-parse": "5.0.2", + "rehype-dom-stringify": "4.0.2", + "rehype-remark": "10.0.1", + "remark-breaks": "4.0.0", + "remark-gfm": "4.0.1", + "remark-parse": "11.0.0", + "remark-rehype": "11.1.2", + "remark-stringify": "11.0.0", + "unified": "11.0.5", + "unist-util-visit": "5.0.0" + } + }, + "node_modules/@ckeditor/ckeditor5-media-embed": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-media-embed/-/ckeditor5-media-embed-48.3.1.tgz", + "integrity": "sha512-Asq6B/nuhhHCka4mtjbIM4RrHAOWwCAYmR4kaX8xh6G81FfXKI/X26rJ8TjLno5J7lNgacdB6OznNu/UcJO0gQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-undo": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-mention": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-mention/-/ckeditor5-mention-48.3.1.tgz", + "integrity": "sha512-TdmXZ+NnBXdbMtbA6Yo93mZpTs0oz9HfN8jY9yEJXsECOC0XPC1+nghQQrxJ3a9+eq6qba/JNLLM+ZV0W6DOaA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-minimap": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-minimap/-/ckeditor5-minimap-48.3.1.tgz", + "integrity": "sha512-kDJfTRv31WrFisXBKzHNtKhAseiSsJKw1oWIPqqLIN9rnYXnEpjqa5BRgypfFbS7LW0wgjKvCOH/yHMyPrv64A==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-page-break": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-page-break/-/ckeditor5-page-break-48.3.1.tgz", + "integrity": "sha512-Wbsq6ZEOQN+zmfhMx05Ey0mda6qCj74j34yEZrnJ5nfFAoody7q3hJ7yJ9Edu10zF8+LXfC8+ebpaXQr77IvLg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-paragraph": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-paragraph/-/ckeditor5-paragraph-48.3.1.tgz", + "integrity": "sha512-TnfBhRiFMBbGoXaPOpUiG109IpUFjbdEQEtjlBfiB4bMG71J1pdVYBe+qN1kvr34cYgdR96P/wbUnqC4BZA6Jw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-paste-from-office": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-paste-from-office/-/ckeditor5-paste-from-office-48.3.1.tgz", + "integrity": "sha512-PAyMPmRRTY9MvvfhLdfPwgDOAlzm9u9C97aS7S/JbeKl2uys9uJ7nZRBC9v+9zf6eJMHx90ThKyVBtstKDC6LQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-remove-format": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-remove-format/-/ckeditor5-remove-format-48.3.1.tgz", + "integrity": "sha512-ZVuLTxUupAnoilnhC6Sjev1+qdzzSJxZ0h1h41boaRHSoqXGtnvXosb4MYZ416LiLYDBkFdL1yLJhhPOsWNXMg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-restricted-editing": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-restricted-editing/-/ckeditor5-restricted-editing-48.3.1.tgz", + "integrity": "sha512-53u38P5fpz/8qwanJtGYtgmi5Szqg+6VhqnbSyPWOWK8dlJAUi0aMJy/ihseFkUFt2AfaUZHlUW7WzpwttbDCw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-select-all": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-select-all/-/ckeditor5-select-all-48.3.1.tgz", + "integrity": "sha512-bPXjCzqNeroJxnpW+dHXtBb5vigap7cwANJ6LS9lvTbQGKk3Ocq6jO0RlhmJ9RvcuHwAMH60a+23zW3PJAOkAQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-show-blocks": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-show-blocks/-/ckeditor5-show-blocks-48.3.1.tgz", + "integrity": "sha512-Frzw96nYEET7ymiEUvMHjkJ/QClUfsGTd+jJufXaT9hUx7t1nONVQVhzIEIruy8dgqt8prIRqVpczD3mQS8T4Q==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-source-editing": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-source-editing/-/ckeditor5-source-editing-48.3.1.tgz", + "integrity": "sha512-20rfbBZgZjEYnFCpS69U27TKZq9ZLMzKCBSXv8Kmv4qObHGgZMBY+R5UsG1VpIRG5DdUjP9lBWgmCVkzlof1EA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-special-characters": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-special-characters/-/ckeditor5-special-characters-48.3.1.tgz", + "integrity": "sha512-gqfxywMLASyjJV7AvhV3WCX3QYuizjWG0SxUy7zOils00UjIeImfQW07X5u/FSi2GLeUsOjSfttsAbtJXQXHzg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-style": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-style/-/ckeditor5-style-48.3.1.tgz", + "integrity": "sha512-rYikWptU+1Kd4UbzZ0s04tO6CqlEoPzfX09jfdwWSNYLbgB9+CfrxnkbnFvfg3SCAgtFDactc07TBtxsH9+Eag==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-html-support": "48.3.1", + "@ckeditor/ckeditor5-list": "48.3.1", + "@ckeditor/ckeditor5-table": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-table": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-table/-/ckeditor5-table-48.3.1.tgz", + "integrity": "sha512-aoVzI5Srl5g0AP2XslxNugJFeogQGstk8JiCwPN1cRvtOb/e7pvMYBsAXhqdf3GFeq3E7HMw/fceXmDLlWJPgw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-typing": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-typing/-/ckeditor5-typing-48.3.1.tgz", + "integrity": "sha512-kBtgdIA9oWqrmTk24WRxaK/p91N00Mz0XE9/w7NDiOLAPBJNeBvt8Le4zOzyeLt8tn+DCP2h/BQg0t04oFveNQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-ui": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-ui/-/ckeditor5-ui-48.3.1.tgz", + "integrity": "sha512-bs0VgxH3xfs8B14it+5dNK9I5YIWDI27qxArJqmfDFbnVBVtxlaLtnntyykbrNWCck53LKZ2qCpDueVsCV2JmA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-editor-multi-root": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@types/color-convert": "2.0.4", + "color-convert": "3.1.0", + "color-parse": "2.0.2", + "es-toolkit": "1.45.1", + "vanilla-colorful": "0.7.2" + } + }, + "node_modules/@ckeditor/ckeditor5-undo": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-undo/-/ckeditor5-undo-48.3.1.tgz", + "integrity": "sha512-psJd40k7knNqfbdCaBc6D6cC88exr4Y6AwQihUn/9DaQv8vBjJDNsifVsSgfBemT7QIchUDIoJwYkF8hmUDT9Q==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-upload": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-upload/-/ckeditor5-upload-48.3.1.tgz", + "integrity": "sha512-6hEOB4rAgtbDhntBw7Vw1wyn6BxR03mXNSGaKHyJ+OCZ1FzNAcCCkHI+6CI5INpAiAp8ISXfzblKec+EB0iA0Q==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1" + } + }, + "node_modules/@ckeditor/ckeditor5-utils": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-utils/-/ckeditor5-utils-48.3.1.tgz", + "integrity": "sha512-hLZLjgwWSQKB3/a7AULSB5066PujeqwQiiUwU71ObxLGAHJA5EWD102Jp093BmiFvf5k8/hXFqOtUuaihDCXtg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-ui": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-watchdog": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-watchdog/-/ckeditor5-watchdog-48.3.1.tgz", + "integrity": "sha512-qv0D8GdaRdP9kM5LFYNNZTT3kN70jeEilZIA8wqLDf2+nIkz68xNZiwMbad9yrjGbMe2iQqrURTmML1/2+FRqA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-widget": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-widget/-/ckeditor5-widget-48.3.1.tgz", + "integrity": "sha512-2XrBEd0pz/aVmSlXYC/Q3vgGVLadtN2KK5T3euQv3MSWTZtA66ZQKeKLFwl5pNvv/ItnCHUDz/RMEAP1CqsfTg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@ckeditor/ckeditor5-word-count": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-word-count/-/ckeditor5-word-count-48.3.1.tgz", + "integrity": "sha512-ylAMJ0LNVJasul0cVbZFSIXK4tdZ/850NXPjtTiL63K//2bYfxRj3Xwh+mN14H4NX3ozkJhN2jaA9PU6Gmfs3Q==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "es-toolkit": "1.45.1" + } + }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@types/color-convert": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz", + "integrity": "sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==", + "license": "MIT", + "dependencies": { + "@types/color-name": "^1.1.0" + } + }, + "node_modules/@types/color-name": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.5.tgz", + "integrity": "sha512-j2K5UJqGTxeesj6oQuGpMgifpT5k9HprgQd8D1Y0lOFqKHl3PJu5GMeS4Y5EgjS55AE6OQxf8mPED9uaGbf4Cg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/blurhash": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ckeditor5": { + "version": "48.3.1", + "resolved": "https://registry.npmjs.org/ckeditor5/-/ckeditor5-48.3.1.tgz", + "integrity": "sha512-uuWdrM7mHVO0NsO3DTGDXp2zBkcuSYoS78+Ovpuci3B1pxzG7eHeLfeaNKIltWorhcWCxY4G+JTWOobcwflCTA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@ckeditor/ckeditor5-adapter-ckfinder": "48.3.1", + "@ckeditor/ckeditor5-alignment": "48.3.1", + "@ckeditor/ckeditor5-autoformat": "48.3.1", + "@ckeditor/ckeditor5-autosave": "48.3.1", + "@ckeditor/ckeditor5-basic-styles": "48.3.1", + "@ckeditor/ckeditor5-block-quote": "48.3.1", + "@ckeditor/ckeditor5-bookmark": "48.3.1", + "@ckeditor/ckeditor5-ckbox": "48.3.1", + "@ckeditor/ckeditor5-ckfinder": "48.3.1", + "@ckeditor/ckeditor5-clipboard": "48.3.1", + "@ckeditor/ckeditor5-cloud-services": "48.3.1", + "@ckeditor/ckeditor5-code-block": "48.3.1", + "@ckeditor/ckeditor5-core": "48.3.1", + "@ckeditor/ckeditor5-easy-image": "48.3.1", + "@ckeditor/ckeditor5-editor-balloon": "48.3.1", + "@ckeditor/ckeditor5-editor-classic": "48.3.1", + "@ckeditor/ckeditor5-editor-decoupled": "48.3.1", + "@ckeditor/ckeditor5-editor-inline": "48.3.1", + "@ckeditor/ckeditor5-editor-multi-root": "48.3.1", + "@ckeditor/ckeditor5-emoji": "48.3.1", + "@ckeditor/ckeditor5-engine": "48.3.1", + "@ckeditor/ckeditor5-enter": "48.3.1", + "@ckeditor/ckeditor5-essentials": "48.3.1", + "@ckeditor/ckeditor5-find-and-replace": "48.3.1", + "@ckeditor/ckeditor5-font": "48.3.1", + "@ckeditor/ckeditor5-fullscreen": "48.3.1", + "@ckeditor/ckeditor5-heading": "48.3.1", + "@ckeditor/ckeditor5-highlight": "48.3.1", + "@ckeditor/ckeditor5-horizontal-line": "48.3.1", + "@ckeditor/ckeditor5-html-embed": "48.3.1", + "@ckeditor/ckeditor5-html-support": "48.3.1", + "@ckeditor/ckeditor5-icons": "48.3.1", + "@ckeditor/ckeditor5-image": "48.3.1", + "@ckeditor/ckeditor5-indent": "48.3.1", + "@ckeditor/ckeditor5-language": "48.3.1", + "@ckeditor/ckeditor5-link": "48.3.1", + "@ckeditor/ckeditor5-list": "48.3.1", + "@ckeditor/ckeditor5-markdown-gfm": "48.3.1", + "@ckeditor/ckeditor5-media-embed": "48.3.1", + "@ckeditor/ckeditor5-mention": "48.3.1", + "@ckeditor/ckeditor5-minimap": "48.3.1", + "@ckeditor/ckeditor5-page-break": "48.3.1", + "@ckeditor/ckeditor5-paragraph": "48.3.1", + "@ckeditor/ckeditor5-paste-from-office": "48.3.1", + "@ckeditor/ckeditor5-remove-format": "48.3.1", + "@ckeditor/ckeditor5-restricted-editing": "48.3.1", + "@ckeditor/ckeditor5-select-all": "48.3.1", + "@ckeditor/ckeditor5-show-blocks": "48.3.1", + "@ckeditor/ckeditor5-source-editing": "48.3.1", + "@ckeditor/ckeditor5-special-characters": "48.3.1", + "@ckeditor/ckeditor5-style": "48.3.1", + "@ckeditor/ckeditor5-table": "48.3.1", + "@ckeditor/ckeditor5-typing": "48.3.1", + "@ckeditor/ckeditor5-ui": "48.3.1", + "@ckeditor/ckeditor5-undo": "48.3.1", + "@ckeditor/ckeditor5-upload": "48.3.1", + "@ckeditor/ckeditor5-utils": "48.3.1", + "@ckeditor/ckeditor5-watchdog": "48.3.1", + "@ckeditor/ckeditor5-widget": "48.3.1", + "@ckeditor/ckeditor5-word-count": "48.3.1" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.0.tgz", + "integrity": "sha512-TVoqAq8ZDIpK5lsQY874DDnu65CSsc9vzq0wLpNQ6UMBq81GSZocVazPiBbYGzngzBOIRahpkTzCLVe2at4MfA==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-parse": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.2.tgz", + "integrity": "sha512-eCtOz5w5ttWIUcaKLiktF+DxZO1R9KLNY/xhbV6CkhM7sR3GhVghmt6X6yOnzeaM24po+Z9/S1apbXMwA3Iepw==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "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/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-dom": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-dom/-/hast-util-to-dom-4.0.1.tgz", + "integrity": "sha512-z1VE7sZ8uFzS2baF3LEflX1IPw2gSzrdo3QFEsyoi23MkCVY3FoE9x6nLgOgjwJu8VNWgo+07iaxtONhDzKrUQ==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "property-information": "^7.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-mdast": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/hast-util-to-mdast/-/hast-util-to-mdast-10.1.2.tgz", + "integrity": "sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "hast-util-to-text": "^4.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "mdast-util-to-string": "^4.0.0", + "rehype-minify-whitespace": "^6.0.0", + "trim-trailing-lines": "^2.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.1.tgz", + "integrity": "sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, + "node_modules/rehype-dom-parse": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/rehype-dom-parse/-/rehype-dom-parse-5.0.2.tgz", + "integrity": "sha512-8CqP11KaqvtWsMqVEC2yM3cZWZsDNqqpr8nPvogjraLuh45stabgcpXadCAxu1n6JaUNJ/Xr3GIqXP7okbNqLg==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "unified": "^11.0.0" + } + }, + "node_modules/rehype-dom-stringify": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/rehype-dom-stringify/-/rehype-dom-stringify-4.0.2.tgz", + "integrity": "sha512-2HVFYbtmm5W3C2j8QsV9lcHdIMc2Yn/ytlPKcSC85/tRx2haZbU8V67Wxyh8STT38ZClvKlZ993Me/Hw8g88Aw==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-dom": "^4.0.0", + "unified": "^11.0.0" + } + }, + "node_modules/rehype-minify-whitespace": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.2.tgz", + "integrity": "sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-remark": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-remark/-/rehype-remark-10.0.1.tgz", + "integrity": "sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "hast-util-to-mdast": "^10.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-html": { + "version": "2.17.6", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz", + "integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "engines": { + "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", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trim-trailing-lines": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-2.1.0.tgz", + "integrity": "sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vanilla-colorful": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/vanilla-colorful/-/vanilla-colorful-0.7.2.tgz", + "integrity": "sha512-z2YZusTFC6KnLERx1cgoIRX2CjPRP0W75N+3CC6gbvdX5Ch47rZkEMGO2Xnf+IEmi3RiFLxS18gayMA27iU7Kg==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..13ad275 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "hengzhun-exam-system", + "version": "1.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.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", + "seed-test-data:sqlite": "node scripts/import-test-data.mjs --sqlite", + "seed-test-data:mysql": "node scripts/import-test-data.mjs --mysql", + "initialize-system": "node scripts/import-test-data.mjs --empty", + "initialize-system:sqlite": "node scripts/import-test-data.mjs --empty --sqlite", + "initialize-system:mysql": "node scripts/import-test-data.mjs --empty --mysql" + }, + "dependencies": { + "ckeditor5": "^48.3.1", + "exceljs": "^4.4.0", + "mysql2": "^3.14.2", + "qrcode": "1.5.4", + "redis": "^5.12.1", + "sanitize-html": "^2.17.6" + }, + "overrides": { + "exceljs": { + "uuid": "^11.1.1" + } + }, + "engines": { + "node": ">=22.5" + } +} diff --git a/scripts/build-regions.mjs b/scripts/build-regions.mjs new file mode 100644 index 0000000..0df0099 --- /dev/null +++ b/scripts/build-regions.mjs @@ -0,0 +1,55 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const input = resolve(process.argv[2] || 'ok_data_level3.csv'); +const output = resolve(process.argv[3] || 'src/data/china-regions.mjs'); + +function parseCsvLine(line) { + const values = []; + let value = ''; + let quoted = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (char === '"') { + if (quoted && line[index + 1] === '"') { value += '"'; index += 1; } + else quoted = !quoted; + } else if (char === ',' && !quoted) { + values.push(value); + value = ''; + } else value += char; + } + values.push(value); + return values; +} + +const source = await readFile(input, 'utf8'); +const rows = source.replace(/^\uFEFF/, '').trim().split(/\r?\n/).slice(1).map(line => { + const [id, pid, deep, , , , extId, extName] = parseCsvLine(line); + return { id, pid, deep: Number(deep), code: extId.slice(0, 6), name: extName }; +}); + +const provinces = rows.filter(item => item.deep === 0 && item.code !== '0').map(province => ({ + code: province.code, + name: province.name, + cities: rows.filter(city => city.deep === 1 && city.pid === province.id).map(city => ({ + code: city.code, + name: city.name, + districts: rows.filter(district => district.deep === 2 && district.pid === city.id).map(district => ({ + code: district.code, + name: district.name + })) + })) +})); + +const hotan = provinces.find(item => item.code === '650000')?.cities.find(item => item.code === '653200'); +for (const district of [ + { code: '653228', name: '和康县' }, + { code: '653229', name: '和安县' } +]) { + if (hotan && !hotan.districts.some(item => item.code === district.code)) hotan.districts.push(district); +} +hotan?.districts.sort((a, b) => a.code.localeCompare(b.code)); + +const banner = `// Generated from AreaCity-JsSpider-StatsGov release 2025.251231.260403.\n// Source snapshot: 国家地名信息库 2025-12-31; generated 2026-07-20.\n// Manual official additions: 和康县 653228, 和安县 653229.\n`; +await writeFile(output, `${banner}export const chinaRegionsVersion = '2025-12-31';\nexport const chinaRegions = ${JSON.stringify(provinces)};\n`, 'utf8'); +console.log(`Generated ${provinces.length} provinces at ${output}`); diff --git a/scripts/import-test-data.mjs b/scripts/import-test-data.mjs new file mode 100644 index 0000000..fb40c57 --- /dev/null +++ b/scripts/import-test-data.mjs @@ -0,0 +1,202 @@ +import { pbkdf2Sync, randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +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'); +if (existsSync(envPath)) loadEnvFile(envPath); + +const options = new Set(process.argv.slice(2)); +if (options.has('--mysql') && options.has('--sqlite')) throw new Error('不能同时指定 --mysql 和 --sqlite'); +const configuredClient = options.has('--mysql') ? 'mysql' : options.has('--sqlite') ? 'sqlite' : process.env.DATABASE_CLIENT; +const client = String(configuredClient || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase(); +if (!['sqlite', 'mysql'].includes(client)) throw new Error(`不支持的 DATABASE_CLIENT:${client}`); +process.env.DATABASE_CLIENT = client; +const force = options.has('--force'); +const initializeEmpty = options.has('--empty'); + +function hashPassword(password, salt = randomBytes(16).toString('hex')) { + const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex'); + return `${salt}:${hash}`; +} + +const createTargetState = () => initializeEmpty + ? 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 + } + }) + : createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword }); + +async function prepareSqlite() { + const databasePath = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite')); + const relativePath = relative(root, databasePath); + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + throw new Error('拒绝覆盖工作区以外的 SQLite 数据库'); + } + await rm(databasePath, { force: true }); + await rm(`${databasePath}-shm`, { force: true }); + await rm(`${databasePath}-wal`, { force: true }); + process.env.SQLITE_PATH = databasePath; + return databasePath; +} + +function mysqlPoolOptions(mysql) { + if (process.env.DATABASE_URL) return mysql.createPool(process.env.DATABASE_URL); + if (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !process.env.MYSQL_DATABASE) { + throw new Error('MySQL 配置不完整:请在 .env 设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE'); + } + return mysql.createPool({ + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT || 3306), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD || '', + database: process.env.MYSQL_DATABASE, + waitForConnections: true, + connectionLimit: 2, + charset: 'utf8mb4', + timezone: 'Z', + enableKeepAlive: true + }); +} + +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', 'admission_records', 'audit_logs' +]; + +async function prepareMysql() { + const { default: mysql } = await import('mysql2/promise'); + const pool = mysqlPoolOptions(mysql); + let connection; + try { + connection = await pool.getConnection(); + const [[databaseRow]] = await connection.query('SELECT DATABASE() AS name'); + const databaseName = String(databaseRow?.name || ''); + if (!databaseName || ['mysql', 'information_schema', 'performance_schema', 'sys'].includes(databaseName.toLowerCase())) { + throw new Error(`拒绝向系统数据库导入测试数据:${databaseName || '未选择数据库'}`); + } + + const [tableRows] = await connection.query(` + SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' + `); + const existingTables = new Set(tableRows.map(row => row.TABLE_NAME)); + const existingAppTables = relationalTables.filter(table => existingTables.has(table)); + if (!existingAppTables.length) return { location: `MySQL database ${databaseName}`, initialized: false }; + if (existingAppTables.length !== relationalTables.length) { + const missing = relationalTables.filter(table => !existingTables.has(table)); + throw new Error(`MySQL 数据库结构不完整,缺少:${missing.join(', ')}。请先使用空数据库启动一次应用完成建表`); + } + + const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1'); + 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( + 'SELECT candidates_table, admissions_table, results_table, centers_table FROM exam_data_partitions' + ); + const [schoolPartitionRows] = await connection.query('SELECT students_table FROM school_student_partitions'); + const dynamicPartitionTables = [ + ...examPartitionRows.flatMap(row => [row.candidates_table, row.admissions_table, row.results_table, row.centers_table]), + ...schoolPartitionRows.map(row => row.students_table) + ]; + if (dynamicPartitionTables.some(table => !/^[a-z][a-z0-9_]{0,63}$/.test(table))) { + throw new Error('分表登记中存在非法表名,拒绝替换测试数据'); + } + + const nonEmpty = []; + for (const table of mysqlBusinessTables) { + const [[row]] = await connection.query(`SELECT COUNT(*) AS count FROM \`${table}\``); + if (Number(row.count) > 0) nonEmpty.push(`${table}=${row.count}`); + } + const [[userRow]] = await connection.query('SELECT COUNT(*) AS count FROM users'); + if (Number(userRow.count) > 1) nonEmpty.push(`users=${userRow.count}`); + 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', 'school_hz9')"); + recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5; + } + if (nonEmpty.length && !force && !recognizedTestData) { + const forceCommand = initializeEmpty + ? 'npm run initialize-system:mysql -- --force' + : 'npm run seed-test-data:mysql -- --force'; + throw new Error( + `MySQL 数据库 ${databaseName} 已有业务数据(${nonEmpty.slice(0, 8).join(', ')}${nonEmpty.length > 8 ? ', ...' : ''})。` + + `如确认这是可覆盖的测试库,请运行 ${forceCommand}` + ); + } + if (nonEmpty.length) { + console.warn(`[${recognizedTestData ? 'test-data cleanup' : 'force'}] Replacing existing business data in MySQL database ${databaseName}`); + } else { + console.log(`Preparing empty MySQL database ${databaseName} for ${initializeEmpty ? 'system initialization' : 'test data'}`); + } + + const state = createTargetState(); + await connection.query('SET FOREIGN_KEY_CHECKS = 0'); + await connection.beginTransaction(); + try { + // 先移除考试,使归档成绩保护触发器在清理 results 时不再命中。 + const clearOrder = ['exams', ...[...relationalTables].reverse().filter(table => !['schema_metadata', 'exams'].includes(table))]; + for (const table of clearOrder) await connection.query(`DELETE FROM \`${table}\``); + for (const operation of buildSeedOperations(state)) await connection.execute(operation.sql, operation.params); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + await connection.query('SET FOREIGN_KEY_CHECKS = 1'); + } + for (const table of dynamicPartitionTables) await connection.query(`DROP TABLE IF EXISTS \`${table}\``); + return { location: `MySQL database ${databaseName}`, initialized: true }; + } finally { + connection?.release(); + await pool.end(); + } +} + +let location; +let mysqlAlreadyImported = false; +if (client === 'sqlite') { + location = await prepareSqlite(); +} else { + const prepared = await prepareMysql(); + location = prepared.location; + mysqlAlreadyImported = prepared.initialized; +} +const database = await createDatabase({ + root, + seed: createTargetState +}); +const state = await database.read(); +await database.close(); + +const pending = state.registrations.filter(item => item.status === 'pending').length; +const rejected = state.registrations.filter(item => item.status === 'rejected').length; +const unpaid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length; +const paid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'paid').length; +if (initializeEmpty) { + console.log(`Initialized empty system in ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`); + 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.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'); +} diff --git a/scripts/reset-dev-database.mjs b/scripts/reset-dev-database.mjs new file mode 100644 index 0000000..9492a52 --- /dev/null +++ b/scripts/reset-dev-database.mjs @@ -0,0 +1,4 @@ +// 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'); diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..36c63f1 --- /dev/null +++ b/server.mjs @@ -0,0 +1,1042 @@ +import { createServer } from 'node:http'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { extname, join, normalize, resolve } from 'node:path'; +import { loadEnvFile } from 'node:process'; +import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto'; +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'; +const publicSiteConfig = Object.freeze({ + organization: { + name: process.env.PUBLIC_SITE_NAME || '考试服务平台', + code: process.env.PUBLIC_SITE_CODE || 'EXAM-SERVICE', + phone: process.env.PUBLIC_SITE_PHONE || '', + address: process.env.PUBLIC_SITE_ADDRESS || '', + email: process.env.PUBLIC_SITE_EMAIL || '' + }, + heroEyebrow: process.env.PUBLIC_SITE_HERO_EYEBROW || 'EXAMINATION SERVICE', + heroTitle: process.env.PUBLIC_SITE_HERO_TITLE || '一个报名号,', + heroHighlight: process.env.PUBLIC_SITE_HERO_HIGHLIGHT || '贯穿每一次考试。', + heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。', + footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || '' +}); +const staticFiles = new Set([ + '/index.html', + '/styles.css', + '/app.js', + '/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/specialty-types.mjs' +]); +const vendorStaticFiles = new Map([ + ['/vendor/ckeditor5/ckeditor5.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.js')], + ['/vendor/ckeditor5/ckeditor5.css', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.css')], + ['/vendor/ckeditor5/translations/zh-cn.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'translations', 'zh-cn.js')] +]); +const mimeTypes = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml' +}; + +function nowIso() { + return new Date().toISOString(); +} + +function uid(prefix) { + return `${prefix}_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`; +} + +function hashPassword(password, salt = randomBytes(16).toString('hex')) { + const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex'); + return `${salt}:${hash}`; +} + +function verifyPassword(password, stored) { + const [salt, expected] = String(stored).split(':'); + if (!salt || !expected) return false; + const actual = pbkdf2Sync(password, salt, 120000, 32, 'sha256'); + const expectedBuffer = Buffer.from(expected, 'hex'); + return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer); +} + +const initializeDatabase = () => createBaseDatabase({ + nowIso, + hashPassword, + initialAdmin: { + username: process.env.INITIAL_ADMIN_USERNAME, + password: process.env.INITIAL_ADMIN_PASSWORD, + displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME + } +}); +const persistentDatabase = await createDatabase({ root, seed: initializeDatabase }); +const cache = await createRedisCache(); +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]; + if (resultCacheWriteMethods.has(method) + || (['createWorkflow', 'processWorkflow', 'transferWorkflow'].includes(method) && instance?.businessType === 'score_appeal') + || (method === 'saveWorkflow' && instance?.businessType === 'score_appeal')) { + namespaces.push('results'); + } + 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({ authState, readDb, sendError }); +const requirePermission = createPermissionGuard(sendError); + +function adminsForStep(db, adminLevel, profile) { + return db.users.filter(item => { + if (item.role !== 'admin' || !item.active || item.adminLevel !== adminLevel) return false; + if (adminLevel === 'super') return true; + if (adminLevel === 'school') return Boolean(profile?.schoolId && item.schoolId === profile.schoolId); + if (adminLevel === 'class') return Boolean( + profile?.schoolId && profile?.classId + && item.schoolId === profile.schoolId && item.classId === profile.classId + ); + return false; + }); +} + +function selectAdminForStep(db, adminLevel, profile) { + const pendingByAdmin = new Map(); + for (const instance of db.workflowInstances) { + if (instance.status !== 'pending' || !instance.assigneeId) continue; + pendingByAdmin.set(instance.assigneeId, (pendingByAdmin.get(instance.assigneeId) || 0) + 1); + } + const assignedByAdmin = new Map(); + for (const action of db.workflowActions) { + if (!action.toAssigneeId) continue; + assignedByAdmin.set(action.toAssigneeId, (assignedByAdmin.get(action.toAssigneeId) || 0) + 1); + } + return adminsForStep(db, adminLevel, profile).sort((left, right) => + (pendingByAdmin.get(left.id) || 0) - (pendingByAdmin.get(right.id) || 0) + || (assignedByAdmin.get(left.id) || 0) - (assignedByAdmin.get(right.id) || 0) + || String(left.createdAt || '').localeCompare(String(right.createdAt || '')) + || left.id.localeCompare(right.id) + )[0] || null; +} + +function activeWorkflow(db, businessType) { + return db.workflows.find(item => item.businessType === businessType && item.active); +} + +function createWorkflowSubmission(db, businessType, businessId, profile, actorId = null) { + const workflow = activeWorkflow(db, businessType); + if (!workflow?.steps.length) throw Object.assign(new Error('该业务尚未配置审批流程'), { status: 409 }); + const firstStep = workflow.steps[0]; + const assignee = selectAdminForStep(db, firstStep.adminLevel, profile); + if (!assignee) throw Object.assign(new Error(`没有可承接“${firstStep.name}”的${adminLevelNames[firstStep.adminLevel]}`), { status: 409 }); + const instance = { + id: uid('flow'), workflowId: workflow.id, businessType, businessId, status: 'pending', currentStep: 1, + assigneeId: assignee.id, createdAt: nowIso(), completedAt: null + }; + const action = { + id: uid('flow_action'), instanceId: instance.id, actorId, action: 'submit', note: '提交审批', + fromAssigneeId: null, toAssigneeId: assignee.id, createdAt: nowIso() + }; + return { workflow, instance, action }; +} + +function workflowView(db, instance) { + if (!instance) return null; + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const assignee = db.users.find(item => item.id === instance.assigneeId); + const actions = db.workflowActions.filter(item => item.instanceId === instance.id).map(item => ({ + ...item, + actorName: db.users.find(user => user.id === item.actorId)?.displayName || '系统', + fromAssigneeName: db.users.find(user => user.id === item.fromAssigneeId)?.displayName || '', + toAssigneeName: db.users.find(user => user.id === item.toAssigneeId)?.displayName || '' + })); + return { + ...instance, + workflowName: workflow?.name || '未命名流程', + steps: workflow?.steps || [], + currentStepDetail: workflow?.steps.find(step => step.position === instance.currentStep) || null, + assignee: assignee ? safeUser(assignee) : null, + actions + }; +} + +function pendingWorkflow(db, businessType, businessId) { + return db.workflowInstances.find(item => item.businessType === businessType && item.businessId === businessId && item.status === 'pending'); +} + +function candidateSequence(db, rule, schoolId, year) { + const prefixParts = rule.segments.filter(item => item.type !== 'sequence').map(segment => segment.type === 'year' ? year : segment.type === 'school_code' ? db.schools.find(school => school.id === schoolId)?.code || '' : '').filter(Boolean); + const prefix = prefixParts.join(rule.separator); + return db.users.filter(item => item.role === 'candidate' && item.candidateNumber && (!prefix || item.candidateNumber.startsWith(prefix))).length + 1; +} + +function generateCandidateNumber(db, profile, year = String(new Date().getFullYear())) { + const rule = db.numberRules.find(item => item.active); + if (!rule?.segments.length) throw Object.assign(new Error('尚未配置可用的报名号生成规则'), { status: 409 }); + const school = db.schools.find(item => item.id === profile.schoolId); + const sequence = candidateSequence(db, rule, profile.schoolId, year); + const parts = rule.segments.map(segment => { + if (segment.type === 'year') return year.slice(-Math.max(2, segment.width || 4)); + if (segment.type === 'school_code') return school?.code || 'NOSCHOOL'; + if (segment.type === 'gender') return profile.gender === '男' ? 'M' : profile.gender === '女' ? 'F' : 'X'; + if (segment.type === 'sequence') return String(sequence).padStart(Math.max(1, segment.width || 4), '0'); + return cleanText(segment.value, 20).toUpperCase(); + }); + return { number: parts.join(rule.separator), ruleId: rule.id }; +} + +function cleanText(value, max = 200) { + return String(value ?? '').trim().slice(0, max); +} + +function centerScopeProfile(db, schoolId) { + const school = db.schools.find(item => item.id === schoolId); + return { schoolId, classId: null, school: school?.name || '', grade: '' }; +} + +function workflowScopeProfile(db, instance) { + if (instance.businessType === 'profile_change') return db.candidateProfiles.find(item => item.id === instance.businessId) || null; + if (instance.businessType === 'registration_review') { + const registration = db.registrations.find(item => item.id === instance.businessId); + return db.candidateProfiles.find(item => item.userId === registration?.userId) || null; + } + if (instance.businessType === 'center_change') { + const change = db.centerChangeRequests.find(item => item.id === instance.businessId); + return change ? centerScopeProfile(db, change.schoolId) : null; + } + if (instance.businessType === 'candidate_account_batch') { + const batch = db.candidateAccountBatches.find(item => item.id === instance.businessId); + return batch ? centerScopeProfile(db, batch.schoolId) : null; + } + if (instance.businessType === 'score_appeal') { + const result = db.results.find(item => item.id === instance.businessId); + const registration = db.registrations.find(item => item.id === result?.registrationId); + return db.candidateProfiles.find(item => item.userId === registration?.userId) || null; + } + return null; +} + +function candidateAccountBatchView(db, batch) { + const items = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position); + const quotaMap = new Map(); + for (const item of items) quotaMap.set(item.classId, (quotaMap.get(item.classId) || 0) + 1); + const instance = db.workflowInstances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batch.id); + return { + ...batch, + schoolName: db.schools.find(item => item.id === batch.schoolId)?.name || '', + requesterName: db.users.find(item => item.id === batch.requestedBy)?.displayName || '原提交人', + totalCount: items.length, + quotas: [...quotaMap.entries()].map(([classId, count]) => { + const schoolClass = db.classes.find(item => item.id === classId); + return { classId, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '', count }; + }), + items: items.map(item => { + const schoolClass = db.classes.find(entry => entry.id === item.classId); + return { ...item, className: schoolClass?.name || '未知班级', grade: schoolClass?.grade || '' }; + }), + workflow: workflowView(db, instance) + }; +} + +function centerChangeView(db, change) { + const instance = db.workflowInstances.find(item => item.businessType === 'center_change' && item.businessId === change.id); + return { + ...change, + schoolName: db.schools.find(item => item.id === change.schoolId)?.name || '', + rooms: db.centerChangeRooms.filter(item => item.requestId === change.id), + workflow: workflowView(db, instance) + }; +} + +function parseCenterChange(db, body, schoolId, center = null) { + const code = cleanText(body.code, 30).toUpperCase(); + const name = cleanText(body.name, 100); + const address = cleanText(body.address, 200); + const region = resolveRegion(body); + const rooms = Array.isArray(body.rooms) ? body.rooms : []; + if (!code || !name || !address || !region) throw Object.assign(new Error('请填写考点代码、名称、省市区县和详细地址'), { status: 400 }); + if (!rooms.length) throw Object.assign(new Error('请至少配置一个结构化考场'), { status: 400 }); + const duplicateCenter = db.testCenters.some(item => item.code.toUpperCase() === code && item.id !== center?.id) + || db.centerChangeRequests.some(item => item.status === 'pending' && item.code.toUpperCase() === code && item.centerId !== center?.id); + if (duplicateCenter) throw Object.assign(new Error('考点代码已被正式档案或待审批申请占用'), { status: 409 }); + const roomCodes = new Set(); + const normalizedRooms = rooms.map((room, index) => { + const roomCode = cleanText(room.code, 30).toUpperCase(); + const roomName = cleanText(room.name, 80); + const building = cleanText(room.building, 80); + const capacity = Number(room.capacity); + if (!roomCode || !roomName || !building || !Number.isInteger(capacity) || capacity < 1) { + throw Object.assign(new Error(`第 ${index + 1} 个考场的代码、名称、楼栋或容量无效`), { status: 400 }); + } + if (roomCodes.has(roomCode)) throw Object.assign(new Error(`考场代码 ${roomCode} 重复`), { status: 400 }); + roomCodes.add(roomCode); + return { + id: uid('change_room'), roomId: cleanText(room.id, 64) || null, code: roomCode, name: roomName, + building, floor: cleanText(room.floor, 30), capacity, seatPlan: cleanText(room.seatPlan, 500), seatStart: 1, seatEnd: capacity, + roomType: ['standard', 'computer', 'accessible', 'spare'].includes(room.roomType) ? room.roomType : 'standard', + status: room.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(room.notes, 300) + }; + }); + return { + center: { + schoolId, code, name, ...region, address, contact: cleanText(body.contact, 80), managerName: cleanText(body.managerName, 50), + managerPhone: cleanText(body.managerPhone, 30), emergencyPhone: cleanText(body.emergencyPhone, 30), + gateOpenTime: cleanText(body.gateOpenTime, 20), transport: cleanText(body.transport, 500), + centerStatus: body.status === 'inactive' ? 'inactive' : 'active', notes: cleanText(body.notes, 1000) + }, + rooms: normalizedRooms + }; +} + +function maskId(value) { + const text = String(value || ''); + return text.length > 8 ? `${text.slice(0, 4)}********${text.slice(-4)}` : text; +} + +function publicExam(exam) { + const now = Date.now(); + const start = new Date(exam.registrationStart).getTime(); + const end = new Date(exam.registrationEnd).getTime(); + return { + ...exam, + totalScore: exam.subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0), + registrationState: exam.archivedAt ? 'archived' : now < start ? 'upcoming' : now > end ? 'closed' : 'open' + }; +} + +function subjectPassThreshold(subject) { + const rule = subject?.passRule || 'fixed_score'; + if (rule !== 'fixed_score') return null; + const value = Number(subject?.passValue ?? subject?.passScore ?? 0); + return Number(value.toFixed(2)); +} + +function subjectPassText(subject) { + const rule = subject?.passRule || 'fixed_score'; + if (rule === 'none') return '不设单科线'; + if (rule === 'rank_percent') return `本科排名前 ${Number(subject.passValue ?? 60)}% 达线`; + return `固定 ${subjectPassThreshold(subject)} 分`; +} + +function gradeForRank(rank, cohortSize) { + const cutoff = ratio => Math.max(1, Math.ceil(cohortSize * ratio)); + if (rank <= cutoff(.1)) return 'A+'; + if (rank <= cutoff(.25)) return 'A'; + if (rank <= cutoff(.5)) return 'B+'; + if (rank <= cutoff(.7)) return 'B'; + if (rank <= cutoff(.9)) return 'C'; + 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 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) }; +} + +function subjectPassEvaluation(db, result, subject, scoreOverride = result?.score) { + const rule = subject?.passRule || 'fixed_score'; + if (rule === 'none') return { qualified: null, passScore: null, cutoffRank: null, ...resultRankInfo(db, result, scoreOverride) }; + if (rule === 'rank_percent') { + const rankInfo = resultRankInfo(db, result, scoreOverride); + const cutoffRank = Math.max(1, Math.ceil(rankInfo.cohortSize * Number(subject.passValue ?? 60) / 100)); + return { ...rankInfo, qualified: rankInfo.rank <= cutoffRank, passScore: null, cutoffRank }; + } + const passScore = subjectPassThreshold(subject); + 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 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); + let qualified = null; + let rank = null; + let cohortSize = null; + + if (complete && policy === 'fixed_score') qualified = total >= value; + if (complete && policy === 'subject_scores') qualified = subjects.every(subject => { + const subjectResult = resultsBySubject.get(subject.id); + return subjectPassEvaluation(db, subjectResult, subject).qualified !== false; + }); + if (complete && policy === 'none') qualified = null; + if (complete && policy === 'rank_percent') { + const totals = summaryIndexes.totalsByCohort.get(cohortKey) || []; + cohortSize = totals.length; + rank = 1 + countScoresGreaterThan(totals, total); + qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100)); + } + + return { + examId: exam.id, + examName: exam.name, + examCode: exam.code, + examStart: exam.examStart, + archivedAt: exam.archivedAt || null, + complete, + publishedSubjects: published.length, + subjectCount: subjects.length, + featureScore: Number(registration.featureScore || 0), + total, + fullScore, + scoreRatio: Number(scoreRatio.toFixed(2)), + passPolicy: policy, + passValue: value, + qualified, + rank, + cohortSize + }; +} + +function examRegistrationView(db, registration) { + const exam = db.exams.find(item => item.id === registration.examId); + const subjects = (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)); + const instance = db.workflowInstances.find(item => item.businessType === 'registration_review' && item.businessId === registration.id && item.status === 'pending') + || db.workflowInstances.filter(item => item.businessType === 'registration_review' && item.businessId === registration.id)[0]; + return { + ...registration, + exam, + subjects, + amountDue: Number(subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)), + paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || '', + workflow: workflowView(db, instance) + }; +} + +function logAction(db, user, action, detail) { + const log = { id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() }; + db.auditLogs.unshift(log); + db.auditLogs = db.auditLogs.slice(0, 200); + return log; +} + +const excelResourceNames = { + classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额', + account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账', + admit_cards: '准考证信息台账', admitted_candidates: '录取考生信息' +}; + +function admissionRowsForRegistrations(db, registrations) { + return registrations.flatMap(registration => { + 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) || { subjects: [] }; + const school = db.schools.find(item => item.id === profile.schoolId); + const schoolClass = db.classes.find(item => item.id === profile.classId); + const card = registration.admitCard; + if (!card) return []; + const center = db.testCenters.find(item => item.id === card.centerId) || {}; + return (card.assignments || []).map(assignment => { + const subject = exam.subjects.find(item => item.id === assignment.subjectId) || {}; + const room = db.testRooms.find(item => item.id === assignment.roomId) || {}; + return { + schoolName: school?.name || profile.school || '', className: schoolClass?.name || profile.grade || '', + candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '', idNumber: profile.idNumber || '', + examCode: exam.code || '', examName: exam.name || '', cardNumber: card.number || '', + centerCode: card.centerCode || center.code || '', centerName: card.testCenter || center.name || '', + centerAddress: card.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '), + subjectName: subject.name || '', subjectDate: subject.date || '', subjectTime: [subject.start, subject.end].filter(Boolean).join('—'), + examRoomCode: assignment.examRoomCode || '', roomName: assignment.roomName || assignment.room || room.name || '', + roomCode: assignment.roomCode || room.code || '', building: assignment.building || room.building || '', + floor: assignment.floor || room.floor || '', seat: assignment.seat || '' + }; + }); + }); +} + +function centerMaterialRows(db, schoolId, examId) { + const centerIds = new Set(db.testCenters.filter(item => item.schoolId === schoolId).map(item => item.id)); + const registrations = db.registrations.filter(item => item.admitCard && item.examId === examId && centerIds.has(item.admitCard.centerId)); + return admissionRowsForRegistrations(db, registrations); +} + +function excelRowsForResource(db, user, resource, searchParams) { + const schools = user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId); + if (resource === 'classes') return db.classes.filter(item => schools.some(school => school.id === item.schoolId)).map(item => ({ + schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', grade: item.grade, name: item.name, status: item.active ? '启用' : '停用' + })); + if (resource === 'class_admins') return db.users.filter(item => item.role === 'admin' && item.adminLevel === 'class' && schools.some(school => school.id === item.schoolId)).map(item => ({ + schoolCode: db.schools.find(school => school.id === item.schoolId)?.code || '', + className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', displayName: item.displayName, + username: item.username, initialPassword: '', status: item.active ? '启用' : '停用' + })); + if (resource === 'account_quotas') return db.classes.filter(item => item.active && schools.some(school => school.id === item.schoolId)).map(item => ({ className: item.name, count: 0 })); + if (resource === 'account_results') { + const batchId = cleanText(searchParams.get('batchId'), 64); + const batch = db.candidateAccountBatches.find(item => item.id === batchId && schools.some(school => school.id === item.schoolId)); + if (!batch) throw Object.assign(new Error('批次不存在或不在当前学校范围内'), { status: 404 }); + return db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position).map(item => ({ + batchId: batch.id, className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || '', candidateNumber: item.candidateNumber, initialPassword: item.initialPassword + })); + } + if (resource === 'candidates') return db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => ({ + candidateNumber: db.users.find(item => item.id === profile.userId)?.candidateNumber || '', name: profile.name, gender: profile.gender, + idNumber: profile.idNumber.startsWith('PENDING-') ? '' : profile.idNumber, phone: profile.phone, email: profile.email, + nativePlace: profile.nativePlace, provinceCode: profile.provinceCode, provinceName: profile.provinceName, + cityCode: profile.cityCode, cityName: profile.cityName, districtCode: profile.districtCode, districtName: profile.districtName, + address: profile.address, className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade, + ethnicity: profile.ethnicity, birthDate: profile.birthDate, postalCode: profile.postalCode, guardianName: profile.guardianName, guardianPhone: profile.guardianPhone + })); + if (resource === 'payments') return db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item)).map(registration => { + 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) || { subjects: [] }; + const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); + return { + examCode: exam.code || '', examName: exam.name || '', + schoolName: db.schools.find(item => item.id === profile.schoolId)?.name || profile.school || '', + className: db.classes.find(item => item.id === profile.classId)?.name || profile.grade || '', + candidateNumber: account.candidateNumber || registration.registrationNumber || '', candidateName: profile.name || account.displayName || '', + subjectNames: subjects.map(subject => subject.name).join('、'), + amountDue: Number(subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)), + paymentStatus: registration.paymentStatus === 'paid' ? '已缴费' : '待缴费', paidAt: registration.paidAt || '', + paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || '' + }; + }); + if (resource === 'centers') return db.testCenters.filter(center => schools.some(school => school.id === center.schoolId)).flatMap(center => { + const rooms = db.testRooms.filter(room => room.centerId === center.id); + return (rooms.length ? rooms : [{}]).map(room => ({ + schoolCode: db.schools.find(school => school.id === center.schoolId)?.code || '', centerCode: center.code, centerName: center.name, + provinceCode: center.provinceCode, provinceName: center.provinceName, cityCode: center.cityCode, cityName: center.cityName, + districtCode: center.districtCode, districtName: center.districtName, address: center.address, + managerName: center.managerName, managerPhone: center.managerPhone, contact: center.contact, + emergencyPhone: center.emergencyPhone, gateOpenTime: center.gateOpenTime, transport: center.transport, + centerStatus: center.status === 'inactive' ? '停用' : '启用', centerNotes: center.notes, + roomCode: room.code || '', roomName: room.name || '', building: room.building || '', floor: room.floor || '', capacity: room.capacity || '', + seatPlan: room.seatPlan || '', roomType: ({ standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' })[room.roomType] || '', + roomStatus: room.status === 'inactive' ? '停用' : '启用', roomNotes: room.notes || '' + })); + }); + if (resource === 'results') { + const examId = cleanText(searchParams.get('examId'), 64); + 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 account = db.users.find(item => item.id === registration?.userId); + const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); + 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') { + const examId = cleanText(searchParams.get('examId'), 64); + const scoped = db.registrations.filter(item => item.admitCard && registrationInScope(db, user, item) && (!examId || item.examId === examId)); + return admissionRowsForRegistrations(db, scoped); + } + return []; +} + +function excelImportError(row, message) { + return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 }); +} + +function prepareResultImport(db, rows) { + const normalized = []; + const errors = []; + const seen = new Set(); + for (const source of Array.isArray(rows) ? rows : []) { + const sourceRow = Number(source.__row || source.sourceRow || normalized.length + 3); + const candidateNumber = cleanText(source.candidateNumber, 120); + const examCode = cleanText(source.examCode, 60); + const subjectName = cleanText(source.subjectName, 50); + const account = db.users.find(item => item.candidateNumber === candidateNumber); + const exam = db.exams.find(item => item.code.toUpperCase() === examCode.toUpperCase()); + const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved'); + const subject = exam?.subjects.find(item => item.name.toLowerCase() === subjectName.toLowerCase()); + const score = source.score == null || String(source.score).trim() === '' ? Number.NaN : Number(source.score); + const rowErrors = []; + if (!candidateNumber) rowErrors.push('报名号不能为空'); + else if (!account) rowErrors.push('报名号不存在'); + if (!examCode) rowErrors.push('考试代码不能为空'); + else if (!exam) rowErrors.push('考试代码不存在'); + else if (exam.archivedAt) rowErrors.push('该考试已归档,成绩已永久锁定'); + if (!subjectName) rowErrors.push('科目不能为空'); + else if (exam && !subject) rowErrors.push('该考试中不存在此科目'); + if (exam && account && !registration) rowErrors.push('该考生没有已通过的本场考试报名'); + if (registration && subject && !registration.subjectIds.includes(subject.id)) rowErrors.push('该考生未报考此科目'); + if (!Number.isFinite(score) || score < 0 || (subject && score > Number(subject.fullScore))) rowErrors.push(`成绩须在 0—${subject?.fullScore ?? 0} 之间`); + const publishText = typeof source.published === 'boolean' ? (source.published ? '发布' : '不发布') : cleanText(source.published, 20) || '不发布'; + if (!['发布', '不发布'].includes(publishText)) rowErrors.push('发布状态只能是“发布”或“不发布”'); + const key = registration && subject ? `${registration.id}|${subject.id}` : `${candidateNumber}|${examCode}|${subjectName}`; + if (seen.has(key)) rowErrors.push('同一考生、考试和科目在文件中重复'); + seen.add(key); + const existing = registration && subject ? db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id) : null; + const evaluation = subject ? subjectPassEvaluation(db, existing || { id: `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, subject, score) : null; + const profile = db.candidateProfiles.find(item => item.userId === account?.id); + const schoolClass = db.classes.find(item => item.id === profile?.classId); + const rank = subject && Number.isFinite(score) ? resultRankInfo(db, { id: existing?.id || `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, score) : null; + const row = { + sourceRow, candidateNumber, candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '', + examId: exam?.id || '', examCode, examName: exam?.name || '', registrationId: registration?.id || '', + subjectId: subject?.id || '', subjectName, fullScore: subject?.fullScore ?? null, + passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore ?? null, + passScore: evaluation?.passScore ?? null, passText: subject ? subjectPassText(subject) : '', score, + rank: rank?.rank ?? null, cohortSize: rank?.cohortSize ?? null, rankPercent: rank?.rankPercent ?? null, + qualified: !Number.isFinite(score) ? null : evaluation?.qualified ?? null, + grade: rank?.grade || '', + published: publishText === '发布', existingResultId: existing?.id || '', mode: existing ? 'update' : 'create', errors: rowErrors + }; + normalized.push(row); + errors.push(...rowErrors.map(message => ({ row: sourceRow, message }))); + } + const previewResults = db.results.map(item => ({ ...item })); + for (const row of normalized.filter(item => !item.errors.length)) { + const existingIndex = row.existingResultId ? previewResults.findIndex(item => item.id === row.existingResultId) : -1; + const previewResult = { + ...(existingIndex >= 0 ? previewResults[existingIndex] : {}), + id: row.existingResultId || `preview-${row.sourceRow}`, + registrationId: row.registrationId, + subjectId: row.subjectId, + score: row.score, + published: row.published + }; + if (existingIndex >= 0) previewResults[existingIndex] = previewResult; + else previewResults.push(previewResult); + } + const previewDb = { ...db, results: previewResults }; + for (const row of normalized.filter(item => !item.errors.length)) { + const previewResult = previewResults.find(item => item.id === (row.existingResultId || `preview-${row.sourceRow}`)); + const subject = db.exams.find(item => item.id === row.examId)?.subjects.find(item => item.id === row.subjectId); + const rank = resultRankInfo(previewDb, previewResult, row.score); + const evaluation = subjectPassEvaluation(previewDb, previewResult, subject, row.score); + Object.assign(row, { rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent, grade: row.published ? rank.grade : '待发布', passScore: evaluation.passScore, cutoffRank: evaluation.cutoffRank, qualified: evaluation.qualified }); + } + return { + rows: normalized, + errors, + summary: { + total: normalized.length, + valid: normalized.filter(item => !item.errors.length).length, + invalid: normalized.filter(item => item.errors.length).length, + create: normalized.filter(item => !item.errors.length && item.mode === 'create').length, + update: normalized.filter(item => !item.errors.length && item.mode === 'update').length, + publish: normalized.filter(item => !item.errors.length && item.published).length + } + }; +} + +async function commitResultImport(db, user, rows) { + if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以批量提交成绩'), { status: 403 }); + const prepared = prepareResultImport(db, rows); + if (prepared.errors.length) { + const first = prepared.errors[0]; + throw Object.assign(new Error(`第 ${first.row} 行:${first.message};请返回预览修正后重试`), { status: 400 }); + } + const entries = prepared.rows.map(row => { + const existing = row.existingResultId ? db.results.find(item => item.id === row.existingResultId) : null; + const result = existing || { id: uid('result'), registrationId: row.registrationId, subjectId: row.subjectId }; + Object.assign(result, { + score: row.score, grade: row.grade, published: row.published, updatedAt: nowIso(), + publishedAt: row.published ? (existing?.publishedAt || nowIso()) : null + }); + const log = logAction(db, user, row.published ? 'Excel 批量发布成绩' : 'Excel 批量保存成绩', `${row.candidateNumber} · ${row.examName} · ${row.subjectName} · ${row.score}`); + return { result, isNew: !existing, log }; + }); + await database.saveResults(entries); + for (const entry of entries) if (entry.isNew) db.results.push(entry.result); + return { count: entries.length, summary: prepared.summary }; +} + +async function importExcelResource(db, user, resource, rows) { + if (resource === 'classes') { + if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 }); + for (const row of rows) { + const school = db.schools.find(item => item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); + if (!school || (user.adminLevel === 'school' && school.id !== user.schoolId)) throw excelImportError(row, '学校代码无效或不在管理范围内'); + const name = cleanText(row.name, 100); const grade = cleanText(row.grade, 60); + if (!name || !grade) throw excelImportError(row, '年级和班级名称不能为空'); + const existing = db.classes.find(item => item.schoolId === school.id && item.name === name); + const schoolClass = existing || { id: uid('class'), schoolId: school.id }; + Object.assign(schoolClass, { name, grade, active: row.status !== '停用' }); + await database.saveSchoolClass(schoolClass, !existing, logAction(db, user, existing ? 'Excel 更新班级' : 'Excel 新增班级', `${school.name} · ${name}`)); + if (!existing) db.classes.push(schoolClass); + } + return { count: rows.length }; + } + if (resource === 'class_admins') { + if (user.adminLevel !== 'school') throw Object.assign(new Error('班级管理员 Excel 导入由校级管理员执行'), { status: 403 }); + for (const row of rows) { + const school = db.schools.find(item => item.id === user.schoolId && item.code.toUpperCase() === String(row.schoolCode).toUpperCase()); + const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100)); + if (!school || !schoolClass) throw excelImportError(row, '学校代码或班级名称无效'); + const username = cleanText(row.username, 50); const displayName = cleanText(row.displayName, 50); const password = String(row.initialPassword || ''); + if (!username || !displayName) throw excelImportError(row, '管理员姓名和登录账号不能为空'); + const existing = db.users.find(item => item.username.toLowerCase() === username.toLowerCase()); + if (existing && (existing.adminLevel !== 'class' || existing.schoolId !== user.schoolId)) throw excelImportError(row, '登录账号已被其他用户占用'); + if (!existing && password.length < 8) throw excelImportError(row, '新建管理员的初始密码至少 8 位'); + if (existing) { + Object.assign(existing, { displayName, classId: schoolClass.id, active: row.status !== '停用' }); + if (password) existing.passwordHash = hashPassword(password); + await database.updateAdmin(existing, Boolean(password), logAction(db, user, 'Excel 更新班级管理员', `${displayName} · ${schoolClass.name}`)); + } else { + const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel: 'class', schoolId: user.schoolId, classId: schoolClass.id, displayName, active: row.status !== '停用', createdAt: nowIso() }; + await database.createAdmin(created, logAction(db, user, 'Excel 创建班级管理员', `${displayName} · ${schoolClass.name}`)); + db.users.push(created); + } + } + return { count: rows.length }; + } + if (resource === 'account_quotas') { + if (user.adminLevel !== 'school') throw Object.assign(new Error('班级配额模板仅供校级管理员使用'), { status: 403 }); + const quotas = rows.filter(row => Number(row.count) > 0).map(row => { + const schoolClass = db.classes.find(item => item.schoolId === user.schoolId && item.name === cleanText(row.className, 100) && item.active); + if (!schoolClass || !Number.isInteger(Number(row.count)) || Number(row.count) < 1 || Number(row.count) > 200) throw excelImportError(row, '班级不存在,或申领数量不在 1—200 之间'); + return { classId: schoolClass.id, className: schoolClass.name, count: Number(row.count) }; + }); + if (!quotas.length) throw Object.assign(new Error('模板中没有大于 0 的申领数量'), { status: 400 }); + return { count: quotas.length, quotas }; + } + if (resource === 'candidates') { + if (!hasPermission(user, 'candidates.write')) throw Object.assign(new Error('当前账号不能导入考生资料'), { status: 403 }); + for (const row of rows) { + const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120)); + const profile = db.candidateProfiles.find(item => item.userId === account?.id); + if (!account || !profile || !profileInScope(user, profile)) throw excelImportError(row, '报名号不存在或不在数据范围内'); + if (pendingWorkflow(db, 'profile_change', profile.id)) throw excelImportError(row, '该考生已有待审批资料流程'); + const schoolClass = db.classes.find(item => item.schoolId === profile.schoolId && item.name === cleanText(row.className, 100)); + if (!schoolClass) throw excelImportError(row, '班级名称无效'); + const required = ['name', 'gender', 'idNumber', 'phone']; + if (required.some(key => !cleanText(row[key], 200))) throw excelImportError(row, '姓名、性别、证件号码和手机号必填'); + const region = resolveRegion(row); + if (!region) throw excelImportError(row, '省、市或区县代码无效,或上下级不匹配'); + Object.assign(profile, { + name: cleanText(row.name, 50), gender: cleanText(row.gender, 10), idNumber: cleanText(row.idNumber, 40), phone: cleanText(row.phone, 30), + email: cleanText(row.email, 100), nativePlace: cleanText(row.nativePlace, 100), ...region, + address: cleanText(row.address, 200), classId: schoolClass.id, + grade: schoolClass.name, ethnicity: cleanText(row.ethnicity, 30), birthDate: cleanText(row.birthDate, 20), postalCode: cleanText(row.postalCode, 20), + guardianName: cleanText(row.guardianName, 50), guardianPhone: cleanText(row.guardianPhone, 30), profileCompleted: true, status: 'pending', reviewNote: '', reviewedAt: null, reviewerId: null, updatedAt: nowIso() + }); + const { instance, action } = createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); + await database.updateCandidateProfile(profile, profile.name, instance, action); + } + return { count: rows.length }; + } + if (resource === 'centers') { + if (!hasPermission(user, 'centers.write')) throw Object.assign(new Error('当前账号不能导入考点考场'), { status: 403 }); + const groups = Map.groupBy(rows, row => cleanText(row.centerCode, 30).toUpperCase()); + for (const [centerCode, centerRows] of groups) { + const first = centerRows[0]; + const school = db.schools.find(item => item.code.toUpperCase() === String(first.schoolCode).toUpperCase() && (user.adminLevel === 'super' || item.id === user.schoolId)); + if (!school || !centerCode) throw excelImportError(first, '学校代码或考点代码无效'); + const existing = db.testCenters.find(item => item.code.toUpperCase() === centerCode); + if (existing && existing.schoolId !== school.id) throw excelImportError(first, '考点代码已属于其他学校'); + if (existing && db.centerChangeRequests.some(item => item.centerId === existing.id && item.status === 'pending')) throw excelImportError(first, '该考点已有待审批变更'); + const body = { + schoolId: school.id, code: centerCode, name: first.centerName, + provinceCode: first.provinceCode, cityCode: first.cityCode, districtCode: first.districtCode, + address: first.address, managerName: first.managerName, + managerPhone: first.managerPhone, contact: first.contact, emergencyPhone: first.emergencyPhone, gateOpenTime: first.gateOpenTime, + transport: first.transport, status: first.centerStatus === '停用' ? 'inactive' : 'active', notes: first.centerNotes, + rooms: centerRows.map(row => ({ code: row.roomCode, name: row.roomName, building: row.building, floor: row.floor, capacity: Number(row.capacity), seatPlan: row.seatPlan, + roomType: ({ 标准考场: 'standard', 机考考场: 'computer', 无障碍考场: 'accessible', 备用考场: 'spare' })[row.roomType] || row.roomType, + status: row.roomStatus === '停用' ? 'inactive' : 'active', notes: row.roomNotes })) + }; + const parsed = parseCenterChange(db, body, school.id, existing || null); + const change = { id: uid('center_change'), centerId: existing?.id || null, schoolId: school.id, requestType: existing ? 'update' : 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; + const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, school.id), user.id); + await database.createCenterChangeRequest(change, parsed.rooms, instance, action, logAction(db, user, 'Excel 提交考点考场审批', `${change.name} · ${parsed.rooms.length} 个考场`)); + } + return { count: groups.size }; + } + if (resource === 'results') { + return commitResultImport(db, user, rows); + } + throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 }); +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); +} + +function admitCardSection(db, user, profile, registration) { + const exam = db.exams.find(item => item.id === registration.examId); + const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); + const assignments = new Map((registration.admitCard.assignments || []).map(item => [item.subjectId, item])); + const center = db.testCenters.find(item => item.id === registration.admitCard.centerId) || {}; + const centerCode = registration.admitCard.centerCode || center.code || ''; + const centerAddress = registration.admitCard.centerAddress || [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '); + const rows = subjects.map(subject => { + const assignment = assignments.get(subject.id) || {}; + const room = db.testRooms.find(item => item.id === assignment.roomId) || {}; + return `${escapeHtml(subject.name)}${escapeHtml(subject.date)}${escapeHtml(subject.start)}—${escapeHtml(subject.end)}${escapeHtml(assignment.examRoomCode || '待定')}${escapeHtml(assignment.roomName || assignment.room || room.name || '待定')}场地代码:${escapeHtml(assignment.roomCode || room.code || '—')}${escapeHtml(assignment.building || room.building || '—')}${escapeHtml(assignment.floor || room.floor || '楼层待定')}${escapeHtml(assignment.seat || '—')}`; + }).join(''); + return `
衡准 · 准考证

${escapeHtml(exam.name)}

准考证号
${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
报名号${escapeHtml(user.candidateNumber || registration.registrationNumber || '—')}
固定考点${escapeHtml(registration.admitCard.testCenter)}考点代码:${escapeHtml(centerCode || '—')}
考点详细地址${escapeHtml(centerAddress || '地址待公布')}
${rows}
科目日期时间考试考场序号考场通用名称 / 场地代码楼栋 / 楼层座位号
编号说明“考试考场序号”是本次考试编排编号;“考场通用名称 / 场地代码”是考点内的物理场地档案,两者不是同一概念。
所有科目均安排在同一考点,但不同科目可能对应不同考试考场序号、物理场地和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
${escapeHtml(db.organization.name)}生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}
`; +} + +function admitCardsDocument(db, items, title) { + const cards = items.map(item => admitCardSection(db, item.user, item.profile, item.registration)).join(''); + return `${escapeHtml(title)}${cards}`; +} + +function admitCardHtml(db, user, profile, registration) { + const exam = db.exams.find(item => item.id === registration.examId); + return admitCardsDocument(db, [{ user, profile, registration }], `${exam.name}-${profile.name}-准考证`); +} + +function admitCardsHtml(db, registrations, title) { + const items = registrations.map(registration => ({ + registration, + user: db.users.find(item => item.id === registration.userId) || {}, + profile: db.candidateProfiles.find(item => item.userId === registration.userId) || {} + })); + return admitCardsDocument(db, items, title); +} + +const routeContext = { + database, + cache, + resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400, + documentVerificationSecret, + scoreReportCode, + admissionNoticeCode, + safeCodeEqual, + readDb, + publicSiteConfig, + sendJson, + sendError, + readJson, + readBodyBuffer, + sendWorkbook, + currentUser, + parseCookies, + safeUser, + requireUser, + hasPermission, + requirePermission, + profileInScope, + registrationInScope, + adminScopeLabel, + adminsForStep, + selectAdminForStep, + activeWorkflow, + createWorkflowSubmission, + workflowView, + pendingWorkflow, + candidateSequence, + generateCandidateNumber, + cleanText, + centerScopeProfile, + workflowScopeProfile, + candidateAccountBatchView, + centerChangeView, + parseCenterChange, + maskId, + publicExam, + examRegistrationView, + examResultSummary, + subjectPassText, + subjectPassEvaluation, + resultRankInfo, + logAction, + excelResourceNames, + excelRowsForResource, + admissionRowsForRegistrations, + centerMaterialRows, + importExcelResource, + prepareResultImport, + commitResultImport, + admitCardHtml, + admitCardsHtml, + hashPassword, + verifyPassword, + randomBytes, + uid, + nowIso, + authState, + buildWorkbook, + buildCenterMaterialsWorkbook, + hasExcelResource, + parseWorkbook, + adminLevelNames, + permissionsByLevel, + resolveRegion +}; +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) { + const requestPath = pathname === '/' ? '/index.html' : pathname; + const filePath = vendorStaticFiles.get(requestPath) + || (staticFiles.has(requestPath) ? normalize(join(root, requestPath.replace(/^\/+/, ''))) : null); + if (!filePath) return false; + const body = await readFile(filePath); + response.writeHead(200, { 'Content-Type': mimeTypes[extname(filePath)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }); + response.end(body); + return true; +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`); + const pathname = decodeURIComponent(url.pathname); + try { + if (pathname.startsWith('/api/public/')) { + const handled = await handlePublic(pathname, response); + if (handled !== false) return; + } + const authHandled = await handleAuth(request, response, pathname); + 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; + sendError(response, 404, '页面或接口不存在'); + } catch (error) { + console.error(error); + sendError(response, error.status || 500, error.status ? error.message : '服务器处理请求时发生错误'); + } +}); + +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(), authState.close()]); + process.exit(0); + }); +} + +process.once('SIGINT', () => shutdown('SIGINT')); +process.once('SIGTERM', () => shutdown('SIGTERM')); diff --git a/src/cache/redis-cache.mjs b/src/cache/redis-cache.mjs new file mode 100644 index 0000000..7b6a4ec --- /dev/null +++ b/src/cache/redis-cache.mjs @@ -0,0 +1,181 @@ +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 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, 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(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(); + 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 connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000); + const pending = new Map(); + let warningReported = false; + + const warn = error => { + if (warningReported) return; + warningReported = true; + logger.warn(`Redis 缓存暂不可用,已回源数据库:${error?.message || error}`); + }; + + const client = clientFactory({ + url, + socket: { + connectTimeout, + reconnectStrategy(retries) { + return retries >= 3 ? false : Math.min(100 * 2 ** retries, 1000); + } + } + }); + client.on('error', warn); + client.on('ready', () => { + warningReported = false; + }); + + try { + await client.connect(); + } catch (error) { + warn(error); + if (client.isOpen) client.destroy(); + return fallback; + } + + const versionKey = namespace => `${prefix}:namespace:${namespace}`; + + async function namespaceVersion(namespace) { + const key = versionKey(namespace); + const current = await client.get(key); + if (current) return current; + await client.set(key, '1', { NX: true }); + return (await client.get(key)) || '1'; + } + + return { + get enabled() { + return client.isReady; + }, + get status() { + return client.isReady ? 'ready' : 'unavailable'; + }, + async remember(namespace, key, loader, { ttlSeconds = defaultTtlSeconds } = {}) { + if (!client.isReady) return fallback.remember(namespace, key, loader, { ttlSeconds }); + try { + const version = await namespaceVersion(namespace); + const cacheKey = `${prefix}:${namespace}:${version}:${key}`; + const cached = await client.get(cacheKey); + if (cached !== null) return JSON.parse(cached); + + if (pending.has(cacheKey)) return pending.get(cacheKey); + const loading = Promise.resolve(loader()).then(async value => { + if (client.isReady) { + try { + await client.set(cacheKey, JSON.stringify(value), { + EX: positiveInteger(ttlSeconds, defaultTtlSeconds, 86400) + }); + } catch (error) { + warn(error); + } + } + return value; + }).finally(() => pending.delete(cacheKey)); + pending.set(cacheKey, loading); + return loading; + } catch (error) { + warn(error); + 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)); + return true; + } catch (error) { + warn(error); + return false; + } + }, + async close() { + await fallback.close(); + if (client.isOpen) await client.quit(); + } + }; +} + +export function withCacheInvalidation(database, cache, namespaces = ['public']) { + const resolveNamespaces = typeof namespaces === 'function' ? namespaces : () => namespaces; + return new Proxy(database, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + if (property === 'read' || property === 'close') return value.bind(target); + return async (...args) => { + const result = await value.apply(target, args); + const affected = [...new Set(resolveNamespaces(property, args, result) || [])]; + await Promise.all(affected.map(namespace => cache.invalidate(namespace))); + return result; + }; + } + }); +} diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs new file mode 100644 index 0000000..736eaba --- /dev/null +++ b/src/client/admin-views.mjs @@ -0,0 +1,430 @@ +import { formatRegionAddress } from './region-select.mjs'; +import { admissionCategoriesEditor } from './admission-plan-editor.mjs'; +import { specialtyLabel } from '../data/specialty-types.mjs'; +import { filterTableItems } from './table-state.mjs'; + +export const numberSegmentMeta = { + year: ['年份', '4 位考试年份'], school_code: ['学校代码', '使用学校档案代码'], gender: ['考生性别', '男 M / 女 F / 未知 X'], + sequence: ['流水号', '按规则前缀连续编号'], literal: ['固定值', '自定义固定字母或数字'] +}; + +export function createAdminViews(context) { + const { + state, + app, + h, + formatDate, + dateRange, + badge, + money, + passPolicyText, + statusLabels, + icons, + api, + renderError, + requireLogin, + emptyState, + brand, + portalShell, + loadingPanel, + adminNavForUser, + accountSecurity + } = context; + + async function renderAdmin(page) { + if (state.user?.role !== 'admin') return requireLogin(); + if (page === 'admissions') page = 'admission-settings'; + app.classList.add('admin-readable'); + const meta = { + dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'], + schools: ['学校管理', '创建和维护学校档案,控制学校在考生公开入口中的可选状态。'], + registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'], + payments: [state.user.adminLevel === 'class' ? '考生缴费确认' : '缴费名单', '查看、导出并修改当前管理范围内考生的考试缴费状态。'], + notices: ['通知发布', '编辑通知草稿,并统一控制手动通知与系统公示是否公开显示。'], admit: [state.user.adminLevel === 'super' ? '准考证编排' : state.user.adminLevel === 'school' ? '校内准考证' : '本班准考证', state.user.adminLevel === 'super' ? '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。' : '按当前管理范围批量下载准考证,并导出逐科准考证信息。'], + results: [state.user.adminLevel === 'super' ? '成绩管理中心' : '成绩分析', state.user.adminLevel === 'super' ? '按考试录入、预览导入、发布并跟踪各科达线情况。' : '按考试、科目和发布状态分析管理范围内成绩。'], + admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'], + centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'], + organization: ['本校组织与权限', '维护本校班级,并为每个班级配置一个或多个班级管理员。'], + 'account-batches': ['批量报名号申领', '按班级填写申领人数;审批通过后系统生成固定报名号和初始密码。'], + flows: [state.user.adminLevel === 'super' ? '流程监督' : '流程中心', state.user.adminLevel === 'super' ? '查看全部流程,监督转交、修改和退回节点。' : '处理分配给你的流程,并可转交给本校同级管理员。'], + 'flow-design': ['流程设计', '配置考生信息、报名审核、考点考场变更与批量建号的审批步骤。'], + 'number-rules': ['报名号规则', '设计审批通过后生成的新账户号码组成。'], + 'admission-settings': ['录取设置', '设置志愿窗口并控制投档、签发通知书和报到阶段。'], + 'admission-accounts': ['招生学校账户', '创建、停用和维护招生学校工作台账户。'], + 'admission-plans': ['招生计划', '代上传、审核招生计划并实时查看计划完成率。'], + 'admission-reporting': ['报到与补录', '审核学校报到统计和是否开展补充录取的决定。'], + 'admission-supervision': ['投档与退档监督', '搜索、筛选投档记录并审批特殊退档;考生志愿保持只读。'], + 'indicator-qualifications': ['指标分配资格确认', '由生源校逐人确认;本校全部考生确认后,系统自动发布资格公示。'], + security: ['账户安全', '使用当前密码设置新的登录密码。'] + }; + const allowedPages = adminNavForUser().map(item => item[0]); + if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard'; + app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]); + try { + const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page.startsWith('admission-') ? 'admissions' : page; + const query = page === 'results' && state.resultExamFilter ? `?examId=${encodeURIComponent(state.resultExamFilter)}` : ''; + const data = page === 'results' && !state.resultExamFilter + ? { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: state.resultExamCatalog || state.publicData.exams || [], resultCache: { enabled: false, status: 'not-loaded' } } + : page === 'security' ? await api('/api/auth/totp') : await api(`/api/admin/${endpoint}${query}`); + if (page === 'results' && data.selectedExamId) state.resultExamFilter = data.selectedExamId; + if (page === 'results' && data.exams?.length) state.resultExamCatalog = data.exams; + state.pageData = data; + const content = { + dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data), registrations: () => adminRegistrations(data.registrations), payments: () => adminPayments(data), + exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices, data.publications), admit: () => adminAdmit(data), results: () => adminResults(data), + schools: () => adminSchoolsV2(data), admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data), + 'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data), 'admission-settings': () => adminAdmissionsV2(data, 'settings'), 'admission-accounts': () => adminAdmissionsV2(data, 'accounts'), 'admission-plans': () => adminAdmissionsV2(data, 'plans'), 'admission-reporting': () => adminAdmissionsV2(data, 'reporting'), 'admission-supervision': () => adminAdmissionsV2(data, 'supervision'), 'indicator-qualifications': () => adminIndicatorQualifications(data), security: () => accountSecurity(data) + }[page](); + app.innerHTML = portalShell('admin', page, content, ...meta[page]); + if (page === 'admission-supervision') mountPreferenceLedger(data); + } catch (error) { renderError(error); } + } + + function mountPreferenceLedger(data) { + const container = app.querySelector('.portal-content'); + if (!container) return; + const rows = data.preferenceRows || []; + const preferencePage = paged(rows, 'adminPreferenceTable'); + const statusClass = { unfilled: 'pending', submitted: 'approved', locked: 'completed', unavailable: 'closed', ineligible: 'rejected' }; + container.insertAdjacentHTML('beforeend', `

考生志愿实时台账

同时显示未填报、已填报和已锁定考生;页面仅供监督,不提供代改入口。

当前筛选 ${preferencePage.total} / 共 ${rows.length} 人
${preferencePage.items.map(item => ``).join('') || ''}
报名号 / 考生考试 / 生源校轮次 / 状态志愿顺序提交与锁定
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.specialty)}${h(item.examName)}${h(item.sourceSchoolName)}${item.className ? ` · ${h(item.className)}` : ''}第 ${h(item.round)} 轮${h(item.fillStatus)}
${item.choices.map((choice, index) => `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(choice.schoolName || choice.schoolId)}${h([choice.schoolCode, choice.categoryName || choice.categoryCode].filter(Boolean).join(' · '))}`).join('') || '尚未填报志愿'}
${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次${item.submittedAt ? formatDate(item.submittedAt, true) : '暂无提交时间'} · ${h(item.lockStatus)}
没有符合条件的考生填报记录
${pagination(preferencePage)}
`); + } + + function adminDashboard(data) { + const m = data.metrics; + const canFlow = true; + return `
${statusLabels[state.user.adminLevel]}
${h(data.scopeLabel)}所有指标均已按当前管理员的数据范围过滤
${icons.users}
范围内考生${m.candidates}${m.pendingCandidates} 人待审核
${icons.check}
考试报名${m.registrations}${m.pendingRegistrations} 条待审核
${icons.ticket}
待确认缴费${m.pendingPayments ?? 0}可按当前管理范围办理
${icons.exam}
待处理流程${m.pendingFlows ?? 0}进入流程中心办理
${icons.chart}
已发布考试${m.publishedExams}全平台考试计划

${canFlow ? '当前工作入口' : '本班查询入口'}

${h(data.scopeLabel)}
${canFlow ? `` : ''}

最近操作

系统审计日志
${data.logs.map(log => `
${h((log.actorName || '系').slice(0,1))}

${h(log.actorName || '系统')} · ${h(log.action)}${h(log.detail)}

`).join('') || '

当前账号暂无操作记录

'}
`; + } + + function excelToolbar(resource, { importable = true, template = true, label = '数据' } = {}) { + return `
${h(label)} Excel使用系统模板可获得逐行校验
${template ? `` : ''}${importable ? `` : ''}
`; + } + + function adminSchools(data) { + const schools = data.schools || []; + const activeCount = schools.filter(item => item.active).length; + return `
学校总数${schools.length}
启用学校${activeCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
${schools.map(item => ``).join('') || ''}
学校学校代码地址班级管理员考生考点状态操作
${h(item.name)}${h(item.id)}${h(item.code)}${h(item.address || '未填写')}${item.classCount}${item.adminCount}${item.candidateCount}${item.centerCount}${badge(item.active ? 'approved' : 'closed')}
还没有学校,请先创建学校档案。
`; + } + + function adminSchoolsV2(data) { + const schools = data.schools || []; + const schoolPage = paged(schools, 'schoolTable', 20); + const sourceCount = schools.filter(item => item.active && item.isSourceSchool).length; + const admissionCount = schools.filter(item => item.active && item.isAdmissionSchool).length; + const roles = item => `${item.isSourceSchool ? '生源校' : ''}${item.isAdmissionSchool ? '招生校' : ''}`; + return `
学校总数${schools.length}
启用生源校${sourceCount}
启用招生校${admissionCount}
在册考生${schools.reduce((sum, item) => sum + item.candidateCount, 0)}
${schoolPage.items.map(item => ``).join('') || ''}
学校 / 代码学校类型地址班级管理员考生考点状态操作
${h(item.name)}${h(item.code)}
${roles(item)}
${h(item.address || '未填写')}${item.classCount}${item.adminCount}${item.candidateCount}${item.centerCount}${badge(item.active ? 'approved' : 'closed')}
没有符合条件的学校。
${pagination(schoolPage)}
`; + } + + function adminSchoolOrganization(data) { + const classes = data.classes || []; + const activeAdmins = classes.reduce((sum, item) => sum + item.admins.filter(admin => admin.active).length, 0); + return `
SCHOOL ORGANIZATION

${h(data.school?.name)}

班级决定考生、报名与成绩的可见范围;一个班级可以配置多名班级管理员。

班级
${classes.length}
班级管理员
${activeAdmins}
在册考生
${classes.reduce((sum, item) => sum + item.candidateCount, 0)}
${excelToolbar('classes', { label: '班级台账' })}${excelToolbar('class_admins', { label: '班级管理员' })}
${classes.map(item => `
${h(item.grade)}

${h(item.name)}

${badge(item.active ? 'approved' : 'closed')}
${item.candidateCount}在册考生${item.admins.length}管理员
班级管理员
${item.admins.map(admin => ``).join('') || '

尚未配置班级管理员

'}
`).join('') || emptyState('还没有班级', '点击“新增班级”建立本校组织范围。')}
`; + } + + function adminCandidates(data) { + const allCandidates = data.candidates || []; + const candidatePage = paged(allCandidates, 'candidateTable'); + const candidates = candidatePage.items; + const canReview = state.user.adminLevel === 'super' || state.permissions?.includes('candidates.review'); + const classById = new Map((data.classes || []).map(item => [item.id, item])); + const optionList = getter => [...new Set(allCandidates.map(getter).filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map(value => ``).join(''); + const archiveConsole = state.user.adminLevel === 'school' ? (() => { + const classes = data.classes || []; + const grades = [...new Set(classes.map(item => item.grade))]; + return `
SCHOOL ACCOUNT ARCHIVE

按班级或年级归档账户

归档只冻结登录,不删除考生、报名、准考证、成绩和审计记录;可随时按相同范围恢复。

`; + })() : ''; + const rows = candidates.map(item => { + const schoolClass = classById.get(item.classId); + const grade = schoolClass?.grade || ''; + const className = schoolClass?.name || item.grade || ''; + const exams = item.registrations || []; + const canSelect = canReview && item.profileCompleted && !item.accountArchived && item.status === 'pending' && item.workflow?.status === 'pending' + && (state.user.adminLevel === 'super' || item.workflow?.assignee?.id === state.user.id); + const examNames = exams.map(registration => registration.exam?.name).filter(Boolean); + return `
${h(item.name.slice(0,1))}
${h(item.name)}${h(item.candidateNumber || '待分配')}
${h(item.idNumberMasked)}${h(item.school || '未填写')}${h([grade, className].filter(Boolean).join(' · '))}
${exams.length ? `${h(examNames.slice(0, 2).join('、'))}${exams.length > 2 ? ` 等 ${exams.length} 场` : ''}${h(exams.flatMap(registration => registration.subjects || []).map(subject => subject.name).slice(0, 5).join('、') || '尚未选择科目')}` : '暂无考试报名'}
${item.accountArchived ? '已归档' : item.mustChangePassword ? '待首次改密' : item.profileCompleted ? '正常' : '待补全资料'}${item.accountArchived ? `${formatDate(item.archivedAt, true)} · ${h(item.archivedByName || '校方')}` : h(item.workflow?.assignee?.displayName || '')}${item.profileCompleted ? badge(item.status) : '未完成'}`; + }).join(''); + const bulkToolbar = canReview ? `
已选择 0 名当前可处理考生
` : ''; + const examOptions = [...new Set(allCandidates.flatMap(item => (item.registrations || []).map(registration => registration.exam?.name).filter(Boolean)))] + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map(value => ``).join(''); + const filters = `
`; + return `${archiveConsole}${excelToolbar('candidates', { importable: true, label: '考生资料' })}
${bulkToolbar}
${filters}
${rows || ''}
报名号 / 考生证件号码学校 / 年级 / 班级关联考试 / 科目账户状态资料状态操作
当前范围暂无考生
${pagination(candidatePage)}
`; + } + + 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 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 ``; + } + + function adminRegistrations(registrations) { + const isSuper = state.user.adminLevel === 'super'; + const canReview = state.user.adminLevel === 'super' || state.permissions?.includes('registrations.review'); + const optionList = (getter) => [...new Set(registrations.map(getter).filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map(value => ``).join(''); + const rows = (items, selectable = false) => items.map(reg => { + const canSelect = selectable && reg.status === 'pending' && reg.workflow?.status === 'pending' + && (isSuper || reg.workflow?.assignee?.id === state.user.id); + return `${selectable ? `` : ''}
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h([reg.schoolName, reg.gradeName, reg.className].filter(Boolean).join(' · '))}
${h(reg.exam.name)}
${reg.subjects.map(subject => `${h(subject.name)}`).join('') || '未选择科目'}
${h(reg.subjects.length)} 科 · ${money(reg.amountDue || 0)}
${h(reg.registrationNumber || '待同步账户号码')}各次考试保持一致${h(reg.workflow?.currentStepDetail?.name || '流程已结束')}${h(reg.workflow?.assignee?.displayName || '')}${badge(reg.paymentStatus)}${badge(reg.exam.archivedAt ? 'archived' : reg.status)}${reg.exam.archivedAt ? '只读封存' : ``}`; + }).join(''); + const table = (items, id, selectable = false) => `
${selectable ? '' : ''}${rows(items, selectable) || ``}
考生考试 / 科目账户报名号当前流程缴费状态操作
当前范围暂无报名记录
`; + const allCurrent = registrations.filter(reg => !reg.exam.archivedAt); + const allArchived = registrations.filter(reg => reg.exam.archivedAt); + const currentPage = paged(allCurrent, 'registrationTable'); + const archivedPage = paged(allArchived, 'archivedRegistrationTable'); + const current = currentPage.items; + const archived = archivedPage.items; + const subjectOptions = [...new Set(registrations.flatMap(item => item.subjects.map(subject => subject.name)))].sort((left, right) => left.localeCompare(right, 'zh-CN')).map(value => ``).join(''); + const bulkToolbar = canReview ? `
已选择 0 条当前可处理报名
` : ''; + return `
${bulkToolbar}
${table(current, 'registrationTable', Boolean(canReview))}${pagination(currentPage)}
${allArchived.length ? `
归档考试报名记录${allArchived.length} 条 · 流程与报名信息已冻结${allArchived.length}
${table(archived, 'archivedRegistrationTable')}${pagination(archivedPage)}
` : ''}`; + } + + function adminPayments(data) { + const allRegistrations = data.registrations || []; + const paymentPage = paged(allRegistrations, 'paymentTable'); + const registrations = paymentPage.items; + const paid = allRegistrations.filter(item => item.paymentStatus === 'paid'); + const unpaid = allRegistrations.filter(item => item.paymentStatus === 'unpaid'); + const totalDue = allRegistrations.reduce((sum, item) => sum + Number(item.amountDue || 0), 0); + const totalPaid = paid.reduce((sum, item) => sum + Number(item.amountDue || 0), 0); + const optionList = (getter) => [...new Set(allRegistrations.map(getter).filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-CN')) + .map(value => ``).join(''); + const rows = registrations.map(item => `${data.canUpdatePayment ? `` : ''}
${h((item.candidate?.name || '?').slice(0, 1))}
${h(item.candidate?.name || '未知考生')}${h(item.registrationNumber || '')}
${h(item.schoolName)}${h([item.gradeName, item.className].filter(Boolean).join(' · '))}${h(item.exam?.name || '')}
${item.subjects.map(subject => `${h(subject.name)}`).join('')}
${money(item.amountDue || 0)}${badge(item.paymentStatus)}${item.paidAt ? `${formatDate(item.paidAt, true)}${h(item.paidByName || '管理员')}` : ''}${data.canUpdatePayment && !item.exam?.archivedAt ? `` : item.exam?.archivedAt ? '只读封存' : ''}`).join(''); + const filters = `
`; + const bulkToolbar = data.canUpdatePayment ? `
已选择 0 条缴费记录
` : ''; + return `${excelToolbar('payments', { importable: false, template: false, label: '缴费名单' })}
报名人数${allRegistrations.length}
待确认${unpaid.length}
已缴费${paid.length}
应缴合计${money(totalDue)}
已缴合计${money(totalPaid)}
${bulkToolbar}
${filters}
${data.canUpdatePayment ? '' : ''}${rows || ``}
考生 / 报名号学校 / 年级 / 班级考试 / 科目应缴金额缴费状态确认记录操作
当前范围暂无已审核通过的报名
${pagination(paymentPage)}
`; + } + + function adminExams(exams) { + const active = exams.filter(exam => !exam.archivedAt); + const archived = exams.filter(exam => exam.archivedAt).sort((left, right) => new Date(right.archivedAt) - new Date(left.archivedAt)); + const card = exam => `
${h(exam.code)}${badge(exam.archivedAt ? 'archived' : exam.status)}

${h(exam.name)}

${h(exam.description)}

考试总分${h(exam.totalScore)}合格规则${h(passPolicyText(exam))}
报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考点
${h(exam.location)}
${exam.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · 满分 ${h(subject.fullScore)} · ${subject.passRule === 'none' ? '不设单科线' : subject.passRule === 'rank_percent' ? `本科排名前 ${h(subject.passValue)}%` : `固定 ${h(subject.passScore)} 分`}`).join('') || '科目待配置'}
${exam.archivedAt ? `${formatDate(exam.archivedAt, true)} 归档 · 成绩永久锁定` : `${exam.registrationCount} 人报名 · ${exam.subjects.length} 科`}${exam.archivedAt ? '' : `
${exam.status === 'draft' ? `` : ''}
`}
`; + const archiveShelf = archived.length ? `
历史考试归档${archived.length} 场 · 成绩永久锁定,点击按需查阅${archived.length}
${archived.map(card).join('')}
` : ''; + return `
${active.map(card).join('') || emptyState('没有进行中的考试', '新建考试,或在下方查阅已归档历史。')}
${archiveShelf}`; + } + + function adminAdmissions(data) { + const selected = data.settings.find(item => item.status !== 'completed') || data.settings[0]; + const pendingPlans = data.plans.filter(item => item.status === 'pending'); + const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending'); + const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止' }; + if (selected && !phaseLabels[selected.status]) phaseLabels[selected.status] = { matching: '投档中', school_review: '学校审核中', supplementary: '补录填报中', completed: '录取完成' }[selected.status] || selected.status; + return `
ADMISSION COMMAND

中考招生录取控制台

志愿内容仅超级管理员可见且不可代改;投档和录取状态变更均进入审计日志。

待审计划
${pendingPlans.length}
学校审核中
${data.placements.filter(item => item.status === 'school_review').length}
退档待审
${withdrawals.length}
正式录取
${data.placements.filter(item => item.status === 'final').length}

考试志愿设置

不是所有考试都需要开启。

${selected ? `
` : ''}

代上传招生计划

每行格式:类别名称 | 计划人数 | 特长类型(普通生留空)。指标分配可由招生学校提交后审核。

招生计划审核

${pendingPlans.length} 份待审
${data.plans.map(plan => ``).join('') || ''}
考试 / 学校计划构成提交人状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${category.specialtyType ? ` · ${h(category.specialtyType)}` : ''}`).join('
')}
${h(plan.payload.submittedBy || '超级管理员')}${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划

投档与退档监督

超级管理员可见,任何管理员均不能修改考生志愿
${data.placements.map(item => ``).join('') || ''}
考生成绩 / 志愿投档学校类别状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.payload.totalScore)} 分 · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.payload.categoryName)}${badge(item.status)}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
尚未产生投档记录
`; + } + + function adminAdmissionsV2(data, section = 'settings') { + const selected = data.settings.find(item => item.status !== 'completed') || data.settings[0]; + const pendingPlans = data.plans.filter(item => item.status === 'pending'); + const withdrawals = data.placements.filter(item => item.status === 'withdrawal_pending'); + const phaseLabels = { draft: '草稿', filling: '志愿填报中', closed: '填报已截止', matching: '投档中', school_review: '学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取完成' }; + const accountPage = paged(data.schoolAccounts, 'admissionAccountTable', 20); + const planPage = paged(data.plans, 'adminAdmissionPlanTable', 20); + const placementPage = paged(data.placements, 'adminPlacementTable'); + const preferenceSnapshots = data.preferenceRows || []; + const preferenceSnapshotPanel = `

志愿填报实时快照

不受填报阶段限制,可随时导出当前轮次全部考生的填报、未填报与锁定情况。

${preferenceSnapshots.length} 人
${data.settings.filter(item => item.payload?.enabled).map(setting => { + const exam = data.exams.find(item => item.id === setting.examId) || {}; + const round = Number(setting.payload?.round || 1); + const rows = preferenceSnapshots.filter(item => item.examId === setting.examId && Number(item.round) === round); + return `
${h(exam.code || 'EXAM')}

${h(exam.name || setting.examId)}

第 ${h(round)} 轮 · ${h(phaseLabels[setting.status] || setting.status)}

全部考生
${rows.length}
尚未填报
${rows.filter(item => item.status === 'unfilled').length}
已填报
${rows.filter(item => ['submitted', 'locked'].includes(item.status)).length}
已锁定
${rows.filter(item => item.status === 'locked').length}
`; + }).join('') || '

启用志愿填报后,可在这里随时导出实时台账。

'}
`; + const settings = `

考试志愿设置

成绩发布后按场次开放,并持续向考生展示录取进度。

${selected ? `
补录不能手工开启,必须由招生学校提交报到情况和补录决定,并经超级管理员审批。` : ''}
`; + const accountRows = accountPage.items.map(account => `
${h((account.displayName || '招').slice(0, 1))}
${h(account.displayName)}${h(account.id)}
${h(account.username)}${h(account.schoolName || '未绑定')}${h(account.schoolCode || '')}${account.active ? '已启用' : '已停用'}${formatDate(account.createdAt, true)}`).join(''); + const accounts = ``; + const planForm = `

代招生校上传计划

每个类别独立设置人数、特长资格和生源校指标,保存后直接审核通过。

${admissionCategoriesEditor(h, data.sourceSchools)}
`; + const plans = `

招生计划审核与完成率

实时核对计划、正式录取和实际报到完成情况。

${pendingPlans.length} 份待审
${planPage.items.map(plan => ``).join('') || ''}
考试 / 学校计划构成实时完成率指标分配状态操作
${h(plan.examName)}${h(plan.schoolName)}${plan.payload.categories.map(category => `${h(category.name)} ${h(category.quota)} 人${h(specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类')}`).join('')}${h(plan.progress?.admissionRate || 0)}%正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}实际报到 ${h(plan.progress?.reportingRate || 0)}%${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('
') || '无定向指标'}
${badge(plan.status)}${plan.status === 'pending' ? `` : h(plan.payload.reviewNote || '')}
暂无招生计划
${pagination(planPage)}
`; + const reportingPage = paged(data.reportingRequests || [], 'adminReportingApprovalTable', 20); + const reportingApprovals = `

报到情况与补录审批

学校决定审批通过后,统计数据和说明将自动进入公开通知。

${(data.reportingRequests || []).filter(item => item.status === 'pending_approval').length} 项待审批
${reportingPage.items.map(item => ``).join('') || ''}
考试 / 学校报到统计学校决定状态审批
${h(item.examName)}${h(item.schoolName)} · 第 ${h(item.payload?.round || 1)} 轮计划 ${h(item.progress.totalQuota)} 人 · 已报到 ${h(item.progress.reportedCount)} 人完成率 ${h(item.progress.reportingRate)}% · 缺额 ${h(item.progress.reportingGap)} 人${item.payload?.supplementDecision === 'supplement' ? '申请补录' : '不进行补录'}${h(item.payload?.decisionNote || '')}${badge(item.status)}${item.status === 'pending_approval' ? `` : h(item.payload?.approvalNote || '—')}
暂无报到与补录审批记录
${pagination(reportingPage)}
`; + const placements = `

录取情况台账

支持跨页搜索、考试/学校/状态筛选,并按当前筛选结果导出 Excel。

当前筛选 ${placementPage.total} / 共 ${data.placements.length} 条
${placementPage.items.map(item => ``).join('') || ''}
考生 / 生源校考试 / 成绩录取学校类别录取 / 报到状态退档审核
${h(item.candidate.name)}${h(item.candidate.registrationNumber)}${h(item.sourceSchoolName || '生源校未登记')}${item.className ? ` · ${h(item.className)}` : ''}${h(item.examName || item.examId)}${h(item.payload.totalScore)} 分 · 特征分 ${h(item.payload.featureScore || 0)} · 第 ${h(item.payload.preferenceOrder)} 志愿${h(item.schoolName)}${h(item.schoolCode || '')}${h(item.payload.categoryName)}${h(item.candidate.specialtyLabel || '普通生')}${badge(item.status)}报到:${h(item.reportingStatusLabel || '—')}${item.status === 'withdrawal_pending' ? `${h(item.payload.withdrawalReason)}` : '—'}
没有符合条件的录取记录
${pagination(placementPage)}
`; + const banner = `
ADMISSION COMMAND

中考招生录取控制台

计划完成率、考生报到与补录审批在同一条可审计链路中实时更新。

待审计划
${pendingPlans.length}
补录待审批
${(data.reportingRequests || []).filter(item => item.status === 'pending_approval').length}
正式录取
${data.placements.filter(item => item.status === 'final').length}
已报到
${(data.reportingRequests || []).reduce((sum, item) => sum + Number(item.progress?.reportedCount || 0), 0)}
`; + const sections = { + settings: `
${settings}${preferenceSnapshotPanel}
`, + accounts, + plans: `${planForm}${plans}`, + reporting: reportingApprovals, + supervision: placements + }; + return `${banner}${sections[section] || sections.settings}`; + } + + function adminIndicatorQualifications(data) { + if (!data.exams?.length) return emptyState('暂无需要确认的考试', '超级管理员启用中考志愿填报后,本校资格名单会出现在这里。'); + return `
SOURCE SCHOOL CERTIFICATION

${h(data.school?.name)}资格确认簿

先按姓名、报名号、确认状态或特长类型筛选,再逐人确认或多选批量设置。每场考试全部确认后立即自动公示。

${data.exams.map((item, index) => { + const status = item.qualificationStatus; + const tableId = `qualificationTable-${index}`; + const qualificationPage = paged(status.rows, tableId); + const specialties = [...new Set(status.rows.map(row => row.specialtyLabel).filter(Boolean))].sort((left, right) => left.localeCompare(right, 'zh-CN')); + const filters = `
`; + const bulk = `
已选 0 人
`; + const rows = qualificationPage.items.map(row => `${h(row.name)}${h(row.registrationNumber)}${h(row.specialtyLabel)}${row.confirmedAt ? formatDate(row.confirmedAt, true) : '待确认'}`).join(''); + return `
${h(item.exam.code)}

${h(item.exam.name)}

${h(status.confirmed)} / ${h(status.total)} 已确认
${status.complete ? '
✓ 本校资格已全部确认,公开公示已自动发布
' : '
未全部确认前不会公开,请逐项核对。
'}${filters}${bulk}
${rows || ''}
选择报名号 / 姓名特长类型指标分配资格确认时间保存
本校暂无资料已完善的在册考生
${pagination(qualificationPage)}
`; + }).join('')}`; + } + + function adminNotices(notices, publications = []) { + const entries = [ + ...notices.map(data => ({ kind: 'notice', status: `ordinary ${data.status} ${data.status === 'published' ? 'visible' : 'hidden'}`, data })), + ...publications.map(data => ({ kind: 'publication', status: `system ${data.status} ${data.visible ? 'visible' : 'hidden'}`, data })) + ]; + const noticePage = paged(entries, 'noticeTable', 20); + const rows = noticePage.items.map(entry => { + const item = entry.data; + if (entry.kind === 'notice') return `${h(item.title)}${h(item.summary)}手动通知${h(item.category)}${h(item.author)}${formatDate(item.publishAt || item.createdAt,true)}${item.pinned ? '首页置顶' : item.status === 'published' ? '通知目录' : '尚未展示'}${badge(item.status)}
${item.status === 'draft' ? `` : ''}
`; + return `${h(item.title)}${h(item.summary)}自动公示${h(item.category)}${h(item.author)}${formatDate(item.publishedAt,true)}通知目录${badge(item.status)}`; + }).join(''); + return `

自动公示的内容由招生录取流程生成,这里只控制是否公开显示。

${rows || ''}
标题来源 / 分类发布人发布时间展示位置状态操作
暂无符合条件的通知或系统公示
${pagination(noticePage)}
`; + } + + function adminAdmit(data) { + const approved = data.registrations || []; + const admitPage = paged(approved, 'admitTable'); + const selectedExam = data.exams.find(exam => !exam.archivedAt && exam.approvedCount > 0) || data.exams.find(exam => !exam.archivedAt) || data.exams[0]; + const selectedPlan = selectedExam?.plan; + const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code; + const form = data.canArrange ? (selectedExam ? `

整场编排控制台

先预检容量与档案,再以一个事务生成或替换整套编排。

${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位
` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。')) : ''; + const ruleCards = data.canArrange ? `
${data.rules.map(rule => `
号码规则

${h(rule.name)}

${h(rule.description)}

${h(rule.example)}
`).join('')}
` : ''; + const planCards = data.canArrange && data.plans.length ? `
${data.plans.map(plan => `
${h(scopeName(plan.mixingScope))}

${h(plan.examName)}

${plan.candidateCount}名考生${plan.centerCount}个考点${plan.subjectCombinationCount}种科目组合

${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%

${plan.warnings.length ? `
    ${plan.warnings.map(item => `
  • ${h(item)}
  • `).join('')}
` : '

容量、档案与时间冲突检查通过

'}
`).join('')}
` : ''; + const exportExam = data.exams.find(exam => exam.arrangedCount > 0) || data.exams[0]; + const exportPanel = exportExam ? `

${data.canArrange ? '准考证与考务材料' : h(data.scopeLabel)}

批量准考证为 A4 横向打印文件;“准考证信息”是逐考生、逐科目的 Excel 台账。

${approved.filter(item => item.admitCard).length} 份范围内准考证
${data.canExportCenterMaterials ? '' : ''}
` : emptyState('当前范围还没有考试报名', '待报名审核通过并完成整场编排后,可在这里批量下载。'); + const rows = admitPage.items.map(reg => { + const assignments = new Map((reg.admitCard?.assignments || []).map(item => [item.subjectId, item])); + const subjects = reg.subjects.map(subject => { + const assignment = assignments.get(subject.id); + return `${h(subject.name)}${assignment ? `考试考场序号 ${h(assignment.examRoomCode)} · ${h(assignment.roomName || assignment.room)}(场地 ${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat)}` : '待编排'}`; + }).join(''); + return `
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${h(reg.exam.code)}${h(reg.admitCard?.number || '待编排')}${h(reg.admitCard?.testCenter || '—')}${h(reg.admitCard?.centerCode || '')} · ${h(reg.admitCard?.centerAddress || '')}
${subjects}
${reg.admitCard ? `` : '—'}`; + }).join(''); + return `${form}${ruleCards}${planCards}${exportPanel}

考试考场序号来自本次编排;通用名称和场地代码来自考点物理场地档案。

${rows || ''}
考生考试准考证号固定考点 / 地址分科考试考场 / 物理场地 / 座位操作
暂无已通过的考试报名
${pagination(admitPage)}
`; + } + + function adminResults(data) { + const preview = state.resultImportPreview; + const activeExam = state.resultExamFilter ? data.exams.find(item => item.id === state.resultExamFilter) : null; + if (!activeExam) { + const examButton = exam => ``; + const currentExams = data.exams.filter(exam => !exam.archivedAt); + const archivedExams = data.exams.filter(exam => exam.archivedAt); + return `
${currentExams.map(examButton).join('')}
${archivedExams.length ? `
历史归档考试 ${archivedExams.length} 场 · 选择后读取
${archivedExams.map(examButton).join('')}
` : ''}${emptyState('请先选择考试', '未选择考试时不会读取成绩、考生录分名单或成绩复议数据。')}`; + } + const activeSubject = activeExam?.subjects.find(item => item.id === state.resultSubjectFilter) || activeExam?.subjects[0]; + if (activeSubject) state.resultSubjectFilter = activeSubject.id; + const examResults = activeExam ? data.results.filter(item => item.examId === activeExam.id) : data.results; + const resultPage = paged(examResults, 'resultTable'); + const examAppeals = activeExam ? (data.appeals || []).filter(item => item.result?.examId === activeExam.id) : (data.appeals || []); + const appealPage = paged(examAppeals, 'resultAppealTable'); + const passRate = activeExam?.complete ? Math.round(activeExam.qualified / activeExam.complete * 100) : null; + const subjectRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id && item.subjectIds.includes(activeSubject?.id)); + const resultEntryPage = paged(subjectRegistrations, 'resultEntryTable'); + const subjectResults = new Map(examResults.filter(item => item.subjectId === activeSubject?.id).map(item => [item.registrationId, item])); + const recordedCount = subjectRegistrations.filter(item => subjectResults.has(item.id)).length; + const publishedCount = subjectRegistrations.filter(item => subjectResults.get(item.id)?.published).length; + const entryRows = resultEntryPage.items.map((registration, index) => { + const result = subjectResults.get(registration.id); + const status = result?.published ? 'published' : result ? 'draft' : 'missing'; + return `${index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${result ? badge(result.published ? 'published' : 'draft') : '未录入'}${result ? formatDate(result.updatedAt || result.publishedAt, true) : '—'}`; + }).join(''); + const entry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
SCORE ENTRY ROSTER

按名单录入单科成绩

选定考试和科目后,直接在完整考生名单中录分;暂存不会向考生发布。

应录人数${subjectRegistrations.length}已录入${recordedCount}已暂存${Math.max(0, recordedCount - publishedCount)}已发布${publishedCount}

${activeSubject ? `${h(activeSubject.name)} · 满分 ${h(activeSubject.fullScore)} 分 · ${h(activeSubject.passRule === 'none' ? '不设单科线' : activeSubject.passRule === 'rank_percent' ? `本科排名前 ${activeSubject.passValue}%` : `固定 ${activeSubject.passScore} 分达线`)}` : '请选择科目'}

${entryRows || ''}
序号考生报名号准考证号成绩(0—${h(activeSubject?.fullScore || '—')})状态最近保存
该科目暂无已通过报名的考生
尚无未保存修改按 Ctrl / ⌘ + S 可快速暂存;发布前必须补齐本科学目全部考生成绩。
` : ''; + const featureRegistrations = (data.registrations || []).filter(item => item.examId === activeExam?.id); + const featurePage = paged(featureRegistrations, 'featureScoreTable'); + const featureModified = featureRegistrations.filter(item => Number(item.featureScore || 0) !== 0).length; + const featureRows = featurePage.items.map((registration, index) => { + const featureScore = Number(registration.featureScore || 0); + const status = `${featureScore ? 'modified' : 'zero'} ${registration.specialtyType ? 'specialty' : 'general'}`; + return `${index + 1}
${h((registration.candidateName || '?').slice(0, 1))}
${h(registration.candidateName)}${h(registration.schoolName)} · ${h(registration.className)}
${h(registration.candidateNumber)}${h(registration.admitCard?.number || '待编排')}${h(registration.specialtyLabel || '普通生')}${registration.specialtyType ? '特长类别投档时计入' : '普通类别不计特征分'}`; + }).join(''); + const featureEntry = state.user.adminLevel === 'super' && !activeExam?.archivedAt ? `
UNIFIED SPECIALTY TEST

按名单登记特征分

所有考生默认 0 分;只有录取到特长生招生类别时,特征分才加入文化课总分参与该类别投档。

分类计分规则普通类别:文化课总分特长类别:文化课总分 + 特征分
本场考生${featureRegistrations.length}默认 0 分${featureRegistrations.length - featureModified}已修改${featureModified}具有特长资格${featureRegistrations.filter(item => item.specialtyType).length}

特征分按考试、按考生独立保存,不改变文化课成绩。

${featureRows || ''}
序号考生报名号准考证号特长资格 / 计分范围特征分(0—1000)
本场暂无已通过报名的考生
尚无未保存修改未修改的考生保持 0 分;保存后仅影响特长生类别的投档分。
` : ''; + const importPreviewPage = preview ? paged(preview.rows, 'resultImportPreviewTable') : null; + const importPreview = preview ? `
EXCEL STAGING AREA

${h(preview.fileName || '成绩导入预览')}

此处数据尚未写入数据库。请检查错误、更新覆盖项和发布状态后再提交。

总行数${preview.summary.total}可提交${preview.summary.valid}有错误${preview.summary.invalid}新增 / 更新${preview.summary.create} / ${preview.summary.update}将发布${preview.summary.publish}
${importPreviewPage.items.map(row => ``).join('')}
Excel 行考生考试 / 科目成绩独立及格线发布写入方式 / 校验
${h(row.sourceRow)}${h(row.candidateName || '未匹配')}${h(row.candidateNumber)}${h(row.examName || row.examCode)}${h(row.subjectName)}${Number.isFinite(row.score) ? h(row.score) : '—'}${row.scoreRate == null ? '' : `${h(row.scoreRate)}% · ${h(row.grade)}`}${h(row.passText || '—')}${row.qualified == null ? '不判定' : row.qualified ? '达到单科线' : '未达到单科线'}${badge(row.published ? 'published' : 'draft')}${row.errors.length ? `
    ${row.errors.map(error => `
  • ${h(error)}
  • `).join('')}
` : `${row.mode === 'update' ? '覆盖已有成绩' : '新增成绩'}校验通过`}
${pagination(importPreviewPage)}

${preview.summary.invalid ? `有 ${preview.summary.invalid} 行错误,修正 Excel 后请重新选择文件。` : `确认后将以一个事务写入 ${preview.summary.valid} 条成绩,失败时不会留下部分数据。`}

` : ''; + const appealLedger = `

本场成绩复议

复议按考生所属班级和学校自动分配,可在流程中心办理。

${appealPage.items.map(appeal => ``).join('') || ''}
考生 / 科目考试原成绩复议理由当前步骤责任人状态
${h(appeal.result?.candidateName)} · ${h(appeal.result?.subjectName)}${h(appeal.result?.examName)}${h(appeal.result?.score)}${h(appeal.reason)}${h(appeal.currentStepDetail?.name || '流程已结束')}${h(appeal.assignee?.displayName || '—')}${badge(appeal.status)}
本场暂无成绩复议申请
${pagination(appealPage)}
`; + const examButton = exam => ``; + const currentExams = data.exams.filter(exam => !exam.archivedAt); + const archivedExams = data.exams.filter(exam => exam.archivedAt); + const examStrip = `
${currentExams.map(examButton).join('')}
${archivedExams.length ? `
历史归档考试 ${archivedExams.length} 场 · 成绩永久锁定
${archivedExams.map(examButton).join('')}
` : ''}`; + const cacheButton = state.user.adminLevel === 'super' + ? `` + : ''; + const toolbar = `
${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '名单模板已预填本场全部考生;导入后先预览,确认才写入数据库'}
${cacheButton}${activeExam?.archivedAt ? '' : ''}${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '' : ''}
`; + const metrics = `
报名考生${activeExam?.registrationCount ?? 0}本场已通过报名
录入进度${activeExam?.scored ?? 0} / ${activeExam?.enrolledSubjects ?? 0}剩余 ${activeExam?.missing ?? 0} 科次
已发布${activeExam?.published ?? 0}草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条
成绩已出齐${activeExam?.complete ?? 0}
整场合格率${passRate == null ? '—' : `${passRate}%`}按本场排名或所设规则判定
成绩复议${examAppeals.length}当前考试累计
`; + const ledger = `
${resultPage.items.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${h((result.candidateName || '?').slice(0,1))}
${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)}
${h(result.subjectName)}${h(result.examCode)}${h(result.score)} / ${h(result.fullScore)}第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}%${h(result.passText)}${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'}${badge(result.published ? 'published' : 'draft')}${formatDate(result.updatedAt || result.publishedAt, true)}
本场考试还没有成绩记录
${pagination(resultPage)}
`; + const archiveLock = activeExam?.archivedAt ? `
${icons.check}
本场考试已归档${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。
` : ''; + return `${examStrip}${archiveLock}${metrics}${toolbar}${activeExam?.archivedAt ? '' : importPreview}${entry}${pagination(resultEntryPage)}${featureEntry}${pagination(featurePage)}${ledger}${appealLedger}`; + } + + function adminUsers(data) { + const adminPage = paged(data.admins || [], 'adminAccountTable', 20); + return `
SELF REGISTRATION

考生自主注册

${data.selfRegistrationEnabled ? '公开入口已开放,考生可以自主申请固定报名号。' : '当前由学校统一创建账户、下发报名号和初始密码。'}

${data.selfRegistrationEnabled ? '已开放' : '已关闭'}
`; + } + + function adminCenters(data) { + const roomTypeNames = { standard: '标准考场', computer: '机考考场', accessible: '无障碍考场', spare: '备用考场' }; + const detailAddresses = new Map(data.centers.map(center => [center.id, center.address])); + data.centers.forEach(center => { center.address = formatRegionAddress(center); }); + const centerPage = paged(data.centers, 'centerDossierGrid', 20); + const cards = centerPage.items.map(center => `
${h(center.schoolName)} · ${h(center.code)}

${h(center.name)}

${center.pendingChange ? '变更审批中' : ''}${badge(center.status === 'active' ? 'approved' : 'closed')}
结构化考场${center.rooms.length}
启用席位${center.totalCapacity}
开放时间${h(center.gateOpenTime || '未设')}
详细地址
${h(center.address)}
考点负责人
${h(center.managerName || '未填写')} · ${h(center.managerPhone || center.contact || '未填写')}
应急电话
${h(center.emergencyPhone || '未填写')}
交通提示
${h(center.transport || '未填写')}
${center.rooms.map(room => ``).join('')}
考场位置类型容量座位编排状态
${h(room.name)}${h(room.code)}${h(room.building)} · ${h(room.floor || '楼层未填')}${h(roomTypeNames[room.roomType] || room.roomType)}${h(room.capacity)} 席${h(room.seatPlan || '按现场座次表编排')}${badge(room.status === 'active' ? 'approved' : 'closed')}
${h(center.notes || '无补充说明')}
`).join(''); + data.centers.forEach(center => { center.address = detailAddresses.get(center.id); }); + const requests = data.changeRequests || []; + const requestPage = paged(requests, 'centerChangeTable', 20); + return `${excelToolbar('centers', { label: '考点考场档案' })}
正式考点${data.centers.length}
结构化考场${data.centers.reduce((sum, item) => sum + item.rooms.length, 0)}
待审批变更${requests.filter(item => item.status === 'pending').length}
${cards || emptyState('没有符合条件的考点', '可清除搜索后查看全部正式考点。')}
${pagination(centerPage)}

考点变更台账

新增和修改均保留申请快照,审批通过后才更新正式档案。

${requestPage.items.map(item => ``).join('') || ''}
申请类型考点学校考场数提交时间当前状态责任人
${item.requestType === 'create' ? '新增考点' : '修改档案'}${h(item.name)}${h(item.code)}${h(item.schoolName)}${item.rooms.length} 个${formatDate(item.createdAt, true)}${badge(item.status)}${h(item.workflow?.assignee?.displayName || '流程已结束')}
没有符合条件的考点变更申请
${pagination(requestPage)}
`; + } + + function adminAccountBatches(data) { + const classes = data.classes || []; + const batches = data.batches || []; + const batchPage = paged(batches, 'accountBatchLedger', 20); + const form = `
SCHOOL ACCOUNT REQUEST

按班级申领报名号

只填写需要的数量。提交后进入审批,最终批准前不会创建任何考生账户。

单批上限500个账户
${classes.map(item => ``).join('')}

审批通过后生成固定报名号、随机初始密码、待补录考生账户

`; + const ledger = batchPage.items.map(batch => { + const resultRows = batch.status === 'approved' ? `
账号下发清单${batch.totalCount} 个账号 · 考生首次登录必须改密
${batch.items.map((item, index) => ``).join('')}
序号班级固定报名号 / 账户初始密码
${index + 1}${h(item.className)}${h(item.candidateNumber)}${h(item.initialPassword)}
` : ''; + return ``; + }).join(''); + return `${excelToolbar('account_quotas', { label: '班级申领配额' })}${form}`; + } + + function adminNumberRules(data) { + const rule = data.activeRule || { name: '自定义报名号规则', separator: '-', segments: [] }; + const byType = Object.fromEntries(rule.segments.map(item => [item.type, item])); + const types = Object.keys(numberSegmentMeta); + return `

账户报名号组成

规则用于最终审批后的批量建号;流水号为必选字段。

当前规则
${types.map((type, index) => { const segment = byType[type]; const checked = Boolean(segment) || type === 'sequence'; return ``; }).join('')}
`; + } + + function adminFlowDesign(workflows) { + const codes = { profile_change: 'PROFILE CHANGE', registration_review: 'REGISTRATION', center_change: 'CENTER & ROOM CHANGE', candidate_account_batch: 'ACCOUNT BATCH', score_appeal: 'SCORE APPEAL' }; + return `
${workflows.map(workflow => `
${h(codes[workflow.businessType] || workflow.businessType)}

${h(workflow.name)}

${workflow.steps.map(step => workflowStepEditor(step)).join('')}
`).join('')}
`; + } + + function workflowStepEditor(step = {}) { + return `
`; + } + + function adminFlows(data) { + const actionNames = { submit: '提交', approve: '通过', reject: '退回考生', transfer: '转交', return: '退回节点', supervise: '监督调整' }; + const typeNames = { profile_change: '考生信息修改', registration_review: '考试报名', center_change: '考点考场变更', candidate_account_batch: '批量报名号申领', score_appeal: '考生成绩复议' }; + const visibleTypes = [...new Set(data.instances.map(instance => instance.businessType))]; + const workflowPage = paged(data.instances, 'workflowBoard', 20); + const toolbar = `
`; + return `${toolbar}
${workflowPage.items.map(instance => { + const isCenter = instance.businessType === 'center_change'; + const isBatch = instance.businessType === 'candidate_account_batch'; + const isAppeal = instance.businessType === 'score_appeal'; + const title = isCenter ? instance.centerName : isBatch ? `${instance.schoolName} · ${instance.batchTotalCount} 个账户` : isAppeal ? `${instance.candidateName} · ${instance.appealResult?.subjectName || '成绩复议'}` : instance.candidateName; + const sub = isCenter ? `${instance.requestType === 'create' ? '新增考点' : '修改档案'} · ${instance.schoolName}` : isBatch ? (instance.accountBatch?.quotas || []).map(item => `${item.className} ${item.count} 人`).join(' · ') : isAppeal ? `${instance.appealResult?.examName || ''} · 原成绩 ${instance.appealResult?.score ?? '—'}` : `${instance.examName ? `${instance.examName} · ` : ''}${instance.schoolName} · ${instance.className}`; + return `
${h(typeNames[instance.businessType] || instance.businessType)}

${h(title)}

${h(sub)}

${badge(instance.status)}
${instance.steps.map(step => `
${step.position < instance.currentStep || instance.status === 'approved' ? '✓' : step.position}${h(step.name)}${h(statusLabels[step.adminLevel])}
`).join('')}
当前责任人${h(instance.assignee?.displayName || '流程已结束')}${h(instance.currentStepDetail?.name || statusLabels[instance.status])}
${instance.actions.length ? `${h(actionNames[instance.actions.at(-1).action] || instance.actions.at(-1).action)} · ${h(instance.actions.at(-1).actorName)}` : '尚无操作记录'}
`; + }).join('') || emptyState('暂无审批流程', '考生资料、考试报名、考点档案或批量建号提交后,流程会显示在这里。')}
${pagination(workflowPage)}`; + } + + return { renderAdmin, workflowStepEditor }; +} diff --git a/src/client/admission-plan-editor.mjs b/src/client/admission-plan-editor.mjs new file mode 100644 index 0000000..3458d6b --- /dev/null +++ b/src/client/admission-plan-editor.mjs @@ -0,0 +1,16 @@ +import { specialtyCatalog } from '../data/specialty-types.mjs'; + +export function indicatorAllocationEditor(h, sourceSchools = [], allocation = {}) { + return `
`; +} + +export function admissionCategoryEditor(h, sourceSchools = [], category = {}) { + const specialty = Boolean(category.specialtyCategory); + const selectedCategory = specialtyCatalog.find(item => item.code === category.specialtyCategory); + return `
招生类别${h(category.name || '新类别')}
指标分配可把本类别计划的一部分定向分配给生源校,合计不得超过计划人数。
${(category.indicatorAllocations || []).map(item => indicatorAllocationEditor(h, sourceSchools, item)).join('')}
`; +} + +export function admissionCategoriesEditor(h, sourceSchools = [], categories = []) { + const initial = categories.length ? categories : [{ name: '普通生', quota: '', indicatorAllocations: [] }]; + return `
招生类别与计划逐项设置类别、资格范围和生源校指标。
${initial.map(category => admissionCategoryEditor(h, sourceSchools, category)).join('')}
`; +} diff --git a/src/client/admission-views.mjs b/src/client/admission-views.mjs new file mode 100644 index 0000000..e5876b2 --- /dev/null +++ b/src/client/admission-views.mjs @@ -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 `
招生学校/${h(title)}
${h((state.user?.displayName || '招').slice(0,1))}${h(state.user?.displayName)}招生学校账号

SCHOOL ADMISSION

${h(title)}

${h(description)}

${content}
`; + } + + 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, '
正在读取数据
', ...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 `
ADMISSION OFFICE

${h(data.school.name)}

学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。

${progress.length ? `
${progress.map(plan => `
${h(plan.examName)}${h(plan.progress.admissionRate)}%

计划 ${h(plan.progress.totalQuota)} 人 · 正式录取 ${h(plan.progress.finalCount)} 人 · 已报到 ${h(plan.progress.reportedCount)} 人

实际报到完成率 ${h(plan.progress.reportingRate)}%
`).join('')}
` : ''}

本校工作入口

${data.exams.length} 场启用志愿
${data.notifications?.length ? `

系统自动通知

${data.notifications.length} 条
${data.notifications.map(notice => ``).join('')}
` : ''}
`; + } + + function reporting(data) { + if (!data.batches?.length) return `

暂无报到批次

超级管理员签发正式录取通知书并开启报到后,本页会生成报到台账。

`; + 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 => `${editable ? `` : ''}${h(item.name)}${h(item.candidateNumber)}${h(item.noticeNumber)}${h(item.categoryName)}`).join(''); + const actions = editable ? `
` : batch.status === 'submitted' ? `
报到情况已提交

请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。

` : `
${h(statusLabels[batch.status] || batch.status)}

${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}

`; + const bulkTools = editable ? `
已选 0 人
` : ''; + const ledger = `
${bulkTools}
${editable ? '' : ''}${rowHtml || ``}
选择考生通知书 / 类别报到状态码备注
本轮没有正式录取考生
${pagination(page)}`; + const ledgerBlock = editable ? `
${ledger}${actions}
` : `
${ledger}
${actions}`; + return `
${h(batch.exam.code)} · 第 ${h(batch.round)} 轮

${h(batch.exam.name)}

计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。

${h(batch.progress.reportingRate)}%计划报到完成率
正式录取 ${h(batch.progress.finalCount)}已报到 ${h(batch.progress.reportedCount)}未报到 ${h(batch.progress.notReportedCount)}计划缺额 ${h(batch.progress.reportingGap)}${h(statusLabels[batch.status] || batch.status)}
${editable ? `
Excel 批量维护黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。
${importSummary ? `
${importSummary.changedCount ? `最近导入已更新 ${h(importSummary.changedCount)} 人` : '最近导入没有产生变化'}读取 ${h(importSummary.count)} 行 · 未变化 ${h(importSummary.unchangedCount)} 行${importSummary.changes?.length ? `${importSummary.changes.slice(0, 3).map(item => `${h(item.name)}:${h(item.fromCode)} → ${h(item.toCode)}`).join(';')}` : 'Excel 内容与当前暂存状态一致。'}
` : ''}
通知书二维码核验打开实时相机扫描;识别后先核对考生,再点击暂存。
` : ''}${ledgerBlock}
`; + }).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 `

模板设计

正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}

${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}
${h(template.eyebrow || 'ADMISSION NOTICE')}

${h(template.title || '录 取 通 知 书')}

${h(data.school?.name)}

通知书编号:AD01-EX-2026-ZK-000001
张同学:

${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}

${h(template.footer || '')}${h(data.school?.name)}
防伪二维码

右侧为 A4 通知书预览;正式下载件会自动写入通知书编号、防伪查询码与二维码。

`; + } + + 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 ``; + } + + function plans(data) { + const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20); + return `

提交本校招生计划

按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。

${admissionCategoriesEditor(h, data.sourceSchools)}
${planPage.items.map(plan => ``).join('') || ''}
考试类别计划实时完成率指标分配状态审核意见
${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}${plan.payload.categories.map(item => `${h(item.name)} ${h(item.quota)} 人${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}`).join('')}${h(plan.progress?.admissionRate || 0)}%正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}实际报到 ${h(plan.progress?.reportingRate || 0)}%${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('
') || '无定向指标'}
${badge(plan.status)}${h(plan.payload.reviewNote || '等待审核')}
尚未提交计划
${pagination(planPage)}
`; + } + + function placements(data) { + const exportBar = data.completedExams?.length ? `
FINAL ROSTER正式录取考生信息 Excel仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。
` : ''; + 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 => `${h(item.candidate.name)}${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}${h(item.examName)}${h(item.candidate.specialtyLabel || '普通生')}${h(item.candidate.specialtyCertificate || '')}${h(item.candidate.policyEligibility || '')}${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('
')}投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}${h(item.payload.categoryName)}第 ${h(item.payload.preferenceOrder)} 志愿${badge(item.status)}${item.status === 'school_review' ? `
` : `${h(item.payload.schoolDecisionNote || '已处理')}`}`).join(''); + return `${exportBar}

本校投档审核台账

可搜索、筛选和多选批量处理;仅待审核记录可被选中。

${pendingCount} 人待审 / 共 ${data.placements.length} 人
已选 0 人
${rows || ''}
选择考生 / 考试资格当次成绩投档类别状态单人审核
暂无投档考生
${pagination(placementPage)}
`; + } + return { renderAdmission }; +} +import { admissionCategoriesEditor } from './admission-plan-editor.mjs'; +import { specialtyLabel } from '../data/specialty-types.mjs'; +import { filterTableItems } from './table-state.mjs'; diff --git a/src/client/api.mjs b/src/client/api.mjs new file mode 100644 index 0000000..40599a4 --- /dev/null +++ b/src/client/api.mjs @@ -0,0 +1,34 @@ +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', + headers: { ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, + ...options, + body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body + }); + const type = response.headers.get('content-type') || ''; + const data = type.includes('application/json') ? await response.json() : await response.text(); + 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; +} diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs new file mode 100644 index 0000000..6639cb3 --- /dev/null +++ b/src/client/candidate-views.mjs @@ -0,0 +1,247 @@ +import { mountRegionSelects } from './region-select.mjs'; +import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.mjs'; + +export function createCandidateViews(context) { + const { + state, + app, + h, + formatDate, + dateRange, + badge, + money, + passPolicyText, + statusLabels, + icons, + api, + renderError, + requireLogin, + emptyState, + brand + } = context; + + const candidateNav = [ + ['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'], + ['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'], 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] || '管理员'; + 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 => `
${h(group)}${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => ``).join('')}
`).join(''); + return `
${roleName}/${h(title)}
${role === 'admin' ? `` : ''}${h((state.user?.displayName || '用').slice(0, 1))}${h(state.user?.displayName)}${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}

${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}

${h(title)}

${h(description)}

${portalHeadingAction(role, page)}
${content}
`; + } + + function portalHeadingAction(role, page) { + if (role === 'admin' && page === 'notices') return ``; + if (role === 'admin' && page === 'exams') return ``; + if (role === 'admin' && page === 'schools') return ``; + if (role === 'admin' && page === 'admins') return ``; + if (role === 'admin' && page === 'centers') return ``; + if (role === 'admin' && page === 'organization') return ``; + if (role === 'candidate' && page === 'profile') return `当前状态 ${badge(state.profile?.status || 'pending')}`; + return ''; + } + + function loadingPanel() { + return `
正在读取数据
`; + } + + 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', `
04

中考招生资格

特长资格按大类和小类登记,填志愿时系统只显示与本人资格相符的招生类别。

`); + } + + function onboardingShell(stage, content) { + const passwordDone = stage !== 'password'; + return `
FIRST SIGN-IN

${stage === 'password' ? '先保护你的账户' : '建立完整考生档案'}

${stage === 'password' ? '初始密码只用于第一次登录。修改成功后才可填写个人信息。' : '带 * 的信息会用于身份核验、学校管理范围和考试联系。'}

${content}
`; + } + + function passwordOnboardingForm() { + return `
新密码要求至少 8 位,且不能与初始密码相同。
`; + } + + async function renderCandidate(page) { + if (state.user?.role !== 'candidate') return requireLogin(); + app.classList.remove('admin-readable'); + if (state.user.mustChangePassword) { + app.innerHTML = onboardingShell('password', passwordOnboardingForm()); + return; + } + if (!state.profile?.profileCompleted) { + try { + 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; + } + const meta = { + dashboard: ['总览', '查看你的资料、报名、准考证与成绩状态。'], + profile: ['个人资料', '维护实名认证与联系方式;修改后需要重新审核。'], + exams: ['考试报名', '在开放时间内选择考试,并自主勾选报考科目。'], + 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' : 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), admissions: () => candidateAdmissions(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data) + }[page](); + app.innerHTML = portalShell('candidate', page, content, ...meta[page]); + if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); } + } catch (error) { renderError(error); } + } + + function candidateDashboard(data) { + const registration = data.registrations[0]; + const steps = [ + ['资料填写', Boolean(data.profile?.name), data.profile?.status === 'rejected' ? '请修改' : '已提交'], + ['资料审核', data.profile?.status === 'approved', statusLabels[data.profile?.status] || '待审核'], + ['考试报名', Boolean(registration), registration ? '已报名' : '未报名'], + ['准考证', Boolean(registration?.admitCard), registration?.admitCard ? '已生成' : '待生成'], + ['成绩发布', Boolean(data.results?.length), data.results?.length ? `已发布 ${data.results.length} 科` : '待发布'] + ]; + return `
${new Date().getHours() < 12 ? '上午好' : '下午好'}

${h(data.profile?.name || state.user.displayName)},下一步已为你标出。

${data.profile?.status === 'approved' ? (registration ? '报名已进入考务流程,请留意准考证下载时间。' : '个人资料已通过审核,现在可以选择考试和报考科目。') : '个人资料正在审核中,通过后即可进行考试报名。'}


${icons.user}
个人资料${statusLabels[data.profile?.status] || '未填写'}
${badge(data.profile?.status || 'pending')}
${icons.exam}
已报名考试${data.registrations.length} 场
${icons.ticket}
可下载准考证${data.registrations.filter(item => item.admitCard).length} 份
${icons.chart}
已发布成绩${data.results.length} 科

我的应考进度

自动更新
${steps.map((step, index) => `
${step[1] ? '✓' : index + 1}
${step[0]}${step[2]}
`).join('')}

最近通知

${data.notices.map(notice => ``).join('')}
`; + } + + function candidateProfile(data, onboarding = false) { + const { profile, schools = [], classes = [], workflow } = data; + const step = workflow?.currentStepDetail; + const idNumber = profile?.idNumber?.startsWith('PENDING-') ? '' : profile?.idNumber; + return `
${workflow ? `
当前审批${h(step?.name || statusLabels[workflow.status])}${workflow.assignee ? `由 ${h(workflow.assignee.displayName)} 处理` : '流程已结束'}
` : ''}
01

身份信息

姓名和证件号码须与有效证件完全一致。

02

学校与班级

学校和班级决定资料审批范围。

03

家庭与联系信息

用于考试通知、身份复核和紧急联系。

${profile?.reviewNote ? `
审核意见

${h(profile.reviewNote)}

` : ''}

${onboarding ? '提交后进入资料审批,审核通过即可报名考试。' : '保存后资料将按当前流程重新审批。'}

`; + } + + function candidateExams(data) { + return `
${data.exams.map(exam => `
${h(exam.code)}${badge(exam.registrationState)}
${exam.registrationCount || 0} 人已报名

${h(exam.name)}

${h(exam.description)}

报名期限
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
计分规则
总分 ${h(exam.totalScore)} · ${h(passPolicyText(exam))}
考点安排
${h(exam.location)}
选择报考科目可多选
${exam.subjects.map(subject => ``).join('') || '

科目安排尚未发布

'}
${exam.registration ? `
${icons.check}已提交报名 · ${exam.registration.subjectIds.length} 个科目${badge(exam.registration.status)}
` : `
已选 0满分 0 · ¥0.00
`}
`).join('')}
`; + } + + function candidateRegistrations(registrations) { + if (!registrations.length) return emptyState('还没有考试报名', '资料审核通过后,即可在“考试报名”中选择考试与科目。', 'candidate/exams', '去考试报名'); + const card = reg => `
${h(reg.exam.code)}

${h(reg.exam.name)}

${badge(reg.exam.archivedAt ? 'archived' : reg.status)}
账户报名号
${h(reg.registrationNumber || state.user.candidateNumber)}
当前审批
${h(reg.workflow?.currentStepDetail?.name || statusLabels[reg.workflow?.status] || '待提交')}
应缴金额
${money(reg.amountDue || 0)}
缴费状态
${badge(reg.paymentStatus)}${reg.paidAt ? `${formatDate(reg.paidAt, true)} · ${h(reg.paidByName || '班级负责人')}` : ''}
已选科目
${reg.subjects.map(subject => `${h(subject.name)}${h(subject.date)} ${h(subject.start)}`).join('')}

${reg.exam.archivedAt ? `本场于 ${formatDate(reg.exam.archivedAt, true)} 归档,以下信息仅供查阅。` : reg.reviewNote ? `审核意见:${h(reg.reviewNote)}` : reg.status === 'pending' ? '本次考试报名已进入审批,账户报名号不会改变。' : reg.paymentStatus === 'unpaid' ? '报名已通过,请线下完成缴费并等待班级负责人确认。' : '缴费已经确认,请留意准考证下载通知。'}

${reg.admitCard && !reg.exam.archivedAt ? `` : ''}
`; + const current = registrations.filter(reg => !reg.exam.archivedAt); + const archived = registrations.filter(reg => reg.exam.archivedAt); + return `
${current.map(card).join('')}
${archived.length ? `
历史报名记录${archived.length} 场归档考试 · 点击查阅${archived.length}
${archived.map(card).join('')}
` : ''}`; + } + + function candidateAdmit(registrations) { + const cards = registrations.filter(reg => reg.admitCard); + return cards.length ? `
${cards.map(reg => { + const now = Date.now(); + const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); + const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item])); + const subjectRows = reg.subjects.map(subject => { + const assignment = assignments.get(subject.id) || {}; + return `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · 考试考场序号 ${h(assignment.examRoomCode || '待定')} · ${h(assignment.roomName || assignment.room || '场地待定')}(${h(assignment.roomCode || '—')})· ${h(assignment.building || '楼栋待定')} ${h(assignment.floor || '')} · 座位 ${h(assignment.seat || '—')}`; + }).join(''); + const ticket = `
${h(reg.exam.code)}${badge(reg.exam.archivedAt ? 'archived' : open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}${h(reg.admitCard.centerCode || '')} · ${h(reg.admitCard.centerAddress || '详细地址待公布')}
逐科详细安排
${subjectRows}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
${reg.exam.archivedAt ? '历史准考证仅供查阅' : '下载后请使用 A4 纸横向打印'}
`; + return reg.exam.archivedAt ? `
${h(reg.exam.name)}${h(reg.exam.code)} · ${formatDate(reg.exam.archivedAt, true)} 归档查看历史准考证${ticket}
` : ticket; + }).join('')}
` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一编排准考证。', 'candidate/registrations', '查看报名状态'); + } + + function candidateResults(data) { + const { results, summaries = [] } = data; + if (!results.length) return emptyState('暂时没有已发布成绩', '成绩发布后会在这里显示,同时首页会发布查分通知。', 'candidate/notices', '查看通知'); + const grouped = Object.groupBy ? Object.groupBy(results, item => item.examId) : results.reduce((acc, item) => ((acc[item.examId] ||= []).push(item), acc), {}); + const completeSummaries = summaries.filter(item => item.complete); + const overview = `
已发布考试${Object.keys(grouped).length}
已发布科目${results.length}
整场已合格${completeSummaries.filter(item => item.qualified === true).length}
复议处理中${results.filter(item => item.appeal?.status === 'pending').length}
`; + return `${overview}
${Object.entries(grouped).sort(([, left], [, right]) => new Date(right[0]?.examStart || 0) - new Date(left[0]?.examStart || 0)).map(([, items]) => { + const examName = items[0].examName; + const summary = summaries.find(item => item.examId === items[0].examId); + const stateText = !summary?.complete ? '等待全部科目发布' : summary.qualified == null ? '本考试不判定合格' : summary.qualified ? '合格' : '未达合格线'; + const detail = summary?.passPolicy === 'rank_percent' && summary.complete ? `第 ${summary.rank} / ${summary.cohortSize} 名` : summary ? passPolicyText(summary) : ''; + const scores = items.map(item => { + const appeal = item.appeal; + const latestAction = appeal?.actions?.at(-1); + const appealPanel = item.archivedAt + ? `
${badge('archived')}本场成绩已永久锁定,复议入口已关闭
` + : appeal?.status === 'pending' + ? `
${badge('pending')}${h(appeal.currentStepDetail?.name || '等待处理')} · ${h(appeal.assignee?.displayName || '待分配')}
` + : appeal?.status === 'approved' + ? `
${badge('approved')}${h(latestAction?.note || '复议流程已完成')}
` + : `${appeal ? `
${badge('rejected')}${h(latestAction?.note || '可补充理由后重新提交')}
` : ''}
`; + const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified'; + return `
${h(item.subjectName)}${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}
${h(item.score)} / ${h(item.fullScore)}${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%
本科排名${h(item.passText || '不设单科线')}
${appealPanel}
`; + }).join(''); + const panel = `
${h(items[0].examCode)}

${h(examName)}

${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}
当前总分${h(summary?.total ?? '—')} / ${h(summary?.fullScore ?? '—')}科目等级按排名特征分${h(summary?.featureScore ?? 0)}独立于考试科目整场合格判定${h(stateText)}${h(detail)}发布进度${h(summary?.publishedSubjects ?? items.length)} / ${h(summary?.subjectCount ?? items.length)} 科${summary?.complete ? '成绩已出齐' : '持续发布中'}
${scores}

${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}

`; + return items[0].archivedAt ? `
${h(examName)}${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定历史成绩${panel}
` : panel; + }).join('')}
`; + } + + 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 `
ADMISSION${invalid ? '失' : '录'}
${invalid ? '录取状态已更新' : '录取结果已发布'}

${h(notification.payload?.title || '录取结果通知')}

${h(notification.payload?.message || '录取结果已经发布,请核对以下信息。')}

录取学校
${h(notification.schoolName || '招生学校')}
招生类别
${h(notification.categoryName || '以录取通知书为准')}
所属考试
${h(notification.examName || '—')}
${notification.noticeNumber ? `
通知书编号
${h(notification.noticeNumber)}
` : ''}
${invalid ? '资格已失效' : '正式录取'}
`; + }).join(''); + return `${notificationCards ? `
${notificationCards}
` : ''}
${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 `
${preferenceType === 'indicator' ? '指标' : index + 1}
`; + }; + 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 `${choice.preferenceType === 'indicator' ? '指标' : index + 1}${h(schoolName)}${h([schoolCode, categoryName].filter(Boolean).join(' · '))}`; }).join(''); + const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生'; + const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格'; + return `
${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮

${h(item.exam.name)}

${badge(item.status)}
${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `
${index < progressIndex ? '✓' : index + 1}${label}
`).join('')}
本场总成绩${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}特征分 ${h(item.featureScore || 0)}特长类型 ${h(qualification)}指标资格 ${h(indicatorText)}${h(phaseLabels[item.status] || item.status)}

${h(item.payload.progress || '等待录取工作更新')}

${item.placement ? `
当前结果${h(placementSchool)} · ${h(item.placement.payload.categoryName)}${item.noticeNumber ? `录取通知书编号:${h(item.noticeNumber)}` : ''}${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}${item.placement.status === 'final' ? `` : ''}
` : ''}${canFill ? `
1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。
已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次
${choiceRows}
` : choices.length ? `
${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}${lockedRows}
` : `
${h(item.supplementIneligibilityReason || (item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'))}
`}
`; + }).join('')}
`; + } + + function candidateNotices(notices) { + return `
${notices.map(notice => ``).join('')}
`; + } + + function accountSecurity(totp = {}) { + const account = h(state.user?.candidateNumber || state.user?.username); + const type = state.user?.role === 'candidate' ? '考生账户' : statusLabels[state.user?.adminLevel] || '管理员账户'; + const password = ``; + const totpPanel = totp.enabled + ? `` + : ``; + return ``; + } + + return { adminNavForUser, portalShell, loadingPanel, renderCandidate, accountSecurity }; +} diff --git a/src/client/pdf-export.mjs b/src/client/pdf-export.mjs new file mode 100644 index 0000000..141bc81 --- /dev/null +++ b/src/client/pdf-export.mjs @@ -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`); +} diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs new file mode 100644 index 0000000..bf0c1f0 --- /dev/null +++ b/src/client/public-views.mjs @@ -0,0 +1,161 @@ +import { filterTableItems } from './table-state.mjs'; + +export function createPublicViews(context) { + const { + state, + app, + h, + formatDate, + dateRange, + badge, + money, + passPolicyText, + statusLabels, + icons, + api, + renderError, + emptyState + } = context; + + function brand() { + return `衡准EXAM SERVICE`; + } + + function publicHeader() { + return `
${brand()}
`; + } + + function renderHome() { + app.classList.remove('admin-readable'); + const { notices, exams, stats, organization } = state.publicData; + const siteCopy = state.publicData.siteCopy || {}; + const featured = exams.find(exam => exam.registrationState === 'open') || exams[0]; + const topNotice = notices[0]; + app.innerHTML = `${publicHeader()}
+
+
+
最新

${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}

${h(siteCopy.heroTitle || '一个报名号,')}
${h(siteCopy.heroHighlight || '贯穿每一次考试。')}

${h(siteCopy.heroDescription || '')}

${state.user?.role === 'candidate' ? `` : state.publicData.selfRegistrationEnabled ? `` : ``}
${h(stats.candidates || 0)}在册考生
${h(stats.registrations || 0)}报名记录
${h(stats.exams || 0)}开放考试
+ ${featured ? renderHeroTicket(featured) : '
暂无开放考试
'} +
+
+

NOTICE BOARD

通知公告

招生录取公示已纳入通知公告,可按类别统一查询。

${notices.slice(1, 5).map(renderNoticeRow).join('') || '
暂无更多通知
'}
+

OPEN EXAMINATIONS

考试报名

登录后选择考试,并按实际需要勾选报考科目。

${exams.map(renderPublicExam).join('') || '
当前没有已发布的考试
'}
+

SERVICE FLOW

报名号是唯一账户

报名号不会随考试改变,每场考试只新增一条报名记录。

${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `
${item[0]}

${item[1]}

${item[2]}

`).join('')}
+
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

${organization.address || organization.email ? `

${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}

` : ''}
${h(siteCopy.footerNotice || '')}
`; + } + + 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 ``; + } + + function renderPublicQualification(document) { + const key = `publicQualification-${document.documentId}`; + const page = publicPaged(document.rows.map(row => ({ ...row, status: row.eligible ? 'eligible' : 'ineligible' })), key); + return `

本公示由生源校完成全部考生资格确认后自动生成。

${page.items.map(row => ``).join('') || ''}
报名号姓名指标分配资格特长类型
${h(row.registrationNumber)}${h(row.name)}${row.eligible ? '有' : '无'}${h(row.specialtyLabel || '普通生')}
没有符合条件的资格记录
${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 `

${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。

${page.items.map(row => ``).join('') || ''}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
没有符合条件的录取记录
${publicPagination(page)}`; + } + + function renderDocumentBody(document) { + if (document.documentType === 'notice') return `
${document.contentHtml || `

${h(document.content || '').replace(/\r?\n/g, '

')}

`}
`; + if (document.documentType === 'plan') return `

招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。

${document.rows.map(row => ``).join('')}
类别代码招生类别计划人数其中定向指标指标分配
${h(row.code)}${h(row.name)}${h(row.specialtyLabel || '普通 / 政策类')}${h(row.quota)} 人${h(row.indicatorQuota || 0)} 人${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人`).join('
') : '无定向指标'}
${document.note ? `

计划说明:${h(document.note)}

` : ''}`; + if (document.documentType === 'qualification') return renderPublicQualification(document); + if (document.documentType === 'admission') return renderPublicAdmission(document); + if (document.documentType === 'reporting') { + const stats = document.statistics || {}; + return `

本公示由招生学校提交报到情况和补录决定,经超级管理员审批后自动发布。

招生计划${h(stats.totalQuota || 0)}
正式录取${h(stats.finalCount || 0)}
已报到${h(stats.reportedCount || 0)}
计划完成率${h(stats.reportingRate || 0)}%按实际报到

学校说明:${h(document.decisionNote || (document.supplementDecision === 'supplement' ? '学校申请补录并已获批准。' : '本轮不进行补录。'))}

`; + } + return `

录取分数线为对应学校、招生类别最终录取考生的最低总成绩。

${document.rows.map(row => ``).join('')}
招生学校招生类别计划数录取数最高分录取分数线
${h(row.schoolName)}${h(row.categoryName)}${h(row.planQuota)}${h(row.admittedCount)}${h(row.highestScore)}${h(row.cutoffScore)}
`; + } + + 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()}
/${h(selected.subtype)}
${h(selected.category)} · ${h(selected.subtype)}

${h(selected.title)}

${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}

${renderDocumentBody(selected)}
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

公开信息以本页面正式发布内容为准
`; + 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()}

PUBLIC NOTICE ARCHIVE

通知公告

考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。

${h(documents.length)}份公开文件
${h(category)}第 ${h(page)} / ${h(totalPages)} 页
共 ${h(filtered.length)} 条
${pageRows.map(item => ``).join('') || '
当前分类暂无公开信息
'}
${Array.from({length:totalPages},(_,index) => index + 1).map(value => ``).join('')}
${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

录取公示为通知公告中的公开类别
`; + } + + function renderHeroTicket(exam) { + const status = exam.registrationState; + return `
${badge(status)}${h(exam.code)}

UPCOMING EXAM

${h(exam.name)}

报名时间
${dateRange(exam.registrationStart, exam.registrationEnd)}
考试时间
${dateRange(exam.examStart, exam.examEnd)}
考试地点
${h(exam.location)}
${exam.subjects.slice(0, 5).map(subject => `${h(subject.name)}`).join('')}${exam.subjects.length > 5 ? `+${exam.subjects.length - 5}` : ''}
报名人数${h(exam.registrationCount || 0)}
`; + } + + function renderNoticeRow(notice) { + return ``; + } + + function renderPublicExam(exam) { + return `
${h(exam.code)}${badge(exam.registrationState)}

${h(exam.name)}

${h(exam.description)}

报名${dateRange(exam.registrationStart, exam.registrationEnd)}考试${dateRange(exam.examStart, exam.examEnd)}总分${h(exam.totalScore)} 分 · ${h(passPolicyText(exam))}
${exam.subjects.length} 个科目 · ${exam.registrationCount || 0} 人已报名
`; + } + + function renderAuth(kind) { + app.classList.remove('admin-readable'); + const login = kind === 'login'; + const selfRegistration = state.publicData.selfRegistrationEnabled; + const authNotice = login && state.authNotice ? `
需要重新登录${h(state.authNotice)}
` : ''; + app.innerHTML = `
${brand()}

CANDIDATE SERVICE

${login ? '凭一个号码,' : '自主申请,'}
${login ? '办理每一次考试。' : '领取固定报名号。'}

报名号就是考生账户,不因考试、科目或年度报名而改变。

首次登录顺序

修改初始密码 → 补全个人信息 → 等待资料审核。

${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}

${login ? '报名号登录' : '自主申请报名号'}

${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}

${authNotice}${login ? loginForm() : selfRegistration ? registerForm() : '
自主注册已关闭学校管理员会为考生创建账户并下发初始密码。
'}${login && selfRegistration ? `
还没有报名号?
` : !login ? '
已经有报名号?
' : ''}
`; + } + + function renderVerification(code = '', result = null, error = '') { + app.classList.remove('admin-readable'); + const organization = state.publicData.organization || {}; + const document = result?.document; + const outcome = document ? `
VERIFIED DOCUMENT

文书真实有效

该查询码由系统签发,当前数据与签发记录一致。

文书类型
${h(document.typeName)}
${document.noticeNumber ? `
通知书编号
${h(document.noticeNumber)}
` : ''}
考生
${h(document.candidateName)}
考试
${h(document.examName)}
${document.schoolName ? `
录取学校
${h(document.schoolName)}
` : ''}${document.categoryName ? `
录取类别
${h(document.categoryName)}
` : ''}${document.totalScore != null ? `
成绩摘要
${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}
` : ''}
签发时间
${formatDate(document.issuedAt, true)}
` : error ? `
!
NOT VERIFIED

未找到有效文书

${h(error)}

` : ''; + app.innerHTML = `${publicHeader()}

DOCUMENT AUTHENTICITY

文书防伪查询

输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。

${outcome}
安全提示

查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。

${brand()}

${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}

系统签名实时核验
`; + } + + function loginForm() { + return `
`; + } + + function registerForm() { + const schools = state.publicData.schools || []; + return `
`; + } + + return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification }; +} diff --git a/src/client/region-select.mjs b/src/client/region-select.mjs new file mode 100644 index 0000000..89108e4 --- /dev/null +++ b/src/client/region-select.mjs @@ -0,0 +1,42 @@ +import { chinaRegions } from '../data/china-regions.mjs'; + +const option = (value, label, selected = false) => ``; + +export function regionSelects(region = {}, { required = true, className = 'region-selects' } = {}) { + const province = chinaRegions.find(item => item.code === region.provinceCode); + const city = province?.cities.find(item => item.code === region.cityCode); + const requiredText = required ? 'required' : ''; + return `
+ + + +
`; +} + +export function updateRegionSelects(select) { + const group = select.closest('[data-region-group]'); + if (!group) return; + const provinceSelect = group.querySelector('[name="provinceCode"]'); + const citySelect = group.querySelector('[name="cityCode"]'); + const districtSelect = group.querySelector('[name="districtCode"]'); + const province = chinaRegions.find(item => item.code === provinceSelect?.value); + if (select.dataset.regionLevel === 'province') { + citySelect.innerHTML = `${(province?.cities || []).map(item => option(item.code, item.name)).join('')}`; + districtSelect.innerHTML = ''; + } else if (select.dataset.regionLevel === 'city') { + const city = province?.cities.find(item => item.code === citySelect?.value); + districtSelect.innerHTML = `${(city?.districts || []).map(item => option(item.code, item.name)).join('')}`; + } +} + +export function mountRegionSelects(root, region = {}, options = {}) { + const address = root?.querySelector('[name="address"]'); + if (!address || root.querySelector('[data-region-group]')) return; + address.closest('label')?.insertAdjacentHTML('beforebegin', regionSelects(region, options)); +} + +export function formatRegionAddress(region = {}) { + const parts = [region.provinceName, region.cityName, region.districtName] + .filter((value, index, values) => value && value !== values[index - 1]); + return [...parts, region.address].filter(Boolean).join(''); +} diff --git a/src/client/state.mjs b/src/client/state.mjs new file mode 100644 index 0000000..d66f51a --- /dev/null +++ b/src/client/state.mjs @@ -0,0 +1,20 @@ +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 +}; diff --git a/src/client/table-state.mjs b/src/client/table-state.mjs new file mode 100644 index 0000000..c819c6f --- /dev/null +++ b/src/client/table-state.mjs @@ -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); +} diff --git a/src/client/ui.mjs b/src/client/ui.mjs new file mode 100644 index 0000000..9615b78 --- /dev/null +++ b/src/client/ui.mjs @@ -0,0 +1,58 @@ +export const statusLabels = { + pending: '待审核', approved: '已通过', rejected: '需修改', + 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 = { + home: '', + user: '', + exam: '', + ticket: '', + chart: '', + bell: '', + users: '', + check: '', + plus: '', + logout: '', + menu: '', + search: '', + arrow: '' +}; + +export function h(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); +} + +export function passPolicyText(exam) { + const value = Number(exam?.passValue ?? 60); + return { + fixed_score: `总分达到 ${value} 分`, + score_ratio: `总成绩排名前 ${value}%`, + rank_percent: `总成绩排名前 ${value}%`, + subject_scores: '所有报考科目均达单科线', + none: '仅发布成绩,不判定合格' + }[exam?.passPolicy || 'rank_percent']; +} + +export function formatDate(value, withTime = false) { + if (!value) return '待定'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return h(value); + return new Intl.DateTimeFormat('zh-CN', withTime ? { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); +} + +export function dateRange(start, end) { + return `${formatDate(start)} — ${formatDate(end)}`; +} + +export function badge(status) { + return `${h(statusLabels[status] || status)}`; +} + +export function money(value) { + return `¥${Number(value || 0).toFixed(2)}`; +} diff --git a/src/data/base.mjs b/src/data/base.mjs new file mode 100644 index 0000000..8a3b6d2 --- /dev/null +++ b/src/data/base.mjs @@ -0,0 +1,90 @@ +import { CURRENT_SCHEMA_VERSION } from '../database/version.mjs'; + +const admissionNumberRules = (nowIso) => [ + { + id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号', + description: '适合县区统一组织,号码直接反映县区、考试考场与座位。', separator: '', example: '32070603108', active: true, createdAt: nowIso(), + segments: [ + { source: 'district_code', label: '县区编号', width: 6 }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'seat', label: '座位号', width: 2 } + ] + }, + { + id: 'admit_rule_district_room_sequence', code: 'district_room_sequence', name: '县区号 + 考场号 + 流水号', + description: '以县区为流水边界,适合不希望座位号直接出现在号码中的场景。', separator: '', example: '3207060310028', active: true, createdAt: nowIso(), + segments: [ + { source: 'district_code', label: '县区号', width: 6 }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'sequence', label: '流水号', width: 4 } + ] + }, + { + id: 'admit_rule_center_school_room_seat', code: 'center_school_room_seat', name: '考点学校代码 + 考场号 + 座位号', + description: '号码前缀取考点所属学校代码,便于考点现场快速识别。', separator: '', example: 'HZ0303108', active: true, createdAt: nowIso(), + segments: [ + { source: 'center_school_code', label: '考点学校代码' }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'seat', label: '座位号', width: 2 } + ] + }, + { + id: 'admit_rule_candidate_school_room_seat', code: 'candidate_school_room_seat', name: '考生学校代码 + 考场号 + 座位号', + description: '号码前缀保留考生学籍学校代码,适合按生源学校归档。', separator: '', example: 'HZ0103108', active: true, createdAt: nowIso(), + segments: [ + { source: 'candidate_school_code', label: '考生学校代码' }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'seat', label: '座位号', width: 2 } + ] + } +]; + +const workflows = (adminId, nowIso) => [ + { id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' }, + { id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' }, + { id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' } + ] }, + { id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' } + ] }, + { id: 'workflow_score_appeal', businessType: 'score_appeal', name: '考生成绩复议', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_score_appeal_step_1', position: 1, name: '班级情况核验', adminLevel: 'class' }, + { id: 'workflow_score_appeal_step_2', position: 2, name: '学校成绩复核', adminLevel: 'school' }, + { id: 'workflow_score_appeal_step_3', position: 3, name: '考试中心终审', adminLevel: 'super' } + ] } +]; + +export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} }) { + const adminId = 'usr_admin'; + const createdAt = nowIso(); + return { + meta: { version: CURRENT_SCHEMA_VERSION, createdAt }, + settings: { selfRegistrationEnabled: false }, + organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' }, + schools: [], classes: [], + users: [{ + id: adminId, username: initialAdmin.username || 'admin', passwordHash: hashPassword(initialAdmin.password || 'Admin123!'), + role: 'admin', adminLevel: 'super', displayName: initialAdmin.displayName || '系统管理员', active: true, createdAt + }], + candidateProfiles: [], notices: [], exams: [], registrations: [], results: [], + testCenters: [], testRooms: [], centerChangeRequests: [], centerChangeRooms: [], + admissionNumberRules: admissionNumberRules(nowIso), arrangementPlans: [], + candidateAccountBatches: [], candidateAccountBatchItems: [], + numberRules: [{ + id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [ + { id: 'segment_year', position: 1, type: 'year', value: '', width: 4 }, + { id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 }, + { id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 }, + { id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 } + ] + }], + workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], admissionRecords: [], auditLogs: [] + }; +} diff --git a/src/data/china-regions.mjs b/src/data/china-regions.mjs new file mode 100644 index 0000000..e8d6f45 --- /dev/null +++ b/src/data/china-regions.mjs @@ -0,0 +1,5 @@ +// Generated from AreaCity-JsSpider-StatsGov release 2025.251231.260403. +// Source snapshot: 国家地名信息库 2025-12-31; generated 2026-07-20. +// Manual official additions: 和康县 653228, 和安县 653229. +export const chinaRegionsVersion = '2025-12-31'; +export const chinaRegions = [{"code":"110000","name":"北京市","cities":[{"code":"110100","name":"北京市","districts":[{"code":"110101","name":"东城区"},{"code":"110102","name":"西城区"},{"code":"110105","name":"朝阳区"},{"code":"110106","name":"丰台区"},{"code":"110107","name":"石景山区"},{"code":"110108","name":"海淀区"},{"code":"110109","name":"门头沟区"},{"code":"110111","name":"房山区"},{"code":"110112","name":"通州区"},{"code":"110113","name":"顺义区"},{"code":"110114","name":"昌平区"},{"code":"110115","name":"大兴区"},{"code":"110116","name":"怀柔区"},{"code":"110117","name":"平谷区"},{"code":"110118","name":"密云区"},{"code":"110119","name":"延庆区"}]}]},{"code":"120000","name":"天津市","cities":[{"code":"120100","name":"天津市","districts":[{"code":"120101","name":"和平区"},{"code":"120102","name":"河东区"},{"code":"120103","name":"河西区"},{"code":"120104","name":"南开区"},{"code":"120105","name":"河北区"},{"code":"120106","name":"红桥区"},{"code":"120110","name":"东丽区"},{"code":"120111","name":"西青区"},{"code":"120112","name":"津南区"},{"code":"120113","name":"北辰区"},{"code":"120114","name":"武清区"},{"code":"120115","name":"宝坻区"},{"code":"120116","name":"滨海新区"},{"code":"120117","name":"宁河区"},{"code":"120118","name":"静海区"},{"code":"120119","name":"蓟州区"}]}]},{"code":"130000","name":"河北省","cities":[{"code":"130100","name":"石家庄市","districts":[{"code":"130102","name":"长安区"},{"code":"130104","name":"桥西区"},{"code":"130105","name":"新华区"},{"code":"130107","name":"井陉矿区"},{"code":"130108","name":"裕华区"},{"code":"130109","name":"藁城区"},{"code":"130110","name":"鹿泉区"},{"code":"130111","name":"栾城区"},{"code":"130121","name":"井陉县"},{"code":"130123","name":"正定县"},{"code":"130125","name":"行唐县"},{"code":"130126","name":"灵寿县"},{"code":"130127","name":"高邑县"},{"code":"130128","name":"深泽县"},{"code":"130129","name":"赞皇县"},{"code":"130130","name":"无极县"},{"code":"130131","name":"平山县"},{"code":"130132","name":"元氏县"},{"code":"130133","name":"赵县"},{"code":"130181","name":"辛集市"},{"code":"130183","name":"晋州市"},{"code":"130184","name":"新乐市"}]},{"code":"130200","name":"唐山市","districts":[{"code":"130202","name":"路南区"},{"code":"130203","name":"路北区"},{"code":"130204","name":"古冶区"},{"code":"130205","name":"开平区"},{"code":"130207","name":"丰南区"},{"code":"130208","name":"丰润区"},{"code":"130209","name":"曹妃甸区"},{"code":"130224","name":"滦南县"},{"code":"130225","name":"乐亭县"},{"code":"130227","name":"迁西县"},{"code":"130229","name":"玉田县"},{"code":"130281","name":"遵化市"},{"code":"130283","name":"迁安市"},{"code":"130284","name":"滦州市"}]},{"code":"130300","name":"秦皇岛市","districts":[{"code":"130302","name":"海港区"},{"code":"130303","name":"山海关区"},{"code":"130304","name":"北戴河区"},{"code":"130306","name":"抚宁区"},{"code":"130321","name":"青龙满族自治县"},{"code":"130322","name":"昌黎县"},{"code":"130324","name":"卢龙县"}]},{"code":"130400","name":"邯郸市","districts":[{"code":"130402","name":"邯山区"},{"code":"130403","name":"丛台区"},{"code":"130404","name":"复兴区"},{"code":"130406","name":"峰峰矿区"},{"code":"130407","name":"肥乡区"},{"code":"130408","name":"永年区"},{"code":"130423","name":"临漳县"},{"code":"130424","name":"成安县"},{"code":"130425","name":"大名县"},{"code":"130426","name":"涉县"},{"code":"130427","name":"磁县"},{"code":"130430","name":"邱县"},{"code":"130431","name":"鸡泽县"},{"code":"130432","name":"广平县"},{"code":"130433","name":"馆陶县"},{"code":"130434","name":"魏县"},{"code":"130435","name":"曲周县"},{"code":"130481","name":"武安市"}]},{"code":"130500","name":"邢台市","districts":[{"code":"130502","name":"襄都区"},{"code":"130503","name":"信都区"},{"code":"130505","name":"任泽区"},{"code":"130506","name":"南和区"},{"code":"130522","name":"临城县"},{"code":"130523","name":"内丘县"},{"code":"130524","name":"柏乡县"},{"code":"130525","name":"隆尧县"},{"code":"130528","name":"宁晋县"},{"code":"130529","name":"巨鹿县"},{"code":"130530","name":"新河县"},{"code":"130531","name":"广宗县"},{"code":"130532","name":"平乡县"},{"code":"130533","name":"威县"},{"code":"130534","name":"清河县"},{"code":"130535","name":"临西县"},{"code":"130581","name":"南宫市"},{"code":"130582","name":"沙河市"}]},{"code":"130600","name":"保定市","districts":[{"code":"130602","name":"竞秀区"},{"code":"130606","name":"莲池区"},{"code":"130607","name":"满城区"},{"code":"130608","name":"清苑区"},{"code":"130609","name":"徐水区"},{"code":"130623","name":"涞水县"},{"code":"130624","name":"阜平县"},{"code":"130626","name":"定兴县"},{"code":"130627","name":"唐县"},{"code":"130628","name":"高阳县"},{"code":"130629","name":"容城县"},{"code":"130630","name":"涞源县"},{"code":"130631","name":"望都县"},{"code":"130632","name":"安新县"},{"code":"130633","name":"易县"},{"code":"130634","name":"曲阳县"},{"code":"130635","name":"蠡县"},{"code":"130636","name":"顺平县"},{"code":"130637","name":"博野县"},{"code":"130638","name":"雄县"},{"code":"130681","name":"涿州市"},{"code":"130682","name":"定州市"},{"code":"130683","name":"安国市"},{"code":"130684","name":"高碑店市"}]},{"code":"130700","name":"张家口市","districts":[{"code":"130702","name":"桥东区"},{"code":"130703","name":"桥西区"},{"code":"130705","name":"宣化区"},{"code":"130706","name":"下花园区"},{"code":"130708","name":"万全区"},{"code":"130709","name":"崇礼区"},{"code":"130722","name":"张北县"},{"code":"130723","name":"康保县"},{"code":"130724","name":"沽源县"},{"code":"130725","name":"尚义县"},{"code":"130726","name":"蔚县"},{"code":"130727","name":"阳原县"},{"code":"130728","name":"怀安县"},{"code":"130730","name":"怀来县"},{"code":"130731","name":"涿鹿县"},{"code":"130732","name":"赤城县"}]},{"code":"130800","name":"承德市","districts":[{"code":"130802","name":"双桥区"},{"code":"130803","name":"双滦区"},{"code":"130804","name":"鹰手营子矿区"},{"code":"130821","name":"承德县"},{"code":"130822","name":"兴隆县"},{"code":"130824","name":"滦平县"},{"code":"130825","name":"隆化县"},{"code":"130826","name":"丰宁满族自治县"},{"code":"130827","name":"宽城满族自治县"},{"code":"130828","name":"围场满族蒙古族自治县"},{"code":"130881","name":"平泉市"}]},{"code":"130900","name":"沧州市","districts":[{"code":"130902","name":"新华区"},{"code":"130903","name":"运河区"},{"code":"130921","name":"沧县"},{"code":"130922","name":"青县"},{"code":"130923","name":"东光县"},{"code":"130924","name":"海兴县"},{"code":"130925","name":"盐山县"},{"code":"130926","name":"肃宁县"},{"code":"130927","name":"南皮县"},{"code":"130928","name":"吴桥县"},{"code":"130929","name":"献县"},{"code":"130930","name":"孟村回族自治县"},{"code":"130981","name":"泊头市"},{"code":"130982","name":"任丘市"},{"code":"130983","name":"黄骅市"},{"code":"130984","name":"河间市"}]},{"code":"131000","name":"廊坊市","districts":[{"code":"131002","name":"安次区"},{"code":"131003","name":"广阳区"},{"code":"131022","name":"固安县"},{"code":"131023","name":"永清县"},{"code":"131024","name":"香河县"},{"code":"131025","name":"大城县"},{"code":"131026","name":"文安县"},{"code":"131028","name":"大厂回族自治县"},{"code":"131081","name":"霸州市"},{"code":"131082","name":"三河市"}]},{"code":"131100","name":"衡水市","districts":[{"code":"131102","name":"桃城区"},{"code":"131103","name":"冀州区"},{"code":"131121","name":"枣强县"},{"code":"131122","name":"武邑县"},{"code":"131123","name":"武强县"},{"code":"131124","name":"饶阳县"},{"code":"131125","name":"安平县"},{"code":"131126","name":"故城县"},{"code":"131127","name":"景县"},{"code":"131128","name":"阜城县"},{"code":"131182","name":"深州市"}]}]},{"code":"140000","name":"山西省","cities":[{"code":"140100","name":"太原市","districts":[{"code":"140105","name":"小店区"},{"code":"140106","name":"迎泽区"},{"code":"140107","name":"杏花岭区"},{"code":"140108","name":"尖草坪区"},{"code":"140109","name":"万柏林区"},{"code":"140110","name":"晋源区"},{"code":"140121","name":"清徐县"},{"code":"140122","name":"阳曲县"},{"code":"140123","name":"娄烦县"},{"code":"140181","name":"古交市"}]},{"code":"140200","name":"大同市","districts":[{"code":"140212","name":"新荣区"},{"code":"140213","name":"平城区"},{"code":"140214","name":"云冈区"},{"code":"140215","name":"云州区"},{"code":"140221","name":"阳高县"},{"code":"140222","name":"天镇县"},{"code":"140223","name":"广灵县"},{"code":"140224","name":"灵丘县"},{"code":"140225","name":"浑源县"},{"code":"140226","name":"左云县"}]},{"code":"140300","name":"阳泉市","districts":[{"code":"140302","name":"城区"},{"code":"140303","name":"矿区"},{"code":"140311","name":"郊区"},{"code":"140321","name":"平定县"},{"code":"140322","name":"盂县"}]},{"code":"140400","name":"长治市","districts":[{"code":"140403","name":"潞州区"},{"code":"140404","name":"上党区"},{"code":"140405","name":"屯留区"},{"code":"140406","name":"潞城区"},{"code":"140423","name":"襄垣县"},{"code":"140425","name":"平顺县"},{"code":"140426","name":"黎城县"},{"code":"140427","name":"壶关县"},{"code":"140428","name":"长子县"},{"code":"140429","name":"武乡县"},{"code":"140430","name":"沁县"},{"code":"140431","name":"沁源县"}]},{"code":"140500","name":"晋城市","districts":[{"code":"140502","name":"城区"},{"code":"140521","name":"沁水县"},{"code":"140522","name":"阳城县"},{"code":"140524","name":"陵川县"},{"code":"140525","name":"泽州县"},{"code":"140581","name":"高平市"}]},{"code":"140600","name":"朔州市","districts":[{"code":"140602","name":"朔城区"},{"code":"140603","name":"平鲁区"},{"code":"140621","name":"山阴县"},{"code":"140622","name":"应县"},{"code":"140623","name":"右玉县"},{"code":"140681","name":"怀仁市"}]},{"code":"140700","name":"晋中市","districts":[{"code":"140702","name":"榆次区"},{"code":"140703","name":"太谷区"},{"code":"140721","name":"榆社县"},{"code":"140722","name":"左权县"},{"code":"140723","name":"和顺县"},{"code":"140724","name":"昔阳县"},{"code":"140725","name":"寿阳县"},{"code":"140727","name":"祁县"},{"code":"140728","name":"平遥县"},{"code":"140729","name":"灵石县"},{"code":"140781","name":"介休市"}]},{"code":"140800","name":"运城市","districts":[{"code":"140802","name":"盐湖区"},{"code":"140821","name":"临猗县"},{"code":"140822","name":"万荣县"},{"code":"140823","name":"闻喜县"},{"code":"140824","name":"稷山县"},{"code":"140825","name":"新绛县"},{"code":"140826","name":"绛县"},{"code":"140827","name":"垣曲县"},{"code":"140828","name":"夏县"},{"code":"140829","name":"平陆县"},{"code":"140830","name":"芮城县"},{"code":"140881","name":"永济市"},{"code":"140882","name":"河津市"}]},{"code":"140900","name":"忻州市","districts":[{"code":"140902","name":"忻府区"},{"code":"140921","name":"定襄县"},{"code":"140922","name":"五台县"},{"code":"140923","name":"代县"},{"code":"140924","name":"繁峙县"},{"code":"140925","name":"宁武县"},{"code":"140926","name":"静乐县"},{"code":"140927","name":"神池县"},{"code":"140928","name":"五寨县"},{"code":"140929","name":"岢岚县"},{"code":"140930","name":"河曲县"},{"code":"140931","name":"保德县"},{"code":"140932","name":"偏关县"},{"code":"140981","name":"原平市"}]},{"code":"141000","name":"临汾市","districts":[{"code":"141002","name":"尧都区"},{"code":"141021","name":"曲沃县"},{"code":"141022","name":"翼城县"},{"code":"141023","name":"襄汾县"},{"code":"141024","name":"洪洞县"},{"code":"141025","name":"古县"},{"code":"141026","name":"安泽县"},{"code":"141027","name":"浮山县"},{"code":"141028","name":"吉县"},{"code":"141029","name":"乡宁县"},{"code":"141030","name":"大宁县"},{"code":"141031","name":"隰县"},{"code":"141032","name":"永和县"},{"code":"141033","name":"蒲县"},{"code":"141034","name":"汾西县"},{"code":"141081","name":"侯马市"},{"code":"141082","name":"霍州市"}]},{"code":"141100","name":"吕梁市","districts":[{"code":"141102","name":"离石区"},{"code":"141121","name":"文水县"},{"code":"141122","name":"交城县"},{"code":"141123","name":"兴县"},{"code":"141124","name":"临县"},{"code":"141125","name":"柳林县"},{"code":"141126","name":"石楼县"},{"code":"141127","name":"岚县"},{"code":"141128","name":"方山县"},{"code":"141129","name":"中阳县"},{"code":"141130","name":"交口县"},{"code":"141181","name":"孝义市"},{"code":"141182","name":"汾阳市"}]}]},{"code":"150000","name":"内蒙古自治区","cities":[{"code":"150100","name":"呼和浩特市","districts":[{"code":"150102","name":"新城区"},{"code":"150103","name":"回民区"},{"code":"150104","name":"玉泉区"},{"code":"150105","name":"赛罕区"},{"code":"150121","name":"土默特左旗"},{"code":"150122","name":"托克托县"},{"code":"150123","name":"和林格尔县"},{"code":"150124","name":"清水河县"},{"code":"150125","name":"武川县"}]},{"code":"150200","name":"包头市","districts":[{"code":"150202","name":"东河区"},{"code":"150203","name":"昆都仑区"},{"code":"150204","name":"青山区"},{"code":"150205","name":"石拐区"},{"code":"150206","name":"白云鄂博矿区"},{"code":"150207","name":"九原区"},{"code":"150221","name":"土默特右旗"},{"code":"150222","name":"固阳县"},{"code":"150223","name":"达尔罕茂明安联合旗"}]},{"code":"150300","name":"乌海市","districts":[{"code":"150302","name":"海勃湾区"},{"code":"150303","name":"海南区"},{"code":"150304","name":"乌达区"}]},{"code":"150400","name":"赤峰市","districts":[{"code":"150402","name":"红山区"},{"code":"150403","name":"元宝山区"},{"code":"150404","name":"松山区"},{"code":"150421","name":"阿鲁科尔沁旗"},{"code":"150422","name":"巴林左旗"},{"code":"150423","name":"巴林右旗"},{"code":"150424","name":"林西县"},{"code":"150425","name":"克什克腾旗"},{"code":"150426","name":"翁牛特旗"},{"code":"150428","name":"喀喇沁旗"},{"code":"150429","name":"宁城县"},{"code":"150430","name":"敖汉旗"}]},{"code":"150500","name":"通辽市","districts":[{"code":"150502","name":"科尔沁区"},{"code":"150521","name":"科尔沁左翼中旗"},{"code":"150522","name":"科尔沁左翼后旗"},{"code":"150523","name":"开鲁县"},{"code":"150524","name":"库伦旗"},{"code":"150525","name":"奈曼旗"},{"code":"150526","name":"扎鲁特旗"},{"code":"150581","name":"霍林郭勒市"}]},{"code":"150600","name":"鄂尔多斯市","districts":[{"code":"150602","name":"东胜区"},{"code":"150603","name":"康巴什区"},{"code":"150621","name":"达拉特旗"},{"code":"150622","name":"准格尔旗"},{"code":"150623","name":"鄂托克前旗"},{"code":"150624","name":"鄂托克旗"},{"code":"150625","name":"杭锦旗"},{"code":"150626","name":"乌审旗"},{"code":"150627","name":"伊金霍洛旗"}]},{"code":"150700","name":"呼伦贝尔市","districts":[{"code":"150702","name":"海拉尔区"},{"code":"150703","name":"扎赉诺尔区"},{"code":"150721","name":"阿荣旗"},{"code":"150722","name":"莫力达瓦达斡尔族自治旗"},{"code":"150723","name":"鄂伦春自治旗"},{"code":"150724","name":"鄂温克族自治旗"},{"code":"150725","name":"陈巴尔虎旗"},{"code":"150726","name":"新巴尔虎左旗"},{"code":"150727","name":"新巴尔虎右旗"},{"code":"150781","name":"满洲里市"},{"code":"150782","name":"牙克石市"},{"code":"150783","name":"扎兰屯市"},{"code":"150784","name":"额尔古纳市"},{"code":"150785","name":"根河市"}]},{"code":"150800","name":"巴彦淖尔市","districts":[{"code":"150802","name":"临河区"},{"code":"150821","name":"五原县"},{"code":"150822","name":"磴口县"},{"code":"150823","name":"乌拉特前旗"},{"code":"150824","name":"乌拉特中旗"},{"code":"150825","name":"乌拉特后旗"},{"code":"150826","name":"杭锦后旗"}]},{"code":"150900","name":"乌兰察布市","districts":[{"code":"150902","name":"集宁区"},{"code":"150921","name":"卓资县"},{"code":"150922","name":"化德县"},{"code":"150923","name":"商都县"},{"code":"150924","name":"兴和县"},{"code":"150925","name":"凉城县"},{"code":"150926","name":"察哈尔右翼前旗"},{"code":"150927","name":"察哈尔右翼中旗"},{"code":"150928","name":"察哈尔右翼后旗"},{"code":"150929","name":"四子王旗"},{"code":"150981","name":"丰镇市"}]},{"code":"152200","name":"兴安盟","districts":[{"code":"152201","name":"乌兰浩特市"},{"code":"152202","name":"阿尔山市"},{"code":"152221","name":"科尔沁右翼前旗"},{"code":"152222","name":"科尔沁右翼中旗"},{"code":"152223","name":"扎赉特旗"},{"code":"152224","name":"突泉县"}]},{"code":"152500","name":"锡林郭勒盟","districts":[{"code":"152501","name":"二连浩特市"},{"code":"152502","name":"锡林浩特市"},{"code":"152522","name":"阿巴嘎旗"},{"code":"152523","name":"苏尼特左旗"},{"code":"152524","name":"苏尼特右旗"},{"code":"152525","name":"东乌珠穆沁旗"},{"code":"152526","name":"西乌珠穆沁旗"},{"code":"152527","name":"太仆寺旗"},{"code":"152528","name":"镶黄旗"},{"code":"152529","name":"正镶白旗"},{"code":"152530","name":"正蓝旗"},{"code":"152531","name":"多伦县"}]},{"code":"152900","name":"阿拉善盟","districts":[{"code":"152921","name":"阿拉善左旗"},{"code":"152922","name":"阿拉善右旗"},{"code":"152923","name":"额济纳旗"}]}]},{"code":"210000","name":"辽宁省","cities":[{"code":"210100","name":"沈阳市","districts":[{"code":"210102","name":"和平区"},{"code":"210103","name":"沈河区"},{"code":"210104","name":"大东区"},{"code":"210105","name":"皇姑区"},{"code":"210106","name":"铁西区"},{"code":"210111","name":"苏家屯区"},{"code":"210112","name":"浑南区"},{"code":"210113","name":"沈北新区"},{"code":"210114","name":"于洪区"},{"code":"210115","name":"辽中区"},{"code":"210123","name":"康平县"},{"code":"210124","name":"法库县"},{"code":"210181","name":"新民市"}]},{"code":"210200","name":"大连市","districts":[{"code":"210202","name":"中山区"},{"code":"210203","name":"西岗区"},{"code":"210204","name":"沙河口区"},{"code":"210211","name":"甘井子区"},{"code":"210212","name":"旅顺口区"},{"code":"210213","name":"金州区"},{"code":"210214","name":"普兰店区"},{"code":"210224","name":"长海县"},{"code":"210281","name":"瓦房店市"},{"code":"210283","name":"庄河市"}]},{"code":"210300","name":"鞍山市","districts":[{"code":"210302","name":"铁东区"},{"code":"210303","name":"铁西区"},{"code":"210304","name":"立山区"},{"code":"210311","name":"千山区"},{"code":"210321","name":"台安县"},{"code":"210323","name":"岫岩满族自治县"},{"code":"210381","name":"海城市"}]},{"code":"210400","name":"抚顺市","districts":[{"code":"210402","name":"新抚区"},{"code":"210403","name":"东洲区"},{"code":"210404","name":"望花区"},{"code":"210411","name":"顺城区"},{"code":"210421","name":"抚顺县"},{"code":"210422","name":"新宾满族自治县"},{"code":"210423","name":"清原满族自治县"}]},{"code":"210500","name":"本溪市","districts":[{"code":"210502","name":"平山区"},{"code":"210503","name":"溪湖区"},{"code":"210504","name":"明山区"},{"code":"210505","name":"南芬区"},{"code":"210521","name":"本溪满族自治县"},{"code":"210522","name":"桓仁满族自治县"}]},{"code":"210600","name":"丹东市","districts":[{"code":"210602","name":"元宝区"},{"code":"210603","name":"振兴区"},{"code":"210604","name":"振安区"},{"code":"210624","name":"宽甸满族自治县"},{"code":"210681","name":"东港市"},{"code":"210682","name":"凤城市"}]},{"code":"210700","name":"锦州市","districts":[{"code":"210702","name":"古塔区"},{"code":"210703","name":"凌河区"},{"code":"210711","name":"太和区"},{"code":"210726","name":"黑山县"},{"code":"210727","name":"义县"},{"code":"210781","name":"凌海市"},{"code":"210782","name":"北镇市"}]},{"code":"210800","name":"营口市","districts":[{"code":"210802","name":"站前区"},{"code":"210803","name":"西市区"},{"code":"210804","name":"鲅鱼圈区"},{"code":"210811","name":"老边区"},{"code":"210881","name":"盖州市"},{"code":"210882","name":"大石桥市"}]},{"code":"210900","name":"阜新市","districts":[{"code":"210902","name":"海州区"},{"code":"210903","name":"新邱区"},{"code":"210904","name":"太平区"},{"code":"210905","name":"清河门区"},{"code":"210911","name":"细河区"},{"code":"210921","name":"阜新蒙古族自治县"},{"code":"210922","name":"彰武县"}]},{"code":"211000","name":"辽阳市","districts":[{"code":"211002","name":"白塔区"},{"code":"211003","name":"文圣区"},{"code":"211004","name":"宏伟区"},{"code":"211005","name":"弓长岭区"},{"code":"211011","name":"太子河区"},{"code":"211021","name":"辽阳县"},{"code":"211081","name":"灯塔市"}]},{"code":"211100","name":"盘锦市","districts":[{"code":"211102","name":"双台子区"},{"code":"211103","name":"兴隆台区"},{"code":"211104","name":"大洼区"},{"code":"211122","name":"盘山县"}]},{"code":"211200","name":"铁岭市","districts":[{"code":"211202","name":"银州区"},{"code":"211204","name":"清河区"},{"code":"211221","name":"铁岭县"},{"code":"211223","name":"西丰县"},{"code":"211224","name":"昌图县"},{"code":"211281","name":"调兵山市"},{"code":"211282","name":"开原市"}]},{"code":"211300","name":"朝阳市","districts":[{"code":"211302","name":"双塔区"},{"code":"211303","name":"龙城区"},{"code":"211321","name":"朝阳县"},{"code":"211322","name":"建平县"},{"code":"211324","name":"喀喇沁左翼蒙古族自治县"},{"code":"211381","name":"北票市"},{"code":"211382","name":"凌源市"}]},{"code":"211400","name":"葫芦岛市","districts":[{"code":"211402","name":"连山区"},{"code":"211403","name":"龙港区"},{"code":"211404","name":"南票区"},{"code":"211421","name":"绥中县"},{"code":"211422","name":"建昌县"},{"code":"211481","name":"兴城市"}]}]},{"code":"220000","name":"吉林省","cities":[{"code":"220100","name":"长春市","districts":[{"code":"220102","name":"南关区"},{"code":"220103","name":"宽城区"},{"code":"220104","name":"朝阳区"},{"code":"220105","name":"二道区"},{"code":"220106","name":"绿园区"},{"code":"220112","name":"双阳区"},{"code":"220113","name":"九台区"},{"code":"220122","name":"农安县"},{"code":"220182","name":"榆树市"},{"code":"220183","name":"德惠市"},{"code":"220184","name":"公主岭市"}]},{"code":"220200","name":"吉林市","districts":[{"code":"220202","name":"昌邑区"},{"code":"220203","name":"龙潭区"},{"code":"220204","name":"船营区"},{"code":"220211","name":"丰满区"},{"code":"220221","name":"永吉县"},{"code":"220281","name":"蛟河市"},{"code":"220282","name":"桦甸市"},{"code":"220283","name":"舒兰市"},{"code":"220284","name":"磐石市"}]},{"code":"220300","name":"四平市","districts":[{"code":"220302","name":"铁西区"},{"code":"220303","name":"铁东区"},{"code":"220322","name":"梨树县"},{"code":"220323","name":"伊通满族自治县"},{"code":"220382","name":"双辽市"}]},{"code":"220400","name":"辽源市","districts":[{"code":"220402","name":"龙山区"},{"code":"220403","name":"西安区"},{"code":"220421","name":"东丰县"},{"code":"220422","name":"东辽县"}]},{"code":"220500","name":"通化市","districts":[{"code":"220502","name":"东昌区"},{"code":"220503","name":"二道江区"},{"code":"220521","name":"通化县"},{"code":"220523","name":"辉南县"},{"code":"220524","name":"柳河县"},{"code":"220581","name":"梅河口市"},{"code":"220582","name":"集安市"}]},{"code":"220600","name":"白山市","districts":[{"code":"220602","name":"浑江区"},{"code":"220605","name":"江源区"},{"code":"220621","name":"抚松县"},{"code":"220622","name":"靖宇县"},{"code":"220623","name":"长白朝鲜族自治县"},{"code":"220681","name":"临江市"}]},{"code":"220700","name":"松原市","districts":[{"code":"220702","name":"宁江区"},{"code":"220721","name":"前郭尔罗斯蒙古族自治县"},{"code":"220722","name":"长岭县"},{"code":"220723","name":"乾安县"},{"code":"220781","name":"扶余市"}]},{"code":"220800","name":"白城市","districts":[{"code":"220802","name":"洮北区"},{"code":"220821","name":"镇赉县"},{"code":"220822","name":"通榆县"},{"code":"220881","name":"洮南市"},{"code":"220882","name":"大安市"}]},{"code":"222400","name":"延边朝鲜族自治州","districts":[{"code":"222401","name":"延吉市"},{"code":"222402","name":"图们市"},{"code":"222403","name":"敦化市"},{"code":"222404","name":"珲春市"},{"code":"222405","name":"龙井市"},{"code":"222406","name":"和龙市"},{"code":"222424","name":"汪清县"},{"code":"222426","name":"安图县"}]}]},{"code":"230000","name":"黑龙江省","cities":[{"code":"230100","name":"哈尔滨市","districts":[{"code":"230102","name":"道里区"},{"code":"230103","name":"南岗区"},{"code":"230104","name":"道外区"},{"code":"230108","name":"平房区"},{"code":"230109","name":"松北区"},{"code":"230110","name":"香坊区"},{"code":"230111","name":"呼兰区"},{"code":"230112","name":"阿城区"},{"code":"230113","name":"双城区"},{"code":"230123","name":"依兰县"},{"code":"230124","name":"方正县"},{"code":"230125","name":"宾县"},{"code":"230126","name":"巴彦县"},{"code":"230127","name":"木兰县"},{"code":"230128","name":"通河县"},{"code":"230129","name":"延寿县"},{"code":"230183","name":"尚志市"},{"code":"230184","name":"五常市"}]},{"code":"230200","name":"齐齐哈尔市","districts":[{"code":"230202","name":"龙沙区"},{"code":"230203","name":"建华区"},{"code":"230204","name":"铁锋区"},{"code":"230205","name":"昂昂溪区"},{"code":"230206","name":"富拉尔基区"},{"code":"230207","name":"碾子山区"},{"code":"230208","name":"梅里斯达斡尔族区"},{"code":"230221","name":"龙江县"},{"code":"230223","name":"依安县"},{"code":"230224","name":"泰来县"},{"code":"230225","name":"甘南县"},{"code":"230227","name":"富裕县"},{"code":"230229","name":"克山县"},{"code":"230230","name":"克东县"},{"code":"230231","name":"拜泉县"},{"code":"230281","name":"讷河市"}]},{"code":"230300","name":"鸡西市","districts":[{"code":"230302","name":"鸡冠区"},{"code":"230303","name":"恒山区"},{"code":"230304","name":"滴道区"},{"code":"230305","name":"梨树区"},{"code":"230306","name":"城子河区"},{"code":"230307","name":"麻山区"},{"code":"230321","name":"鸡东县"},{"code":"230381","name":"虎林市"},{"code":"230382","name":"密山市"}]},{"code":"230400","name":"鹤岗市","districts":[{"code":"230402","name":"向阳区"},{"code":"230403","name":"工农区"},{"code":"230404","name":"南山区"},{"code":"230405","name":"兴安区"},{"code":"230406","name":"东山区"},{"code":"230407","name":"兴山区"},{"code":"230421","name":"萝北县"},{"code":"230422","name":"绥滨县"}]},{"code":"230500","name":"双鸭山市","districts":[{"code":"230502","name":"尖山区"},{"code":"230503","name":"岭东区"},{"code":"230505","name":"四方台区"},{"code":"230506","name":"宝山区"},{"code":"230521","name":"集贤县"},{"code":"230522","name":"友谊县"},{"code":"230523","name":"宝清县"},{"code":"230524","name":"饶河县"}]},{"code":"230600","name":"大庆市","districts":[{"code":"230602","name":"萨尔图区"},{"code":"230603","name":"龙凤区"},{"code":"230604","name":"让胡路区"},{"code":"230605","name":"红岗区"},{"code":"230606","name":"大同区"},{"code":"230621","name":"肇州县"},{"code":"230622","name":"肇源县"},{"code":"230623","name":"林甸县"},{"code":"230624","name":"杜尔伯特蒙古族自治县"}]},{"code":"230700","name":"伊春市","districts":[{"code":"230717","name":"伊美区"},{"code":"230718","name":"乌翠区"},{"code":"230719","name":"友好区"},{"code":"230722","name":"嘉荫县"},{"code":"230723","name":"汤旺县"},{"code":"230724","name":"丰林县"},{"code":"230725","name":"大箐山县"},{"code":"230726","name":"南岔县"},{"code":"230751","name":"金林区"},{"code":"230781","name":"铁力市"}]},{"code":"230800","name":"佳木斯市","districts":[{"code":"230803","name":"向阳区"},{"code":"230804","name":"前进区"},{"code":"230805","name":"东风区"},{"code":"230811","name":"郊区"},{"code":"230822","name":"桦南县"},{"code":"230826","name":"桦川县"},{"code":"230828","name":"汤原县"},{"code":"230881","name":"同江市"},{"code":"230882","name":"富锦市"},{"code":"230883","name":"抚远市"}]},{"code":"230900","name":"七台河市","districts":[{"code":"230902","name":"新兴区"},{"code":"230903","name":"桃山区"},{"code":"230904","name":"茄子河区"},{"code":"230921","name":"勃利县"}]},{"code":"231000","name":"牡丹江市","districts":[{"code":"231002","name":"东安区"},{"code":"231003","name":"阳明区"},{"code":"231004","name":"爱民区"},{"code":"231005","name":"西安区"},{"code":"231025","name":"林口县"},{"code":"231081","name":"绥芬河市"},{"code":"231083","name":"海林市"},{"code":"231084","name":"宁安市"},{"code":"231085","name":"穆棱市"},{"code":"231086","name":"东宁市"}]},{"code":"231100","name":"黑河市","districts":[{"code":"231102","name":"爱辉区"},{"code":"231123","name":"逊克县"},{"code":"231124","name":"孙吴县"},{"code":"231181","name":"北安市"},{"code":"231182","name":"五大连池市"},{"code":"231183","name":"嫩江市"}]},{"code":"231200","name":"绥化市","districts":[{"code":"231202","name":"北林区"},{"code":"231221","name":"望奎县"},{"code":"231222","name":"兰西县"},{"code":"231223","name":"青冈县"},{"code":"231224","name":"庆安县"},{"code":"231225","name":"明水县"},{"code":"231226","name":"绥棱县"},{"code":"231281","name":"安达市"},{"code":"231282","name":"肇东市"},{"code":"231283","name":"海伦市"}]},{"code":"232700","name":"大兴安岭地区","districts":[{"code":"232701","name":"漠河市"},{"code":"232721","name":"呼玛县"},{"code":"232722","name":"塔河县"},{"code":"232761","name":"加格达奇区"}]}]},{"code":"310000","name":"上海市","cities":[{"code":"310100","name":"上海市","districts":[{"code":"310101","name":"黄浦区"},{"code":"310104","name":"徐汇区"},{"code":"310105","name":"长宁区"},{"code":"310106","name":"静安区"},{"code":"310107","name":"普陀区"},{"code":"310109","name":"虹口区"},{"code":"310110","name":"杨浦区"},{"code":"310112","name":"闵行区"},{"code":"310113","name":"宝山区"},{"code":"310114","name":"嘉定区"},{"code":"310115","name":"浦东新区"},{"code":"310116","name":"金山区"},{"code":"310117","name":"松江区"},{"code":"310118","name":"青浦区"},{"code":"310120","name":"奉贤区"},{"code":"310151","name":"崇明区"}]}]},{"code":"320000","name":"江苏省","cities":[{"code":"320100","name":"南京市","districts":[{"code":"320102","name":"玄武区"},{"code":"320104","name":"秦淮区"},{"code":"320105","name":"建邺区"},{"code":"320106","name":"鼓楼区"},{"code":"320111","name":"浦口区"},{"code":"320113","name":"栖霞区"},{"code":"320114","name":"雨花台区"},{"code":"320115","name":"江宁区"},{"code":"320116","name":"六合区"},{"code":"320117","name":"溧水区"},{"code":"320118","name":"高淳区"}]},{"code":"320200","name":"无锡市","districts":[{"code":"320205","name":"锡山区"},{"code":"320206","name":"惠山区"},{"code":"320211","name":"滨湖区"},{"code":"320213","name":"梁溪区"},{"code":"320214","name":"新吴区"},{"code":"320281","name":"江阴市"},{"code":"320282","name":"宜兴市"}]},{"code":"320300","name":"徐州市","districts":[{"code":"320302","name":"鼓楼区"},{"code":"320303","name":"云龙区"},{"code":"320305","name":"贾汪区"},{"code":"320311","name":"泉山区"},{"code":"320312","name":"铜山区"},{"code":"320321","name":"丰县"},{"code":"320322","name":"沛县"},{"code":"320324","name":"睢宁县"},{"code":"320381","name":"新沂市"},{"code":"320382","name":"邳州市"}]},{"code":"320400","name":"常州市","districts":[{"code":"320402","name":"天宁区"},{"code":"320404","name":"钟楼区"},{"code":"320411","name":"新北区"},{"code":"320412","name":"武进区"},{"code":"320413","name":"金坛区"},{"code":"320481","name":"溧阳市"}]},{"code":"320500","name":"苏州市","districts":[{"code":"320505","name":"虎丘区"},{"code":"320506","name":"吴中区"},{"code":"320507","name":"相城区"},{"code":"320508","name":"姑苏区"},{"code":"320509","name":"吴江区"},{"code":"320581","name":"常熟市"},{"code":"320582","name":"张家港市"},{"code":"320583","name":"昆山市"},{"code":"320585","name":"太仓市"}]},{"code":"320600","name":"南通市","districts":[{"code":"320612","name":"通州区"},{"code":"320613","name":"崇川区"},{"code":"320614","name":"海门区"},{"code":"320623","name":"如东县"},{"code":"320681","name":"启东市"},{"code":"320682","name":"如皋市"},{"code":"320685","name":"海安市"}]},{"code":"320700","name":"连云港市","districts":[{"code":"320703","name":"连云区"},{"code":"320706","name":"海州区"},{"code":"320707","name":"赣榆区"},{"code":"320722","name":"东海县"},{"code":"320723","name":"灌云县"},{"code":"320724","name":"灌南县"}]},{"code":"320800","name":"淮安市","districts":[{"code":"320803","name":"淮安区"},{"code":"320804","name":"淮阴区"},{"code":"320812","name":"清江浦区"},{"code":"320813","name":"洪泽区"},{"code":"320826","name":"涟水县"},{"code":"320830","name":"盱眙县"},{"code":"320831","name":"金湖县"}]},{"code":"320900","name":"盐城市","districts":[{"code":"320902","name":"亭湖区"},{"code":"320903","name":"盐都区"},{"code":"320904","name":"大丰区"},{"code":"320921","name":"响水县"},{"code":"320922","name":"滨海县"},{"code":"320923","name":"阜宁县"},{"code":"320924","name":"射阳县"},{"code":"320925","name":"建湖县"},{"code":"320981","name":"东台市"}]},{"code":"321000","name":"扬州市","districts":[{"code":"321002","name":"广陵区"},{"code":"321003","name":"邗江区"},{"code":"321012","name":"江都区"},{"code":"321023","name":"宝应县"},{"code":"321081","name":"仪征市"},{"code":"321084","name":"高邮市"}]},{"code":"321100","name":"镇江市","districts":[{"code":"321102","name":"京口区"},{"code":"321111","name":"润州区"},{"code":"321112","name":"丹徒区"},{"code":"321181","name":"丹阳市"},{"code":"321182","name":"扬中市"},{"code":"321183","name":"句容市"}]},{"code":"321200","name":"泰州市","districts":[{"code":"321202","name":"海陵区"},{"code":"321203","name":"高港区"},{"code":"321204","name":"姜堰区"},{"code":"321281","name":"兴化市"},{"code":"321282","name":"靖江市"},{"code":"321283","name":"泰兴市"}]},{"code":"321300","name":"宿迁市","districts":[{"code":"321302","name":"宿城区"},{"code":"321311","name":"宿豫区"},{"code":"321322","name":"沭阳县"},{"code":"321323","name":"泗阳县"},{"code":"321324","name":"泗洪县"}]}]},{"code":"330000","name":"浙江省","cities":[{"code":"330100","name":"杭州市","districts":[{"code":"330102","name":"上城区"},{"code":"330105","name":"拱墅区"},{"code":"330106","name":"西湖区"},{"code":"330108","name":"滨江区"},{"code":"330109","name":"萧山区"},{"code":"330110","name":"余杭区"},{"code":"330111","name":"富阳区"},{"code":"330112","name":"临安区"},{"code":"330113","name":"临平区"},{"code":"330114","name":"钱塘区"},{"code":"330122","name":"桐庐县"},{"code":"330127","name":"淳安县"},{"code":"330182","name":"建德市"}]},{"code":"330200","name":"宁波市","districts":[{"code":"330203","name":"海曙区"},{"code":"330205","name":"江北区"},{"code":"330206","name":"北仑区"},{"code":"330211","name":"镇海区"},{"code":"330212","name":"鄞州区"},{"code":"330213","name":"奉化区"},{"code":"330225","name":"象山县"},{"code":"330226","name":"宁海县"},{"code":"330281","name":"余姚市"},{"code":"330282","name":"慈溪市"}]},{"code":"330300","name":"温州市","districts":[{"code":"330302","name":"鹿城区"},{"code":"330303","name":"龙湾区"},{"code":"330304","name":"瓯海区"},{"code":"330305","name":"洞头区"},{"code":"330324","name":"永嘉县"},{"code":"330326","name":"平阳县"},{"code":"330327","name":"苍南县"},{"code":"330328","name":"文成县"},{"code":"330329","name":"泰顺县"},{"code":"330381","name":"瑞安市"},{"code":"330382","name":"乐清市"},{"code":"330383","name":"龙港市"}]},{"code":"330400","name":"嘉兴市","districts":[{"code":"330402","name":"南湖区"},{"code":"330411","name":"秀洲区"},{"code":"330421","name":"嘉善县"},{"code":"330424","name":"海盐县"},{"code":"330481","name":"海宁市"},{"code":"330482","name":"平湖市"},{"code":"330483","name":"桐乡市"}]},{"code":"330500","name":"湖州市","districts":[{"code":"330502","name":"吴兴区"},{"code":"330503","name":"南浔区"},{"code":"330521","name":"德清县"},{"code":"330522","name":"长兴县"},{"code":"330523","name":"安吉县"}]},{"code":"330600","name":"绍兴市","districts":[{"code":"330602","name":"越城区"},{"code":"330603","name":"柯桥区"},{"code":"330604","name":"上虞区"},{"code":"330624","name":"新昌县"},{"code":"330681","name":"诸暨市"},{"code":"330683","name":"嵊州市"}]},{"code":"330700","name":"金华市","districts":[{"code":"330702","name":"婺城区"},{"code":"330703","name":"金东区"},{"code":"330723","name":"武义县"},{"code":"330726","name":"浦江县"},{"code":"330727","name":"磐安县"},{"code":"330781","name":"兰溪市"},{"code":"330782","name":"义乌市"},{"code":"330783","name":"东阳市"},{"code":"330784","name":"永康市"}]},{"code":"330800","name":"衢州市","districts":[{"code":"330802","name":"柯城区"},{"code":"330803","name":"衢江区"},{"code":"330822","name":"常山县"},{"code":"330824","name":"开化县"},{"code":"330825","name":"龙游县"},{"code":"330881","name":"江山市"}]},{"code":"330900","name":"舟山市","districts":[{"code":"330902","name":"定海区"},{"code":"330903","name":"普陀区"},{"code":"330921","name":"岱山县"},{"code":"330922","name":"嵊泗县"}]},{"code":"331000","name":"台州市","districts":[{"code":"331002","name":"椒江区"},{"code":"331003","name":"黄岩区"},{"code":"331004","name":"路桥区"},{"code":"331022","name":"三门县"},{"code":"331023","name":"天台县"},{"code":"331024","name":"仙居县"},{"code":"331081","name":"温岭市"},{"code":"331082","name":"临海市"},{"code":"331083","name":"玉环市"}]},{"code":"331100","name":"丽水市","districts":[{"code":"331102","name":"莲都区"},{"code":"331121","name":"青田县"},{"code":"331122","name":"缙云县"},{"code":"331123","name":"遂昌县"},{"code":"331124","name":"松阳县"},{"code":"331125","name":"云和县"},{"code":"331126","name":"庆元县"},{"code":"331127","name":"景宁畲族自治县"},{"code":"331181","name":"龙泉市"}]}]},{"code":"340000","name":"安徽省","cities":[{"code":"340100","name":"合肥市","districts":[{"code":"340102","name":"瑶海区"},{"code":"340103","name":"庐阳区"},{"code":"340104","name":"蜀山区"},{"code":"340111","name":"包河区"},{"code":"340121","name":"长丰县"},{"code":"340122","name":"肥东县"},{"code":"340123","name":"肥西县"},{"code":"340124","name":"庐江县"},{"code":"340181","name":"巢湖市"}]},{"code":"340200","name":"芜湖市","districts":[{"code":"340202","name":"镜湖区"},{"code":"340207","name":"鸠江区"},{"code":"340209","name":"弋江区"},{"code":"340210","name":"湾沚区"},{"code":"340212","name":"繁昌区"},{"code":"340223","name":"南陵县"},{"code":"340281","name":"无为市"}]},{"code":"340300","name":"蚌埠市","districts":[{"code":"340302","name":"龙子湖区"},{"code":"340303","name":"蚌山区"},{"code":"340304","name":"禹会区"},{"code":"340311","name":"淮上区"},{"code":"340321","name":"怀远县"},{"code":"340322","name":"五河县"},{"code":"340323","name":"固镇县"}]},{"code":"340400","name":"淮南市","districts":[{"code":"340402","name":"大通区"},{"code":"340403","name":"田家庵区"},{"code":"340404","name":"谢家集区"},{"code":"340405","name":"八公山区"},{"code":"340406","name":"潘集区"},{"code":"340421","name":"凤台县"},{"code":"340422","name":"寿县"}]},{"code":"340500","name":"马鞍山市","districts":[{"code":"340503","name":"花山区"},{"code":"340504","name":"雨山区"},{"code":"340506","name":"博望区"},{"code":"340521","name":"当涂县"},{"code":"340522","name":"含山县"},{"code":"340523","name":"和县"}]},{"code":"340600","name":"淮北市","districts":[{"code":"340602","name":"杜集区"},{"code":"340603","name":"相山区"},{"code":"340604","name":"烈山区"},{"code":"340621","name":"濉溪县"}]},{"code":"340700","name":"铜陵市","districts":[{"code":"340705","name":"铜官区"},{"code":"340706","name":"义安区"},{"code":"340711","name":"郊区"},{"code":"340722","name":"枞阳县"}]},{"code":"340800","name":"安庆市","districts":[{"code":"340802","name":"迎江区"},{"code":"340803","name":"大观区"},{"code":"340811","name":"宜秀区"},{"code":"340822","name":"怀宁县"},{"code":"340825","name":"太湖县"},{"code":"340826","name":"宿松县"},{"code":"340827","name":"望江县"},{"code":"340828","name":"岳西县"},{"code":"340881","name":"桐城市"},{"code":"340882","name":"潜山市"}]},{"code":"341000","name":"黄山市","districts":[{"code":"341002","name":"屯溪区"},{"code":"341003","name":"黄山区"},{"code":"341004","name":"徽州区"},{"code":"341021","name":"歙县"},{"code":"341022","name":"休宁县"},{"code":"341023","name":"黟县"},{"code":"341024","name":"祁门县"}]},{"code":"341100","name":"滁州市","districts":[{"code":"341102","name":"琅琊区"},{"code":"341103","name":"南谯区"},{"code":"341122","name":"来安县"},{"code":"341124","name":"全椒县"},{"code":"341125","name":"定远县"},{"code":"341126","name":"凤阳县"},{"code":"341181","name":"天长市"},{"code":"341182","name":"明光市"}]},{"code":"341200","name":"阜阳市","districts":[{"code":"341202","name":"颍州区"},{"code":"341203","name":"颍东区"},{"code":"341204","name":"颍泉区"},{"code":"341221","name":"临泉县"},{"code":"341222","name":"太和县"},{"code":"341225","name":"阜南县"},{"code":"341226","name":"颍上县"},{"code":"341282","name":"界首市"}]},{"code":"341300","name":"宿州市","districts":[{"code":"341302","name":"埇桥区"},{"code":"341321","name":"砀山县"},{"code":"341322","name":"萧县"},{"code":"341323","name":"灵璧县"},{"code":"341324","name":"泗县"}]},{"code":"341500","name":"六安市","districts":[{"code":"341502","name":"金安区"},{"code":"341503","name":"裕安区"},{"code":"341504","name":"叶集区"},{"code":"341522","name":"霍邱县"},{"code":"341523","name":"舒城县"},{"code":"341524","name":"金寨县"},{"code":"341525","name":"霍山县"}]},{"code":"341600","name":"亳州市","districts":[{"code":"341602","name":"谯城区"},{"code":"341621","name":"涡阳县"},{"code":"341622","name":"蒙城县"},{"code":"341623","name":"利辛县"}]},{"code":"341700","name":"池州市","districts":[{"code":"341702","name":"贵池区"},{"code":"341721","name":"东至县"},{"code":"341722","name":"石台县"},{"code":"341723","name":"青阳县"}]},{"code":"341800","name":"宣城市","districts":[{"code":"341802","name":"宣州区"},{"code":"341821","name":"郎溪县"},{"code":"341823","name":"泾县"},{"code":"341824","name":"绩溪县"},{"code":"341825","name":"旌德县"},{"code":"341881","name":"宁国市"},{"code":"341882","name":"广德市"}]}]},{"code":"350000","name":"福建省","cities":[{"code":"350100","name":"福州市","districts":[{"code":"350102","name":"鼓楼区"},{"code":"350103","name":"台江区"},{"code":"350104","name":"仓山区"},{"code":"350105","name":"马尾区"},{"code":"350111","name":"晋安区"},{"code":"350112","name":"长乐区"},{"code":"350121","name":"闽侯县"},{"code":"350122","name":"连江县"},{"code":"350123","name":"罗源县"},{"code":"350124","name":"闽清县"},{"code":"350125","name":"永泰县"},{"code":"350128","name":"平潭县"},{"code":"350181","name":"福清市"}]},{"code":"350200","name":"厦门市","districts":[{"code":"350203","name":"思明区"},{"code":"350205","name":"海沧区"},{"code":"350206","name":"湖里区"},{"code":"350211","name":"集美区"},{"code":"350212","name":"同安区"},{"code":"350213","name":"翔安区"}]},{"code":"350300","name":"莆田市","districts":[{"code":"350302","name":"城厢区"},{"code":"350303","name":"涵江区"},{"code":"350304","name":"荔城区"},{"code":"350305","name":"秀屿区"},{"code":"350322","name":"仙游县"}]},{"code":"350400","name":"三明市","districts":[{"code":"350404","name":"三元区"},{"code":"350405","name":"沙县区"},{"code":"350421","name":"明溪县"},{"code":"350423","name":"清流县"},{"code":"350424","name":"宁化县"},{"code":"350425","name":"大田县"},{"code":"350426","name":"尤溪县"},{"code":"350428","name":"将乐县"},{"code":"350429","name":"泰宁县"},{"code":"350430","name":"建宁县"},{"code":"350481","name":"永安市"}]},{"code":"350500","name":"泉州市","districts":[{"code":"350502","name":"鲤城区"},{"code":"350503","name":"丰泽区"},{"code":"350504","name":"洛江区"},{"code":"350505","name":"泉港区"},{"code":"350521","name":"惠安县"},{"code":"350524","name":"安溪县"},{"code":"350525","name":"永春县"},{"code":"350526","name":"德化县"},{"code":"350527","name":"金门县"},{"code":"350581","name":"石狮市"},{"code":"350582","name":"晋江市"},{"code":"350583","name":"南安市"}]},{"code":"350600","name":"漳州市","districts":[{"code":"350602","name":"芗城区"},{"code":"350603","name":"龙文区"},{"code":"350604","name":"龙海区"},{"code":"350605","name":"长泰区"},{"code":"350622","name":"云霄县"},{"code":"350623","name":"漳浦县"},{"code":"350624","name":"诏安县"},{"code":"350626","name":"东山县"},{"code":"350627","name":"南靖县"},{"code":"350628","name":"平和县"},{"code":"350629","name":"华安县"}]},{"code":"350700","name":"南平市","districts":[{"code":"350702","name":"延平区"},{"code":"350703","name":"建阳区"},{"code":"350721","name":"顺昌县"},{"code":"350722","name":"浦城县"},{"code":"350723","name":"光泽县"},{"code":"350724","name":"松溪县"},{"code":"350725","name":"政和县"},{"code":"350781","name":"邵武市"},{"code":"350782","name":"武夷山市"},{"code":"350783","name":"建瓯市"}]},{"code":"350800","name":"龙岩市","districts":[{"code":"350802","name":"新罗区"},{"code":"350803","name":"永定区"},{"code":"350821","name":"长汀县"},{"code":"350823","name":"上杭县"},{"code":"350824","name":"武平县"},{"code":"350825","name":"连城县"},{"code":"350881","name":"漳平市"}]},{"code":"350900","name":"宁德市","districts":[{"code":"350902","name":"蕉城区"},{"code":"350921","name":"霞浦县"},{"code":"350922","name":"古田县"},{"code":"350923","name":"屏南县"},{"code":"350924","name":"寿宁县"},{"code":"350925","name":"周宁县"},{"code":"350926","name":"柘荣县"},{"code":"350981","name":"福安市"},{"code":"350982","name":"福鼎市"}]}]},{"code":"360000","name":"江西省","cities":[{"code":"360100","name":"南昌市","districts":[{"code":"360102","name":"东湖区"},{"code":"360103","name":"西湖区"},{"code":"360104","name":"青云谱区"},{"code":"360111","name":"青山湖区"},{"code":"360112","name":"新建区"},{"code":"360113","name":"红谷滩区"},{"code":"360121","name":"南昌县"},{"code":"360123","name":"安义县"},{"code":"360124","name":"进贤县"}]},{"code":"360200","name":"景德镇市","districts":[{"code":"360202","name":"昌江区"},{"code":"360203","name":"珠山区"},{"code":"360222","name":"浮梁县"},{"code":"360281","name":"乐平市"}]},{"code":"360300","name":"萍乡市","districts":[{"code":"360302","name":"安源区"},{"code":"360313","name":"湘东区"},{"code":"360321","name":"莲花县"},{"code":"360322","name":"上栗县"},{"code":"360323","name":"芦溪县"}]},{"code":"360400","name":"九江市","districts":[{"code":"360402","name":"濂溪区"},{"code":"360403","name":"浔阳区"},{"code":"360404","name":"柴桑区"},{"code":"360423","name":"武宁县"},{"code":"360424","name":"修水县"},{"code":"360425","name":"永修县"},{"code":"360426","name":"德安县"},{"code":"360428","name":"都昌县"},{"code":"360429","name":"湖口县"},{"code":"360430","name":"彭泽县"},{"code":"360481","name":"瑞昌市"},{"code":"360482","name":"共青城市"},{"code":"360483","name":"庐山市"}]},{"code":"360500","name":"新余市","districts":[{"code":"360502","name":"渝水区"},{"code":"360521","name":"分宜县"}]},{"code":"360600","name":"鹰潭市","districts":[{"code":"360602","name":"月湖区"},{"code":"360603","name":"余江区"},{"code":"360681","name":"贵溪市"}]},{"code":"360700","name":"赣州市","districts":[{"code":"360702","name":"章贡区"},{"code":"360703","name":"南康区"},{"code":"360704","name":"赣县区"},{"code":"360722","name":"信丰县"},{"code":"360723","name":"大余县"},{"code":"360724","name":"上犹县"},{"code":"360725","name":"崇义县"},{"code":"360726","name":"安远县"},{"code":"360728","name":"定南县"},{"code":"360729","name":"全南县"},{"code":"360730","name":"宁都县"},{"code":"360731","name":"于都县"},{"code":"360732","name":"兴国县"},{"code":"360733","name":"会昌县"},{"code":"360734","name":"寻乌县"},{"code":"360735","name":"石城县"},{"code":"360781","name":"瑞金市"},{"code":"360783","name":"龙南市"}]},{"code":"360800","name":"吉安市","districts":[{"code":"360802","name":"吉州区"},{"code":"360803","name":"青原区"},{"code":"360821","name":"吉安县"},{"code":"360822","name":"吉水县"},{"code":"360823","name":"峡江县"},{"code":"360824","name":"新干县"},{"code":"360825","name":"永丰县"},{"code":"360826","name":"泰和县"},{"code":"360827","name":"遂川县"},{"code":"360828","name":"万安县"},{"code":"360829","name":"安福县"},{"code":"360830","name":"永新县"},{"code":"360881","name":"井冈山市"}]},{"code":"360900","name":"宜春市","districts":[{"code":"360902","name":"袁州区"},{"code":"360921","name":"奉新县"},{"code":"360922","name":"万载县"},{"code":"360923","name":"上高县"},{"code":"360924","name":"宜丰县"},{"code":"360925","name":"靖安县"},{"code":"360926","name":"铜鼓县"},{"code":"360981","name":"丰城市"},{"code":"360982","name":"樟树市"},{"code":"360983","name":"高安市"}]},{"code":"361000","name":"抚州市","districts":[{"code":"361002","name":"临川区"},{"code":"361003","name":"东乡区"},{"code":"361021","name":"南城县"},{"code":"361022","name":"黎川县"},{"code":"361023","name":"南丰县"},{"code":"361024","name":"崇仁县"},{"code":"361025","name":"乐安县"},{"code":"361026","name":"宜黄县"},{"code":"361027","name":"金溪县"},{"code":"361028","name":"资溪县"},{"code":"361030","name":"广昌县"}]},{"code":"361100","name":"上饶市","districts":[{"code":"361102","name":"信州区"},{"code":"361103","name":"广丰区"},{"code":"361104","name":"广信区"},{"code":"361123","name":"玉山县"},{"code":"361124","name":"铅山县"},{"code":"361125","name":"横峰县"},{"code":"361126","name":"弋阳县"},{"code":"361127","name":"余干县"},{"code":"361128","name":"鄱阳县"},{"code":"361129","name":"万年县"},{"code":"361130","name":"婺源县"},{"code":"361181","name":"德兴市"}]}]},{"code":"370000","name":"山东省","cities":[{"code":"370100","name":"济南市","districts":[{"code":"370102","name":"历下区"},{"code":"370103","name":"市中区"},{"code":"370104","name":"槐荫区"},{"code":"370105","name":"天桥区"},{"code":"370112","name":"历城区"},{"code":"370113","name":"长清区"},{"code":"370114","name":"章丘区"},{"code":"370115","name":"济阳区"},{"code":"370116","name":"莱芜区"},{"code":"370117","name":"钢城区"},{"code":"370124","name":"平阴县"},{"code":"370126","name":"商河县"}]},{"code":"370200","name":"青岛市","districts":[{"code":"370202","name":"市南区"},{"code":"370203","name":"市北区"},{"code":"370211","name":"黄岛区"},{"code":"370212","name":"崂山区"},{"code":"370213","name":"李沧区"},{"code":"370214","name":"城阳区"},{"code":"370215","name":"即墨区"},{"code":"370281","name":"胶州市"},{"code":"370283","name":"平度市"},{"code":"370285","name":"莱西市"}]},{"code":"370300","name":"淄博市","districts":[{"code":"370302","name":"淄川区"},{"code":"370303","name":"张店区"},{"code":"370304","name":"博山区"},{"code":"370305","name":"临淄区"},{"code":"370306","name":"周村区"},{"code":"370321","name":"桓台县"},{"code":"370322","name":"高青县"},{"code":"370323","name":"沂源县"}]},{"code":"370400","name":"枣庄市","districts":[{"code":"370402","name":"市中区"},{"code":"370403","name":"薛城区"},{"code":"370404","name":"峄城区"},{"code":"370405","name":"台儿庄区"},{"code":"370406","name":"山亭区"},{"code":"370481","name":"滕州市"}]},{"code":"370500","name":"东营市","districts":[{"code":"370502","name":"东营区"},{"code":"370503","name":"河口区"},{"code":"370505","name":"垦利区"},{"code":"370522","name":"利津县"},{"code":"370523","name":"广饶县"}]},{"code":"370600","name":"烟台市","districts":[{"code":"370602","name":"芝罘区"},{"code":"370611","name":"福山区"},{"code":"370612","name":"牟平区"},{"code":"370613","name":"莱山区"},{"code":"370614","name":"蓬莱区"},{"code":"370681","name":"龙口市"},{"code":"370682","name":"莱阳市"},{"code":"370683","name":"莱州市"},{"code":"370685","name":"招远市"},{"code":"370686","name":"栖霞市"},{"code":"370687","name":"海阳市"}]},{"code":"370700","name":"潍坊市","districts":[{"code":"370702","name":"潍城区"},{"code":"370703","name":"寒亭区"},{"code":"370704","name":"坊子区"},{"code":"370705","name":"奎文区"},{"code":"370724","name":"临朐县"},{"code":"370725","name":"昌乐县"},{"code":"370781","name":"青州市"},{"code":"370782","name":"诸城市"},{"code":"370783","name":"寿光市"},{"code":"370784","name":"安丘市"},{"code":"370785","name":"高密市"},{"code":"370786","name":"昌邑市"}]},{"code":"370800","name":"济宁市","districts":[{"code":"370811","name":"任城区"},{"code":"370812","name":"兖州区"},{"code":"370826","name":"微山县"},{"code":"370827","name":"鱼台县"},{"code":"370828","name":"金乡县"},{"code":"370829","name":"嘉祥县"},{"code":"370830","name":"汶上县"},{"code":"370831","name":"泗水县"},{"code":"370832","name":"梁山县"},{"code":"370881","name":"曲阜市"},{"code":"370883","name":"邹城市"}]},{"code":"370900","name":"泰安市","districts":[{"code":"370902","name":"泰山区"},{"code":"370911","name":"岱岳区"},{"code":"370921","name":"宁阳县"},{"code":"370923","name":"东平县"},{"code":"370982","name":"新泰市"},{"code":"370983","name":"肥城市"}]},{"code":"371000","name":"威海市","districts":[{"code":"371002","name":"环翠区"},{"code":"371003","name":"文登区"},{"code":"371082","name":"荣成市"},{"code":"371083","name":"乳山市"}]},{"code":"371100","name":"日照市","districts":[{"code":"371102","name":"东港区"},{"code":"371103","name":"岚山区"},{"code":"371121","name":"五莲县"},{"code":"371122","name":"莒县"}]},{"code":"371300","name":"临沂市","districts":[{"code":"371302","name":"兰山区"},{"code":"371311","name":"罗庄区"},{"code":"371312","name":"河东区"},{"code":"371321","name":"沂南县"},{"code":"371322","name":"郯城县"},{"code":"371323","name":"沂水县"},{"code":"371324","name":"兰陵县"},{"code":"371325","name":"费县"},{"code":"371326","name":"平邑县"},{"code":"371327","name":"莒南县"},{"code":"371328","name":"蒙阴县"},{"code":"371329","name":"临沭县"}]},{"code":"371400","name":"德州市","districts":[{"code":"371402","name":"德城区"},{"code":"371403","name":"陵城区"},{"code":"371422","name":"宁津县"},{"code":"371423","name":"庆云县"},{"code":"371424","name":"临邑县"},{"code":"371425","name":"齐河县"},{"code":"371426","name":"平原县"},{"code":"371427","name":"夏津县"},{"code":"371428","name":"武城县"},{"code":"371481","name":"乐陵市"},{"code":"371482","name":"禹城市"}]},{"code":"371500","name":"聊城市","districts":[{"code":"371502","name":"东昌府区"},{"code":"371503","name":"茌平区"},{"code":"371521","name":"阳谷县"},{"code":"371522","name":"莘县"},{"code":"371524","name":"东阿县"},{"code":"371525","name":"冠县"},{"code":"371526","name":"高唐县"},{"code":"371581","name":"临清市"}]},{"code":"371600","name":"滨州市","districts":[{"code":"371602","name":"滨城区"},{"code":"371603","name":"沾化区"},{"code":"371621","name":"惠民县"},{"code":"371622","name":"阳信县"},{"code":"371623","name":"无棣县"},{"code":"371625","name":"博兴县"},{"code":"371681","name":"邹平市"}]},{"code":"371700","name":"菏泽市","districts":[{"code":"371702","name":"牡丹区"},{"code":"371703","name":"定陶区"},{"code":"371721","name":"曹县"},{"code":"371722","name":"单县"},{"code":"371723","name":"成武县"},{"code":"371724","name":"巨野县"},{"code":"371725","name":"郓城县"},{"code":"371726","name":"鄄城县"},{"code":"371728","name":"东明县"}]}]},{"code":"410000","name":"河南省","cities":[{"code":"410100","name":"郑州市","districts":[{"code":"410102","name":"中原区"},{"code":"410103","name":"二七区"},{"code":"410104","name":"管城回族区"},{"code":"410105","name":"金水区"},{"code":"410106","name":"上街区"},{"code":"410108","name":"惠济区"},{"code":"410122","name":"中牟县"},{"code":"410181","name":"巩义市"},{"code":"410182","name":"荥阳市"},{"code":"410183","name":"新密市"},{"code":"410184","name":"新郑市"},{"code":"410185","name":"登封市"}]},{"code":"410200","name":"开封市","districts":[{"code":"410202","name":"龙亭区"},{"code":"410203","name":"顺河回族区"},{"code":"410204","name":"鼓楼区"},{"code":"410205","name":"禹王台区"},{"code":"410212","name":"祥符区"},{"code":"410221","name":"杞县"},{"code":"410222","name":"通许县"},{"code":"410223","name":"尉氏县"},{"code":"410225","name":"兰考县"}]},{"code":"410300","name":"洛阳市","districts":[{"code":"410302","name":"老城区"},{"code":"410303","name":"西工区"},{"code":"410304","name":"瀍河回族区"},{"code":"410305","name":"涧西区"},{"code":"410307","name":"偃师区"},{"code":"410308","name":"孟津区"},{"code":"410311","name":"洛龙区"},{"code":"410323","name":"新安县"},{"code":"410324","name":"栾川县"},{"code":"410325","name":"嵩县"},{"code":"410326","name":"汝阳县"},{"code":"410327","name":"宜阳县"},{"code":"410328","name":"洛宁县"},{"code":"410329","name":"伊川县"}]},{"code":"410400","name":"平顶山市","districts":[{"code":"410402","name":"新华区"},{"code":"410403","name":"卫东区"},{"code":"410404","name":"石龙区"},{"code":"410411","name":"湛河区"},{"code":"410421","name":"宝丰县"},{"code":"410422","name":"叶县"},{"code":"410423","name":"鲁山县"},{"code":"410425","name":"郏县"},{"code":"410481","name":"舞钢市"},{"code":"410482","name":"汝州市"}]},{"code":"410500","name":"安阳市","districts":[{"code":"410502","name":"文峰区"},{"code":"410503","name":"北关区"},{"code":"410505","name":"殷都区"},{"code":"410506","name":"龙安区"},{"code":"410522","name":"安阳县"},{"code":"410523","name":"汤阴县"},{"code":"410526","name":"滑县"},{"code":"410527","name":"内黄县"},{"code":"410581","name":"林州市"}]},{"code":"410600","name":"鹤壁市","districts":[{"code":"410602","name":"鹤山区"},{"code":"410603","name":"山城区"},{"code":"410611","name":"淇滨区"},{"code":"410621","name":"浚县"},{"code":"410622","name":"淇县"}]},{"code":"410700","name":"新乡市","districts":[{"code":"410702","name":"红旗区"},{"code":"410703","name":"卫滨区"},{"code":"410704","name":"凤泉区"},{"code":"410711","name":"牧野区"},{"code":"410721","name":"新乡县"},{"code":"410724","name":"获嘉县"},{"code":"410725","name":"原阳县"},{"code":"410726","name":"延津县"},{"code":"410727","name":"封丘县"},{"code":"410781","name":"卫辉市"},{"code":"410782","name":"辉县市"},{"code":"410783","name":"长垣市"}]},{"code":"410800","name":"焦作市","districts":[{"code":"410802","name":"解放区"},{"code":"410803","name":"中站区"},{"code":"410804","name":"马村区"},{"code":"410811","name":"山阳区"},{"code":"410821","name":"修武县"},{"code":"410822","name":"博爱县"},{"code":"410823","name":"武陟县"},{"code":"410825","name":"温县"},{"code":"410882","name":"沁阳市"},{"code":"410883","name":"孟州市"}]},{"code":"410900","name":"濮阳市","districts":[{"code":"410902","name":"华龙区"},{"code":"410922","name":"清丰县"},{"code":"410923","name":"南乐县"},{"code":"410926","name":"范县"},{"code":"410927","name":"台前县"},{"code":"410928","name":"濮阳县"}]},{"code":"411000","name":"许昌市","districts":[{"code":"411002","name":"魏都区"},{"code":"411003","name":"建安区"},{"code":"411024","name":"鄢陵县"},{"code":"411025","name":"襄城县"},{"code":"411081","name":"禹州市"},{"code":"411082","name":"长葛市"}]},{"code":"411100","name":"漯河市","districts":[{"code":"411102","name":"源汇区"},{"code":"411103","name":"郾城区"},{"code":"411104","name":"召陵区"},{"code":"411121","name":"舞阳县"},{"code":"411122","name":"临颍县"}]},{"code":"411200","name":"三门峡市","districts":[{"code":"411202","name":"湖滨区"},{"code":"411203","name":"陕州区"},{"code":"411221","name":"渑池县"},{"code":"411224","name":"卢氏县"},{"code":"411281","name":"义马市"},{"code":"411282","name":"灵宝市"}]},{"code":"411300","name":"南阳市","districts":[{"code":"411302","name":"宛城区"},{"code":"411303","name":"卧龙区"},{"code":"411321","name":"南召县"},{"code":"411322","name":"方城县"},{"code":"411323","name":"西峡县"},{"code":"411324","name":"镇平县"},{"code":"411325","name":"内乡县"},{"code":"411326","name":"淅川县"},{"code":"411327","name":"社旗县"},{"code":"411328","name":"唐河县"},{"code":"411329","name":"新野县"},{"code":"411330","name":"桐柏县"},{"code":"411381","name":"邓州市"}]},{"code":"411400","name":"商丘市","districts":[{"code":"411402","name":"梁园区"},{"code":"411403","name":"睢阳区"},{"code":"411421","name":"民权县"},{"code":"411422","name":"睢县"},{"code":"411423","name":"宁陵县"},{"code":"411424","name":"柘城县"},{"code":"411425","name":"虞城县"},{"code":"411426","name":"夏邑县"},{"code":"411481","name":"永城市"}]},{"code":"411500","name":"信阳市","districts":[{"code":"411502","name":"浉河区"},{"code":"411503","name":"平桥区"},{"code":"411521","name":"罗山县"},{"code":"411522","name":"光山县"},{"code":"411523","name":"新县"},{"code":"411524","name":"商城县"},{"code":"411525","name":"固始县"},{"code":"411526","name":"潢川县"},{"code":"411527","name":"淮滨县"},{"code":"411528","name":"息县"}]},{"code":"411600","name":"周口市","districts":[{"code":"411602","name":"川汇区"},{"code":"411603","name":"淮阳区"},{"code":"411621","name":"扶沟县"},{"code":"411622","name":"西华县"},{"code":"411623","name":"商水县"},{"code":"411624","name":"沈丘县"},{"code":"411625","name":"郸城县"},{"code":"411627","name":"太康县"},{"code":"411628","name":"鹿邑县"},{"code":"411681","name":"项城市"}]},{"code":"411700","name":"驻马店市","districts":[{"code":"411702","name":"驿城区"},{"code":"411721","name":"西平县"},{"code":"411722","name":"上蔡县"},{"code":"411723","name":"平舆县"},{"code":"411724","name":"正阳县"},{"code":"411725","name":"确山县"},{"code":"411726","name":"泌阳县"},{"code":"411727","name":"汝南县"},{"code":"411728","name":"遂平县"},{"code":"411729","name":"新蔡县"}]},{"code":"419001","name":"济源市","districts":[{"code":"419001","name":"济源市"}]}]},{"code":"420000","name":"湖北省","cities":[{"code":"420100","name":"武汉市","districts":[{"code":"420102","name":"江岸区"},{"code":"420103","name":"江汉区"},{"code":"420104","name":"硚口区"},{"code":"420105","name":"汉阳区"},{"code":"420106","name":"武昌区"},{"code":"420107","name":"青山区"},{"code":"420111","name":"洪山区"},{"code":"420112","name":"东西湖区"},{"code":"420113","name":"汉南区"},{"code":"420114","name":"蔡甸区"},{"code":"420115","name":"江夏区"},{"code":"420116","name":"黄陂区"},{"code":"420117","name":"新洲区"}]},{"code":"420200","name":"黄石市","districts":[{"code":"420202","name":"黄石港区"},{"code":"420203","name":"西塞山区"},{"code":"420204","name":"下陆区"},{"code":"420205","name":"铁山区"},{"code":"420222","name":"阳新县"},{"code":"420281","name":"大冶市"}]},{"code":"420300","name":"十堰市","districts":[{"code":"420302","name":"茅箭区"},{"code":"420303","name":"张湾区"},{"code":"420304","name":"郧阳区"},{"code":"420322","name":"郧西县"},{"code":"420323","name":"竹山县"},{"code":"420324","name":"竹溪县"},{"code":"420325","name":"房县"},{"code":"420381","name":"丹江口市"}]},{"code":"420500","name":"宜昌市","districts":[{"code":"420502","name":"西陵区"},{"code":"420503","name":"伍家岗区"},{"code":"420504","name":"点军区"},{"code":"420505","name":"猇亭区"},{"code":"420506","name":"夷陵区"},{"code":"420525","name":"远安县"},{"code":"420526","name":"兴山县"},{"code":"420527","name":"秭归县"},{"code":"420528","name":"长阳土家族自治县"},{"code":"420529","name":"五峰土家族自治县"},{"code":"420581","name":"宜都市"},{"code":"420582","name":"当阳市"},{"code":"420583","name":"枝江市"}]},{"code":"420600","name":"襄阳市","districts":[{"code":"420602","name":"襄城区"},{"code":"420606","name":"樊城区"},{"code":"420607","name":"襄州区"},{"code":"420624","name":"南漳县"},{"code":"420625","name":"谷城县"},{"code":"420626","name":"保康县"},{"code":"420682","name":"老河口市"},{"code":"420683","name":"枣阳市"},{"code":"420684","name":"宜城市"}]},{"code":"420700","name":"鄂州市","districts":[{"code":"420702","name":"梁子湖区"},{"code":"420703","name":"华容区"},{"code":"420704","name":"鄂城区"}]},{"code":"420800","name":"荆门市","districts":[{"code":"420802","name":"东宝区"},{"code":"420804","name":"掇刀区"},{"code":"420822","name":"沙洋县"},{"code":"420881","name":"钟祥市"},{"code":"420882","name":"京山市"}]},{"code":"420900","name":"孝感市","districts":[{"code":"420902","name":"孝南区"},{"code":"420921","name":"孝昌县"},{"code":"420922","name":"大悟县"},{"code":"420923","name":"云梦县"},{"code":"420981","name":"应城市"},{"code":"420982","name":"安陆市"},{"code":"420984","name":"汉川市"}]},{"code":"421000","name":"荆州市","districts":[{"code":"421002","name":"沙市区"},{"code":"421003","name":"荆州区"},{"code":"421022","name":"公安县"},{"code":"421024","name":"江陵县"},{"code":"421081","name":"石首市"},{"code":"421083","name":"洪湖市"},{"code":"421087","name":"松滋市"},{"code":"421088","name":"监利市"}]},{"code":"421100","name":"黄冈市","districts":[{"code":"421102","name":"黄州区"},{"code":"421121","name":"团风县"},{"code":"421122","name":"红安县"},{"code":"421123","name":"罗田县"},{"code":"421124","name":"英山县"},{"code":"421125","name":"浠水县"},{"code":"421126","name":"蕲春县"},{"code":"421127","name":"黄梅县"},{"code":"421181","name":"麻城市"},{"code":"421182","name":"武穴市"}]},{"code":"421200","name":"咸宁市","districts":[{"code":"421202","name":"咸安区"},{"code":"421221","name":"嘉鱼县"},{"code":"421222","name":"通城县"},{"code":"421223","name":"崇阳县"},{"code":"421224","name":"通山县"},{"code":"421281","name":"赤壁市"}]},{"code":"421300","name":"随州市","districts":[{"code":"421303","name":"曾都区"},{"code":"421321","name":"随县"},{"code":"421381","name":"广水市"}]},{"code":"422800","name":"恩施土家族苗族自治州","districts":[{"code":"422801","name":"恩施市"},{"code":"422802","name":"利川市"},{"code":"422822","name":"建始县"},{"code":"422823","name":"巴东县"},{"code":"422825","name":"宣恩县"},{"code":"422826","name":"咸丰县"},{"code":"422827","name":"来凤县"},{"code":"422828","name":"鹤峰县"}]},{"code":"429004","name":"仙桃市","districts":[{"code":"429004","name":"仙桃市"}]},{"code":"429005","name":"潜江市","districts":[{"code":"429005","name":"潜江市"}]},{"code":"429006","name":"天门市","districts":[{"code":"429006","name":"天门市"}]},{"code":"429021","name":"神农架林区","districts":[{"code":"429021","name":"神农架林区"}]}]},{"code":"430000","name":"湖南省","cities":[{"code":"430100","name":"长沙市","districts":[{"code":"430102","name":"芙蓉区"},{"code":"430103","name":"天心区"},{"code":"430104","name":"岳麓区"},{"code":"430105","name":"开福区"},{"code":"430111","name":"雨花区"},{"code":"430112","name":"望城区"},{"code":"430121","name":"长沙县"},{"code":"430181","name":"浏阳市"},{"code":"430182","name":"宁乡市"}]},{"code":"430200","name":"株洲市","districts":[{"code":"430202","name":"荷塘区"},{"code":"430203","name":"芦淞区"},{"code":"430204","name":"石峰区"},{"code":"430211","name":"天元区"},{"code":"430212","name":"渌口区"},{"code":"430223","name":"攸县"},{"code":"430224","name":"茶陵县"},{"code":"430225","name":"炎陵县"},{"code":"430281","name":"醴陵市"}]},{"code":"430300","name":"湘潭市","districts":[{"code":"430302","name":"雨湖区"},{"code":"430304","name":"岳塘区"},{"code":"430321","name":"湘潭县"},{"code":"430381","name":"湘乡市"},{"code":"430382","name":"韶山市"}]},{"code":"430400","name":"衡阳市","districts":[{"code":"430405","name":"珠晖区"},{"code":"430406","name":"雁峰区"},{"code":"430407","name":"石鼓区"},{"code":"430408","name":"蒸湘区"},{"code":"430412","name":"南岳区"},{"code":"430421","name":"衡阳县"},{"code":"430422","name":"衡南县"},{"code":"430423","name":"衡山县"},{"code":"430424","name":"衡东县"},{"code":"430426","name":"祁东县"},{"code":"430481","name":"耒阳市"},{"code":"430482","name":"常宁市"}]},{"code":"430500","name":"邵阳市","districts":[{"code":"430502","name":"双清区"},{"code":"430503","name":"大祥区"},{"code":"430511","name":"北塔区"},{"code":"430522","name":"新邵县"},{"code":"430523","name":"邵阳县"},{"code":"430524","name":"隆回县"},{"code":"430525","name":"洞口县"},{"code":"430527","name":"绥宁县"},{"code":"430528","name":"新宁县"},{"code":"430529","name":"城步苗族自治县"},{"code":"430581","name":"武冈市"},{"code":"430582","name":"邵东市"}]},{"code":"430600","name":"岳阳市","districts":[{"code":"430602","name":"岳阳楼区"},{"code":"430603","name":"云溪区"},{"code":"430611","name":"君山区"},{"code":"430621","name":"岳阳县"},{"code":"430623","name":"华容县"},{"code":"430624","name":"湘阴县"},{"code":"430626","name":"平江县"},{"code":"430681","name":"汨罗市"},{"code":"430682","name":"临湘市"}]},{"code":"430700","name":"常德市","districts":[{"code":"430702","name":"武陵区"},{"code":"430703","name":"鼎城区"},{"code":"430721","name":"安乡县"},{"code":"430722","name":"汉寿县"},{"code":"430723","name":"澧县"},{"code":"430724","name":"临澧县"},{"code":"430725","name":"桃源县"},{"code":"430726","name":"石门县"},{"code":"430781","name":"津市市"}]},{"code":"430800","name":"张家界市","districts":[{"code":"430802","name":"永定区"},{"code":"430811","name":"武陵源区"},{"code":"430821","name":"慈利县"},{"code":"430822","name":"桑植县"}]},{"code":"430900","name":"益阳市","districts":[{"code":"430902","name":"资阳区"},{"code":"430903","name":"赫山区"},{"code":"430921","name":"南县"},{"code":"430922","name":"桃江县"},{"code":"430923","name":"安化县"},{"code":"430981","name":"沅江市"}]},{"code":"431000","name":"郴州市","districts":[{"code":"431002","name":"北湖区"},{"code":"431003","name":"苏仙区"},{"code":"431021","name":"桂阳县"},{"code":"431022","name":"宜章县"},{"code":"431023","name":"永兴县"},{"code":"431024","name":"嘉禾县"},{"code":"431025","name":"临武县"},{"code":"431026","name":"汝城县"},{"code":"431027","name":"桂东县"},{"code":"431028","name":"安仁县"},{"code":"431081","name":"资兴市"}]},{"code":"431100","name":"永州市","districts":[{"code":"431102","name":"零陵区"},{"code":"431103","name":"冷水滩区"},{"code":"431122","name":"东安县"},{"code":"431123","name":"双牌县"},{"code":"431124","name":"道县"},{"code":"431125","name":"江永县"},{"code":"431126","name":"宁远县"},{"code":"431127","name":"蓝山县"},{"code":"431128","name":"新田县"},{"code":"431129","name":"江华瑶族自治县"},{"code":"431181","name":"祁阳市"}]},{"code":"431200","name":"怀化市","districts":[{"code":"431202","name":"鹤城区"},{"code":"431221","name":"中方县"},{"code":"431222","name":"沅陵县"},{"code":"431223","name":"辰溪县"},{"code":"431224","name":"溆浦县"},{"code":"431225","name":"会同县"},{"code":"431226","name":"麻阳苗族自治县"},{"code":"431227","name":"新晃侗族自治县"},{"code":"431228","name":"芷江侗族自治县"},{"code":"431229","name":"靖州苗族侗族自治县"},{"code":"431230","name":"通道侗族自治县"},{"code":"431281","name":"洪江市"}]},{"code":"431300","name":"娄底市","districts":[{"code":"431302","name":"娄星区"},{"code":"431321","name":"双峰县"},{"code":"431322","name":"新化县"},{"code":"431381","name":"冷水江市"},{"code":"431382","name":"涟源市"}]},{"code":"433100","name":"湘西土家族苗族自治州","districts":[{"code":"433101","name":"吉首市"},{"code":"433122","name":"泸溪县"},{"code":"433123","name":"凤凰县"},{"code":"433124","name":"花垣县"},{"code":"433125","name":"保靖县"},{"code":"433126","name":"古丈县"},{"code":"433127","name":"永顺县"},{"code":"433130","name":"龙山县"}]}]},{"code":"440000","name":"广东省","cities":[{"code":"440100","name":"广州市","districts":[{"code":"440103","name":"荔湾区"},{"code":"440104","name":"越秀区"},{"code":"440105","name":"海珠区"},{"code":"440106","name":"天河区"},{"code":"440111","name":"白云区"},{"code":"440112","name":"黄埔区"},{"code":"440113","name":"番禺区"},{"code":"440114","name":"花都区"},{"code":"440115","name":"南沙区"},{"code":"440117","name":"从化区"},{"code":"440118","name":"增城区"}]},{"code":"440200","name":"韶关市","districts":[{"code":"440203","name":"武江区"},{"code":"440204","name":"浈江区"},{"code":"440205","name":"曲江区"},{"code":"440222","name":"始兴县"},{"code":"440224","name":"仁化县"},{"code":"440229","name":"翁源县"},{"code":"440232","name":"乳源瑶族自治县"},{"code":"440233","name":"新丰县"},{"code":"440281","name":"乐昌市"},{"code":"440282","name":"南雄市"}]},{"code":"440300","name":"深圳市","districts":[{"code":"440303","name":"罗湖区"},{"code":"440304","name":"福田区"},{"code":"440305","name":"南山区"},{"code":"440306","name":"宝安区"},{"code":"440307","name":"龙岗区"},{"code":"440308","name":"盐田区"},{"code":"440309","name":"龙华区"},{"code":"440310","name":"坪山区"},{"code":"440311","name":"光明区"}]},{"code":"440400","name":"珠海市","districts":[{"code":"440402","name":"香洲区"},{"code":"440403","name":"斗门区"},{"code":"440404","name":"金湾区"}]},{"code":"440500","name":"汕头市","districts":[{"code":"440507","name":"龙湖区"},{"code":"440511","name":"金平区"},{"code":"440512","name":"濠江区"},{"code":"440513","name":"潮阳区"},{"code":"440514","name":"潮南区"},{"code":"440515","name":"澄海区"},{"code":"440523","name":"南澳县"}]},{"code":"440600","name":"佛山市","districts":[{"code":"440604","name":"禅城区"},{"code":"440605","name":"南海区"},{"code":"440606","name":"顺德区"},{"code":"440607","name":"三水区"},{"code":"440608","name":"高明区"}]},{"code":"440700","name":"江门市","districts":[{"code":"440703","name":"蓬江区"},{"code":"440704","name":"江海区"},{"code":"440705","name":"新会区"},{"code":"440781","name":"台山市"},{"code":"440783","name":"开平市"},{"code":"440784","name":"鹤山市"},{"code":"440785","name":"恩平市"}]},{"code":"440800","name":"湛江市","districts":[{"code":"440802","name":"赤坎区"},{"code":"440803","name":"霞山区"},{"code":"440804","name":"坡头区"},{"code":"440811","name":"麻章区"},{"code":"440823","name":"遂溪县"},{"code":"440825","name":"徐闻县"},{"code":"440881","name":"廉江市"},{"code":"440882","name":"雷州市"},{"code":"440883","name":"吴川市"}]},{"code":"440900","name":"茂名市","districts":[{"code":"440902","name":"茂南区"},{"code":"440904","name":"电白区"},{"code":"440981","name":"高州市"},{"code":"440982","name":"化州市"},{"code":"440983","name":"信宜市"}]},{"code":"441200","name":"肇庆市","districts":[{"code":"441202","name":"端州区"},{"code":"441203","name":"鼎湖区"},{"code":"441204","name":"高要区"},{"code":"441223","name":"广宁县"},{"code":"441224","name":"怀集县"},{"code":"441225","name":"封开县"},{"code":"441226","name":"德庆县"},{"code":"441284","name":"四会市"}]},{"code":"441300","name":"惠州市","districts":[{"code":"441302","name":"惠城区"},{"code":"441303","name":"惠阳区"},{"code":"441322","name":"博罗县"},{"code":"441323","name":"惠东县"},{"code":"441324","name":"龙门县"}]},{"code":"441400","name":"梅州市","districts":[{"code":"441402","name":"梅江区"},{"code":"441403","name":"梅县区"},{"code":"441422","name":"大埔县"},{"code":"441423","name":"丰顺县"},{"code":"441424","name":"五华县"},{"code":"441426","name":"平远县"},{"code":"441427","name":"蕉岭县"},{"code":"441481","name":"兴宁市"}]},{"code":"441500","name":"汕尾市","districts":[{"code":"441502","name":"城区"},{"code":"441521","name":"海丰县"},{"code":"441523","name":"陆河县"},{"code":"441581","name":"陆丰市"}]},{"code":"441600","name":"河源市","districts":[{"code":"441602","name":"源城区"},{"code":"441621","name":"紫金县"},{"code":"441622","name":"龙川县"},{"code":"441623","name":"连平县"},{"code":"441624","name":"和平县"},{"code":"441625","name":"东源县"}]},{"code":"441700","name":"阳江市","districts":[{"code":"441702","name":"江城区"},{"code":"441704","name":"阳东区"},{"code":"441721","name":"阳西县"},{"code":"441781","name":"阳春市"}]},{"code":"441800","name":"清远市","districts":[{"code":"441802","name":"清城区"},{"code":"441803","name":"清新区"},{"code":"441821","name":"佛冈县"},{"code":"441823","name":"阳山县"},{"code":"441825","name":"连山壮族瑶族自治县"},{"code":"441826","name":"连南瑶族自治县"},{"code":"441881","name":"英德市"},{"code":"441882","name":"连州市"}]},{"code":"441900","name":"东莞市","districts":[{"code":"441900","name":"东莞市"}]},{"code":"442000","name":"中山市","districts":[{"code":"442000","name":"中山市"}]},{"code":"445100","name":"潮州市","districts":[{"code":"445102","name":"湘桥区"},{"code":"445103","name":"潮安区"},{"code":"445122","name":"饶平县"}]},{"code":"445200","name":"揭阳市","districts":[{"code":"445202","name":"榕城区"},{"code":"445203","name":"揭东区"},{"code":"445222","name":"揭西县"},{"code":"445224","name":"惠来县"},{"code":"445281","name":"普宁市"}]},{"code":"445300","name":"云浮市","districts":[{"code":"445302","name":"云城区"},{"code":"445303","name":"云安区"},{"code":"445321","name":"新兴县"},{"code":"445322","name":"郁南县"},{"code":"445381","name":"罗定市"}]}]},{"code":"450000","name":"广西壮族自治区","cities":[{"code":"450100","name":"南宁市","districts":[{"code":"450102","name":"兴宁区"},{"code":"450103","name":"青秀区"},{"code":"450105","name":"江南区"},{"code":"450107","name":"西乡塘区"},{"code":"450108","name":"良庆区"},{"code":"450109","name":"邕宁区"},{"code":"450110","name":"武鸣区"},{"code":"450123","name":"隆安县"},{"code":"450124","name":"马山县"},{"code":"450125","name":"上林县"},{"code":"450126","name":"宾阳县"},{"code":"450181","name":"横州市"}]},{"code":"450200","name":"柳州市","districts":[{"code":"450202","name":"城中区"},{"code":"450203","name":"鱼峰区"},{"code":"450204","name":"柳南区"},{"code":"450205","name":"柳北区"},{"code":"450206","name":"柳江区"},{"code":"450222","name":"柳城县"},{"code":"450223","name":"鹿寨县"},{"code":"450224","name":"融安县"},{"code":"450225","name":"融水苗族自治县"},{"code":"450226","name":"三江侗族自治县"}]},{"code":"450300","name":"桂林市","districts":[{"code":"450302","name":"秀峰区"},{"code":"450303","name":"叠彩区"},{"code":"450304","name":"象山区"},{"code":"450305","name":"七星区"},{"code":"450311","name":"雁山区"},{"code":"450312","name":"临桂区"},{"code":"450321","name":"阳朔县"},{"code":"450323","name":"灵川县"},{"code":"450324","name":"全州县"},{"code":"450325","name":"兴安县"},{"code":"450326","name":"永福县"},{"code":"450327","name":"灌阳县"},{"code":"450328","name":"龙胜各族自治县"},{"code":"450329","name":"资源县"},{"code":"450330","name":"平乐县"},{"code":"450332","name":"恭城瑶族自治县"},{"code":"450381","name":"荔浦市"}]},{"code":"450400","name":"梧州市","districts":[{"code":"450403","name":"万秀区"},{"code":"450405","name":"长洲区"},{"code":"450406","name":"龙圩区"},{"code":"450421","name":"苍梧县"},{"code":"450422","name":"藤县"},{"code":"450423","name":"蒙山县"},{"code":"450481","name":"岑溪市"}]},{"code":"450500","name":"北海市","districts":[{"code":"450502","name":"海城区"},{"code":"450503","name":"银海区"},{"code":"450512","name":"铁山港区"},{"code":"450521","name":"合浦县"}]},{"code":"450600","name":"防城港市","districts":[{"code":"450602","name":"港口区"},{"code":"450603","name":"防城区"},{"code":"450621","name":"上思县"},{"code":"450681","name":"东兴市"}]},{"code":"450700","name":"钦州市","districts":[{"code":"450702","name":"钦南区"},{"code":"450703","name":"钦北区"},{"code":"450721","name":"灵山县"},{"code":"450722","name":"浦北县"}]},{"code":"450800","name":"贵港市","districts":[{"code":"450802","name":"港北区"},{"code":"450803","name":"港南区"},{"code":"450804","name":"覃塘区"},{"code":"450821","name":"平南县"},{"code":"450881","name":"桂平市"}]},{"code":"450900","name":"玉林市","districts":[{"code":"450902","name":"玉州区"},{"code":"450903","name":"福绵区"},{"code":"450921","name":"容县"},{"code":"450922","name":"陆川县"},{"code":"450923","name":"博白县"},{"code":"450924","name":"兴业县"},{"code":"450981","name":"北流市"}]},{"code":"451000","name":"百色市","districts":[{"code":"451002","name":"右江区"},{"code":"451003","name":"田阳区"},{"code":"451022","name":"田东县"},{"code":"451024","name":"德保县"},{"code":"451026","name":"那坡县"},{"code":"451027","name":"凌云县"},{"code":"451028","name":"乐业县"},{"code":"451029","name":"田林县"},{"code":"451030","name":"西林县"},{"code":"451031","name":"隆林各族自治县"},{"code":"451081","name":"靖西市"},{"code":"451082","name":"平果市"}]},{"code":"451100","name":"贺州市","districts":[{"code":"451102","name":"八步区"},{"code":"451103","name":"平桂区"},{"code":"451121","name":"昭平县"},{"code":"451122","name":"钟山县"},{"code":"451123","name":"富川瑶族自治县"}]},{"code":"451200","name":"河池市","districts":[{"code":"451202","name":"金城江区"},{"code":"451203","name":"宜州区"},{"code":"451221","name":"南丹县"},{"code":"451222","name":"天峨县"},{"code":"451223","name":"凤山县"},{"code":"451224","name":"东兰县"},{"code":"451225","name":"罗城仫佬族自治县"},{"code":"451226","name":"环江毛南族自治县"},{"code":"451227","name":"巴马瑶族自治县"},{"code":"451228","name":"都安瑶族自治县"},{"code":"451229","name":"大化瑶族自治县"}]},{"code":"451300","name":"来宾市","districts":[{"code":"451302","name":"兴宾区"},{"code":"451321","name":"忻城县"},{"code":"451322","name":"象州县"},{"code":"451323","name":"武宣县"},{"code":"451324","name":"金秀瑶族自治县"},{"code":"451381","name":"合山市"}]},{"code":"451400","name":"崇左市","districts":[{"code":"451402","name":"江州区"},{"code":"451421","name":"扶绥县"},{"code":"451422","name":"宁明县"},{"code":"451423","name":"龙州县"},{"code":"451424","name":"大新县"},{"code":"451425","name":"天等县"},{"code":"451481","name":"凭祥市"}]}]},{"code":"460000","name":"海南省","cities":[{"code":"460100","name":"海口市","districts":[{"code":"460105","name":"秀英区"},{"code":"460106","name":"龙华区"},{"code":"460107","name":"琼山区"},{"code":"460108","name":"美兰区"}]},{"code":"460200","name":"三亚市","districts":[{"code":"460202","name":"海棠区"},{"code":"460203","name":"吉阳区"},{"code":"460204","name":"天涯区"},{"code":"460205","name":"崖州区"}]},{"code":"460300","name":"三沙市","districts":[{"code":"460302","name":"西沙区"},{"code":"460303","name":"南沙区"}]},{"code":"460400","name":"儋州市","districts":[{"code":"460400","name":"儋州市"}]},{"code":"469001","name":"五指山市","districts":[{"code":"469001","name":"五指山市"}]},{"code":"469002","name":"琼海市","districts":[{"code":"469002","name":"琼海市"}]},{"code":"469005","name":"文昌市","districts":[{"code":"469005","name":"文昌市"}]},{"code":"469006","name":"万宁市","districts":[{"code":"469006","name":"万宁市"}]},{"code":"469007","name":"东方市","districts":[{"code":"469007","name":"东方市"}]},{"code":"469021","name":"定安县","districts":[{"code":"469021","name":"定安县"}]},{"code":"469022","name":"屯昌县","districts":[{"code":"469022","name":"屯昌县"}]},{"code":"469023","name":"澄迈县","districts":[{"code":"469023","name":"澄迈县"}]},{"code":"469024","name":"临高县","districts":[{"code":"469024","name":"临高县"}]},{"code":"469025","name":"白沙黎族自治县","districts":[{"code":"469025","name":"白沙黎族自治县"}]},{"code":"469026","name":"昌江黎族自治县","districts":[{"code":"469026","name":"昌江黎族自治县"}]},{"code":"469027","name":"乐东黎族自治县","districts":[{"code":"469027","name":"乐东黎族自治县"}]},{"code":"469028","name":"陵水黎族自治县","districts":[{"code":"469028","name":"陵水黎族自治县"}]},{"code":"469029","name":"保亭黎族苗族自治县","districts":[{"code":"469029","name":"保亭黎族苗族自治县"}]},{"code":"469030","name":"琼中黎族苗族自治县","districts":[{"code":"469030","name":"琼中黎族苗族自治县"}]}]},{"code":"500000","name":"重庆市","cities":[{"code":"500100","name":"重庆城区","districts":[{"code":"500101","name":"万州区"},{"code":"500102","name":"涪陵区"},{"code":"500103","name":"渝中区"},{"code":"500104","name":"大渡口区"},{"code":"500106","name":"沙坪坝区"},{"code":"500107","name":"九龙坡区"},{"code":"500108","name":"南岸区"},{"code":"500109","name":"北碚区"},{"code":"500110","name":"綦江区"},{"code":"500111","name":"大足区"},{"code":"500113","name":"巴南区"},{"code":"500114","name":"黔江区"},{"code":"500115","name":"长寿区"},{"code":"500116","name":"江津区"},{"code":"500117","name":"合川区"},{"code":"500118","name":"永川区"},{"code":"500119","name":"南川区"},{"code":"500120","name":"璧山区"},{"code":"500151","name":"铜梁区"},{"code":"500152","name":"潼南区"},{"code":"500153","name":"荣昌区"},{"code":"500154","name":"开州区"},{"code":"500155","name":"梁平区"},{"code":"500156","name":"武隆区"},{"code":"500157","name":"两江新区"}]},{"code":"500200","name":"重庆郊县","districts":[{"code":"500229","name":"城口县"},{"code":"500230","name":"丰都县"},{"code":"500231","name":"垫江县"},{"code":"500233","name":"忠县"},{"code":"500235","name":"云阳县"},{"code":"500236","name":"奉节县"},{"code":"500237","name":"巫山县"},{"code":"500238","name":"巫溪县"},{"code":"500240","name":"石柱土家族自治县"},{"code":"500241","name":"秀山土家族苗族自治县"},{"code":"500242","name":"酉阳土家族苗族自治县"},{"code":"500243","name":"彭水苗族土家族自治县"}]}]},{"code":"510000","name":"四川省","cities":[{"code":"510100","name":"成都市","districts":[{"code":"510104","name":"锦江区"},{"code":"510105","name":"青羊区"},{"code":"510106","name":"金牛区"},{"code":"510107","name":"武侯区"},{"code":"510108","name":"成华区"},{"code":"510112","name":"龙泉驿区"},{"code":"510113","name":"青白江区"},{"code":"510114","name":"新都区"},{"code":"510115","name":"温江区"},{"code":"510116","name":"双流区"},{"code":"510117","name":"郫都区"},{"code":"510118","name":"新津区"},{"code":"510121","name":"金堂县"},{"code":"510129","name":"大邑县"},{"code":"510131","name":"蒲江县"},{"code":"510181","name":"都江堰市"},{"code":"510182","name":"彭州市"},{"code":"510183","name":"邛崃市"},{"code":"510184","name":"崇州市"},{"code":"510185","name":"简阳市"}]},{"code":"510300","name":"自贡市","districts":[{"code":"510302","name":"自流井区"},{"code":"510303","name":"贡井区"},{"code":"510304","name":"大安区"},{"code":"510311","name":"沿滩区"},{"code":"510321","name":"荣县"},{"code":"510322","name":"富顺县"}]},{"code":"510400","name":"攀枝花市","districts":[{"code":"510402","name":"东区"},{"code":"510403","name":"西区"},{"code":"510411","name":"仁和区"},{"code":"510421","name":"米易县"},{"code":"510422","name":"盐边县"}]},{"code":"510500","name":"泸州市","districts":[{"code":"510502","name":"江阳区"},{"code":"510503","name":"纳溪区"},{"code":"510504","name":"龙马潭区"},{"code":"510521","name":"泸县"},{"code":"510522","name":"合江县"},{"code":"510524","name":"叙永县"},{"code":"510525","name":"古蔺县"}]},{"code":"510600","name":"德阳市","districts":[{"code":"510603","name":"旌阳区"},{"code":"510604","name":"罗江区"},{"code":"510623","name":"中江县"},{"code":"510681","name":"广汉市"},{"code":"510682","name":"什邡市"},{"code":"510683","name":"绵竹市"}]},{"code":"510700","name":"绵阳市","districts":[{"code":"510703","name":"涪城区"},{"code":"510704","name":"游仙区"},{"code":"510705","name":"安州区"},{"code":"510722","name":"三台县"},{"code":"510723","name":"盐亭县"},{"code":"510725","name":"梓潼县"},{"code":"510726","name":"北川羌族自治县"},{"code":"510727","name":"平武县"},{"code":"510781","name":"江油市"}]},{"code":"510800","name":"广元市","districts":[{"code":"510802","name":"利州区"},{"code":"510811","name":"昭化区"},{"code":"510812","name":"朝天区"},{"code":"510821","name":"旺苍县"},{"code":"510822","name":"青川县"},{"code":"510823","name":"剑阁县"},{"code":"510824","name":"苍溪县"}]},{"code":"510900","name":"遂宁市","districts":[{"code":"510903","name":"船山区"},{"code":"510904","name":"安居区"},{"code":"510921","name":"蓬溪县"},{"code":"510923","name":"大英县"},{"code":"510981","name":"射洪市"}]},{"code":"511000","name":"内江市","districts":[{"code":"511002","name":"市中区"},{"code":"511011","name":"东兴区"},{"code":"511024","name":"威远县"},{"code":"511025","name":"资中县"},{"code":"511083","name":"隆昌市"}]},{"code":"511100","name":"乐山市","districts":[{"code":"511102","name":"市中区"},{"code":"511111","name":"沙湾区"},{"code":"511112","name":"五通桥区"},{"code":"511113","name":"金口河区"},{"code":"511123","name":"犍为县"},{"code":"511124","name":"井研县"},{"code":"511126","name":"夹江县"},{"code":"511129","name":"沐川县"},{"code":"511132","name":"峨边彝族自治县"},{"code":"511133","name":"马边彝族自治县"},{"code":"511181","name":"峨眉山市"}]},{"code":"511300","name":"南充市","districts":[{"code":"511302","name":"顺庆区"},{"code":"511303","name":"高坪区"},{"code":"511304","name":"嘉陵区"},{"code":"511321","name":"南部县"},{"code":"511322","name":"营山县"},{"code":"511323","name":"蓬安县"},{"code":"511324","name":"仪陇县"},{"code":"511325","name":"西充县"},{"code":"511381","name":"阆中市"}]},{"code":"511400","name":"眉山市","districts":[{"code":"511402","name":"东坡区"},{"code":"511403","name":"彭山区"},{"code":"511421","name":"仁寿县"},{"code":"511423","name":"洪雅县"},{"code":"511424","name":"丹棱县"},{"code":"511425","name":"青神县"}]},{"code":"511500","name":"宜宾市","districts":[{"code":"511502","name":"翠屏区"},{"code":"511503","name":"南溪区"},{"code":"511504","name":"叙州区"},{"code":"511523","name":"江安县"},{"code":"511524","name":"长宁县"},{"code":"511525","name":"高县"},{"code":"511526","name":"珙县"},{"code":"511527","name":"筠连县"},{"code":"511528","name":"兴文县"},{"code":"511529","name":"屏山县"}]},{"code":"511600","name":"广安市","districts":[{"code":"511602","name":"广安区"},{"code":"511603","name":"前锋区"},{"code":"511621","name":"岳池县"},{"code":"511622","name":"武胜县"},{"code":"511623","name":"邻水县"},{"code":"511681","name":"华蓥市"}]},{"code":"511700","name":"达州市","districts":[{"code":"511702","name":"通川区"},{"code":"511703","name":"达川区"},{"code":"511722","name":"宣汉县"},{"code":"511723","name":"开江县"},{"code":"511724","name":"大竹县"},{"code":"511725","name":"渠县"},{"code":"511781","name":"万源市"}]},{"code":"511800","name":"雅安市","districts":[{"code":"511802","name":"雨城区"},{"code":"511803","name":"名山区"},{"code":"511822","name":"荥经县"},{"code":"511823","name":"汉源县"},{"code":"511824","name":"石棉县"},{"code":"511825","name":"天全县"},{"code":"511826","name":"芦山县"},{"code":"511827","name":"宝兴县"}]},{"code":"511900","name":"巴中市","districts":[{"code":"511902","name":"巴州区"},{"code":"511903","name":"恩阳区"},{"code":"511921","name":"通江县"},{"code":"511922","name":"南江县"},{"code":"511923","name":"平昌县"}]},{"code":"512000","name":"资阳市","districts":[{"code":"512002","name":"雁江区"},{"code":"512021","name":"安岳县"},{"code":"512022","name":"乐至县"}]},{"code":"513200","name":"阿坝藏族羌族自治州","districts":[{"code":"513201","name":"马尔康市"},{"code":"513221","name":"汶川县"},{"code":"513222","name":"理县"},{"code":"513223","name":"茂县"},{"code":"513224","name":"松潘县"},{"code":"513225","name":"九寨沟县"},{"code":"513226","name":"金川县"},{"code":"513227","name":"小金县"},{"code":"513228","name":"黑水县"},{"code":"513230","name":"壤塘县"},{"code":"513231","name":"阿坝县"},{"code":"513232","name":"若尔盖县"},{"code":"513233","name":"红原县"}]},{"code":"513300","name":"甘孜藏族自治州","districts":[{"code":"513301","name":"康定市"},{"code":"513322","name":"泸定县"},{"code":"513323","name":"丹巴县"},{"code":"513324","name":"九龙县"},{"code":"513325","name":"雅江县"},{"code":"513326","name":"道孚县"},{"code":"513327","name":"炉霍县"},{"code":"513328","name":"甘孜县"},{"code":"513329","name":"新龙县"},{"code":"513330","name":"德格县"},{"code":"513331","name":"白玉县"},{"code":"513332","name":"石渠县"},{"code":"513333","name":"色达县"},{"code":"513334","name":"理塘县"},{"code":"513335","name":"巴塘县"},{"code":"513336","name":"乡城县"},{"code":"513337","name":"稻城县"},{"code":"513338","name":"得荣县"}]},{"code":"513400","name":"凉山彝族自治州","districts":[{"code":"513401","name":"西昌市"},{"code":"513402","name":"会理市"},{"code":"513422","name":"木里藏族自治县"},{"code":"513423","name":"盐源县"},{"code":"513424","name":"德昌县"},{"code":"513426","name":"会东县"},{"code":"513427","name":"宁南县"},{"code":"513428","name":"普格县"},{"code":"513429","name":"布拖县"},{"code":"513430","name":"金阳县"},{"code":"513431","name":"昭觉县"},{"code":"513432","name":"喜德县"},{"code":"513433","name":"冕宁县"},{"code":"513434","name":"越西县"},{"code":"513435","name":"甘洛县"},{"code":"513436","name":"美姑县"},{"code":"513437","name":"雷波县"}]}]},{"code":"520000","name":"贵州省","cities":[{"code":"520100","name":"贵阳市","districts":[{"code":"520102","name":"南明区"},{"code":"520103","name":"云岩区"},{"code":"520111","name":"花溪区"},{"code":"520112","name":"乌当区"},{"code":"520113","name":"白云区"},{"code":"520115","name":"观山湖区"},{"code":"520121","name":"开阳县"},{"code":"520122","name":"息烽县"},{"code":"520123","name":"修文县"},{"code":"520181","name":"清镇市"}]},{"code":"520200","name":"六盘水市","districts":[{"code":"520201","name":"钟山区"},{"code":"520203","name":"六枝特区"},{"code":"520204","name":"水城区"},{"code":"520281","name":"盘州市"}]},{"code":"520300","name":"遵义市","districts":[{"code":"520302","name":"红花岗区"},{"code":"520303","name":"汇川区"},{"code":"520304","name":"播州区"},{"code":"520322","name":"桐梓县"},{"code":"520323","name":"绥阳县"},{"code":"520324","name":"正安县"},{"code":"520325","name":"道真仡佬族苗族自治县"},{"code":"520326","name":"务川仡佬族苗族自治县"},{"code":"520327","name":"凤冈县"},{"code":"520328","name":"湄潭县"},{"code":"520329","name":"余庆县"},{"code":"520330","name":"习水县"},{"code":"520381","name":"赤水市"},{"code":"520382","name":"仁怀市"}]},{"code":"520400","name":"安顺市","districts":[{"code":"520402","name":"西秀区"},{"code":"520403","name":"平坝区"},{"code":"520422","name":"普定县"},{"code":"520423","name":"镇宁布依族苗族自治县"},{"code":"520424","name":"关岭布依族苗族自治县"},{"code":"520425","name":"紫云苗族布依族自治县"}]},{"code":"520500","name":"毕节市","districts":[{"code":"520502","name":"七星关区"},{"code":"520521","name":"大方县"},{"code":"520523","name":"金沙县"},{"code":"520524","name":"织金县"},{"code":"520525","name":"纳雍县"},{"code":"520526","name":"威宁彝族回族苗族自治县"},{"code":"520527","name":"赫章县"},{"code":"520581","name":"黔西市"}]},{"code":"520600","name":"铜仁市","districts":[{"code":"520602","name":"碧江区"},{"code":"520603","name":"万山区"},{"code":"520621","name":"江口县"},{"code":"520622","name":"玉屏侗族自治县"},{"code":"520623","name":"石阡县"},{"code":"520624","name":"思南县"},{"code":"520625","name":"印江土家族苗族自治县"},{"code":"520626","name":"德江县"},{"code":"520627","name":"沿河土家族自治县"},{"code":"520628","name":"松桃苗族自治县"}]},{"code":"522300","name":"黔西南布依族苗族自治州","districts":[{"code":"522301","name":"兴义市"},{"code":"522302","name":"兴仁市"},{"code":"522323","name":"普安县"},{"code":"522324","name":"晴隆县"},{"code":"522325","name":"贞丰县"},{"code":"522326","name":"望谟县"},{"code":"522327","name":"册亨县"},{"code":"522328","name":"安龙县"}]},{"code":"522600","name":"黔东南苗族侗族自治州","districts":[{"code":"522601","name":"凯里市"},{"code":"522622","name":"黄平县"},{"code":"522623","name":"施秉县"},{"code":"522624","name":"三穗县"},{"code":"522625","name":"镇远县"},{"code":"522626","name":"岑巩县"},{"code":"522627","name":"天柱县"},{"code":"522628","name":"锦屏县"},{"code":"522629","name":"剑河县"},{"code":"522630","name":"台江县"},{"code":"522631","name":"黎平县"},{"code":"522632","name":"榕江县"},{"code":"522633","name":"从江县"},{"code":"522634","name":"雷山县"},{"code":"522635","name":"麻江县"},{"code":"522636","name":"丹寨县"}]},{"code":"522700","name":"黔南布依族苗族自治州","districts":[{"code":"522701","name":"都匀市"},{"code":"522702","name":"福泉市"},{"code":"522722","name":"荔波县"},{"code":"522723","name":"贵定县"},{"code":"522725","name":"瓮安县"},{"code":"522726","name":"独山县"},{"code":"522727","name":"平塘县"},{"code":"522728","name":"罗甸县"},{"code":"522729","name":"长顺县"},{"code":"522730","name":"龙里县"},{"code":"522731","name":"惠水县"},{"code":"522732","name":"三都水族自治县"}]}]},{"code":"530000","name":"云南省","cities":[{"code":"530100","name":"昆明市","districts":[{"code":"530102","name":"五华区"},{"code":"530103","name":"盘龙区"},{"code":"530111","name":"官渡区"},{"code":"530112","name":"西山区"},{"code":"530113","name":"东川区"},{"code":"530114","name":"呈贡区"},{"code":"530115","name":"晋宁区"},{"code":"530124","name":"富民县"},{"code":"530125","name":"宜良县"},{"code":"530126","name":"石林彝族自治县"},{"code":"530127","name":"嵩明县"},{"code":"530128","name":"禄劝彝族苗族自治县"},{"code":"530129","name":"寻甸回族彝族自治县"},{"code":"530181","name":"安宁市"}]},{"code":"530300","name":"曲靖市","districts":[{"code":"530302","name":"麒麟区"},{"code":"530303","name":"沾益区"},{"code":"530304","name":"马龙区"},{"code":"530322","name":"陆良县"},{"code":"530323","name":"师宗县"},{"code":"530324","name":"罗平县"},{"code":"530325","name":"富源县"},{"code":"530326","name":"会泽县"},{"code":"530381","name":"宣威市"}]},{"code":"530400","name":"玉溪市","districts":[{"code":"530402","name":"红塔区"},{"code":"530403","name":"江川区"},{"code":"530423","name":"通海县"},{"code":"530424","name":"华宁县"},{"code":"530425","name":"易门县"},{"code":"530426","name":"峨山彝族自治县"},{"code":"530427","name":"新平彝族傣族自治县"},{"code":"530428","name":"元江哈尼族彝族傣族自治县"},{"code":"530481","name":"澄江市"}]},{"code":"530500","name":"保山市","districts":[{"code":"530502","name":"隆阳区"},{"code":"530521","name":"施甸县"},{"code":"530523","name":"龙陵县"},{"code":"530524","name":"昌宁县"},{"code":"530581","name":"腾冲市"}]},{"code":"530600","name":"昭通市","districts":[{"code":"530602","name":"昭阳区"},{"code":"530621","name":"鲁甸县"},{"code":"530622","name":"巧家县"},{"code":"530623","name":"盐津县"},{"code":"530624","name":"大关县"},{"code":"530625","name":"永善县"},{"code":"530626","name":"绥江县"},{"code":"530627","name":"镇雄县"},{"code":"530628","name":"彝良县"},{"code":"530629","name":"威信县"},{"code":"530681","name":"水富市"}]},{"code":"530700","name":"丽江市","districts":[{"code":"530702","name":"古城区"},{"code":"530721","name":"玉龙纳西族自治县"},{"code":"530722","name":"永胜县"},{"code":"530723","name":"华坪县"},{"code":"530724","name":"宁蒗彝族自治县"}]},{"code":"530800","name":"普洱市","districts":[{"code":"530802","name":"思茅区"},{"code":"530821","name":"宁洱哈尼族彝族自治县"},{"code":"530822","name":"墨江哈尼族自治县"},{"code":"530823","name":"景东彝族自治县"},{"code":"530824","name":"景谷傣族彝族自治县"},{"code":"530825","name":"镇沅彝族哈尼族拉祜族自治县"},{"code":"530826","name":"江城哈尼族彝族自治县"},{"code":"530827","name":"孟连傣族拉祜族佤族自治县"},{"code":"530828","name":"澜沧拉祜族自治县"},{"code":"530829","name":"西盟佤族自治县"}]},{"code":"530900","name":"临沧市","districts":[{"code":"530902","name":"临翔区"},{"code":"530921","name":"凤庆县"},{"code":"530922","name":"云县"},{"code":"530923","name":"永德县"},{"code":"530924","name":"镇康县"},{"code":"530925","name":"双江拉祜族佤族布朗族傣族自治县"},{"code":"530926","name":"耿马傣族佤族自治县"},{"code":"530927","name":"沧源佤族自治县"}]},{"code":"532300","name":"楚雄彝族自治州","districts":[{"code":"532301","name":"楚雄市"},{"code":"532302","name":"禄丰市"},{"code":"532322","name":"双柏县"},{"code":"532323","name":"牟定县"},{"code":"532324","name":"南华县"},{"code":"532325","name":"姚安县"},{"code":"532326","name":"大姚县"},{"code":"532327","name":"永仁县"},{"code":"532328","name":"元谋县"},{"code":"532329","name":"武定县"}]},{"code":"532500","name":"红河哈尼族彝族自治州","districts":[{"code":"532501","name":"个旧市"},{"code":"532502","name":"开远市"},{"code":"532503","name":"蒙自市"},{"code":"532504","name":"弥勒市"},{"code":"532523","name":"屏边苗族自治县"},{"code":"532524","name":"建水县"},{"code":"532525","name":"石屏县"},{"code":"532527","name":"泸西县"},{"code":"532528","name":"元阳县"},{"code":"532529","name":"红河县"},{"code":"532530","name":"金平苗族瑶族傣族自治县"},{"code":"532531","name":"绿春县"},{"code":"532532","name":"河口瑶族自治县"}]},{"code":"532600","name":"文山壮族苗族自治州","districts":[{"code":"532601","name":"文山市"},{"code":"532622","name":"砚山县"},{"code":"532623","name":"西畴县"},{"code":"532624","name":"麻栗坡县"},{"code":"532625","name":"马关县"},{"code":"532626","name":"丘北县"},{"code":"532627","name":"广南县"},{"code":"532628","name":"富宁县"}]},{"code":"532800","name":"西双版纳傣族自治州","districts":[{"code":"532801","name":"景洪市"},{"code":"532822","name":"勐海县"},{"code":"532823","name":"勐腊县"}]},{"code":"532900","name":"大理白族自治州","districts":[{"code":"532901","name":"大理市"},{"code":"532922","name":"漾濞彝族自治县"},{"code":"532923","name":"祥云县"},{"code":"532924","name":"宾川县"},{"code":"532925","name":"弥渡县"},{"code":"532926","name":"南涧彝族自治县"},{"code":"532927","name":"巍山彝族回族自治县"},{"code":"532928","name":"永平县"},{"code":"532929","name":"云龙县"},{"code":"532930","name":"洱源县"},{"code":"532931","name":"剑川县"},{"code":"532932","name":"鹤庆县"}]},{"code":"533100","name":"德宏傣族景颇族自治州","districts":[{"code":"533102","name":"瑞丽市"},{"code":"533103","name":"芒市"},{"code":"533122","name":"梁河县"},{"code":"533123","name":"盈江县"},{"code":"533124","name":"陇川县"}]},{"code":"533300","name":"怒江傈僳族自治州","districts":[{"code":"533301","name":"泸水市"},{"code":"533323","name":"福贡县"},{"code":"533324","name":"贡山独龙族怒族自治县"},{"code":"533325","name":"兰坪白族普米族自治县"}]},{"code":"533400","name":"迪庆藏族自治州","districts":[{"code":"533401","name":"香格里拉市"},{"code":"533422","name":"德钦县"},{"code":"533423","name":"维西傈僳族自治县"}]}]},{"code":"540000","name":"西藏自治区","cities":[{"code":"540100","name":"拉萨市","districts":[{"code":"540102","name":"城关区"},{"code":"540103","name":"堆龙德庆区"},{"code":"540104","name":"达孜区"},{"code":"540121","name":"林周县"},{"code":"540122","name":"当雄县"},{"code":"540123","name":"尼木县"},{"code":"540124","name":"曲水县"},{"code":"540127","name":"墨竹工卡县"}]},{"code":"540200","name":"日喀则市","districts":[{"code":"540202","name":"桑珠孜区"},{"code":"540221","name":"南木林县"},{"code":"540222","name":"江孜县"},{"code":"540223","name":"定日县"},{"code":"540224","name":"萨迦县"},{"code":"540225","name":"拉孜县"},{"code":"540226","name":"昂仁县"},{"code":"540227","name":"谢通门县"},{"code":"540228","name":"白朗县"},{"code":"540229","name":"仁布县"},{"code":"540230","name":"康马县"},{"code":"540231","name":"定结县"},{"code":"540232","name":"仲巴县"},{"code":"540233","name":"亚东县"},{"code":"540234","name":"吉隆县"},{"code":"540235","name":"聂拉木县"},{"code":"540236","name":"萨嘎县"},{"code":"540237","name":"岗巴县"}]},{"code":"540300","name":"昌都市","districts":[{"code":"540302","name":"卡若区"},{"code":"540321","name":"江达县"},{"code":"540322","name":"贡觉县"},{"code":"540323","name":"类乌齐县"},{"code":"540324","name":"丁青县"},{"code":"540325","name":"察雅县"},{"code":"540326","name":"八宿县"},{"code":"540327","name":"左贡县"},{"code":"540328","name":"芒康县"},{"code":"540329","name":"洛隆县"},{"code":"540330","name":"边坝县"}]},{"code":"540400","name":"林芝市","districts":[{"code":"540402","name":"巴宜区"},{"code":"540421","name":"工布江达县"},{"code":"540423","name":"墨脱县"},{"code":"540424","name":"波密县"},{"code":"540425","name":"察隅县"},{"code":"540426","name":"朗县"},{"code":"540481","name":"米林市"}]},{"code":"540500","name":"山南市","districts":[{"code":"540502","name":"乃东区"},{"code":"540521","name":"扎囊县"},{"code":"540522","name":"贡嘎县"},{"code":"540523","name":"桑日县"},{"code":"540524","name":"琼结县"},{"code":"540525","name":"曲松县"},{"code":"540526","name":"措美县"},{"code":"540527","name":"洛扎县"},{"code":"540528","name":"加查县"},{"code":"540529","name":"隆子县"},{"code":"540531","name":"浪卡子县"},{"code":"540581","name":"错那市"}]},{"code":"540600","name":"那曲市","districts":[{"code":"540602","name":"色尼区"},{"code":"540621","name":"嘉黎县"},{"code":"540622","name":"比如县"},{"code":"540623","name":"聂荣县"},{"code":"540624","name":"安多县"},{"code":"540625","name":"申扎县"},{"code":"540626","name":"索县"},{"code":"540627","name":"班戈县"},{"code":"540628","name":"巴青县"},{"code":"540629","name":"尼玛县"},{"code":"540630","name":"双湖县"}]},{"code":"542500","name":"阿里地区","districts":[{"code":"542521","name":"普兰县"},{"code":"542522","name":"札达县"},{"code":"542523","name":"噶尔县"},{"code":"542524","name":"日土县"},{"code":"542525","name":"革吉县"},{"code":"542526","name":"改则县"},{"code":"542527","name":"措勤县"}]}]},{"code":"610000","name":"陕西省","cities":[{"code":"610100","name":"西安市","districts":[{"code":"610102","name":"新城区"},{"code":"610103","name":"碑林区"},{"code":"610104","name":"莲湖区"},{"code":"610111","name":"灞桥区"},{"code":"610112","name":"未央区"},{"code":"610113","name":"雁塔区"},{"code":"610114","name":"阎良区"},{"code":"610115","name":"临潼区"},{"code":"610116","name":"长安区"},{"code":"610117","name":"高陵区"},{"code":"610118","name":"鄠邑区"},{"code":"610122","name":"蓝田县"},{"code":"610124","name":"周至县"}]},{"code":"610200","name":"铜川市","districts":[{"code":"610202","name":"王益区"},{"code":"610203","name":"印台区"},{"code":"610204","name":"耀州区"},{"code":"610222","name":"宜君县"}]},{"code":"610300","name":"宝鸡市","districts":[{"code":"610302","name":"渭滨区"},{"code":"610303","name":"金台区"},{"code":"610304","name":"陈仓区"},{"code":"610305","name":"凤翔区"},{"code":"610323","name":"岐山县"},{"code":"610324","name":"扶风县"},{"code":"610326","name":"眉县"},{"code":"610327","name":"陇县"},{"code":"610328","name":"千阳县"},{"code":"610329","name":"麟游县"},{"code":"610330","name":"凤县"},{"code":"610331","name":"太白县"}]},{"code":"610400","name":"咸阳市","districts":[{"code":"610402","name":"秦都区"},{"code":"610403","name":"杨陵区"},{"code":"610404","name":"渭城区"},{"code":"610422","name":"三原县"},{"code":"610423","name":"泾阳县"},{"code":"610424","name":"乾县"},{"code":"610425","name":"礼泉县"},{"code":"610426","name":"永寿县"},{"code":"610428","name":"长武县"},{"code":"610429","name":"旬邑县"},{"code":"610430","name":"淳化县"},{"code":"610431","name":"武功县"},{"code":"610481","name":"兴平市"},{"code":"610482","name":"彬州市"}]},{"code":"610500","name":"渭南市","districts":[{"code":"610502","name":"临渭区"},{"code":"610503","name":"华州区"},{"code":"610522","name":"潼关县"},{"code":"610523","name":"大荔县"},{"code":"610524","name":"合阳县"},{"code":"610525","name":"澄城县"},{"code":"610526","name":"蒲城县"},{"code":"610527","name":"白水县"},{"code":"610528","name":"富平县"},{"code":"610581","name":"韩城市"},{"code":"610582","name":"华阴市"}]},{"code":"610600","name":"延安市","districts":[{"code":"610602","name":"宝塔区"},{"code":"610603","name":"安塞区"},{"code":"610621","name":"延长县"},{"code":"610622","name":"延川县"},{"code":"610625","name":"志丹县"},{"code":"610626","name":"吴起县"},{"code":"610627","name":"甘泉县"},{"code":"610628","name":"富县"},{"code":"610629","name":"洛川县"},{"code":"610630","name":"宜川县"},{"code":"610631","name":"黄龙县"},{"code":"610632","name":"黄陵县"},{"code":"610681","name":"子长市"}]},{"code":"610700","name":"汉中市","districts":[{"code":"610702","name":"汉台区"},{"code":"610703","name":"南郑区"},{"code":"610722","name":"城固县"},{"code":"610723","name":"洋县"},{"code":"610724","name":"西乡县"},{"code":"610725","name":"勉县"},{"code":"610726","name":"宁强县"},{"code":"610727","name":"略阳县"},{"code":"610728","name":"镇巴县"},{"code":"610729","name":"留坝县"},{"code":"610730","name":"佛坪县"}]},{"code":"610800","name":"榆林市","districts":[{"code":"610802","name":"榆阳区"},{"code":"610803","name":"横山区"},{"code":"610822","name":"府谷县"},{"code":"610824","name":"靖边县"},{"code":"610825","name":"定边县"},{"code":"610826","name":"绥德县"},{"code":"610827","name":"米脂县"},{"code":"610828","name":"佳县"},{"code":"610829","name":"吴堡县"},{"code":"610830","name":"清涧县"},{"code":"610831","name":"子洲县"},{"code":"610881","name":"神木市"}]},{"code":"610900","name":"安康市","districts":[{"code":"610902","name":"汉滨区"},{"code":"610921","name":"汉阴县"},{"code":"610922","name":"石泉县"},{"code":"610923","name":"宁陕县"},{"code":"610924","name":"紫阳县"},{"code":"610925","name":"岚皋县"},{"code":"610926","name":"平利县"},{"code":"610927","name":"镇坪县"},{"code":"610929","name":"白河县"},{"code":"610981","name":"旬阳市"}]},{"code":"611000","name":"商洛市","districts":[{"code":"611002","name":"商州区"},{"code":"611021","name":"洛南县"},{"code":"611022","name":"丹凤县"},{"code":"611023","name":"商南县"},{"code":"611024","name":"山阳县"},{"code":"611025","name":"镇安县"},{"code":"611026","name":"柞水县"}]}]},{"code":"620000","name":"甘肃省","cities":[{"code":"620100","name":"兰州市","districts":[{"code":"620102","name":"城关区"},{"code":"620103","name":"七里河区"},{"code":"620104","name":"西固区"},{"code":"620105","name":"安宁区"},{"code":"620111","name":"红古区"},{"code":"620121","name":"永登县"},{"code":"620122","name":"皋兰县"},{"code":"620123","name":"榆中县"}]},{"code":"620200","name":"嘉峪关市","districts":[{"code":"620200","name":"嘉峪关市"}]},{"code":"620300","name":"金昌市","districts":[{"code":"620302","name":"金川区"},{"code":"620321","name":"永昌县"}]},{"code":"620400","name":"白银市","districts":[{"code":"620402","name":"白银区"},{"code":"620403","name":"平川区"},{"code":"620421","name":"靖远县"},{"code":"620422","name":"会宁县"},{"code":"620423","name":"景泰县"}]},{"code":"620500","name":"天水市","districts":[{"code":"620502","name":"秦州区"},{"code":"620503","name":"麦积区"},{"code":"620521","name":"清水县"},{"code":"620522","name":"秦安县"},{"code":"620523","name":"甘谷县"},{"code":"620524","name":"武山县"},{"code":"620525","name":"张家川回族自治县"}]},{"code":"620600","name":"武威市","districts":[{"code":"620602","name":"凉州区"},{"code":"620621","name":"民勤县"},{"code":"620622","name":"古浪县"},{"code":"620623","name":"天祝藏族自治县"}]},{"code":"620700","name":"张掖市","districts":[{"code":"620702","name":"甘州区"},{"code":"620721","name":"肃南裕固族自治县"},{"code":"620722","name":"民乐县"},{"code":"620723","name":"临泽县"},{"code":"620724","name":"高台县"},{"code":"620725","name":"山丹县"}]},{"code":"620800","name":"平凉市","districts":[{"code":"620802","name":"崆峒区"},{"code":"620821","name":"泾川县"},{"code":"620822","name":"灵台县"},{"code":"620823","name":"崇信县"},{"code":"620825","name":"庄浪县"},{"code":"620826","name":"静宁县"},{"code":"620881","name":"华亭市"}]},{"code":"620900","name":"酒泉市","districts":[{"code":"620902","name":"肃州区"},{"code":"620921","name":"金塔县"},{"code":"620922","name":"瓜州县"},{"code":"620923","name":"肃北蒙古族自治县"},{"code":"620924","name":"阿克塞哈萨克族自治县"},{"code":"620981","name":"玉门市"},{"code":"620982","name":"敦煌市"}]},{"code":"621000","name":"庆阳市","districts":[{"code":"621002","name":"西峰区"},{"code":"621021","name":"庆城县"},{"code":"621022","name":"环县"},{"code":"621023","name":"华池县"},{"code":"621024","name":"合水县"},{"code":"621025","name":"正宁县"},{"code":"621026","name":"宁县"},{"code":"621027","name":"镇原县"}]},{"code":"621100","name":"定西市","districts":[{"code":"621102","name":"安定区"},{"code":"621121","name":"通渭县"},{"code":"621122","name":"陇西县"},{"code":"621123","name":"渭源县"},{"code":"621124","name":"临洮县"},{"code":"621125","name":"漳县"},{"code":"621126","name":"岷县"}]},{"code":"621200","name":"陇南市","districts":[{"code":"621202","name":"武都区"},{"code":"621221","name":"成县"},{"code":"621222","name":"文县"},{"code":"621223","name":"宕昌县"},{"code":"621224","name":"康县"},{"code":"621225","name":"西和县"},{"code":"621226","name":"礼县"},{"code":"621227","name":"徽县"},{"code":"621228","name":"两当县"}]},{"code":"622900","name":"临夏回族自治州","districts":[{"code":"622901","name":"临夏市"},{"code":"622921","name":"临夏县"},{"code":"622922","name":"康乐县"},{"code":"622923","name":"永靖县"},{"code":"622924","name":"广河县"},{"code":"622925","name":"和政县"},{"code":"622926","name":"东乡族自治县"},{"code":"622927","name":"积石山保安族东乡族撒拉族自治县"}]},{"code":"623000","name":"甘南藏族自治州","districts":[{"code":"623001","name":"合作市"},{"code":"623021","name":"临潭县"},{"code":"623022","name":"卓尼县"},{"code":"623023","name":"舟曲县"},{"code":"623024","name":"迭部县"},{"code":"623025","name":"玛曲县"},{"code":"623026","name":"碌曲县"},{"code":"623027","name":"夏河县"}]}]},{"code":"630000","name":"青海省","cities":[{"code":"630100","name":"西宁市","districts":[{"code":"630102","name":"城东区"},{"code":"630103","name":"城中区"},{"code":"630104","name":"城西区"},{"code":"630105","name":"城北区"},{"code":"630106","name":"湟中区"},{"code":"630121","name":"大通回族土族自治县"},{"code":"630123","name":"湟源县"}]},{"code":"630200","name":"海东市","districts":[{"code":"630202","name":"乐都区"},{"code":"630203","name":"平安区"},{"code":"630222","name":"民和回族土族自治县"},{"code":"630223","name":"互助土族自治县"},{"code":"630224","name":"化隆回族自治县"},{"code":"630225","name":"循化撒拉族自治县"}]},{"code":"632200","name":"海北藏族自治州","districts":[{"code":"632221","name":"门源回族自治县"},{"code":"632222","name":"祁连县"},{"code":"632223","name":"海晏县"},{"code":"632224","name":"刚察县"}]},{"code":"632300","name":"黄南藏族自治州","districts":[{"code":"632301","name":"同仁市"},{"code":"632322","name":"尖扎县"},{"code":"632323","name":"泽库县"},{"code":"632324","name":"河南蒙古族自治县"}]},{"code":"632500","name":"海南藏族自治州","districts":[{"code":"632521","name":"共和县"},{"code":"632522","name":"同德县"},{"code":"632523","name":"贵德县"},{"code":"632524","name":"兴海县"},{"code":"632525","name":"贵南县"}]},{"code":"632600","name":"果洛藏族自治州","districts":[{"code":"632621","name":"玛沁县"},{"code":"632622","name":"班玛县"},{"code":"632623","name":"甘德县"},{"code":"632624","name":"达日县"},{"code":"632625","name":"久治县"},{"code":"632626","name":"玛多县"}]},{"code":"632700","name":"玉树藏族自治州","districts":[{"code":"632701","name":"玉树市"},{"code":"632722","name":"杂多县"},{"code":"632723","name":"称多县"},{"code":"632724","name":"治多县"},{"code":"632725","name":"囊谦县"},{"code":"632726","name":"曲麻莱县"}]},{"code":"632800","name":"海西蒙古族藏族自治州","districts":[{"code":"632801","name":"格尔木市"},{"code":"632802","name":"德令哈市"},{"code":"632803","name":"茫崖市"},{"code":"632821","name":"乌兰县"},{"code":"632822","name":"都兰县"},{"code":"632823","name":"天峻县"},{"code":"632825","name":"大柴旦行政委员会"}]}]},{"code":"640000","name":"宁夏回族自治区","cities":[{"code":"640100","name":"银川市","districts":[{"code":"640104","name":"兴庆区"},{"code":"640105","name":"西夏区"},{"code":"640106","name":"金凤区"},{"code":"640121","name":"永宁县"},{"code":"640122","name":"贺兰县"},{"code":"640181","name":"灵武市"}]},{"code":"640200","name":"石嘴山市","districts":[{"code":"640202","name":"大武口区"},{"code":"640205","name":"惠农区"},{"code":"640221","name":"平罗县"}]},{"code":"640300","name":"吴忠市","districts":[{"code":"640302","name":"利通区"},{"code":"640303","name":"红寺堡区"},{"code":"640323","name":"盐池县"},{"code":"640324","name":"同心县"},{"code":"640381","name":"青铜峡市"}]},{"code":"640400","name":"固原市","districts":[{"code":"640402","name":"原州区"},{"code":"640422","name":"西吉县"},{"code":"640423","name":"隆德县"},{"code":"640424","name":"泾源县"},{"code":"640425","name":"彭阳县"}]},{"code":"640500","name":"中卫市","districts":[{"code":"640502","name":"沙坡头区"},{"code":"640521","name":"中宁县"},{"code":"640522","name":"海原县"}]}]},{"code":"650000","name":"新疆维吾尔自治区","cities":[{"code":"650100","name":"乌鲁木齐市","districts":[{"code":"650102","name":"天山区"},{"code":"650103","name":"沙依巴克区"},{"code":"650104","name":"新市区"},{"code":"650105","name":"水磨沟区"},{"code":"650106","name":"头屯河区"},{"code":"650107","name":"达坂城区"},{"code":"650109","name":"米东区"},{"code":"650121","name":"乌鲁木齐县"}]},{"code":"650200","name":"克拉玛依市","districts":[{"code":"650202","name":"独山子区"},{"code":"650203","name":"克拉玛依区"},{"code":"650204","name":"白碱滩区"},{"code":"650205","name":"乌尔禾区"}]},{"code":"650400","name":"吐鲁番市","districts":[{"code":"650402","name":"高昌区"},{"code":"650421","name":"鄯善县"},{"code":"650422","name":"托克逊县"}]},{"code":"650500","name":"哈密市","districts":[{"code":"650502","name":"伊州区"},{"code":"650521","name":"巴里坤哈萨克自治县"},{"code":"650522","name":"伊吾县"}]},{"code":"652300","name":"昌吉回族自治州","districts":[{"code":"652301","name":"昌吉市"},{"code":"652302","name":"阜康市"},{"code":"652323","name":"呼图壁县"},{"code":"652324","name":"玛纳斯县"},{"code":"652325","name":"奇台县"},{"code":"652327","name":"吉木萨尔县"},{"code":"652328","name":"木垒哈萨克自治县"}]},{"code":"652700","name":"博尔塔拉蒙古自治州","districts":[{"code":"652701","name":"博乐市"},{"code":"652702","name":"阿拉山口市"},{"code":"652722","name":"精河县"},{"code":"652723","name":"温泉县"}]},{"code":"652800","name":"巴音郭楞蒙古自治州","districts":[{"code":"652801","name":"库尔勒市"},{"code":"652822","name":"轮台县"},{"code":"652823","name":"尉犁县"},{"code":"652824","name":"若羌县"},{"code":"652825","name":"且末县"},{"code":"652826","name":"焉耆回族自治县"},{"code":"652827","name":"和静县"},{"code":"652828","name":"和硕县"},{"code":"652829","name":"博湖县"}]},{"code":"652900","name":"阿克苏地区","districts":[{"code":"652901","name":"阿克苏市"},{"code":"652902","name":"库车市"},{"code":"652922","name":"温宿县"},{"code":"652924","name":"沙雅县"},{"code":"652925","name":"新和县"},{"code":"652926","name":"拜城县"},{"code":"652927","name":"乌什县"},{"code":"652928","name":"阿瓦提县"},{"code":"652929","name":"柯坪县"}]},{"code":"653000","name":"克孜勒苏柯尔克孜自治州","districts":[{"code":"653001","name":"阿图什市"},{"code":"653022","name":"阿克陶县"},{"code":"653023","name":"阿合奇县"},{"code":"653024","name":"乌恰县"}]},{"code":"653100","name":"喀什地区","districts":[{"code":"653101","name":"喀什市"},{"code":"653121","name":"疏附县"},{"code":"653122","name":"疏勒县"},{"code":"653123","name":"英吉沙县"},{"code":"653124","name":"泽普县"},{"code":"653125","name":"莎车县"},{"code":"653126","name":"叶城县"},{"code":"653127","name":"麦盖提县"},{"code":"653128","name":"岳普湖县"},{"code":"653129","name":"伽师县"},{"code":"653130","name":"巴楚县"},{"code":"653131","name":"塔什库尔干塔吉克自治县"}]},{"code":"653200","name":"和田地区","districts":[{"code":"653201","name":"和田市"},{"code":"653221","name":"和田县"},{"code":"653222","name":"墨玉县"},{"code":"653223","name":"皮山县"},{"code":"653224","name":"洛浦县"},{"code":"653225","name":"策勒县"},{"code":"653226","name":"于田县"},{"code":"653227","name":"民丰县"},{"code":"653228","name":"和康县"},{"code":"653229","name":"和安县"}]},{"code":"654000","name":"伊犁哈萨克自治州","districts":[{"code":"654002","name":"伊宁市"},{"code":"654003","name":"奎屯市"},{"code":"654004","name":"霍尔果斯市"},{"code":"654021","name":"伊宁县"},{"code":"654022","name":"察布查尔锡伯自治县"},{"code":"654023","name":"霍城县"},{"code":"654024","name":"巩留县"},{"code":"654025","name":"新源县"},{"code":"654026","name":"昭苏县"},{"code":"654027","name":"特克斯县"},{"code":"654028","name":"尼勒克县"}]},{"code":"654200","name":"塔城地区","districts":[{"code":"654201","name":"塔城市"},{"code":"654202","name":"乌苏市"},{"code":"654203","name":"沙湾市"},{"code":"654221","name":"额敏县"},{"code":"654224","name":"托里县"},{"code":"654225","name":"裕民县"},{"code":"654226","name":"和布克赛尔蒙古自治县"}]},{"code":"654300","name":"阿勒泰地区","districts":[{"code":"654301","name":"阿勒泰市"},{"code":"654321","name":"布尔津县"},{"code":"654322","name":"富蕴县"},{"code":"654323","name":"福海县"},{"code":"654324","name":"哈巴河县"},{"code":"654325","name":"青河县"},{"code":"654326","name":"吉木乃县"}]},{"code":"659001","name":"石河子市","districts":[{"code":"659001","name":"石河子市"}]},{"code":"659002","name":"阿拉尔市","districts":[{"code":"659002","name":"阿拉尔市"}]},{"code":"659003","name":"图木舒克市","districts":[{"code":"659003","name":"图木舒克市"}]},{"code":"659004","name":"五家渠市","districts":[{"code":"659004","name":"五家渠市"}]},{"code":"659005","name":"北屯市","districts":[{"code":"659005","name":"北屯市"}]},{"code":"659006","name":"铁门关市","districts":[{"code":"659006","name":"铁门关市"}]},{"code":"659007","name":"双河市","districts":[{"code":"659007","name":"双河市"}]},{"code":"659008","name":"可克达拉市","districts":[{"code":"659008","name":"可克达拉市"}]},{"code":"659009","name":"昆玉市","districts":[{"code":"659009","name":"昆玉市"}]},{"code":"659010","name":"胡杨河市","districts":[{"code":"659010","name":"胡杨河市"}]},{"code":"659011","name":"新星市","districts":[{"code":"659011","name":"新星市"}]},{"code":"659012","name":"白杨市","districts":[{"code":"659012","name":"白杨市"}]}]},{"code":"710000","name":"台湾省","cities":[{"code":"710100","name":"台北市","districts":[{"code":"710101","name":"中正区"},{"code":"710102","name":"大同区"},{"code":"710103","name":"中山区"},{"code":"710104","name":"松山区"},{"code":"710105","name":"大安区"},{"code":"710106","name":"万华区"},{"code":"710107","name":"信义区"},{"code":"710108","name":"士林区"},{"code":"710109","name":"北投区"},{"code":"710110","name":"内湖区"},{"code":"710111","name":"南港区"},{"code":"710112","name":"文山区"}]},{"code":"710200","name":"高雄市","districts":[{"code":"710201","name":"新兴区"},{"code":"710202","name":"前金区"},{"code":"710203","name":"苓雅区"},{"code":"710204","name":"盐埕区"},{"code":"710205","name":"鼓山区"},{"code":"710206","name":"旗津区"},{"code":"710207","name":"前镇区"},{"code":"710208","name":"三民区"},{"code":"710209","name":"左营区"},{"code":"710210","name":"楠梓区"},{"code":"710211","name":"小港区"},{"code":"710242","name":"仁武区"},{"code":"710243","name":"大社区"},{"code":"710244","name":"冈山区"},{"code":"710245","name":"路竹区"},{"code":"710246","name":"阿莲区"},{"code":"710247","name":"田寮区"},{"code":"710248","name":"燕巢区"},{"code":"710249","name":"桥头区"},{"code":"710250","name":"梓官区"},{"code":"710251","name":"弥陀区"},{"code":"710252","name":"永安区"},{"code":"710253","name":"湖内区"},{"code":"710254","name":"凤山区"},{"code":"710255","name":"大寮区"},{"code":"710256","name":"林园区"},{"code":"710257","name":"鸟松区"},{"code":"710258","name":"大树区"},{"code":"710259","name":"旗山区"},{"code":"710260","name":"美浓区"},{"code":"710261","name":"六龟区"},{"code":"710262","name":"内门区"},{"code":"710263","name":"杉林区"},{"code":"710264","name":"甲仙区"},{"code":"710265","name":"桃源区"},{"code":"710266","name":"那玛夏区"},{"code":"710267","name":"茂林区"},{"code":"710268","name":"茄萣区"}]},{"code":"710300","name":"台南市","districts":[{"code":"710301","name":"中西区"},{"code":"710302","name":"东区"},{"code":"710303","name":"南区"},{"code":"710304","name":"北区"},{"code":"710305","name":"安平区"},{"code":"710306","name":"安南区"},{"code":"710339","name":"永康区"},{"code":"710340","name":"归仁区"},{"code":"710341","name":"新化区"},{"code":"710342","name":"左镇区"},{"code":"710343","name":"玉井区"},{"code":"710344","name":"楠西区"},{"code":"710345","name":"南化区"},{"code":"710346","name":"仁德区"},{"code":"710347","name":"关庙区"},{"code":"710348","name":"龙崎区"},{"code":"710349","name":"官田区"},{"code":"710350","name":"麻豆区"},{"code":"710351","name":"佳里区"},{"code":"710352","name":"西港区"},{"code":"710353","name":"七股区"},{"code":"710354","name":"将军区"},{"code":"710355","name":"学甲区"},{"code":"710356","name":"北门区"},{"code":"710357","name":"新营区"},{"code":"710358","name":"后壁区"},{"code":"710359","name":"白河区"},{"code":"710360","name":"东山区"},{"code":"710361","name":"六甲区"},{"code":"710362","name":"下营区"},{"code":"710363","name":"柳营区"},{"code":"710364","name":"盐水区"},{"code":"710365","name":"善化区"},{"code":"710366","name":"大内区"},{"code":"710367","name":"山上区"},{"code":"710368","name":"新市区"},{"code":"710369","name":"安定区"}]},{"code":"710400","name":"台中市","districts":[{"code":"710401","name":"中区"},{"code":"710402","name":"东区"},{"code":"710403","name":"南区"},{"code":"710404","name":"西区"},{"code":"710405","name":"北区"},{"code":"710406","name":"北屯区"},{"code":"710407","name":"西屯区"},{"code":"710408","name":"南屯区"},{"code":"710431","name":"太平区"},{"code":"710432","name":"大里区"},{"code":"710433","name":"雾峰区"},{"code":"710434","name":"乌日区"},{"code":"710435","name":"丰原区"},{"code":"710436","name":"后里区"},{"code":"710437","name":"石冈区"},{"code":"710438","name":"东势区"},{"code":"710439","name":"和平区"},{"code":"710440","name":"新社区"},{"code":"710441","name":"潭子区"},{"code":"710442","name":"大雅区"},{"code":"710443","name":"神冈区"},{"code":"710444","name":"大肚区"},{"code":"710445","name":"沙鹿区"},{"code":"710446","name":"龙井区"},{"code":"710447","name":"梧栖区"},{"code":"710448","name":"清水区"},{"code":"710449","name":"大甲区"},{"code":"710450","name":"外埔区"},{"code":"710451","name":"大安区"}]},{"code":"710600","name":"南投县","districts":[{"code":"710614","name":"南投市"},{"code":"710615","name":"中寮乡"},{"code":"710616","name":"草屯镇"},{"code":"710617","name":"国姓乡"},{"code":"710618","name":"埔里镇"},{"code":"710619","name":"仁爱乡"},{"code":"710620","name":"名间乡"},{"code":"710621","name":"集集镇"},{"code":"710622","name":"水里乡"},{"code":"710623","name":"鱼池乡"},{"code":"710624","name":"信义乡"},{"code":"710625","name":"竹山镇"},{"code":"710626","name":"鹿谷乡"}]},{"code":"710700","name":"基隆市","districts":[{"code":"710701","name":"仁爱区"},{"code":"710702","name":"信义区"},{"code":"710703","name":"中正区"},{"code":"710704","name":"中山区"},{"code":"710705","name":"安乐区"},{"code":"710706","name":"暖暖区"},{"code":"710707","name":"七堵区"}]},{"code":"710800","name":"新竹市","districts":[{"code":"710801","name":"东区"},{"code":"710802","name":"北区"},{"code":"710803","name":"香山区"}]},{"code":"710900","name":"嘉义市","districts":[{"code":"710901","name":"东区"},{"code":"710902","name":"西区"}]},{"code":"711100","name":"新北市","districts":[{"code":"711130","name":"万里区"},{"code":"711131","name":"金山区"},{"code":"711132","name":"板桥区"},{"code":"711133","name":"汐止区"},{"code":"711134","name":"深坑区"},{"code":"711135","name":"石碇区"},{"code":"711136","name":"瑞芳区"},{"code":"711137","name":"平溪区"},{"code":"711138","name":"双溪区"},{"code":"711139","name":"贡寮区"},{"code":"711140","name":"新店区"},{"code":"711141","name":"坪林区"},{"code":"711142","name":"乌来区"},{"code":"711143","name":"永和区"},{"code":"711144","name":"中和区"},{"code":"711145","name":"土城区"},{"code":"711146","name":"三峡区"},{"code":"711147","name":"树林区"},{"code":"711148","name":"莺歌区"},{"code":"711149","name":"三重区"},{"code":"711150","name":"新庄区"},{"code":"711151","name":"泰山区"},{"code":"711152","name":"林口区"},{"code":"711153","name":"芦洲区"},{"code":"711154","name":"五股区"},{"code":"711155","name":"八里区"},{"code":"711156","name":"淡水区"},{"code":"711157","name":"三芝区"},{"code":"711158","name":"石门区"}]},{"code":"711200","name":"宜兰县","districts":[{"code":"711214","name":"宜兰市"},{"code":"711215","name":"头城镇"},{"code":"711216","name":"礁溪乡"},{"code":"711217","name":"壮围乡"},{"code":"711218","name":"员山乡"},{"code":"711219","name":"罗东镇"},{"code":"711220","name":"三星乡"},{"code":"711221","name":"大同乡"},{"code":"711222","name":"五结乡"},{"code":"711223","name":"冬山乡"},{"code":"711224","name":"苏澳镇"},{"code":"711225","name":"南澳乡"}]},{"code":"711300","name":"新竹县","districts":[{"code":"711314","name":"竹北市"},{"code":"711315","name":"湖口乡"},{"code":"711316","name":"新丰乡"},{"code":"711317","name":"新埔镇"},{"code":"711318","name":"关西镇"},{"code":"711319","name":"芎林乡"},{"code":"711320","name":"宝山乡"},{"code":"711321","name":"竹东镇"},{"code":"711322","name":"五峰乡"},{"code":"711323","name":"横山乡"},{"code":"711324","name":"尖石乡"},{"code":"711325","name":"北埔乡"},{"code":"711326","name":"峨眉乡"}]},{"code":"711400","name":"桃园市","districts":[{"code":"711414","name":"中坜区"},{"code":"711415","name":"平镇区"},{"code":"711416","name":"龙潭区"},{"code":"711417","name":"杨梅区"},{"code":"711418","name":"新屋区"},{"code":"711419","name":"观音区"},{"code":"711420","name":"桃园区"},{"code":"711421","name":"龟山区"},{"code":"711422","name":"八德区"},{"code":"711423","name":"大溪区"},{"code":"711424","name":"复兴区"},{"code":"711425","name":"大园区"},{"code":"711426","name":"芦竹区"}]},{"code":"711500","name":"苗栗县","districts":[{"code":"711519","name":"竹南镇"},{"code":"711520","name":"头份市"},{"code":"711521","name":"三湾乡"},{"code":"711522","name":"南庄乡"},{"code":"711523","name":"狮潭乡"},{"code":"711524","name":"后龙镇"},{"code":"711525","name":"通霄镇"},{"code":"711526","name":"苑里镇"},{"code":"711527","name":"苗栗市"},{"code":"711528","name":"造桥乡"},{"code":"711529","name":"头屋乡"},{"code":"711530","name":"公馆乡"},{"code":"711531","name":"大湖乡"},{"code":"711532","name":"泰安乡"},{"code":"711533","name":"铜锣乡"},{"code":"711534","name":"三义乡"},{"code":"711535","name":"西湖乡"},{"code":"711536","name":"卓兰镇"}]},{"code":"711700","name":"彰化县","districts":[{"code":"711727","name":"彰化市"},{"code":"711728","name":"芬园乡"},{"code":"711729","name":"花坛乡"},{"code":"711730","name":"秀水乡"},{"code":"711731","name":"鹿港镇"},{"code":"711732","name":"福兴乡"},{"code":"711733","name":"线西乡"},{"code":"711734","name":"和美镇"},{"code":"711735","name":"伸港乡"},{"code":"711736","name":"员林市"},{"code":"711737","name":"社头乡"},{"code":"711738","name":"永靖乡"},{"code":"711739","name":"埔心乡"},{"code":"711740","name":"溪湖镇"},{"code":"711741","name":"大村乡"},{"code":"711742","name":"埔盐乡"},{"code":"711743","name":"田中镇"},{"code":"711744","name":"北斗镇"},{"code":"711745","name":"田尾乡"},{"code":"711746","name":"埤头乡"},{"code":"711747","name":"溪州乡"},{"code":"711748","name":"竹塘乡"},{"code":"711749","name":"二林镇"},{"code":"711750","name":"大城乡"},{"code":"711751","name":"芳苑乡"},{"code":"711752","name":"二水乡"}]},{"code":"711900","name":"嘉义县","districts":[{"code":"711919","name":"番路乡"},{"code":"711920","name":"梅山乡"},{"code":"711921","name":"竹崎乡"},{"code":"711922","name":"阿里山乡"},{"code":"711923","name":"中埔乡"},{"code":"711924","name":"大埔乡"},{"code":"711925","name":"水上乡"},{"code":"711926","name":"鹿草乡"},{"code":"711927","name":"太保市"},{"code":"711928","name":"朴子市"},{"code":"711929","name":"东石乡"},{"code":"711930","name":"六脚乡"},{"code":"711931","name":"新港乡"},{"code":"711932","name":"民雄乡"},{"code":"711933","name":"大林镇"},{"code":"711934","name":"溪口乡"},{"code":"711935","name":"义竹乡"},{"code":"711936","name":"布袋镇"}]},{"code":"712100","name":"云林县","districts":[{"code":"712121","name":"斗南镇"},{"code":"712122","name":"大埤乡"},{"code":"712123","name":"虎尾镇"},{"code":"712124","name":"土库镇"},{"code":"712125","name":"褒忠乡"},{"code":"712126","name":"东势乡"},{"code":"712127","name":"台西乡"},{"code":"712128","name":"仑背乡"},{"code":"712129","name":"麦寮乡"},{"code":"712130","name":"斗六市"},{"code":"712131","name":"林内乡"},{"code":"712132","name":"古坑乡"},{"code":"712133","name":"莿桐乡"},{"code":"712134","name":"西螺镇"},{"code":"712135","name":"二仑乡"},{"code":"712136","name":"北港镇"},{"code":"712137","name":"水林乡"},{"code":"712138","name":"口湖乡"},{"code":"712139","name":"四湖乡"},{"code":"712140","name":"元长乡"}]},{"code":"712400","name":"屏东县","districts":[{"code":"712434","name":"屏东市"},{"code":"712435","name":"三地门乡"},{"code":"712436","name":"雾台乡"},{"code":"712437","name":"玛家乡"},{"code":"712438","name":"九如乡"},{"code":"712439","name":"里港乡"},{"code":"712440","name":"高树乡"},{"code":"712441","name":"盐埔乡"},{"code":"712442","name":"长治乡"},{"code":"712443","name":"麟洛乡"},{"code":"712444","name":"竹田乡"},{"code":"712445","name":"内埔乡"},{"code":"712446","name":"万丹乡"},{"code":"712447","name":"潮州镇"},{"code":"712448","name":"泰武乡"},{"code":"712449","name":"来义乡"},{"code":"712450","name":"万峦乡"},{"code":"712451","name":"崁顶乡"},{"code":"712452","name":"新埤乡"},{"code":"712453","name":"南州乡"},{"code":"712454","name":"林边乡"},{"code":"712455","name":"东港镇"},{"code":"712456","name":"琉球乡"},{"code":"712457","name":"佳冬乡"},{"code":"712458","name":"新园乡"},{"code":"712459","name":"枋寮乡"},{"code":"712460","name":"枋山乡"},{"code":"712461","name":"春日乡"},{"code":"712462","name":"狮子乡"},{"code":"712463","name":"车城乡"},{"code":"712464","name":"牡丹乡"},{"code":"712465","name":"恒春镇"},{"code":"712466","name":"满州乡"}]},{"code":"712500","name":"台东县","districts":[{"code":"712517","name":"台东市"},{"code":"712518","name":"绿岛乡"},{"code":"712519","name":"兰屿乡"},{"code":"712520","name":"延平乡"},{"code":"712521","name":"卑南乡"},{"code":"712522","name":"鹿野乡"},{"code":"712523","name":"关山镇"},{"code":"712524","name":"海端乡"},{"code":"712525","name":"池上乡"},{"code":"712526","name":"东河乡"},{"code":"712527","name":"成功镇"},{"code":"712528","name":"长滨乡"},{"code":"712529","name":"金峰乡"},{"code":"712530","name":"大武乡"},{"code":"712531","name":"达仁乡"},{"code":"712532","name":"太麻里乡"}]},{"code":"712600","name":"花莲县","districts":[{"code":"712615","name":"花莲市"},{"code":"712616","name":"新城乡"},{"code":"712618","name":"秀林乡"},{"code":"712619","name":"吉安乡"},{"code":"712620","name":"寿丰乡"},{"code":"712621","name":"凤林镇"},{"code":"712622","name":"光复乡"},{"code":"712623","name":"丰滨乡"},{"code":"712624","name":"瑞穗乡"},{"code":"712625","name":"万荣乡"},{"code":"712626","name":"玉里镇"},{"code":"712627","name":"卓溪乡"},{"code":"712628","name":"富里乡"}]},{"code":"712700","name":"澎湖县","districts":[{"code":"712707","name":"马公市"},{"code":"712708","name":"西屿乡"},{"code":"712709","name":"望安乡"},{"code":"712710","name":"七美乡"},{"code":"712711","name":"白沙乡"},{"code":"712712","name":"湖西乡"}]}]},{"code":"810000","name":"香港特别行政区","cities":[{"code":"810000","name":"香港特别行政区","districts":[{"code":"810000","name":"香港特别行政区"}]}]},{"code":"820000","name":"澳门特别行政区","cities":[{"code":"820000","name":"澳门特别行政区","districts":[{"code":"820000","name":"澳门特别行政区"}]}]}]; diff --git a/src/data/region-service.mjs b/src/data/region-service.mjs new file mode 100644 index 0000000..5db8822 --- /dev/null +++ b/src/data/region-service.mjs @@ -0,0 +1,25 @@ +import { chinaRegions } from './china-regions.mjs'; + +export function resolveRegion(input = {}) { + const provinceCode = String(input.provinceCode || '').trim(); + const cityCode = String(input.cityCode || '').trim(); + const districtCode = String(input.districtCode || '').trim(); + const province = chinaRegions.find(item => item.code === provinceCode); + const city = province?.cities.find(item => item.code === cityCode); + const district = city?.districts.find(item => item.code === districtCode); + if (!province || !city || !district) return null; + return { + provinceCode: province.code, + provinceName: province.name, + cityCode: city.code, + cityName: city.name, + districtCode: district.code, + districtName: district.name + }; +} + +export function regionLabel(region = {}) { + return [region.provinceName, region.cityName, region.districtName] + .filter((value, index, values) => value && value !== values[index - 1]) + .join(''); +} diff --git a/src/data/seed.mjs b/src/data/seed.mjs new file mode 100644 index 0000000..4e98d88 --- /dev/null +++ b/src/data/seed.mjs @@ -0,0 +1,396 @@ +export function createSeedDatabase({ nowIso, hashPassword, candidateCount = 1200 }) { + const adminId = 'usr_admin'; + const schoolAdminId = 'usr_school_admin'; + const schoolAdmin2Id = 'usr_school_admin_2'; + const candidateId = 'usr_demo'; + 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 }, + organization: { + name: '海州市教育考试中心', + code: 'HZ-EDU-032', + phone: '0518-8602 3158', + address: '江苏省连云港市海州区文教路 18 号' + }, + schools: [ + { 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 }, + { id: 'class_hz1_302', schoolId: 'school_hz1', name: '高三(2)班', grade: '高三', active: true }, + { id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true } + ], + users: [ + { id: adminId, username: 'admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() }, + { id: 'usr_supervisor', username: 'supervisor', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() }, + { id: schoolAdminId, username: 'school_admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() }, + { id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() }, + { id: 'usr_class_admin', username: 'class_admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() }, + { id: 'usr_class_admin_2', username: 'class_admin_2', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '李班管', active: true, createdAt: nowIso() }, + { id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: testPasswordHash, role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: false, createdAt: nowIso() } + ], + candidateProfiles: [ + { + id: 'profile_demo', userId: candidateId, name: '周雨桐', gender: '女', idNumber: '320101200808164821', + 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', + 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' + } + ], + notices: [ + { id: 'notice_1', title: '2026 年秋季统一考试报名安排', summary: '报名时间为 7 月 1 日至 7 月 31 日,请考生完成实名认证后选报科目。', content: '2026 年秋季统一考试报名现已开放。考生须在规定时间内登录平台,核对个人信息并选择报考科目。逾期不再补报。', category: '报名通知', pinned: true, status: 'published', publishAt: '2026-07-01T01:00:00.000Z', author: '考试中心' }, + { id: 'notice_2', title: '准考证下载与考场规则说明', summary: '准考证开放下载后,请使用 A4 纸打印并妥善保管。', content: '准考证下载时间为 7 月 20 日至 8 月 16 日。考生须携带身份证和纸质准考证入场,开考 15 分钟后不得进入考点。', category: '考试须知', pinned: false, status: 'published', publishAt: '2026-07-15T02:30:00.000Z', author: '考试中心' }, + { id: 'notice_3', title: '市第三中学考点交通提示', summary: '考试期间考点周边实行临时交通管制,请提前规划路线。', content: '建议考生至少提前 50 分钟到达考点。考点不提供停车位,请优先选择公共交通出行。', category: '考点公告', pinned: false, status: 'published', publishAt: '2026-07-18T06:00:00.000Z', author: '考务组' } + ], + exams: [ + { + 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: [ + { 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: 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: [], + testCenters: [ + { id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', address: '学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() }, + { id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320703', districtName: '连云区', address: '育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() } + ], + testRooms: [ + { id: 'room_hz1_001', centerId: 'center_hz1', code: '001', name: '第 001 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_002', centerId: 'center_hz1', code: '002', name: '第 002 考场', building: '教学楼 A', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴从前至后编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz1_pc01', centerId: 'center_hz1', code: 'PC01', name: '机考 01 考场', building: '实验楼', floor: '3 层', capacity: 40, seatPlan: '按终端编号编排', roomType: 'computer', status: 'active', notes: '配备备用终端 4 台' }, + { id: 'room_hz3_001', centerId: 'center_hz3', code: '001', name: '第 001 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '按现场桌贴编排', roomType: 'standard', status: 'active', notes: '' }, + { id: 'room_hz3_002', centerId: 'center_hz3', code: '002', name: '第 002 考场', building: '笃学楼', floor: '1 层', capacity: 30, seatPlan: '无障碍席位优先编排', roomType: 'accessible', status: 'active', notes: '靠近无障碍通道' } + ], + centerChangeRequests: [], + centerChangeRooms: [], + admissionNumberRules: [ + { + id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号', + description: '适合县区统一组织,号码直接反映县区、考试考场与座位。', separator: '', example: '32070603108', active: true, createdAt: nowIso(), + segments: [ + { source: 'district_code', label: '县区编号', width: 6 }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'seat', label: '座位号', width: 2 } + ] + }, + { + id: 'admit_rule_district_room_sequence', code: 'district_room_sequence', name: '县区号 + 考场号 + 流水号', + description: '以县区为流水边界,适合不希望座位号直接出现在号码中的场景。', separator: '', example: '3207060310028', active: true, createdAt: nowIso(), + segments: [ + { source: 'district_code', label: '县区号', width: 6 }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'sequence', label: '流水号', width: 4 } + ] + }, + { + id: 'admit_rule_center_school_room_seat', code: 'center_school_room_seat', name: '考点学校代码 + 考场号 + 座位号', + description: '号码前缀取考点所属学校代码,便于考点现场快速识别。', separator: '', example: 'HZ0303108', active: true, createdAt: nowIso(), + segments: [ + { source: 'center_school_code', label: '考点学校代码' }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'seat', label: '座位号', width: 2 } + ] + }, + { + id: 'admit_rule_candidate_school_room_seat', code: 'candidate_school_room_seat', name: '考生学校代码 + 考场号 + 座位号', + description: '号码前缀保留考生学籍学校代码,适合按生源学校归档。', separator: '', example: 'HZ0103108', active: true, createdAt: nowIso(), + segments: [ + { source: 'candidate_school_code', label: '考生学校代码' }, + { source: 'exam_room_code', label: '考场号', width: 3 }, + { source: 'seat', label: '座位号', width: 2 } + ] + } + ], + arrangementPlans: [], + candidateAccountBatches: [], + candidateAccountBatchItems: [], + numberRules: [ + { id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [ + { id: 'segment_year', position: 1, type: 'year', value: '', width: 4 }, + { id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 }, + { id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 }, + { id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 } + ] } + ], + workflows: [ + { id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' }, + { id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' }, + { id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' } + ] }, + { id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' } + ] }, + { id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' } + ] }, + { id: 'workflow_score_appeal', businessType: 'score_appeal', name: '考生成绩复议', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [ + { id: 'workflow_score_appeal_step_1', position: 1, name: '班级情况核验', adminLevel: 'class' }, + { id: 'workflow_score_appeal_step_2', position: 2, name: '学校成绩复核', adminLevel: 'school' }, + { id: 'workflow_score_appeal_step_3', position: 3, name: '考试中心终审', adminLevel: 'super' } + ] } + ], + workflowInstances: [], + workflowActions: [], + admissionRecords: [], + auditLogs: [ + { id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' } + ] + }; + + 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: 'hz9', id: 'school_hz9', code: 'HZ09', name: '海州市第九中学', districtCode: '320724', districtName: '灌南县', address: '新安路 39 号' } + ]; + 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}`, isSourceSchool: true, isAdmissionSchool: false, active: true }); + } + for (let classIndex = 1; classIndex <= 3; classIndex += 1) { + const classId = `class_${school.key}_30${classIndex}`; + if (!database.classes.some(item => item.id === classId)) { + database.classes.push({ id: classId, schoolId: school.id, name: `高三(${classIndex})班`, grade: '高三', active: true }); + } + } + } + + 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: `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, + displayName: `${school.name}高三${classIndex}班测试班管`, active: true, createdAt: nowIso() + }); + } + } + + for (const school of sourceSchools) { + let center = database.testCenters.find(item => item.schoolId === school.id); + if (!center) { + center = { + id: `center_${school.key}`, schoolId: school.id, code: `${school.code}-C01`, name: `${school.name}考点`, + provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', + districtCode: school.districtCode, districtName: school.districtName, address: school.address, + contact: '0518-8602 0000', managerName: '测试负责人', managerPhone: '13800000000', emergencyPhone: '0518-8602 0120', + gateOpenTime: '07:00', transport: '测试数据:请以正式考点通知为准', status: 'active', notes: '仅供手动导入测试数据使用', rooms: '', updatedAt: nowIso() + }; + database.testCenters.push(center); + } + for (let roomIndex = 1; roomIndex <= 4; roomIndex += 1) { + const roomId = `room_${school.key}_test_${roomIndex}`; + if (!database.testRooms.some(item => item.id === roomId)) { + database.testRooms.push({ + id: roomId, centerId: center.id, code: `T0${roomIndex}`, name: `测试第 ${roomIndex} 考场`, building: '测试教学楼', + floor: `${Math.ceil(roomIndex / 2)} 层`, capacity: 30, seatPlan: '等待正式编排', roomType: 'standard', status: 'active', notes: '未编排' + }); + } + } + } + + // 测试库停留在考场编排前:不预置编排计划或准考证;主考试成绩与志愿已完成。 + const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫']; + const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡']; + 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: '舞蹈' } + ]; + for (let index = 0; index < mainCandidateCount - 1; index += 1) { + const serial = index + 1001; + 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'; + const userId = `usr_bulk_${String(index + 1).padStart(4, '0')}`; + 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, 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')}`; + database.users.push({ + 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, + provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', + districtCode: school.districtCode, districtName: school.districtName, address: `${school.address}测试宿舍 ${index % 20 + 1} 号`, + 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')}`, + 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 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: mainSubjectIds, status: 'approved', paymentStatus, + paidAt: paymentStatus === 'paid' ? '2026-05-18T08:30:00.000Z' : null, + paidBy: paymentStatus === 'paid' ? classAdminId : null, + createdAt, reviewedAt: '2026-04-30T08:00:00.000Z', reviewNote: '批量演示数据:报名审核通过', + registrationNumber: candidateNumber, numberRuleId: 'rule_default', + featureScore: specialty ? Number((80 + seededRandom() * 20).toFixed(1)) : 0 + }); + } + + 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' + }); + } + 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; +} diff --git a/src/data/specialty-types.mjs b/src/data/specialty-types.mjs new file mode 100644 index 0000000..04d9be4 --- /dev/null +++ b/src/data/specialty-types.mjs @@ -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; +} diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs new file mode 100644 index 0000000..60c714b --- /dev/null +++ b/src/database/mysql-adapter.mjs @@ -0,0 +1,455 @@ +import { synchronizeMysqlPartitions } from './partition-storage.mjs'; + +import { createStateCache } from './state-cache.mjs'; + +export function createMysqlAdapter(context) { + const { + mkdir, + dirname, + sqliteSchema, + mysqlSchema, + optional, + buildSeedOperations, + stateFromRows, + readSqliteRows, + readMysqlRows, + createRepository + } = context; + + async function createMysqlStore({ seed }) { + const { default: mysql } = await import('mysql2/promise'); + const connectionUrl = process.env.DATABASE_URL; + const database = process.env.MYSQL_DATABASE; + + if (!connectionUrl && (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !database)) { + throw new Error('MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE'); + } + + const pool = connectionUrl + ? mysql.createPool(connectionUrl) + : mysql.createPool({ + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT || 3306), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD || '', + database, + waitForConnections: true, + connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10), + charset: 'utf8mb4', + timezone: 'Z', + enableKeepAlive: true + }); + + const mysqlTableNames = mysqlSchema.map(statement => + statement.match(/^CREATE TABLE IF NOT EXISTS\s+([a-z0-9_]+)/i)?.[1] + ).filter(Boolean); + const [databaseTables] = await pool.execute(` + SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' + `); + const existingTableNames = new Set(databaseTables.map(row => row.TABLE_NAME)); + const existingAppTables = mysqlTableNames.filter(table => existingTableNames.has(table)); + let hasSchemaMetadata = false; + let existingSchemaVersion = null; + if (existingTableNames.has('schema_metadata')) { + const [metadataRows] = await pool.execute('SELECT id, schema_version FROM schema_metadata WHERE id = 1'); + hasSchemaMetadata = metadataRows.length > 0; + existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null; + } + 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}\``); + } + } + + for (const statement of mysqlSchema) await pool.query(statement); + // Development schemas are created from the current DDL as a whole. MySQL 8.4 lacks + // MariaDB-style conditional column addition; outdated schemas are rejected by the + // version check below and should be rebuilt instead of migrated column by column. + const [resultLockTriggers] = await pool.execute(` + SELECT TRIGGER_NAME FROM information_schema.TRIGGERS + WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME LIKE 'trg_results_lock_archived_%' + `); + const existingResultLockTriggers = new Set(resultLockTriggers.map(item => item.TRIGGER_NAME)); + const mysqlResultLockTriggers = { + trg_results_lock_archived_insert: `CREATE TRIGGER trg_results_lock_archived_insert BEFORE INSERT ON results FOR EACH ROW + BEGIN + IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id + WHERE registration.id = NEW.registration_id AND exam.archived_at IS NOT NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定'; + END IF; + END`, + trg_results_lock_archived_update: `CREATE TRIGGER trg_results_lock_archived_update BEFORE UPDATE ON results FOR EACH ROW + BEGIN + IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id + WHERE registration.id IN (OLD.registration_id, NEW.registration_id) AND exam.archived_at IS NOT NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定'; + END IF; + END`, + trg_results_lock_archived_delete: `CREATE TRIGGER trg_results_lock_archived_delete BEFORE DELETE ON results FOR EACH ROW + BEGIN + IF EXISTS (SELECT 1 FROM registrations registration JOIN exams exam ON exam.id = registration.exam_id + WHERE registration.id = OLD.registration_id AND exam.archived_at IS NOT NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '归档考试成绩已永久锁定'; + END IF; + END` + }; + for (const [name, statement] of Object.entries(mysqlResultLockTriggers)) { + if (!existingResultLockTriggers.has(name)) await pool.query(statement); + } + const [legacyRegistrationNumberIndexes] = await pool.query("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); + if (legacyRegistrationNumberIndexes.length) await pool.query('ALTER TABLE registrations DROP INDEX uq_registrations_number'); + const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1'); + if (existing.length) { + const [metadataRows] = await pool.execute('SELECT app_version, schema_version FROM schema_metadata WHERE id = 1'); + if (Number(metadataRows[0]?.schema_version || 1) < 9) { + await pool.query('DROP TABLE IF EXISTS admit_card_subjects'); + await pool.query('DROP TABLE IF EXISTS admit_cards'); + await pool.query('DROP TABLE IF EXISTS exam_arrangement_plans'); + await pool.query('DROP TABLE IF EXISTS admission_number_rules'); + const admissionTables = ['admission_number_rules', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects']; + for (const table of admissionTables) { + const statement = mysqlSchema.find(item => item.includes(`CREATE TABLE IF NOT EXISTS ${table} (`)); + if (!statement) throw new Error(`缺少 ${table} 的 MySQL 表定义`); + await pool.query(statement); + } + const extension = seed(); + for (const rule of extension.admissionNumberRules) await pool.execute( + `INSERT INTO admission_number_rules ( + id, code, name, description, \`separator\`, segments_json, example, active, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []), + rule.example || '', rule.active === false ? 0 : 1, rule.createdAt] + ); + await pool.execute('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1'); + metadataRows[0].schema_version = 9; + metadataRows[0].app_version = 9; + } + if (Number(metadataRows[0]?.schema_version || 1) < 10) { + await pool.execute(`UPDATE admit_cards card LEFT JOIN test_centers center ON center.id = card.center_id SET + card.center_code = COALESCE(center.code, card.center_code), + card.center_address = COALESCE(CONCAT_WS(' ', center.province_name, center.city_name, center.district_name, center.address), card.center_address)`); + await pool.execute(`UPDATE admit_card_subjects assignment LEFT JOIN test_rooms room ON room.id = assignment.room_id SET + assignment.building = COALESCE(room.building, assignment.building), + assignment.floor = COALESCE(room.floor, assignment.floor)`); + await pool.execute('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1'); + metadataRows[0].schema_version = 10; + metadataRows[0].app_version = 10; + } + if (Number(metadataRows[0]?.schema_version || 1) < 11) { + await pool.execute("UPDATE exam_subjects SET pass_rule = 'fixed_score', pass_value = pass_score"); + await pool.execute('UPDATE schema_metadata SET schema_version = 11, app_version = 11 WHERE id = 1'); + metadataRows[0].schema_version = 11; + metadataRows[0].app_version = 11; + } + if (Number(metadataRows[0]?.schema_version || 1) < 12) { + await pool.execute("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'"); + await pool.execute('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1'); + metadataRows[0].schema_version = 12; + metadataRows[0].app_version = 12; + } + if (Number(metadataRows[0]?.schema_version || 1) < 13) { + await pool.execute('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1'); + metadataRows[0].schema_version = 13; + metadataRows[0].app_version = 13; + } + if (Number(metadataRows[0]?.schema_version || 1) < 15) { + throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动'); + } + if (Number(metadataRows[0]?.schema_version || 1) < 16) { + await pool.execute('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1'); + metadataRows[0].schema_version = 16; + } + if (Number(metadataRows[0]?.schema_version || 1) < 17) { + await pool.query(`ALTER TABLE users + ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER must_change_password, + ADD COLUMN totp_secret_encrypted VARCHAR(512) NULL AFTER totp_enabled, + ADD COLUMN totp_recovery_codes VARCHAR(2048) NOT NULL DEFAULT '[]' AFTER totp_secret_encrypted, + ADD COLUMN totp_last_used_step BIGINT NULL AFTER totp_recovery_codes`); + 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(); + try { + await connection.beginTransaction(); + for (const school of extension.schools) await connection.execute( + 'INSERT IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)', + [school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1] + ); + for (const schoolClass of extension.classes) await connection.execute( + 'INSERT IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)', + [schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1] + ); + await connection.execute("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, TRUE) WHERE role = 'admin'"); + for (const user of extension.users.filter(item => item.role === 'admin')) await connection.execute( + `INSERT IGNORE INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`, + [user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt] + ); + for (const profile of extension.candidateProfiles) await connection.execute( + `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) + WHERE school = ? AND grade = ?`, + [optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade] + ); + const [centerRows] = await connection.execute('SELECT id FROM test_centers LIMIT 1'); + if (!centerRows.length) for (const center of extension.testCenters) await connection.execute( + 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt] + ); + const [ruleRows] = await connection.execute('SELECT id FROM number_rules LIMIT 1'); + if (!ruleRows.length) for (const rule of extension.numberRules) { + await connection.execute( + 'INSERT INTO number_rules (id, name, `separator`, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt] + ); + for (const [index, segment] of rule.segments.entries()) await connection.execute( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)', + [segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)] + ); + } + const [workflowRows] = await connection.execute('SELECT id FROM workflow_definitions LIMIT 1'); + if (!workflowRows.length) for (const workflow of extension.workflows) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt] + ); + for (const [index, step] of workflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 3) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const center of extension.testCenters) await connection.execute( + `UPDATE test_centers SET + code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), + manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), + gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), + status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) + WHERE id = ?`, + [center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id] + ); + await connection.execute("UPDATE test_centers SET code = CONCAT('CENTER-', RIGHT(id, 8)) WHERE code IS NULL OR code = ''"); + const [roomRows] = await connection.execute('SELECT id FROM test_rooms LIMIT 1'); + if (!roomRows.length) for (const room of extension.testRooms) await connection.execute( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)] + ); + const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); + const [centerWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1"); + if (centerWorkflow && !centerWorkflowRows.length) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt] + ); + for (const [index, step] of centerWorkflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 4) { + const extension = seed(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const user of extension.users.filter(item => item.role === 'candidate')) await connection.execute( + `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), + must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`, + [optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id] + ); + await connection.execute(`UPDATE users SET candidate_number = COALESCE( + (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), + CONCAT('CAND-', RIGHT(id, 10)) + ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`); + for (const profile of extension.candidateProfiles) await connection.execute( + `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), + ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), + guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`, + [optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id] + ); + await connection.execute(`UPDATE registrations JOIN users ON users.id = registrations.user_id + SET registrations.registration_number = users.candidate_number + WHERE registrations.registration_number IS NULL OR registrations.registration_number = ''`); + await connection.execute('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 5) { + const extension = seed(); + const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [batchWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1"); + if (batchWorkflow && !batchWorkflowRows.length) { + await connection.execute( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt] + ); + for (const [index, step] of batchWorkflow.steps.entries()) await connection.execute( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)', + [step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel] + ); + } + await connection.execute('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1'); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + if (Number(metadataRows[0]?.app_version || 1) < 6) { + await pool.execute('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1'); + } + if (Number(metadataRows[0]?.app_version || 1) < 7) { + await pool.execute('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1'); + } + if (Number(metadataRows[0]?.schema_version || 1) < 15) { + throw new Error('数据库结构已升级到 v15,请重建开发数据库后重新启动'); + } + } + if (!existing.length) { + const initialState = seed(); + const connection = await pool.getConnection(); + try { + 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, 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); + } + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } + + const [centerCodeIndexes] = await pool.query("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'"); + if (!centerCodeIndexes.length) { + await pool.query('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); + } + const [candidateNumberIndexes] = await pool.query("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'"); + if (!candidateNumberIndexes.length) { + await pool.query('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); + } + + const partitionConnection = await pool.getConnection(); + try { + await synchronizeMysqlPartitions(partitionConnection); + } finally { + partitionConnection.release(); + } + + let stateCache; + const transaction = async operations => { + stateCache?.invalidate(); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const item of operations) await connection.execute(item.sql, item.params); + await connection.commit(); + await synchronizeMysqlPartitions(connection); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + stateCache?.invalidate(); + connection.release(); + } + }; + const loadState = async () => { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const state = stateFromRows(await readMysqlRows(connection)); + await connection.commit(); + return state; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + 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: stateCache.read, + transaction, + close: async () => pool.end() + }); + } + + return createMysqlStore; +} diff --git a/src/database/partition-storage.mjs b/src/database/partition-storage.mjs new file mode 100644 index 0000000..0c7f0cd --- /dev/null +++ b/src/database/partition-storage.mjs @@ -0,0 +1,324 @@ +import { createHash } from 'node:crypto'; + +const identifierPattern = /^[a-z][a-z0-9_]{0,63}$/; + +function partitionKey(ownerId) { + return createHash('sha256').update(String(ownerId)).digest('hex').slice(0, 16); +} + +function namesForExam(examId) { + const key = partitionKey(examId); + return { + key, + candidates: `exam_${key}_candidates`, + admissions: `exam_${key}_admissions`, + results: `exam_${key}_results`, + centers: `exam_${key}_centers` + }; +} + +function namesForSchool(schoolId) { + const key = partitionKey(schoolId); + return { key, students: `school_${key}_students` }; +} + +function quoteSqlite(identifier) { + if (!identifierPattern.test(identifier)) throw new Error(`非法 SQLite 分表名称:${identifier}`); + return `"${identifier}"`; +} + +function quoteMysql(identifier) { + if (!identifierPattern.test(identifier)) throw new Error(`非法 MySQL 分表名称:${identifier}`); + return `\`${identifier}\``; +} + +function sqliteExamTables(connection, names) { + const candidates = quoteSqlite(names.candidates); + const admissions = quoteSqlite(names.admissions); + const results = quoteSqlite(names.results); + const centers = quoteSqlite(names.centers); + connection.exec(` + CREATE TABLE IF NOT EXISTS ${candidates} ( + registration_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, user_id TEXT NOT NULL, + candidate_number TEXT, candidate_name TEXT NOT NULL, school_id TEXT, class_id TEXT, + registration_status TEXT NOT NULL, payment_status TEXT NOT NULL, registered_at TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS ${admissions} ( + registration_id TEXT NOT NULL, subject_id TEXT NOT NULL, exam_id TEXT NOT NULL, + candidate_number TEXT, admission_number TEXT, test_center TEXT, center_code TEXT, + center_address TEXT, room_id TEXT, room_name TEXT, room_code TEXT, exam_room_code TEXT, + building TEXT, floor TEXT, seat TEXT, generated_at TEXT, + PRIMARY KEY (registration_id, subject_id) + ) STRICT; + CREATE TABLE IF NOT EXISTS ${results} ( + result_id TEXT PRIMARY KEY, exam_id TEXT NOT NULL, registration_id TEXT NOT NULL, + subject_id TEXT NOT NULL, candidate_number TEXT, score REAL NOT NULL, grade TEXT NOT NULL, + published INTEGER NOT NULL CHECK (published IN (0, 1)), updated_at TEXT, published_at TEXT, + UNIQUE (registration_id, subject_id) + ) STRICT; + CREATE TABLE IF NOT EXISTS ${centers} ( + center_key TEXT PRIMARY KEY, exam_id TEXT NOT NULL, center_id TEXT, center_code TEXT, + center_name TEXT NOT NULL, center_address TEXT NOT NULL, candidate_count INTEGER NOT NULL, + room_count INTEGER NOT NULL + ) STRICT; + `); +} + +function sqliteSchoolTable(connection, names) { + const students = quoteSqlite(names.students); + connection.exec(` + CREATE TABLE IF NOT EXISTS ${students} ( + user_id TEXT PRIMARY KEY, school_id TEXT NOT NULL, candidate_number TEXT, + candidate_name TEXT NOT NULL, id_number TEXT, gender TEXT, class_id TEXT, grade TEXT, + phone TEXT, email TEXT, profile_status TEXT, active INTEGER NOT NULL CHECK (active IN (0, 1)), + updated_at TEXT + ) STRICT; + `); +} + +function syncSqliteExam(connection, examId, names) { + const candidates = quoteSqlite(names.candidates); + const admissions = quoteSqlite(names.admissions); + const results = quoteSqlite(names.results); + const centers = quoteSqlite(names.centers); + connection.exec(`DELETE FROM ${candidates}; DELETE FROM ${admissions}; DELETE FROM ${results}; DELETE FROM ${centers};`); + connection.prepare(` + INSERT INTO ${candidates} ( + registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id, + registration_status, payment_status, registered_at + ) + SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number, + COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id), + COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status, + registration.created_at + FROM registrations registration + JOIN users user ON user.id = registration.user_id + LEFT JOIN candidate_profiles profile ON profile.user_id = user.id + WHERE registration.exam_id = ? + `).run(examId); + connection.prepare(` + INSERT INTO ${admissions} ( + registration_id, subject_id, exam_id, candidate_number, admission_number, test_center, + center_code, center_address, room_id, room_name, room_code, exam_room_code, + building, floor, seat, generated_at + ) + SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number, + card.card_number, card.test_center, card.center_code, card.center_address, + assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code, + assignment.building, assignment.floor, assignment.seat, card.generated_at + FROM registrations registration + JOIN users user ON user.id = registration.user_id + JOIN registration_subjects selected ON selected.registration_id = registration.id + LEFT JOIN admit_cards card ON card.registration_id = registration.id + LEFT JOIN admit_card_subjects assignment + ON assignment.registration_id = registration.id AND assignment.subject_id = selected.subject_id + WHERE registration.exam_id = ? + `).run(examId); + connection.prepare(` + INSERT INTO ${results} ( + result_id, exam_id, registration_id, subject_id, candidate_number, score, grade, + published, updated_at, published_at + ) + SELECT result.id, registration.exam_id, result.registration_id, result.subject_id, + user.candidate_number, result.score, result.grade, result.published, + result.updated_at, result.published_at + FROM results result + JOIN registrations registration ON registration.id = result.registration_id + JOIN users user ON user.id = registration.user_id + WHERE registration.exam_id = ? + `).run(examId); + connection.prepare(` + INSERT INTO ${centers} ( + center_key, exam_id, center_id, center_code, center_name, center_address, + candidate_count, room_count + ) + SELECT COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id, + card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address), + COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id) + FROM admit_cards card + JOIN registrations registration ON registration.id = card.registration_id + LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id + WHERE registration.exam_id = ? + GROUP BY COALESCE(card.center_id, 'snapshot:' || card.center_code), registration.exam_id, + card.center_id, card.center_code + `).run(examId); +} + +function syncSqliteSchool(connection, schoolId, names) { + const students = quoteSqlite(names.students); + connection.exec(`DELETE FROM ${students};`); + connection.prepare(` + INSERT INTO ${students} ( + user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id, + grade, phone, email, profile_status, active, updated_at + ) + SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name), + profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade, + profile.phone, profile.email, profile.status, user.active, profile.updated_at + FROM users user + LEFT JOIN candidate_profiles profile ON profile.user_id = user.id + WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ? + `).run(schoolId, schoolId); +} + +export function synchronizeSqlitePartitions(connection) { + const now = new Date().toISOString(); + const exams = connection.prepare('SELECT id FROM exams ORDER BY id').all(); + const schools = connection.prepare('SELECT id FROM schools ORDER BY id').all(); + for (const { id } of exams) { + const names = namesForExam(id); + sqliteExamTables(connection, names); + connection.prepare(` + INSERT INTO exam_data_partitions ( + exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(exam_id) DO UPDATE SET partition_key = excluded.partition_key, + candidates_table = excluded.candidates_table, admissions_table = excluded.admissions_table, + results_table = excluded.results_table, centers_table = excluded.centers_table, + updated_at = excluded.updated_at + `).run(id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now); + syncSqliteExam(connection, id, names); + } + for (const { id } of schools) { + const names = namesForSchool(id); + sqliteSchoolTable(connection, names); + connection.prepare(` + INSERT INTO school_student_partitions (school_id, partition_key, students_table, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(school_id) DO UPDATE SET partition_key = excluded.partition_key, + students_table = excluded.students_table, updated_at = excluded.updated_at + `).run(id, names.key, names.students, now, now); + syncSqliteSchool(connection, id, names); + } +} + +async function mysqlExamTables(connection, names) { + const candidates = quoteMysql(names.candidates); + const admissions = quoteMysql(names.admissions); + const results = quoteMysql(names.results); + const centers = quoteMysql(names.centers); + await connection.query(`CREATE TABLE IF NOT EXISTS ${candidates} ( + registration_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, + candidate_number VARCHAR(120) NULL, candidate_name VARCHAR(120) NOT NULL, school_id VARCHAR(64) NULL, + class_id VARCHAR(64) NULL, registration_status VARCHAR(20) NOT NULL, payment_status VARCHAR(20) NOT NULL, + registered_at VARCHAR(35) NOT NULL, PRIMARY KEY (registration_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); + await connection.query(`CREATE TABLE IF NOT EXISTS ${admissions} ( + registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, + candidate_number VARCHAR(120) NULL, admission_number VARCHAR(120) NULL, test_center VARCHAR(200) NULL, + center_code VARCHAR(60) NULL, center_address VARCHAR(500) NULL, room_id VARCHAR(64) NULL, + room_name VARCHAR(120) NULL, room_code VARCHAR(60) NULL, exam_room_code VARCHAR(120) NULL, + building VARCHAR(120) NULL, floor VARCHAR(60) NULL, seat VARCHAR(60) NULL, generated_at VARCHAR(35) NULL, + PRIMARY KEY (registration_id, subject_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); + await connection.query(`CREATE TABLE IF NOT EXISTS ${results} ( + result_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, registration_id VARCHAR(64) NOT NULL, + subject_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL, score DOUBLE NOT NULL, + grade VARCHAR(30) NOT NULL, published BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL, + published_at VARCHAR(35) NULL, PRIMARY KEY (result_id), UNIQUE KEY uq_registration_subject (registration_id, subject_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); + await connection.query(`CREATE TABLE IF NOT EXISTS ${centers} ( + center_key VARCHAR(160) NOT NULL, exam_id VARCHAR(64) NOT NULL, center_id VARCHAR(64) NULL, + center_code VARCHAR(60) NULL, center_name VARCHAR(200) NOT NULL, center_address VARCHAR(500) NOT NULL, + candidate_count INT UNSIGNED NOT NULL, room_count INT UNSIGNED NOT NULL, PRIMARY KEY (center_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); +} + +async function mysqlSchoolTable(connection, names) { + const students = quoteMysql(names.students); + await connection.query(`CREATE TABLE IF NOT EXISTS ${students} ( + user_id VARCHAR(64) NOT NULL, school_id VARCHAR(64) NOT NULL, candidate_number VARCHAR(120) NULL, + candidate_name VARCHAR(120) NOT NULL, id_number VARCHAR(30) NULL, gender VARCHAR(20) NULL, + class_id VARCHAR(64) NULL, grade VARCHAR(60) NULL, phone VARCHAR(60) NULL, email VARCHAR(160) NULL, + profile_status VARCHAR(20) NULL, active BOOLEAN NOT NULL, updated_at VARCHAR(35) NULL, + PRIMARY KEY (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`); +} + +async function syncMysqlExam(connection, examId, names) { + const candidates = quoteMysql(names.candidates); + const admissions = quoteMysql(names.admissions); + const results = quoteMysql(names.results); + const centers = quoteMysql(names.centers); + await connection.query(`DELETE FROM ${candidates}`); + await connection.execute(`INSERT INTO ${candidates} ( + registration_id, exam_id, user_id, candidate_number, candidate_name, school_id, class_id, + registration_status, payment_status, registered_at + ) SELECT registration.id, registration.exam_id, registration.user_id, user.candidate_number, + COALESCE(profile.name, user.display_name), COALESCE(profile.school_id, user.school_id), + COALESCE(profile.class_id, user.class_id), registration.status, registration.payment_status, + registration.created_at FROM registrations registration JOIN users user ON user.id = registration.user_id + LEFT JOIN candidate_profiles profile ON profile.user_id = user.id WHERE registration.exam_id = ?`, [examId]); + await connection.query(`DELETE FROM ${admissions}`); + await connection.execute(`INSERT INTO ${admissions} ( + registration_id, subject_id, exam_id, candidate_number, admission_number, test_center, + center_code, center_address, room_id, room_name, room_code, exam_room_code, + building, floor, seat, generated_at + ) SELECT registration.id, selected.subject_id, registration.exam_id, user.candidate_number, + card.card_number, card.test_center, card.center_code, card.center_address, + assignment.room_id, assignment.room, assignment.room_code, assignment.exam_room_code, + assignment.building, assignment.floor, assignment.seat, card.generated_at + FROM registrations registration JOIN users user ON user.id = registration.user_id + JOIN registration_subjects selected ON selected.registration_id = registration.id + LEFT JOIN admit_cards card ON card.registration_id = registration.id + LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = registration.id + AND assignment.subject_id = selected.subject_id WHERE registration.exam_id = ?`, [examId]); + await connection.query(`DELETE FROM ${results}`); + await connection.execute(`INSERT INTO ${results} ( + result_id, exam_id, registration_id, subject_id, candidate_number, score, grade, + published, updated_at, published_at + ) SELECT result.id, registration.exam_id, result.registration_id, result.subject_id, + user.candidate_number, result.score, result.grade, result.published, result.updated_at, result.published_at + FROM results result JOIN registrations registration ON registration.id = result.registration_id + JOIN users user ON user.id = registration.user_id WHERE registration.exam_id = ?`, [examId]); + await connection.query(`DELETE FROM ${centers}`); + await connection.execute(`INSERT INTO ${centers} ( + center_key, exam_id, center_id, center_code, center_name, center_address, candidate_count, room_count + ) SELECT COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)), registration.exam_id, + card.center_id, card.center_code, MAX(card.test_center), MAX(card.center_address), + COUNT(DISTINCT card.registration_id), COUNT(DISTINCT assignment.room_id) + FROM admit_cards card JOIN registrations registration ON registration.id = card.registration_id + LEFT JOIN admit_card_subjects assignment ON assignment.registration_id = card.registration_id + WHERE registration.exam_id = ? GROUP BY COALESCE(card.center_id, CONCAT('snapshot:', card.center_code)), + registration.exam_id, card.center_id, card.center_code`, [examId]); +} + +async function syncMysqlSchool(connection, schoolId, names) { + const students = quoteMysql(names.students); + await connection.query(`DELETE FROM ${students}`); + await connection.execute(`INSERT INTO ${students} ( + user_id, school_id, candidate_number, candidate_name, id_number, gender, class_id, + grade, phone, email, profile_status, active, updated_at + ) SELECT user.id, ?, user.candidate_number, COALESCE(profile.name, user.display_name), + profile.id_number, profile.gender, COALESCE(profile.class_id, user.class_id), profile.grade, + profile.phone, profile.email, profile.status, user.active, profile.updated_at + FROM users user LEFT JOIN candidate_profiles profile ON profile.user_id = user.id + WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?`, [schoolId, schoolId]); +} + +export async function synchronizeMysqlPartitions(connection) { + const now = new Date().toISOString(); + const [exams] = await connection.query('SELECT id FROM exams ORDER BY id'); + const [schools] = await connection.query('SELECT id FROM schools ORDER BY id'); + for (const { id } of exams) { + const names = namesForExam(id); + await mysqlExamTables(connection, names); + await connection.execute(`INSERT INTO exam_data_partitions ( + exam_id, partition_key, candidates_table, admissions_table, results_table, centers_table, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key), + candidates_table = VALUES(candidates_table), admissions_table = VALUES(admissions_table), + results_table = VALUES(results_table), centers_table = VALUES(centers_table), updated_at = VALUES(updated_at)`, + [id, names.key, names.candidates, names.admissions, names.results, names.centers, now, now]); + await syncMysqlExam(connection, id, names); + } + for (const { id } of schools) { + const names = namesForSchool(id); + await mysqlSchoolTable(connection, names); + await connection.execute(`INSERT INTO school_student_partitions ( + school_id, partition_key, students_table, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE partition_key = VALUES(partition_key), + students_table = VALUES(students_table), updated_at = VALUES(updated_at)`, + [id, names.key, names.students, now, now]); + await syncMysqlSchool(connection, id, names); + } +} diff --git a/src/database/schema.mjs b/src/database/schema.mjs new file mode 100644 index 0000000..7edd605 --- /dev/null +++ b/src/database/schema.mjs @@ -0,0 +1,1045 @@ +export const sqliteSchema = ` + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS schema_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + schema_version INTEGER NOT NULL DEFAULT 1, + app_version INTEGER NOT NULL DEFAULT 1, + self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)), + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS organization ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL, + code TEXT NOT NULL, + phone TEXT NOT NULL, + address TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS schools ( + id TEXT PRIMARY KEY, + 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; + + CREATE TABLE IF NOT EXISTS school_classes ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name TEXT NOT NULL, + grade TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + UNIQUE (school_id, name) + ) STRICT; + + CREATE TABLE IF NOT EXISTS school_student_partitions ( + school_id TEXT PRIMARY KEY REFERENCES schools(id) ON DELETE CASCADE, + partition_key TEXT NOT NULL UNIQUE, + students_table TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS users ( + 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(id) ON DELETE RESTRICT, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS candidate_profiles ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + gender TEXT, + id_number TEXT NOT NULL UNIQUE, + phone TEXT NOT NULL, + email TEXT, + school TEXT, + grade TEXT, + school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, + class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL, + province_code TEXT, + province_name TEXT, + city_code TEXT, + city_name TEXT, + district_code TEXT, + district_name TEXT, + address TEXT, + emergency_contact TEXT, + emergency_phone TEXT, + native_place TEXT, + birth_date TEXT, + ethnicity TEXT, + 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, + reviewed_at TEXT, + reviewer_id TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS notices ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + content TEXT NOT NULL, + category TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), + status TEXT NOT NULL CHECK (status IN ('draft', 'published')), + publish_at TEXT, + created_at TEXT, + author TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS exams ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL, + registration_start TEXT NOT NULL, + registration_end TEXT NOT NULL, + exam_start TEXT NOT NULL, + exam_end TEXT NOT NULL, + admit_download_start TEXT NOT NULL, + admit_download_end TEXT NOT NULL, + location TEXT NOT NULL, + pass_policy TEXT NOT NULL DEFAULT 'rank_percent' CHECK (pass_policy IN ('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none')), + pass_value REAL NOT NULL DEFAULT 60, + status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')), + archived_at TEXT, + archived_by TEXT REFERENCES users(id) ON DELETE RESTRICT, + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS exam_data_partitions ( + exam_id TEXT PRIMARY KEY REFERENCES exams(id) ON DELETE CASCADE, + partition_key TEXT NOT NULL UNIQUE, + candidates_table TEXT NOT NULL UNIQUE, + admissions_table TEXT NOT NULL UNIQUE, + results_table TEXT NOT NULL UNIQUE, + centers_table TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS exam_subjects ( + id TEXT PRIMARY KEY, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + name TEXT NOT NULL, + subject_date TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + fee REAL NOT NULL DEFAULT 0, + full_score REAL NOT NULL DEFAULT 150, + pass_score REAL NOT NULL DEFAULT 90, + pass_rule TEXT NOT NULL DEFAULT 'fixed_score' CHECK (pass_rule IN ('fixed_score', 'score_ratio', 'none')), + pass_value REAL NOT NULL DEFAULT 90, + position INTEGER NOT NULL, + UNIQUE (exam_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS registrations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid')), + paid_at TEXT, + paid_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + reviewed_at TEXT, + 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; + + CREATE TABLE IF NOT EXISTS registration_subjects ( + registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + PRIMARY KEY (registration_id, subject_id) + ) STRICT; + + CREATE TABLE IF NOT EXISTS admission_number_rules ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL, + separator TEXT NOT NULL DEFAULT '', + segments_json TEXT NOT NULL, + example TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS exam_arrangement_plans ( + id TEXT PRIMARY KEY, + exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE, + number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id), + mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')), + random_seed TEXT NOT NULL, + candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0), + center_count INTEGER NOT NULL CHECK (center_count >= 0), + subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0), + subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0), + same_school_center_rate REAL NOT NULL, + warnings_json TEXT NOT NULL, + generated_by TEXT REFERENCES users(id) ON DELETE SET NULL, + generated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS admit_cards ( + registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, + plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE, + card_number TEXT NOT NULL UNIQUE, + center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, + test_center TEXT NOT NULL, + center_code TEXT NOT NULL, + center_address TEXT NOT NULL, + generated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS admit_card_subjects ( + registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, + room TEXT NOT NULL, + room_code TEXT NOT NULL, + exam_room_code TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT NOT NULL, + seat TEXT NOT NULL, + subject_signature TEXT NOT NULL, + PRIMARY KEY (registration_id, subject_id), + UNIQUE (subject_id, room_id, seat) + ) STRICT; + + CREATE TABLE IF NOT EXISTS results ( + id TEXT PRIMARY KEY, + registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + score REAL NOT NULL CHECK (score >= 0), + grade TEXT NOT NULL, + published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), + updated_at TEXT, + published_at TEXT, + UNIQUE (registration_id, subject_id) + ) STRICT; + + CREATE TABLE IF NOT EXISTS test_centers ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + province_code TEXT NOT NULL, + province_name TEXT NOT NULL, + city_code TEXT NOT NULL, + city_name TEXT NOT NULL, + district_code TEXT NOT NULL, + district_name TEXT NOT NULL, + address TEXT NOT NULL, + contact TEXT, + manager_name TEXT, + manager_phone TEXT, + emergency_phone TEXT, + gate_open_time TEXT, + transport TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + notes TEXT, + rooms TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (school_id, name) + ) STRICT; + + CREATE TABLE IF NOT EXISTS test_rooms ( + id TEXT PRIMARY KEY, + center_id TEXT NOT NULL REFERENCES test_centers(id) ON DELETE CASCADE, + code TEXT NOT NULL, + name TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT, + capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_plan TEXT, + seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0), + seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start), + room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + notes TEXT, + UNIQUE (center_id, code) + ) STRICT; + + CREATE TABLE IF NOT EXISTS center_change_requests ( + id TEXT PRIMARY KEY, + center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')), + code TEXT NOT NULL, + name TEXT NOT NULL, + province_code TEXT NOT NULL, + province_name TEXT NOT NULL, + city_code TEXT NOT NULL, + city_name TEXT NOT NULL, + district_code TEXT NOT NULL, + district_name TEXT NOT NULL, + address TEXT NOT NULL, + contact TEXT, + manager_name TEXT, + manager_phone TEXT, + emergency_phone TEXT, + gate_open_time TEXT, + transport TEXT, + center_status TEXT NOT NULL CHECK (center_status IN ('active', 'inactive')), + notes TEXT, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + reviewed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS center_change_rooms ( + id TEXT PRIMARY KEY, + request_id TEXT NOT NULL REFERENCES center_change_requests(id) ON DELETE CASCADE, + room_id TEXT, + code TEXT NOT NULL, + name TEXT NOT NULL, + building TEXT NOT NULL, + floor TEXT, + capacity INTEGER NOT NULL CHECK (capacity > 0), + seat_plan TEXT, + seat_start INTEGER NOT NULL DEFAULT 1, + seat_end INTEGER NOT NULL, + room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')), + status TEXT NOT NULL CHECK (status IN ('active', 'inactive')), + notes TEXT, + UNIQUE (request_id, code) + ) STRICT; + + CREATE TABLE IF NOT EXISTS number_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + separator TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)), + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS number_rule_segments ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES number_rules(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('year', 'school_code', 'gender', 'sequence', 'literal')), + value TEXT, + width INTEGER NOT NULL DEFAULT 0, + UNIQUE (rule_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS candidate_account_batches ( + id TEXT PRIMARY KEY, + school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + requested_by TEXT REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES candidate_account_batches(id) ON DELETE CASCADE, + class_id TEXT NOT NULL REFERENCES school_classes(id) ON DELETE RESTRICT, + position INTEGER NOT NULL, + candidate_number TEXT UNIQUE, + initial_password TEXT, + user_id TEXT UNIQUE REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT, + UNIQUE (batch_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_definitions ( + id TEXT PRIMARY KEY, + business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch', 'score_appeal')), + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL, + UNIQUE (business_type, active) + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + name TEXT NOT NULL, + admin_level TEXT NOT NULL CHECK (admin_level IN ('class', 'school', 'super')), + UNIQUE (workflow_id, position) + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_instances ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE RESTRICT, + business_type TEXT NOT NULL, + business_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + current_step INTEGER NOT NULL DEFAULT 1, + assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + completed_at TEXT + ) STRICT; + + CREATE TABLE IF NOT EXISTS workflow_actions ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE, + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL CHECK (action IN ('submit', 'approve', 'reject', 'transfer', 'return', 'supervise')), + note TEXT, + from_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + to_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL, + 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); + CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status); + CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at); + CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); + CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position); + CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status); + CREATE INDEX IF NOT EXISTS idx_registrations_payment ON registrations(payment_status, paid_at); + CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id); + CREATE INDEX IF NOT EXISTS idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at); + CREATE INDEX IF NOT EXISTS idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat); + CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type); + CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code); + CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code); + CREATE INDEX IF NOT EXISTS idx_center_changes_school ON center_change_requests(school_id, status, created_at); + CREATE INDEX IF NOT EXISTS idx_account_batches_school ON candidate_account_batches(school_id, status, created_at); + 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 + WHEN EXISTS ( + SELECT 1 FROM registrations registration + JOIN exams exam ON exam.id = registration.exam_id + WHERE registration.id = NEW.registration_id AND exam.archived_at IS NOT NULL + ) + BEGIN + SELECT RAISE(ABORT, '归档考试成绩已永久锁定'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_update + BEFORE UPDATE ON results + WHEN EXISTS ( + SELECT 1 FROM registrations registration + JOIN exams exam ON exam.id = registration.exam_id + WHERE registration.id IN (OLD.registration_id, NEW.registration_id) AND exam.archived_at IS NOT NULL + ) + BEGIN + SELECT RAISE(ABORT, '归档考试成绩已永久锁定'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_delete + BEFORE DELETE ON results + WHEN EXISTS ( + SELECT 1 FROM registrations registration + JOIN exams exam ON exam.id = registration.exam_id + WHERE registration.id = OLD.registration_id AND exam.archived_at IS NOT NULL + ) + BEGIN + SELECT RAISE(ABORT, '归档考试成绩已永久锁定'); + END; +`; + +export const mysqlSchema = [ + `CREATE TABLE IF NOT EXISTS schema_metadata ( + id TINYINT UNSIGNED NOT NULL, + schema_version INT UNSIGNED NOT NULL DEFAULT 1, + app_version INT UNSIGNED NOT NULL DEFAULT 1, + self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS organization ( + id TINYINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + code VARCHAR(60) NOT NULL, + phone VARCHAR(60) NOT NULL, + address VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_organization_singleton CHECK (id = 1) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS schools ( + id VARCHAR(64) NOT NULL, + 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), + UNIQUE KEY uq_schools_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS school_classes ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + grade VARCHAR(60) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY uq_classes_school_name (school_id, name), + CONSTRAINT fk_classes_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 school_student_partitions ( + school_id VARCHAR(64) NOT NULL, + partition_key VARCHAR(32) NOT NULL, + students_table VARCHAR(64) NOT NULL, + created_at VARCHAR(35) NOT NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (school_id), + UNIQUE KEY uq_school_partitions_key (partition_key), + UNIQUE KEY uq_school_partitions_table (students_table), + CONSTRAINT fk_school_partitions_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 users ( + id VARCHAR(64) NOT NULL, + username VARCHAR(100) NOT NULL, + candidate_number VARCHAR(120) NULL, + password_hash VARCHAR(255) 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, + active BOOLEAN NOT NULL DEFAULT TRUE, + must_change_password BOOLEAN NOT NULL DEFAULT FALSE, + totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, + totp_secret_encrypted VARCHAR(512) NULL, + totp_recovery_codes VARCHAR(2048) NOT NULL DEFAULT '[]', + totp_last_used_step BIGINT NULL, + archived_at VARCHAR(35) NULL, + archived_by VARCHAR(64) NULL, + display_name VARCHAR(100) NOT NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_users_username (username), + UNIQUE KEY uq_users_candidate_number (candidate_number), + KEY idx_users_admin_scope (role, admin_level, school_id, class_id), + KEY idx_users_archive_scope (role, school_id, class_id, archived_at), + CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, + CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL, + CONSTRAINT fk_users_archiver FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_profiles ( + id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + gender VARCHAR(20) NULL, + id_number VARCHAR(60) NOT NULL, + phone VARCHAR(60) NOT NULL, + email VARCHAR(160) NULL, + school VARCHAR(160) NULL, + grade VARCHAR(100) NULL, + school_id VARCHAR(64) NULL, + class_id VARCHAR(64) NULL, + province_code VARCHAR(6) NULL, + province_name VARCHAR(80) NULL, + city_code VARCHAR(6) NULL, + city_name VARCHAR(100) NULL, + district_code VARCHAR(6) NULL, + district_name VARCHAR(100) NULL, + address VARCHAR(255) NULL, + emergency_contact VARCHAR(100) NULL, + emergency_phone VARCHAR(60) NULL, + native_place VARCHAR(160) NULL, + birth_date VARCHAR(20) NULL, + ethnicity VARCHAR(60) NULL, + 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, + reviewed_at VARCHAR(35) NULL, + reviewer_id VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_profiles_user (user_id), + UNIQUE KEY uq_profiles_id_number (id_number), + KEY idx_profiles_status (status), + CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_profiles_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL, + CONSTRAINT fk_profiles_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS notices ( + id VARCHAR(64) NOT NULL, + title VARCHAR(240) NOT NULL, + summary VARCHAR(500) NOT NULL, + content TEXT NOT NULL, + category VARCHAR(60) NOT NULL, + pinned BOOLEAN NOT NULL DEFAULT FALSE, + status ENUM('draft', 'published') NOT NULL, + publish_at VARCHAR(35) NULL, + created_at VARCHAR(35) NULL, + author VARCHAR(100) NOT NULL, + PRIMARY KEY (id), + KEY idx_notices_status_publish (status, publish_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS exams ( + id VARCHAR(64) NOT NULL, + code VARCHAR(60) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + registration_start VARCHAR(35) NOT NULL, + registration_end VARCHAR(35) NOT NULL, + exam_start VARCHAR(35) NOT NULL, + exam_end VARCHAR(35) NOT NULL, + admit_download_start VARCHAR(35) NOT NULL, + admit_download_end VARCHAR(35) NOT NULL, + location VARCHAR(200) NOT NULL, + pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'rank_percent', + pass_value DOUBLE NOT NULL DEFAULT 60, + status ENUM('draft', 'published', 'closed') NOT NULL, + archived_at VARCHAR(35) NULL, + archived_by VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_exams_code (code), + KEY idx_exams_status_registration (status, registration_start, registration_end), + KEY idx_exams_archive (archived_at, exam_end), + CONSTRAINT fk_exams_archived_by FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS exam_data_partitions ( + exam_id VARCHAR(64) NOT NULL, + partition_key VARCHAR(32) NOT NULL, + candidates_table VARCHAR(64) NOT NULL, + admissions_table VARCHAR(64) NOT NULL, + results_table VARCHAR(64) NOT NULL, + centers_table VARCHAR(64) NOT NULL, + created_at VARCHAR(35) NOT NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (exam_id), + UNIQUE KEY uq_exam_partitions_key (partition_key), + UNIQUE KEY uq_exam_partitions_candidates (candidates_table), + UNIQUE KEY uq_exam_partitions_admissions (admissions_table), + UNIQUE KEY uq_exam_partitions_results (results_table), + UNIQUE KEY uq_exam_partitions_centers (centers_table), + CONSTRAINT fk_exam_partitions_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS exam_subjects ( + id VARCHAR(64) NOT NULL, + exam_id VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + subject_date VARCHAR(35) NOT NULL, + start_time VARCHAR(20) NOT NULL, + end_time VARCHAR(20) NOT NULL, + fee DOUBLE NOT NULL DEFAULT 0, + full_score DOUBLE NOT NULL DEFAULT 150, + pass_score DOUBLE NOT NULL DEFAULT 90, + pass_rule ENUM('fixed_score', 'score_ratio', 'none') NOT NULL DEFAULT 'fixed_score', + pass_value DOUBLE NOT NULL DEFAULT 90, + position INT UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_subjects_exam_position (exam_id, position), + KEY idx_subjects_exam (exam_id, position), + CONSTRAINT fk_subjects_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS registrations ( + id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + exam_id VARCHAR(64) NOT NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + payment_status ENUM('unpaid', 'paid') NOT NULL, + paid_at VARCHAR(35) NULL, + paid_by VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + 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), + KEY idx_registrations_payment (payment_status, paid_at), + KEY idx_registrations_exam (exam_id), + CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + CONSTRAINT fk_registrations_paid_by FOREIGN KEY (paid_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS registration_subjects ( + registration_id VARCHAR(64) NOT NULL, + subject_id VARCHAR(64) NOT NULL, + PRIMARY KEY (registration_id, subject_id), + CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, + CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS results ( + id VARCHAR(64) NOT NULL, + registration_id VARCHAR(64) NOT NULL, + subject_id VARCHAR(64) NOT NULL, + score DOUBLE NOT NULL, + grade VARCHAR(20) NOT NULL, + published BOOLEAN NOT NULL DEFAULT FALSE, + updated_at VARCHAR(35) NULL, + published_at VARCHAR(35) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_results_registration_subject (registration_id, subject_id), + KEY idx_results_registration (registration_id, published), + CONSTRAINT chk_results_score CHECK (score >= 0 AND score <= 150), + CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, + CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS test_centers ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + province_code VARCHAR(6) NOT NULL, + province_name VARCHAR(80) NOT NULL, + city_code VARCHAR(6) NOT NULL, + city_name VARCHAR(100) NOT NULL, + district_code VARCHAR(6) NOT NULL, + district_name VARCHAR(100) NOT NULL, + address VARCHAR(255) NOT NULL, + contact VARCHAR(100) NULL, + manager_name VARCHAR(100) NULL, + manager_phone VARCHAR(60) NULL, + emergency_phone VARCHAR(60) NULL, + gate_open_time VARCHAR(40) NULL, + transport VARCHAR(500) NULL, + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + notes VARCHAR(1000) NULL, + rooms TEXT NOT NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_centers_code (code), + UNIQUE KEY uq_centers_school_name (school_id, name), + CONSTRAINT fk_centers_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 test_rooms ( + id VARCHAR(64) NOT NULL, + center_id VARCHAR(64) NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(40) NULL, + capacity INT UNSIGNED NOT NULL, + seat_plan VARCHAR(500) NULL, + seat_start INT UNSIGNED NOT NULL DEFAULT 1, + seat_end INT UNSIGNED NOT NULL, + room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, + status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + notes VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_rooms_center_code (center_id, code), + KEY idx_rooms_center (center_id, status, code), + CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS admission_number_rules ( + id VARCHAR(64) NOT NULL, + code VARCHAR(80) NOT NULL, + name VARCHAR(160) NOT NULL, + description VARCHAR(500) NOT NULL, + \`separator\` VARCHAR(10) NOT NULL DEFAULT '', + segments_json JSON NOT NULL, + example VARCHAR(120) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_admission_rules_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS exam_arrangement_plans ( + id VARCHAR(64) NOT NULL, + exam_id VARCHAR(64) NOT NULL, + number_rule_id VARCHAR(64) NOT NULL, + mixing_scope ENUM('class', 'school', 'district', 'city', 'province') NOT NULL, + random_seed VARCHAR(80) NOT NULL, + candidate_count INT UNSIGNED NOT NULL, + center_count INT UNSIGNED NOT NULL, + subject_assignment_count INT UNSIGNED NOT NULL, + subject_combination_count INT UNSIGNED NOT NULL, + same_school_center_rate DOUBLE NOT NULL, + warnings_json JSON NOT NULL, + generated_by VARCHAR(64) NULL, + generated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_arrangement_plans_exam (exam_id), + CONSTRAINT fk_arrangement_plans_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + CONSTRAINT fk_arrangement_plans_rule FOREIGN KEY (number_rule_id) REFERENCES admission_number_rules(id), + CONSTRAINT fk_arrangement_plans_generator FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS admit_cards ( + registration_id VARCHAR(64) NOT NULL, + plan_id VARCHAR(64) NOT NULL, + card_number VARCHAR(100) NOT NULL, + center_id VARCHAR(64) NULL, + test_center VARCHAR(200) NOT NULL, + center_code VARCHAR(40) NOT NULL, + center_address VARCHAR(500) NOT NULL, + generated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (registration_id), + UNIQUE KEY uq_admit_cards_number (card_number), + CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, + CONSTRAINT fk_admit_cards_plan FOREIGN KEY (plan_id) REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE, + CONSTRAINT fk_admit_cards_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS admit_card_subjects ( + registration_id VARCHAR(64) NOT NULL, + subject_id VARCHAR(64) NOT NULL, + room_id VARCHAR(64) NULL, + room VARCHAR(120) NOT NULL, + room_code VARCHAR(40) NOT NULL, + exam_room_code VARCHAR(20) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(80) NOT NULL, + seat VARCHAR(30) NOT NULL, + subject_signature VARCHAR(1000) NOT NULL, + PRIMARY KEY (registration_id, subject_id), + UNIQUE KEY uq_admit_subject_room_seat (subject_id, room_id, seat), + KEY idx_admit_subjects_room (subject_id, room_id, seat), + CONSTRAINT fk_admit_subjects_card FOREIGN KEY (registration_id) REFERENCES admit_cards(registration_id) ON DELETE CASCADE, + CONSTRAINT fk_admit_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE, + CONSTRAINT fk_admit_subjects_room FOREIGN KEY (room_id) REFERENCES test_rooms(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS center_change_requests ( + id VARCHAR(64) NOT NULL, + center_id VARCHAR(64) NULL, + school_id VARCHAR(64) NOT NULL, + request_type ENUM('create', 'update') NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + province_code VARCHAR(6) NOT NULL, + province_name VARCHAR(80) NOT NULL, + city_code VARCHAR(6) NOT NULL, + city_name VARCHAR(100) NOT NULL, + district_code VARCHAR(6) NOT NULL, + district_name VARCHAR(100) NOT NULL, + address VARCHAR(255) NOT NULL, + contact VARCHAR(100) NULL, + manager_name VARCHAR(100) NULL, + manager_phone VARCHAR(60) NULL, + emergency_phone VARCHAR(60) NULL, + gate_open_time VARCHAR(40) NULL, + transport VARCHAR(500) NULL, + center_status ENUM('active', 'inactive') NOT NULL, + notes VARCHAR(1000) NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + review_note VARCHAR(500) NULL, + requested_by VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_center_changes_school (school_id, status, created_at), + CONSTRAINT fk_center_change_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL, + CONSTRAINT fk_center_change_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, + CONSTRAINT fk_center_change_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS center_change_rooms ( + id VARCHAR(64) NOT NULL, + request_id VARCHAR(64) NOT NULL, + room_id VARCHAR(64) NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + building VARCHAR(120) NOT NULL, + floor VARCHAR(40) NULL, + capacity INT UNSIGNED NOT NULL, + seat_plan VARCHAR(500) NULL, + seat_start INT UNSIGNED NOT NULL DEFAULT 1, + seat_end INT UNSIGNED NOT NULL, + room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL, + status ENUM('active', 'inactive') NOT NULL, + notes VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_center_change_rooms_code (request_id, code), + CONSTRAINT fk_center_change_rooms_request FOREIGN KEY (request_id) REFERENCES center_change_requests(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS number_rules ( + id VARCHAR(64) NOT NULL, + name VARCHAR(120) NOT NULL, + \`separator\` VARCHAR(10) NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT FALSE, + created_by VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_number_rules_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS number_rule_segments ( + id VARCHAR(64) NOT NULL, + rule_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + type ENUM('year', 'school_code', 'gender', 'sequence', 'literal') NOT NULL, + value VARCHAR(60) NULL, + width INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uq_rule_segments_position (rule_id, position), + CONSTRAINT fk_rule_segments_rule FOREIGN KEY (rule_id) REFERENCES number_rules(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_account_batches ( + id VARCHAR(64) NOT NULL, + school_id VARCHAR(64) NOT NULL, + requested_by VARCHAR(64) NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + review_note VARCHAR(500) NULL, + created_at VARCHAR(35) NOT NULL, + reviewed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_account_batches_school (school_id, status, created_at), + CONSTRAINT fk_account_batches_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE, + CONSTRAINT fk_account_batches_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS candidate_account_batch_items ( + id VARCHAR(64) NOT NULL, + batch_id VARCHAR(64) NOT NULL, + class_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + candidate_number VARCHAR(120) NULL, + initial_password VARCHAR(120) NULL, + user_id VARCHAR(64) NULL, + created_at VARCHAR(35) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_account_batch_position (batch_id, position), + UNIQUE KEY uq_account_batch_number (candidate_number), + UNIQUE KEY uq_account_batch_user (user_id), + KEY idx_account_batch_items (batch_id, class_id, position), + CONSTRAINT fk_account_batch_items_batch FOREIGN KEY (batch_id) REFERENCES candidate_account_batches(id) ON DELETE CASCADE, + CONSTRAINT fk_account_batch_items_class FOREIGN KEY (class_id) REFERENCES school_classes(id), + CONSTRAINT fk_account_batch_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_definitions ( + id VARCHAR(64) NOT NULL, + business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch', 'score_appeal') NOT NULL, + name VARCHAR(120) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + updated_by VARCHAR(64) NULL, + updated_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_workflow_type_active (business_type, active), + CONSTRAINT fk_workflow_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_steps ( + id VARCHAR(64) NOT NULL, + workflow_id VARCHAR(64) NOT NULL, + position INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + admin_level ENUM('class', 'school', 'super') NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_workflow_steps_position (workflow_id, position), + CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_instances ( + id VARCHAR(64) NOT NULL, + workflow_id VARCHAR(64) NOT NULL, + business_type VARCHAR(40) NOT NULL, + business_id VARCHAR(64) NOT NULL, + status ENUM('pending', 'approved', 'rejected') NOT NULL, + current_step INT UNSIGNED NOT NULL DEFAULT 1, + assignee_id VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + completed_at VARCHAR(35) NULL, + PRIMARY KEY (id), + KEY idx_workflow_inbox (status, assignee_id, business_type), + CONSTRAINT fk_workflow_instance_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id), + CONSTRAINT fk_workflow_instance_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS workflow_actions ( + id VARCHAR(64) NOT NULL, + instance_id VARCHAR(64) NOT NULL, + actor_id VARCHAR(64) NULL, + action ENUM('submit', 'approve', 'reject', 'transfer', 'return', 'supervise') NOT NULL, + note VARCHAR(500) NULL, + from_assignee_id VARCHAR(64) NULL, + to_assignee_id VARCHAR(64) NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + KEY idx_workflow_actions_instance (instance_id, created_at), + CONSTRAINT fk_workflow_action_instance FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE, + CONSTRAINT fk_workflow_action_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL, + 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, + action VARCHAR(100) NOT NULL, + detail VARCHAR(1000) NOT NULL, + created_at VARCHAR(35) NOT NULL, + PRIMARY KEY (id), + KEY idx_audit_created (created_at), + CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` +]; diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs new file mode 100644 index 0000000..14b4884 --- /dev/null +++ b/src/database/sqlite-adapter.mjs @@ -0,0 +1,526 @@ +import { synchronizeSqlitePartitions } from './partition-storage.mjs'; +import { createStateCache } from './state-cache.mjs'; + +export function createSqliteAdapter(context) { + const { + mkdir, + dirname, + sqliteSchema, + mysqlSchema, + optional, + buildSeedOperations, + stateFromRows, + readSqliteRows, + readMysqlRows, + createRepository + } = context; + + async function createSqliteStore({ path, seed }) { + const { DatabaseSync } = await import('node:sqlite'); + await mkdir(dirname(path), { recursive: true }); + + const connection = new DatabaseSync(path, { timeout: 5000 }); + 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; + const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name)); + for (const [name, definition] of columns) { + if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } + }; + 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'], ['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'], ['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'] + ]); + ensureColumns('exam_subjects', [ + ['full_score', 'REAL NOT NULL DEFAULT 150'], ['pass_score', 'REAL NOT NULL DEFAULT 90'], + ['pass_rule', "TEXT NOT NULL DEFAULT 'fixed_score'"], ['pass_value', 'REAL NOT NULL DEFAULT 90'] + ]); + ensureColumns('test_centers', [ + ['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'], + ['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT'], + ['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'], + ['district_code', 'TEXT'], ['district_name', 'TEXT'] + ]); + ensureColumns('center_change_requests', [ + ['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'], + ['district_code', 'TEXT'], ['district_name', 'TEXT'] + ]); + ensureColumns('test_rooms', [['seat_plan', 'TEXT']]); + ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]); + ensureColumns('admit_cards', [ + ['center_code', "TEXT NOT NULL DEFAULT ''"], ['center_address', "TEXT NOT NULL DEFAULT ''"] + ]); + 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')) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE workflow_definitions_v5 ( + id TEXT PRIMARY KEY, + business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')), + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL, + UNIQUE (business_type, active) + ) STRICT; + INSERT INTO workflow_definitions_v5 (id, business_type, name, active, updated_by, updated_at) + SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions; + DROP TABLE workflow_definitions; + ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } + } + if (tableExists('registrations')) { + const registrationsSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registrations'").get()?.sql || ''; + if (/registration_number\s+TEXT\s+UNIQUE/i.test(registrationsSql)) { + connection.exec(` + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; + CREATE TABLE registrations_v4 ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), + payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), + created_at TEXT NOT NULL, + reviewed_at TEXT, + review_note TEXT, + registration_number TEXT, + number_rule_id TEXT, + UNIQUE (user_id, exam_id) + ) STRICT; + INSERT INTO registrations_v4 ( + id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id + ) SELECT id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id FROM registrations; + DROP TABLE registrations; + ALTER TABLE registrations_v4 RENAME TO registrations; + COMMIT; + PRAGMA foreign_keys = ON; + `); + } + } + connection.exec(sqliteSchema); + connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)'); + + const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(); + if (existingSystem && Number(existingSystem.schema_version || 1) < 9) { + const extension = seed(); + connection.exec('PRAGMA foreign_keys = OFF;'); + connection.exec('BEGIN IMMEDIATE'); + try { + connection.exec(` + DROP TABLE IF EXISTS admit_card_subjects; + DROP TABLE IF EXISTS admit_cards; + DROP TABLE IF EXISTS exam_arrangement_plans; + DROP TABLE IF EXISTS admission_number_rules; + CREATE TABLE admission_number_rules ( + id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL, + separator TEXT NOT NULL DEFAULT '', segments_json TEXT NOT NULL, example TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), created_at TEXT NOT NULL + ) STRICT; + CREATE TABLE exam_arrangement_plans ( + id TEXT PRIMARY KEY, exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE, + number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id), + mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')), + random_seed TEXT NOT NULL, candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0), + center_count INTEGER NOT NULL CHECK (center_count >= 0), + subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0), + subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0), + same_school_center_rate REAL NOT NULL, warnings_json TEXT NOT NULL, + generated_by TEXT REFERENCES users(id) ON DELETE SET NULL, generated_at TEXT NOT NULL + ) STRICT; + CREATE TABLE admit_cards ( + registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, + plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE, + card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL, + test_center TEXT NOT NULL, center_code TEXT NOT NULL, center_address TEXT NOT NULL, generated_at TEXT NOT NULL + ) STRICT; + CREATE TABLE admit_card_subjects ( + registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, room TEXT NOT NULL, room_code TEXT NOT NULL, + exam_room_code TEXT NOT NULL, building TEXT NOT NULL, floor TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL, + PRIMARY KEY (registration_id, subject_id), UNIQUE (subject_id, room_id, seat) + ) STRICT; + CREATE INDEX idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at); + CREATE INDEX idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat); + `); + for (const rule of extension.admissionNumberRules) connection.prepare( + `INSERT INTO admission_number_rules ( + id, code, name, description, separator, segments_json, example, active, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []), + rule.example || '', rule.active === false ? 0 : 1, rule.createdAt); + connection.prepare('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } finally { + try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {} + } + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 10) { + connection.prepare(` + UPDATE admit_cards SET + center_code = COALESCE((SELECT code FROM test_centers WHERE id = admit_cards.center_id), center_code), + center_address = COALESCE((SELECT trim( + COALESCE(province_name, '') || ' ' || COALESCE(city_name, '') || ' ' || + COALESCE(district_name, '') || ' ' || COALESCE(address, '') + ) FROM test_centers WHERE id = admit_cards.center_id), center_address) + `).run(); + connection.prepare(` + UPDATE admit_card_subjects SET + building = COALESCE((SELECT building FROM test_rooms WHERE id = admit_card_subjects.room_id), building), + floor = COALESCE((SELECT floor FROM test_rooms WHERE id = admit_card_subjects.room_id), floor) + `).run(); + connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run(); + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 11) { + connection.exec('PRAGMA foreign_keys = OFF;'); + connection.exec('BEGIN IMMEDIATE'); + try { + connection.prepare("UPDATE exam_subjects SET pass_rule = 'fixed_score', pass_value = pass_score").run(); + connection.exec(` + CREATE TABLE results_v11 ( + id TEXT PRIMARY KEY, + registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, + score REAL NOT NULL CHECK (score >= 0), + grade TEXT NOT NULL, + published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), + updated_at TEXT, + published_at TEXT, + UNIQUE (registration_id, subject_id) + ) STRICT; + INSERT INTO results_v11 (id, registration_id, subject_id, score, grade, published, updated_at, published_at) + SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results; + DROP TABLE results; + ALTER TABLE results_v11 RENAME TO results; + CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); + `); + connection.prepare('UPDATE schema_metadata SET schema_version = 11, app_version = 11 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } finally { + try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {} + } + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 12) { + connection.prepare("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'").run(); + connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run(); + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 13) { + connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run(); + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 15) { + throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库'); + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 16) { + 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(` + 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) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const school of extension.schools) connection.prepare( + 'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)' + ).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1); + for (const schoolClass of extension.classes) connection.prepare( + 'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)' + ).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1); + connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run(); + for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare( + `INSERT OR IGNORE INTO users ( + id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at + ) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)` + ).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt); + for (const profile of extension.candidateProfiles) connection.prepare( + `UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?) + WHERE school = ? AND grade = ?` + ).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade); + if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) { + for (const center of extension.testCenters) connection.prepare( + 'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt); + } + if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) { + for (const rule of extension.numberRules) { + connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt); + rule.segments.forEach((segment, index) => connection.prepare( + 'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)' + ).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0))); + } + } + if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) { + for (const workflow of extension.workflows) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt); + workflow.steps.forEach((step, index) => connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel)); + } + } + connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 3) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const center of extension.testCenters) connection.prepare( + `UPDATE test_centers SET + code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?), + manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?), + gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?), + status = COALESCE(status, 'active'), notes = COALESCE(notes, ?) + WHERE id = ?` + ).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone), + optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id); + connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run(); + if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) { + for (const room of extension.testRooms) connection.prepare( + `INSERT INTO test_rooms ( + id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity), + Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)); + } + const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change'); + if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt); + for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); + } + connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 4) { + const extension = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + for (const user of extension.users.filter(item => item.role === 'candidate')) connection.prepare( + `UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?), + must_change_password = COALESCE(must_change_password, ?) WHERE id = ?` + ).run(optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id); + connection.prepare(`UPDATE users SET candidate_number = COALESCE( + (SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1), + 'CAND-' || substr(id, -10) + ) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`).run(); + for (const profile of extension.candidateProfiles) connection.prepare( + `UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?), + ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?), + guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?` + ).run(optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode), + optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id); + connection.prepare(`UPDATE registrations SET registration_number = ( + SELECT candidate_number FROM users WHERE users.id = registrations.user_id + ) WHERE registration_number IS NULL OR registration_number = ''`).run(); + connection.prepare('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 5) { + const extension = seed(); + const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch'); + connection.exec('BEGIN IMMEDIATE'); + try { + if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) { + connection.prepare( + 'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt); + for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare( + 'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)' + ).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel); + } + connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1').run(); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + if (existingSystem && Number(existingSystem.app_version || 1) < 6) { + connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run(); + } + if (existingSystem && Number(existingSystem.app_version || 1) < 7) { + connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run(); + } + if (existingSystem && Number(existingSystem.schema_version || 1) < 15) { + throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库'); + } + + if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { + const initialState = seed(); + connection.exec('BEGIN IMMEDIATE'); + try { + connection.prepare(` + INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at) + 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'); + } catch (error) { + connection.exec('ROLLBACK'); + connection.close(); + throw error; + } + } + + 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); + synchronizeSqlitePartitions(connection); + connection.exec('COMMIT'); + } catch (error) { + connection.exec('ROLLBACK'); + throw error; + } finally { + stateCache.invalidate(); + } + }; + return createRepository({ + client: 'sqlite', + location: path, + read: stateCache.read, + transaction, + close: async () => connection.close() + }); + } + + return createSqliteStore; +} diff --git a/src/database/state-cache.mjs b/src/database/state-cache.mjs new file mode 100644 index 0000000..c71bdbe --- /dev/null +++ b/src/database/state-cache.mjs @@ -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 }; +} diff --git a/src/database/version.mjs b/src/database/version.mjs new file mode 100644 index 0000000..c210217 --- /dev/null +++ b/src/database/version.mjs @@ -0,0 +1 @@ +export const CURRENT_SCHEMA_VERSION = 20; diff --git a/src/http/responses.mjs b/src/http/responses.mjs new file mode 100644 index 0000000..44429e7 --- /dev/null +++ b/src/http/responses.mjs @@ -0,0 +1,46 @@ +export function sendJson(response, status, payload, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }); + response.end(JSON.stringify(payload)); +} + +export function sendError(response, status, message, details) { + sendJson(response, status, { ok: false, message, ...(details ? { details } : {}) }); +} + +export async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 1024 * 1024) throw Object.assign(new Error('请求内容过大'), { status: 413 }); + chunks.push(chunk); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw Object.assign(new Error('请求数据格式不正确'), { status: 400 }); + } +} + +export async function readBodyBuffer(request, maxBytes = 12 * 1024 * 1024) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > maxBytes) throw Object.assign(new Error('Excel 文件不能超过 12 MB'), { status: 413 }); + chunks.push(chunk); + } + if (!chunks.length) throw Object.assign(new Error('请选择要导入的 Excel 文件'), { status: 400 }); + return Buffer.concat(chunks); +} + +export function sendWorkbook(response, buffer, filename) { + response.writeHead(200, { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, + 'Content-Length': buffer.length, + 'Cache-Control': 'no-store' + }); + response.end(buffer); +} diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs new file mode 100644 index 0000000..552be86 --- /dev/null +++ b/src/routes/admin.routes.mjs @@ -0,0 +1,1881 @@ +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 { + database, + cache, + readDb, + sendJson, + sendError, + readJson, + readBodyBuffer, + sendWorkbook, + currentUser, + safeUser, + requireUser, + hasPermission, + requirePermission, + profileInScope, + registrationInScope, + adminScopeLabel, + adminsForStep, + selectAdminForStep, + activeWorkflow, + createWorkflowSubmission, + workflowView, + pendingWorkflow, + candidateSequence, + generateCandidateNumber, + cleanText, + centerScopeProfile, + workflowScopeProfile, + candidateAccountBatchView, + centerChangeView, + parseCenterChange, + maskId, + publicExam, + examResultSummary, + subjectPassText, + resultRankInfo, + subjectPassEvaluation, + examRegistrationView, + logAction, + excelResourceNames, + excelRowsForResource, + admissionRowsForRegistrations, + centerMaterialRows, + importExcelResource, + prepareResultImport, + commitResultImport, + admitCardHtml, + admitCardsHtml, + hashPassword, + verifyPassword, + randomBytes, + uid, + nowIso, + authState, + buildWorkbook, + buildCenterMaterialsWorkbook, + hasExcelResource, + parseWorkbook, + adminLevelNames, + permissionsByLevel + } = context; + + const passPolicies = new Set(['fixed_score', 'rank_percent', 'subject_scores', 'none']); + const subjectPassRules = new Set(['fixed_score', 'rank_percent', 'none']); + + function normalizeSubjects(input, examStart) { + const source = Array.isArray(input) ? input : String(input || '').split(/[,,]/); + return source.map((item, index) => { + const structured = item && typeof item === 'object'; + const name = cleanText(structured ? item.name : item, 50); + if (!name) return null; + const fullScore = Number(structured ? item.fullScore : 150); + const requestedRule = item?.passRule === 'score_ratio' ? 'rank_percent' : item?.passRule; + const passRule = subjectPassRules.has(requestedRule) ? requestedRule : 'fixed_score'; + const passValue = passRule === 'none' ? 0 : Number(structured ? (item.passValue ?? item.passScore ?? fullScore * .6) : fullScore * .6); + const passScore = passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null; + return { + id: uid('sub'), + name, + date: cleanText(structured ? item.date : '', 10) || String(examStart).slice(0, 10), + start: cleanText(structured ? item.start : '', 5) || '09:00', + end: cleanText(structured ? item.end : '', 5) || '11:00', + fee: Number(structured ? item.fee ?? 0 : 0), + fullScore, + passRule, + passValue, + passScore, + order: index + 1 + }; + }).filter(Boolean); + } + + function validateExamScoring(subjects, passPolicy, passValue) { + if (!subjects.length) return '请至少添加一个考试科目'; + if (subjects.some(item => !Number.isFinite(item.fullScore) || item.fullScore <= 0 || item.fullScore > 1000)) return '科目满分必须大于 0 且不超过 1000'; + if (subjects.some(item => !subjectPassRules.has(item.passRule))) return '请选择有效的单科合格线计算方式'; + if (subjects.some(item => item.passRule === 'fixed_score' && (!Number.isFinite(item.passValue) || item.passValue < 0 || item.passValue > item.fullScore))) return '固定单科合格分必须在 0 与该科满分之间'; + if (subjects.some(item => item.passRule === 'rank_percent' && (!Number.isFinite(item.passValue) || item.passValue <= 0 || item.passValue > 100))) return '单科排名比例必须大于 0 且不超过 100%'; + if (subjects.some(item => !Number.isFinite(item.fee) || item.fee < 0 || item.fee > 100000)) return '科目费用必须在有效范围内'; + if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略'; + const totalScore = subjects.reduce((sum, item) => sum + item.fullScore, 0); + if (passPolicy === 'fixed_score' && (!Number.isFinite(passValue) || passValue < 0 || passValue > totalScore)) return `固定合格线必须在 0 与总分 ${totalScore} 之间`; + if (passPolicy === 'rank_percent' && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '排名比例必须大于 0 且不超过 100'; + 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; + 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, '账户不得删除;考生账户请由校方归档,管理员账户可停用'); + } + + if (request.method === 'GET' && pathname === '/api/admin/context') { + return sendJson(response, 200, { + ok: true, + admin: safeUser(user), + adminLevelName: adminLevelNames[user.adminLevel || 'super'], + permissions: permissionsByLevel[user.adminLevel || 'super'], + scopeLabel: adminScopeLabel(db, user), + schools: db.schools, + 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') { + const resource = excelMatch[1]; + if (!hasExcelResource(resource)) return sendError(response, 404, 'Excel 数据类型不存在'); + if (['classes', 'class_admins', 'account_quotas', 'account_results'].includes(resource) && !['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能导出该数据'); + if (resource === 'centers' && !hasPermission(user, 'centers.read')) return sendError(response, 403, '当前账号不能导出考点考场'); + if (resource === 'candidates' && !hasPermission(user, 'candidates.read')) return sendError(response, 403, '当前账号不能导出考生资料'); + if (resource === 'payments' && !hasPermission(user, 'payments.read')) return sendError(response, 403, '当前账号不能导出缴费名单'); + 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 && 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`); + } + if (excelMatch && request.method === 'POST') { + const resource = excelMatch[1]; + if (['account_results', 'payments'].includes(resource)) return sendError(response, 400, '该清单只支持导出'); + const rows = await parseWorkbook(resource, await readBodyBuffer(request)); + if (resource === 'results') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以预览导入成绩'); + return sendJson(response, 200, { ok: true, preview: true, ...prepareResultImport(db, rows) }); + } + const result = await importExcelResource(db, user, resource, rows); + return sendJson(response, 200, { ok: true, ...result }); + } + + if (pathname === '/api/admin/schools' && request.method === 'GET') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以管理学校'); + const schools = db.schools.map(school => ({ + ...school, + classCount: db.classes.filter(item => item.schoolId === school.id).length, + adminCount: db.users.filter(item => item.role === 'admin' && item.schoolId === school.id).length, + candidateCount: db.candidateProfiles.filter(item => item.schoolId === school.id).length, + centerCount: db.testCenters.filter(item => item.schoolId === school.id).length + })); + return sendJson(response, 200, { ok: true, schools }); + } + if (pathname === '/api/admin/schools' && request.method === 'POST') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建学校'); + const body = await readJson(request); + 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, isSourceSchool, isAdmissionSchool, active: body.active !== false }; + await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`)); + return sendJson(response, 201, { ok: true, school }); + } + const schoolMatch = pathname.match(/^\/api\/admin\/schools\/([^/]+)$/); + if (schoolMatch && request.method === 'PATCH') { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护学校'); + const body = await readJson(request); + const school = db.schools.find(item => item.id === schoolMatch[1]); + if (!school) return sendError(response, 404, '学校不存在'); + 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, 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 }); + } + + if (pathname === '/api/admin/school-organization' && request.method === 'GET') { + if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校组织'); + const school = db.schools.find(item => item.id === user.schoolId); + const classes = db.classes.filter(item => item.schoolId === user.schoolId).map(item => ({ + ...item, + candidateCount: db.candidateProfiles.filter(profile => profile.classId === item.id).length, + admins: db.users.filter(admin => admin.role === 'admin' && admin.adminLevel === 'class' && admin.classId === item.id).map(admin => ({ ...safeUser(admin), active: admin.active })) + })); + return sendJson(response, 200, { ok: true, school, classes }); + } + 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, '年级和班级名称不能为空'); + if (db.classes.some(item => item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级'); + const schoolClass = { id: uid('class'), schoolId: user.schoolId, name, grade, active: body.active !== false }; + await database.saveSchoolClass(schoolClass, true, logAction(db, user, '新增本校班级', `${grade} · ${name}`)); + return sendJson(response, 201, { ok: true, schoolClass }); + } + const classMatch = pathname.match(/^\/api\/admin\/classes\/([^/]+)$/); + if (classMatch && request.method === 'PATCH') { + if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级'); + const body = await readJson(request); + const schoolClass = db.classes.find(item => item.id === classMatch[1] && item.schoolId === user.schoolId); + if (!schoolClass) return sendError(response, 404, '班级不存在'); + const name = cleanText(body.name ?? schoolClass.name, 100); const grade = cleanText(body.grade ?? schoolClass.grade, 60); + if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空'); + if (db.classes.some(item => item.id !== schoolClass.id && item.schoolId === user.schoolId && item.name === name)) return sendError(response, 409, '本校已存在同名班级'); + Object.assign(schoolClass, { name, grade, active: body.active == null ? schoolClass.active : Boolean(body.active) }); + await database.saveSchoolClass(schoolClass, false, logAction(db, user, '更新本校班级', `${grade} · ${name} · ${schoolClass.active ? '启用' : '停用'}`)); + return sendJson(response, 200, { ok: true, schoolClass }); + } + + if (pathname === '/api/admin/admins' && request.method === 'GET') { + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能管理管理员'); + const admins = db.users.filter(item => item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId))).map(item => ({ + ...safeUser(item), + active: item.active, + levelName: adminLevelNames[item.adminLevel], + 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.filter(item => item.isSourceSchool), classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled }); + } + if (pathname === '/api/admin/admins' && request.method === 'POST') { + const body = await readJson(request); + const username = cleanText(body.username, 50); + const password = String(body.password || ''); + const displayName = cleanText(body.displayName, 50); + const adminLevel = user.adminLevel === 'school' ? 'class' : cleanText(body.adminLevel, 20); + if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能创建管理员'); + if (!username || !displayName || password.length < 8 || !['super', 'school', 'class'].includes(adminLevel)) return sendError(response, 400, '请完整填写管理员账号、姓名、层级和至少 8 位密码'); + 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 && 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]}`); + await database.createAdmin(created, log); + return sendJson(response, 201, { ok: true, admin: safeUser(created) }); + } + const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/); + if (adminMatch && request.method === 'PATCH') { + 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' && (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 || 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} · ${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); + const enabled = Boolean(body.enabled); + const log = logAction(db, user, enabled ? '开启自主注册' : '关闭自主注册', enabled ? '考生可从公开入口申请报名号' : '仅允许使用学校下发的报名号登录'); + await database.updateRegistrationSetting(enabled, log); + return sendJson(response, 200, { ok: true, enabled }); + } + if (pathname === '/api/admin/candidate-account-batches' && request.method === 'GET') { + if (!requirePermission(user, response, 'candidates.write')) return true; + if (!['school', 'super'].includes(user.adminLevel)) return sendError(response, 403, '只有校级管理员可以申领批量报名号'); + const batches = db.candidateAccountBatches + .filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId) + .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 && 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, '批量报名号由校级管理员发起申领'); + const body = await readJson(request); + const requestedQuotas = Array.isArray(body.quotas) ? body.quotas : []; + const quotas = requestedQuotas.map(item => ({ classId: cleanText(item.classId, 64), count: Number(item.count) })).filter(item => item.count > 0); + if (!quotas.length) return sendError(response, 400, '请至少为一个班级填写申领数量'); + if (new Set(quotas.map(item => item.classId)).size !== quotas.length) return sendError(response, 400, '同一班级只能填写一次申领数量'); + if (quotas.some(item => !Number.isInteger(item.count) || item.count < 1 || item.count > 200)) return sendError(response, 400, '每个班级一次可申领 1—200 个报名号'); + if (quotas.some(item => !db.classes.some(schoolClass => schoolClass.id === item.classId && schoolClass.schoolId === user.schoolId && schoolClass.active))) return sendError(response, 400, '只能为本校有效班级申领报名号'); + const totalCount = quotas.reduce((sum, item) => sum + item.count, 0); + if (totalCount > 500) return sendError(response, 400, '单个批次最多申领 500 个报名号'); + const batch = { id: uid('account_batch'), schoolId: user.schoolId, requestedBy: user.id, status: 'pending', reviewNote: '', createdAt: nowIso(), reviewedAt: null }; + const items = []; + let position = 1; + for (const quota of quotas) for (let index = 0; index < quota.count; index += 1) { + items.push({ id: uid('account_batch_item'), batchId: batch.id, classId: quota.classId, position, candidateNumber: '', initialPassword: '', userId: null, createdAt: null }); + position += 1; + } + const { instance, action } = createWorkflowSubmission(db, 'candidate_account_batch', batch.id, centerScopeProfile(db, user.schoolId), user.id); + const quotaSummary = quotas.map(item => `${db.classes.find(entry => entry.id === item.classId)?.name} ${item.count} 人`).join(';'); + const log = logAction(db, user, '提交批量报名号申领', `${totalCount} 个账户 · ${quotaSummary}`); + await database.createCandidateAccountBatch(batch, items, instance, action, log); + const fresh = await readDb(); + return sendJson(response, 202, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) }); + } + const accountBatchMatch = pathname.match(/^\/api\/admin\/candidate-account-batches\/([^/]+)$/); + if (accountBatchMatch && request.method === 'PATCH') { + if (!requirePermission(user, response, 'candidates.write')) return true; + const body = await readJson(request); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效'); + const batch = db.candidateAccountBatches.find(item => item.id === accountBatchMatch[1] && item.status === 'pending'); + if (!batch) return sendError(response, 404, '待审批的批量报名号申请不存在'); + const instance = pendingWorkflow(db, 'candidate_account_batch', batch.id); + const workflow = instance && db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (!instance || !workflow || !step) return sendError(response, 409, '批量报名号审批流程状态异常'); + if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交'); + const note = cleanText(body.reviewNote, 300); + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() }; + const log = logAction(db, user, body.status === 'approved' ? '审批批量报名号申领' : '退回批量报名号申领', `${db.schools.find(item => item.id === batch.schoolId)?.name} · ${note || '无备注'}`); + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + batch.status = 'rejected'; batch.reviewNote = note; batch.reviewedAt = nowIso(); + await database.processWorkflow(instance, action, batch, log); + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, centerScopeProfile(db, batch.schoolId)); + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + batch.reviewNote = note; + await database.processWorkflow(instance, action, batch, log); + } else { + const batchItems = db.candidateAccountBatchItems.filter(item => item.batchId === batch.id).sort((a, b) => a.position - b.position); + if (!batchItems.length || batchItems.some(item => item.userId || item.candidateNumber)) return sendError(response, 409, '批次明细异常或已经生成过账号'); + const generationDb = { ...db, users: [...db.users] }; + const users = []; + const profiles = []; + for (const [index, item] of batchItems.entries()) { + const schoolClass = db.classes.find(entry => entry.id === item.classId && entry.schoolId === batch.schoolId); + if (!schoolClass) return sendError(response, 409, '批次包含无效班级,无法生成账号'); + const generated = generateCandidateNumber(generationDb, { schoolId: batch.schoolId, classId: item.classId, gender: '' }); + const userId = uid('usr'); + const initialPassword = `Init-${randomBytes(6).toString('base64url')}`; + const displayName = `待补录考生 ${String(index + 1).padStart(3, '0')}`; + const candidateUser = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(initialPassword), role: 'candidate', displayName, schoolId: batch.schoolId, classId: item.classId, active: true, mustChangePassword: true, createdAt: nowIso() }; + const profile = { id: uid('profile'), userId, name: displayName, gender: '', idNumber: `PENDING-${userId}`, phone: '', email: '', school: db.schools.find(entry => entry.id === batch.schoolId)?.name || '', grade: schoolClass.name, schoolId: batch.schoolId, classId: item.classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() }; + item.candidateNumber = generated.number; item.initialPassword = initialPassword; item.userId = userId; item.createdAt = nowIso(); + users.push(candidateUser); profiles.push(profile); generationDb.users.push(candidateUser); + } + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + batch.status = 'approved'; batch.reviewNote = note; batch.reviewedAt = nowIso(); + await database.completeCandidateAccountBatch(batch, batchItems, users, profiles, instance, action, log); + } + const fresh = await readDb(); + return sendJson(response, 200, { ok: true, batch: candidateAccountBatchView(fresh, fresh.candidateAccountBatches.find(item => item.id === batch.id)) }); + } + + if (pathname === '/api/admin/centers' && request.method === 'GET') { + if (!requirePermission(user, response, 'centers.read')) return true; + const centers = db.testCenters.filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId).map(item => ({ + ...item, + schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', + rooms: db.testRooms.filter(room => room.centerId === item.id), + totalCapacity: db.testRooms.filter(room => room.centerId === item.id && room.status === 'active').reduce((sum, room) => sum + Number(room.capacity || 0), 0), + pendingChange: db.centerChangeRequests.some(change => change.centerId === item.id && change.status === 'pending') + })); + const changeRequests = db.centerChangeRequests + .filter(item => user.adminLevel === 'super' || item.schoolId === user.schoolId) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + .map(item => centerChangeView(db, item)); + return sendJson(response, 200, { ok: true, centers, changeRequests, schools: user.adminLevel === 'super' ? db.schools : db.schools.filter(item => item.id === user.schoolId) }); + } + if (pathname === '/api/admin/centers' && request.method === 'POST') { + if (!requirePermission(user, response, 'centers.write')) return true; + const body = await readJson(request); + const schoolId = user.adminLevel === 'super' ? cleanText(body.schoolId, 64) : user.schoolId; + if (!db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '考点必须归属有效学校'); + const parsed = parseCenterChange(db, body, schoolId); + const change = { id: uid('center_change'), centerId: null, schoolId, requestType: 'create', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; + const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, schoolId), user.id); + const log = logAction(db, user, '提交新增考点审批', `${change.name} · ${parsed.rooms.length} 个考场`); + await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log); + return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } }); + } + const centerMatch = pathname.match(/^\/api\/admin\/centers\/([^/]+)$/); + if (centerMatch && request.method === 'PATCH') { + if (!requirePermission(user, response, 'centers.write')) return true; + const body = await readJson(request); + const center = db.testCenters.find(item => item.id === centerMatch[1]); + if (!center) return sendError(response, 404, '考点不存在'); + if (user.adminLevel !== 'super' && center.schoolId !== user.schoolId) return sendError(response, 403, '只能维护本校考点'); + if (db.centerChangeRequests.some(item => item.centerId === center.id && item.status === 'pending')) return sendError(response, 409, '该考点已有待审批变更,请处理完成后再提交'); + const parsed = parseCenterChange(db, body, center.schoolId, center); + const change = { id: uid('center_change'), centerId: center.id, schoolId: center.schoolId, requestType: 'update', ...parsed.center, status: 'pending', reviewNote: '', requestedBy: user.id, createdAt: nowIso(), reviewedAt: null }; + const { instance, action } = createWorkflowSubmission(db, 'center_change', change.id, centerScopeProfile(db, center.schoolId), user.id); + const log = logAction(db, user, '提交考点变更审批', `${change.name} · ${parsed.rooms.length} 个考场`); + await database.createCenterChangeRequest(change, parsed.rooms, instance, action, log); + return sendJson(response, 202, { ok: true, changeRequest: { ...change, rooms: parsed.rooms, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) } }); + } + const centerChangeMatch = pathname.match(/^\/api\/admin\/center-change-requests\/([^/]+)$/); + if (centerChangeMatch && request.method === 'PATCH') { + if (!requirePermission(user, response, 'centers.write')) return true; + const body = await readJson(request); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审批状态无效'); + const change = db.centerChangeRequests.find(item => item.id === centerChangeMatch[1] && item.status === 'pending'); + if (!change) return sendError(response, 404, '待审批的考点变更不存在'); + if (user.adminLevel !== 'super' && change.schoolId !== user.schoolId) return sendError(response, 403, '该变更不在你的学校范围内'); + const instance = pendingWorkflow(db, 'center_change', change.id); + const workflow = instance && db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (!instance || !workflow || !step) return sendError(response, 409, '考点变更审批流程状态异常'); + if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交'); + const note = cleanText(body.reviewNote, 300); + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() }; + const log = logAction(db, user, body.status === 'approved' ? '审批考点变更' : '退回考点变更', `${change.name} · ${note || '无备注'}`); + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + change.status = 'rejected'; change.reviewNote = note; change.reviewedAt = nowIso(); + await database.applyCenterChange(change, instance, action, null, [], log); + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, centerScopeProfile(db, change.schoolId)); + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + change.reviewNote = note; + await database.processWorkflow(instance, action, change, log); + } else { + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + change.status = 'approved'; change.reviewNote = note; change.reviewedAt = nowIso(); + const centerId = change.centerId || uid('center'); + const proposedRooms = db.centerChangeRooms.filter(item => item.requestId === change.id); + const rooms = proposedRooms.map(room => ({ ...room, id: room.roomId || uid('room'), centerId })); + const center = { + id: centerId, schoolId: change.schoolId, code: change.code, name: change.name, + provinceCode: change.provinceCode, provinceName: change.provinceName, cityCode: change.cityCode, + cityName: change.cityName, districtCode: change.districtCode, districtName: change.districtName, + address: change.address, + contact: change.contact, managerName: change.managerName, managerPhone: change.managerPhone, + emergencyPhone: change.emergencyPhone, gateOpenTime: change.gateOpenTime, transport: change.transport, + status: change.centerStatus, notes: change.notes, + rooms: rooms.map(room => `${room.building} ${room.name}`).join(';'), updatedAt: nowIso() + }; + await database.applyCenterChange(change, instance, action, center, rooms, log); + } + return sendJson(response, 200, { ok: true, changeRequest: centerChangeView({ ...db, workflowActions: [...db.workflowActions, action] }, change) }); + } + + if (pathname === '/api/admin/number-rules' && request.method === 'GET') { + if (!requirePermission(user, response, '*')) return true; + const rule = db.numberRules.find(item => item.active) || null; + const previewProfile = db.candidateProfiles[0] || { gender: '女', schoolId: db.schools[0]?.id }; + let preview = ''; + if (rule) preview = generateCandidateNumber(db, previewProfile).number; + return sendJson(response, 200, { ok: true, rules: db.numberRules, activeRule: rule, preview }); + } + if (pathname === '/api/admin/number-rules' && request.method === 'POST') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const allowedTypes = ['year', 'school_code', 'gender', 'sequence', 'literal']; + const requested = Array.isArray(body.segments) ? body.segments : []; + if (!requested.length || requested.some(item => !allowedTypes.includes(item.type)) || !requested.some(item => item.type === 'sequence')) return sendError(response, 400, '报名号规则至少包含一个流水号段'); + const existing = db.numberRules.find(item => item.id === body.id); + const rule = { + id: existing?.id || uid('rule'), name: cleanText(body.name, 80) || '自定义报名号规则', separator: cleanText(body.separator, 3), + active: true, createdBy: user.id, updatedAt: nowIso(), segments: requested.map((item, index) => ({ + id: uid('segment'), position: index + 1, type: item.type, value: cleanText(item.value, 20), width: Math.min(12, Math.max(0, Number(item.width || 0))) + })) + }; + const log = logAction(db, user, '更新报名号规则', `${rule.name} · ${rule.segments.map(item => item.type).join(' + ')}`); + await database.saveNumberRule(rule, !existing, log); + return sendJson(response, 200, { ok: true, rule }); + } + if (pathname === '/api/admin/workflows' && request.method === 'GET') { + if (!requirePermission(user, response, '*')) return true; + return sendJson(response, 200, { ok: true, workflows: db.workflows }); + } + const workflowDefinitionMatch = pathname.match(/^\/api\/admin\/workflows\/(profile_change|registration_review|center_change|candidate_account_batch|score_appeal)$/); + if (workflowDefinitionMatch && request.method === 'PUT') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const workflow = activeWorkflow(db, workflowDefinitionMatch[1]); + if (!workflow) return sendError(response, 404, '审批流程不存在'); + const steps = Array.isArray(body.steps) ? body.steps : []; + if (!steps.length || steps.some(item => !['class', 'school', 'super'].includes(item.adminLevel))) return sendError(response, 400, '流程至少需要一个班级、校级或超级管理员审批步骤'); + if (['center_change', 'candidate_account_batch'].includes(workflowDefinitionMatch[1]) && steps.some(item => item.adminLevel === 'class')) return sendError(response, 400, '该业务不对应单一班级,不能配置班级管理员审批步骤'); + if (workflowDefinitionMatch[1] === 'candidate_account_batch' && steps.at(-1)?.adminLevel !== 'super') return sendError(response, 400, '批量报名号申领的最终步骤必须由超级管理员审批'); + workflow.name = cleanText(body.name, 80) || workflow.name; + workflow.updatedBy = user.id; + workflow.updatedAt = nowIso(); + workflow.steps = steps.map((item, index) => ({ id: uid('workflow_step'), position: index + 1, name: cleanText(item.name, 80) || `第 ${index + 1} 步`, adminLevel: item.adminLevel })); + const log = logAction(db, user, '修改审批流程', `${workflow.name} · ${workflow.steps.length} 个步骤`); + await database.saveWorkflow(workflow, log); + return sendJson(response, 200, { ok: true, workflow }); + } + + if (pathname === '/api/admin/workflow-instances' && request.method === 'GET') { + if (!requirePermission(user, response, 'workflows.inbox')) return true; + const instances = db.workflowInstances.filter(instance => { + if (user.adminLevel === 'super') return true; + const profile = workflowScopeProfile(db, instance); + return Boolean(profile && profileInScope(user, profile)); + }).map(instance => { + const profile = workflowScopeProfile(db, instance); + const registration = instance.businessType === 'registration_review' ? db.registrations.find(item => item.id === instance.businessId) : null; + const centerChange = instance.businessType === 'center_change' ? db.centerChangeRequests.find(item => item.id === instance.businessId) : null; + const accountBatch = instance.businessType === 'candidate_account_batch' ? db.candidateAccountBatches.find(item => item.id === instance.businessId) : null; + const appealResult = instance.businessType === 'score_appeal' ? db.results.find(item => item.id === instance.businessId) : null; + const appealRegistration = appealResult ? db.registrations.find(item => item.id === appealResult.registrationId) : null; + const appealExam = appealRegistration ? db.exams.find(item => item.id === appealRegistration.examId) : null; + const appealSubject = appealExam?.subjects.find(item => item.id === appealResult?.subjectId); + const appealRank = appealResult ? resultRankInfo(db, appealResult) : null; + const appealPass = appealResult ? subjectPassEvaluation(db, appealResult, appealSubject) : null; + return { + ...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '', + examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '', + centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null, + accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null, + batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0, + appealResult: appealResult ? { + score: appealResult.score, grade: appealRank?.grade || appealResult.grade, rank: appealRank?.rank, cohortSize: appealRank?.cohortSize, rankPercent: appealRank?.rankPercent, + examName: appealExam?.name || '', examCode: appealExam?.code || '', subjectName: appealSubject?.name || '', + fullScore: appealSubject?.fullScore, passRule: appealSubject?.passRule || 'fixed_score', passValue: appealSubject?.passValue ?? appealSubject?.passScore, + passScore: appealPass?.passScore ?? null, cutoffRank: appealPass?.cutoffRank ?? null, + passText: subjectPassText(appealSubject), qualified: appealPass?.qualified ?? null + } : null + }; + }); + const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser); + return sendJson(response, 200, { ok: true, instances, availableAdmins, canSupervise: user.adminLevel === 'super' }); + } + const transferMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/transfer$/); + if (transferMatch && request.method === 'PATCH') { + const body = await readJson(request); + const instance = db.workflowInstances.find(item => item.id === transferMatch[1] && item.status === 'pending'); + if (!instance) return sendError(response, 404, '待处理流程不存在'); + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (user.adminLevel !== 'super' && instance.assigneeId !== user.id) return sendError(response, 403, '只有当前处理人可以转交该流程'); + const target = db.users.find(item => item.id === body.assigneeId && item.role === 'admin' && item.active && item.adminLevel === step?.adminLevel); + if (!target) return sendError(response, 400, '只能转交给当前步骤同级管理员'); + const profile = workflowScopeProfile(db, instance); + if (step.adminLevel === 'school' && target.schoolId !== profile?.schoolId) return sendError(response, 400, '校级流程只能转交给本校同级管理员'); + if (step.adminLevel === 'class' && (target.schoolId !== profile?.schoolId || target.classId !== profile?.classId)) return sendError(response, 400, '班级流程只能转交给本班同级管理员'); + const previous = instance.assigneeId; + instance.assigneeId = target.id; + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: 'transfer', note: cleanText(body.note, 300), fromAssigneeId: previous, toAssigneeId: target.id, createdAt: nowIso() }; + const log = logAction(db, user, '转交审批流程', `${workflow.name} → ${target.displayName}`); + await database.transferWorkflow(instance, action, log); + return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + } + const superviseMatch = pathname.match(/^\/api\/admin\/workflow-instances\/([^/]+)\/supervise$/); + if (superviseMatch && request.method === 'PATCH') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const instance = db.workflowInstances.find(item => item.id === superviseMatch[1]); + if (!instance) return sendError(response, 404, '流程不存在'); + if (instance.businessType === 'candidate_account_batch' && db.candidateAccountBatchItems.some(item => item.batchId === instance.businessId && item.userId)) return sendError(response, 409, '已生成账号的批次不可重新打开,避免重复建号'); + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const requestedStep = Math.min(workflow.steps.length, Math.max(1, Number(body.currentStep || instance.currentStep))); + const step = workflow.steps.find(item => item.position === requestedStep); + const profile = workflowScopeProfile(db, instance); + const eligible = adminsForStep(db, step.adminLevel, profile); + const requestedAssignee = body.assigneeId ? eligible.find(item => item.id === body.assigneeId) : null; + if (body.assigneeId && !requestedAssignee) return sendError(response, 400, '指定管理员不在该学校或班级的目标步骤范围内'); + const assignee = requestedAssignee || selectAdminForStep(db, step.adminLevel, profile); + if (!assignee) return sendError(response, 409, '目标步骤没有可用管理员'); + const previous = instance.assigneeId; + const previousStep = instance.currentStep; + instance.status = 'pending'; instance.completedAt = null; instance.currentStep = requestedStep; instance.assigneeId = assignee.id; + const note = cleanText(body.note, 300) || `超级管理员将流程调整到第 ${requestedStep} 步`; + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: requestedStep < previousStep ? 'return' : 'supervise', note, fromAssigneeId: previous, toAssigneeId: assignee.id, createdAt: nowIso() }; + const business = instance.businessType === 'profile_change' + ? profile + : instance.businessType === 'registration_review' + ? db.registrations.find(item => item.id === instance.businessId) + : instance.businessType === 'center_change' + ? db.centerChangeRequests.find(item => item.id === instance.businessId) + : instance.businessType === 'candidate_account_batch' + ? db.candidateAccountBatches.find(item => item.id === instance.businessId) + : null; + if (business) { + business.status = 'pending'; business.reviewNote = note; business.reviewedAt = null; business.reviewerId = null; + } + const log = logAction(db, user, '监督调整审批流程', `${workflow.name} · 第 ${requestedStep} 步 · ${assignee.displayName}`); + await database.processWorkflow(instance, action, business, log); + return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + } + + if (request.method === 'GET' && pathname === '/api/admin/dashboard') { + const profiles = db.candidateProfiles.filter(item => profileInScope(user, item)); + const registrations = db.registrations.filter(item => registrationInScope(db, user, item)); + const visibleFlows = db.workflowInstances.filter(instance => { + if (user.adminLevel === 'super') return true; + const business = workflowScopeProfile(db, instance); + return business && profileInScope(user, business) && (instance.assigneeId === user.id || instance.status !== 'pending'); + }); + const pendingCandidates = profiles.filter(item => item.status === 'pending').length; + const pendingRegistrations = registrations.filter(item => item.status === 'pending').length; + const pendingPayments = registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length; + return sendJson(response, 200, { + ok: true, + admin: safeUser(user), + scopeLabel: adminScopeLabel(db, user), + permissions: permissionsByLevel[user.adminLevel || 'super'], + metrics: { candidates: profiles.length, pendingCandidates, registrations: registrations.length, pendingRegistrations, pendingPayments, pendingFlows: visibleFlows.filter(item => item.status === 'pending').length, publishedExams: db.exams.filter(item => item.status === 'published').length, notices: db.notices.filter(item => item.status === 'published').length }, + logs: user.adminLevel === 'super' ? db.auditLogs.slice(0, 8) : db.auditLogs.filter(log => log.actorId === user.id).slice(0, 8) + }); + } + if (request.method === 'GET' && pathname === '/api/admin/candidates') { + if (!requirePermission(user, response, 'candidates.read')) return true; + 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), + username: account?.username, + candidateNumber: account?.candidateNumber || '', + mustChangePassword: Boolean(account?.mustChangePassword), + accountArchived: Boolean(account?.archivedAt), + archivedAt: account?.archivedAt || null, + archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '', + registrations, + workflow: workflowView(db, instance) + }; + }); + return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) }); + } + if (request.method === 'POST' && pathname === '/api/admin/candidate-accounts/archive') { + if (user.adminLevel !== 'school') return sendError(response, 403, '考生账户归档由校级管理员负责'); + const body = await readJson(request); + const scopeType = cleanText(body.scopeType, 20); + const scopeValue = cleanText(body.scopeValue, 100); + const archived = Boolean(body.archived); + if (!['class', 'grade'].includes(scopeType) || !scopeValue) return sendError(response, 400, '请选择要归档的班级或年级'); + const scopedClasses = db.classes.filter(item => item.schoolId === user.schoolId); + const targetClassIds = scopeType === 'class' + ? scopedClasses.filter(item => item.id === scopeValue).map(item => item.id) + : scopedClasses.filter(item => item.grade === scopeValue).map(item => item.id); + if (!targetClassIds.length) return sendError(response, 404, '所选班级或年级不在本校范围内'); + const targetIds = new Set(db.candidateProfiles.filter(profile => profile.schoolId === user.schoolId && targetClassIds.includes(profile.classId)).map(profile => profile.userId)); + const targets = db.users.filter(item => item.role === 'candidate' && targetIds.has(item.id) && Boolean(item.archivedAt) !== archived); + const changedAt = archived ? nowIso() : null; + for (const target of targets) { + target.archivedAt = changedAt; + target.archivedBy = archived ? user.id : null; + } + const scopeLabel = scopeType === 'class' + ? db.classes.find(item => item.id === scopeValue)?.name + : scopeValue; + await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`)); + if (archived && targets.length) { + const targetUserIds = new Set(targets.map(item => item.id)); + await authState.deleteUsersSessions(targetUserIds); + } + return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel }); + } + const candidatePasswordResetMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)\/reset-password$/); + if (request.method === 'POST' && candidatePasswordResetMatch) { + if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置考生密码'); + const profile = db.candidateProfiles.find(item => item.id === candidatePasswordResetMatch[1]); + const target = db.users.find(item => item.id === profile?.userId && item.role === 'candidate'); + if (!profile || !target) return sendError(response, 404, '考生账户不存在'); + if (target.archivedAt) return sendError(response, 409, '归档账户需由校方恢复后才能重置密码'); + const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`; + target.passwordHash = hashPassword(temporaryPassword); + target.mustChangePassword = true; + await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`)); + await authState.deleteUserSessions(target.id); + return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword }); + } + const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/); + if (request.method === 'PATCH' && candidateMatch) { + if (!requirePermission(user, response, 'candidates.review')) return true; + const body = await readJson(request); + const profile = db.candidateProfiles.find(item => item.id === candidateMatch[1]); + if (!profile) return sendError(response, 404, '考生资料不存在'); + if (!profileInScope(user, profile) && user.adminLevel !== 'super') return sendError(response, 403, '该考生不在你的数据范围内'); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效'); + const instance = pendingWorkflow(db, 'profile_change', profile.id); + if (!instance) return sendError(response, 409, '当前没有待处理的考生信息流程'); + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交'); + const note = cleanText(body.reviewNote, 300); + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() }; + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + profile.status = 'rejected'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id; + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile); + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + profile.status = 'pending'; profile.reviewNote = note; + } else { + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + profile.status = 'approved'; profile.reviewNote = note; profile.reviewedAt = nowIso(); profile.reviewerId = user.id; + } + const log = logAction(db, user, body.status === 'approved' ? '处理考生信息流程' : '退回考生信息', `${profile.name}:${note || '无备注'}`); + await database.processWorkflow(instance, action, profile, log); + return sendJson(response, 200, { ok: true, profile, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + } + if (request.method === 'GET' && pathname === '/api/admin/registrations') { + if (!requirePermission(user, response, 'registrations.read')) return true; + const registrations = db.registrations.filter(registration => registrationInScope(db, user, registration)).map(registration => { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + const schoolClass = db.classes.find(item => item.id === profile?.classId); + return { + ...examRegistrationView(db, registration), + candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null, + schoolName: db.schools.find(item => item.id === profile?.schoolId)?.name || profile?.school || '', + gradeName: schoolClass?.grade || '', + className: schoolClass?.name || profile?.grade || '' + }; + }); + return sendJson(response, 200, { ok: true, registrations }); + } + if (request.method === 'GET' && pathname === '/api/admin/payments') { + if (!requirePermission(user, response, 'payments.read')) return true; + const registrations = db.registrations + .filter(registration => registration.status === 'approved' && registrationInScope(db, user, registration)) + .map(registration => { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + const view = examRegistrationView(db, registration); + return { + ...view, + candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null, + schoolName: db.schools.find(item => item.id === profile?.schoolId)?.name || profile?.school || '', + gradeName: db.classes.find(item => item.id === profile?.classId)?.grade || '', + className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '', + amountDue: Number(view.subjects.reduce((sum, subject) => sum + Number(subject.fee || 0), 0).toFixed(2)), + paidByName: db.users.find(item => item.id === registration.paidBy)?.displayName || '' + }; + }); + return sendJson(response, 200, { + ok: true, + scopeLabel: adminScopeLabel(db, user), + canConfirmPayment: hasPermission(user, 'payments.write'), + canUpdatePayment: hasPermission(user, 'payments.write'), + registrations + }); + } + const paymentMatch = pathname.match(/^\/api\/admin\/payments\/([^/]+)$/); + if (request.method === 'PATCH' && paymentMatch) { + if (!requirePermission(user, response, 'payments.write')) return true; + const body = await readJson(request); + const registration = db.registrations.find(item => item.id === paymentMatch[1]); + if (!registration || !registrationInScope(db, user, registration)) return sendError(response, 404, '缴费记录不存在或不在当前管理范围内'); + if (registration.status !== 'approved') return sendError(response, 409, '报名审核通过后才能确认缴费'); + if (db.exams.find(item => item.id === registration.examId)?.archivedAt) return sendError(response, 409, '该考试已归档,缴费记录已冻结'); + const nextStatus = body.status || 'paid'; + if (!['paid', 'unpaid'].includes(nextStatus)) return sendError(response, 400, '缴费状态无效'); + if (registration.paymentStatus === nextStatus) return sendError(response, 409, `该考生已经是${nextStatus === 'paid' ? '已缴费' : '待缴费'}状态`); + registration.paymentStatus = nextStatus; + registration.paidAt = nextStatus === 'paid' ? nowIso() : null; + registration.paidBy = nextStatus === 'paid' ? user.id : null; + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + const exam = db.exams.find(item => item.id === registration.examId); + await database.updateRegistrationPayment( + registration, + logAction(db, user, nextStatus === 'paid' ? '标记考生已缴费' : '撤销考生缴费确认', `${profile?.name || registration.registrationNumber} · ${exam?.name || registration.examId}`) + ); + return sendJson(response, 200, { + ok: true, + payment: { registrationId: registration.id, status: registration.paymentStatus, paidAt: registration.paidAt, paidBy: registration.paidBy, paidByName: nextStatus === 'paid' ? user.displayName : '' } + }); + } + if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') { + if (!requirePermission(user, response, 'registrations.read')) return true; + const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item)); + const registrations = scopedRegistrations.map(registration => { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null }; + }); + const plans = db.arrangementPlans.map(plan => ({ + ...plan, + examName: db.exams.find(item => item.id === plan.examId)?.name || '', + ruleName: db.admissionNumberRules.find(item => item.id === plan.numberRuleId)?.name || '', + mixingScopeName: admissionMixingScopes.find(item => item.code === plan.mixingScope)?.name || plan.mixingScope + })); + const visibleExams = user.adminLevel === 'super' ? db.exams : db.exams.filter(exam => scopedRegistrations.some(item => item.examId === exam.id)); + const exams = visibleExams.map(exam => ({ + ...publicExam(exam), + approvedCount: scopedRegistrations.filter(item => item.examId === exam.id).length, + arrangedCount: scopedRegistrations.filter(item => item.examId === exam.id && item.admitCard).length, + plan: plans.find(item => item.examId === exam.id) || null + })); + const centerScope = user.adminLevel === 'super' + ? db.testCenters + : user.adminLevel === 'school' ? db.testCenters.filter(item => item.schoolId === user.schoolId) : []; + return sendJson(response, 200, { + ok: true, + canArrange: user.adminLevel === 'super', + canExportCenterMaterials: user.adminLevel === 'school', + scopeLabel: adminScopeLabel(db, user), + exams, + registrations, + plans: user.adminLevel === 'super' ? plans : [], + rules: user.adminLevel === 'super' ? db.admissionNumberRules.filter(item => item.active) : [], + mixingScopes: user.adminLevel === 'super' ? admissionMixingScopes : [], + centers: centerScope.filter(item => item.status === 'active').map(center => ({ + ...center, + roomCount: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active').length, + capacity: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active' && room.roomType !== 'spare').reduce((sum, room) => sum + room.capacity, 0) + })) + }); + } + const admissionExportMatch = pathname.match(/^\/api\/admin\/admission-exports\/(admit-cards|info|center-materials)$/); + if (request.method === 'GET' && admissionExportMatch) { + if (!requirePermission(user, response, 'registrations.read')) return true; + const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`); + const examId = cleanText(requestUrl.searchParams.get('examId'), 64); + const exam = db.exams.find(item => item.id === examId); + if (!exam) return sendError(response, 404, '请选择有效考试'); + const type = admissionExportMatch[1]; + if (type === 'center-materials') { + if (user.adminLevel !== 'school') return sendError(response, 403, '考点桌贴、门贴和签名单只能由维护该考点的校级管理员导出'); + const rows = centerMaterialRows(db, user.schoolId, exam.id); + if (!rows.length) return sendError(response, 404, '本校维护考点暂无该考试的已编排考生'); + const buffer = Buffer.from(await buildCenterMaterialsWorkbook(rows, `${exam.name}|${adminScopeLabel(db, user)}`)); + return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-考点桌贴门贴签名单.xlsx`); + } + const scoped = db.registrations.filter(item => item.examId === exam.id && item.admitCard && registrationInScope(db, user, item)); + if (!scoped.length) return sendError(response, 404, '当前范围暂无已生成的准考证'); + if (type === 'admit-cards') { + const html = admitCardsHtml(db, scoped, `${exam.name}-${adminScopeLabel(db, user)}-准考证`); + const filename = encodeURIComponent(`${exam.name}-${adminScopeLabel(db, user)}-准考证批量打印.html`); + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' }); + response.end(html); + return true; + } + const rows = admissionRowsForRegistrations(db, scoped); + const buffer = Buffer.from(await buildWorkbook('admit_cards', rows, { subtitle: `${exam.name}|${adminScopeLabel(db, user)}` })); + return sendWorkbook(response, buffer, `${exam.name}-${adminScopeLabel(db, user)}-准考证信息.xlsx`); + } + const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/); + if (request.method === 'PATCH' && registrationMatch) { + if (!requirePermission(user, response, 'registrations.review')) return true; + const body = await readJson(request); + const registration = db.registrations.find(item => item.id === registrationMatch[1]); + if (!registration) return sendError(response, 404, '报名记录不存在'); + if (db.exams.find(item => item.id === registration.examId)?.archivedAt) return sendError(response, 409, '该考试已归档,报名流程已冻结'); + if (!registrationInScope(db, user, registration) && user.adminLevel !== 'super') return sendError(response, 403, '该报名不在你的数据范围内'); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效'); + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + const instance = pendingWorkflow(db, 'registration_review', registration.id); + if (!instance) return sendError(response, 409, '当前没有待处理的报名审核流程'); + const workflow = db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step?.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交'); + const note = cleanText(body.reviewNote, 300); + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() }; + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + registration.status = 'rejected'; registration.reviewNote = note; registration.reviewedAt = nowIso(); + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile); + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + registration.status = 'pending'; registration.reviewNote = note; + } else { + const account = db.users.find(item => item.id === registration.userId); + if (!account?.candidateNumber) return sendError(response, 409, '考生账户尚未分配报名号,请先在报名号管理中完成分配'); + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + registration.status = 'approved'; registration.reviewNote = note; registration.reviewedAt = nowIso(); + registration.registrationNumber = account.candidateNumber; + registration.numberRuleId = db.numberRules.find(item => item.active)?.id || registration.numberRuleId; + } + const log = logAction(db, user, body.status === 'approved' ? '处理报名审核流程' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`); + await database.processWorkflow(instance, action, registration, log); + return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + } + const scoreAppealMatch = pathname.match(/^\/api\/admin\/score-appeals\/([^/]+)$/); + if (request.method === 'PATCH' && scoreAppealMatch) { + if (!requirePermission(user, response, 'workflows.inbox')) return true; + const body = await readJson(request); + if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '复议处理状态无效'); + const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published); + const registration = db.registrations.find(item => item.id === result?.registrationId); + const profile = db.candidateProfiles.find(item => item.userId === registration?.userId); + if (!result || !registration || !profile) return sendError(response, 404, '待处理的成绩复议不存在'); + if (!profileInScope(user, profile)) return sendError(response, 403, '该成绩复议不在你的数据范围内'); + const instance = pendingWorkflow(db, 'score_appeal', result.id); + const workflow = instance && db.workflows.find(item => item.id === instance.workflowId); + const step = workflow?.steps.find(item => item.position === instance.currentStep); + if (!instance || !workflow || !step) return sendError(response, 409, '成绩复议流程状态异常'); + if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交'); + const note = cleanText(body.reviewNote, 300); + const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() }; + const exam = db.exams.find(item => item.id === registration.examId); + if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩及复议流程已永久锁定'); + const subject = exam?.subjects.find(item => item.id === result.subjectId); + const finalStep = instance.currentStep >= workflow.steps.length; + let reviewedScore = null; + if (body.status === 'approved' && finalStep) { + reviewedScore = body.reviewedScore == null || String(body.reviewedScore).trim() === '' ? Number.NaN : Number(body.reviewedScore); + if (!Number.isFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > Number(subject?.fullScore || 0)) return sendError(response, 400, `最终审批必须填写 0—${subject?.fullScore || 0} 之间的复核后分数`); + } + if (body.status === 'rejected') { + instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null; + } else if (instance.currentStep < workflow.steps.length) { + const nextStep = workflow.steps.find(item => item.position === instance.currentStep + 1); + const nextAssignee = selectAdminForStep(db, nextStep.adminLevel, profile); + if (!nextAssignee) return sendError(response, 409, `没有可承接“${nextStep.name}”的管理员`); + instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id; + } else { + instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null; + const originalScore = result.score; + result.score = reviewedScore; + const rank = resultRankInfo(db, result, reviewedScore); + result.grade = rank.grade; + result.updatedAt = nowIso(); + const pass = subjectPassEvaluation(db, result, subject, reviewedScore); + const passConclusion = pass.qualified == null + ? '本科不判定单科达线' + : `${pass.qualified ? '达到' : '未达到'}${subject.passRule === 'rank_percent' ? `排名前 ${subject.passValue}%(当前第 ${pass.rank}/${pass.cohortSize} 名,截止第 ${pass.cutoffRank} 名)` : `固定及格线 ${pass.passScore} 分`}`; + action.note = [`原分 ${originalScore} → 复核后 ${reviewedScore}`, passConclusion, note].filter(Boolean).join(';'); + } + const log = logAction(db, user, body.status === 'approved' ? (finalStep ? '复议终审并更新成绩' : '处理成绩复议') : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}${finalStep && body.status === 'approved' ? ` · ${result.score} 分` : ''}`); + await database.processWorkflow(instance, action, finalStep && body.status === 'approved' ? result : null, log); + const rank = resultRankInfo(db, result); + const pass = subjectPassEvaluation(db, result, subject); + return sendJson(response, 200, { + ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance), + result: { ...result, ...rank, passScore: pass.passScore, cutoffRank: pass.cutoffRank, qualified: pass.qualified } + }); + } + const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/); + if (request.method === 'POST' && arrangementMatch) { + if (!requirePermission(user, response, '*')) return true; + const arrangementExam = db.exams.find(item => item.id === arrangementMatch[1]); + if (arrangementExam?.archivedAt) return sendError(response, 409, '该考试已归档,不能重新编排准考证'); + const body = await readJson(request); + const generatedAt = nowIso(); + const result = buildAdmissionArrangement(db, { + examId: arrangementMatch[1], + mixingScope: cleanText(body.mixingScope, 20), + numberRuleId: cleanText(body.numberRuleId, 64), + seed: cleanText(body.seed, 80), + generatedAt + }); + if (arrangementMatch[2]) return sendJson(response, 200, { + ok: true, + preview: true, + summary: result.summary, + warnings: result.warnings, + samples: result.cards.slice(0, 5) + }); + const plan = { + id: uid('arrangement'), + examId: result.exam.id, + numberRuleId: result.rule.id, + mixingScope: result.mixingScope, + randomSeed: result.seed, + ...result.summary, + warnings: result.warnings, + generatedBy: user.id, + generatedAt + }; + const log = logAction(db, user, db.arrangementPlans.some(item => item.examId === result.exam.id) ? '重新编排准考证' : '批量编排准考证', + `${result.exam.name} · ${plan.candidateCount} 人 · ${plan.centerCount} 个考点 · ${result.rule.name}`); + await database.saveAdmissionArrangement(plan, result.cards, log); + return sendJson(response, 200, { ok: true, plan, summary: result.summary, warnings: result.warnings, cards: result.cards }); + } + const legacyAdmitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/); + if (request.method === 'POST' && legacyAdmitMatch) { + return sendError(response, 410, '单人生成已停用,请在“准考证编排”中按整场考试预检并批量生成'); + } + if (request.method === 'GET' && pathname === '/api/admin/exams') { + if (!requirePermission(user, response, '*')) return true; + return sendJson(response, 200, { ok: true, exams: db.exams.map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length })) }); + } + if (request.method === 'POST' && pathname === '/api/admin/exams') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const name = cleanText(body.name, 100); + if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期'); + const subjects = normalizeSubjects(body.subjects, body.examStart); + const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy; + const passPolicy = passPolicies.has(requestedPolicy) ? requestedPolicy : 'rank_percent'; + const passValue = ['subject_scores', 'none'].includes(passPolicy) ? 0 : Number(body.passValue ?? 60); + const scoringError = validateExamScoring(subjects, passPolicy, passValue); + if (scoringError) return sendError(response, 400, scoringError); + const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), passPolicy, passValue, status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() }; + const log = logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`); + await database.createExam(exam, log); + return sendJson(response, 201, { ok: true, exam }); + } + const examArchiveMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/archive$/); + if (request.method === 'POST' && examArchiveMatch) { + if (!requirePermission(user, response, '*')) return true; + const exam = db.exams.find(item => item.id === examArchiveMatch[1]); + if (!exam) return sendError(response, 404, '考试不存在'); + if (exam.archivedAt) return sendError(response, 409, '该考试已经归档,归档操作不可撤销'); + const resultIds = new Set(db.results.filter(result => { + const registration = db.registrations.find(item => item.id === result.registrationId); + return registration?.examId === exam.id; + }).map(result => result.id)); + const pendingAppeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && instance.status === 'pending' && resultIds.has(instance.businessId)); + if (pendingAppeals.length) return sendError(response, 409, `本场还有 ${pendingAppeals.length} 项成绩复议待处理,请先办结后再归档`); + exam.archivedAt = nowIso(); + exam.archivedBy = user.id; + exam.status = 'closed'; + const log = logAction(db, user, '归档考试并永久锁定成绩', `${exam.name} · ${exam.code}`); + await database.archiveExam(exam, log); + return sendJson(response, 200, { ok: true, exam: publicExam(exam), message: '考试已归档,全部成绩已永久锁定' }); + } + const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/); + if (request.method === 'PATCH' && examMatch) { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const exam = db.exams.find(item => item.id === examMatch[1]); + if (!exam) return sendError(response, 404, '考试不存在'); + if (exam.archivedAt) return sendError(response, 409, '该考试已归档,所有配置和成绩均已锁定'); + const originalStatus = exam.status; + const detailFields = ['code', 'name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd']; + const editingDetails = detailFields.some(field => body[field] != null) || body.subjects != null || body.passPolicy != null || body.passValue != null; + if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑'); + if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status; + detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); }); + if (body.passPolicy != null) { + const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy; + if (passPolicies.has(requestedPolicy)) exam.passPolicy = requestedPolicy; + } + if (body.passValue != null) exam.passValue = Number(body.passValue); + let replaceSubjects = false; + if (body.subjects != null) { + if (db.registrations.some(registration => registration.examId === exam.id)) return sendError(response, 409, '已有报名记录,不能修改考试科目'); + exam.subjects = normalizeSubjects(body.subjects, exam.examStart); + replaceSubjects = true; + } + const scoringError = validateExamScoring(exam.subjects, exam.passPolicy, Number(exam.passValue)); + if (scoringError) return sendError(response, 400, scoringError); + if (!exam.name || !exam.registrationStart || !exam.registrationEnd || !exam.examStart || !exam.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期'); + if (exam.status === 'published' && !exam.subjects.length) return sendError(response, 400, '请先配置考试科目再发布'); + const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`); + await database.updateExam(exam, log, replaceSubjects); + return sendJson(response, 200, { ok: true, exam }); + } + if (request.method === 'GET' && pathname === '/api/admin/notices') { + if (!requirePermission(user, response, '*')) return true; + 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, publications: systemPublications(db) }); + } + if (request.method === 'POST' && pathname === '/api/admin/notices') { + if (!requirePermission(user, response, '*')) return true; + const body = await readJson(request); + const title = cleanText(body.title, 120); + const content = sanitizeNoticeContent(body.content); + const contentText = noticePlainText(content); + if (!title || !contentText) return sendError(response, 400, '通知标题和正文不能为空'); + 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; + const body = await readJson(request); + const notice = db.notices.find(item => item.id === noticeMatch[1]); + if (!notice) return sendError(response, 404, '通知不存在'); + ['title', 'summary', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], 260); }); + if (body.content != null) { + const content = sanitizeNoticeContent(body.content); + if (!noticePlainText(content)) return sendError(response, 400, '通知正文不能为空'); + notice.content = content; + } + if (body.pinned != null) notice.pinned = Boolean(body.pinned); + if (body.status && ['draft', 'published'].includes(body.status)) { + notice.status = body.status; + if (body.status === 'published' && !notice.publishAt) notice.publishAt = nowIso(); + } + 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') { + if (!requirePermission(user, response, '*')) return true; + const refreshed = await cache.invalidate('results'); + return sendJson(response, 200, { + ok: true, + refreshed, + cacheStatus: cache.status, + message: refreshed ? '成绩 Redis 缓存已刷新,后续查询将重新生成缓存' : 'Redis 缓存当前未连接或刷新失败,成绩查询继续直接读取数据库' + }); + } + 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 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 ? 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: 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 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 exams = db.exams.map(exam => { + 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: 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.length + }; + }); + const registrations = user.adminLevel === 'super' ? approved.map(item => { + const view = examRegistrationView(db, item); + 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, 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; + const body = await readJson(request); + 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); + const registration = db.registrations.find(item => item.id === body.registrationId && 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, '该考试已归档,成绩已永久锁定'); + if (!registration.subjectIds.includes(body.subjectId) || !exam.subjects.some(item => item.id === body.subjectId)) return sendError(response, 400, '该考生未报名此科目'); + const score = Number(body.score); + const subject = exam.subjects.find(item => item.id === body.subjectId); + if (!Number.isFinite(score) || score < 0 || score > subject.fullScore) return sendError(response, 400, `成绩必须在 0—${subject.fullScore} 之间`); + let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId); + const isNew = !result; + if (!result) { + result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId }; + db.results.push(result); + } + Object.assign(result, { score, published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? (result.publishedAt || nowIso()) : null }); + result.grade = result.published ? resultRankInfo(db, result, score).grade : '待发布'; + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`); + await database.saveResult(result, isNew, log); + return sendJson(response, 200, { ok: true, result }); + } + return sendError(response, 404, '管理功能接口不存在'); + } + + return handleAdmin; +} diff --git a/src/routes/admission.routes.mjs b/src/routes/admission.routes.mjs new file mode 100644 index 0000000..06e3d6c --- /dev/null +++ b/src/routes/admission.routes.mjs @@ -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; +} diff --git a/src/routes/auth.routes.mjs b/src/routes/auth.routes.mjs new file mode 100644 index 0000000..037c12e --- /dev/null +++ b/src/routes/auth.routes.mjs @@ -0,0 +1,270 @@ +import QRCode from 'qrcode'; +import { + assertTotpConfiguration, + buildOtpAuthUri, + consumeRecoveryCode, + createRecoveryCodes, + createTotpSecret, + decryptTotpSecret, + encryptTotpSecret, + hashRecoveryCode, + verifyTotp +} from '../security/totp.mjs'; + +export function createAuthRoutes(context) { + assertTotpConfiguration(); + const { + database, + readDb, + sendJson, + sendError, + readJson, + readBodyBuffer, + sendWorkbook, + currentUser, + parseCookies, + safeUser, + requireUser, + hasPermission, + requirePermission, + profileInScope, + registrationInScope, + adminScopeLabel, + adminsForStep, + activeWorkflow, + createWorkflowSubmission, + workflowView, + pendingWorkflow, + candidateSequence, + generateCandidateNumber, + cleanText, + centerScopeProfile, + workflowScopeProfile, + candidateAccountBatchView, + centerChangeView, + parseCenterChange, + maskId, + publicExam, + examRegistrationView, + logAction, + excelResourceNames, + excelRowsForResource, + importExcelResource, + admitCardHtml, + hashPassword, + verifyPassword, + randomBytes, + uid, + nowIso, + authState, + buildWorkbook, + hasExcelResource, + parseWorkbook, + adminLevelNames, + permissionsByLevel + } = context; + + async function issueSession(user) { + const token = randomBytes(32).toString('hex'); + 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=${authState.sessionTtlSeconds}` }; + } + + function sessionToken(request) { + return parseCookies(request).hz_session || ''; + } + + function verifySecondFactor(user, code) { + if (!user.totpEnabled || !user.totpSecretEncrypted) return null; + const normalized = String(code || '').trim(); + if (/^\d{6}$/.test(normalized)) { + const step = verifyTotp(normalized, decryptTotpSecret(user.totpSecretEncrypted), { lastUsedStep: user.totpLastUsedStep }); + return step == null ? null : { type: 'totp', step }; + } + const recoveryCodes = consumeRecoveryCode(normalized, user.totpRecoveryCodes || []); + return recoveryCodes ? { type: 'recovery', recoveryCodes } : null; + } + + async function handleAuth(request, response, pathname) { + if (request.method === 'GET' && pathname === '/api/auth/me') { + const user = await currentUser(request); + if (!user) return sendJson(response, 200, { ok: true, user: null }); + const db = await readDb(); + const profile = user.role === 'candidate' ? db.candidateProfiles.find(item => item.userId === user.id) : null; + return sendJson(response, 200, { ok: true, user: safeUser(user), profile, ...(user.role === 'admin' ? { permissions: permissionsByLevel[user.adminLevel || 'super'], scopeLabel: adminScopeLabel(db, user) } : {}) }); + } + if (request.method === 'POST' && pathname === '/api/auth/register') { + const body = await readJson(request); + const password = String(body.password || ''); + const name = cleanText(body.name, 30); + const gender = cleanText(body.gender, 10); + if (!name || !['男', '女'].includes(gender)) return sendError(response, 400, '请填写姓名并选择性别'); + if (password.length < 8) return sendError(response, 400, '密码至少需要 8 位'); + const db = await readDb(); + 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 && 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 }; + const generated = generateCandidateNumber(db, draftProfile); + const userId = uid('usr'); + const user = { id: userId, username: generated.number, candidateNumber: generated.number, passwordHash: hashPassword(password), role: 'candidate', displayName: name, active: true, mustChangePassword: false, createdAt: nowIso() }; + const profile = { id: uid('profile'), userId, name, idNumber: `PENDING-${userId}`, phone: '', gender, email: '', school: school.name, grade: schoolClass.name, schoolId, classId, address: '', emergencyContact: '', emergencyPhone: '', nativePlace: '', birthDate: '', ethnicity: '', postalCode: '', guardianName: '', guardianPhone: '', profileCompleted: false, status: 'pending', reviewNote: '', updatedAt: nowIso() }; + await database.createCandidate(user, profile, null, null); + return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' }); + } + if (request.method === 'POST' && pathname === '/api/auth/login') { + const body = await readJson(request); + const db = await readDb(); + const account = cleanText(body.username, 120).toLowerCase(); + const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account); + 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'); + await authState.createLoginChallenge(challenge, user.id); + return sendJson(response, 200, { ok: true, requiresTotp: true, challenge }); + } + 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') { + const body = await readJson(request); + const challengeKey = String(body.challenge || ''); + 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) { + await authState.deleteLoginChallenge(challengeKey); + return sendError(response, 401, '验证请求已失效,请重新登录'); + } + let verified = null; + try { verified = verifySecondFactor(user, body.code); } catch {} + if (!verified) { + 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); + 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') { + const user = await requireUser(request, response); + if (!user) return true; + const body = await readJson(request); + const currentPassword = String(body.currentPassword || ''); + const newPassword = String(body.newPassword || ''); + if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确'); + if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位'); + if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与当前密码相同'); + user.passwordHash = hashPassword(newPassword); + user.mustChangePassword = false; + const db = await readDb(); + const log = logAction(db, user, '修改登录密码', user.role === 'candidate' ? `报名号 ${user.candidateNumber}` : user.username); + await database.changePassword(user, log); + return sendJson(response, 200, { ok: true, user: safeUser(user) }); + } + if (request.method === 'GET' && pathname === '/api/auth/totp') { + const user = await requireUser(request, response); + if (!user) return true; + return sendJson(response, 200, { + ok: true, + enabled: Boolean(user.totpEnabled), + recoveryCodesRemaining: user.totpEnabled ? (user.totpRecoveryCodes || []).length : 0 + }); + } + if (request.method === 'POST' && pathname === '/api/auth/totp/setup') { + const user = await requireUser(request, response); + if (!user) return true; + if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证'); + if (user.totpEnabled) return sendError(response, 409, '当前账号已经启用 TOTP 二次验证'); + const body = await readJson(request); + if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确'); + const db = await readDb(); + const issuer = cleanText(db.organization?.name || '考试服务平台', 80); + const secret = createTotpSecret(); + const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer }); + const token = sessionToken(request); + 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') { + const user = await requireUser(request, response); + if (!user) return true; + const token = sessionToken(request); + 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); + const step = verifyTotp(body.code, setup.secret); + if (step == null) return sendError(response, 400, '动态验证码不正确,请确认设备时间准确后重试'); + const recoveryCodes = createRecoveryCodes(); + user.totpEnabled = true; + user.totpSecretEncrypted = encryptTotpSecret(setup.secret); + user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode); + user.totpLastUsedStep = step; + const db = await readDb(); + const log = logAction(db, user, '启用 TOTP 二次验证', user.username); + await database.updateTotpSecurity(user, log); + await authState.deleteTotpSetup(token); + return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) }); + } + if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') { + const user = await requireUser(request, response); + if (!user) return true; + if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证'); + const body = await readJson(request); + if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确'); + let verified = null; + try { verified = verifySecondFactor(user, body.code); } catch {} + if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确'); + const recoveryCodes = createRecoveryCodes(); + user.totpRecoveryCodes = recoveryCodes.map(hashRecoveryCode); + if (verified.type === 'totp') user.totpLastUsedStep = verified.step; + const db = await readDb(); + const log = logAction(db, user, '重新生成 TOTP 恢复码', user.username); + await database.updateTotpSecurity(user, log); + return sendJson(response, 200, { ok: true, recoveryCodes }); + } + if (request.method === 'POST' && pathname === '/api/auth/totp/disable') { + const user = await requireUser(request, response); + if (!user) return true; + if (!user.totpEnabled) return sendError(response, 400, '当前账号尚未启用 TOTP 二次验证'); + const body = await readJson(request); + if (!verifyPassword(String(body.currentPassword || ''), user.passwordHash)) return sendError(response, 400, '当前密码不正确'); + let verified = null; + try { verified = verifySecondFactor(user, body.code); } catch {} + if (!verified) return sendError(response, 400, '动态验证码或恢复码不正确'); + user.totpEnabled = false; + user.totpSecretEncrypted = null; + user.totpRecoveryCodes = []; + user.totpLastUsedStep = null; + const db = await readDb(); + const log = logAction(db, user, '关闭 TOTP 二次验证', user.username); + await database.updateTotpSecurity(user, log); + 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) await authState.deleteSession(token); + return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' }); + } + return false; + } + + return handleAuth; +} diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs new file mode 100644 index 0000000..5b5325a --- /dev/null +++ b/src/routes/candidate.routes.mjs @@ -0,0 +1,346 @@ +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 { + database, + cache, + resultsCacheTtlSeconds, + readDb, + sendJson, + sendError, + readJson, + readBodyBuffer, + sendWorkbook, + currentUser, + safeUser, + requireUser, + hasPermission, + requirePermission, + profileInScope, + registrationInScope, + adminScopeLabel, + adminsForStep, + activeWorkflow, + createWorkflowSubmission, + workflowView, + pendingWorkflow, + candidateSequence, + generateCandidateNumber, + cleanText, + centerScopeProfile, + workflowScopeProfile, + candidateAccountBatchView, + centerChangeView, + parseCenterChange, + maskId, + publicExam, + examRegistrationView, + examResultSummary, + subjectPassText, + resultRankInfo, + documentVerificationSecret, + scoreReportCode, + admissionNoticeCode, + subjectPassEvaluation, + logAction, + excelResourceNames, + excelRowsForResource, + importExcelResource, + admitCardHtml, + hashPassword, + verifyPassword, + uid, + nowIso, + buildWorkbook, + hasExcelResource, + parseWorkbook, + adminLevelNames, + 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'); + if (!user) return true; + const db = await readDb(); + const profile = db.candidateProfiles.find(item => item.userId === user.id); + if (user.mustChangePassword) return sendError(response, 428, '首次登录必须先修改初始密码'); + const profileRoute = pathname === '/api/candidate/profile'; + if (!profile.profileCompleted && !profileRoute) return sendError(response, 428, '请先补全个人信息并提交审核'); + + 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').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 && 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', '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 && 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; + profile.classId = schoolClass.id; + profile.school = school.name; + profile.grade = schoolClass.name; + if (!profile.name || !['男', '女'].includes(profile.gender) || !profile.idNumber || profile.idNumber.startsWith('PENDING-') || !profile.nativePlace || !profile.address || !profile.phone || !profile.email || !profile.school || !profile.classId) return sendError(response, 400, '请完整填写姓名、性别、证件号码、籍贯、省市区县、家庭住址、手机号、邮箱、学校和班级'); + if (db.candidateProfiles.some(item => item.id !== profile.id && item.idNumber === profile.idNumber)) return sendError(response, 409, '证件号码已被其他考生使用'); + profile.status = 'pending'; + profile.profileCompleted = true; + profile.reviewNote = ''; + profile.updatedAt = nowIso(); + const existingWorkflow = pendingWorkflow(db, 'profile_change', profile.id); + const submission = existingWorkflow ? null : createWorkflowSubmission(db, 'profile_change', profile.id, profile, user.id); + await database.updateCandidateProfile(profile, profile.name, submission?.instance, submission?.action); + return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' }); + } + if (request.method === 'GET' && pathname === '/api/candidate/exams') { + const registrations = db.registrations.filter(item => item.userId === user.id); + const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registration: registrations.find(reg => reg.examId === exam.id) || null })); + return sendJson(response, 200, { ok: true, profileStatus: profile.status, exams }); + } + if (request.method === 'GET' && pathname === '/api/candidate/registrations') { + return sendJson(response, 200, { ok: true, registrations: db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item)) }); + } + if (request.method === 'POST' && pathname === '/api/candidate/registrations') { + if (profile.status !== 'approved') return sendError(response, 403, '个人资料审核通过后才能报名考试'); + const body = await readJson(request); + const exam = db.exams.find(item => item.id === body.examId && item.status === 'published' && !item.archivedAt); + if (!exam) return sendError(response, 404, '考试不存在或尚未发布'); + const state = publicExam(exam).registrationState; + if (state !== 'open') return sendError(response, 400, state === 'upcoming' ? '报名尚未开始' : '报名已经截止'); + if (db.registrations.some(item => item.userId === user.id && item.examId === exam.id)) return sendError(response, 409, '你已经报名该考试'); + const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])]; + if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目'); + const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', paidAt: null, paidBy: null, createdAt: nowIso(), registrationNumber: user.candidateNumber, numberRuleId: db.numberRules.find(item => item.active)?.id || null, admitCard: null }; + const { instance, action } = createWorkflowSubmission(db, 'registration_review', registration.id, profile, user.id); + await database.createRegistration(registration, instance, action); + return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); + } + if (request.method === 'GET' && pathname === '/api/candidate/results') { + const payload = await cache.remember('results', `candidate:${encodeURIComponent(user.id)}`, async () => { + const registrations = db.registrations.filter(item => item.userId === user.id); + const results = db.results.filter(item => item.published && registrations.some(reg => reg.id === item.registrationId)).map(result => { + const registration = registrations.find(reg => reg.id === result.registrationId); + const exam = db.exams.find(item => item.id === registration.examId); + const subject = exam.subjects.find(item => item.id === result.subjectId); + const appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id); + const appeal = appealInstance ? workflowView(db, appealInstance) : null; + const rank = resultRankInfo(db, result); + const pass = subjectPassEvaluation(db, result, subject); + return { + ...result, ...rank, grade: rank.grade, examId: exam.id, examName: exam.name, examCode: exam.code, examStart: exam.examStart, archivedAt: exam.archivedAt || null, + subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, + passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore, + passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), qualified: pass.qualified, + appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null + }; + }); + 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); + const registration = db.registrations.find(item => item.id === result?.registrationId && item.userId === user.id); + if (!result || !registration) return sendError(response, 404, '已发布成绩不存在或不属于当前考生'); + const exam = db.exams.find(item => item.id === registration.examId); + if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,成绩及复议入口已永久锁定'); + if (pendingWorkflow(db, 'score_appeal', result.id)) return sendError(response, 409, '该科成绩已有待处理复议,请勿重复提交'); + const body = await readJson(request); + const reason = cleanText(body.reason, 500); + if (reason.length < 5) return sendError(response, 400, '请至少填写 5 个字的复议理由'); + const { instance, action } = createWorkflowSubmission(db, 'score_appeal', result.id, profile, user.id); + action.note = reason; + const subject = exam?.subjects.find(item => item.id === result.subjectId); + const log = logAction(db, user, '提交成绩复议', `${exam?.name || ''} · ${subject?.name || ''}`); + await database.createWorkflow(instance, action, log); + return sendJson(response, 201, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) }); + } + const admitMatch = pathname.match(/^\/api\/candidate\/registrations\/([^/]+)\/admit-card$/); + if (request.method === 'GET' && admitMatch) { + const registration = db.registrations.find(item => item.id === admitMatch[1] && item.userId === user.id); + if (!registration || !registration.admitCard) return sendError(response, 404, '准考证尚未生成'); + const exam = db.exams.find(item => item.id === registration.examId); + const now = Date.now(); + if (now < new Date(exam.admitDownloadStart).getTime()) return sendError(response, 403, '准考证下载尚未开放'); + if (now > new Date(exam.admitDownloadEnd).getTime()) return sendError(response, 403, '准考证下载时间已结束'); + const html = admitCardHtml(db, user, profile, registration); + const filename = encodeURIComponent(`${exam.name}-${profile.name}-准考证.html`); + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Disposition': `attachment; filename*=UTF-8''${filename}`, 'Cache-Control': 'no-store' }); + response.end(html); + return true; + } + return sendError(response, 404, '考生功能接口不存在'); + } + + return handleCandidate; +} diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs new file mode 100644 index 0000000..8d1c72f --- /dev/null +++ b/src/routes/public.routes.mjs @@ -0,0 +1,146 @@ +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 { + database, + cache, + readDb, + publicSiteConfig, + sendJson, + sendError, + readJson, + readBodyBuffer, + sendWorkbook, + currentUser, + safeUser, + requireUser, + hasPermission, + requirePermission, + profileInScope, + registrationInScope, + adminScopeLabel, + adminsForStep, + activeWorkflow, + createWorkflowSubmission, + workflowView, + pendingWorkflow, + candidateSequence, + generateCandidateNumber, + cleanText, + centerScopeProfile, + workflowScopeProfile, + candidateAccountBatchView, + centerChangeView, + parseCenterChange, + maskId, + publicExam, + examRegistrationView, + logAction, + excelResourceNames, + excelRowsForResource, + importExcelResource, + admitCardHtml, + hashPassword, + verifyPassword, + uid, + nowIso, + 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 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 && 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); + } + const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/); + if (noticeMatch) { + 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'); + 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, '通知不存在或尚未发布'); + } + return false; + } + + return handlePublic; +} diff --git a/src/security/auth-state.mjs b/src/security/auth-state.mjs new file mode 100644 index 0000000..492e14b --- /dev/null +++ b/src/security/auth-state.mjs @@ -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(); + } + }; +} diff --git a/src/security/authorization.mjs b/src/security/authorization.mjs new file mode 100644 index 0000000..223d4eb --- /dev/null +++ b/src/security/authorization.mjs @@ -0,0 +1,40 @@ +export const adminLevelNames = { super: '超级管理员', school: '校级管理员', class: '班级管理员' }; + +export const permissionsByLevel = { + super: ['*'], + school: ['dashboard.read', 'candidates.read', 'candidates.write', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'payments.write', 'results.read', 'centers.read', 'centers.write', 'workflows.inbox'], + class: ['dashboard.read', 'candidates.read', 'candidates.review', 'registrations.read', 'registrations.review', 'payments.read', 'payments.write', 'results.read', 'workflows.inbox'] +}; + +export function hasPermission(user, permission) { + if (user?.role !== 'admin') return false; + const permissions = permissionsByLevel[user.adminLevel || 'super'] || []; + return permissions.includes('*') || permissions.includes(permission); +} + +export function createPermissionGuard(sendError) { + return function requirePermission(user, response, permission) { + if (hasPermission(user, permission)) return true; + sendError(response, 403, '当前管理员层级无权执行此操作'); + return false; + }; +} + +export function profileInScope(user, profile) { + if (user.adminLevel === 'super') return true; + if (user.adminLevel === 'school') return Boolean(user.schoolId && profile.schoolId === user.schoolId); + return Boolean(user.classId && profile.classId === user.classId); +} + +export function registrationInScope(db, user, registration) { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + return Boolean(profile && profileInScope(user, profile)); +} + +export function adminScopeLabel(db, user) { + if (user.adminLevel === 'super') return '全部学校与班级'; + const school = db.schools.find(item => item.id === user.schoolId)?.name || '未绑定学校'; + if (user.adminLevel === 'school') return school; + const schoolClass = db.classes.find(item => item.id === user.classId)?.name || '未绑定班级'; + return `${school} · ${schoolClass}`; +} diff --git a/src/security/document-verification.mjs b/src/security/document-verification.mjs new file mode 100644 index 0000000..cd8cde1 --- /dev/null +++ b/src/security/document-verification.mjs @@ -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); +} diff --git a/src/security/notice-content.mjs b/src/security/notice-content.mjs new file mode 100644 index 0000000..aa7e92b --- /dev/null +++ b/src/security/notice-content.mjs @@ -0,0 +1,94 @@ +import sanitizeHtml from 'sanitize-html'; + +const allowedTags = [ + 'p', 'br', 'h2', 'h3', 'h4', + 'strong', 'em', 'u', 's', + 'ul', 'ol', 'li', 'blockquote', 'a', + 'figure', 'figcaption', 'img', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td' +]; + +const blockTags = /<\/?(?:p|h[2-4]|ul|ol|li|blockquote|br|figure|figcaption|table|thead|tbody|tfoot|tr|th|td)\b[^>]*>/gi; + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function decodeTextEntities(value) { + const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", nbsp: ' ' }; + const codePoint = (code, radix) => { + const parsed = Number.parseInt(code, radix); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0x10ffff && !(parsed >= 0xd800 && parsed <= 0xdfff) + ? String.fromCodePoint(parsed) + : '�'; + }; + return String(value) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => codePoint(code, 16)) + .replace(/&#(\d+);/g, (_, code) => codePoint(code, 10)) + .replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/gi, (_, name) => named[name.toLowerCase()]); +} + +export function sanitizeNoticeContent(value) { + const source = String(value ?? '').trim().slice(0, 20000); + return sanitizeHtml(source, { + allowedTags, + allowedAttributes: { + a: ['href', 'target', 'rel'], + figure: ['class'], + img: ['src', 'alt'], + th: ['colspan', 'rowspan'], + td: ['colspan', 'rowspan'] + }, + allowedClasses: { + figure: [ + 'image', 'table', 'image-style-inline', 'image-style-block', 'image-style-side', + 'image-style-align-left', 'image-style-align-right', + 'image-style-block-align-left', 'image-style-block-align-right' + ] + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + allowProtocolRelative: false, + transformTags: { + a(tagName, attributes) { + const safeAttributes = {}; + if (attributes.href) safeAttributes.href = attributes.href; + if (attributes.target === '_blank') safeAttributes.target = '_blank'; + safeAttributes.rel = 'noopener noreferrer'; + return { tagName, attribs: safeAttributes }; + }, + img(tagName, attributes) { + const safeAttributes = {}; + if (/^https?:\/\//i.test(attributes.src || '')) safeAttributes.src = attributes.src; + if (attributes.alt) safeAttributes.alt = attributes.alt; + return { tagName, attribs: safeAttributes }; + } + } + }); +} + +export function noticePlainText(value) { + const sanitized = sanitizeNoticeContent(value).replace(blockTags, ' '); + const withoutTags = sanitizeHtml(sanitized, { allowedTags: [], allowedAttributes: {} }); + return decodeTextEntities(withoutTags).replace(/\s+/g, ' ').trim(); +} + +export function noticeContentHtml(value) { + const source = String(value ?? '').trim(); + if (!source) return ''; + if (!/<\/?(?:p|h[2-4]|strong|em|u|s|ul|ol|li|blockquote|a|br|figure|figcaption|img|table|thead|tbody|tfoot|tr|th|td)\b/i.test(source)) { + return source + .split(/\r?\n{2,}/) + .map(paragraph => `

${escapeHtml(paragraph).replace(/\r?\n/g, '
')}

`) + .join(''); + } + return sanitizeNoticeContent(source); +} + +export function noticeForClient(notice) { + return { ...notice, content: sanitizeNoticeContent(notice.content), contentHtml: noticeContentHtml(notice.content) }; +} diff --git a/src/security/session.mjs b/src/security/session.mjs new file mode 100644 index 0000000..363854f --- /dev/null +++ b/src/security/session.mjs @@ -0,0 +1,54 @@ +export function createSessionManager({ authState, readDb, sendError }) { + function normalizeUser(user) { + if (user?.role === 'admin' && !user.adminLevel) return { ...user, adminLevel: 'super' }; + return user; + } + + function parseCookies(request) { + return Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => part.trim()).filter(Boolean).map(part => { + const index = part.indexOf('='); + return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))]; + })); + } + + async function currentUser(request) { + const token = parseCookies(request).hz_session; + 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); + } + + function safeUser(user) { + return { + id: user.id, + username: user.username, + role: user.role, + adminLevel: user.role === 'admin' ? user.adminLevel || 'super' : null, + schoolId: user.schoolId || null, + classId: user.classId || null, + displayName: user.displayName, + candidateNumber: user.candidateNumber || null, + mustChangePassword: Boolean(user.mustChangePassword), + totpEnabled: Boolean(user.totpEnabled), + archived: Boolean(user.archivedAt) + }; + } + + async function requireUser(request, response, role) { + const user = await currentUser(request); + if (!user) { + sendError(response, 401, '请先登录'); + return null; + } + if (role && user.role !== role) { + sendError(response, 403, '当前账号无权执行此操作'); + return null; + } + return user; + } + + return { parseCookies, currentUser, safeUser, requireUser }; +} diff --git a/src/security/totp.mjs b/src/security/totp.mjs new file mode 100644 index 0000000..ef3161f --- /dev/null +++ b/src/security/totp.mjs @@ -0,0 +1,121 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + timingSafeEqual +} from 'node:crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const RECOVERY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; +const TOTP_PERIOD_SECONDS = 30; + +function encryptionKey() { + const configured = String(process.env.TOTP_ENCRYPTION_KEY || ''); + if (process.env.NODE_ENV === 'production' && configured.length < 32) { + throw new Error('生产环境启用 TOTP 前必须设置至少 32 个字符的 TOTP_ENCRYPTION_KEY'); + } + const material = configured || `development-only:${process.env.INITIAL_ADMIN_PASSWORD || 'local-exam-system'}`; + return createHash('sha256').update(material).digest(); +} + +export function assertTotpConfiguration() { + encryptionKey(); +} + +export function createTotpSecret() { + const bytes = randomBytes(20); + let bits = ''; + for (const byte of bytes) bits += byte.toString(2).padStart(8, '0'); + let encoded = ''; + for (let index = 0; index < bits.length; index += 5) { + encoded += BASE32_ALPHABET[Number.parseInt(bits.slice(index, index + 5).padEnd(5, '0'), 2)]; + } + return encoded; +} + +function decodeBase32(value) { + const normalized = String(value || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); + let bits = ''; + for (const character of normalized) { + const index = BASE32_ALPHABET.indexOf(character); + if (index < 0) throw new Error('TOTP 密钥格式无效'); + bits += index.toString(2).padStart(5, '0'); + } + const bytes = []; + for (let index = 0; index + 8 <= bits.length; index += 8) bytes.push(Number.parseInt(bits.slice(index, index + 8), 2)); + return Buffer.from(bytes); +} + +export function totpAtStep(secret, step) { + const counter = Buffer.alloc(8); + counter.writeBigUInt64BE(BigInt(step)); + const digest = createHmac('sha1', decodeBase32(secret)).update(counter).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const binary = (digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000; + return String(binary).padStart(6, '0'); +} + +export function verifyTotp(code, secret, { now = Date.now(), window = 1, lastUsedStep = null } = {}) { + const normalized = String(code || '').replace(/\s/g, ''); + if (!/^\d{6}$/.test(normalized)) return null; + const currentStep = Math.floor(now / 1000 / TOTP_PERIOD_SECONDS); + for (let offset = -window; offset <= window; offset += 1) { + const step = currentStep + offset; + if (lastUsedStep != null && step <= Number(lastUsedStep)) continue; + const expected = Buffer.from(totpAtStep(secret, step)); + const supplied = Buffer.from(normalized); + if (expected.length === supplied.length && timingSafeEqual(expected, supplied)) return step; + } + return null; +} + +export function buildOtpAuthUri({ secret, account, issuer }) { + const label = `${issuer}:${account}`; + const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: String(TOTP_PERIOD_SECONDS) }); + return `otpauth://totp/${encodeURIComponent(label)}?${params}`; +} + +export function encryptTotpSecret(secret) { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv); + const encrypted = Buffer.concat([cipher.update(String(secret), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`; +} + +export function decryptTotpSecret(value) { + const [version, ivValue, tagValue, encryptedValue] = String(value || '').split('.'); + if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) throw new Error('TOTP 密钥数据无效'); + const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url')); + decipher.setAuthTag(Buffer.from(tagValue, 'base64url')); + return Buffer.concat([decipher.update(Buffer.from(encryptedValue, 'base64url')), decipher.final()]).toString('utf8'); +} + +function normalizeRecoveryCode(code) { + return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); +} + +export function hashRecoveryCode(code) { + return createHmac('sha256', encryptionKey()).update(normalizeRecoveryCode(code)).digest('hex'); +} + +export function createRecoveryCodes(count = 8) { + return Array.from({ length: count }, () => { + let value = ''; + const bytes = randomBytes(10); + for (let index = 0; index < 10; index += 1) value += RECOVERY_ALPHABET[bytes[index] % RECOVERY_ALPHABET.length]; + return `${value.slice(0, 5)}-${value.slice(5)}`; + }); +} + +export function consumeRecoveryCode(code, hashes = []) { + const candidate = Buffer.from(hashRecoveryCode(code)); + const index = hashes.findIndex(hash => { + const stored = Buffer.from(String(hash || '')); + return stored.length === candidate.length && timingSafeEqual(stored, candidate); + }); + if (index < 0) return null; + return hashes.filter((_, itemIndex) => itemIndex !== index); +} diff --git a/src/services/admission-arrangement.mjs b/src/services/admission-arrangement.mjs new file mode 100644 index 0000000..3b43a08 --- /dev/null +++ b/src/services/admission-arrangement.mjs @@ -0,0 +1,276 @@ +export const admissionMixingScopes = [ + { code: 'class', name: '班内混编', description: '以班级为边界,同班考生按科目组合穿插编排。' }, + { code: 'school', name: '校内混编', description: '同校跨班混编,优先安排在本校考点。' }, + { code: 'district', name: '县区内混编', description: '同县区跨学校混编,优先使用本县区考点。' }, + { code: 'city', name: '市内混编', description: '同市跨县区混编,优先使用本市考点。' }, + { code: 'province', name: '省内混编', description: '全省范围混编,按容量和科目组合选择考点。' } +]; + +const mixingScopeCodes = new Set(admissionMixingScopes.map(item => item.code)); + +function arrangementError(message, status = 409) { + return Object.assign(new Error(message), { status }); +} + +function normalizedCode(value, fallback = '') { + return String(value || fallback).replace(/[^0-9A-Z]/gi, '').toUpperCase(); +} + +function hashText(value) { + let hash = 2166136261; + for (const char of String(value)) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function stableCompare(seed, left, right) { + return hashText(`${seed}:${left.id}`) - hashText(`${seed}:${right.id}`) || left.id.localeCompare(right.id); +} + +function scopeValue(profile, scope) { + if (scope === 'class') return profile.classId; + if (scope === 'school') return profile.schoolId; + if (scope === 'district') return profile.districtCode; + if (scope === 'city') return profile.cityCode; + return profile.provinceCode; +} + +function centerMatchesProfile(center, profile, scope) { + if (scope === 'class' || scope === 'school') return center.schoolId === profile.schoolId; + if (scope === 'district') return Boolean(profile.districtCode && center.districtCode === profile.districtCode); + if (scope === 'city') return Boolean(profile.cityCode && center.cityCode === profile.cityCode); + return Boolean(profile.provinceCode && center.provinceCode === profile.provinceCode); +} + +function localityScore(center, profile) { + if (center.schoolId === profile.schoolId) return 5000; + if (profile.districtCode && center.districtCode === profile.districtCode) return 1000; + if (profile.cityCode && center.cityCode === profile.cityCode) return 300; + if (profile.provinceCode && center.provinceCode === profile.provinceCode) return 100; + return 0; +} + +function minutes(value) { + const [hour, minute] = String(value || '').split(':').map(Number); + return Number.isFinite(hour) && Number.isFinite(minute) ? hour * 60 + minute : NaN; +} + +function overlappingSubjects(subjects) { + for (let left = 0; left < subjects.length; left += 1) { + for (let right = left + 1; right < subjects.length; right += 1) { + const first = subjects[left]; + const second = subjects[right]; + if (first.date !== second.date) continue; + const firstStart = minutes(first.start); + const firstEnd = minutes(first.end); + const secondStart = minutes(second.start); + const secondEnd = minutes(second.end); + if ([firstStart, firstEnd, secondStart, secondEnd].every(Number.isFinite) + && firstStart < secondEnd && secondStart < firstEnd) return [first, second]; + } + } + return null; +} + +function parseRuleSegments(rule) { + if (Array.isArray(rule.segments)) return rule.segments; + try { return JSON.parse(rule.segmentsJson || '[]'); } catch { return []; } +} + +function segmentValue(segment, sources, sequence) { + const source = segment.source === 'sequence' ? String(sequence) : String(sources[segment.source] || ''); + if (!source) throw arrangementError(`准考证号规则需要“${segment.label || segment.source}”,但考生或考点档案中缺少该值`); + const width = Math.max(0, Number(segment.width || 0)); + return width ? source.padStart(width, '0') : source; +} + +function formatAdmissionNumber(rule, sources, sequence) { + const segments = parseRuleSegments(rule); + if (!segments.length) throw arrangementError('所选准考证号规则没有可用的组成段'); + return segments.map(segment => segmentValue(segment, sources, sequence)).join(rule.separator || ''); +} + +function sequenceGroup(rule, sources) { + if (rule.code.startsWith('district_')) return sources.district_code; + if (rule.code.startsWith('center_school_')) return sources.center_school_code; + if (rule.code.startsWith('candidate_school_')) return sources.candidate_school_code; + return 'all'; +} + +export function buildAdmissionArrangement(db, options) { + const exam = db.exams.find(item => item.id === options.examId); + if (!exam) throw arrangementError('考试不存在', 404); + const mixingScope = String(options.mixingScope || 'school'); + if (!mixingScopeCodes.has(mixingScope)) throw arrangementError('请选择有效的混编范围', 400); + const rule = db.admissionNumberRules.find(item => item.id === options.numberRuleId && item.active !== false); + if (!rule) throw arrangementError('请选择有效的准考证号规则', 400); + const seed = String(options.seed || exam.code || exam.id).slice(0, 80); + const warnings = []; + const warn = message => { if (!warnings.includes(message)) warnings.push(message); }; + const subjectOrder = new Map(exam.subjects.map((subject, index) => [subject.id, index])); + const registrations = db.registrations.filter(item => item.examId === exam.id && item.status === 'approved'); + if (!registrations.length) throw arrangementError('该考试没有已审核通过的报名,无法编排'); + + const candidates = registrations.map(registration => { + const profile = db.candidateProfiles.find(item => item.userId === registration.userId); + if (!profile) throw arrangementError(`报名 ${registration.id} 缺少考生档案`); + const boundary = scopeValue(profile, mixingScope); + if (!boundary) throw arrangementError(`${profile.name} 缺少${admissionMixingScopes.find(item => item.code === mixingScope)?.name.replace('内混编', '') || '范围'}信息`); + const subjects = registration.subjectIds.map(id => exam.subjects.find(subject => subject.id === id)).filter(Boolean); + if (subjects.length !== registration.subjectIds.length || !subjects.length) throw arrangementError(`${profile.name} 的报考科目无效`); + const overlap = overlappingSubjects(subjects); + if (overlap) throw arrangementError(`${profile.name} 报考的“${overlap[0].name}”与“${overlap[1].name}”时间冲突`); + const orderedSubjectIds = subjects.sort((a, b) => subjectOrder.get(a.id) - subjectOrder.get(b.id)).map(subject => subject.id); + return { + id: registration.id, + registration, + profile, + subjectIds: orderedSubjectIds, + signature: orderedSubjectIds.join('|'), + scopeKey: `${mixingScope}:${boundary}`, + centerId: null + }; + }); + + const activeCenters = db.testCenters.filter(center => center.status === 'active').map(center => { + const allRooms = db.testRooms.filter(room => room.centerId === center.id && room.status === 'active'); + const regularRooms = allRooms.filter(room => room.roomType !== 'spare'); + const rooms = regularRooms.length ? regularRooms : allRooms; + if (!regularRooms.length && allRooms.length) warn(`${center.name} 没有普通启用考场,本次将使用备用考场`); + return { ...center, rooms, allRooms }; + }).filter(center => center.rooms.length); + if (!activeCenters.length) throw arrangementError('没有可用的启用考点和考场'); + + const remaining = new Map(activeCenters.map(center => [center.id, new Map( + exam.subjects.map(subject => [subject.id, center.rooms.reduce((sum, room) => sum + Number(room.capacity), 0)]) + )])); + const assignedCounts = new Map(activeCenters.map(center => [center.id, 0])); + const affinity = new Map(); + const groups = new Map(); + for (const candidate of candidates) { + const key = `${candidate.scopeKey}:${candidate.signature}`; + const group = groups.get(key) || []; + group.push(candidate); + groups.set(key, group); + } + const orderedGroups = [...groups.entries()].sort((left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0])); + + for (const [, group] of orderedGroups) { + group.sort((left, right) => stableCompare(seed, left, right)); + for (const candidate of group) { + const eligible = activeCenters.filter(center => candidate.subjectIds.every(subjectId => (remaining.get(center.id).get(subjectId) || 0) > 0)); + if (!eligible.length) throw arrangementError(`${candidate.profile.name} 的全部报考科目无法在同一考点容纳;请增加考场容量或缩小本次报名范围`); + const local = eligible.filter(center => centerMatchesProfile(center, candidate.profile, mixingScope)); + if (!local.length) warn(`${candidate.profile.name} 所属范围内没有足够考点,已跨范围使用可用考点`); + const pool = local.length ? local : eligible; + const selected = [...pool].sort((left, right) => { + const leftAffinity = affinity.get(`${left.id}:${candidate.signature}`) || 0; + const rightAffinity = affinity.get(`${right.id}:${candidate.signature}`) || 0; + const leftCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(left.id).get(id))); + const rightCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(right.id).get(id))); + const leftScore = leftAffinity * 100000 + localityScore(left, candidate.profile) + leftCapacity - (assignedCounts.get(left.id) || 0); + const rightScore = rightAffinity * 100000 + localityScore(right, candidate.profile) + rightCapacity - (assignedCounts.get(right.id) || 0); + return rightScore - leftScore || left.code.localeCompare(right.code); + })[0]; + candidate.centerId = selected.id; + candidate.subjectIds.forEach(subjectId => remaining.get(selected.id).set(subjectId, remaining.get(selected.id).get(subjectId) - 1)); + assignedCounts.set(selected.id, (assignedCounts.get(selected.id) || 0) + 1); + affinity.set(`${selected.id}:${candidate.signature}`, (affinity.get(`${selected.id}:${candidate.signature}`) || 0) + 1); + } + } + + const assignments = new Map(candidates.map(candidate => [candidate.id, []])); + const roomUses = []; + for (const subject of exam.subjects) { + for (const center of activeCenters) { + const subjectCandidates = candidates.filter(candidate => candidate.centerId === center.id && candidate.subjectIds.includes(subject.id)); + if (!subjectCandidates.length) continue; + subjectCandidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey) + || left.signature.localeCompare(right.signature) || stableCompare(seed, left, right)); + let roomIndex = 0; + let seatIndex = 0; + let currentScopeKey = ''; + const rooms = [...center.rooms].sort((left, right) => left.code.localeCompare(right.code) || left.id.localeCompare(right.id)); + for (const candidate of subjectCandidates) { + if (currentScopeKey && candidate.scopeKey !== currentScopeKey && seatIndex > 0) { roomIndex += 1; seatIndex = 0; } + currentScopeKey = candidate.scopeKey; + while (roomIndex < rooms.length && seatIndex >= Number(rooms[roomIndex].capacity)) { roomIndex += 1; seatIndex = 0; } + const room = rooms[roomIndex]; + if (!room) throw arrangementError(`${center.name} 在“${subject.name}”科目下按${admissionMixingScopes.find(item => item.code === mixingScope)?.name || '当前范围'}隔离后考场不足;请增加考场或扩大混编范围`); + const seatNumber = Number(room.seatStart || 1) + seatIndex; + const assignment = { + registrationId: candidate.id, + subjectId: subject.id, + centerId: center.id, + centerName: center.name, + roomId: room.id, + roomName: room.name, + roomCode: room.code, + building: room.building || '', + floor: room.floor || '', + examRoomCode: '', + seat: String(seatNumber).padStart(2, '0'), + subjectSignature: candidate.signature + }; + assignments.get(candidate.id).push(assignment); + roomUses.push({ centerCode: center.code, roomCode: room.code, roomId: room.id }); + seatIndex += 1; + } + } + } + + const examRoomCodes = new Map([...new Map(roomUses.map(item => [item.roomId, item])).values()] + .sort((left, right) => left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode) || left.roomId.localeCompare(right.roomId)) + .map((item, index) => [item.roomId, String(index + 1).padStart(3, '0')])); + for (const subjectAssignments of assignments.values()) { + subjectAssignments.sort((left, right) => subjectOrder.get(left.subjectId) - subjectOrder.get(right.subjectId)); + subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(item.roomId); }); + } + + const sequenceCounters = new Map(); + const numbers = new Set(); + const cards = candidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey) || stableCompare(seed, left, right)).map(candidate => { + const center = activeCenters.find(item => item.id === candidate.centerId); + const primary = assignments.get(candidate.id)[0]; + const candidateSchool = db.schools.find(item => item.id === candidate.profile.schoolId); + const centerSchool = db.schools.find(item => item.id === center.schoolId); + const sources = { + district_code: normalizedCode(candidate.profile.districtCode || center.districtCode), + center_school_code: normalizedCode(centerSchool?.code), + candidate_school_code: normalizedCode(candidateSchool?.code), + exam_room_code: primary.examRoomCode, + seat: primary.seat + }; + const counterKey = sequenceGroup(rule, sources); + const sequence = (sequenceCounters.get(counterKey) || 0) + 1; + sequenceCounters.set(counterKey, sequence); + const number = formatAdmissionNumber(rule, sources, sequence); + if (numbers.has(number)) throw arrangementError(`规则“${rule.name}”生成了重复准考证号 ${number},请检查规则组成`); + numbers.add(number); + return { + registrationId: candidate.id, + number, + centerId: center.id, + testCenter: center.name, + centerCode: center.code || '', + centerAddress: [center.provinceName, center.cityName, center.districtName, center.address].filter(Boolean).join(' '), + generatedAt: options.generatedAt, + assignments: assignments.get(candidate.id) + }; + }); + + const usedCenters = new Set(cards.map(card => card.centerId)); + const sameSchoolCenterCount = candidates.filter(candidate => activeCenters.find(center => center.id === candidate.centerId)?.schoolId === candidate.profile.schoolId).length; + const summary = { + candidateCount: candidates.length, + centerCount: usedCenters.size, + subjectAssignmentCount: cards.reduce((sum, card) => sum + card.assignments.length, 0), + subjectCombinationCount: new Set(candidates.map(candidate => candidate.signature)).size, + sameSchoolCenterCount, + sameSchoolCenterRate: Number((sameSchoolCenterCount * 100 / candidates.length).toFixed(1)), + reservedSpareRooms: activeCenters.reduce((sum, center) => sum + center.allRooms.filter(room => room.roomType === 'spare' && !center.rooms.includes(room)).length, 0) + }; + return { exam, rule, mixingScope, seed, warnings, summary, cards }; +} diff --git a/src/services/system-notifications.mjs b/src/services/system-notifications.mjs new file mode 100644 index 0000000..be6a46b --- /dev/null +++ b/src/services/system-notifications.mjs @@ -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, + `

${h(schoolName(record.schoolId))}招生计划已经审核通过。

    ${(record.payload?.categories || []).map(category => `
  • ${h(category.name)}:${Number(category.quota || 0)} 人
  • `).join('')}
` + )); + 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, + `

${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。

` + )); + 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, + `

${h(examName(record.examId))}第 ${record.round} 轮录取工作已经完成,共 ${record.rows.length} 名考生正式录取。

` + )); + 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, + `

${h(examName(record.examId))}录取工作已经完成,请在招生录取公示中查询脱敏结果。

` + )); + 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, + `

系统已汇总 ${Number(record.payload?.rows?.length || 0)} 个学校招生类别的录取分数线。

` + )); + 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, + `

${h(summary)}

${h(decision)}

  • 正式录取:${Number(stats.finalCount || 0)} 人
  • 已报到:${Number(stats.reportedCount || 0)} 人
  • 未报到:${Number(stats.notReportedCount || 0)} 人
  • 计划缺额:${Number(stats.reportingGap || 0)} 人

${h(record.payload?.approvalNote || '')}

`); + }); + return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports] + .sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt)); +} diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs new file mode 100644 index 0000000..4591266 --- /dev/null +++ b/src/services/volunteer-admission.mjs @@ -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'; diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..8cc66bd --- /dev/null +++ b/styles.css @@ -0,0 +1,1110 @@ +:root { + --ink: #14203d; + --navy: #132451; + --navy-soft: #21386e; + --blue: #315fba; + --red: #c8473d; + --jade: #268466; + --amber: #d79624; + --paper: #f5f7fb; + --white: #ffffff; + --line: #e1e6ef; + --muted: #788197; + --shadow: 0 18px 50px rgba(25, 41, 81, .09); + --radius: 16px; +} + +/* School organization and Excel operations */ +.excel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 22px; margin-bottom: 12px; padding: 14px 18px; border: 1px solid #dbe5ea; border-left: 4px solid #2c7b82; border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgba(23,63,96,.05); } +.excel-toolbar > span strong, .excel-toolbar > span small { display: block; } +.excel-toolbar > span strong { color: #173f60; } +.excel-toolbar > span small { margin-top: 3px; color: #788b97; font-size: 11px; } +.excel-toolbar > div { display: flex; flex-wrap: wrap; gap: 8px; } +.school-org-banner { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 22px; padding: 28px 30px; color: #fff; background: linear-gradient(118deg, #142f4c, #1c526b 70%, #2a7c7c); border-radius: 12px; box-shadow: 0 18px 42px rgba(20,47,76,.16); } +.school-org-banner > div > span { color: #8ed8d4; font-size: 11px; font-weight: 800; letter-spacing: .16em; } +.school-org-banner h2 { margin: 6px 0; font-family: "STKaiti", "KaiTi", serif; font-size: 28px; } +.school-org-banner p { max-width: 620px; margin: 0; color: #c7dce4; line-height: 1.65; } +.school-org-banner dl { display: flex; gap: 26px; margin: 0; } +.school-org-banner dl div { min-width: 74px; padding-left: 18px; border-left: 1px solid rgba(255,255,255,.22); } +.school-org-banner dt { color: #bdd3dc; font-size: 11px; } +.school-org-banner dd { margin: 4px 0 0; font: 700 27px Consolas, monospace; } +.org-class-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 24px; } +.org-class-card { padding: 0; overflow: hidden; } +.org-class-card.inactive { opacity: .72; } +.org-class-card > header { display: flex; align-items: flex-start; justify-content: space-between; padding: 22px 24px 14px; } +.org-class-card > header span:first-child { color: #2c7b82; font-size: 11px; font-weight: 800; letter-spacing: .1em; } +.org-class-card h2 { margin: 4px 0 0; color: #173b55; font-family: "STKaiti", "KaiTi", serif; font-size: 23px; } +.org-class-metrics { display: flex; gap: 1px; background: #dfe7eb; } +.org-class-metrics span { flex: 1; padding: 13px 24px; background: #f7fafb; } +.org-class-metrics strong, .org-class-metrics small { display: block; } +.org-class-metrics strong { color: #1b5265; font-size: 20px; } +.org-class-metrics small { margin-top: 2px; color: #80909a; } +.org-class-card > section { padding: 18px 24px; } +.org-admin-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.org-admin-head > strong { color: #365266; font-size: 13px; } +.org-admin-row { width: 100%; display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 9px 0; border: 0; border-top: 1px solid #e7edef; background: transparent; text-align: left; } +.org-admin-row > span:nth-child(2) strong, .org-admin-row > span:nth-child(2) small { display: block; } +.org-admin-row > span:nth-child(2) small { margin-top: 2px; color: #81909a; font-size: 10px; } +.org-empty { margin: 10px 0 0; color: #8a98a1; font-size: 12px; } +.org-class-card > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 13px 24px; border-top: 1px solid #e5ecef; background: #f8fafb; } + +/* School account batch issuance */ +.batch-quota-panel { overflow: hidden; padding: 0; } +.batch-quota-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 28px; padding: 30px 32px 24px; color: #fff; background: linear-gradient(120deg, #102944, #173f60 72%, #276e82); } +.batch-quota-head span, .ledger-title span { display: block; margin-bottom: 8px; color: #77d5d3; font-size: 11px; font-weight: 800; letter-spacing: .16em; } +.batch-quota-head h2, .ledger-title h2 { margin: 0; font-family: "STKaiti", "KaiTi", serif; font-size: 27px; } +.batch-quota-head p { max-width: 650px; margin: 8px 0 0; color: #c5d7e4; line-height: 1.7; } +.batch-quota-head > div:last-child { min-width: 118px; padding-left: 24px; border-left: 1px solid rgba(255,255,255,.2); } +.batch-quota-head > div:last-child small, .batch-quota-head > div:last-child span { margin: 0; color: #b9d0dd; font-size: 12px; letter-spacing: 0; } +.batch-quota-head > div:last-child strong { display: block; margin: 3px 0; color: #fff; font-family: "STKaiti", "KaiTi", serif; font-size: 36px; } +.quota-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; padding: 1px; background: #dfe7ec; } +.quota-grid > label { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 88px; padding: 18px 28px; background: #fff; } +.quota-grid > label > span:first-child strong, .quota-grid > label > span:first-child small { display: block; } +.quota-grid > label > span:first-child strong { color: #16334c; font-size: 16px; } +.quota-grid > label > span:first-child small { margin-top: 4px; color: #81909c; } +.quota-input { display: flex; align-items: center; overflow: hidden; border: 1px solid #ccd8df; border-radius: 7px; background: #f7fafb; } +.quota-input input { width: 82px; padding: 10px 8px 10px 14px; border: 0; outline: 0; color: #14334e; background: transparent; font: 700 18px "Microsoft YaHei UI", sans-serif; text-align: right; } +.quota-input em { padding: 0 13px 0 4px; color: #6f818e; font-size: 12px; font-style: normal; } +.batch-submit-bar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 20px 28px; border-top: 1px solid #e3eaee; background: #f7fafb; } +.batch-submit-bar p { margin: 0; } +.batch-submit-bar p strong, .batch-submit-bar p span { display: block; } +.batch-submit-bar p strong { color: #23445c; } +.batch-submit-bar p span { margin-top: 3px; color: #7b8c98; font-size: 12px; } +.account-batch-ledger { margin-top: 34px; } +.ledger-title { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 16px; } +.ledger-title span { color: #2b7b83; } +.ledger-title p { max-width: 440px; margin: 0; color: #778895; font-size: 13px; text-align: right; } +.account-batch-card { margin-bottom: 18px; padding: 0; overflow: hidden; border-left: 4px solid #d7a744; } +.account-batch-card.approved { border-left-color: #2e8c74; } +.account-batch-card.rejected { border-left-color: #b85d58; } +.account-batch-card > header { display: flex; justify-content: space-between; gap: 20px; padding: 24px 28px 18px; } +.account-batch-card > header span.mono { color: #82929d; font-size: 11px; } +.account-batch-card > header h2 { margin: 5px 0; color: #173750; font-family: "STKaiti", "KaiTi", serif; font-size: 21px; } +.account-batch-card > header p { margin: 0; color: #7c8c97; font-size: 12px; } +.batch-quota-summary { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 28px 18px; } +.batch-quota-summary span { display: inline-flex; gap: 10px; padding: 8px 11px; border: 1px solid #dce5ea; border-radius: 5px; background: #f7fafb; color: #29485e; } +.batch-quota-summary em { color: #28757c; font-style: normal; font-weight: 800; } +.batch-flow-line { display: grid; grid-template-columns: 110px 1fr auto; gap: 14px; align-items: center; padding: 14px 28px; border-top: 1px solid #e6ecef; background: #f8fafb; } +.batch-flow-line > span { color: #7d8d99; font-size: 12px; } +.batch-flow-line > strong { color: #203f56; } +.batch-flow-line > small { color: #657b89; } +.batch-review-note { display: flex; gap: 18px; padding: 14px 28px; border-top: 1px solid #e6ecef; color: #617783; font-size: 13px; } +.batch-review-note strong { color: #2e4d62; } +.credential-sheet { border-top: 1px solid #d9e5e6; background: #f1f7f6; } +.credential-sheet > header { display: flex; align-items: center; justify-content: space-between; padding: 18px 28px; } +.credential-sheet > header strong, .credential-sheet > header span { display: block; } +.credential-sheet > header strong { color: #1e594f; font-family: "STKaiti", "KaiTi", serif; font-size: 18px; } +.credential-sheet > header span { margin-top: 3px; color: #718985; font-size: 12px; } +.credential-sheet table { background: #fff; } +.credential-password { color: #8b5222; font-weight: 800; } +.flow-batch-snapshot { margin-bottom: 20px; padding: 20px; border: 1px solid #dbe7e8; border-radius: 9px; background: #f3f8f8; } +.flow-batch-snapshot header { display: flex; align-items: flex-end; justify-content: space-between; } +.flow-batch-snapshot header span { color: #378286; font-size: 11px; font-weight: 800; letter-spacing: .12em; } +.flow-batch-snapshot h3 { margin: 4px 0 0; color: #183e53; } +.flow-batch-snapshot header b { color: #2f7778; } +.flow-batch-snapshot > div { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 10px; } +.flow-batch-snapshot > div span { padding: 8px 11px; border: 1px solid #d2e1e2; border-radius: 5px; background: #fff; } +.flow-batch-snapshot > div strong, .flow-batch-snapshot > div small { display: block; } +.flow-batch-snapshot > div small, .flow-batch-snapshot > p { color: #70858b; font-size: 12px; } +.flow-batch-snapshot > p { margin: 0; } + +@media (max-width: 760px) { + .excel-toolbar, .school-org-banner { align-items: stretch; flex-direction: column; } + .school-org-banner dl { justify-content: space-between; } + .org-class-grid { grid-template-columns: 1fr; } + .batch-quota-head, .batch-submit-bar, .ledger-title { align-items: stretch; flex-direction: column; } + .batch-quota-head > div:last-child { padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.2); border-left: 0; } + .quota-grid { grid-template-columns: 1fr; } + .batch-flow-line { grid-template-columns: 1fr; gap: 4px; } + .ledger-title p { text-align: left; } +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; color: var(--ink); background: var(--paper); font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; -webkit-font-smoothing: antialiased; } +button, input, select, textarea { color: inherit; font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +a { color: inherit; text-decoration: none; } +svg { width: 1.25em; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(49, 95, 186, .25); outline-offset: 2px; } +button:disabled { cursor: not-allowed; opacity: .5; } +.boot-screen { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--navy); } +.boot-screen strong { font-family: "STKaiti", "KaiTi", serif; font-size: 32px; letter-spacing: 8px; } +.boot-screen span:last-child { color: var(--muted); font-size: 12px; } + +.brand { display: inline-flex; align-items: center; gap: 12px; } +.brand > span:last-child { display: grid; } +.brand strong { font-family: "STKaiti", "KaiTi", serif; font-size: 25px; letter-spacing: 5px; line-height: 1; } +.brand small { margin-top: 5px; color: #7e8db5; font-family: Consolas, monospace; font-size: 8px; letter-spacing: 1.8px; } +.brand-symbol { position: relative; width: 36px; height: 36px; display: inline-grid; place-items: center; flex: 0 0 auto; border: 1px solid currentColor; border-radius: 50%; transform: rotate(-9deg); } +.brand-symbol i { position: absolute; width: 21px; height: 2px; border-radius: 3px; background: currentColor; } +.brand-symbol i:first-child { width: 13px; transform: translateY(-6px); } +.brand-symbol i:last-child { width: 9px; transform: translateY(6px); } +.solid-button, .ghost-button, .text-button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 17px; border-radius: 9px; font-size: 12px; font-weight: 700; transition: transform .18s ease, border-color .18s ease, background .18s ease; } +.solid-button { border: 1px solid var(--navy); color: #fff; background: var(--navy); box-shadow: 0 8px 20px rgba(19, 36, 81, .16); } +.solid-button:hover:not(:disabled) { transform: translateY(-1px); background: var(--navy-soft); } +.solid-button svg, .ghost-button svg { width: 15px; } +.ghost-button { border: 1px solid var(--line); color: #525d75; background: #fff; } +.ghost-button:hover { border-color: #b9c1d1; color: var(--navy); } +.text-button { border: 0; color: var(--navy); background: transparent; } +.large { min-height: 48px; padding: 0 21px; } +.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-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, .status-hidden { color: #6d7589; background: #eef0f4; } +.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; } + +/* Public site */ +.public-header { position: sticky; top: 0; z-index: 30; height: 76px; border-bottom: 1px solid rgba(225, 230, 239, .85); background: rgba(255, 255, 255, .9); backdrop-filter: blur(18px); } +.public-nav { width: min(1180px, calc(100% - 48px)); height: 100%; display: flex; align-items: center; gap: 34px; margin: auto; } +.public-nav nav { display: flex; gap: 29px; margin-left: 35px; } +.public-nav nav a { position: relative; color: #596278; font-size: 12px; font-weight: 600; } +.public-nav nav a::after { content: ""; position: absolute; left: 0; right: 100%; bottom: -9px; height: 2px; background: var(--red); transition: right .18s; } +.public-nav nav a:hover { color: var(--navy); } +.public-nav nav a:hover::after { right: 0; } +.nav-actions { display: flex; align-items: center; gap: 4px; margin-left: auto; } +.mobile-menu { display: none; width: 39px; height: 39px; place-items: center; border: 1px solid var(--line); border-radius: 9px; background: #fff; } +.public-main { overflow: hidden; } +.hero { position: relative; min-height: 660px; display: grid; align-items: center; background-color: #f7f8fb; background-image: linear-gradient(rgba(19,36,81,.025) 1px, transparent 1px), linear-gradient(90deg,rgba(19,36,81,.025) 1px,transparent 1px); background-size: 28px 28px; } +.hero::before { content: ""; position: absolute; width: 600px; height: 600px; right: -340px; top: -250px; border: 1px solid rgba(19,36,81,.07); border-radius: 50%; box-shadow: 0 0 0 80px rgba(19,36,81,.02), 0 0 0 160px rgba(19,36,81,.015); } +.hero-grid { width: min(1180px, calc(100% - 48px)); display: grid; grid-template-columns: minmax(0,1fr) minmax(470px,.88fr); align-items: center; gap: 75px; margin: auto; padding: 70px 0 90px; } +.notice-ticker { width: fit-content; max-width: 100%; display: flex; align-items: center; gap: 9px; margin-bottom: 39px; padding: 6px 10px 6px 6px; border: 1px solid #e0e5ef; border-radius: 7px; background: #fff; box-shadow: 0 6px 20px rgba(22, 38, 77, .04); } +.notice-ticker span { padding: 3px 7px; border-radius: 4px; color: #fff; background: var(--red); font-size: 9px; } +.notice-ticker button { max-width: 330px; overflow: hidden; border: 0; color: #5e687f; background: transparent; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.hero-copy h1 { margin: 15px 0 21px; font-family: "STKaiti", "KaiTi", serif; font-size: clamp(46px, 5vw, 68px); font-weight: 400; line-height: 1.18; letter-spacing: 1px; } +.hero-copy h1 em { color: var(--navy); font-style: normal; } +.hero-copy h1 em::after { content: ""; width: 54px; height: 5px; display: inline-block; margin-left: 14px; border-radius: 5px; background: var(--red); vertical-align: middle; transform: rotate(-3deg); } +.hero-lead { max-width: 540px; margin: 0; color: #697389; font-size: 14px; line-height: 1.9; } +.hero-actions { display: flex; gap: 10px; margin-top: 30px; } +.hero-stats { display: flex; gap: 40px; margin-top: 45px; } +.hero-stats div { display: grid; gap: 3px; } +.hero-stats strong { font-family: Georgia, serif; font-size: 26px; font-weight: 500; } +.hero-stats span { color: #8790a4; font-size: 10px; } +.hero-ticket { position: relative; display: grid; grid-template-columns: 1fr 105px; border-radius: 18px; color: #fff; background: var(--navy); box-shadow: 0 30px 70px rgba(19,36,81,.25); transform: rotate(1.7deg); } +.hero-ticket::before, .hero-ticket::after { content: ""; position: absolute; z-index: 2; right: 93px; width: 24px; height: 24px; border-radius: 50%; background: var(--paper); } +.hero-ticket::before { top: -12px; }.hero-ticket::after { bottom: -12px; } +.ticket-main { position: relative; padding: 31px 31px 28px; overflow: hidden; } +.ticket-main::after { content: "准"; position: absolute; right: -18px; bottom: -66px; color: rgba(255,255,255,.035); font-family: "STKaiti",serif; font-size: 210px; } +.ticket-main header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; } +.ticket-main header .status { color: #f6b1aa; background: rgba(200,71,61,.18); } +.ticket-main header small { color: #95a2c6; font-family: Consolas,monospace; font-size: 9px; } +.ticket-main > p { margin: 37px 0 7px; color: #7383b2; font-family: Consolas,monospace; font-size: 9px; letter-spacing: 1.7px; } +.ticket-main h2 { position: relative; z-index: 1; margin: 0 0 24px; font-family: "STKaiti",serif; font-size: 26px; font-weight: 400; letter-spacing: 1px; } +.ticket-main dl { position: relative; z-index: 1; display: grid; gap: 12px; margin: 0; } +.ticket-main dl div { display: grid; grid-template-columns: 70px 1fr; align-items: baseline; gap: 10px; } +.ticket-main dt { color: #8492ba; font-size: 9px; } +.ticket-main dd { margin: 0; color: #dce2f3; font-size: 11px; } +.subject-chips { position: relative; z-index: 1; display: flex; flex-wrap: wrap; gap: 5px; margin-top: 24px; } +.subject-chips span { padding: 5px 8px; border: 1px solid rgba(255,255,255,.13); border-radius: 5px; color: #bac4e2; font-size: 9px; } +.ticket-stub { position: relative; display: flex; align-items: center; flex-direction: column; justify-content: center; gap: 7px; border-left: 1px dashed rgba(255,255,255,.2); text-align: center; } +.ticket-stub span { color: #8290b9; font-size: 8px; }.ticket-stub strong { font-family: Georgia,serif; font-size: 30px; font-weight: 400; } +.ticket-stub i { width: 1px; height: 30px; background: rgba(255,255,255,.16); } +.ticket-stub button { border: 0; color: #fff; background: transparent; font-size: 10px; writing-mode: vertical-rl; letter-spacing: 2px; } +.content-section { width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 90px 0; } +.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 35px; margin-bottom: 35px; } +.section-heading h2 { margin: 7px 0 0; font-family: "STKaiti",serif; font-size: 35px; font-weight: 400; } +.section-heading > p { max-width: 450px; margin: 0; color: #7d8698; font-size: 12px; line-height: 1.8; text-align: right; } +.notice-layout { display: grid; grid-template-columns: .85fr 1.3fr; gap: 18px; } +.featured-notice { min-height: 310px; display: flex; flex-direction: column; padding: 30px; border-radius: var(--radius); color: #fff; background: var(--navy); box-shadow: var(--shadow); } +.featured-notice > span { width: fit-content; padding: 4px 8px; border-radius: 4px; color: #ffd0cb; background: rgba(200,71,61,.22); font-size: 9px; } +.featured-notice h3 { margin: 28px 0 13px; font-family: "STKaiti",serif; font-size: 25px; font-weight: 400; line-height: 1.5; } +.featured-notice p { margin: 0; color: #aeb9d6; font-size: 11px; line-height: 1.9; } +.featured-notice footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.1); } +.featured-notice time { color: #8593ba; font-size: 9px; }.featured-notice button { display: flex; align-items: center; gap: 6px; border: 0; color: #fff; background: transparent; font-size: 10px; }.featured-notice button svg { width: 14px; } +.notice-list { border: 1px solid var(--line); border-radius: var(--radius); background: #fff; box-shadow: 0 9px 30px rgba(23,39,76,.045); overflow: hidden; } +.notice-row { width: 100%; min-height: 77px; display: grid; grid-template-columns: 75px 1fr 22px; align-items: center; gap: 12px; padding: 12px 20px; border: 0; border-bottom: 1px solid #edf0f5; color: var(--ink); background: #fff; text-align: left; transition: background .15s; } +.notice-row:last-child { border-bottom: 0; }.notice-row:hover { background: #fafbfc; }.notice-row time { color: #8b93a5; font-size: 9px; } +.notice-row > span { min-width: 0; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 4px 9px; }.notice-row em { padding: 3px 5px; border-radius: 4px; color: var(--red); background: #fbe9e7; font-size: 8px; font-style: normal; }.notice-row strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.notice-row small { grid-column: 1/-1; overflow: hidden; color: #8c94a5; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.notice-row > svg { color: #9da4b3; } +.exam-section { padding-top: 40px; } +.public-exam-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 17px; } +.public-exam-card { padding: 24px; border: 1px solid var(--line); border-radius: var(--radius); background: #fff; transition: transform .2s, box-shadow .2s; } +.public-exam-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); }.public-exam-card header { display: flex; justify-content: space-between; } +.public-exam-card h3 { margin: 19px 0 7px; font-family: "STKaiti",serif; font-size: 22px; font-weight: 400; }.public-exam-card > p { min-height: 42px; margin: 0; color: #7d8597; font-size: 10px; line-height: 1.8; } +.exam-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; }.exam-meta span { display: grid; gap: 4px; padding: 10px; border-radius: 7px; background: #f7f8fb; color: #657087; font-size: 9px; }.exam-meta b { color: #9ba2b0; font-size: 8px; font-weight: 500; } +.exam-meta span:last-child:nth-child(3) { grid-column:1/-1; } +.public-exam-card footer { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--line); }.public-exam-card footer > span { color: #8b93a4; font-size: 9px; }.public-exam-card footer button { display: flex; align-items: center; gap: 6px; border: 0; color: var(--navy); background: transparent; font-size: 10px; font-weight: 700; }.public-exam-card footer svg { width: 14px; } +.service-flow { padding: 85px max(24px,calc((100% - 1180px)/2)); color: #fff; background: var(--navy); } +.section-heading.light .overline { color: #7182b2; }.section-heading.light h2 { color: #fff; }.section-heading.light > p { color: #95a2c5; } +.flow-track { position: relative; display: grid; grid-template-columns: repeat(5,1fr); gap: 25px; }.flow-track::before { content:""; position:absolute; top:20px; left:20px; right:20px; height:1px; background:rgba(255,255,255,.13); }.flow-track article { position: relative; }.flow-track article span { width: 41px; height: 41px; display: grid; place-items: center; border: 1px solid #5d6e9e; border-radius: 50%; color: #fff; background: var(--navy); font-family: Georgia,serif; font-size: 11px; }.flow-track article:nth-child(3) span { border-color: var(--red); background: var(--red); box-shadow: 0 0 0 7px rgba(200,71,61,.12); }.flow-track h3 { margin: 19px 0 7px; font-size: 12px; }.flow-track p { margin: 0; color: #8e9abe; font-size: 9px; line-height: 1.7; } +.public-footer { display: flex; align-items: flex-end; justify-content: space-between; padding: 38px max(24px,calc((100% - 1180px)/2)); border-top: 1px solid var(--line); background: #fff; }.public-footer > div { display: grid; gap: 13px; }.public-footer p,.public-footer > span { margin:0; color:#858d9e; font-size:9px; }.public-footer .public-contact-detail { color:#9aa1af; } + +/* Authentication */ +.auth-page { min-height: 100vh; display: grid; grid-template-columns: .9fr 1.1fr; background: #fff; } +.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; } +.panel { border:1px solid var(--line); border-radius:var(--radius); background:#fff; box-shadow:0 8px 28px rgba(24,40,79,.045); }.panel-title { min-height:58px; display:flex; align-items:center; justify-content:space-between; padding:0 19px; border-bottom:1px solid var(--line); }.panel-title h2 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }.panel-title > span,.panel-title > button { border:0; color:#8c94a5; background:transparent; font-size:9px; }.loading-panel { min-height:360px; display:grid; place-content:center; justify-items:center; gap:12px; color:#858d9e; }.loading-panel i { width:28px; height:28px; border:2px solid #dce1eb; border-top-color:var(--navy); border-radius:50%; animation:spin .7s linear infinite; }.loading-panel span { font-size:10px; }@keyframes spin{to{transform:rotate(360deg)}} + +/* Candidate */ +.candidate-welcome { position:relative; min-height:165px; display:flex; align-items:center; justify-content:space-between; padding:28px 32px; border-radius:var(--radius); color:#fff; background:var(--navy); overflow:hidden; }.candidate-welcome::after { content:""; position:absolute; width:360px; height:360px; right:-220px; top:-160px; border:1px solid rgba(255,255,255,.08); border-radius:50%; box-shadow:0 0 0 55px rgba(255,255,255,.025),0 0 0 110px rgba(255,255,255,.018); }.candidate-welcome span { color:#8998c1; font-size:9px; }.candidate-welcome h2 { margin:6px 0; font-family:"STKaiti"; font-size:26px; font-weight:400; }.candidate-welcome p { margin:0; color:#abb6d4; font-size:10px; }.welcome-seal { z-index:1; width:70px; height:70px; display:grid; place-items:center; border:3px double #eb7770; border-radius:50%; color:#ef8b84; font-family:"STKaiti"; font-size:20px; line-height:1; text-align:center; transform:rotate(-7deg); }.summary-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:16px 0; }.summary-grid article { display:grid; grid-template-columns:38px 1fr auto; align-items:center; gap:10px; padding:17px; border:1px solid var(--line); border-radius:11px; background:#fff; }.summary-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fc; }.summary-icon svg { width:17px; }.summary-grid article div { display:grid; gap:2px; }.summary-grid article small { color:#8d95a5; font-size:8px; }.summary-grid article strong { font-size:13px; }.summary-grid article > button { border:0; color:var(--blue); background:transparent; font-size:8px; }.candidate-grid { display:grid; grid-template-columns:1.35fr 1fr; gap:16px; }.candidate-progress { display:grid; grid-template-columns:repeat(5,1fr); padding:28px 20px; }.progress-step { position:relative; display:grid; justify-items:center; gap:8px; text-align:center; }.progress-step::before { content:""; position:absolute; top:14px; right:50%; left:-50%; height:2px; background:#e5e9f0; }.progress-step:first-child::before { display:none; }.progress-step i { z-index:1; width:29px; height:29px; display:grid; place-items:center; border:2px solid #dfe3eb; border-radius:50%; color:#9da4b2; background:#fff; font-size:9px; font-style:normal; }.progress-step.done::before,.progress-step.done i { border-color:var(--navy); color:#fff; background:var(--navy); }.progress-step.current i { border-color:var(--red); color:#fff; background:var(--red); box-shadow:0 0 0 5px rgba(200,71,61,.1); }.progress-step div { display:grid; gap:3px; }.progress-step strong { font-size:9px; }.progress-step small { color:#9198a8; font-size:8px; }.compact-notices > button { width:100%; display:grid; grid-template-columns:70px 1fr; gap:8px; padding:15px 18px; border:0; border-bottom:1px solid #edf0f5; color:var(--ink); background:#fff; text-align:left; }.compact-notices > button:last-child { border-bottom:0; }.compact-notices time { color:#9299a9; font-size:8px; }.compact-notices span { overflow:hidden; font-size:9px; text-overflow:ellipsis; white-space:nowrap; } +.form-panel { padding:25px; }.form-section-title { display:flex; align-items:center; gap:12px; margin:3px 0 18px; }.form-section-title:not(:first-child) { margin-top:30px; padding-top:24px; border-top:1px solid var(--line); }.form-section-title > span { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--navy); font-family:Georgia,serif; font-size:9px; }.form-section-title h2 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }.form-section-title p { margin:3px 0 0; color:#8d95a5; font-size:8px; }.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:15px; }.profile-form input,.profile-form select { min-height:42px; }.review-note { margin-top:20px; padding:14px; border-radius:8px; font-size:9px; }.review-note.approved { color:#236f56; background:#e9f5f0; }.review-note.rejected { color:#9a3e38; background:#fcebea; }.review-note strong { display:block; margin-bottom:4px; }.review-note p { margin:0; }.form-actions { display:flex; align-items:center; justify-content:space-between; margin-top:25px; padding-top:18px; border-top:1px solid var(--line); }.form-actions p { margin:0; color:#8d95a5; font-size:9px; } +.exam-application-list { display:grid; gap:17px; }.apply-card { border:1px solid var(--line); border-radius:var(--radius); background:#fff; box-shadow:0 8px 28px rgba(24,40,79,.045); overflow:hidden; }.apply-card > header { display:flex; align-items:center; justify-content:space-between; padding:16px 21px; border-bottom:1px solid var(--line); }.apply-card > header > div { display:flex; align-items:center; gap:9px; }.apply-card > header small { color:#8f96a6; font-size:9px; }.apply-card-main { display:grid; grid-template-columns:.8fr 1.2fr; }.apply-copy { padding:26px; border-right:1px solid var(--line); background:#fafbfc; }.apply-copy h2 { margin:0 0 8px; font-family:"STKaiti"; font-size:23px; font-weight:400; }.apply-copy > p { min-height:35px; margin:0; color:#828a9b; font-size:9px; line-height:1.8; }.apply-copy dl { display:grid; gap:12px; margin:23px 0 0; }.apply-copy dl div { display:grid; gap:4px; }.apply-copy dt { color:#9ba2b0; font-size:8px; }.apply-copy dd { margin:0; color:#596379; font-size:9px; }.subject-selector { padding:22px; }.subject-title { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }.subject-title strong { font-size:11px; }.subject-title span { color:#8e96a7; font-size:8px; }.subject-options { display:grid; grid-template-columns:repeat(2,1fr); gap:7px; }.subject-options label { cursor:pointer; }.subject-options input { position:absolute; opacity:0; }.subject-options label > span { display:grid; grid-template-columns:31px 1fr auto; align-items:center; gap:0 9px; padding:10px; border:1px solid var(--line); border-radius:8px; transition:.15s; }.subject-options label > span > i { grid-row:1/3; width:31px; height:31px; display:grid; place-items:center; border-radius:7px; color:#52658f; background:#e9edf6; font-size:10px; font-style:normal; font-weight:700; }.subject-options b { font-size:9px; }.subject-options small { color:#9299a9; font-size:7px; }.subject-options em { grid-row:1/3; grid-column:3; color:#727c90; font-size:8px; font-style:normal; }.subject-options input:checked + span { border-color:#7f91bd; background:#f2f5fb; box-shadow:inset 0 0 0 1px #7f91bd; }.subject-options input:checked + span > i { color:#fff; background:var(--navy); }.subject-total { display:flex; align-items:center; justify-content:space-between; margin:16px 0 11px; padding-top:12px; border-top:1px solid var(--line); color:#7d8597; font-size:9px; }.subject-total b { color:var(--red); }.subject-total strong { color:var(--ink); font-size:11px; }.subject-selector > .solid-button { width:100%; }.registered-banner { display:flex; align-items:center; gap:8px; margin-top:16px; padding:11px; border-radius:8px; color:#27775b; background:#e7f4ef; font-size:9px; }.registered-banner svg { width:15px; }.registered-banner .status { margin-left:auto; } +.registration-cards { display:grid; gap:15px; }.registration-card { border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.registration-card > header { display:flex; align-items:center; justify-content:space-between; padding:18px 22px; border-bottom:1px solid var(--line); }.registration-card > header h2 { margin:3px 0 0; font-family:"STKaiti"; font-size:19px; font-weight:400; }.registration-info { display:grid; grid-template-columns:.8fr 1.2fr; }.registration-info > dl { display:grid; gap:13px; margin:0; padding:22px; border-right:1px solid var(--line); background:#fafbfc; }.registration-info dl div { display:grid; gap:3px; }.registration-info dt { color:#969dac; font-size:8px; }.registration-info dd { margin:0; color:#5c667b; font-family:Consolas,monospace; font-size:9px; }.selected-subjects { padding:22px; }.selected-subjects > strong { font-size:10px; }.selected-subjects > div { display:flex; flex-wrap:wrap; gap:7px; margin-top:11px; }.selected-subjects span { min-width:105px; display:grid; gap:3px; padding:9px; border:1px solid var(--line); border-radius:7px; font-size:9px; }.selected-subjects small { color:#9299aa; font-size:7px; }.registration-card > footer { display:flex; justify-content:space-between; align-items:center; padding:13px 22px; border-top:1px solid var(--line); }.registration-card > footer p { margin:0; color:#818a9c; font-size:8px; }.admit-list { display:grid; gap:18px; }.admit-ticket { position:relative; display:grid; grid-template-columns:1fr 205px; border-radius:var(--radius); color:#fff; background:var(--navy); box-shadow:var(--shadow); overflow:hidden; }.admit-ticket::before,.admit-ticket::after { content:""; position:absolute; right:193px; width:24px; height:24px; border-radius:50%; background:var(--paper); }.admit-ticket::before { top:-12px; }.admit-ticket::after { bottom:-12px; }.admit-main { padding:27px 30px; }.admit-main header { display:flex; justify-content:space-between; }.admit-main header > span { color:#8291ba; font-family:Consolas,monospace; font-size:9px; }.admit-main h2 { margin:16px 0 19px; font-family:"STKaiti"; font-size:23px; font-weight:400; }.admit-number { display:flex; align-items:flex-end; gap:14px; margin-bottom:18px; }.admit-number small { color:#8290b7; font-size:8px; }.admit-number strong { font-family:Consolas,monospace; font-size:18px; letter-spacing:1px; }.admit-main dl { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin:0; }.admit-main dl div { display:grid; gap:4px; }.admit-main dt { color:#7988b2; font-size:8px; }.admit-main dd { margin:0; color:#d6ddef; font-size:9px; }.admit-stub { display:flex; align-items:center; flex-direction:column; justify-content:center; gap:15px; padding:24px; border-left:1px dashed rgba(255,255,255,.18); text-align:center; }.admit-stub > span { color:#6f7fac; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.admit-stub i { width:36px; height:1px; background:rgba(255,255,255,.15); }.admit-stub .solid-button { border-color:#fff; color:var(--navy); background:#fff; box-shadow:none; }.admit-stub small { color:#7e8db6; font-size:7px; }.result-groups { display:grid; gap:16px; }.result-panel { overflow:hidden; }.result-panel > header { display:flex; justify-content:space-between; align-items:center; padding:20px 23px; border-bottom:1px solid var(--line); }.result-panel > header span { color:#7f899f; font-family:Consolas,monospace; font-size:8px; }.result-panel > header h2 { margin:4px 0 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.result-panel > header small { color:#8c94a4; font-size:8px; }.score-grid { display:grid; grid-template-columns:repeat(5,1fr); }.score-grid article { position:relative; min-height:145px; display:grid; place-content:center; justify-items:center; padding:18px; border-right:1px solid var(--line); }.score-grid article:last-child { border-right:0; }.score-grid span { color:#596379; font-size:10px; }.score-grid strong { margin:8px 0 1px; font-family:Georgia,serif; font-size:37px; font-weight:400; }.score-grid em { position:absolute; top:15px; right:15px; padding:3px 6px; border-radius:4px; color:#27795c; background:#e2f3ec; font-size:8px; font-style:normal; }.score-grid small { color:#a0a6b3; font-size:7px; }.result-panel > footer { display:flex; justify-content:space-between; padding:13px 22px; border-top:1px solid var(--line); color:#878f9f; font-size:8px; }.result-panel > footer p { margin:0; }.notice-center { overflow:hidden; }.notice-center-list > button { width:100%; min-height:83px; display:grid; grid-template-columns:55px 1fr auto 20px; align-items:center; gap:15px; padding:13px 20px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.notice-center-list > button:last-child { border-bottom:0; }.notice-center-list > button:hover { background:#fafbfc; }.notice-center time { display:grid; justify-items:center; color:var(--navy); }.notice-center time strong { font-family:Georgia,serif; font-size:22px; font-weight:400; }.notice-center time span { color:#8e96a7; font-size:8px; }.notice-center button > span { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:4px 8px; }.notice-center em { padding:3px 5px; border-radius:4px; color:var(--red); background:#fbe9e7; font-size:7px; font-style:normal; }.notice-center button > span strong { font-size:10px; }.notice-center button > span small { grid-column:1/-1; overflow:hidden; color:#8b93a4; font-size:8px; text-overflow:ellipsis; white-space:nowrap; }.notice-center button > i { color:var(--red); font-size:7px; font-style:normal; } +.empty-panel { min-height:370px; display:grid; place-content:center; justify-items:center; padding:40px; text-align:center; }.empty-panel > span { width:56px; height:56px; display:grid; place-items:center; border-radius:50%; color:#687da9; background:#edf1f8; }.empty-panel > span svg { width:24px; }.empty-panel h2 { margin:17px 0 7px; font-family:"STKaiti"; font-size:22px; font-weight:400; }.empty-panel p { max-width:440px; margin:0 0 18px; color:#858d9e; font-size:9px; line-height:1.8; } + +.score-appeal-state { width:100%; display:grid; justify-items:center; gap:5px; margin-top:10px; padding-top:9px; border-top:1px solid var(--line); }.score-appeal-state small { max-width:180px; text-align:center; line-height:1.5; }.score-appeal-form { width:100%; display:grid; gap:7px; margin-top:10px; padding-top:9px; border-top:1px solid var(--line); }.score-appeal-form textarea { width:100%; min-height:52px; padding:7px 8px; resize:vertical; border:1px solid var(--line); border-radius:6px; font:inherit; font-size:8px; }.score-appeal-form .row-action { justify-self:stretch; } + +/* 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); } +.exam-score-band > span { min-height:64px; display:flex; align-items:baseline; gap:5px; padding:13px 22px; } +.exam-score-band > span + span { display:grid; align-content:center; gap:5px; border-left:1px solid rgba(255,255,255,.12); } +.exam-score-band small { color:#8491b5; font-size:7px; } +.exam-score-band strong { margin-left:auto; font-family:Georgia,serif; font-size:27px; font-weight:400; } +.exam-score-band em { color:#aab5d2; font-size:7px; font-style:normal; } +.exam-score-band b { color:#e4e9f5; font-size:8px; font-weight:500; } +.score-rule-hint { margin:0; padding:9px 11px; border-radius:7px; color:#6d7890; background:#f3f6fb; font-size:8px; } + +/* 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; } +.notice-editor-field > span { color:#555f75; font-size:10px; font-weight:700; } +.notice-editor-field > small { color:#8b94a5; font-size:8px; font-weight:400; } +.notice-editor-form .ck.ck-editor { width:100%; } +.notice-editor-form .ck-editor__editable_inline { min-height:280px; max-height:430px; color:#333d52; font-size:13px; line-height:1.75; } +.notice-editor-form .ck.ck-toolbar { border-color:#dce1ea; border-radius:8px 8px 0 0; background:#f8f9fc; } +.notice-editor-form .ck.ck-editor__main > .ck-editor__editable { border-color:#dce1ea; border-radius:0 0 8px 8px; } +.notice-content figure.image { margin:20px auto; text-align:center; } +.notice-content figure.image img { display:block; width:auto; max-width:100%; height:auto; margin:auto; border-radius:7px; } +.notice-content figure.image.image-style-side { max-width:50%; margin-left:auto; } +.notice-content figure.image.image-style-align-left,.notice-content figure.image.image-style-block-align-left { margin-right:auto; margin-left:0; } +.notice-content figure.image.image-style-align-right,.notice-content figure.image.image-style-block-align-right { margin-right:0; margin-left:auto; } +.notice-content figcaption { margin-top:7px; color:#8a93a5; font-size:9px; line-height:1.6; text-align:center; } +.notice-content figure.table { margin:18px 0; overflow-x:auto; } +.notice-content table { width:100%; border-collapse:collapse; background:#fff; font-size:10px; } +.notice-content th,.notice-content td { min-width:70px; padding:9px 11px; border:1px solid #d8dfeb; color:#525d73; line-height:1.7; text-align:left; vertical-align:top; } +.notice-content th { color:var(--navy); background:#f1f4f9; font-weight:700; } +.notice-content td p,.notice-content th p { margin:0; font-size:inherit; line-height:inherit; } +.exam-config-form { gap:18px; } +.exam-form-section { display:grid; gap:14px; padding:17px; border:1px solid var(--line); border-radius:11px; background:#fbfcfe; } +.exam-form-section > header { display:flex; align-items:center; gap:11px; } +.exam-form-section > header > span { width:27px; height:27px; display:grid; place-items:center; flex:0 0 auto; border-radius:7px; color:#fff; background:var(--navy); font-family:Consolas,monospace; font-size:8px; } +.exam-form-section > header h3 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; } +.exam-form-section > header p { margin:3px 0 0; color:#8b94a5; font-size:7px; } +.exam-form-section > header aside { display:flex; align-items:baseline; gap:15px; margin-left:auto; } +.exam-form-section > header aside small { color:#8490a5; font-size:8px; } +.exam-form-section > header aside strong { color:var(--navy); font-family:Georgia,serif; font-size:22px; font-weight:400; } +.subject-config-section > div { display:grid; gap:10px; } +.exam-subject-editor { border:1px solid #dce3ef; border-radius:9px; background:#fff; overflow:hidden; } +.exam-subject-editor > header { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; background:#f1f4fa; } +.exam-subject-editor > header span { color:#65728c; font-size:8px; font-weight:700; } +.exam-subject-editor > header button { border:0; color:#a74c45; background:transparent; font-size:7px; } +.exam-subject-grid { display:grid; grid-template-columns:1.35fr repeat(6,1fr); gap:9px; padding:12px; } +.exam-subject-grid label { min-width:0; } +.exam-subject-grid input { min-height:40px; padding:8px 9px; } +.add-subject-button { min-height:40px; display:flex; align-items:center; justify-content:center; gap:7px; border:1px dashed #aab8d0; border-radius:8px; color:#506488; background:#f6f8fc; font-size:9px; font-weight:700; } +.add-subject-button svg { width:14px; fill:none; stroke:currentColor; stroke-width:1.7; } +.pass-policy-grid { display:grid; grid-template-columns:1.4fr .6fr; gap:13px; } +.unit-input { position:relative; display:block; } +.unit-input input { padding-right:40px; } +.unit-input em { position:absolute; top:50%; right:13px; color:#8490a4; font-size:9px; font-style:normal; transform:translateY(-50%); } +.pass-policy-hint { margin:0; padding:11px 13px; border-left:3px solid var(--jade); border-radius:7px; color:#627066; background:#edf6f2; font-size:8px; line-height:1.7; } +.result-summary { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--line); background:#f5f7fb; } +.result-summary > span { min-height:72px; display:flex; align-items:baseline; gap:7px; padding:15px 23px; } +.result-summary > span + span { border-left:1px solid var(--line); } +.result-summary small { margin-right:auto; color:#8992a4; font-size:8px; } +.result-summary strong { color:var(--navy); font-family:Georgia,"STKaiti",serif; font-size:20px; font-weight:400; } +.result-summary strong em,.result-summary > span > em { color:#8b94a5; font-size:8px; font-style:normal; } +.result-summary.qualified { background:#eef8f3; } +.result-summary.qualified > span:last-child strong { color:#237358; } +.result-summary.unqualified { background:#fff5f2; } +.result-summary.unqualified > span:last-child strong { color:#a24f43; } +.panel-title h2 { flex:0 0 auto; white-space:nowrap; } +.audit-feed > .panel-title { min-height:58px; display:flex; grid-template-columns:none; align-items:center; justify-content:space-between; gap:0; padding:0 19px; } +@keyframes fadeIn{from{opacity:0}}@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.98)}} + +@media (max-width: 1120px) { + .hero-grid { grid-template-columns:1fr 430px; gap:40px; }.hero-copy h1 { font-size:52px; }.summary-grid,.admin-metrics { grid-template-columns:1fr 1fr; }.candidate-grid,.admin-dashboard-grid { grid-template-columns:1fr; }.score-grid { grid-template-columns:repeat(3,1fr); }.score-grid article:nth-child(3) { border-right:0; }.score-grid article:nth-child(n+4) { border-top:1px solid var(--line); }.results-admin-grid { grid-template-columns:1fr; } + .exam-subject-grid { grid-template-columns:1.3fr repeat(3,1fr); }.exam-subject-grid label:nth-child(n+5) { grid-column:auto; } +} +@media (max-width: 850px) { + .public-nav { width:calc(100% - 28px); }.public-nav nav { position:absolute; top:75px; left:14px; right:14px; display:none; flex-direction:column; gap:0; margin:0; padding:9px; border:1px solid var(--line); border-radius:10px; background:#fff; box-shadow:var(--shadow); }.public-nav nav.open { display:flex; }.public-nav nav a { padding:12px; }.mobile-menu { display:grid; }.nav-actions > .text-button { display:none; }.hero { min-height:auto; }.hero-grid { grid-template-columns:1fr; gap:55px; padding:60px 0 75px; }.hero-ticket { width:min(540px,100%); }.notice-layout,.public-exam-grid { grid-template-columns:1fr; }.flow-track { grid-template-columns:1fr 1fr; gap:30px; }.flow-track::before { display:none; }.auth-page { grid-template-columns:1fr; }.auth-story { min-height:360px; padding:35px 35px 40px; }.auth-story .overline { margin-top:55px; }.auth-story h1 { font-size:42px; }.auth-quote { display:none; }.portal-sidebar { transform:translateX(-100%); transition:.25s; box-shadow:18px 0 55px rgba(12,22,48,.2); }.portal-sidebar.open { transform:none; }.portal-brand > button { display:block; }.portal-main { margin-left:0; }.sidebar-toggle { display:grid; }.portal-topbar { padding:0 18px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { display:none; }.portal-content { padding:22px 18px; }.apply-card-main,.registration-info { grid-template-columns:1fr; }.apply-copy,.registration-info > dl { border-right:0; border-bottom:1px solid var(--line); }.admit-ticket { grid-template-columns:1fr 170px; }.admit-ticket::before,.admit-ticket::after { right:158px; }.admin-exam-grid { grid-template-columns:1fr; } +} +@media (max-width: 620px) { + .region-selects { grid-template-columns:1fr; } + .public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; } +} +@media (max-width: 720px) { + .table-filter-selects { grid-template-columns:1fr 1fr; } + .registration-bulk-bar { align-items:stretch; flex-direction:column; } + .registration-bulk-bar > div { display:grid; grid-template-columns:1fr 1fr; } +} + +.flow-appeal-snapshot { margin:18px 24px 4px; padding:16px; border:1px solid #d4dfed; border-radius:10px; background:linear-gradient(120deg,#f7f9fd,#eef4fb); } +.flow-appeal-snapshot header { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; } +.flow-appeal-snapshot header span { color:#7888a6; font-size:7px; font-weight:700; letter-spacing:.08em; } +.flow-appeal-snapshot h3 { margin:4px 0 0; color:var(--navy); font-size:14px; } +.flow-appeal-snapshot header b { color:var(--blue); font-family:Georgia,serif; font-size:22px; } +.flow-appeal-snapshot dl { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px 18px; margin:15px 0 0; } +.flow-appeal-snapshot dl div { display:grid; grid-template-columns:70px 1fr; gap:8px; } +.flow-appeal-snapshot dt { color:#8994a8; font-size:7px; } +.flow-appeal-snapshot dd { margin:0; color:#33425b; font-size:8px; } +.rank-rule-line { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:10px; padding:9px 10px; border-radius:7px; background:#f1f4f9; } +.rank-rule-line span { color:#8993a6; font-size:7px; } +.rank-rule-line b { color:#45536c; font-size:8px; } +@media (max-width: 620px) { .flow-appeal-snapshot dl { grid-template-columns:1fr; } } + +/* Multi-exam score center, staged Excel import, and per-subject pass rules */ +.result-exam-strip { display:flex; gap:10px; margin-bottom:14px; overflow-x:auto; padding:2px 1px 8px; scrollbar-width:thin; } +.result-exam-strip > button { position:relative; min-width:245px; display:grid; gap:5px; overflow:hidden; padding:16px 17px 18px; border:1px solid #dce3ed; border-radius:12px; color:#536078; background:#fff; text-align:left; box-shadow:0 7px 20px rgba(20,36,81,.04); } +.result-exam-strip > button.active { border-color:#315fba; color:#142451; background:linear-gradient(130deg,#fff,#f0f5ff); box-shadow:0 10px 26px rgba(49,95,186,.12); } +.result-exam-strip span { color:#7383a5; font:700 10px Consolas,monospace; letter-spacing:.05em; } +.result-exam-strip strong { font-size:14px; } +.result-exam-strip small { color:#8a93a5; font-size:10px; } +.result-exam-strip i { position:absolute; right:0; bottom:0; left:0; height:4px; background:linear-gradient(90deg,#315fba var(--progress),#e8edf5 var(--progress)); } +.result-metric-grid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:10px; margin-bottom:14px; } +.result-metric-grid article { min-width:0; padding:15px 16px; border:1px solid #e0e6ef; border-radius:11px; background:#fff; box-shadow:0 7px 20px rgba(20,36,81,.04); } +.result-metric-grid small,.result-metric-grid span { display:block; color:#8a93a5; font-size:9px; } +.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; } +.result-entry-console form { padding:18px 20px 20px; } +.result-entry-fields { display:grid; grid-template-columns:1.2fr 1fr 1.3fr .65fr .65fr; gap:11px; } +.result-entry-fields label { min-width:0; display:grid; gap:6px; } +.result-entry-fields label > span { color:#667187; font-size:10px; font-weight:700; } +.result-entry-fields input,.result-entry-fields select { width:100%; min-height:40px; padding:8px 10px; border:1px solid #d7deea; border-radius:8px; color:#253149; background:#fff; font-size:11px; } +.result-entry-fields select:disabled { color:#a0a7b4; background:#f4f6f9; } +.result-entry-console .score-rule-hint { margin:12px 0; padding:10px 12px; border-left:3px solid #315fba; border-radius:6px; color:#61708c; background:#f1f5fc; font-size:10px; } +.result-entry-submit { display:flex; align-items:center; justify-content:space-between; gap:16px; padding-top:12px; border-top:1px solid #e8ecf2; } +.result-entry-submit .agreement { margin:0; } +.result-import-preview { margin-bottom:14px; overflow:hidden; border-color:#bed0e9; } +.result-import-preview > header { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:20px 22px; color:#fff; background:linear-gradient(112deg,#142451,#234c77); } +.result-import-preview header span { color:#83bdd0; font-size:9px; font-weight:800; letter-spacing:.14em; } +.result-import-preview header h2 { margin:5px 0; font-family:"STKaiti","KaiTi",serif; font-size:23px; font-weight:400; } +.result-import-preview header p { margin:0; color:#c1cedf; font-size:10px; } +.result-import-preview header .row-action { border-color:#6d82a3; color:#fff; background:rgba(255,255,255,.08); } +.import-preview-metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:1px; background:#e1e7ef; } +.import-preview-metrics span { padding:13px 18px; background:#fff; } +.import-preview-metrics small { display:block; color:#8993a5; font-size:9px; } +.import-preview-metrics strong { display:block; margin-top:3px; color:#173f60; font-size:18px; } +.import-preview-metrics .valid strong { color:#268466; } +.import-preview-metrics .invalid strong { color:#c8473d; } +.import-preview-table { min-width:1040px; } +.import-preview-table tr.row-invalid { background:#fff4f2; } +.import-preview-table td ul { margin:0; padding-left:15px; color:#b3453e; font-size:9px; } +.import-preview-table td strong,.import-preview-table td small { display:block; } +.import-preview-table td small { margin-top:3px; color:#8791a3; } +.import-commit-bar { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 20px; border-top:1px solid #e1e7ef; background:#f7f9fc; } +.import-commit-bar p { margin:0; color:#69768e; font-size:10px; } +.result-ledger { margin-bottom:14px; overflow:hidden; } +.result-ledger .data-toolbar { flex-wrap:wrap; } +.result-ledger table { min-width:1120px; } +.result-ledger td > strong,.result-ledger td > small { display:block; } +.result-score { color:#173f60; font:700 18px Georgia,serif; } +.result-score em { color:#929bad; font-size:10px; font-style:normal; } +.result-qualified,.result-unqualified,.result-neutral { display:inline-flex; padding:5px 8px; border-radius:99px; font-size:9px; font-weight:700; } +.result-qualified { color:#1f7459; background:#e2f3ec; } +.result-unqualified { color:#a7453f; background:#fbe8e6; } +.result-neutral { color:#69758c; background:#edf0f5; } +.subject-pass-preview { margin:0; padding:9px 12px; border-top:1px dashed #dce3ee; color:#6d7890; background:#f8fafe; font-size:9px; } +.candidate-result-overview { display:grid; grid-template-columns:repeat(4,1fr); gap:11px; margin-bottom:15px; } +.candidate-result-overview article { display:flex; align-items:baseline; gap:5px; padding:16px 18px; border:1px solid #e0e5ee; border-radius:11px; background:#fff; box-shadow:0 8px 22px rgba(20,36,81,.05); } +.candidate-result-overview small { margin-right:auto; color:#818b9e; font-size:9px; } +.candidate-result-overview strong { color:#173f60; font:400 25px Georgia,serif; } +.candidate-result-overview span { color:#8d96a7; font-size:9px; } +.result-panel .result-summary { grid-template-columns:repeat(3,1fr); } +.result-summary > span > i { display:block; margin-top:5px; color:#7e899c; font-size:9px; font-style:normal; } +.score-grid article { position:relative; } +.score-subject-head { display:flex; align-items:center; justify-content:space-between; gap:8px; } +.score-subject-head i { padding:4px 7px; border-radius:99px; color:#6d7890; background:#edf0f5; font-size:8px; font-style:normal; } +.score-grid article.qualified .score-subject-head i { color:#24755b; background:#e2f3ec; } +.score-grid article.unqualified .score-subject-head i { color:#aa463f; background:#fbe8e6; } +.score-grid article > strong small { color:#8d96a7; font-size:10px; } +.score-progress { position:relative; height:7px; margin:10px 0 7px; border-radius:99px; background:#e8ecf3; } +.score-progress > i { position:absolute; inset:0 auto 0 0; border-radius:99px; background:linear-gradient(90deg,#315fba,#4e8dc0); } +.score-progress > b { position:absolute; top:-3px; width:2px; height:13px; background:#c8473d; box-shadow:0 0 0 2px #fff; } + +@media (max-width: 1100px) { + .result-metric-grid { grid-template-columns:repeat(3,1fr); } + .result-entry-fields { grid-template-columns:repeat(2,minmax(0,1fr)); } + .result-entry-fields label:first-child { grid-column:span 2; } +} +@media (max-width: 700px) { + .result-metric-grid,.candidate-result-overview { grid-template-columns:repeat(2,1fr); } + .result-entry-fields { grid-template-columns:1fr; } + .result-entry-fields label:first-child { grid-column:auto; } + .result-entry-submit,.result-import-preview > header,.import-commit-bar { align-items:flex-start; flex-direction:column; } + .result-entry-submit button,.import-commit-bar button { width:100%; } + .import-preview-metrics { grid-template-columns:repeat(2,1fr); } + .result-panel .result-summary { grid-template-columns:1fr; } +} + +/* Admission-card arrangement engine */ +.arrangement-config { margin-bottom:15px; overflow:hidden; }.arrangement-config .panel-title { height:auto; min-height:74px; }.arrangement-config .panel-title p { margin:4px 0 0; color:var(--muted); font-size:8px; }.arrangement-config form { padding:20px; }.arrangement-fields { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }.arrangement-fields label { display:grid; align-content:start; gap:6px; }.arrangement-fields label > span { color:#59657c; font-size:8px; font-weight:700; }.arrangement-fields select,.arrangement-fields input { width:100%; min-height:42px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; color:var(--ink); background:#fff; font-size:9px; }.arrangement-fields label small { color:#9199a9; font-size:7px; line-height:1.6; }.arrangement-actions { display:flex; justify-content:flex-end; gap:9px; margin-top:16px; }.arrangement-preview { display:grid; gap:5px; margin:0 20px 20px; padding:13px 15px; border-radius:9px; }.arrangement-preview.ok { color:#245f4c; background:#e7f4ef; }.arrangement-preview.warning { color:#795a27; background:#fff3dc; }.arrangement-preview strong { font-size:9px; }.arrangement-preview span,.arrangement-preview small,.arrangement-preview li { font-size:8px; }.arrangement-preview ul { margin:5px 0 0; padding-left:18px; } +.arrangement-rules { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:15px; }.arrangement-rules article { display:grid; align-content:start; gap:6px; min-height:155px; padding:16px; border:1px solid var(--line); border-radius:11px; background:linear-gradient(145deg,#fff,#f5f7fb); }.arrangement-rules span { color:#8792a9; font-size:7px; }.arrangement-rules h3 { margin:0; font-family:"STKaiti"; font-size:16px; font-weight:400; }.arrangement-rules p { margin:0; color:#818b9f; font-size:7px; line-height:1.7; }.arrangement-rules code { margin-top:auto; color:var(--blue); font-size:10px; } +.arrangement-plan-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin-bottom:15px; }.arrangement-plan-grid article { padding:17px; }.arrangement-plan-grid header { display:flex; justify-content:space-between; gap:10px; }.arrangement-plan-grid header span,.arrangement-plan-grid time { color:#8691a6; font-size:7px; }.arrangement-plan-grid h3 { margin:4px 0 0; font-size:12px; }.arrangement-plan-grid article > div { display:grid; grid-template-columns:auto 1fr auto 1fr auto 1fr; align-items:baseline; gap:5px; margin:15px 0; }.arrangement-plan-grid article > div strong { color:var(--navy); font-family:Georgia,serif; font-size:20px; font-weight:400; }.arrangement-plan-grid article > div small,.arrangement-plan-grid p,.arrangement-plan-grid li { color:#858fa1; font-size:7px; }.arrangement-plan-grid ul { margin:8px 0 0; padding-left:17px; }.arrangement-ok { color:#2c755c !important; } +.subject-room-list,.admit-subject-rooms { display:grid; gap:4px; }.subject-room-list span,.admit-subject-rooms span { display:grid; grid-template-columns:minmax(45px,auto) 1fr; gap:7px; }.subject-room-list b,.admit-subject-rooms b { font-size:8px; }.subject-room-list small,.admit-subject-rooms small { color:#7f899c; font-size:7px; }.admit-preview table { width:100%; margin-top:16px; border-collapse:collapse; }.admit-preview th,.admit-preview td { padding:8px; border:1px solid var(--line); font-size:8px; text-align:left; } +@media (max-width: 1000px) { .arrangement-fields,.arrangement-rules { grid-template-columns:repeat(2,minmax(0,1fr)); } } +@media (max-width: 620px) { .arrangement-fields,.arrangement-rules,.arrangement-plan-grid { grid-template-columns:1fr; }.arrangement-actions { align-items:stretch; flex-direction:column; } } +@media (max-width: 620px) { + .exam-score-band { grid-template-columns:1fr; }.exam-score-band > span + span { border-top:1px solid rgba(255,255,255,.12); border-left:0; } + .exam-form-section { padding:13px; }.exam-form-section > header { align-items:flex-start; }.exam-form-section > header aside { display:grid; gap:2px; } + .exam-subject-grid,.pass-policy-grid,.result-summary { grid-template-columns:1fr; } + .exam-subject-grid .subject-name-field { grid-column:auto; }.result-summary > span + span { border-top:1px solid var(--line); border-left:0; } +} +@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; transition-duration:.01ms !important; } } + +/* 考试归档:以档案封存条区分当前考务与只读历史。 */ +.status-archived { color:#596272; background:#e9ecf1; } +.admin-exam-card.archived { border-style:dashed; border-color:#c7ccd5; background:#f6f7f9; box-shadow:none; } +.admin-exam-card.archived::before { content:"封存"; position:absolute; top:21px; right:-31px; width:112px; padding:4px 0; color:#fff; background:#697180; font:700 8px Consolas,monospace; letter-spacing:.16em; text-align:center; transform:rotate(38deg); } +.archive-exam-action { color:#a33f39; border-color:#e2bbb8; } +.exam-archive-shelf,.candidate-archive-fold,.result-archive-switcher { margin-top:18px; border:1px dashed #c7ced9; border-radius:12px; background:#f5f6f8; } +.exam-archive-shelf > summary,.candidate-archive-fold > summary,.result-archive-switcher > summary { display:flex; align-items:center; justify-content:space-between; gap:18px; padding:17px 20px; color:#4f5868; cursor:pointer; list-style:none; } +.exam-archive-shelf > summary::-webkit-details-marker,.candidate-archive-fold > summary::-webkit-details-marker,.result-archive-switcher > summary::-webkit-details-marker { display:none; } +.exam-archive-shelf > summary span,.candidate-archive-fold > summary span { display:grid; gap:3px; } +.exam-archive-shelf > summary b,.candidate-archive-fold > summary strong { font-size:11px; } +.exam-archive-shelf > summary small,.candidate-archive-fold > summary small,.result-archive-switcher > summary span { color:#858d9b; font-size:8px; } +.exam-archive-shelf > summary > strong,.candidate-archive-fold > summary > b { min-width:31px; height:31px; display:grid; place-items:center; border:1px solid #d3d7de; border-radius:50%; color:#616a78; background:#fff; font-size:9px; } +.exam-archive-shelf[open] > summary,.candidate-archive-fold[open] > summary { border-bottom:1px dashed #c7ced9; } +.archived-exam-grid { padding:18px; } +.candidate-archive-fold .registration-cards,.candidate-archive-fold > .result-panel,.candidate-archive-fold > .admit-ticket { margin:18px; } +.admit-archive-fold { margin:0; } +.result-archive-fold { margin-top:0; } +.result-archive-switcher { margin:0 0 14px; } +.result-archive-switcher > summary { justify-content:flex-start; padding:12px 16px; font-size:9px; font-weight:700; } +.result-archive-switcher .result-exam-strip { margin:0; padding:4px 14px 15px; } +.result-exam-strip > button.archived { border-style:dashed; box-shadow:none; background:#f6f7f9; } +.result-exam-strip > button.archived i { background:#9299a5; } +.exam-lock-banner { display:flex; align-items:center; gap:14px; margin-bottom:14px; padding:15px 18px; border:1px solid #d1d5dc; border-left:4px solid #697180; border-radius:10px; color:#4f5868; background:#f2f3f5; } +.exam-lock-banner > span { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:#697180; } +.exam-lock-banner svg { width:16px; fill:none; stroke:currentColor; stroke-width:2; } +.exam-lock-banner div { display:grid; gap:3px; } +.exam-lock-banner strong { font-size:10px; } +.exam-lock-banner small { color:#7e8693; font-size:8px; } +.result-panel.archived { filter:saturate(.72); } +.archived-score-lock { border-color:#d4d8df; background:#f0f2f5; } +.admin-registration-archive .table-scroll { margin:0 18px 18px; border:1px solid #dfe2e7; border-radius:9px; background:#fff; } +.archive-readonly-label { color:#777f8d; font-size:8px; } +@media (max-width:620px) { + .exam-card-actions { align-items:stretch; flex-direction:column; } + .exam-archive-shelf > summary,.candidate-archive-fold > summary { padding:14px; } + .archived-exam-grid { padding:12px; } + .candidate-archive-fold .registration-cards,.candidate-archive-fold > .result-panel,.candidate-archive-fold > .admit-ticket { margin:12px; } + .admin-registration-archive .table-scroll { margin:0 12px 12px; } +} + +/* Role scopes, numbering and workflow studio */ +.hidden { display:none !important; } +.scope-banner { display:flex; align-items:center; gap:14px; margin-bottom:14px; padding:14px 18px; border:1px solid #d8e1f3; border-radius:11px; background:linear-gradient(90deg,#edf2fb,#f9fbff); } +.scope-banner > span { padding:5px 9px; border-radius:6px; color:#fff; background:var(--navy); font-size:8px; font-weight:700; } +.scope-banner div { display:grid; gap:3px; }.scope-banner strong { font-size:10px; }.scope-banner small { color:var(--muted); font-size:8px; } +.admin-level { display:inline-flex; padding:5px 8px; border-radius:6px; font-size:8px; font-weight:700; } +.level-super { color:#9f3932; background:#fbe6e4; }.level-school { color:#294d99; background:#e7edf9; }.level-class { color:#237259; background:#e2f2ec; } +.candidate-flow-note { display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; margin:20px 20px 0; padding:12px 14px; border-left:3px solid var(--amber); border-radius:8px; background:#fff7e8; } +.candidate-flow-note span,.candidate-flow-note small { color:#94713a; font-size:8px; }.candidate-flow-note strong { font-size:10px; } +.center-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:15px; }.center-card { padding:20px; }.center-card header { display:flex; justify-content:space-between; gap:15px; }.center-card header span { color:var(--blue); font-size:8px; }.center-card h2 { margin:5px 0 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.center-card dl { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:18px 0; }.center-card dl div { padding:10px; border-radius:7px; background:#f7f8fb; }.center-card dt { color:#969dac; font-size:7px; }.center-card dd { margin:4px 0 0; font-size:9px; }.room-map { padding:13px; border:1px dashed #cbd4e5; border-radius:8px; }.room-map span { color:#8b94a7; font-size:7px; }.room-map p { margin:7px 0 0; color:#4f5b73; font-family:Consolas,monospace; font-size:9px; line-height:1.8; }.center-card footer { margin-top:13px; color:#9299a9; font-size:7px; } +.number-rule-layout { display:grid; grid-template-columns:minmax(0,1.5fr) minmax(260px,.55fr); gap:16px; }.rule-builder { overflow:hidden; }.rule-builder .panel-title { height:auto; min-height:72px; }.rule-builder .panel-title p { margin:4px 0 0; color:var(--muted); font-size:8px; }.rule-builder form { display:grid; gap:17px; padding:20px; }.rule-builder label { display:grid; gap:7px; }.rule-builder label > span { color:#555f75; font-size:9px; font-weight:700; }.rule-builder input { min-height:42px; padding:8px 11px; border:1px solid #dce1ea; border-radius:8px; background:#fff; font-size:10px; }.segment-builder { display:grid; gap:8px; }.segment-option { grid-template-columns:20px 60px 1fr 92px 55px; align-items:center; gap:10px; padding:11px; border:1px solid var(--line); border-radius:9px; background:#fafbfc; }.segment-option.selected { border-color:#b8c5df; background:#f4f7fd; }.segment-option > input[type="checkbox"] { width:15px; height:15px; min-height:0; }.segment-order { display:grid; gap:2px; }.segment-order small { color:#989faf; font-size:6px; }.segment-order input,.segment-value,.segment-width { min-height:31px !important; padding:5px 7px !important; }.segment-copy { display:grid; gap:3px; }.segment-copy strong { font-size:9px; }.segment-copy small { color:#8f97a8; font-size:7px; }.rule-preview { position:relative; min-height:270px; display:flex; flex-direction:column; justify-content:center; padding:28px; border-radius:var(--radius); color:#fff; background:var(--navy); overflow:hidden; }.rule-preview::after { content:"号"; position:absolute; right:-18px; bottom:-75px; color:rgba(255,255,255,.04); font-family:"STKaiti"; font-size:180px; }.rule-preview span { color:#8291bc; font-size:8px; }.rule-preview strong { position:relative; z-index:1; margin:12px 0; font-family:Consolas,monospace; font-size:clamp(18px,2vw,29px); letter-spacing:1px; word-break:break-all; }.rule-preview p { color:#b8c2de; font-size:8px; }.rule-preview small { margin-top:auto; color:#8997bc; font-size:7px; line-height:1.8; } +.workflow-design-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:15px; }.workflow-designer { overflow:hidden; }.workflow-designer > header { display:flex; justify-content:space-between; align-items:flex-start; padding:19px 20px; border-bottom:1px solid var(--line); }.workflow-designer header span { color:#8993a8; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }.workflow-designer h2 { margin:5px 0 0; font-family:"STKaiti"; font-size:20px; font-weight:400; }.workflow-designer form { display:grid; gap:14px; padding:18px; }.workflow-designer form > input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:7px; font-size:10px; }.workflow-step-editor { position:relative; display:grid; gap:8px; }.workflow-step-editor::before { content:""; position:absolute; top:19px; bottom:19px; left:17px; width:2px; background:#dfe5f0; }.workflow-step-row { position:relative; z-index:1; display:grid; grid-template-columns:34px 1fr 125px 26px; align-items:center; gap:8px; }.workflow-step-row > i { width:34px; height:34px; border:7px solid #fff; border-radius:50%; background:var(--blue); box-shadow:0 0 0 1px #cbd5e8; }.workflow-step-row input,.workflow-step-row select { min-height:37px; padding:7px 9px; border:1px solid var(--line); border-radius:7px; background:#fff; font-size:9px; }.workflow-step-row button { width:26px; height:26px; border:0; border-radius:6px; color:var(--red); background:#fbe9e7; } +.workflow-board { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:15px; }.workflow-card { overflow:hidden; }.workflow-card > header { display:flex; justify-content:space-between; gap:14px; padding:18px 20px; border-bottom:1px solid var(--line); }.workflow-card header span:first-child { color:var(--blue); font-size:7px; }.workflow-card h2 { margin:5px 0; font-family:"STKaiti"; font-size:19px; font-weight:400; }.workflow-card header p { margin:0; color:#8f97a8; font-size:7px; }.workflow-track,.flow-detail-track { display:flex; overflow-x:auto; padding:18px 20px; }.workflow-track > div,.flow-detail-track > div { position:relative; min-width:130px; display:grid; grid-template-columns:28px 1fr; align-items:center; gap:7px; }.workflow-track > div:not(:last-child)::after,.flow-detail-track > div:not(:last-child)::after { content:""; position:absolute; top:13px; left:28px; right:0; height:1px; background:#dbe1ec; }.workflow-track i,.flow-detail-track i { position:relative; z-index:1; width:27px; height:27px; display:grid; place-items:center; border:1px solid #ccd4e2; border-radius:50%; color:#8a93a5; background:#fff; font-size:8px; font-style:normal; }.workflow-track .done i,.flow-detail-track .done i { border-color:var(--jade); color:#fff; background:var(--jade); }.workflow-track .current i,.flow-detail-track .current i { border-color:var(--red); color:#fff; background:var(--red); box-shadow:0 0 0 5px rgba(200,71,61,.1); }.workflow-track span,.flow-detail-track span { z-index:1; display:grid; gap:2px; padding-right:8px; background:#fff; }.workflow-track strong,.flow-detail-track strong { font-size:8px; }.workflow-track small,.flow-detail-track small { color:#9199a9; font-size:6px; }.workflow-owner { display:grid; grid-template-columns:90px 1fr auto; align-items:center; gap:8px; margin:0 20px 16px; padding:10px 12px; border-radius:7px; background:#f5f7fb; }.workflow-owner span,.workflow-owner small { color:#8e96a7; font-size:7px; }.workflow-owner strong { font-size:9px; }.workflow-card > footer { display:flex; align-items:center; justify-content:space-between; padding:12px 20px; border-top:1px solid var(--line); }.workflow-card footer > span { color:#8f97a8; font-size:7px; } +.flow-history { padding:0 24px 5px; }.flow-history h3,.supervisor-form h3 { margin:5px 0 13px; font-family:"STKaiti"; font-size:17px; font-weight:400; }.flow-history > div { display:grid; grid-template-columns:12px 1fr auto; gap:8px; padding:9px 0; border-top:1px solid #edf0f5; }.flow-history i { width:7px; height:7px; margin-top:4px; border-radius:50%; background:var(--blue); }.flow-history span { display:grid; gap:3px; }.flow-history strong { font-size:8px; }.flow-history small,.flow-history time { color:#929aaa; font-size:7px; }.compact-flow-form,.supervisor-form { margin-top:12px; padding-bottom:20px; border-top:1px solid var(--line); }.supervisor-form { margin-top:0; background:#f8f5ee; }.read-only-callout { margin:16px 24px; padding:12px; border-radius:8px; color:#7c6a4b; background:#fff4dd; font-size:8px; } +.results-admin-grid.read-only { grid-template-columns:1fr; }.results-admin-grid.read-only .published-results { max-width:none; } + +/* Controlled test-site dossiers and batch numbering */ +.center-summary { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:15px; }.center-summary > div { display:flex; align-items:baseline; gap:8px; padding:16px 18px; border:1px solid var(--line); border-radius:11px; background:#fff; }.center-summary span { color:#8790a2; font-size:8px; }.center-summary strong { margin-left:auto; color:var(--navy); font-family:Georgia,serif; font-size:24px; font-weight:400; }.center-dossier-grid { display:grid; gap:16px; }.center-dossier { overflow:hidden; }.center-dossier > header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; padding:19px 21px; border-bottom:1px solid var(--line); background:linear-gradient(100deg,#fff,#f7f9fd); }.center-dossier > header > div:last-child { display:flex; align-items:center; gap:8px; }.center-dossier header span { color:#70809f; font-size:8px; }.center-dossier header h2 { margin:5px 0 0; font-family:"STKaiti"; font-size:22px; font-weight:400; }.pending-mark { padding:5px 8px; border-radius:6px; color:#99631d !important; background:#fff0d3; font-weight:700; }.row-action:disabled { color:#9ca4b3; cursor:not-allowed; }.center-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; border-bottom:1px solid var(--line); background:var(--line); }.center-metrics > div { display:flex; align-items:baseline; gap:5px; padding:14px 20px; background:#fff; }.center-metrics small { margin-right:auto; color:#9098a9; font-size:7px; }.center-metrics strong { font-family:Georgia,serif; font-size:19px; font-weight:400; }.center-metrics span { color:#8f97a8; font-size:7px; }.center-profile { display:grid; grid-template-columns:1fr 1fr; gap:10px 24px; margin:0; padding:18px 21px; }.center-profile div { display:grid; grid-template-columns:74px 1fr; gap:7px; }.center-profile dt { color:#949cac; font-size:7px; }.center-profile dd { margin:0; color:#556077; font-size:8px; line-height:1.6; }.room-table-wrap { margin:0 20px 18px; overflow:auto; border:1px solid var(--line); border-radius:9px; }.room-table th { background:#f3f6fb; }.center-dossier > footer { display:flex; justify-content:space-between; gap:15px; padding:12px 21px; border-top:1px solid var(--line); color:#8b94a5; font-size:7px; }.center-change-ledger { margin-top:16px; overflow:hidden; }.center-change-ledger .panel-title { min-height:70px; height:auto; }.center-change-ledger .panel-title p { margin:4px 0 0; color:#8b94a5; font-size:7px; }.modal-card:has(.center-dossier-form) { width:min(980px,100%); }.center-dossier-form { gap:18px; }.center-form-section { display:grid; gap:13px; padding:16px; border:1px solid var(--line); border-radius:10px; background:#fbfcfe; }.center-form-section > h3,.center-form-section > header h3 { margin:0; font-family:"STKaiti"; font-size:18px; font-weight:400; }.center-form-section > header { display:flex; align-items:center; justify-content:space-between; gap:15px; }.center-form-section > header p { margin:4px 0 0; color:#8f97a8; font-size:7px; }.rooms-section > div { display:grid; gap:10px; }.center-room-editor { overflow:hidden; border:1px solid #dce3ef; border-radius:9px; background:#fff; }.center-room-editor > header { display:flex; align-items:center; justify-content:space-between; padding:9px 12px; background:#f1f4fa; }.center-room-editor > header span { color:#5e6c87; font-size:8px; font-weight:700; }.center-room-editor > header button { border:0; color:#a74c45; background:transparent; font-size:7px; }.room-editor-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; padding:12px; }.room-editor-grid label { min-width:0; }.room-editor-grid input,.room-editor-grid select { width:100%; }.room-editor-grid .room-notes { grid-column:span 2; }.approval-callout { display:flex; align-items:center; gap:12px; padding:12px 14px; border-left:3px solid var(--amber); border-radius:8px; background:#fff7e9; }.approval-callout strong { color:#765c30; font-size:9px; }.approval-callout span { color:#94794d; font-size:8px; }.batch-number-panel { margin-top:16px; padding:21px; background:linear-gradient(120deg,#fff 0 65%,#f0f4fb); }.batch-number-intro span { color:#7786a5; font-family:Consolas,monospace; font-size:7px; letter-spacing:1.2px; }.batch-number-intro h2 { margin:5px 0; font-family:"STKaiti"; font-size:22px; font-weight:400; }.batch-number-intro p { margin:0; color:#8790a2; font-size:8px; }.batch-number-panel form { display:grid; grid-template-columns:1fr 1fr 130px auto; align-items:end; gap:12px; margin-top:18px; }.batch-number-panel form label { display:grid; gap:6px; }.batch-number-panel form label span { color:#68738a; font-size:8px; font-weight:700; }.batch-number-panel select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.batch-ready { display:flex; align-items:baseline; gap:5px; min-height:40px; padding:8px 12px; border:1px solid #d6dfef; border-radius:8px; background:#f5f8fd; }.batch-ready small { margin-right:auto; color:#7f899d; font-size:7px; }.batch-ready strong { color:var(--blue); font-family:Georgia,serif; font-size:20px; }.batch-ready span { color:#8892a5; font-size:7px; }.batch-candidate-strip { display:flex; flex-wrap:wrap; gap:7px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.batch-candidate-strip > span { display:grid; gap:2px; padding:7px 9px; border-radius:7px; background:#f2f5fa; }.batch-candidate-strip b { font-size:8px; }.batch-candidate-strip small,.batch-candidate-strip p,.batch-candidate-strip em { color:#8a93a5; font-size:7px; font-style:normal; }.flow-center-snapshot { margin:18px 24px 4px; padding:14px; border:1px solid #dce3ef; border-radius:10px; background:#f7f9fd; }.flow-center-snapshot header { display:flex; justify-content:space-between; align-items:center; }.flow-center-snapshot header span { color:#8390a8; font-size:7px; }.flow-center-snapshot h3 { margin:3px 0 0; font-size:12px; }.flow-center-snapshot header b { color:var(--blue); font-size:9px; }.flow-center-snapshot dl { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:12px 0; }.flow-center-snapshot dl div { display:grid; grid-template-columns:62px 1fr; gap:6px; }.flow-center-snapshot dt { color:#929bad; font-size:7px; }.flow-center-snapshot dd { margin:0; font-size:8px; }.flow-center-snapshot > div { display:flex; flex-wrap:wrap; gap:6px; }.flow-center-snapshot > div span { display:grid; gap:2px; padding:6px 8px; border-radius:6px; background:#fff; }.flow-center-snapshot > div strong { font-size:8px; }.flow-center-snapshot > div small { color:#8d96a8; font-size:7px; } +.payment-summary { grid-template-columns:repeat(5,1fr); }.payment-summary strong { font-size:18px; }.registration-info dd > small { display:block; margin-top:5px; color:#8d95a5; font-family:inherit; font-size:7px; } + +/* Candidate account issuance and first-login onboarding */ +.registration-closed { display:grid; gap:12px; padding:20px; border:1px solid #dde3ee; border-radius:11px; background:#f7f9fc; }.registration-closed strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.registration-closed span { color:#7f889b; font-size:9px; line-height:1.8; } +.issued-number { display:grid; justify-items:center; gap:10px; padding:32px 24px; text-align:center; }.issued-number > span { color:#818ca4; font-size:8px; }.issued-number > strong { padding:13px 18px; border:1px dashed #aebbd4; border-radius:9px; color:var(--navy); background:#f4f7fd; font-family:Consolas,monospace; font-size:25px; letter-spacing:1px; }.issued-number p { max-width:420px; margin:0; color:#858ea1; font-size:8px; line-height:1.8; } +.onboarding-page { min-height:100vh; display:grid; grid-template-columns:minmax(330px,36%) 1fr; background:#f3f5f9; }.onboarding-identity { position:sticky; top:0; min-height:100vh; display:flex; flex-direction:column; align-items:flex-start; padding:42px clamp(30px,5vw,72px); color:#fff; background:var(--navy); }.onboarding-identity > span { margin-top:82px; color:#8593ba; font-size:8px; letter-spacing:1px; }.onboarding-identity > strong { max-width:100%; margin:10px 0 15px; overflow-wrap:anywhere; font-family:Consolas,monospace; font-size:clamp(22px,3vw,36px); letter-spacing:1px; }.onboarding-identity > p { max-width:390px; margin:0; color:#abb6d2; font-size:10px; line-height:1.9; }.onboarding-identity > button { margin-top:auto; border:0; color:#98a6ca; background:transparent; font-size:8px; }.onboarding-steps { width:100%; display:grid; gap:0; margin-top:55px; }.onboarding-steps > div { position:relative; display:grid; grid-template-columns:34px 1fr; gap:12px; min-height:72px; opacity:.45; }.onboarding-steps > div:not(:last-child)::after { content:""; position:absolute; top:34px; bottom:0; left:16px; width:1px; background:#53638d; }.onboarding-steps > div.current,.onboarding-steps > div.done { opacity:1; }.onboarding-steps i { z-index:1; width:34px; height:34px; display:grid; place-items:center; border:1px solid #63739d; border-radius:50%; color:#c1cae0; background:var(--navy); font-size:9px; font-style:normal; }.onboarding-steps .current i { border-color:#fff; color:var(--navy); background:#fff; box-shadow:0 0 0 6px rgba(255,255,255,.08); }.onboarding-steps .done i { border-color:var(--jade); color:#fff; background:var(--jade); }.onboarding-steps span { display:grid; align-content:start; gap:4px; padding-top:3px; }.onboarding-steps b { font-size:10px; }.onboarding-steps small { color:#96a3c5; font-size:7px; }.onboarding-work { width:min(900px,100%); padding:55px clamp(24px,5vw,70px) 70px; }.onboarding-work-head { margin-bottom:24px; }.onboarding-work-head > span { color:#7d89a2; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.onboarding-work-head h1 { margin:8px 0; font-family:"STKaiti"; font-size:34px; font-weight:400; }.onboarding-work-head p { margin:0; color:#828b9d; font-size:9px; }.password-onboarding { max-width:560px; padding:23px; }.password-rule { display:grid; gap:4px; margin-bottom:18px; padding:12px; border-left:3px solid var(--blue); background:#eef3fb; }.password-rule b { font-size:9px; }.password-rule span { color:#6e7990; font-size:8px; }.password-onboarding form { display:grid; gap:14px; }.onboarding-profile .profile-form { padding-top:0; }.profile-form .wide-field { grid-column:span 2; } +.registration-policy { display:flex; align-items:center; justify-content:space-between; gap:25px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f1f5fc); }.registration-policy > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; }.registration-policy h2 { margin:5px 0; font-family:"STKaiti"; font-size:21px; font-weight:400; }.registration-policy p { margin:0; color:#848d9f; font-size:8px; }.registration-policy form { display:flex; align-items:center; gap:11px; }.policy-state { padding:6px 9px; border-radius:99px; color:#8b5f22; background:#fff0d4; font-size:8px; font-weight:700; }.policy-state.open { color:#247157; background:#e2f2ec; }.onboarding-badge { display:inline-flex; padding:5px 8px; border-radius:6px; color:#8a6229; background:#fff0d6; font-size:8px; font-weight:700; }.account-issue-note { display:flex; align-items:center; gap:10px; padding:11px 13px; border-radius:8px; color:#6d5733; background:#fff5e3; }.account-issue-note strong { font-size:8px; }.account-issue-note span { font-size:7px; }.account-number-principle { display:grid; grid-template-columns:auto 1fr; gap:4px 18px; margin-bottom:15px; padding:17px 20px; border-left:4px solid var(--blue); border-radius:10px; color:#fff; background:var(--navy); }.account-number-principle span { grid-row:1/3; align-self:center; color:#8290b5; font-family:Consolas,monospace; font-size:7px; writing-mode:vertical-rl; letter-spacing:1px; }.account-number-principle strong { font-family:"STKaiti"; font-size:20px; font-weight:400; }.account-number-principle p { margin:0; color:#aeb8d2; font-size:8px; } + +.account-security-panel { max-width:820px; display:grid; grid-template-columns:minmax(0,.9fr) minmax(320px,1.1fr); gap:34px; padding:28px; } +.account-security-stack { display:grid; gap:18px; } +.account-security-copy > span,.candidate-archive-console > div > span { color:#7483a3; font-family:Consolas,monospace; font-size:7px; letter-spacing:1px; } +.account-security-copy h2,.candidate-archive-console h2 { margin:7px 0; font-family:"STKaiti"; font-size:24px; font-weight:400; } +.account-security-copy p,.candidate-archive-console p { margin:0; color:#818b9e; font-size:8px; line-height:1.8; } +.account-security-copy dl { display:grid; gap:9px; margin:24px 0 0; }.account-security-copy dl div { display:grid; grid-template-columns:80px 1fr; gap:9px; padding:9px 0; border-top:1px solid var(--line); } +.account-security-copy dt { color:#929bad; font-size:7px; }.account-security-copy dd { margin:0; font-size:9px; } +.account-password-form { display:grid; gap:14px; padding:20px; border:1px solid var(--line); border-radius:10px; background:#f8fafd; }.account-password-form label { display:grid; gap:6px; }.account-password-form label span { color:#5e6980; font-size:8px; font-weight:700; }.account-password-form input { min-height:42px; padding:9px 11px; border:1px solid #dce2ed; border-radius:8px; background:#fff; } +.totp-security-panel { position:relative; overflow:hidden; border-color:#ccd8ed; } +.totp-security-panel::after { position:absolute; right:-42px; top:-62px; width:170px; height:170px; border:1px solid #dbe5f5; border-radius:50%; box-shadow:0 0 0 26px rgba(225,234,248,.42),0 0 0 52px rgba(225,234,248,.2); content:""; pointer-events:none; } +.totp-security-panel > * { position:relative; z-index:1; } +.form-hint { margin:0; color:#7a8599; font-size:8px; line-height:1.7; } +.totp-signal { display:grid; grid-template-columns:10px auto 1fr; align-items:center; gap:8px; margin-top:22px; padding:11px 13px; border:1px solid #bfdfd1; border-radius:9px; background:#f1faf6; color:#246449; } +.totp-signal i { width:9px; height:9px; border-radius:50%; background:#2a9b6d; box-shadow:0 0 0 4px rgba(42,155,109,.13); } +.totp-signal strong { font-size:9px; }.totp-signal span { justify-self:end; color:#56806e; font-size:7px; } +.totp-security-actions { display:grid; align-content:start; gap:10px; } +.totp-security-actions details { border:1px solid var(--line); border-radius:10px; background:#f8fafd; } +.totp-security-actions summary { padding:14px 16px; color:#43516a; font-size:9px; font-weight:700; cursor:pointer; } +.totp-security-actions details[open] summary { border-bottom:1px solid var(--line); } +.totp-security-actions .account-password-form { border:0; border-radius:0 0 10px 10px; } +.danger-details summary { color:#9d413e; }.danger-button { min-height:40px; padding:9px 14px; border:1px solid #d9a5a1; border-radius:8px; background:#fff4f3; color:#9d3430; font:700 8px inherit; cursor:pointer; } +.totp-setup-grid { display:grid; grid-template-columns:240px 1fr; gap:22px; padding:0 24px 18px; align-items:center; } +.totp-qr { display:grid; place-items:center; padding:8px; border:1px solid #dce4f1; border-radius:12px; background:#fff; } +.totp-qr img { display:block; width:100%; height:auto; } +.totp-manual { display:grid; gap:10px; min-width:0; }.totp-manual > span { color:#65728a; font-size:8px; }.totp-manual code { overflow-wrap:anywhere; color:#263d67; font:700 11px/1.7 Consolas,monospace; letter-spacing:1px; }.totp-manual small { color:#8a94a6; font-size:7px; line-height:1.6; } +.totp-confirm-form { border-top:1px solid var(--line); }.totp-login-form input,.totp-confirm-form input { font:700 18px Consolas,monospace; letter-spacing:5px; text-align:center; } +.recovery-code-sheet { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin:0 24px 16px; padding:16px; border:1px dashed #bdcbe0; border-radius:10px; background:#f7f9fd; } +.recovery-code-sheet code { color:#263d67; font:700 11px Consolas,monospace; letter-spacing:.8px; text-align:center; } +.security-warning { margin:0 24px; padding:10px 12px; border-left:3px solid #c69037; background:#fff9ed; color:#7a633c; font-size:8px; line-height:1.7; } +.candidate-archive-console { display:flex; align-items:center; justify-content:space-between; gap:26px; margin-bottom:15px; padding:20px 22px; background:linear-gradient(110deg,#fff,#f3f6fb); }.archive-controls { min-width:420px; display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:9px; }.archive-controls select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:9px; }.archive-button { background:#9f463f; }.account-archived-row { opacity:.7; background:#f5f6f8; }.candidate-account-actions { display:flex; flex-wrap:wrap; gap:6px; } +.reset-warning { padding:14px; border-left:3px solid var(--red); border-radius:8px; background:#fceceb; }.reset-warning strong { font-size:9px; }.reset-warning p { margin:5px 0 0; color:#875b58; font-size:8px; line-height:1.7; } + +/* Admin readability: retain the information-dense layout while lifting microcopy to a readable floor. */ +.admin-readable .portal-role { font-size:11px; } +.admin-readable .portal-sidebar nav button { font-size:13px; } +.admin-readable .portal-sidebar nav button em { font-size:10px; } +.admin-readable .sidebar-help span,.admin-readable .sidebar-help small { font-size:11px; } +.admin-readable .sidebar-help strong { font-size:13px; } +.admin-readable .portal-topbar > div:first-of-type,.admin-readable .portal-user > span strong { font-size:12px; } +.admin-readable .portal-user > span small { font-size:11px; } +.admin-readable .portal-heading > div > p:last-child,.admin-readable .heading-status { font-size:13px; line-height:1.65; } +.admin-readable .portal-content { line-height:1.55; } +.admin-readable .portal-content :where(p,small,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:12px !important; } +.admin-readable .portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:12px !important; } +.admin-readable .portal-content :where(td strong,button strong,p strong) { font-size:12px !important; } +.admin-readable .portal-content .status,.admin-readable .portal-content .overline { font-size:11px !important; } + +@media (max-width: 1000px) { .candidate-archive-console { align-items:flex-start; flex-direction:column; }.archive-controls { width:100%; min-width:0; } } +@media (max-width: 620px) { .account-security-panel { grid-template-columns:1fr; padding:18px; }.archive-controls { grid-template-columns:1fr; }.totp-setup-grid { grid-template-columns:1fr; padding:0 18px 16px; }.totp-qr { width:min(240px,100%); justify-self:center; }.recovery-code-sheet { grid-template-columns:1fr; margin-inline:18px; }.security-warning { margin-inline:18px; } } + +@media (max-width: 1000px) { + .number-rule-layout,.workflow-design-grid,.workflow-board,.center-grid { grid-template-columns:1fr; }.batch-number-panel form { grid-template-columns:1fr 1fr; }.room-editor-grid { grid-template-columns:repeat(3,1fr); }.onboarding-page { grid-template-columns:290px 1fr; }.onboarding-identity { padding:32px 28px; }.onboarding-work { padding:42px 28px 60px; } +} +@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; } +} diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs new file mode 100644 index 0000000..e8bfff5 --- /dev/null +++ b/tests/admission.test.mjs @@ -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('志愿投档、指标名额与脱敏公示测试通过'); diff --git a/tests/api-dedup.test.mjs b/tests/api-dedup.test.mjs new file mode 100644 index 0000000..0536a7d --- /dev/null +++ b/tests/api-dedup.test.mjs @@ -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'); diff --git a/tests/auth-state.test.mjs b/tests/auth-state.test.mjs new file mode 100644 index 0000000..3719f0b --- /dev/null +++ b/tests/auth-state.test.mjs @@ -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 临时状态'); diff --git a/tests/cache.test.mjs b/tests/cache.test.mjs new file mode 100644 index 0000000..225f0ee --- /dev/null +++ b/tests/cache.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { createRedisCache, withCacheInvalidation } from '../src/cache/redis-cache.mjs'; + +class FakeRedisClient extends EventEmitter { + constructor({ connectError = null } = {}) { + super(); + this.connectError = connectError; + this.isOpen = false; + this.isReady = false; + this.values = new Map(); + } + + async connect() { + if (this.connectError) throw this.connectError; + this.isOpen = true; + this.isReady = true; + this.emit('ready'); + } + + async get(key) { + return this.values.has(key) ? this.values.get(key) : null; + } + + async set(key, value, options = {}) { + if (options.NX && this.values.has(key)) return null; + this.values.set(key, value); + return 'OK'; + } + + async incr(key) { + const next = Number(this.values.get(key) || 0) + 1; + this.values.set(key, String(next)); + return next; + } + + async quit() { + this.isReady = false; + this.isOpen = false; + } + + destroy() { + this.isReady = false; + this.isOpen = false; + } +} + +const silentLogger = { warn() {} }; + +{ + const client = new FakeRedisClient(); + const cache = await createRedisCache({ + env: { REDIS_URL: 'redis://test', REDIS_CACHE_PREFIX: 'test', REDIS_CACHE_TTL_SECONDS: '30' }, + logger: silentLogger, + clientFactory: () => client + }); + let loads = 0; + const load = async () => ({ version: ++loads }); + + assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 }); + assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 }); + assert.equal(loads, 1, '相同缓存键应只读取一次数据源'); + + await cache.invalidate('public'); + assert.deepEqual(await cache.remember('public', 'home', load), { version: 2 }); + assert.equal(loads, 2, '命名空间失效后应重新读取数据源'); + + let resultLoads = 0; + const loadResults = async () => ({ version: ++resultLoads }); + assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 1 }); + + const database = withCacheInvalidation({ + client: 'test', + async read() { return {}; }, + async save() { return 'saved'; }, + async close() {} + }, cache, method => method === 'save' ? ['public', 'results'] : ['public']); + assert.equal(await database.save(), 'saved'); + assert.deepEqual(await cache.remember('public', 'home', load), { version: 3 }); + assert.equal(loads, 3, '数据库写入后应让公开缓存失效'); + assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 2 }); + assert.equal(resultLoads, 2, '成绩相关写入后应让成绩缓存失效'); + 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; + 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', '旧加载完成后不得覆盖新缓存'); +} + +{ + const client = new FakeRedisClient({ connectError: new Error('connection refused') }); + const cache = await createRedisCache({ + env: { REDIS_URL: 'redis://unavailable' }, + logger: silentLogger, + clientFactory: () => client + }); + 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 缓存测试通过'); diff --git a/tests/client-auth.test.mjs b/tests/client-auth.test.mjs new file mode 100644 index 0000000..d5a470f --- /dev/null +++ b/tests/client-auth.test.mjs @@ -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('客户端登录失效处理测试通过'); diff --git a/tests/document-verification.test.mjs b/tests/document-verification.test.mjs new file mode 100644 index 0000000..0d6f732 --- /dev/null +++ b/tests/document-verification.test.mjs @@ -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('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过'); diff --git a/tests/seed.test.mjs b/tests/seed.test.mjs new file mode 100644 index 0000000..b508cbb --- /dev/null +++ b/tests/seed.test.mjs @@ -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('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过'); diff --git a/tests/state-cache.test.mjs b/tests/state-cache.test.mjs new file mode 100644 index 0000000..a8e66fd --- /dev/null +++ b/tests/state-cache.test.mjs @@ -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'); diff --git a/tests/system.test.mjs b/tests/system.test.mjs new file mode 100644 index 0000000..dd4742c --- /dev/null +++ b/tests/system.test.mjs @@ -0,0 +1,1270 @@ +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 = 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)); +assert.equal(mysqlRuleSchemas.length, 2, 'MySQL 应包含两张号码规则表'); +for (const statement of mysqlRuleSchemas) { + assert.match(statement, /`separator` VARCHAR\(10\)/, 'MySQL 保留字 separator 必须作为标识符转义'); +} +const mysqlRegistrationSchema = mysqlSchema.find(statement => /CREATE TABLE IF NOT EXISTS registrations\b/.test(statement)); +assert.match(mysqlRegistrationSchema || '', /paid_at VARCHAR\(35\).*paid_by VARCHAR\(64\)/s, 'MySQL 报名表应保存缴费确认时间和班级负责人'); +const mysqlWorkflowSchema = mysqlSchema.find(statement => /CREATE TABLE IF NOT EXISTS workflow_definitions\b/.test(statement)); +assert.match(mysqlWorkflowSchema || '', /'score_appeal'/, 'MySQL 工作流枚举应包含成绩复议'); +const mysqlTableOrder = new Map(mysqlSchema.map((statement, index) => [ + statement.match(/^CREATE TABLE IF NOT EXISTS\s+([a-z0-9_]+)/i)?.[1], index +])); +for (const [table, index] of mysqlTableOrder) { + const statement = mysqlSchema[index]; + for (const match of statement.matchAll(/REFERENCES\s+([a-z0-9_]+)/gi)) { + assert.ok(mysqlTableOrder.has(match[1]) && mysqlTableOrder.get(match[1]) <= index, `MySQL 表 ${table} 的外键依赖必须先创建`); + } +} +const mysqlAdapterSource = await readFile(resolve(root, 'src', 'database', 'mysql-adapter.mjs'), 'utf8'); +assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'MySQL 8.4 不支持 ADD COLUMN IF NOT EXISTS'); +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, 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/, '服务启动不得引用测试数据生成器'); +const testDataImportSource = await readFile(resolve(root, 'scripts', 'import-test-data.mjs'), 'utf8'); +assert.match(testDataImportSource, /loadEnvFile\(envPath\)/, '测试数据脚本应读取项目 .env'); +assert.match(testDataImportSource, /options\.has\('--mysql'\).*options\.has\('--sqlite'\)/, '测试数据脚本应支持显式选择 MySQL 或 SQLite'); +assert.doesNotMatch(testDataImportSource, /process\.env\.DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '测试数据脚本不得再强制使用 SQLite'); +assert.match(testDataImportSource, /nonEmpty\.length && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖'); +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, '正常首次建库不得预置考试'); +assert.equal(baseState.registrations.length, 0, '正常首次建库不得预置报名'); +const passwordProbeState = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` }); +assert.deepEqual(new Set(passwordProbeState.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置测试账号应统一使用密码 12345678'); +assert.ok(passwordProbeState.users.every(user => !user.mustChangePassword), '预置测试账号登录后不应强制修改统一密码'); +await rm(testDb, { force: true }); +await rm(`${testDb}-shm`, { force: true }); +await rm(`${testDb}-wal`, { 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 = testDb; +const seededTestDatabase = await createDatabase({ + root, + seed: () => { + // 全量 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, + id: 'admin_legacy_super', + username: 'legacy_super', + adminLevel: null, + displayName: '旧版超级管理员' + }); + return state; + } +}); +await seededTestDatabase.close(); + +const server = spawn(process.execPath, ['server.mjs'], { + cwd: root, + env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', REDIS_URL: '', TOTP_ENCRYPTION_KEY: 'test-only-totp-encryption-key-32-characters', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' }, + stdio: ['ignore', 'pipe', 'pipe'] +}); + +let serverError = ''; +server.stderr.on('data', chunk => { serverError += chunk.toString(); }); + +async function waitForServer() { + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await fetch(`${baseUrl}/api/public/home`); + if (response.ok) return; + } catch {} + await new Promise(resolveWait => setTimeout(resolveWait, 100)); + } + throw new Error(`服务器未能启动:${serverError}`); +} + +function createClient() { + let cookie = ''; + return { + async request(path, options = {}) { + const binaryBody = Buffer.isBuffer(options.body) || options.body instanceof ArrayBuffer || ArrayBuffer.isView(options.body); + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { ...(cookie ? { Cookie: cookie } : {}), ...(options.body && !binaryBody ? { 'Content-Type': 'application/json' } : {}), ...options.headers }, + body: options.body && typeof options.body !== 'string' && !binaryBody ? JSON.stringify(options.body) : options.body + }); + const setCookie = response.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + const type = response.headers.get('content-type') || ''; + const data = type.includes('application/json') ? await response.json() : type.includes('spreadsheetml') ? Buffer.from(await response.arrayBuffer()) : await response.text(); + return { response, data }; + } + }; +} + +const admin = createClient(); +const legacyAdmin = createClient(); +const schoolAdmin = createClient(); +const schoolAdmin2 = createClient(); +const classAdmin = createClient(); +const classAdmin2 = createClient(); +const candidate = createClient(); +const selfCandidate = createClient(); +const batchCandidate = createClient(); +const admissionSchoolClient = createClient(); +const anonymous = createClient(); + +try { + await waitForServer(); + + const sqliteFile = await readFile(testDb); + assert.equal(sqliteFile.subarray(0, 16).toString(), 'SQLite format 3\0', '测试持久化文件必须是真实 SQLite 数据库'); + 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'))); + const appSource = clientSources.join('\n'); + for (const modulePath of ['/src/client/api.mjs', '/src/client/ui.mjs', '/src/client/public-views.mjs', '/src/client/candidate-views.mjs', '/src/client/admin-views.mjs']) { + const moduleResponse = await anonymous.request(modulePath); + assert.equal(moduleResponse.response.status, 200, `${modulePath} 应作为浏览器模块提供`); + assert.match(moduleResponse.response.headers.get('content-type') || '', /text\/javascript/, `${modulePath} 应返回 JavaScript MIME 类型`); + } + assert.doesNotMatch(appSource, /onclick=["']event\.stopPropagation\(\)/, '弹窗不得截断内部按钮的委托点击事件'); + assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭'); + 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'); + const inspector = new DatabaseSync(testDb, { readOnly: true }); + const tableNames = inspector.prepare(` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `).all().map(row => row.name); + const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version; + const resultLockTriggers = inspector.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'trg_results_lock_archived_%'").all().map(row => row.name); + const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name); + const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_subjects)').all().map(row => row.name); + 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; + const seededCandidateCount = inspector.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'candidate'").get().count; + const seededRegistrationCounts = inspector.prepare(` + SELECT status, payment_status, COUNT(*) AS count + FROM registrations + GROUP BY status, payment_status + `).all(); + const seededArrangementCount = inspector.prepare('SELECT COUNT(*) AS count FROM exam_arrangement_plans').get().count; + const seededAdmitCardCount = inspector.prepare('SELECT COUNT(*) AS count FROM admit_cards').get().count; + const examPartitions = inspector.prepare('SELECT * FROM exam_data_partitions ORDER BY exam_id').all(); + const schoolPartitions = inspector.prepare('SELECT * FROM school_student_partitions ORDER BY school_id').all(); + const examPartitionCoverage = examPartitions.map(partition => { + for (const name of [partition.candidates_table, partition.admissions_table, partition.results_table, partition.centers_table]) { + assert.match(name, /^[a-z][a-z0-9_]{0,63}$/, '考试分表名必须是安全的数据库标识符'); + assert.ok(tableNames.includes(name), `考试专属表 ${name} 必须真实存在`); + } + return { + examId: partition.exam_id, + candidates: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.candidates_table}"`).get().count, + expectedCandidates: inspector.prepare('SELECT COUNT(*) AS count FROM registrations WHERE exam_id = ?').get(partition.exam_id).count, + admissions: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.admissions_table}"`).get().count, + expectedAdmissions: inspector.prepare(`SELECT COUNT(*) AS count FROM registration_subjects selected + JOIN registrations registration ON registration.id = selected.registration_id WHERE registration.exam_id = ?`).get(partition.exam_id).count, + results: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.results_table}"`).get().count, + expectedResults: inspector.prepare(`SELECT COUNT(*) AS count FROM results result + JOIN registrations registration ON registration.id = result.registration_id WHERE registration.exam_id = ?`).get(partition.exam_id).count + }; + }); + const schoolPartitionCoverage = schoolPartitions.map(partition => { + assert.match(partition.students_table, /^[a-z][a-z0-9_]{0,63}$/, '学校学生分表名必须是安全的数据库标识符'); + assert.ok(tableNames.includes(partition.students_table), `学校学生专属表 ${partition.students_table} 必须真实存在`); + return { + schoolId: partition.school_id, + students: inspector.prepare(`SELECT COUNT(*) AS count FROM "${partition.students_table}"`).get().count, + expectedStudents: inspector.prepare(`SELECT COUNT(*) AS count FROM users user + LEFT JOIN candidate_profiles profile ON profile.user_id = user.id + WHERE user.role = 'candidate' AND COALESCE(profile.school_id, user.school_id) = ?`).get(partition.school_id).count + }; + }); + inspector.close(); + assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在'); + assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); + 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), '考试专属表应与该场考试的考生、准考信息和成绩数据一致'); + assert.ok(schoolPartitions.length > 0, '每所学校都应登记学生专属物理表'); + assert.equal(schoolPartitions.length, seededSchoolCount, '学校学生分表登记不得缺漏'); + assert.ok(schoolPartitionCoverage.every(item => item.students === item.expectedStudents), '学校学生专属表应仅保存本校学生且数量一致'); + assert.ok(['archived_at', 'archived_by'].every(column => examColumns.includes(column)), '考试应保存不可逆归档时间和超级管理员'); + 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), '测试数据应包含大量已报名未缴费记录'); + assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'paid' && item.count >= 90), '测试数据应包含大量已报名且缴费记录'); + assert.equal(seededArrangementCount, 0, '测试数据不得预置考场编排计划'); + assert.equal(seededAdmitCardCount, 0, '测试数据不得预置准考证或考场座位'); + assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照'); + assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照'); + assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值'); + + const publicHome = await anonymous.request('/api/public/home'); + assert.equal(publicHome.response.status, 200); + assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知'); + assert.ok(publicHome.data.exams.some(exam => exam.subjects.length > 1), '公开考试应包含多个科目'); + assert.equal(publicHome.data.selfRegistrationEnabled, false, '自主注册默认应关闭'); + assert.deepEqual(publicHome.data.organization, { name: '环境变量考试中心', code: 'ENV-TEST', phone: '0518-1234 5678', address: '测试地址 1 号', email: 'service@example.test' }, '公开机构名称及联系方式应从环境变量读取'); + assert.equal(publicHome.data.siteCopy.heroTitle, '一次配置,', '首页主标语应从环境变量读取'); + assert.equal(publicHome.data.siteCopy.heroHighlight, '统一首页文案。', '首页强调标语应从环境变量读取'); + assert.equal(publicHome.data.siteCopy.footerNotice, '环境变量页脚提示', '首页页脚提示应从环境变量读取'); + const ckeditorAsset = await anonymous.request('/vendor/ckeditor5/ckeditor5.js'); + assert.equal(ckeditorAsset.response.status, 200, '服务端应提供自托管 CKEditor 浏览器包'); + assert.match(ckeditorAsset.data, /ImageInsertViaUrl/, '自托管 CKEditor 应包含图片 URL 插件'); + const ckeditorStyles = await anonymous.request('/vendor/ckeditor5/ckeditor5.css'); + assert.equal(ckeditorStyles.response.status, 200, '服务端应提供自托管 CKEditor 样式'); + + const closedRegister = await candidate.request('/api/auth/register', { + method: 'POST', + body: { password: 'Test12345!', name: '测试考生', gender: '男', schoolId: 'school_hz1', classId: 'class_hz1_302' } + }); + assert.equal(closedRegister.response.status, 403, '自主注册关闭时公开注册必须拒绝'); + + const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } }); + assert.equal(loginAdmin.data.user.role, 'admin'); + assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员'); + assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '账号应默认关闭 TOTP'); + const totpSetup = await admin.request('/api/auth/totp/setup', { method: 'POST', body: { currentPassword: '12345678' } }); + assert.equal(totpSetup.response.status, 200, '当前密码验证通过后应可开始绑定 TOTP'); + assert.match(totpSetup.data.qrCode, /^data:image\/png;base64,/, 'TOTP 二维码应在服务端本地生成为 PNG data URL'); + assert.match(totpSetup.data.uri, /^otpauth:\/\/totp\//, '绑定响应应提供标准 otpauth URI'); + const setupStep = Math.floor(Date.now() / 1000 / 30); + const totpEnable = await admin.request('/api/auth/totp/enable', { method: 'POST', body: { code: totpAtStep(totpSetup.data.secret, setupStep) } }); + assert.equal(totpEnable.response.status, 200, '正确动态验证码应完成 TOTP 绑定'); + assert.equal(totpEnable.data.recoveryCodes.length, 8, '启用后应一次性签发 8 个恢复码'); + assert.equal(totpEnable.data.user.totpEnabled, true, '安全用户信息应公开 TOTP 开启状态但不公开密钥'); + assert.equal(totpEnable.data.user.totpSecretEncrypted, undefined, 'API 不得返回加密后的 TOTP 密钥'); + const originalRecoveryCodes = totpEnable.data.recoveryCodes; + + await admin.request('/api/auth/logout', { method: 'POST' }); + const totpPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } }); + assert.equal(totpPasswordLogin.data.requiresTotp, true, '启用后密码验证不得直接创建登录会话'); + assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: '000000' } })).response.status, 401, '错误动态验证码应被拒绝'); + const loginStep = Math.floor(Date.now() / 1000 / 30) + 1; + const totpLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: totpPasswordLogin.data.challenge, code: totpAtStep(totpSetup.data.secret, loginStep) } }); + assert.equal(totpLogin.response.status, 200, '验证器动态验证码应完成第二步登录'); + + await admin.request('/api/auth/logout', { method: 'POST' }); + const recoveryPasswordLogin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } }); + const recoveryLogin = await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: recoveryPasswordLogin.data.challenge, code: originalRecoveryCodes[0] } }); + assert.equal(recoveryLogin.response.status, 200, '恢复码应可替代动态验证码登录'); + assert.equal(recoveryLogin.data.usedRecoveryCode, true, '恢复码登录应明确提醒用户'); + assert.equal((await admin.request('/api/auth/totp')).data.recoveryCodesRemaining, 7, '使用后的恢复码应立即失效并减少剩余数量'); + + await admin.request('/api/auth/logout', { method: 'POST' }); + const reusedRecoveryPassword = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } }); + assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[0] } })).response.status, 401, '恢复码不得重复使用'); + assert.equal((await admin.request('/api/auth/login/totp', { method: 'POST', body: { challenge: reusedRecoveryPassword.data.challenge, code: originalRecoveryCodes[1] } })).response.status, 200, '同一挑战剩余尝试次数内应允许改用有效恢复码'); + const regeneratedRecovery = await admin.request('/api/auth/totp/recovery-codes', { method: 'POST', body: { currentPassword: '12345678', code: originalRecoveryCodes[2] } }); + assert.equal(regeneratedRecovery.data.recoveryCodes.length, 8, '通过二次验证后应可轮换全部恢复码'); + const disableTotp = await admin.request('/api/auth/totp/disable', { method: 'POST', body: { currentPassword: '12345678', code: regeneratedRecovery.data.recoveryCodes[0] } }); + assert.equal(disableTotp.response.status, 200, '当前密码和第二因素均通过后应可关闭 TOTP'); + assert.equal((await admin.request('/api/auth/totp')).data.enabled, false, '关闭后应清空 TOTP 状态'); + await admin.request('/api/auth/logout', { method: 'POST' }); + assert.equal((await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } })).data.user.role, 'admin', '关闭后密码登录应恢复为单步会话'); + const loginLegacyAdmin = await legacyAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'legacy_super', password: '12345678' } }); + assert.equal(loginLegacyAdmin.data.user.adminLevel, 'super', '未保存层级的旧版管理员登录后应规范化为超级管理员'); + assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: '12345678' } })).data.user.adminLevel, 'school'); + assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: '12345678' } })).data.user.adminLevel, 'school'); + assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: '12345678' } })).data.user.adminLevel, 'class'); + assert.equal((await classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: '12345678' } })).data.user.adminLevel, 'class'); + assert.equal((await classAdmin2.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: '12345678', newPassword: 'ClassChanged123!' } })).response.status, 200, '管理员登录后应可修改自己的密码'); + assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: '12345678' } })).response.status, 401, '管理员改密后旧密码应立即失效'); + + assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号'); + const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [ + { classId: 'class_hz1_302', count: 1 } + ] } }); + assert.equal(singleAccountBatch.response.status, 202, '即使只申领一个账号,校级管理员也应提交审批'); + assert.ok(singleAccountBatch.data.batch.items.every(item => !item.candidateNumber), '最终批准前不得生成账号'); + const approvedSingleBatch = await admin.request(`/api/admin/candidate-account-batches/${singleAccountBatch.data.batch.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '单人名额核验通过' } }); + assert.equal(approvedSingleBatch.response.status, 200); + const candidateNumber = approvedSingleBatch.data.batch.items[0].candidateNumber; + const initialPassword = approvedSingleBatch.data.batch.items[0].initialPassword; + assert.match(candidateNumber, /^2026-HZ01-X-\d{4}$/, '占位账户应按当前规则生成固定报名号'); + const loginCandidate = await candidate.request('/api/auth/login', { method: 'POST', body: { username: candidateNumber, password: initialPassword } }); + assert.equal(loginCandidate.data.user.mustChangePassword, true, '学校下发账户首次登录必须修改初始密码'); + assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未修改初始密码前不得进入考生业务'); + const changedPassword = await candidate.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: initialPassword, newPassword: 'Test12345!' } }); + assert.equal(changedPassword.data.user.mustChangePassword, false, '修改密码后应解除首次登录限制'); + 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', 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'); + assert.equal(candidateCannotAdmin.response.status, 403, '考生不得访问管理接口'); + + assert.equal((await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: true } })).response.status, 200, '超级管理员应能开启自主注册'); + const openHome = await anonymous.request('/api/public/home'); + assert.equal(openHome.data.selfRegistrationEnabled, true, '公开端应同步自主注册开关'); + const selfRegister = await selfCandidate.request('/api/auth/register', { method: 'POST', body: { password: 'Self12345!', name: '自主注册考生', gender: '女', schoolId: 'school_hz3', classId: 'class_hz3_301' } }); + assert.equal(selfRegister.response.status, 201, '开关开启后考生应可自主申请固定报名号'); + assert.match(selfRegister.data.registrationNumber, /^2026-HZ03-F-\d{4}$/); + await admin.request('/api/admin/settings/self-registration', { method: 'PUT', body: { enabled: false } }); + + const schoolDirectory = await admin.request('/api/admin/schools'); + assert.equal(schoolDirectory.response.status, 200, '超级管理员应有学校管理入口'); + assert.ok(schoolDirectory.data.schools.every(item => Number.isInteger(item.classCount) && Number.isInteger(item.adminCount)), '学校目录应汇总班级和管理员数量'); + assert.equal((await schoolAdmin.request('/api/admin/schools')).response.status, 403, '校级管理员不得跨校维护学校档案'); + 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, '超级管理员应能编辑和停用学校'); + assert.equal(disableSchool.data.school.active, false); + assert.equal(disableSchool.data.school.name, '海州市第四实验中学'); + assert.equal((await schoolAdmin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { active: true } })).response.status, 403, '校级管理员不得启停学校'); + + 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'); + assert.ok(legacySuperCenters.data.centers.some(item => item.schoolId === 'school_hz3'), '旧版超级管理员应能跨校读取学校维护的正式考点'); + assert.ok(legacySuperCenters.data.centers.flatMap(item => item.rooms).some(item => item.centerId === 'center_hz3'), '旧版超级管理员应能读取其他学校维护的结构化考场'); + const newCenter = await schoolAdmin.request('/api/admin/centers', { method: 'POST', body: { + code: 'HZ01-EAST', name: '海州市第一中学东区考点', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 8 号', contact: '0518-12345678', + managerName: '测试负责人', managerPhone: '13800001234', emergencyPhone: '0518-120', gateOpenTime: '07:00', transport: '东门入场', status: 'active', notes: '自动化测试档案', + rooms: [{ code: 'E001', name: '东区第 001 考场', building: '东教学楼', floor: '1 层', capacity: 30, seatPlan: '按现场座次表编排', roomType: 'standard', status: 'active', notes: '' }] + } }); + assert.equal(newCenter.response.status, 202, '新增考点应创建审批申请而不是直接落库'); + assert.equal(newCenter.data.changeRequest.schoolId, 'school_hz1', '校级管理员新增考点必须自动归属本校'); + const legacyPendingCenters = await legacyAdmin.request('/api/admin/centers'); + const legacyPendingCenter = legacyPendingCenters.data.changeRequests.find(item => item.id === newCenter.data.changeRequest.id); + assert.equal(legacyPendingCenter?.rooms[0]?.code, 'E001', '旧版超级管理员应能读取学校提交的待审批考点与考场快照'); + const beforeCenterApproval = await schoolAdmin.request('/api/admin/centers'); + assert.ok(!beforeCenterApproval.data.centers.some(item => item.code === 'HZ01-EAST'), '考点审批通过前不得进入正式档案'); + const approveCenter = await admin.request(`/api/admin/center-change-requests/${newCenter.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考务条件核验通过' } }); + assert.equal(approveCenter.response.status, 200, '超级管理员应可审批考点变更'); + const afterCenterApproval = await schoolAdmin.request('/api/admin/centers'); + const createdCenter = afterCenterApproval.data.centers.find(item => item.code === 'HZ01-EAST'); + assert.equal(createdCenter.rooms.length, 1, '审批通过后应同时写入结构化考场'); + assert.equal(createdCenter.totalCapacity, 30, '考场容量应汇总到考点档案'); + const legacyApprovedCenter = (await legacyAdmin.request('/api/admin/centers')).data.centers.find(item => item.code === 'HZ01-EAST'); + assert.equal(legacyApprovedCenter?.rooms[0]?.code, 'E001', '旧版超级管理员应能读取学校维护并审批生效的考点与考场'); + const centerUpdate = await schoolAdmin.request(`/api/admin/centers/${createdCenter.id}`, { method: 'PATCH', body: { + ...createdCenter, managerName: '变更后负责人', rooms: [ + ...createdCenter.rooms, + { code: 'E002', name: '东区第 002 考场', building: '东教学楼', floor: '1 层', capacity: 25, seatPlan: '无障碍座位现场标记', roomType: 'accessible', status: 'active', notes: '无障碍通道' } + ] + } }); + assert.equal(centerUpdate.response.status, 202, '修改考点和考场也必须提交审批'); + const unchangedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id); + assert.equal(unchangedCenter.managerName, '测试负责人', '变更审批通过前正式档案不得改变'); + await admin.request(`/api/admin/center-change-requests/${centerUpdate.data.changeRequest.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '同意扩充考场' } }); + const changedCenter = (await schoolAdmin.request('/api/admin/centers')).data.centers.find(item => item.id === createdCenter.id); + assert.equal(changedCenter.managerName, '变更后负责人', '审批通过后正式考点档案应更新'); + assert.equal(changedCenter.rooms.length, 2, '审批通过后考场明细应按申请快照整体替换'); + assert.equal((await classAdmin.request('/api/admin/centers')).response.status, 403, '班级管理员不得读取或维护考点'); + + const organization = await schoolAdmin.request('/api/admin/school-organization'); + assert.equal(organization.response.status, 200, '校级管理员应有本校组织管理入口'); + assert.ok(organization.data.classes.every(item => item.schoolId === 'school_hz1'), '组织页只能返回本校班级'); + const createClass = await schoolAdmin.request('/api/admin/classes', { method: 'POST', body: { grade: '高二', name: '高二(9)班', active: true } }); + assert.equal(createClass.response.status, 201, '校级管理员应能新增本校班级'); + const newClassId = createClass.data.schoolClass.id; + const createClassAdmin = await schoolAdmin.request('/api/admin/admins', { method: 'POST', body: { displayName: '批量测试班管', username: 'class_excel_test', password: 'ClassExcel123!', classId: newClassId } }); + assert.equal(createClassAdmin.response.status, 201, '校级管理员应能创建并绑定本校班级管理员'); + assert.equal(createClassAdmin.data.admin.adminLevel, 'class'); + assert.equal(createClassAdmin.data.admin.schoolId, 'school_hz1'); + assert.equal((await schoolAdmin.request(`/api/admin/admins/${createClassAdmin.data.admin.id}`, { method: 'PATCH', body: { classId: 'class_hz1_301', active: false } })).response.status, 200, '校级管理员应能调整或停用本校班级管理员'); + assert.equal((await admin.request('/api/admin/classes', { method: 'POST', body: { grade: '越权', name: '越权班' } })).response.status, 403, '超级管理员不应代替学校维护班级'); + + const classTemplate = await schoolAdmin.request('/api/admin/excel/classes?template=1'); + assert.equal(classTemplate.response.status, 200, '班级 Excel 模板应可下载'); + 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 导入本校班级'); + assert.equal(classImport.data.count, 1); + const quotaFile = Buffer.from(await buildWorkbook('account_quotas', [{ className: '高三(1)班', count: 12 }, { className: '高三(2)班', count: 8 }])); + const quotaImport = await schoolAdmin.request('/api/admin/excel/account_quotas', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: quotaFile }); + assert.deepEqual(quotaImport.data.quotas.map(item => item.count), [12, 8], '报名号配额 Excel 应解析并回填各班数量'); + const centerTemplate = await schoolAdmin.request('/api/admin/excel/centers?template=1'); + const centerWorkbook = new ExcelJS.Workbook(); await centerWorkbook.xlsx.load(centerTemplate.data); + const centerHeaders = centerWorkbook.worksheets[0].getRow(2).values.map(String); + assert.ok(centerHeaders.includes('座位编排说明'), '考场 Excel 应使用座位编排说明'); + assert.ok(!centerHeaders.some(item => item.includes('座位起号') || item.includes('座位止号')), '考场 Excel 不应再出现座位起止号'); + assert.equal(centerWorkbook.worksheets[0].getCell('S3').value, '001', '考场代码应保留前导零'); + assert.equal(centerWorkbook.worksheets[0].getCell('S3').numFmt, '@', '标识号列应固定为文本格式'); + const classAdminImportFile = Buffer.from(await buildWorkbook('class_admins', [{ schoolCode: 'HZ01', className: '高一(8)班', displayName: 'Excel 班管', username: 'excel_class_admin', initialPassword: 'ExcelClass123!', status: '启用' }])); + const classAdminImport = await schoolAdmin.request('/api/admin/excel/class_admins', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classAdminImportFile }); + assert.equal(classAdminImport.data.count, 1, '班级管理员应支持 Excel 批量导入'); + const centerImportFile = Buffer.from(await buildWorkbook('centers', [{ + schoolCode: 'HZ01', centerCode: 'HZ01-XLSX', centerName: 'Excel 导入考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', address: '表格路 1 号', managerName: '表格负责人', managerPhone: '13800138001', contact: '0518-88000000', emergencyPhone: '0518-120', gateOpenTime: '07:10', transport: '北门入场', centerStatus: '启用', centerNotes: 'Excel 自动化测试', + roomCode: 'X001', roomName: 'Excel 第 001 考场', building: '综合楼', floor: '2 层', capacity: 32, seatPlan: '按现场桌贴编排', roomType: '标准考场', roomStatus: '启用', roomNotes: '' + }])); + const centerImport = await schoolAdmin.request('/api/admin/excel/centers', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: centerImportFile }); + assert.equal(centerImport.response.status, 200, '考点考场 Excel 导入应创建审批申请'); + assert.equal(centerImport.data.count, 1); + const excelPendingCenter = (await schoolAdmin.request('/api/admin/centers')).data.changeRequests.find(item => item.code === 'HZ01-XLSX'); + assert.ok(excelPendingCenter && excelPendingCenter.status === 'pending', 'Excel 导入的考点必须进入审批而非直接生效'); + assert.equal(excelPendingCenter.rooms[0].seatPlan, '按现场桌贴编排'); + assert.equal((await schoolAdmin.request('/api/admin/excel/candidates')).response.status, 200, '考生资料应支持按学校范围导出 Excel'); + assert.equal((await schoolAdmin.request('/api/admin/excel/results')).response.status, 200, '成绩台账应支持按学校范围导出 Excel'); + assert.equal((await classAdmin.request('/api/admin/excel/centers')).response.status, 403, '班级管理员不得通过 Excel 接口越权导出考场'); + + const numberRules = await admin.request('/api/admin/number-rules'); + assert.ok(numberRules.data.activeRule.segments.some(item => item.type === 'sequence'), '系统应提供启用的报名号规则'); + const saveNumberRule = await admin.request('/api/admin/number-rules', { method: 'POST', body: { + id: numberRules.data.activeRule.id, name: '测试组合规则', separator: '-', segments: [ + { type: 'year', width: 4 }, { type: 'school_code' }, { type: 'gender' }, { type: 'sequence', width: 4 } + ] + } }); + assert.equal(saveNumberRule.response.status, 200, '超级管理员应可自由组合并启用报名号逻辑'); + assert.equal(numberRules.data.batchCandidates, undefined, '超级管理员号码规则页不应再提供直接批量建号数据'); + assert.equal((await admin.request('/api/admin/registration-numbers/batch', { method: 'POST', body: {} })).response.status, 404, '旧的超级管理员直接批量生成接口应移除'); + + const candidatesBeforeBatch = (await schoolAdmin.request('/api/admin/candidates')).data.candidates.length; + const createAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [ + { classId: 'class_hz1_301', count: 2 }, { classId: 'class_hz1_302', count: 1 } + ] } }); + assert.equal(createAccountBatch.response.status, 202, '校级管理员应可按多个班级数量提交批量建号申请'); + assert.equal(createAccountBatch.data.batch.totalCount, 3, '批次总数应等于各班级配额之和'); + assert.deepEqual(createAccountBatch.data.batch.quotas.map(item => item.count).sort(), [1, 2], '批次应保留每个班级的独立数量'); + assert.ok(createAccountBatch.data.batch.items.every(item => !item.candidateNumber && !item.initialPassword), '审批通过前不得提前生成报名号或初始密码'); + assert.equal((await schoolAdmin.request('/api/admin/candidates')).data.candidates.length, candidatesBeforeBatch, '审批通过前不得创建任何考生账户'); + const batchId = createAccountBatch.data.batch.id; + assert.equal((await schoolAdmin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved' } })).response.status, 403, '校级申请人不得绕过超级管理员审批'); + const batchFlowList = await admin.request('/api/admin/workflow-instances'); + const batchFlow = batchFlowList.data.instances.find(item => item.businessType === 'candidate_account_batch' && item.businessId === batchId); + assert.equal(batchFlow.batchTotalCount, 3, '超级管理员流程中心应展示批次总数'); + assert.deepEqual(batchFlow.accountBatch.quotas.map(item => item.count).sort(), [1, 2], '流程详情应展示班级配额'); + const approveAccountBatch = await admin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学籍名额核验通过' } }); + assert.equal(approveAccountBatch.response.status, 200, '超级管理员最终批准后应生成整批账号'); + assert.equal(approveAccountBatch.data.batch.status, 'approved'); + assert.equal(approveAccountBatch.data.batch.items.length, 3); + assert.ok(approveAccountBatch.data.batch.items.every(item => /^2026-HZ01-X-\d{4}$/.test(item.candidateNumber)), '批量占位账户应按规则使用未知性别 X 生成固定号码'); + assert.equal(new Set(approveAccountBatch.data.batch.items.map(item => item.candidateNumber)).size, 3, '批量生成的报名号必须唯一'); + assert.ok(approveAccountBatch.data.batch.items.every(item => item.initialPassword.startsWith('Init-')), '结果应向校级管理员返回随机初始密码'); + assert.equal((await schoolAdmin.request('/api/admin/candidates')).data.candidates.length, candidatesBeforeBatch + 3, '最终批准应原子创建全部考生账户'); + assert.equal((await admin.request(`/api/admin/candidate-account-batches/${batchId}`, { method: 'PATCH', body: { status: 'approved' } })).response.status, 404, '重复审批不得再次生成账号'); + const schoolBatchResult = await schoolAdmin.request('/api/admin/candidate-account-batches'); + const returnedBatch = schoolBatchResult.data.batches.find(item => item.id === batchId); + assert.ok(returnedBatch.items.every(item => item.candidateNumber && item.initialPassword), '批准结果应回到校级管理员的批次详情'); + const firstIssued = returnedBatch.items[0]; + const batchLogin = await batchCandidate.request('/api/auth/login', { method: 'POST', body: { username: firstIssued.candidateNumber, password: firstIssued.initialPassword } }); + assert.equal(batchLogin.data.user.mustChangePassword, true, '批量下发账户首次登录也必须强制修改密码'); + const archiveTarget = returnedBatch.items.find(item => item.classId === 'class_hz1_301'); + const archiveCandidate = createClient(); + assert.equal((await archiveCandidate.request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 200); + assert.equal((await admin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } })).response.status, 403, '超级管理员不得代替学校归档考生账户'); + assert.equal((await classAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } })).response.status, 403, '班级管理员不得批量归档考生账户'); + const archivedAccounts = await schoolAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'class', scopeValue: 'class_hz1_301', archived: true } }); + assert.equal(archivedAccounts.response.status, 200, '校级管理员应可按班级归档考生账户'); + assert.ok(archivedAccounts.data.count >= 2, '按班级归档应批量更新该班考生'); + const archivedDirectory = await schoolAdmin.request('/api/admin/candidates'); + const archivedProfile = archivedDirectory.data.candidates.find(item => item.candidateNumber === archiveTarget.candidateNumber); + assert.equal(archivedProfile.accountArchived, true, '考生目录应显示归档状态'); + assert.equal((await archiveCandidate.request('/api/auth/me')).data.user, null, '归档应立即使考生现有会话失效'); + assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 401, '归档账户不得登录'); + assert.equal((await admin.request(`/api/admin/candidates/${archivedProfile.id}/reset-password`, { method: 'POST' })).response.status, 409, '归档账户恢复前不得重置密码'); + const restoredAccounts = await schoolAdmin.request('/api/admin/candidate-accounts/archive', { method: 'POST', body: { scopeType: 'grade', scopeValue: '高三', archived: false } }); + assert.equal(restoredAccounts.response.status, 200, '校级管理员应可按年级恢复考生账户'); + const resetPassword = await admin.request(`/api/admin/candidates/${archivedProfile.id}/reset-password`, { method: 'POST' }); + assert.equal(resetPassword.response.status, 200, '超级管理员应可为未归档考生重置密码'); + assert.match(resetPassword.data.temporaryPassword, /^Reset-/, '重置服务应返回一次性临时密码'); + assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: archiveTarget.initialPassword } })).response.status, 401, '重置后原密码应失效'); + const resetLogin = await createClient().request('/api/auth/login', { method: 'POST', body: { username: archiveTarget.candidateNumber, password: resetPassword.data.temporaryPassword } }); + assert.equal(resetLogin.data.user.mustChangePassword, true, '使用重置临时密码登录后必须先改密'); + assert.equal((await schoolAdmin.request(`/api/admin/candidates/${archivedProfile.id}`, { method: 'DELETE' })).response.status, 405, '任何管理员都不得删除考生账户'); + + const now = Date.now(); + const hour = 60 * 60 * 1000; + const createExam = await admin.request('/api/admin/exams', { + method: 'POST', + body: { + name: '系统全流程测试考试', code: 'EX-TEST-FLOW', description: '自动化测试创建的多科目考试', + registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(), + examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(), + admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(), + location: '测试考点', status: 'published', subjects: [ + { name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 20, fullScore: 150, passRule: 'fixed_score', passValue: 90 }, + { name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 20, fullScore: 150, passScore: 90 }, + { name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 25, fullScore: 150, passRule: 'rank_percent', passValue: 60 } + ] + } + }); + assert.equal(createExam.response.status, 201); + assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试'); + 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, '创建考试时应同步登记该场考试的专属物理表'); + for (const table of [createdExamPartition.candidates_table, createdExamPartition.admissions_table, createdExamPartition.results_table, createdExamPartition.centers_table]) { + assert.match(table, /^[a-z][a-z0-9_]{0,63}$/); + assert.ok(createdExamInspector.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), `新考试专属表 ${table} 应立即创建`); + } + createdExamInspector.close(); + const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } }); + assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划'); + + const draftResponse = await admin.request('/api/admin/exams', { + method: 'POST', + body: { + name: '待完善考试草稿', code: 'EX-TEST-DRAFT', description: '等待管理员继续配置', + registrationStart: new Date(now + 72 * hour).toISOString(), registrationEnd: new Date(now + 96 * hour).toISOString(), + examStart: new Date(now + 120 * hour).toISOString(), examEnd: new Date(now + 132 * hour).toISOString(), + admitDownloadStart: new Date(now + 100 * hour).toISOString(), admitDownloadEnd: new Date(now + 119 * hour).toISOString(), + location: '待定考点', status: 'draft', subjects: ['待定科目'] + } + }); + const editedDraft = await admin.request(`/api/admin/exams/${draftResponse.data.exam.id}`, { + method: 'PATCH', + body: { name: '已完善考试草稿', location: '测试中心', status: 'draft', passPolicy: 'rank_percent', passValue: 30, subjects: [ + { name: '语文', date: new Date(now + 120 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 20, fullScore: 120, passScore: 72 }, + { name: '数学', date: new Date(now + 121 * hour).toISOString().slice(0, 10), start: '14:00', end: '16:00', fee: 20, fullScore: 180, passScore: 108 } + ] } + }); + assert.equal(editedDraft.response.status, 200, '管理员应可继续编辑考试草稿'); + assert.deepEqual(editedDraft.data.exam.subjects.map(subject => subject.name), ['语文', '数学'], '草稿编辑应保存科目配置'); + const refreshedAdminExams = await admin.request('/api/admin/exams'); + const persistedDraft = refreshedAdminExams.data.exams.find(item => item.id === draftResponse.data.exam.id); + assert.equal(persistedDraft.name, '已完善考试草稿', '草稿修改应持久化'); + assert.equal(persistedDraft.subjects.length, 2, '草稿科目修改应持久化'); + assert.equal(persistedDraft.totalScore, 300, '考试总分应由结构化科目满分自动汇总'); + assert.equal(persistedDraft.passPolicy, 'rank_percent', '考试应持久化排名百分比合格策略'); + assert.equal(persistedDraft.passValue, 30, '合格策略数值应持久化'); + assert.equal(persistedDraft.subjects[1].passScore, 108, '每科应独立保存满分与单科合格分'); + + const beforeApproval = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id] } }); + assert.equal(beforeApproval.response.status, 403, '资料审核前不得报名考试'); + + const candidates = await admin.request('/api/admin/candidates'); + const profile = candidates.data.candidates.find(item => item.candidateNumber === candidateNumber); + assert.ok(profile, '管理员应能看到新注册考生'); + assert.equal(profile.address, '测试路 1 号', '详细门牌地址应独立保存'); + assert.deepEqual( + [profile.provinceCode, profile.cityCode, profile.districtCode, profile.provinceName, profile.cityName, profile.districtName], + ['320000', '320700', '320706', '江苏省', '连云港市', '海州区'], + '管理员应能审核考生自主填写且经过服务端规范化的省市区县信息' + ); + const schoolCandidates = await schoolAdmin.request('/api/admin/candidates'); + assert.ok(schoolCandidates.data.candidates.some(item => item.id === profile.id), '校级管理员应看到本校考生'); + const classCandidates = await classAdmin.request('/api/admin/candidates'); + assert.ok(classCandidates.data.candidates.some(item => item.id === profile.id), '班级管理员应看到本班考生'); + const classCannotReview = await classAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved' } }); + assert.equal(classCannotReview.response.status, 403, '班级管理员不得审核考生资料'); + + const schoolApproveProfile = await schoolAdmin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校学籍复核通过' } }); + assert.equal(schoolApproveProfile.data.profile.status, 'pending', '校级初审后应进入超级管理员终审'); + const approveProfile = await admin.request(`/api/admin/candidates/${profile.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考试中心终审通过' } }); + assert.equal(approveProfile.data.profile.status, 'approved'); + + const submitRegistration = await candidate.request('/api/candidate/registrations', { + method: 'POST', body: { examId: exam.id, subjectIds: [exam.subjects[0].id, exam.subjects[2].id] } + }); + assert.equal(submitRegistration.response.status, 201); + assert.equal(submitRegistration.data.registration.subjectIds.length, 2, '考生应可自主选择多个科目'); + const registrationId = submitRegistration.data.registration.id; + + const adminRegistrations = await admin.request('/api/admin/registrations'); + 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'); + assert.ok(registrationFlow, '报名应建立可追踪的审批实例'); + const assignedSchoolClient = registrationFlow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; + const transferTargetId = registrationFlow.assignee.id === 'usr_school_admin_2' ? 'usr_school_admin' : 'usr_school_admin_2'; + const transferTargetClient = transferTargetId === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; + const transfer = await assignedSchoolClient.request(`/api/admin/workflow-instances/${registrationFlow.id}/transfer`, { method: 'PATCH', body: { assigneeId: transferTargetId, note: '同级管理员协办' } }); + assert.equal(transfer.data.workflow.assignee.id, transferTargetId, '同级管理员之间应可转交流程'); + const schoolApproveRegistration = await transferTargetClient.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校报名初审通过' } }); + assert.equal(schoolApproveRegistration.data.registration.status, 'pending', '校级初审后报名仍应待终审'); + + const allFlows = await admin.request('/api/admin/workflow-instances'); + const supervisedFlow = allFlows.data.instances.find(item => item.id === registrationFlow.id); + assert.equal(supervisedFlow.currentStep, 2, '超级管理员应看到全部流程及当前节点'); + const supervisedReturn = await admin.request(`/api/admin/workflow-instances/${registrationFlow.id}/supervise`, { method: 'PATCH', body: { currentStep: 1, assigneeId: 'usr_school_admin', note: '抽查后退回学校复核' } }); + assert.equal(supervisedReturn.data.workflow.currentStep, 1, '超级管理员应可监督并退回流程节点'); + await schoolAdmin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校再次复核通过' } }); + const approveRegistration = await admin.request(`/api/admin/registrations/${registrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '科目与资格终审通过' } }); + assert.equal(approveRegistration.data.registration.status, 'approved'); + assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号'); + assert.equal(approveRegistration.data.registration.paymentStatus, 'unpaid', '报名终审不得代替线下缴费确认'); + + const superPaymentList = await admin.request('/api/admin/payments'); + const schoolPaymentList = await schoolAdmin.request('/api/admin/payments'); + const classPaymentList = await classAdmin.request('/api/admin/payments'); + assert.ok(superPaymentList.data.registrations.some(item => item.id === registrationId), '超级管理员应能查看全部范围缴费名单'); + assert.ok(schoolPaymentList.data.registrations.some(item => item.id === registrationId), '校级管理员应能查看本校缴费名单'); + assert.ok(classPaymentList.data.registrations.some(item => item.id === registrationId), '班级负责人应能查看本班缴费名单'); + assert.equal(classPaymentList.data.canConfirmPayment, true, '班级负责人应取得缴费确认能力'); + assert.equal(superPaymentList.data.canUpdatePayment, true, '超级管理员应能修改全局范围缴费状态'); + assert.equal(schoolPaymentList.data.canUpdatePayment, true, '校级管理员应能修改本校范围缴费状态'); + assert.ok(superPaymentList.data.registrations.find(item => item.id === registrationId)?.gradeName, '缴费名单应提供年级字段用于筛选'); + const superConfirmedPayment = await admin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'paid' } }); + assert.equal(superConfirmedPayment.response.status, 200, '超级管理员应能将负责范围考生标记为已缴费'); + const schoolRevertedPayment = await schoolAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'unpaid' } }); + assert.equal(schoolRevertedPayment.response.status, 200, '校级管理员应能将本校考生改回待缴费'); + assert.equal(schoolRevertedPayment.data.payment.paidAt, null, '改回待缴费后应清除原确认时间'); + assert.equal(schoolRevertedPayment.data.payment.paidBy, null, '改回待缴费后应清除原确认人'); + const confirmedPayment = await classAdmin.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH', body: { status: 'paid' } }); + assert.equal(confirmedPayment.response.status, 200, '班级负责人应能在线下收款后确认缴费'); + assert.equal(confirmedPayment.data.payment.status, 'paid'); + assert.ok(confirmedPayment.data.payment.paidAt && confirmedPayment.data.payment.paidByName, '缴费确认应记录时间和办理人'); + assert.equal((await classAdmin2.request(`/api/admin/payments/${registrationId}`, { method: 'PATCH' })).response.status, 409, '重复确认缴费应被拒绝'); + const paidCandidateRegistration = (await candidate.request('/api/candidate/registrations')).data.registrations.find(item => item.id === registrationId); + assert.equal(paidCandidateRegistration.paymentStatus, 'paid', '班级确认后考生端应同步显示已缴费'); + assert.ok(paidCandidateRegistration.paidAt && paidCandidateRegistration.paidByName, '考生应能看到缴费确认记录'); + assert.equal(paidCandidateRegistration.amountDue, 45, '应缴金额应按所选科目费用汇总'); + for (const [client, label] of [[admin, '超级管理员'], [schoolAdmin, '校级管理员'], [classAdmin, '班级负责人']]) { + const exportPaymentList = await client.request('/api/admin/excel/payments'); + assert.equal(exportPaymentList.response.status, 200, `${label}应能导出管理范围内的缴费名单`); + assert.ok(Buffer.isBuffer(exportPaymentList.data) && exportPaymentList.data.length > 1000, `${label}缴费名单应为有效 Excel 文件`); + } + + const arrangementContext = await admin.request('/api/admin/admission-arrangements'); + assert.equal(arrangementContext.data.rules.length, 4, '系统应预置四种准考证号规则'); + assert.deepEqual(arrangementContext.data.mixingScopes.map(item => item.code), ['class', 'school', 'district', 'city', 'province'], '应支持班、校、县区、市、省五级混编'); + for (const scope of arrangementContext.data.mixingScopes) { + const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: { + mixingScope: scope.code, numberRuleId: arrangementContext.data.rules[0].id, seed: `scope-${scope.code}` + } }); + assert.equal(preview.response.status, 200, `${scope.name}应通过容量与同考点约束预检`); + } + for (const rule of arrangementContext.data.rules) { + const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: { + mixingScope: 'school', numberRuleId: rule.id, seed: `rule-${rule.code}` + } }); + assert.equal(preview.response.status, 200, `${rule.name}应能生成有效号码`); + assert.equal(new Set(preview.data.samples.map(item => item.number)).size, preview.data.samples.length, `${rule.name}生成的样例号码不得重复`); + } + const arrangementBody = { mixingScope: 'school', numberRuleId: 'admit_rule_district_room_seat', seed: 'system-test-stable' }; + const arrangementPreview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: arrangementBody }); + assert.equal(arrangementPreview.response.status, 200, '正式写入前应可预检整场编排'); + assert.ok(arrangementPreview.data.summary.subjectAssignmentCount >= arrangementPreview.data.summary.candidateCount, '预检应统计全部科次座位'); + const generateAdmit = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody }); + const generatedCard = generateAdmit.data.cards.find(item => item.registrationId === registrationId); + assert.ok(generatedCard.number, '管理员应能按整场考试批量生成准考证号'); + assert.ok(generatedCard.centerCode && generatedCard.centerAddress, '准考证应包含考点代码和完整地址'); + assert.equal(new Set(generatedCard.assignments.map(item => item.centerId)).size, 1, '同一考生所有科目必须固定在同一考点'); + assert.equal(generatedCard.assignments.length, submitRegistration.data.registration.subjectIds.length, '每个报考科目都必须有独立考场座位'); + assert.ok(generatedCard.assignments.every(item => item.examRoomCode && item.roomName && item.roomCode && item.building && item.floor), '逐科安排必须同时保存考试考场序号、物理场地和楼栋楼层'); + const codesByPhysicalRoom = Map.groupBy + ? Map.groupBy(generateAdmit.data.cards.flatMap(card => card.assignments), item => item.roomId) + : generateAdmit.data.cards.flatMap(card => card.assignments).reduce((groups, item) => groups.set(item.roomId, [...(groups.get(item.roomId) || []), item]), new Map()); + assert.ok([...codesByPhysicalRoom.values()].every(items => new Set(items.map(item => item.examRoomCode)).size === 1), '同一场考试内,同一物理考场跨科目必须保持同一个考试考场序号'); + assert.equal((await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: {} })).response.status, 410, '旧单人生成入口应明确停用'); + + const candidateRegistrations = await candidate.request('/api/candidate/registrations'); + const candidateRegistration = candidateRegistrations.data.registrations.find(item => item.id === registrationId); + assert.ok(candidateRegistration.admitCard, '考生端应看到已生成准考证'); + + const downloadAdmit = await candidate.request(`/api/candidate/registrations/${registrationId}/admit-card`); + assert.equal(downloadAdmit.response.status, 200, '规定时间内应可下载准考证'); + assert.match(downloadAdmit.data, /测试考生新名/); + assert.match(downloadAdmit.data, /考试考场序号/); + assert.match(downloadAdmit.data, /考场通用名称 \/ 场地代码/); + assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].building)); + assert.match(downloadAdmit.data, new RegExp(generatedCard.assignments[0].floor)); + assert.match(downloadAdmit.response.headers.get('content-disposition') || '', /attachment/); + + const schoolAdmitContext = await schoolAdmin.request('/api/admin/admission-arrangements'); + assert.equal(schoolAdmitContext.response.status, 200, '校级管理员应有校内准考证入口'); + assert.equal(schoolAdmitContext.data.canArrange, false, '校级管理员不能执行整场编排'); + assert.ok(schoolAdmitContext.data.registrations.every(item => item.candidate.schoolId === 'school_hz1'), '校级准考证台账必须限制为本校考生'); + const classAdmitContext = await classAdmin.request('/api/admin/admission-arrangements'); + assert.equal(classAdmitContext.response.status, 200, '班级管理员应有本班准考证入口'); + assert.ok(classAdmitContext.data.registrations.every(item => item.candidate.classId === 'class_hz1_302'), '班级准考证台账必须限制为本班考生'); + + const schoolBatchCards = await schoolAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`); + assert.equal(schoolBatchCards.response.status, 200, '校级管理员应可批量下载校内准考证'); + assert.match(schoolBatchCards.response.headers.get('content-disposition') || '', /attachment/); + assert.match(schoolBatchCards.data, /考试考场序号/); + const classBatchCards = await classAdmin.request(`/api/admin/admission-exports/admit-cards?examId=${exam.id}`); + assert.equal(classBatchCards.response.status, 200, '班级管理员应可批量下载本班准考证'); + assert.match(classBatchCards.data, /测试考生新名/); + + const classInfoExport = await classAdmin.request(`/api/admin/admission-exports/info?examId=${exam.id}`); + assert.equal(classInfoExport.response.status, 200, '班级管理员应可导出本班准考证信息台账'); + const classInfoBook = new ExcelJS.Workbook(); + await classInfoBook.xlsx.load(classInfoExport.data); + const classInfoSheet = classInfoBook.getWorksheet('准考证信息'); + assert.ok(classInfoSheet, '准考证信息导出应有独立工作表'); + assert.equal(classInfoSheet.rowCount, generatedCard.assignments.length + 2, '准考证信息应按考生逐科导出'); + assert.equal(classInfoSheet.getCell('D3').value, '测试考生新名'); + assert.equal(classInfoSheet.getCell('O3').value, generatedCard.assignments[0].examRoomCode, '信息台账应明确导出考试考场序号'); + assert.equal(classInfoSheet.getCell('P3').value, generatedCard.assignments[0].roomName, '信息台账应另列考场通用名称'); + + const schoolMaterials = await schoolAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`); + assert.equal(schoolMaterials.response.status, 200, '校级管理员应可导出本校维护考点的现场物料'); + const materialsBook = new ExcelJS.Workbook(); + await materialsBook.xlsx.load(schoolMaterials.data); + assert.deepEqual(materialsBook.worksheets.map(sheet => sheet.name), ['桌贴', '门贴', '考场签名单'], '考点物料应包含桌贴、门贴和考场签名单三张表'); + const deskStickerSheet = materialsBook.getWorksheet('桌贴'); + assert.ok(deskStickerSheet.rowCount >= 8, '每张方形桌贴应占用完整标签卡片区域'); + assert.equal(deskStickerSheet.pageSetup.orientation, 'portrait', '方形桌贴应使用 A4 纵向打印'); + assert.equal(deskStickerSheet.getRow(1).height, 32.5, '桌贴应使用固定行高形成方形标签'); + assert.equal(deskStickerSheet.getColumn(1).width, deskStickerSheet.getColumn(3).width, '桌贴卡片各列应等宽'); + assert.ok(deskStickerSheet.model.merges.includes('A1:C1'), '第一张桌贴应跨三列形成完整方形卡片'); + assert.match(String(deskStickerSheet.getCell('A3').value), /考试考场序号/); + assert.equal(deskStickerSheet.getCell('A4').text, generatedCard.assignments[0].examRoomCode, '桌贴应突出考试考场序号'); + assert.match(String(deskStickerSheet.getCell('A5').value), new RegExp(generatedCard.assignments[0].seat), '桌贴应突出座位号'); + assert.match(String(materialsBook.getWorksheet('门贴').getCell('A1').value), /考场门贴/); + assert.equal((await classAdmin.request(`/api/admin/admission-exports/center-materials?examId=${exam.id}`)).response.status, 403, '班级管理员不得导出考点级现场物料'); + + const paginationRows = Array.from({ length: 7 }, (_, index) => ({ + examCode: 'EX-QA', examName: '桌贴分页测试', subjectName: '语文', subjectDate: '2026-10-01', subjectTime: '09:00—11:00', + centerCode: 'C01', centerName: '测试考点', examRoomCode: '001', roomName: '第一教室', roomCode: 'R01', building: '教学楼', floor: '1 层', + seat: String(index + 1).padStart(2, '0'), cardNumber: `CARD${index + 1}`, candidateName: `考生${index + 1}`, schoolName: '测试学校', className: '测试班' + })); + const paginationBook = new ExcelJS.Workbook(); + await paginationBook.xlsx.load(await buildCenterMaterialsWorkbook(paginationRows, '分页测试')); + assert.equal(paginationBook.getWorksheet('桌贴').pageSetup.printArea, 'A1:G48', '超过六张桌贴时应扩展为两页固定打印区域'); + assert.equal(paginationBook.getWorksheet('桌贴').getRow(32).height, 32.5, '第二页桌贴仍应保持相同方形标签高度'); + + const futureExamResponse = await admin.request('/api/admin/exams', { + method: 'POST', + body: { + name: '准考证窗口限制测试', code: 'EX-TEST-WINDOW', description: '验证下载时间限制', + registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(), + examStart: new Date(now + 96 * hour).toISOString(), examEnd: new Date(now + 100 * hour).toISOString(), + admitDownloadStart: new Date(now + 48 * hour).toISOString(), admitDownloadEnd: new Date(now + 95 * hour).toISOString(), + location: '测试考点', status: 'published', subjects: ['综合能力'] + } + }); + const futureExam = futureExamResponse.data.exam; + const futureRegistration = await candidate.request('/api/candidate/registrations', { method: 'POST', body: { examId: futureExam.id, subjectIds: [futureExam.subjects[0].id] } }); + const futureRegistrationId = futureRegistration.data.registration.id; + const futureFlow = (await schoolAdmin.request('/api/admin/workflow-instances')).data.instances.find(item => item.businessId === futureRegistrationId && item.status === 'pending'); + const futureSchoolClient = futureFlow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; + await futureSchoolClient.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } }); + const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } }); + assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号'); + const futureArrangement = await admin.request(`/api/admin/exams/${futureExam.id}/admission-arrangement`, { method: 'POST', body: { + mixingScope: 'district', numberRuleId: 'admit_rule_candidate_school_room_seat', seed: 'future-window-test' + } }); + assert.equal(futureArrangement.response.status, 200, '不同考试应分别保存独立编排方案'); + const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`); + assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载'); + + const publishNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { + title: '系统测试成绩发布通知', + summary: '', + content: '

成绩发布

系统测试考试成绩现已发布。下载说明

成绩发布横幅
成绩发布说明
科目状态
语文已发布
', + category: '成绩通知', + pinned: true, + status: 'published' + } }); + assert.equal(publishNotice.response.status, 201); + assert.match(publishNotice.data.notice.contentHtml, /

成绩发布<\/h2>/, '通知接口应保留 CKEditor 标题格式'); + assert.match(publishNotice.data.notice.contentHtml, /href="https:\/\/files\.example\.com\/results\.pdf"/, '通知接口应保留安全超链接'); + assert.match(publishNotice.data.notice.contentHtml, /rel="noopener noreferrer"/, '外部链接应带安全关系属性'); + assert.match(publishNotice.data.notice.contentHtml, /成绩发布横幅/, '通知接口应保留专用服务器图片链接'); + assert.match(publishNotice.data.notice.contentHtml, /.*
科目<\/th>.*语文<\/td>.*<\/table>/s, '通知接口应保留表格结构'); + assert.doesNotMatch(publishNotice.data.notice.contentHtml, /script|onclick|onerror/i, '通知富文本必须移除脚本和事件属性'); + assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本'); + const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '', status: 'draft' } }); + assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存'); + const draftNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '待编辑草稿', content: '

初始草稿内容

', 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: '

修改后的草稿内容

', 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); + 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?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?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' }); + assert.equal(cacheRefresh.response.status, 200, '超级管理员应能调用成绩缓存刷新接口'); + assert.equal(cacheRefresh.data.refreshed, false, 'Redis 未配置时刷新接口应安全降级为数据库直读'); + 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+'), '单科等级应按同场同科排名计算'); + 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, '班级管理员不得录入或发布成绩'); + + const livePartitionInspector = new DatabaseSync(testDb, { readOnly: true }); + const liveExamPartition = livePartitionInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id); + const partitionCandidate = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.candidates_table}" WHERE registration_id = ?`).get(registrationId); + const partitionAdmissions = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.admissions_table}" WHERE registration_id = ? ORDER BY subject_id`).all(registrationId); + const partitionResults = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.results_table}" WHERE registration_id = ? ORDER BY subject_id`).all(registrationId); + const partitionCenters = livePartitionInspector.prepare(`SELECT * FROM "${liveExamPartition.centers_table}"`).all(); + assert.equal(partitionCandidate.candidate_number, candidateNumber, '考试考生专属表应保存该场报名考生'); + assert.equal(partitionCandidate.payment_status, 'paid', '考试考生专属表应同步报名与缴费状态'); + assert.equal(partitionAdmissions.length, 2, '考试准考证专属表应按报考科目保存考号和座位'); + assert.ok(partitionAdmissions.every(item => item.admission_number === generatedCard.number && item.seat), '考试准考证专属表应同步准考证号、考场和座位'); + assert.equal(partitionResults.length, 2, '考试成绩专属表应只保存该场考试成绩'); + assert.ok(partitionResults.every(item => item.published === 1), '考试成绩专属表应同步发布状态'); + assert.ok(partitionCenters.some(item => item.center_code === generatedCard.centerCode), '考试考点专属表应保存本场实际使用的考点'); + const schoolStudentPartition = livePartitionInspector.prepare('SELECT students_table FROM school_student_partitions WHERE school_id = ?').get('school_hz1'); + const partitionStudent = livePartitionInspector.prepare(`SELECT * FROM "${schoolStudentPartition.students_table}" WHERE candidate_number = ?`).get(candidateNumber); + assert.equal(partitionStudent.candidate_name, '测试考生新名', '学校学生专属表应保存且同步本校学生资料'); + livePartitionInspector.close(); + + const appealResults = results.data.results.filter(item => [exam.subjects[0].id, exam.subjects[2].id].includes(item.subjectId)); + assert.equal(appealResults.length, 2, '测试考生应有两科可提交成绩复议'); + const submittedAppeals = []; + for (const result of appealResults) { + const submitted = await candidate.request(`/api/candidate/results/${result.id}/appeals`, { method: 'POST', body: { reason: `${result.subjectName}成绩与个人估分差异较大,请复核答卷计分。` } }); + assert.equal(submitted.response.status, 201, '考生应能对已发布成绩提交复议'); + submittedAppeals.push({ result, workflow: submitted.data.workflow }); + } + assert.deepEqual(new Set(submittedAppeals.map(item => item.workflow.assignee.id)), new Set(['usr_class_admin', 'usr_class_admin_2']), '同班多位班级管理员的新增复议应均分'); + const classFlows = await classAdmin.request('/api/admin/workflow-instances'); + assert.equal(classFlows.response.status, 200, '班级管理员应有流程中心和审批权限'); + assert.ok(classFlows.data.instances.filter(item => item.businessType === 'score_appeal' && item.status === 'pending').every(item => item.assignee.classId === 'class_hz1_302' && item.assignee.schoolId === 'school_hz1'), '班级步骤只能分配给该校该班管理员'); + const schoolAppeals = []; + for (const appeal of submittedAppeals) { + const classClient = appeal.workflow.assignee.id === 'usr_class_admin_2' ? classAdmin2 : classAdmin; + const classApproved = await classClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '班级已核验考生身份与科目' } }); + assert.equal(classApproved.response.status, 200, '班级管理员应能审批分配给自己的成绩复议'); + assert.equal(classApproved.data.workflow.currentStepDetail.adminLevel, 'school', '班级审批后应进入本校管理员步骤'); + assert.equal(classApproved.data.workflow.assignee.schoolId, 'school_hz1', '校级步骤必须匹配考生所属学校'); + schoolAppeals.push({ result: appeal.result, workflow: classApproved.data.workflow }); + } + assert.deepEqual(new Set(schoolAppeals.map(item => item.workflow.assignee.id)), new Set(['usr_school_admin', 'usr_school_admin_2']), '同校多位校级管理员的复议任务应均分'); + for (const appeal of schoolAppeals) { + const schoolClient = appeal.workflow.assignee.id === 'usr_school_admin_2' ? schoolAdmin2 : schoolAdmin; + const schoolApproved = await schoolClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校完成成绩复核' } }); + assert.equal(schoolApproved.response.status, 200); + assert.equal(schoolApproved.data.workflow.currentStepDetail.adminLevel, 'super', '校级复核后应进入考试中心终审'); + const missingReviewedScore = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '遗漏复核分数' } }); + assert.equal(missingReviewedScore.response.status, 400, '成绩复议终审批准时必须填写复核后分数'); + const reviewedScore = appeal.result.subjectId === exam.subjects[0].id ? 128 : 89; + const finalApproved = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewedScore, reviewNote: '考试中心终审完成' } }); + assert.equal(finalApproved.data.workflow.status, 'approved', '成绩复议应可完整走完班级、学校和考试中心流程'); + assert.equal(finalApproved.data.result.score, reviewedScore, '终审批准应在同一事务中更新复核后成绩'); + assert.equal(finalApproved.data.result.grade, 'A+', '复议后等级应按当前同科排名重新判断'); + if (appeal.result.subjectId === exam.subjects[2].id) assert.equal(finalApproved.data.result.qualified, true, '排名比例科目只判断复核后成绩所在当前排名区间'); + } + const appealedResults = await candidate.request('/api/candidate/results'); + assert.ok(appealedResults.data.results.filter(item => appealResults.some(result => result.id === item.id)).every(item => item.appeal?.status === 'approved'), '考生端应显示每科复议结果'); + assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[0].id).score, 128, '考生端应立即看到复议后的语文成绩'); + assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[2].id).score, 89, '考生端应立即看到复议后的外语成绩'); + + assert.equal((await schoolAdmin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' })).response.status, 403, '只有超级管理员可以归档考试'); + const archivedExam = await admin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' }); + assert.equal(archivedExam.response.status, 200, '超级管理员应能归档考试'); + assert.ok(archivedExam.data.exam.archivedAt, '归档应记录不可逆时间戳'); + assert.equal(archivedExam.data.exam.status, 'closed', '归档考试应退出公开发布状态'); + assert.equal((await admin.request(`/api/admin/exams/${exam.id}/archive`, { method: 'POST' })).response.status, 409, '归档操作不可重复或撤销'); + assert.equal((await admin.request(`/api/admin/exams/${exam.id}`, { method: 'PATCH', body: { status: 'published' } })).response.status, 409, '归档后考试配置不得再修改'); + assert.equal((await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody })).response.status, 409, '归档后不得重新编排准考证'); + assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1, published: true } })).response.status, 409, '归档后手工录入不得修改成绩'); + const lockInspector = new DatabaseSync(testDb); + assert.throws(() => lockInspector.prepare('UPDATE results SET score = 1 WHERE id = ?').run(appealResults[0].id), /永久锁定/, '绕过接口直接写库也不得修改归档成绩'); + lockInspector.close(); + const archivedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 1, published: '发布' }])); + const archivedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: archivedResultFile }); + assert.equal(archivedPreview.response.status, 200, '归档成绩 Excel 仍应返回逐行预览'); + 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?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), '考生历史成绩应标记为归档并进入折叠区'); + const archivedRegistrations = await candidate.request('/api/candidate/registrations'); + assert.ok(archivedRegistrations.data.registrations.find(item => item.exam.id === exam.id)?.exam.archivedAt, '报名和准考证信息应同步进入归档折叠区'); + const homeAfterArchive = await anonymous.request('/api/public/home'); + assert.ok(!homeAfterArchive.data.exams.some(item => item.id === exam.id), '归档考试不得继续出现在公开考试列表'); + + const workflowDefinitions = await admin.request('/api/admin/workflows'); + const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change'); + assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'center_change'), '流程设计应包含考点考场变更审批'); + assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'candidate_account_batch'), '流程设计应包含批量报名号申领审批'); + assert.ok(workflowDefinitions.data.workflows.some(item => item.businessType === 'score_appeal'), '流程设计应包含考生成绩复议'); + assert.match(appSource, /renderAdmin, workflowStepEditor/, '添加流程步骤事件必须取得视图模块导出的编辑器,避免未定义错误'); + assert.match(appSource, /createAdminViews, numberSegmentMeta/, '保存报名号规则必须导入字段元数据,避免提交时出现未定义错误'); + const updateWorkflow = await admin.request('/api/admin/workflows/profile_change', { method: 'PUT', body: { name: profileWorkflow.name, steps: profileWorkflow.steps.map(item => ({ name: item.name, adminLevel: item.adminLevel })) } }); + assert.equal(updateWorkflow.response.status, 200, '超级管理员应可设计考生信息修改审批流程'); + + console.log('✓ 公开首页与通知读取'); + console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张核心表 + 按考试/学校动态专属分表)`); + console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离'); + console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关'); + console.log('✓ 多科目考试创建与考生自主选科报名'); + console.log('✓ 五级混编、四种号码规则、多科目同考点与逐科座位'); + console.log('✓ 审批流程设计、同级转交与超级管理员监督退回'); + console.log('✓ 校级按班级批量申领、终审原子建号与结果返回'); + console.log('✓ 结构化考点考场档案、变更审批与班级只读边界'); + console.log('✓ 本校班级/班级管理员管理与多资源 Excel 导入导出'); + console.log('✓ 三级管理员范围内缴费状态修改、状态同步与名单导出'); + console.log('✓ 成绩录入、发布与考生查询'); + console.log('✓ 成绩复议、班级审批、范围匹配与多人均分'); +} finally { + 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 }); +}