57 Commits
101 changed files with 19559 additions and 861 deletions
+3
View File
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
MYSQL_PASSWORD= MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD= MYSQL_ROOT_PASSWORD=
CLICKHOUSE_USER=jiaowu_analytics
CLICKHOUSE_PASSWORD=
RABBITMQ_USER=jiaowu RABBITMQ_USER=jiaowu
RABBITMQ_PASSWORD= RABBITMQ_PASSWORD=
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1 BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
+22 -3
View File
@@ -2,6 +2,13 @@
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://0.0.0.0:8080 ASPNETCORE_URLS=http://0.0.0.0:8080
# 反向代理必须在转发请求时设置 X-Forwarded-For 和 X-Forwarded-Proto。
# 仅填写实际直接连接 API 的代理 IP;多个代理依次使用 __0、__1……。
# 使用 Docker 时通常是宿主机/代理容器在 Docker 网络中的 IP,而非访客 IP。
# 默认仅信任 127.0.0.1 和 ::1。
# ReverseProxy__TrustedProxies__0=127.0.0.1
# ReverseProxy__TrustedProxies__1=::1
Database__Provider=MySql Database__Provider=MySql
Database__ApplyMigrationsOnStartup=false Database__ApplyMigrationsOnStartup=false
Database__CommandTimeoutSeconds=30 Database__CommandTimeoutSeconds=30
@@ -37,15 +44,15 @@ Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30 Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048 Cache__MaximumPayloadKilobytes=2048
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发 # 应用日志建立接口与慢 SQL 基线;日志系统可按 DurationMs、RequestId、QueryName 聚合
Observability__Enabled=true Observability__Enabled=true
Observability__ServiceName=jiaowu-api Observability__ServiceName=jiaowu-api
Observability__LogAllApiRequests=true
Observability__SlowRequestThresholdMilliseconds=1000
Observability__SlowQueryThresholdMilliseconds=500 Observability__SlowQueryThresholdMilliseconds=500
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。 # 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
Observability__IncludeSqlText=false Observability__IncludeSqlText=false
Observability__MaximumSqlTextLength=2000 Observability__MaximumSqlTextLength=2000
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。 # 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
PerformanceReporting__Enabled=false PerformanceReporting__Enabled=false
@@ -55,6 +62,18 @@ PerformanceReporting__Enabled=false
PerformanceReporting__CacheSeconds=30 PerformanceReporting__CacheSeconds=30
PerformanceReporting__TimeoutSeconds=10 PerformanceReporting__TimeoutSeconds=10
# ClickHouse 仅作为异步分析读模型,不参与教务事务写入。启用前请为应用创建
# 仅能操作该分析库的独立账号,并通过 TLS 或受信任的内网访问。
ClickHouseAnalytics__Enabled=false
# ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
# ClickHouseAnalytics__Database=jiaowu_analytics
# ClickHouseAnalytics__UserName=jiaowu_analytics
# ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
ClickHouseAnalytics__CreateSchemaOnStartup=true
ClickHouseAnalytics__SyncIntervalSeconds=60
ClickHouseAnalytics__SourceLookbackDays=90
ClickHouseAnalytics__BatchSize=1000
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。 # 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
Operations__BackupDirectory=/var/lib/jiaowu/backups Operations__BackupDirectory=/var/lib/jiaowu/backups
Operations__BackupWarningHours=24 Operations__BackupWarningHours=24
+2 -2
View File
@@ -73,7 +73,7 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "profile=$profile" >> "$GITHUB_OUTPUT" echo "profile=$profile" >> "$GITHUB_OUTPUT"
- name: Build self-contained platform packages - name: Build framework-dependent platform packages
shell: bash shell: bash
run: | run: |
bash ./scripts/publish-platform-packages.sh \ bash ./scripts/publish-platform-packages.sh \
@@ -155,7 +155,7 @@ jobs:
- Windows x64`.zip` - Windows x64`.zip`
- Linux x64 / ARM64`.tar.gz` - Linux x64 / ARM64`.tar.gz`
- `SHA256SUMS`:发布包校验值 - `SHA256SUMS`:发布包校验值
- 自包含程序包无需预装 .NET 或 ASP.NET Core Runtime - 二进制程序包不包含 .NET 或 ASP.NET Core Runtime;请先安装匹配版本的 ASP.NET Core Runtime
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。 Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
Submodule Academic-Affairs-System.wiki added at fe88e71716
+2
View File
@@ -2,6 +2,7 @@
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
WORKDIR /source WORKDIR /source
COPY versions.props ./
COPY web/package.json web/package-lock.json ./web/ COPY web/package.json web/package-lock.json ./web/
RUN npm --prefix web ci RUN npm --prefix web ci
COPY web/ ./web/ COPY web/ ./web/
@@ -11,6 +12,7 @@ FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG TARGETARCH ARG TARGETARCH
WORKDIR /source WORKDIR /source
COPY global.json ./ COPY global.json ./
COPY versions.props ./
COPY .env.example ./ COPY .env.example ./
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/ COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH" RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH"
+32 -19
View File
@@ -366,39 +366,36 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis `allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。 如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
### OpenTelemetry 与慢查询定位 ### 慢接口与慢查询基线
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过 应用不依赖 OpenTelemetry。启用 `Observability` 后,会为每个 `/api` 请求写入结构化的
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置 方法、路径、终结点、状态码、耗时和请求号;超过阈值或返回 5xx 的请求会提升为 Warning。
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发; EF Core 数据库命令超过阈值时同样写入查询名称、SQL 模板哈希、数据库类型、耗时和相同的
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志 请求号。用日志平台按 `DurationMs` 聚合即可得到真实的 P50/P95/P99 和慢接口排行。
仍然有效。
```text ```text
Observability__Enabled=true Observability__Enabled=true
Observability__ServiceName=jiaowu-api Observability__ServiceName=jiaowu-api
Observability__LogAllApiRequests=true
Observability__SlowRequestThresholdMilliseconds=1000
Observability__SlowQueryThresholdMilliseconds=500 Observability__SlowQueryThresholdMilliseconds=500
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
``` ```
数据库指标包括 `jiaowu.db.command.duration``jiaowu.db.command.slow` 为关键 EF 查询添加 `TagWith("模块.查询名")` 后,慢 SQL 日志会直接显示稳定名称;无标签
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和 查询只显示操作类型和 SQL 模板哈希。默认 `Observability__IncludeSqlText=false`,不会把
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认 SQL 模板、参数值或连接串写入日志。仅在受控诊断窗口内临时启用 SQL 模板记录,并限制日志
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪 访问权限与保留时间。
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询 应用侧请求号用于关联接口和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再 日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环 对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产 次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html) 写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。 和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
OpenTelemetry Collector 将指标写入 Prometheus ,超级管理员可直接在“组织与权限 → 如部署环境另行提供 Prometheus 兼容指标源,超级管理员可在“组织与权限 → 运维与审计 →
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢 系统性能”查看其汇总数据。该页面只读查询外部指标源,浏览器不会接触其地址或令牌;结果
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会 默认缓存 30 秒。应用本身不会再通过 OpenTelemetry 向该指标源写入数据。
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
Grafana 中下钻,配置其地址后页面会显示跳转入口。
```text ```text
PerformanceReporting__Enabled=true PerformanceReporting__Enabled=true
@@ -415,6 +412,22 @@ Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的 指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
`*MetricName``ServiceNameLabel` 配置项适配,无需改前端。 `*MetricName``ServiceNameLabel` 配置项适配,无需改前端。
### ClickHouse 分析读模型
ClickHouse 仅用于考勤、操作审计和成绩趋势的多维聚合,MySQL 仍是所有教务业务的唯一写入源。默认关闭;启用后,后台工作器以可重试的滚动窗口投影 MySQL 当前事实到 `ReplacingMergeTree` 表,重复投递不会改变读结果。
生产环境请为分析库创建独立账号,并限制其只能访问 `ClickHouseAnalytics__Database`。推荐通过 HTTPS 或内网连接:
```ini
ClickHouseAnalytics__Enabled=true
ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
ClickHouseAnalytics__Database=jiaowu_analytics
ClickHouseAnalytics__UserName=jiaowu_analytics
ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
```
分析概览通过 `GET /api/clickhouse-analytics/overview` 提供;学院管理员只能读取本学院的考勤和成绩趋势,跨学院的操作审计仅对全校数据范围角色开放。ClickHouse 暂时不可用时,业务写入不会失败,工作器会在下一个周期重试。
### 后台任务与 RabbitMQ ### 后台任务与 RabbitMQ
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与 自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
@@ -455,7 +468,7 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays` 消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`
`MaintenanceIntervalSeconds``CleanupBatchSize` 调整。应用暴露 `MaintenanceIntervalSeconds``CleanupBatchSize` 调整。应用暴露
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量, `Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成 由现有日志平台或运行时指标采集器汇总。MySQL 或 RabbitMQ 暂时不可用时,未完成
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用 消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。 `BackgroundJobOutbox` 数据库迁移,再启动应用实例。
+27
View File
@@ -16,6 +16,11 @@ x-jiaowu-environment: &jiaowu-environment
ConnectionStrings__Redis: "redis:6379,abortConnect=false" ConnectionStrings__Redis: "redis:6379,abortConnect=false"
Cache__Enabled: "true" Cache__Enabled: "true"
Cache__KeyPrefix: "jiaowu:v1" Cache__KeyPrefix: "jiaowu:v1"
ClickHouseAnalytics__Enabled: "true"
ClickHouseAnalytics__Endpoint: "http://clickhouse:8123"
ClickHouseAnalytics__Database: "jiaowu_analytics"
ClickHouseAnalytics__UserName: "${CLICKHOUSE_USER:-jiaowu_analytics}"
ClickHouseAnalytics__Password: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
BackgroundJobs__Transport: RabbitMq BackgroundJobs__Transport: RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}" BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}" BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
@@ -47,6 +52,25 @@ x-json-logging: &json-logging
max-file: "3" max-file: "3"
services: services:
clickhouse:
image: clickhouse/clickhouse-server:25.8-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: jiaowu_analytics
CLICKHOUSE_USER: "${CLICKHOUSE_USER:-jiaowu_analytics}"
CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
healthcheck:
test:
- CMD-SHELL
- wget -qO- http://localhost:8123/ping | grep -q Ok
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
- clickhouse-data:/var/lib/clickhouse
logging: *json-logging
rabbitmq: rabbitmq:
image: rabbitmq:4.2-management-alpine image: rabbitmq:4.2-management-alpine
restart: unless-stopped restart: unless-stopped
@@ -135,6 +159,8 @@ services:
condition: service_started condition: service_started
mysql: mysql:
condition: service_healthy condition: service_healthy
clickhouse:
condition: service_healthy
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
ports: ports:
@@ -165,5 +191,6 @@ services:
volumes: volumes:
mysql-data: mysql-data:
clickhouse-data:
rabbitmq-data: rabbitmq-data:
backup-data: backup-data:
+1 -1
View File
@@ -39,7 +39,7 @@ for runtime in "${runtimes[@]}"; do
dotnet publish "$project" \ dotnet publish "$project" \
--configuration Release \ --configuration Release \
--runtime "$runtime" \ --runtime "$runtime" \
--self-contained true \ --self-contained false \
--output "$package_root" \ --output "$package_root" \
-p:BuildFrontendOnPublish=false \ -p:BuildFrontendOnPublish=false \
-p:Version="$version" \ -p:Version="$version" \
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
@@ -23,43 +24,73 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
// ═══════════════ Aggregated pending ═══════════════ // ═══════════════ Aggregated pending ═══════════════
[HttpGet("pending")] [HttpGet("pending")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> GetPending(CancellationToken ct) public async Task<ActionResult<PagedResult<ApprovalItem>>> GetPending(
int page = 1,
int pageSize = 20,
CancellationToken ct = default)
{ {
var items = new List<ApprovalItem>(); if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("page 必须大于 0pageSize 必须在 1 到 100 之间。");
var take = checked(page * pageSize);
var total = 0;
var items = new List<ApprovalItem>(take * 8);
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted) items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
.OrderByDescending(x => x.SubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name)) .Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted) items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
.OrderByDescending(x => x.SubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name)) .Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved) items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
.OrderByDescending(x => x.SubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name)) .Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved).CountAsync(ct);
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted) items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
.OrderByDescending(x => x.SubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name)) .Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled) items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
.OrderByDescending(x => x.SubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name)) .Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled).CountAsync(ct);
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted) items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
.OrderByDescending(x => x.SubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name)) .Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted).CountAsync(ct);
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted) items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
.OrderByDescending(x => x.SubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name)) .Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted).CountAsync(ct);
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending) items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
.OrderByDescending(x => x.AppealSubmittedAt).Take(take)
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name)) .Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
.ToListAsync(ct)); .ToListAsync(ct));
total += await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending).CountAsync(ct);
return Ok(items.OrderByDescending(x => x.Time).ToList()); var pageItems = items
.OrderByDescending(x => x.Time)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToArray();
return Ok(new PagedResult<ApprovalItem>(pageItems, total, page, pageSize));
} }
// ═══════════════ Course Exemption ═══════════════ // ═══════════════ Course Exemption ═══════════════
@@ -3,6 +3,7 @@ using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using ClosedXML.Excel; using ClosedXML.Excel;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -33,14 +34,32 @@ public sealed class AttendanceController(
[Authorize(Roles = AttendanceRoles)] [Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetMyTasks( public async Task<ActionResult> GetMyTasks(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{ {
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 50);
var tasks = AccessibleTasks().AsNoTracking() var tasks = AccessibleTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published); .Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue) if (academicTermId.HasValue)
tasks = tasks.Where(x => x.AcademicTermId == academicTermId); tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
tasks = tasks.Where(x =>
x.TaskNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.Course!.Code.Contains(keyword) ||
x.Course.Name.Contains(keyword));
}
var total = await tasks.CountAsync(cancellationToken);
var result = await tasks var result = await tasks
.OrderBy(x => x.Course!.Code) .OrderBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -63,22 +82,44 @@ public sealed class AttendanceController(
sheet.TeachingTaskId == x.Id) sheet.TeachingTaskId == x.Id)
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(result); return Ok(new PagedResult<object>(result, total, page, pageSize));
} }
[HttpGet("sheets")] [HttpGet("sheets")]
[Authorize(Roles = AttendanceRoles)] [Authorize(Roles = AttendanceRoles)]
public async Task<ActionResult> GetSheets( public async Task<ActionResult> GetSheets(
Guid teachingTaskId, Guid teachingTaskId,
CancellationToken cancellationToken) string? keyword = null,
AttendanceSheetStatus? status = null,
AttendanceCheckInMethod? checkInMethod = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{ {
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 50);
var task = await AccessibleTasks().AsNoTracking() var task = await AccessibleTasks().AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
var sheets = await db.AttendanceSheets.AsNoTracking() var source = db.AttendanceSheets.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId) .Where(x => x.TeachingTaskId == teachingTaskId);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x => x.Name.Contains(keyword) || x.Notes!.Contains(keyword));
}
if (status.HasValue)
source = source.Where(x => x.Status == status.Value);
if (checkInMethod.HasValue)
source = source.Where(x => x.CheckInMethod == checkInMethod.Value);
var total = await source.CountAsync(cancellationToken);
var sheets = await source
.OrderByDescending(x => x.AttendanceDate) .OrderByDescending(x => x.AttendanceDate)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -99,7 +140,7 @@ public sealed class AttendanceController(
TotalCount = x.Records.Count TotalCount = x.Records.Count
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(sheets); return Ok(new PagedResult<object>(sheets, total, page, pageSize));
} }
[HttpPost("sheets")] [HttpPost("sheets")]
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Common; using Jiaowu.Api.Domain.Common;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
@@ -232,8 +233,49 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
} }
[HttpGet("classes")] [HttpGet("classes")]
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) public async Task<ActionResult<object>> GetClasses(
int? page,
int? pageSize,
string? keyword,
Guid? collegeId,
Guid? majorId,
int? grade,
bool? isEnabled,
CancellationToken cancellationToken)
{ {
if (page.HasValue || pageSize.HasValue)
{
if (page is < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = db.AdministrativeClasses.AsNoTracking();
if (collegeId.HasValue)
source = source.Where(x => x.Major!.CollegeId == collegeId.Value);
if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId.Value);
if (grade.HasValue) source = source.Where(x => x.Grade == grade.Value);
if (isEnabled.HasValue) source = source.Where(x => x.IsEnabled == isEnabled.Value);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.Code.Contains(keyword) || x.Name.Contains(keyword) ||
x.Major!.Name.Contains(keyword) ||
x.Major.College!.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
.Skip((page!.Value - 1) * pageSize!.Value)
.Take(pageSize.Value)
.Select(x => new AdministrativeClassListItem(
x.Id, x.Code, x.Name, x.MajorId, x.Major!.Name,
x.Major.College!.Name, x.Grade, x.CounselorUserId,
x.CounselorName, x.IsEnabled, x.SortOrder))
.ToListAsync(cancellationToken);
return Ok(new PagedResult<AdministrativeClassListItem>(
items, total, page.Value, pageSize.Value));
}
var result = await cache.GetOrCreateAsync( var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("classes"), AppCacheKeys.BaseData("classes"),
token => db.AdministrativeClasses.AsNoTracking() token => db.AdministrativeClasses.AsNoTracking()
@@ -474,8 +516,54 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
} }
[HttpGet("classrooms")] [HttpGet("classrooms")]
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) public async Task<ActionResult<object>> GetClassrooms(
int? page,
int? pageSize,
string? keyword,
Guid? campusId,
Guid? buildingId,
int? minimumCapacity,
TeachingVenueNature? teachingVenueNature,
bool? isEnabled,
CancellationToken cancellationToken)
{ {
if (page.HasValue || pageSize.HasValue)
{
if (page is < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = db.Classrooms.AsNoTracking();
if (campusId.HasValue)
source = source.Where(x => x.Building!.CampusId == campusId.Value);
if (buildingId.HasValue) source = source.Where(x => x.BuildingId == buildingId.Value);
if (minimumCapacity.HasValue)
source = source.Where(x => x.Capacity >= minimumCapacity.Value);
if (teachingVenueNature.HasValue)
source = source.Where(x => (x.TeachingVenueNature & teachingVenueNature.Value) != 0);
if (isEnabled.HasValue) source = source.Where(x => x.IsEnabled == isEnabled.Value);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.Code.Contains(keyword) || x.Name.Contains(keyword) ||
x.Building!.Name.Contains(keyword) ||
x.Building.Campus!.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.Building!.Campus!.SortOrder).ThenBy(x => x.Code)
.Skip((page!.Value - 1) * pageSize!.Value)
.Take(pageSize.Value)
.Select(x => new ClassroomListItem(
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
x.Building.Name, x.Building.Campus!.Name, x.Capacity,
x.RoomType, x.TeachingVenueNature, x.Equipment,
x.IsEnabled, x.SortOrder))
.ToListAsync(cancellationToken);
return Ok(new PagedResult<ClassroomListItem>(
items, total, page.Value, pageSize.Value));
}
var result = await cache.GetOrCreateAsync( var result = await cache.GetOrCreateAsync(
AppCacheKeys.BaseData("classrooms"), AppCacheKeys.BaseData("classrooms"),
token => db.Classrooms.AsNoTracking() token => db.Classrooms.AsNoTracking()
@@ -0,0 +1,96 @@
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Analytics;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/clickhouse-analytics")]
public sealed class ClickHouseAnalyticsController(
ClickHouseAnalyticsClient client,
ClickHouseAnalyticsOptions options,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," + SystemRoles.Leader;
[HttpGet("status")]
public ActionResult GetStatus() => Ok(new
{
options.Enabled,
options.SyncIntervalSeconds,
options.SourceLookbackDays,
options.BatchSize,
options.Database
});
[HttpGet("overview")]
public async Task<ActionResult> GetOverview(
DateOnly? from,
DateOnly? to,
CancellationToken cancellationToken)
{
if (!options.Enabled)
return Conflict(new ProblemDetails
{
Title = "ClickHouse 分析未启用",
Detail = "请先配置 ClickHouseAnalytics 并启动分析库。",
Status = StatusCodes.Status409Conflict
});
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
var start = from ?? end.AddDays(-29);
if (start > end || end.DayNumber - start.DayNumber > 366)
return ValidationProblem("分析时间范围应为 1 到 366 天,且开始日期不能晚于结束日期。");
var scope = currentUserDataScope.Current;
var collegeFilter = scope.RestrictedCollegeId is { } collegeId
? $" AND collegeId = toUUID('{collegeId:D}')"
: string.Empty;
var dateFilter = $"attendanceDate >= toDate('{start:yyyy-MM-dd}') AND attendanceDate <= toDate('{end:yyyy-MM-dd}')";
var attendance = await client.QueryAsync($"""
SELECT attendanceDate, count() AS total, countIf(status = 1) AS present,
countIf(status = 2) AS absent, countIf(status = 3) AS late
FROM {options.Database}.attendanceRecords FINAL
WHERE {dateFilter}{collegeFilter}
GROUP BY attendanceDate ORDER BY attendanceDate
""", cancellationToken);
var grades = await client.QueryAsync($"""
SELECT academicTermId, academicTermName,
totalStudentCount AS studentCount,
round(totalWeightedScore / nullIf(totalStudentCount, 0), 2) AS averageScore,
round(totalPassedCount / nullIf(totalStudentCount, 0), 4) AS passRate
FROM
(
SELECT academicTermId, any(academicTermName) AS academicTermName,
sum(studentCount) AS totalStudentCount,
sum(averageScore * studentCount) AS totalWeightedScore,
sum(passedCount) AS totalPassedCount
FROM {options.Database}.gradeStatistics FINAL
WHERE 1 = 1{collegeFilter}
GROUP BY academicTermId
) AS gradeTotals
ORDER BY academicTermName
""", cancellationToken);
// Audit data has no college dimension, so it is never exposed to a
// college-scoped administrator.
var audit = scope.RestrictedCollegeId is null
? await client.QueryAsync($"""
SELECT toDate(occurredAt) AS date, count() AS total,
countIf(statusCode >= 400) AS failed
FROM {options.Database}.auditEvents FINAL
WHERE occurredAt >= toDateTime('{start:yyyy-MM-dd}')
AND occurredAt < toDateTime('{end.AddDays(1):yyyy-MM-dd}')
GROUP BY date ORDER BY date
""", cancellationToken)
: [];
return Ok(new { Start = start, End = end, Attendance = attendance, Grades = grades, Audit = audit });
}
}
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -176,8 +177,13 @@ public sealed class CourseAdjustmentsController(
public async Task<ActionResult> GetMine( public async Task<ActionResult> GetMine(
Guid? academicTermId, Guid? academicTermId,
CourseAdjustmentStatus? status, CourseAdjustmentStatus? status,
CancellationToken cancellationToken) int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var userId = currentUserDataScope.Current.UserId; var userId = currentUserDataScope.Current.UserId;
var source = db.CourseAdjustments.AsNoTracking() var source = db.CourseAdjustments.AsNoTracking()
.Where(x => x.ApplicantUserId == userId); .Where(x => x.ApplicantUserId == userId);
@@ -187,10 +193,15 @@ public sealed class CourseAdjustmentsController(
if (status.HasValue) if (status.HasValue)
source = source.Where(x => x.Status == status); source = source.Where(x => x.Status == status);
return Ok(await source var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(AdjustmentProjection()) .Select(AdjustmentProjection())
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
} }
// ═══════════════ Pending reviews ═══════════════ // ═══════════════ Pending reviews ═══════════════
@@ -199,8 +210,13 @@ public sealed class CourseAdjustmentsController(
[Authorize(Roles = Reviewers)] [Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetPendingReviews( public async Task<ActionResult> GetPendingReviews(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var scope = currentUserDataScope.Current; var scope = currentUserDataScope.Current;
var source = db.CourseAdjustments.AsNoTracking() var source = db.CourseAdjustments.AsNoTracking()
.Where(x => x.Status == CourseAdjustmentStatus.Submitted); .Where(x => x.Status == CourseAdjustmentStatus.Submitted);
@@ -211,10 +227,15 @@ public sealed class CourseAdjustmentsController(
source = source.Where(x => source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId); x.TeachingTask!.AcademicTermId == academicTermId);
return Ok(await source var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.SubmittedAt) .OrderByDescending(x => x.SubmittedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(AdjustmentProjection()) .Select(AdjustmentProjection())
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
} }
// ═══════════════ Detail ═══════════════ // ═══════════════ Detail ═══════════════
@@ -399,8 +399,22 @@ public sealed class CourseSelectionsController(
[HttpGet("offerings/{id:guid}/roster")] [HttpGet("offerings/{id:guid}/roster")]
[Authorize(Roles = RosterReaders)] [Authorize(Roles = RosterReaders)]
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> GetRoster(
Guid id,
string? keyword = null,
int? grade = null,
Guid? majorId = null,
Guid? administrativeClassId = null,
int studentPage = 1,
int studentPageSize = 20,
int waitlistPage = 1,
int waitlistPageSize = 20,
CancellationToken cancellationToken = default)
{ {
studentPage = Math.Max(1, studentPage);
studentPageSize = Math.Clamp(studentPageSize, 10, 100);
waitlistPage = Math.Max(1, waitlistPage);
waitlistPageSize = Math.Clamp(waitlistPageSize, 10, 100);
var offering = await db.CourseSelectionOfferings.AsNoTracking() var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id) .Where(x => x.Id == id)
.Select(x => new .Select(x => new
@@ -428,11 +442,64 @@ public sealed class CourseSelectionsController(
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
return Forbid(); return Forbid();
var students = await db.CourseEnrollments.AsNoTracking() var enrolledSource = db.CourseEnrollments.AsNoTracking()
.Where(x => .Where(x =>
x.CourseSelectionOfferingId == id && x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled) x.Status == CourseEnrollmentStatus.Enrolled);
var waitlistSource = db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Waitlisted);
var enrolledCount = await enrolledSource.CountAsync(cancellationToken);
var waitlistedCount = await waitlistSource.CountAsync(cancellationToken);
var rosterSource = db.CourseEnrollments.AsNoTracking().Where(x =>
x.CourseSelectionOfferingId == id &&
(x.Status == CourseEnrollmentStatus.Enrolled ||
x.Status == CourseEnrollmentStatus.Waitlisted));
var filterOptions = await rosterSource
.Select(x => new
{
Grade = x.Student!.AdministrativeClass!.Grade,
MajorId = x.Student.AdministrativeClass.MajorId,
MajorName = x.Student.AdministrativeClass.Major!.Name,
ClassId = x.Student.AdministrativeClassId,
ClassName = x.Student.AdministrativeClass.Name
})
.Distinct()
.ToListAsync(cancellationToken);
if (grade.HasValue)
{
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
}
if (majorId.HasValue)
{
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
}
if (administrativeClassId.HasValue)
{
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
enrolledSource = enrolledSource.Where(x =>
x.Student!.StudentNumber.Contains(keyword) ||
x.Student.Name.Contains(keyword) ||
x.Student.AdministrativeClass!.Name.Contains(keyword));
waitlistSource = waitlistSource.Where(x =>
x.Student!.StudentNumber.Contains(keyword) ||
x.Student.Name.Contains(keyword) ||
x.Student.AdministrativeClass!.Name.Contains(keyword));
}
var enrolledTotal = await enrolledSource.CountAsync(cancellationToken);
var waitlistedTotal = await waitlistSource.CountAsync(cancellationToken);
var students = await enrolledSource
.OrderBy(x => x.Student!.StudentNumber) .OrderBy(x => x.Student!.StudentNumber)
.Skip((studentPage - 1) * studentPageSize)
.Take(studentPageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -445,12 +512,11 @@ public sealed class CourseSelectionsController(
x.EnrolledAt x.EnrolledAt
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var waitlistedRows = await db.CourseEnrollments.AsNoTracking() var waitlistedRows = await waitlistSource
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Waitlisted)
.OrderBy(x => x.WaitlistedAt) .OrderBy(x => x.WaitlistedAt)
.ThenBy(x => x.CreatedAt) .ThenBy(x => x.CreatedAt)
.Skip((waitlistPage - 1) * waitlistPageSize)
.Take(waitlistPageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -474,7 +540,7 @@ public sealed class CourseSelectionsController(
item.MajorName, item.MajorName,
item.Grade, item.Grade,
item.WaitlistedAt, item.WaitlistedAt,
Position = index + 1 Position = (waitlistPage - 1) * waitlistPageSize + index + 1
}) })
.ToList(); .ToList();
return Ok(new return Ok(new
@@ -487,10 +553,24 @@ public sealed class CourseSelectionsController(
offering.CourseName, offering.CourseName,
offering.CourseNature, offering.CourseNature,
offering.Capacity, offering.Capacity,
EnrolledCount = students.Count, EnrolledCount = enrolledCount,
Students = students, Students = students,
WaitlistedCount = waitlist.Count, StudentPage = studentPage,
StudentPageSize = studentPageSize,
StudentTotal = enrolledTotal,
WaitlistedCount = waitlistedCount,
Waitlist = waitlist, Waitlist = waitlist,
WaitlistPage = waitlistPage,
WaitlistPageSize = waitlistPageSize,
WaitlistTotal = waitlistedTotal,
FilterOptions = new
{
Grades = filterOptions.Select(x => x.Grade).Distinct().OrderBy(x => x),
Majors = filterOptions.Select(x => new { x.MajorId, x.MajorName }).Distinct()
.OrderBy(x => x.MajorName),
Classes = filterOptions.Select(x => new { x.ClassId, x.ClassName, x.MajorId })
.Distinct().OrderBy(x => x.ClassName)
},
CanManageWaitlist = CanManageWaitlist =
offering.RoundStatus == CourseSelectionRoundStatus.Open && offering.RoundStatus == CourseSelectionRoundStatus.Open &&
(scope.IsInRole(SystemRoles.SuperAdmin) || (scope.IsInRole(SystemRoles.SuperAdmin) ||
@@ -135,9 +135,179 @@ public sealed class DashboardController(
currentTerm, currentTerm,
counts, counts,
pending, pending,
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
DateTime.UtcNow)); DateTime.UtcNow));
} }
[HttpGet("greeting")]
public async Task<ActionResult<DashboardGreeting>> GetGreeting(
CancellationToken cancellationToken)
{
var currentTermId = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
return Ok(await BuildGreetingAsync(
currentUserDataScope.Current,
currentTermId,
null,
null,
cancellationToken));
}
private async Task<DashboardGreeting> BuildGreetingAsync(
CurrentUserScope scope,
Guid? currentTermId,
DashboardCounts? counts,
DashboardPending? pending,
CancellationToken cancellationToken)
{
var name = string.IsNullOrWhiteSpace(scope.DisplayName) ? "" : $"{scope.DisplayName}";
var greeting = GetTimeGreeting();
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Leader) ||
scope.IsInRole(SystemRoles.Counselor);
if (!isManager && scope.IsInRole(SystemRoles.Student))
{
var studentId = await db.Students.AsNoTracking()
.Where(x => x.UserId == scope.UserId)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return new DashboardGreeting("Student", $"{name}{greeting}", "绑定学籍后,将为你生成课程与成绩学习概览。", "学习节奏", "关联学生档案后,可从课程安排、成绩和考试中生成学习状态摘要。", []);
var student = await db.Students.AsNoTracking()
.Where(x => x.Id == studentId.Value)
.Select(x => new { x.AdministrativeClassId })
.FirstAsync(cancellationToken);
var currentTasks = db.TeachingTasks.AsNoTracking().Where(task =>
currentTermId.HasValue &&
task.AcademicTermId == currentTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
(task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == studentId.Value &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)));
var taskWorkload = await currentTasks.Select(task => new
{
task.Id,
task.CourseId,
Credits = task.Course!.Credits,
IsClassAssigned = task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)
}).ToListAsync(cancellationToken);
var currentCourses = taskWorkload
.GroupBy(x => x.CourseId)
.Select(x => x.First())
.ToList();
var courseCount = currentCourses.Count;
var courseCredits = currentCourses.Sum(x => x.Credits);
var classAssignedCount = taskWorkload.Count(x => x.IsClassAssigned);
var selfSelectedCount = taskWorkload.Count(x => !x.IsClassAssigned);
var publishedGrades = db.GradeRecords.AsNoTracking().Where(x =>
x.StudentId == studentId.Value &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
currentTermId.HasValue &&
x.GradeSheet.TeachingTask!.AcademicTermId == currentTermId.Value);
var gradeCount = await publishedGrades.CountAsync(cancellationToken);
var average = await publishedGrades
.Where(x => x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue)
.AverageAsync(x => (decimal?)x.TotalScore, cancellationToken);
var failed = await publishedGrades.CountAsync(x =>
x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue && x.TotalScore < 60,
cancellationToken);
var subtitle = failed > 0
? $"已发布成绩中有 {failed} 门课程需要重点关注,建议优先查看课程反馈。"
: courseCount > 0
? $"本学期已有 {courseCount} 门课程、{courseCredits:0.#} 学分进入你的学习安排。"
: "本学期暂未发现为你安排或确认选课的课程,可先查看培养方案和选课安排。";
var narrative = courseCount == 0
? "你的当前学习安排尚未形成:系统还没有找到行政班已安排课程或已确认选课。"
: failed > 0
? $"本学期已形成 {courseCount} 门课程安排,其中 {classAssignedCount} 个教学班来自行政班安排;已发布成绩中有 {failed} 门需要重点关注。"
: gradeCount > 0
? $"本学期有 {courseCount} 门课程进入学习安排,已发布 {gradeCount} 门成绩,当前没有不及格记录。"
: $"本学期有 {courseCount} 门课程进入学习安排,包含 {classAssignedCount} 个行政班教学班和 {selfSelectedCount} 个自主选课教学班,成绩发布后会在这里更新。";
return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", narrative,
[
new DashboardGreetingInsight("本学期课程", $"{courseCount} 门", $"共 {courseCredits:0.#} 学分", "calm"),
new DashboardGreetingInsight("已发布成绩", $"{gradeCount} 门", average.HasValue ? $"平均分 {average.Value:0.0}" : "等待成绩发布", "calm"),
new DashboardGreetingInsight("重点关注", $"{failed} 门", failed > 0 ? "建议尽早安排复习与答疑" : "当前无不及格记录", failed > 0 ? "attention" : "positive")
]);
}
if (!isManager && scope.IsInRole(SystemRoles.Teacher))
{
var teacherId = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == scope.UserId && x.Status == TeacherStatus.Active)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!teacherId.HasValue)
return new DashboardGreeting("Teacher", $"{name}{greeting}", "绑定教师档案后,将为你生成本学期教学负荷概览。", "教学节奏", "关联教师档案后,可从教学班、授课学时和成绩进度生成今日工作摘要。", []);
var tasks = db.TeachingTasks.AsNoTracking().Where(x =>
currentTermId.HasValue && x.AcademicTermId == currentTermId.Value &&
x.Teachers.Any(t => t.TeacherId == teacherId.Value));
var teachingClasses = await tasks.CountAsync(cancellationToken);
var estimatedHours = await tasks.SumAsync(
x => (int?)(x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), cancellationToken) ?? 0;
var gradeSheets = db.GradeSheets.AsNoTracking().Where(x =>
x.TeachingTask!.Teachers.Any(t => t.TeacherId == teacherId.Value) &&
currentTermId.HasValue && x.TeachingTask.AcademicTermId == currentTermId.Value);
var pendingGrades = await gradeSheets.CountAsync(x =>
x.Status == GradeSheetStatus.Draft ||
x.Status == GradeSheetStatus.Returned, cancellationToken);
var submittedGrades = await gradeSheets.CountAsync(x => x.Status == GradeSheetStatus.Submitted, cancellationToken);
var subtitle = pendingGrades > 0
? $"有 {pendingGrades} 张成绩登记册尚待提交,完成后可进入审核流程。"
: teachingClasses > 0
? $"本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时。"
: "本学期暂未分配教学班,请留意教学任务安排。";
var narrative = pendingGrades > 0
? $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时;有 {pendingGrades} 张成绩登记册等待提交。"
: $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时,目前没有待提交的成绩登记册。";
return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", narrative,
[
new DashboardGreetingInsight("教学班", $"{teachingClasses} 个", $"预计 {estimatedHours} 学时", "calm"),
new DashboardGreetingInsight("待提交成绩", $"{pendingGrades} 张", pendingGrades > 0 ? "请在截止日前完成登记" : "当前无需提交", pendingGrades > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("审核中成绩", $"{submittedGrades} 张", submittedGrades > 0 ? "等待审核结果" : "暂无审核中登记册", "calm")
]);
}
var actionable = pending is null ? 0 : pending.TeacherApplications + pending.GradeSheets +
pending.CourseAdjustments + pending.StudentStatusChanges + pending.GradeModifications +
pending.ClassroomReservations + pending.GeneralApprovals;
var taskCount = counts?.TeachingTasks ?? 0;
var scheduledCount = counts?.ScheduledTeachingTasks ?? 0;
var subtitleForManager = actionable > 0
? $"当前有 {actionable} 项待办需要跟进,优先处理时效性审核事项。"
: taskCount > 0
? $"本学期 {taskCount} 个教学班正在运行,当前没有积压待办。"
: "当前学期运行数据已就绪,可从教学任务开始推进。";
var managerNarrative = actionable > 0
? $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表;{actionable} 项待办正等待处理。"
: $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表,暂未发现需要你处理的积压事项。";
return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", managerNarrative,
[
new DashboardGreetingInsight("当前待办", $"{actionable} 项", actionable > 0 ? "优先处理可操作事项" : "暂无积压", actionable > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("本学期教学班", $"{taskCount} 个", "教学运行规模", "calm"),
new DashboardGreetingInsight("已进入课表", $"{scheduledCount} 个", taskCount > 0 ? $"覆盖 {Math.Round(scheduledCount * 100d / taskCount)}% 教学班" : "等待教学任务发布", "calm")
]);
}
private static string GetTimeGreeting()
{
var hour = DateTime.UtcNow.AddHours(8).Hour;
return hour < 11 ? "早上好" : hour < 14 ? "中午好" : hour < 18 ? "下午好" : "晚上好";
}
private async Task<DashboardPending> LoadPendingAsync( private async Task<DashboardPending> LoadPendingAsync(
CurrentUserScope scope, CurrentUserScope scope,
Guid? restrictedCollegeId, Guid? restrictedCollegeId,
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
DashboardTerm? CurrentTerm, DashboardTerm? CurrentTerm,
DashboardCounts Counts, DashboardCounts Counts,
DashboardPending Pending, DashboardPending Pending,
DashboardGreeting Greeting,
DateTime GeneratedAt); DateTime GeneratedAt);
public sealed record DashboardGreeting(
string Role,
string Title,
string Subtitle,
string Label,
string Narrative,
IReadOnlyList<DashboardGreetingInsight> Insights);
public sealed record DashboardGreetingInsight(
string Label,
string Value,
string Hint,
string Tone);
public sealed record DashboardAudience( public sealed record DashboardAudience(
string Level, string Level,
string Title, string Title,
@@ -391,81 +391,90 @@ public sealed class EvaluationsController(
if (taskIds.Count == 0) if (taskIds.Count == 0)
return Ok(new { Tasks = Array.Empty<object>() }); return Ok(new { Tasks = Array.Empty<object>() });
var setups = await db.EvaluationSetups.AsNoTracking() var taskSummaries = await db.EvaluationRecords.AsNoTracking()
.Where(x => x.Status == EvaluationSetupStatus.Closed) .WhereIn(taskIds, record => record.TeachingTaskId)
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder)) .Where(record => record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed)
.OrderByDescending(x => x.AcademicTerm!.StartDate) .GroupBy(record => new
{
record.EvaluationSetupId,
SetupName = record.EvaluationSetup!.Name,
SetupStartDate = record.EvaluationSetup.AcademicTerm!.StartDate,
record.TeachingTaskId,
TaskNumber = record.TeachingTask!.TaskNumber,
TaskName = record.TeachingTask.Name,
record.TeachingTask.AcademicTermId,
CourseCode = record.TeachingTask.Course!.Code,
CourseName = record.TeachingTask.Course.Name
})
.Select(group => new
{
group.Key,
StudentCount = group.Count(),
AverageTotal = group.Average(record =>
record.Scores.Sum(score => score.Score))
})
.OrderByDescending(x => x.Key.SetupStartDate)
.ThenBy(x => x.Key.CourseCode)
.ThenBy(x => x.Key.TaskNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var result = new List<object>(); var dimensionSummaries = await (
foreach (var setup in setups) from score in db.EvaluationScores.AsNoTracking()
{ join record in db.EvaluationRecords.AsNoTracking()
var setupTaskIds = await db.EvaluationRecords.AsNoTracking() on score.EvaluationRecordId equals record.Id
.Where(r => r.EvaluationSetupId == setup.Id) join dimension in db.EvaluationDimensions.AsNoTracking()
.WhereIn(taskIds, r => r.TeachingTaskId) on score.EvaluationDimensionId equals dimension.Id
.Select(r => r.TeachingTaskId) where taskIds.Contains(record.TeachingTaskId) &&
.Distinct() record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed
.ToListAsync(cancellationToken); group score by new
foreach (var taskId in setupTaskIds)
{ {
var task = await db.TeachingTasks.AsNoTracking() record.EvaluationSetupId,
.Where(x => x.Id == taskId) record.TeachingTaskId,
.Select(x => new dimension.Id,
{ dimension.Name,
x.Id, dimension.MaxScore,
x.TaskNumber, dimension.SortOrder
x.Name,
x.AcademicTermId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null) continue;
var records = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.EvaluationSetupId == setup.Id &&
r.TeachingTaskId == taskId)
.Include(r => r.Scores)
.ToListAsync(cancellationToken);
if (records.Count == 0) continue;
var dimensionResults = setup.Dimensions.Select(dim =>
{
var scores = records
.SelectMany(r => r.Scores)
.Where(s => s.EvaluationDimensionId == dim.Id)
.Select(s => (double)s.Score)
.ToList();
return new
{
DimensionId = dim.Id,
DimensionName = dim.Name,
MaxScore = dim.MaxScore,
AverageScore = scores.Count > 0
? Math.Round(scores.Average(), 1) : 0,
TotalScore = scores.Sum(),
Count = scores.Count
};
}).ToList();
var totalAvg = records
.Select(r => (double)r.Scores.Sum(s => s.Score))
.ToList();
result.Add(new
{
SetupId = setup.Id,
setup.Name,
Task = task,
StudentCount = records.Count,
AverageTotal = totalAvg.Count > 0
? Math.Round(totalAvg.Average(), 1) : 0,
Dimensions = dimensionResults
});
} }
} into scoreGroup
select new
{
scoreGroup.Key,
AverageScore = scoreGroup.Average(score => score.Score),
TotalScore = scoreGroup.Sum(score => score.Score),
Count = scoreGroup.Count()
})
.ToListAsync(cancellationToken);
var dimensionsByTask = dimensionSummaries
.GroupBy(item => (item.Key.EvaluationSetupId, item.Key.TeachingTaskId))
.ToDictionary(
group => group.Key,
group => (IReadOnlyCollection<TeacherEvaluationDimensionResult>)group
.OrderBy(item => item.Key.SortOrder)
.Select(item => new TeacherEvaluationDimensionResult(
item.Key.Id,
item.Key.Name,
item.Key.MaxScore,
Math.Round((double)item.AverageScore, 1),
item.TotalScore,
item.Count))
.ToArray());
var result = taskSummaries.Select(item => new TeacherEvaluationTaskResult(
item.Key.EvaluationSetupId,
item.Key.SetupName,
new TeacherEvaluationTask(
item.Key.TeachingTaskId,
item.Key.TaskNumber,
item.Key.TaskName,
item.Key.AcademicTermId,
item.Key.CourseCode,
item.Key.CourseName),
item.StudentCount,
Math.Round((double)item.AverageTotal, 1),
dimensionsByTask.GetValueOrDefault(
(item.Key.EvaluationSetupId, item.Key.TeachingTaskId),
Array.Empty<TeacherEvaluationDimensionResult>())))
.ToArray();
return Ok(new { Tasks = result }); return Ok(new { Tasks = result });
} }
@@ -594,3 +603,27 @@ public sealed record SubmitEvaluationRequest(
public sealed record EvaluationScoreRequest( public sealed record EvaluationScoreRequest(
Guid DimensionId, Guid DimensionId,
int Score); int Score);
public sealed record TeacherEvaluationTaskResult(
Guid SetupId,
string SetupName,
TeacherEvaluationTask Task,
int StudentCount,
double AverageTotal,
IReadOnlyCollection<TeacherEvaluationDimensionResult> Dimensions);
public sealed record TeacherEvaluationTask(
Guid Id,
string TaskNumber,
string Name,
Guid AcademicTermId,
string CourseCode,
string CourseName);
public sealed record TeacherEvaluationDimensionResult(
Guid DimensionId,
string DimensionName,
int MaxScore,
double AverageScore,
decimal TotalScore,
int Count);
@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Data; using System.Data;
using System.Globalization;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
@@ -74,19 +76,36 @@ public sealed class ExperimentGradesController(
item.Teacher!.TeacherNumber.Contains(keyword) || item.Teacher!.TeacherNumber.Contains(keyword) ||
item.Teacher.Name.Contains(keyword))); item.Teacher.Name.Contains(keyword)));
var total = await source.CountAsync(cancellationToken); var total = await source.Select(x => x.TeachingTaskId)
var items = await source .Distinct()
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate) .CountAsync(cancellationToken);
.ThenBy(x => x.TeachingTask!.TaskNumber) var taskIds = await source
.ThenBy(x => x.Code) .GroupBy(x => new
{
x.TeachingTaskId,
StartDate = x.TeachingTask!.AcademicTerm!.StartDate,
x.TeachingTask.TaskNumber
})
.OrderByDescending(x => x.Key.StartDate)
.ThenBy(x => x.Key.TaskNumber)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(x => x.Key.TeachingTaskId)
.ToListAsync(cancellationToken);
var projects = await source
.Where(x => taskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.ScheduleWeek ?? int.MaxValue)
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.DayOfWeek)
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.StartPeriod)
.ThenBy(x => x.StartDate)
.ThenBy(x => x.Code)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
x.Code, x.Code,
x.Name, x.Name,
x.ArrangementMode, x.ArrangementMode,
x.ScheduleWeek,
ProjectStatus = x.Status, ProjectStatus = x.Status,
x.TeachingTaskId, x.TeachingTaskId,
x.TeachingTask!.TaskNumber, x.TeachingTask!.TaskNumber,
@@ -99,6 +118,14 @@ public sealed class ExperimentGradesController(
TeacherNames = x.TeachingTask.Teachers TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
ScheduleEntry = x.ScheduleEntry == null
? null
: new
{
x.ScheduleEntry.DayOfWeek,
x.ScheduleEntry.StartPeriod,
x.ScheduleEntry.PeriodCount
},
Sheet = x.GradeSheet == null Sheet = x.GradeSheet == null
? null ? null
: new : new
@@ -122,6 +149,32 @@ public sealed class ExperimentGradesController(
} }
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var order = taskIds.Select((id, index) => new { id, index })
.ToDictionary(x => x.id, x => x.index);
var items = projects.GroupBy(x => x.TeachingTaskId)
.OrderBy(group => order[group.Key])
.Select(group =>
{
var first = group.First();
return new
{
TeachingTaskId = group.Key,
first.TaskNumber,
first.TaskName,
first.AcademicTermId,
first.TermName,
first.CourseCode,
first.CourseName,
first.CollegeName,
first.TeacherNames,
ProjectCount = group.Count(),
SheetCount = group.Count(project => project.Sheet != null),
ScoredCount = group.Sum(project => project.Sheet?.ScoredCount ?? 0),
StudentCount = group.Sum(project => project.Sheet?.StudentCount ?? 0),
Projects = group.ToList()
};
})
.ToList();
return Ok(new return Ok(new
{ {
Items = items, Items = items,
@@ -316,6 +369,15 @@ public sealed class ExperimentGradesController(
ProjectCode = x.ExperimentProject!.Code, ProjectCode = x.ExperimentProject!.Code,
ProjectName = x.ExperimentProject.Name, ProjectName = x.ExperimentProject.Name,
x.ExperimentProject.ArrangementMode, x.ExperimentProject.ArrangementMode,
x.ExperimentProject.ScheduleWeek,
ScheduleEntry = x.ExperimentProject.ScheduleEntry == null
? null
: new
{
x.ExperimentProject.ScheduleEntry.DayOfWeek,
x.ExperimentProject.ScheduleEntry.StartPeriod,
x.ExperimentProject.ScheduleEntry.PeriodCount
},
x.ExperimentProject.TeachingTaskId, x.ExperimentProject.TeachingTaskId,
x.ExperimentProject.TeachingTask!.TaskNumber, x.ExperimentProject.TeachingTask!.TaskNumber,
TaskName = x.ExperimentProject.TeachingTask.Name, TaskName = x.ExperimentProject.TeachingTask.Name,
@@ -357,6 +419,14 @@ public sealed class ExperimentGradesController(
var recordsSource = db.ExperimentGradeRecords.AsNoTracking() var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
.Where(x => x.ExperimentGradeSheetId == id); .Where(x => x.ExperimentGradeSheetId == id);
var scope = currentUserDataScope.Current;
var isBoundInstructor = scope.Scope == DataScope.Self &&
scope.IsInRole(SystemRoles.Teacher) &&
sheet.ArrangementMode == ExperimentArrangementMode.SelfScheduled;
if (isBoundInstructor)
recordsSource = recordsSource.Where(x =>
x.ExperimentSession != null && x.ExperimentSession.Instructors.Any(
instructor => instructor.Teacher!.UserId == scope.UserId));
if (studentKeyword is not null) if (studentKeyword is not null)
recordsSource = recordsSource.Where(x => recordsSource = recordsSource.Where(x =>
x.Student!.StudentNumber.Contains(studentKeyword) || x.Student!.StudentNumber.Contains(studentKeyword) ||
@@ -450,7 +520,7 @@ public sealed class ExperimentGradesController(
sheet.CreatedAt, sheet.CreatedAt,
sheet.UpdatedAt sheet.UpdatedAt
}, },
CanEdit = CanEdit(task) && CanEdit = (CanEdit(task) || isBoundInstructor) &&
sheet.Status is sheet.Status is
ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned, ExperimentGradeSheetStatus.Returned,
@@ -522,13 +592,18 @@ public sealed class ExperimentGradesController(
.Include(x => x.Items) .Include(x => x.Items)
.Include(x => x.Records) .Include(x => x.Records)
.ThenInclude(x => x.ItemScores) .ThenInclude(x => x.ItemScores)
.Include(x => x.Records)
.ThenInclude(x => x.ExperimentSession)
.ThenInclude(x => x!.Instructors)
.ThenInclude(x => x.Teacher)
.Include(x => x.ExperimentProject) .Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers) .ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher) .ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound(); if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); if (!CanEdit(sheet.ExperimentProject!.TeachingTask!) &&
!CanEditBoundSessionRecords(sheet, request.Records)) return Forbid();
if (sheet.Status is not ( if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned)) ExperimentGradeSheetStatus.Returned))
@@ -574,6 +649,166 @@ public sealed class ExperimentGradesController(
return NoContent(); return NoContent();
} }
[HttpGet("sheets/{id:guid}/template")]
[Authorize(Roles = Managers)]
public async Task<IActionResult> DownloadTemplate(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
var headers = ExperimentImportHeaders(sheet.Items);
var rows = sheet.Records
.OrderBy(record => record.Student!.StudentNumber)
.Select(record =>
{
var values = new List<object?>
{
record.Student!.StudentNumber,
record.Student.Name,
record.Student.AdministrativeClass!.Name,
ParticipationLabel(record.ParticipationStatus)
};
foreach (var item in sheet.Items.OrderBy(item => item.SortOrder))
values.Add(record.ItemScores.FirstOrDefault(score =>
score.ExperimentGradeItemId == item.Id)?.Score);
values.Add(record.SafetyViolation);
values.Add(record.AttemptNumber);
values.Add(null);
values.Add(record.TeacherComment);
return (IReadOnlyList<object?>)values;
})
.ToList();
var itemCount = sheet.Items.Count;
var totalColumn = 7 + itemCount;
var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
var instructions = new List<string>
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。",
"参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。",
"评分项填写 0—100 的数值,留空表示暂未录入。",
"安全违规填写 是 或 否;实验次数填写 1—20。",
"实验总评(自动计算)仅供 Excel 预览;上传时系统会按评分项、参与状态和安全违规重新计算。",
$"本实验共 {itemCount} 个评分项:{string.Join("", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。"
};
var bytes = ExcelWorkbookHelper.Create(
"实验成绩导入", headers, rows, instructions,
(worksheet, rowNumber) =>
{
var itemReferences = Enumerable.Range(5, itemCount)
.Select(column => $"{ColumnLetter(column)}{rowNumber}")
.ToArray();
var weightedExpression = string.Join("+", sheet.Items
.OrderBy(item => item.SortOrder)
.Select((item, index) =>
$"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
var statusReference = $"D{rowNumber}";
var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}";
worksheet.Cell(rowNumber, totalColumn).FormulaA1 =
$"=IF(OR({statusReference}=\"\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))";
var totalCell = worksheet.Cell(rowNumber, totalColumn);
totalCell.Style.NumberFormat.Format = "0.0";
totalCell.Style.Font.Bold = true;
totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
foreach (var column in scoreColumns)
{
var format = worksheet.Range(rowNumber, column, rowNumber, column)
.AddConditionalFormat().WhenLessThan(60);
format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318");
}
});
return File(bytes, ExcelWorkbookHelper.ContentType,
$"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx");
}
[HttpPost("sheets/{id:guid}/import")]
[Authorize(Roles = Managers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> ImportRecords(
Guid id,
IFormFile file,
CancellationToken cancellationToken)
{
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned))
return ConflictProblem("只有录入中或已退回实验成绩单可以导入成绩。");
var headers = ExperimentImportHeaders(sheet.Items);
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(file,
headers.Where(header => header != "实验总评(自动计算)").ToArray(),
cancellationToken);
}
catch (InvalidDataException exception)
{
return ValidationProblem(exception.Message);
}
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。");
var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber,
StringComparer.OrdinalIgnoreCase);
var items = sheet.Items.OrderBy(item => item.SortOrder).ToList();
var errors = new List<string>();
var updated = 0;
foreach (var row in rows)
{
var studentNumber = row["学号"];
if (string.IsNullOrWhiteSpace(studentNumber))
{
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
continue;
}
if (!records.TryGetValue(studentNumber, out var record))
{
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本实验成绩单中。");
continue;
}
if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError))
{
errors.Add($"第 {row.RowNumber} 行:{participationError}");
continue;
}
if (!TryParseYesNo(row["安全违规"], out var safetyViolation))
{
errors.Add($"第 {row.RowNumber} 行:“安全违规”请填写是或否。");
continue;
}
if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20)
{
errors.Add($"第 {row.RowNumber} 行:“实验次数”请填写 1—20 的整数。");
continue;
}
var scores = new List<decimal?>();
foreach (var item in items)
{
var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break;
scores.Add(score);
}
if (scores.Count != items.Count) continue;
record.ParticipationStatus = participationStatus;
record.SafetyViolation = safetyViolation;
record.AttemptNumber = attemptNumber;
record.TeacherComment = Normalize(row["教师评语"]);
var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId);
for (var index = 0; index < items.Count; index++)
scoreMap[items[index].Id].Score = scores[index];
Recalculate(sheet, record);
updated++;
}
if (errors.Count > 0) return ImportValidationProblem(errors);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { Updated = updated, Total = rows.Count });
}
[HttpPost("sheets/{id:guid}/sync-participants")] [HttpPost("sheets/{id:guid}/sync-participants")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> SyncParticipants( public async Task<ActionResult> SyncParticipants(
@@ -834,6 +1069,22 @@ public sealed class ExperimentGradesController(
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
} }
private async Task<ExperimentGradeSheet?> LoadEditableSheetAsync(
Guid id,
CancellationToken cancellationToken) =>
await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.Records)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync( private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
ExperimentProject project, ExperimentProject project,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -900,6 +1151,15 @@ public sealed class ExperimentGradesController(
private IQueryable<ExperimentProject> ScopedProjects() private IQueryable<ExperimentProject> ScopedProjects()
{ {
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
return db.ExperimentProjects.Where(x =>
(x.ArrangementMode == ExperimentArrangementMode.Centralized &&
x.TeachingTask!.Teachers.Any(item =>
item.Teacher!.UserId == scope.UserId)) ||
(x.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
x.Sessions.Any(session => session.Instructors.Any(instructor =>
instructor.Teacher!.UserId == scope.UserId))));
var taskIds = AccessibleTeachingTasks().Select(x => x.Id); var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
return db.ExperimentProjects.Where(x => return db.ExperimentProjects.Where(x =>
taskIds.Contains(x.TeachingTaskId)); taskIds.Contains(x.TeachingTaskId));
@@ -922,6 +1182,22 @@ public sealed class ExperimentGradesController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
IsAssignedTeacher(task); IsAssignedTeacher(task);
private bool CanEditBoundSessionRecords(
ExperimentGradeSheet sheet,
IReadOnlyCollection<ExperimentGradeRecordRequest> requestedRecords)
{
var scope = currentUserDataScope.Current;
if (sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled ||
scope.Scope != DataScope.Self || !scope.IsInRole(SystemRoles.Teacher) ||
requestedRecords.Count == 0)
return false;
var requestedIds = requestedRecords.Select(x => x.Id).ToHashSet();
return requestedIds.All(recordId => sheet.Records.Any(record =>
record.Id == recordId &&
record.ExperimentSession?.Instructors.Any(instructor =>
instructor.Teacher?.UserId == scope.UserId) == true));
}
private bool IsAssignedTeacher(TeachingTask task) => private bool IsAssignedTeacher(TeachingTask task) =>
task.Teachers.Any(x => task.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId); x.Teacher?.UserId == currentUserDataScope.Current.UserId);
@@ -974,6 +1250,93 @@ public sealed class ExperimentGradesController(
private static bool ValidScore(decimal? score) => private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100; !score.HasValue || score.Value is >= 0 and <= 100;
private static List<string> ExperimentImportHeaders(
IEnumerable<ExperimentGradeItem> items)
{
var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name));
headers.AddRange(["安全违规", "实验次数", "实验总评(自动计算)", "教师评语"]);
return headers;
}
private static string ParticipationLabel(
ExperimentParticipationStatus status) => status switch
{
ExperimentParticipationStatus.Pending => "待登记",
ExperimentParticipationStatus.Completed => "已完成",
ExperimentParticipationStatus.Absent => "缺席",
ExperimentParticipationStatus.Excused => "请假",
ExperimentParticipationStatus.Makeup => "补做",
ExperimentParticipationStatus.Exempt => "免做",
_ => "待登记"
};
private static bool TryParseParticipationStatus(
ExcelRow row,
out ExperimentParticipationStatus status,
out string error)
{
error = string.Empty;
switch (row["参与状态"])
{
case "待登记": status = ExperimentParticipationStatus.Pending; return true;
case "已完成": status = ExperimentParticipationStatus.Completed; return true;
case "缺席": status = ExperimentParticipationStatus.Absent; return true;
case "请假": status = ExperimentParticipationStatus.Excused; return true;
case "补做": status = ExperimentParticipationStatus.Makeup; return true;
case "免做": status = ExperimentParticipationStatus.Exempt; return true;
default:
status = default;
error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。";
return false;
}
}
private static bool TryParseYesNo(string value, out bool result)
{
if (value == "是") { result = true; return true; }
if (value == "否") { result = false; return true; }
result = false;
return false;
}
private static decimal? ParseOptionalDecimal(
ExcelRow row,
string header,
decimal minimum,
decimal maximum,
List<string> errors)
{
var value = row[header];
if (string.IsNullOrWhiteSpace(value)) return null;
if (decimal.TryParse(value, NumberStyles.Number,
CultureInfo.InvariantCulture, out var result) &&
result >= minimum && result <= maximum)
return result;
errors.Add($"第 {row.RowNumber} 行:“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
return null;
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
if (errors.Count > 50)
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
return ValidationProblem(ModelState);
}
private static string ColumnLetter(int column)
{
var result = string.Empty;
while (column > 0)
{
column--;
result = (char)('A' + column % 26) + result;
column /= 26;
}
return result;
}
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -87,6 +87,7 @@ public sealed class ExperimentsController(
x.Name, x.Name,
x.AcademicTermId, x.AcademicTermId,
x.CourseId, x.CourseId,
x.SchedulingMode,
TermName = x.AcademicTerm!.Name, TermName = x.AcademicTerm!.Name,
TermStartDate = x.AcademicTerm.StartDate, TermStartDate = x.AcademicTerm.StartDate,
TermEndDate = x.AcademicTerm.EndDate, TermEndDate = x.AcademicTerm.EndDate,
@@ -163,6 +164,19 @@ public sealed class ExperimentsController(
,x.TeachingVenueNature ,x.TeachingVenueNature
}) })
.ToListAsync(cancellationToken), .ToListAsync(cancellationToken),
Teachers = await AccessibleTeachingTasks().AsNoTracking()
.SelectMany(x => x.Teachers)
.Where(x => x.Teacher != null && x.Teacher.Status == TeacherStatus.Active)
.Select(x => new
{
x.TeachingTaskId,
x.TeacherId,
TeacherNumber = x.Teacher!.TeacherNumber,
TeacherName = x.Teacher.Name
})
.Distinct()
.OrderBy(x => x.TeacherNumber)
.ToListAsync(cancellationToken),
Periods = periodItems Periods = periodItems
}); });
} }
@@ -183,6 +197,11 @@ public sealed class ExperimentsController(
int pageSize = 20) int pageSize = 20)
{ {
var source = ScopedProjects().AsNoTracking(); var source = ScopedProjects().AsNoTracking();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
source = source.Where(x => x.Sessions.Any(session =>
session.Instructors.Any(instructor =>
instructor.Teacher!.UserId == scope.UserId)));
if (academicTermId.HasValue) if (academicTermId.HasValue)
source = source.Where(x => source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId); x.TeachingTask!.AcademicTermId == academicTermId);
@@ -249,6 +268,8 @@ public sealed class ExperimentsController(
x.Requirements, x.Requirements,
x.StartDate, x.StartDate,
x.EndDate, x.EndDate,
x.SelectionStartsAt,
x.SelectionEndsAt,
x.Status, x.Status,
x.PublishedAt, x.PublishedAt,
x.ClosedAt, x.ClosedAt,
@@ -294,6 +315,14 @@ public sealed class ExperimentsController(
ClassroomName = item.Classroom!.Name, ClassroomName = item.Classroom!.Name,
BuildingName = item.Classroom.Building!.Name, BuildingName = item.Classroom.Building!.Name,
CampusName = item.Classroom.Building.Campus!.Name CampusName = item.Classroom.Building.Campus!.Name
,InstructorNames = item.Instructors
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
.Select(instructor => instructor.Teacher!.Name)
,InstructorTeacherIds = item.Instructors
.Select(instructor => instructor.TeacherId)
,CanManage = scope.Scope != DataScope.Self ||
item.Instructors.Any(instructor =>
instructor.Teacher!.UserId == scope.UserId)
}) })
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -337,6 +366,8 @@ public sealed class ExperimentsController(
x.Requirements, x.Requirements,
x.StartDate, x.StartDate,
x.EndDate, x.EndDate,
x.SelectionStartsAt,
x.SelectionEndsAt,
x.Status, x.Status,
AcademicTermId = x.TeachingTask!.AcademicTermId, AcademicTermId = x.TeachingTask!.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name, TermName = x.TeachingTask.AcademicTerm!.Name,
@@ -376,6 +407,9 @@ public sealed class ExperimentsController(
ClassroomName = item.Classroom!.Name, ClassroomName = item.Classroom!.Name,
BuildingName = item.Classroom.Building!.Name, BuildingName = item.Classroom.Building!.Name,
CampusName = item.Classroom.Building.Campus!.Name CampusName = item.Classroom.Building.Campus!.Name
,InstructorNames = item.Instructors
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
.Select(instructor => instructor.Teacher!.Name)
}), }),
MyBooking = x.Bookings MyBooking = x.Bookings
.Where(item => .Where(item =>
@@ -430,7 +464,9 @@ public sealed class ExperimentsController(
Description = Normalize(request.Description), Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements), Requirements = Normalize(request.Requirements),
StartDate = request.StartDate, StartDate = request.StartDate,
EndDate = request.EndDate EndDate = request.EndDate,
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
}; };
db.ExperimentProjects.Add(project); db.ExperimentProjects.Add(project);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -569,7 +605,9 @@ public sealed class ExperimentsController(
Description = Normalize(request.Description), Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements), Requirements = Normalize(request.Requirements),
StartDate = request.StartDate, StartDate = request.StartDate,
EndDate = request.EndDate EndDate = request.EndDate,
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
}); });
} }
} }
@@ -645,6 +683,8 @@ public sealed class ExperimentsController(
project.Requirements = Normalize(request.Requirements); project.Requirements = Normalize(request.Requirements);
project.StartDate = request.StartDate; project.StartDate = request.StartDate;
project.EndDate = request.EndDate; project.EndDate = request.EndDate;
project.SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt);
project.SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return NoContent(); return NoContent();
} }
@@ -666,6 +706,39 @@ public sealed class ExperimentsController(
return NoContent(); return NoContent();
} }
[HttpPut("{id:guid}/published-details")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CorrectPublishedProjectDetails(
Guid id,
PublishedExperimentProjectCorrectionRequest request,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (project is null) return NotFound();
if (project.Status != ExperimentProjectStatus.Published)
return ConflictProblem("只有已发布实验项目可以修正教学内容。");
if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return ValidationProblem("请填写实验项目名称。");
var changed = project.Name != request.Name.Trim() ||
project.Description != Normalize(request.Description) ||
project.Requirements != Normalize(request.Requirements);
project.Name = request.Name.Trim();
project.Description = Normalize(request.Description);
project.Requirements = Normalize(request.Requirements);
await db.SaveChangesAsync(cancellationToken);
if (changed)
await NotifyProjectCorrectionAsync(project, cancellationToken);
return NoContent();
}
[HttpPut("batch/names")] [HttpPut("batch/names")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateProjectNames( public async Task<ActionResult> UpdateProjectNames(
@@ -722,6 +795,7 @@ public sealed class ExperimentsController(
{ {
var project = await ScopedProjects() var project = await ScopedProjects()
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.Instructors)
.Include(x => x.TeachingTask) .Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course) .ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -741,6 +815,14 @@ public sealed class ExperimentsController(
(x.SessionDate < project.StartDate || (x.SessionDate < project.StartDate ||
x.SessionDate > project.EndDate))) x.SessionDate > project.EndDate)))
return ConflictProblem("存在不在项目开放日期范围内的实验场次。"); return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
(project.SelectionStartsAt is null || project.SelectionEndsAt is null ||
project.SelectionStartsAt >= project.SelectionEndsAt))
return ConflictProblem("请先设置有效的开始选课和截至选课时间。");
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
x.Instructors.Count == 0))
return ConflictProblem("请为每个有效实验场次至少指定一位指导老师。");
project.Status = ExperimentProjectStatus.Published; project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = DateTime.UtcNow; project.PublishedAt = DateTime.UtcNow;
@@ -839,6 +921,10 @@ public sealed class ExperimentsController(
cancellationToken), cancellationToken),
Notes = Normalize(request.Notes) Notes = Normalize(request.Notes)
}; };
session.Instructors = request.InstructorTeacherIds!
.Distinct()
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
.ToList();
db.ExperimentSessions.Add(session); db.ExperimentSessions.Add(session);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -874,16 +960,14 @@ public sealed class ExperimentsController(
if (request.Items.Count > 100) if (request.Items.Count > 100)
return Task.FromResult<ActionResult>( return Task.FromResult<ActionResult>(
ValidationProblem("单次最多安排 100 条实验场次。")); ValidationProblem("单次最多安排 100 条实验场次。"));
if (request.Items.Any(x => x.ProjectId == Guid.Empty) || if (request.Items.Any(x => x.ProjectId == Guid.Empty))
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
request.Items.Count)
return Task.FromResult<ActionResult>( return Task.FromResult<ActionResult>(
ValidationProblem("同一批次中每个实验项目只能安排一个场次。")); ValidationProblem("实验项目不能为空。"));
return db.ExecuteInRetriableTransactionAsync<ActionResult>( return db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction => async transaction =>
{ {
var projectIds = request.Items.Select(x => x.ProjectId).ToList(); var projectIds = request.Items.Select(x => x.ProjectId).Distinct().ToList();
var projects = await ScopedProjects() var projects = await ScopedProjects()
.Include(x => x.TeachingTask) .Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm) .ThenInclude(x => x!.AcademicTerm)
@@ -927,6 +1011,10 @@ public sealed class ExperimentsController(
cancellationToken), cancellationToken),
Notes = Normalize(item.Notes) Notes = Normalize(item.Notes)
}; };
session.Instructors = item.InstructorTeacherIds!
.Distinct()
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
.ToList();
db.ExperimentSessions.Add(session); db.ExperimentSessions.Add(session);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
createdSessions.Add((project, session)); createdSessions.Add((project, session));
@@ -970,12 +1058,11 @@ public sealed class ExperimentsController(
.Include(x => x.ExperimentProject) .Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course) .ThenInclude(x => x!.Course)
.Include(x => x.Instructors)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null || if (session is null) return NotFound();
!await ScopedProjects().AnyAsync( if (!CanManageSession(session)) return Forbid();
x => x.Id == session.ExperimentProjectId,
cancellationToken))
return NotFound();
if (session.Status == ExperimentSessionStatus.Cancelled) if (session.Status == ExperimentSessionStatus.Cancelled)
return NoContent(); return NoContent();
@@ -1037,12 +1124,11 @@ public sealed class ExperimentsController(
{ {
var session = await db.ExperimentSessions.AsNoTracking() var session = await db.ExperimentSessions.AsNoTracking()
.Include(x => x.ExperimentProject) .Include(x => x.ExperimentProject)
.Include(x => x.Instructors)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null || if (session is null) return NotFound();
!await ScopedProjects().AnyAsync( if (!CanManageSession(session)) return Forbid();
x => x.Id == session.ExperimentProjectId,
cancellationToken))
return NotFound();
if (session.ExperimentProject!.ArrangementMode == if (session.ExperimentProject!.ArrangementMode ==
ExperimentArrangementMode.Centralized) ExperimentArrangementMode.Centralized)
@@ -1105,6 +1191,12 @@ public sealed class ExperimentsController(
if (session.ExperimentProject.ArrangementMode != if (session.ExperimentProject.ArrangementMode !=
ExperimentArrangementMode.SelfScheduled) ExperimentArrangementMode.SelfScheduled)
return ConflictProblem("集中安排实验无需学生预约。"); return ConflictProblem("集中安排实验无需学生预约。");
var now = DateTime.UtcNow;
if (session.ExperimentProject.SelectionStartsAt is null ||
session.ExperimentProject.SelectionEndsAt is null ||
now < session.ExperimentProject.SelectionStartsAt ||
now > session.ExperimentProject.SelectionEndsAt)
return ConflictProblem("当前不在该实验项目的选课时间范围内。");
if (!await TeachingTaskRosterQuery if (!await TeachingTaskRosterQuery
.TaskIdsForStudent(db, student.Id) .TaskIdsForStudent(db, student.Id)
.ContainsAsync( .ContainsAsync(
@@ -1210,11 +1302,34 @@ public sealed class ExperimentsController(
IsolationLevel.Serializable); IsolationLevel.Serializable);
} }
private async Task<string?> ValidateInstructorTeacherIdsAsync(
ExperimentProject project,
IReadOnlyList<Guid>? instructorTeacherIds,
CancellationToken cancellationToken)
{
var ids = instructorTeacherIds?.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
if (ids.Count == 0) return "请至少选择一位指导老师。";
if (ids.Count > 20) return "单个实验场次最多选择 20 位指导老师。";
var validCount = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x => x.TeachingTaskId == project.TeachingTaskId &&
ids.Contains(x.TeacherId) &&
x.Teacher!.Status == TeacherStatus.Active)
.Select(x => x.TeacherId)
.Distinct()
.CountAsync(cancellationToken);
return validCount == ids.Count
? null
: "指导老师必须是该教学任务的在职任课教师。";
}
private async Task<string?> ValidateSessionAsync( private async Task<string?> ValidateSessionAsync(
ExperimentProject project, ExperimentProject project,
ExperimentSessionRequest request, ExperimentSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var instructorProblem = await ValidateInstructorTeacherIdsAsync(
project, request.InstructorTeacherIds, cancellationToken);
if (instructorProblem is not null) return instructorProblem;
if (request.SessionDate < project.StartDate || if (request.SessionDate < project.StartDate ||
request.SessionDate > project.EndDate) request.SessionDate > project.EndDate)
return "实验场次日期必须在项目开放日期范围内。"; return "实验场次日期必须在项目开放日期范围内。";
@@ -1442,6 +1557,26 @@ public sealed class ExperimentsController(
taskIds.Contains(x.TeachingTaskId)); taskIds.Contains(x.TeachingTaskId));
} }
private bool CanManageSession(ExperimentSession session)
{
var scope = currentUserDataScope.Current;
if (scope.Scope is DataScope.All or DataScope.College)
return true;
return scope.IsInRole(SystemRoles.Teacher) &&
session.Instructors.Any(instructor =>
instructor.Teacher?.UserId == scope.UserId);
}
private bool CanCorrectPublishedProject(TeachingTask task)
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
task.Teachers.Any(item =>
item.Teacher?.UserId == scope.UserId);
}
private Task<Student?> CurrentStudentAsync( private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x => db.Students.FirstOrDefaultAsync(x =>
@@ -1492,6 +1627,22 @@ public sealed class ExperimentsController(
NotificationCategory.Schedule); NotificationCategory.Schedule);
} }
private async Task NotifyProjectCorrectionAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
if (userIds.Count == 0) return;
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目内容已更新",
$"《{project.TeachingTask!.Course!.Name}》的“{project.Name}”教学内容或要求已修正,请重新查看。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds) private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
{ {
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList(); var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
@@ -1510,6 +1661,12 @@ public sealed class ExperimentsController(
return "实验安排方式无效。"; return "实验安排方式无效。";
if (request.StartDate > request.EndDate) if (request.StartDate > request.EndDate)
return "项目开始日期不能晚于结束日期。"; return "项目开始日期不能晚于结束日期。";
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
request.SelectionStartsAt.HasValue != request.SelectionEndsAt.HasValue)
return "开始选课和截至选课时间需要同时填写。";
if (request.SelectionStartsAt.HasValue && request.SelectionEndsAt.HasValue &&
request.SelectionStartsAt >= request.SelectionEndsAt)
return "开始选课时间必须早于截至选课时间。";
if (request.StartDate < term.StartDate || if (request.StartDate < term.StartDate ||
request.EndDate > term.EndDate) request.EndDate > term.EndDate)
return "实验项目日期必须在所属学期起止日期内。"; return "实验项目日期必须在所属学期起止日期内。";
@@ -1543,6 +1700,15 @@ public sealed class ExperimentsController(
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static DateTime? NormalizeSelectionTime(DateTime? value)
{
if (!value.HasValue) return null;
if (value.Value.Kind == DateTimeKind.Utc) return value.Value;
var china = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
return TimeZoneInfo.ConvertTimeToUtc(
DateTime.SpecifyKind(value.Value, DateTimeKind.Unspecified), china);
}
private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? names) private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? names)
{ {
if (names is null || names.Count == 0) return null; if (names is null || names.Count == 0) return null;
@@ -1567,7 +1733,14 @@ public sealed record ExperimentProjectRequest(
[MaxLength(1000)] string? Requirements, [MaxLength(1000)] string? Requirements,
DateOnly StartDate, DateOnly StartDate,
DateOnly EndDate, DateOnly EndDate,
Guid? ScheduleEntryId = null); Guid? ScheduleEntryId = null,
DateTime? SelectionStartsAt = null,
DateTime? SelectionEndsAt = null);
public sealed record PublishedExperimentProjectCorrectionRequest(
[Required, MaxLength(120)] string Name,
[MaxLength(1000)] string? Description,
[MaxLength(1000)] string? Requirements);
public sealed record ExperimentProjectBatchRequest( public sealed record ExperimentProjectBatchRequest(
[Required] IReadOnlyList<Guid> TeachingTaskIds, [Required] IReadOnlyList<Guid> TeachingTaskIds,
@@ -1579,7 +1752,9 @@ public sealed record ExperimentProjectBatchRequest(
DateOnly StartDate, DateOnly StartDate,
DateOnly EndDate, DateOnly EndDate,
IReadOnlyList<string>? Names = null, IReadOnlyList<string>? Names = null,
bool GenerateSequentialNames = false) bool GenerateSequentialNames = false,
DateTime? SelectionStartsAt = null,
DateTime? SelectionEndsAt = null)
{ {
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) => public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new( new(
@@ -1590,7 +1765,10 @@ public sealed record ExperimentProjectBatchRequest(
Description, Description,
Requirements, Requirements,
StartDate, StartDate,
EndDate); EndDate,
null,
SelectionStartsAt,
SelectionEndsAt);
} }
public sealed record ExperimentProjectBulkRequest( public sealed record ExperimentProjectBulkRequest(
@@ -1606,7 +1784,8 @@ public sealed record ExperimentSessionRequest(
[Range(1, 30)] int StartPeriod, [Range(1, 30)] int StartPeriod,
[Range(1, 30)] int PeriodCount, [Range(1, 30)] int PeriodCount,
[Range(1, 10000)] int Capacity, [Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes); [MaxLength(500)] string? Notes,
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
public sealed record ExperimentSessionBatchRequest( public sealed record ExperimentSessionBatchRequest(
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items); [Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
@@ -1618,7 +1797,8 @@ public sealed record ExperimentSessionBatchItem(
[Range(1, 30)] int StartPeriod, [Range(1, 30)] int StartPeriod,
[Range(1, 30)] int PeriodCount, [Range(1, 30)] int PeriodCount,
[Range(1, 10000)] int Capacity, [Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes) [MaxLength(500)] string? Notes,
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null)
{ {
public ExperimentSessionRequest ToSessionRequest() => public ExperimentSessionRequest ToSessionRequest() =>
new( new(
@@ -1627,7 +1807,8 @@ public sealed record ExperimentSessionBatchItem(
StartPeriod, StartPeriod,
PeriodCount, PeriodCount,
Capacity, Capacity,
Notes); Notes,
InstructorTeacherIds);
} }
public sealed record ExperimentPeriodOption( public sealed record ExperimentPeriodOption(
@@ -93,6 +93,9 @@ public sealed class GradeAnalyticsController(
public async Task<ActionResult> GetTeachingClasses( public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId, Guid? academicTermId,
string? keyword, string? keyword,
Guid? collegeId = null,
string? teacherKeyword = null,
bool? riskOnly = null,
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -105,12 +108,23 @@ public sealed class GradeAnalyticsController(
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId)); .Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
if (academicTermId.HasValue) if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId); source = source.Where(x => x.AcademicTermId == academicTermId);
if (collegeId.HasValue)
source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value);
if (keyword is not null) if (keyword is not null)
source = source.Where(x => source = source.Where(x =>
x.TeachingTask!.TaskNumber.Contains(keyword) || x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) || x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) || x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword)); x.TeachingTask.Course.Name.Contains(keyword));
if (!string.IsNullOrWhiteSpace(teacherKeyword))
{
teacherKeyword = teacherKeyword.Trim();
source = source.Where(x => x.TeachingTask!.Teachers.Any(item =>
item.Teacher!.Name.Contains(teacherKeyword) ||
item.Teacher.TeacherNumber.Contains(teacherKeyword)));
}
if (riskOnly == true)
source = source.Where(x => x.PassRate < 60 || x.AverageScore < 60);
var total = await source.CountAsync(cancellationToken); var total = await source.CountAsync(cancellationToken);
var items = await source var items = await source
@@ -706,7 +706,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]); headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
var rows = sheet.Records.Select(record => var rows = sheet.Records.Select(record =>
{ {
@@ -723,8 +723,8 @@ public sealed class GradesController(
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score; .FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
values.Add(score); values.Add(score);
} }
values.Add(null);
values.Add(record.FinalScore); values.Add(record.FinalScore);
values.Add(null);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" : values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" : record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" : record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
@@ -745,9 +745,9 @@ public sealed class GradesController(
var regularColumn = 4; var regularColumn = 4;
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray(); var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
var totalColumn = regularColumn + itemColumns.Length + 1; var finalColumn = regularColumn + itemColumns.Length + 1;
var finalColumn = totalColumn + 1; var totalColumn = finalColumn + 1;
var statusColumn = finalColumn + 1; var statusColumn = totalColumn + 1;
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) }; var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
weightedColumns.AddRange(sheet.Items.Select((item, index) => weightedColumns.AddRange(sheet.Items.Select((item, index) =>
(itemColumns[index], item.Weight))); (itemColumns[index], item.Weight)));
@@ -819,7 +819,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]); headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows; IReadOnlyList<ExcelRow> rows;
try try
@@ -58,6 +58,7 @@ public sealed class NotificationsController(
var total = await source.CountAsync(cancellationToken); var total = await source.CountAsync(cancellationToken);
var items = await source var items = await source
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(x => new .Select(x => new
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -32,15 +33,24 @@ public sealed class OfficialDocumentsController(
OfficialDocumentType? type, OfficialDocumentType? type,
OfficialDocumentStatus? status, OfficialDocumentStatus? status,
Guid? studentId, Guid? studentId,
CancellationToken cancellationToken) CancellationToken cancellationToken,
int page = 1,
int pageSize = 20)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = AccessibleDocuments().AsNoTracking(); var source = AccessibleDocuments().AsNoTracking();
if (type.HasValue) source = source.Where(x => x.Type == type); if (type.HasValue) source = source.Where(x => x.Type == type);
if (status.HasValue) source = source.Where(x => x.Status == status); if (status.HasValue) source = source.Where(x => x.Status == status);
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId); if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
return Ok(await source var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.IssuedAt) .OrderByDescending(x => x.IssuedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -62,15 +72,21 @@ public sealed class OfficialDocumentsController(
DownloadCount = x.Downloads.Count, DownloadCount = x.Downloads.Count,
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt) LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
} }
[HttpGet("students/options")] [HttpGet("students/options")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> StudentOptions( public async Task<ActionResult> StudentOptions(
string? keyword, string? keyword,
CancellationToken cancellationToken) CancellationToken cancellationToken,
int page = 1,
int pageSize = 30)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = AccessibleStudents().AsNoTracking(); var source = AccessibleStudents().AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword)) if (!string.IsNullOrWhiteSpace(keyword))
{ {
@@ -79,7 +95,10 @@ public sealed class OfficialDocumentsController(
x.StudentNumber.Contains(value) || x.Name.Contains(value)); x.StudentNumber.Contains(value) || x.Name.Contains(value));
} }
return Ok(await source.OrderBy(x => x.StudentNumber) var total = await source.CountAsync(cancellationToken);
var items = await source.OrderBy(x => x.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -90,7 +109,8 @@ public sealed class OfficialDocumentsController(
MajorName = x.AdministrativeClass.Major!.Name, MajorName = x.AdministrativeClass.Major!.Name,
CollegeName = x.AdministrativeClass.Major.College!.Name CollegeName = x.AdministrativeClass.Major.College!.Name
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
} }
[HttpPost] [HttpPost]
@@ -207,14 +227,24 @@ public sealed class OfficialDocumentsController(
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> DownloadHistory( public async Task<ActionResult> DownloadHistory(
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken,
int page = 1,
int pageSize = 20)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken)) if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
return NotFound(); return NotFound();
return Ok(await db.OfficialDocumentDownloads.AsNoTracking() var source = db.OfficialDocumentDownloads.AsNoTracking()
.Where(x => x.OfficialDocumentId == id) .Where(x => x.OfficialDocumentId == id)
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id);
var total = await source.CountAsync(cancellationToken);
var items = await source
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -224,7 +254,8 @@ public sealed class OfficialDocumentsController(
x.IpAddress, x.IpAddress,
x.UserAgent x.UserAgent
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
} }
[HttpPost("{id:guid}/invalidate")] [HttpPost("{id:guid}/invalidate")]
@@ -330,7 +330,7 @@ public sealed class OperationsController(
{ {
var ids = pageItems.Select(x => x.Id).ToArray(); var ids = pageItems.Select(x => x.Id).ToArray();
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking() var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => ids.Contains(x.JobId)) .WhereIn(ids, x => x.JobId)
.Select(x => new { x.JobId, x.ProcessingAttempts }) .Select(x => new { x.JobId, x.ProcessingAttempts })
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts, .ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
cancellationToken); cancellationToken);
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -20,10 +21,38 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
[HttpGet("batches")] [HttpGet("batches")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches public async Task<ActionResult> GetBatches(
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt) string? keyword = null,
OtherExamBatchStatus? status = null,
OtherExamMetricKind? metricKind = null,
DateOnly? examDateFrom = null,
DateOnly? examDateTo = null,
int page = 1,
int pageSize = 20,
CancellationToken ct = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = db.OtherExamBatches.AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
(x.ExamCode != null && x.ExamCode.Contains(keyword)) ||
x.Name.Contains(keyword) ||
(x.Organizer != null && x.Organizer.Contains(keyword)));
}
if (status.HasValue) source = source.Where(x => x.Status == status.Value);
if (metricKind.HasValue) source = source.Where(x => x.MetricKind == metricKind.Value);
if (examDateFrom.HasValue) source = source.Where(x => x.ExamDate >= examDateFrom.Value);
if (examDateTo.HasValue) source = source.Where(x => x.ExamDate <= examDateTo.Value);
var total = await source.CountAsync(ct);
var items = await source.OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count }) .Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
.ToListAsync(ct)); .ToListAsync(ct);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost("batches")] [HttpPost("batches")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
@@ -74,7 +103,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct); var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
if (batch is null) return NotFound(); if (batch is null) return NotFound();
var result = await ReplaceResultsAsync(batch, request.Results, ct); var result = await ReplaceResultsAsync(batch, request.Results, ct);
return result is null ? Ok(new { updated = batch.Results.Count }) : result; return result is null ? Ok(new { updated = request.Results.Count }) : result;
} }
[HttpGet("batches/{id:guid}/template")] [HttpGet("batches/{id:guid}/template")]
@@ -153,7 +182,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。"); if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
foreach (var item in inputs) foreach (var item in inputs)
{ {
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed); var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed, item.Notes);
if (error is not null) return ValidationProblem(error); if (error is not null) return ValidationProblem(error);
} }
var studentIds = students.Values.Select(x => x.Id).ToList(); var studentIds = students.Values.Select(x => x.Id).ToList();
@@ -166,7 +195,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
batch.Status = OtherExamBatchStatus.Draft; batch.Status = OtherExamBatchStatus.Draft;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
batch.Results = inputs.Select(x => var replacementResults = inputs.Select(x =>
{ {
var student = students[x.StudentNumber.Trim()]; var student = students[x.StudentNumber.Trim()];
return new OtherExamResult return new OtherExamResult
@@ -180,6 +209,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
Notes = Normalize(x.Notes) Notes = Normalize(x.Notes)
}; };
}).ToList(); }).ToList();
db.OtherExamResults.AddRange(replacementResults);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct); await transaction.CommitAsync(ct);
return null; return null;
@@ -209,13 +239,20 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。", OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
_ => null _ => null
}; };
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass, string? notes)
{ {
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。", if (Normalize(notes)?.Length > 500) return "备注不能超过 500 个字符。";
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。", return b.MetricKind switch
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。", {
_ => null OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
}; OtherExamMetricKind.Score when decimal.Round(score.Value, 2) != score.Value => "分数最多保留两位小数。",
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
OtherExamMetricKind.Level when Normalize(level)!.Length > 50 => "等级不能超过 50 个字符。",
OtherExamMetricKind.Level when !(b.LevelOptions ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Contains(Normalize(level)!, StringComparer.Ordinal) => "等级必须从该考试场次配置的等级选项中选择。",
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
_ => null
};
}
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options) private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
{ {
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000); if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
ToResponse(job)); ToResponse(job));
} }
[HttpGet("plans/{planId:guid}/preflight")]
public async Task<ActionResult> Preflight(Guid planId, CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.Name, CourseName = x.Course!.Name })
.ToListAsync(cancellationToken);
var scheduled = await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlanId == planId)
.Select(x => x.TeachingTaskId).Distinct().ToListAsync(cancellationToken);
var missing = tasks.Where(x => !scheduled.Contains(x.Id))
.Select(x => $"《{x.CourseName}》{x.Name}").Take(20).ToList();
return Ok(new { totalTasks = tasks.Count, scheduledTasks = scheduled.Count, unscheduledTasks = missing.Count, messages = missing });
}
[HttpGet("auto-schedule-jobs/{jobId:guid}")] [HttpGet("auto-schedule-jobs/{jobId:guid}")]
public async Task<ActionResult<AutomaticScheduleJobResponse>> public async Task<ActionResult<AutomaticScheduleJobResponse>>
GetAutomaticScheduleJob( GetAutomaticScheduleJob(
@@ -17,17 +17,53 @@ public sealed class StudentStatusChangesController(
ICurrentUserDataScope currentUserDataScope) : ControllerBase ICurrentUserDataScope currentUserDataScope) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult> Get(CancellationToken token) public async Task<ActionResult<StudentStatusChangePage>> Get(
int page = 1,
int pageSize = 20,
CancellationToken token = default)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = ScopedChanges().AsNoTracking(); var source = ScopedChanges().AsNoTracking();
return Ok(await source.OrderByDescending(x => x.SubmittedAt).Select(x => new var userScope = currentUserDataScope.Current;
{ var total = await source.CountAsync(token);
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, var actionableTotal = await source.CountAsync(change =>
ClassName = x.Student.AdministrativeClass!.Name, (change.State == StudentStatusChangeState.Submitted &&
CollegeName = x.Student.AdministrativeClass.Major!.College!.Name, userScope.IsInRole(SystemRoles.Counselor)) ||
x.Type, x.OriginalStatus, x.TargetStatus, x.Reason, x.State, (change.State == StudentStatusChangeState.CounselorApproved &&
x.ReviewComment, x.SubmittedAt, x.ReviewedAt, x.ApprovedAt userScope.IsInRole(SystemRoles.CollegeAdmin)) ||
}).ToListAsync(token)); (change.State == StudentStatusChangeState.CollegeApproved &&
(userScope.IsInRole(SystemRoles.AcademicAdmin) ||
userScope.IsInRole(SystemRoles.SuperAdmin))), token);
var finishedTotal = await source.CountAsync(change =>
change.State == StudentStatusChangeState.Approved ||
change.State == StudentStatusChangeState.Rejected ||
change.State == StudentStatusChangeState.Cancelled, token);
var items = await source
.OrderByDescending(x => x.SubmittedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new StudentStatusChangeListItem(
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
x.Student.AdministrativeClass!.Name,
x.Student.AdministrativeClass.Major!.College!.Name,
x.Type,
x.OriginalStatus,
x.TargetStatus,
x.Reason,
x.State,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.ApprovedAt))
.ToListAsync(token);
return Ok(new StudentStatusChangePage(
items, total, page, pageSize, actionableTotal, finishedTotal));
} }
[HttpGet("options")] [HttpGet("options")]
@@ -199,6 +235,31 @@ public sealed class StudentStatusChangesController(
}); });
} }
public sealed record StudentStatusChangePage(
IReadOnlyCollection<StudentStatusChangeListItem> Items,
int Total,
int Page,
int PageSize,
int ActionableTotal,
int FinishedTotal);
public sealed record StudentStatusChangeListItem(
Guid Id,
Guid StudentId,
string StudentNumber,
string Name,
string ClassName,
string CollegeName,
StudentStatusChangeType Type,
StudentStatus OriginalStatus,
StudentStatus TargetStatus,
string Reason,
StudentStatusChangeState State,
string? ReviewComment,
DateTime SubmittedAt,
DateTime? ReviewedAt,
DateTime? ApprovedAt);
public sealed record StudentStatusChangeRequest( public sealed record StudentStatusChangeRequest(
StudentStatusChangeType Type, StudentStatusChangeType Type,
[Required, MinLength(10), MaxLength(1000)] string Reason); [Required, MinLength(10), MaxLength(1000)] string Reason);
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -46,17 +47,26 @@ public sealed class TeacherCourseApplicationsController(
[Authorize(Roles = SystemRoles.Teacher)] [Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMine( public async Task<ActionResult> GetMine(
Guid? academicTermId, Guid? academicTermId,
CancellationToken cancellationToken) int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var teacher = await CurrentTeacherAsync(cancellationToken); var teacher = await CurrentTeacherAsync(cancellationToken);
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。"); if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
var source = db.TeacherCourseApplications.AsNoTracking() var source = db.TeacherCourseApplications.AsNoTracking()
.Where(x => x.TeacherId == teacher.Id); .Where(x => x.TeacherId == teacher.Id);
if (academicTermId.HasValue) if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId); source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.AcademicTerm!.StartDate) .OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code) .ThenBy(x => x.Course!.Code)
.ThenBy(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -72,7 +82,8 @@ public sealed class TeacherCourseApplicationsController(
x.SubmittedAt, x.SubmittedAt,
x.ReviewedAt x.ReviewedAt
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
} }
[HttpPost("mine")] [HttpPost("mine")]
@@ -144,15 +155,24 @@ public sealed class TeacherCourseApplicationsController(
public async Task<ActionResult> GetReviews( public async Task<ActionResult> GetReviews(
Guid? academicTermId, Guid? academicTermId,
TeacherCourseApplicationStatus? status, TeacherCourseApplicationStatus? status,
CancellationToken cancellationToken) int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = ScopedApplications().AsNoTracking(); var source = ScopedApplications().AsNoTracking();
if (academicTermId.HasValue) if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId); source = source.Where(x => x.AcademicTermId == academicTermId);
if (status.HasValue) source = source.Where(x => x.Status == status); if (status.HasValue) source = source.Where(x => x.Status == status);
return Ok(await source var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.Status) .OrderBy(x => x.Status)
.ThenByDescending(x => x.SubmittedAt) .ThenByDescending(x => x.SubmittedAt)
.ThenBy(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -172,7 +192,8 @@ public sealed class TeacherCourseApplicationsController(
x.SubmittedAt, x.SubmittedAt,
x.ReviewedAt x.ReviewedAt
}) })
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
} }
[HttpGet("assignment-options")] [HttpGet("assignment-options")]
@@ -171,6 +171,95 @@ public sealed class TimetableManagementController(
return File(bytes, ExcelWorkbookHelper.ContentType, fileName); return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
} }
[HttpPost("export/batch.xlsx")]
public async Task<ActionResult> ExportBatch(
TimetableBatchExportRequest request,
CancellationToken cancellationToken)
{
var resourceIds = request.ResourceIds.Distinct().ToArray();
if (resourceIds.Length is 0 or > 100)
return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest);
var timetables = new List<TimetableData>(resourceIds.Length);
foreach (var resourceId in resourceIds)
{
var result = await LoadAuthorizedAsync(
request.ResourceType,
resourceId,
request.AcademicTermId,
request.SchedulePlanId,
cancellationToken);
if (result.Result is null) return result.Error!;
timetables.Add(result.Result);
}
var bytes = TimetableExcelExporter.Create(timetables);
var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.xlsx";
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
}
[HttpGet("export.pdf")]
public async Task<ActionResult> ExportPdf(
TimetableResourceType resourceType,
Guid resourceId,
Guid academicTermId,
Guid? schedulePlanId,
CancellationToken cancellationToken)
{
var result = await LoadAuthorizedAsync(
resourceType,
resourceId,
academicTermId,
schedulePlanId,
cancellationToken);
if (result.Result is null) return result.Error!;
return PdfFile(result.Result);
}
[HttpPost("export/batch.pdf")]
public async Task<ActionResult> ExportBatchPdf(
TimetableBatchExportRequest request,
CancellationToken cancellationToken)
{
var resourceIds = request.ResourceIds.Distinct().ToArray();
if (resourceIds.Length is 0 or > 100)
return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest);
var timetables = new List<TimetableData>(resourceIds.Length);
foreach (var resourceId in resourceIds)
{
var result = await LoadAuthorizedAsync(
request.ResourceType,
resourceId,
request.AcademicTermId,
request.SchedulePlanId,
cancellationToken);
if (result.Result is null) return result.Error!;
timetables.Add(result.Result);
}
var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.pdf";
return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName);
}
[HttpPost("display-links/export.xlsx")]
public async Task<ActionResult> ExportDisplayLinks(VenueDisplayLinkExportRequest request, CancellationToken cancellationToken)
{
var classroomIds = request.ClassroomIds.Distinct().Take(200).ToArray();
var buildingIds = request.BuildingIds.Distinct().Take(100).ToArray();
if (classroomIds.Length + buildingIds.Length == 0)
return ValidationProblem("请至少选择一个教室或教学楼。", statusCode: StatusCodes.Status400BadRequest);
var classrooms = await db.Classrooms.AsNoTracking().WhereIn(classroomIds, x => x.Id)
.OrderBy(x => x.Building!.Name).ThenBy(x => x.Code)
.Select(x => new object?[] { "教室当天课程", x.Building!.Name + " · " + x.Name, "/venue-display/classroom/" + x.Id })
.ToListAsync(cancellationToken);
var buildings = await db.Buildings.AsNoTracking().WhereIn(buildingIds, x => x.Id).OrderBy(x => x.Name)
.Select(x => new object?[] { "教学楼空余教室", x.Name, "/venue-display/building/" + x.Id })
.ToListAsync(cancellationToken);
var bytes = ExcelWorkbookHelper.Create("展牌链接", ["展牌类型", "场地", "相对链接"], classrooms.Concat(buildings).ToList());
return File(bytes, ExcelWorkbookHelper.ContentType, "场地信息展牌链接.xlsx");
}
private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync( private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync(
TimetableResourceType resourceType, TimetableResourceType resourceType,
Guid resourceId, Guid resourceId,
@@ -205,8 +294,22 @@ public sealed class TimetableManagementController(
value = value.Replace(character, '-'); value = value.Replace(character, '-');
return value.Trim(); return value.Trim();
} }
private ActionResult PdfFile(TimetableData timetable) =>
File(
TimetablePdfExporter.Create(timetable),
"application/pdf",
$"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf");
} }
public sealed record TimetableBatchExportRequest(
TimetableResourceType ResourceType,
[param: Required, MinLength(1), MaxLength(100)] IReadOnlyCollection<Guid> ResourceIds,
Guid AcademicTermId,
Guid? SchedulePlanId);
public sealed record VenueDisplayLinkExportRequest(IReadOnlyCollection<Guid> ClassroomIds, IReadOnlyCollection<Guid> BuildingIds);
[ApiController] [ApiController]
[Route("api/timetables")] [Route("api/timetables")]
public sealed class FreeClassroomsController( public sealed class FreeClassroomsController(
@@ -45,6 +45,30 @@ public sealed class TimetablesController(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
BuildTimetableAsync(classId, academicTermId, null, cancellationToken); BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
[HttpGet("my-class")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMyClass(CancellationToken cancellationToken)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var administrativeClassId = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => (Guid?)x.AdministrativeClassId)
.FirstOrDefaultAsync(cancellationToken);
if (!administrativeClassId.HasValue)
{
return Conflict(new ProblemDetails
{
Title = "档案未关联",
Detail = "当前登录账号没有关联学生档案,请联系教务管理员。",
Status = StatusCodes.Status409Conflict
});
}
return Ok(new { AdministrativeClassId = administrativeClassId.Value });
}
[HttpGet("teachers/{teacherId:guid}")] [HttpGet("teachers/{teacherId:guid}")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult> GetTeacherTimetable( public async Task<ActionResult> GetTeacherTimetable(
@@ -76,6 +100,21 @@ public sealed class TimetablesController(
return ExcelFile(result); return ExcelFile(result);
} }
[HttpGet("teachers/{teacherId:guid}/export.pdf")]
[AllowAnonymous]
public async Task<ActionResult> ExportTeacherTimetablePdf(
Guid teacherId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Teacher,
teacherId,
academicTermId,
cancellationToken);
return result is null ? NotFound() : PdfFile(result);
}
[HttpGet("classes/{classId:guid}/export.xlsx")] [HttpGet("classes/{classId:guid}/export.xlsx")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult> ExportClassTimetable( public async Task<ActionResult> ExportClassTimetable(
@@ -92,6 +131,21 @@ public sealed class TimetablesController(
return ExcelFile(result); return ExcelFile(result);
} }
[HttpGet("classes/{classId:guid}/export.pdf")]
[AllowAnonymous]
public async Task<ActionResult> ExportClassTimetablePdf(
Guid classId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var result = await GetPublishedTimetableAsync(
TimetableResourceType.Class,
classId,
academicTermId,
cancellationToken);
return result is null ? NotFound() : PdfFile(result);
}
[HttpGet("mine")] [HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> GetMyTimetable( public async Task<ActionResult> GetMyTimetable(
@@ -203,6 +257,39 @@ public sealed class TimetablesController(
return NotFound(); return NotFound();
} }
[HttpGet("mine/export.pdf")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> ExportMyTimetablePdf(
Guid? academicTermId,
CancellationToken cancellationToken)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
return Unauthorized();
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new { x.Id, x.AdministrativeClassId, x.StudentNumber, x.Name })
.FirstOrDefaultAsync(cancellationToken);
if (student is not null)
{
var result = await timetableDataService.BuildAsync(
TimetableResourceType.Class, student.AdministrativeClassId, academicTermId, null,
false, student.Id, new TimetableStudentDto(student.StudentNumber, student.Name),
cancellationToken);
return result is null ? NotFound() : PdfFile(result);
}
var teacher = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
.Select(x => new { x.Id })
.FirstOrDefaultAsync(cancellationToken);
if (teacher is null) return NotFound();
var teacherResult = await timetableDataService.BuildAsync(
TimetableResourceType.Teacher, teacher.Id, academicTermId, null, false, null, null,
cancellationToken);
return teacherResult is null ? NotFound() : PdfFile(teacherResult);
}
[HttpGet("mine/calendar-subscription")] [HttpGet("mine/calendar-subscription")]
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)] [Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
public async Task<ActionResult> GetMyCalendarSubscription( public async Task<ActionResult> GetMyCalendarSubscription(
@@ -426,6 +513,13 @@ public sealed class TimetablesController(
return File(bytes, ExcelWorkbookHelper.ContentType, fileName); return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
} }
private ActionResult PdfFile(TimetableData result)
{
var fileName =
$"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.pdf";
return File(TimetablePdfExporter.Create(result), "application/pdf", fileName);
}
private static string SafeFileName(string value) private static string SafeFileName(string value)
{ {
foreach (var character in Path.GetInvalidFileNameChars()) foreach (var character in Path.GetInvalidFileNameChars())
+85 -22
View File
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Security.Claims; using System.Security.Claims;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
@@ -19,10 +20,54 @@ public sealed class UsersController(
RoleManager<ApplicationRole> roleManager) : ControllerBase RoleManager<ApplicationRole> roleManager) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<object>> GetUsers(CancellationToken cancellationToken) public async Task<ActionResult<PagedResult<UserListItem>>> GetUsers(
int page = 1,
int pageSize = 20,
string? keyword = null,
string? roleName = null,
Guid? collegeId = null,
bool? isEnabled = null,
bool? hasLoggedIn = null,
CancellationToken cancellationToken = default)
{ {
var users = await userManager.Users.AsNoTracking() if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var query = userManager.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
query = query.Where(user =>
user.UserName!.Contains(keyword) ||
user.DisplayName.Contains(keyword) ||
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
(from userRole in db.UserRoles
join role in db.Roles on userRole.RoleId equals role.Id
where userRole.UserId == user.Id && role.Name!.Contains(keyword)
select role.Id).Any());
}
if (!string.IsNullOrWhiteSpace(roleName))
{
roleName = roleName.Trim();
query = query.Where(user =>
(from userRole in db.UserRoles
join candidateRole in db.Roles on userRole.RoleId equals candidateRole.Id
where userRole.UserId == user.Id && candidateRole.Name == roleName
select userRole.RoleId).Any());
}
if (collegeId.HasValue) query = query.Where(user => user.CollegeId == collegeId.Value);
if (isEnabled.HasValue) query = query.Where(user => user.IsEnabled == isEnabled.Value);
if (hasLoggedIn.HasValue)
query = hasLoggedIn.Value
? query.Where(user => user.LastLoginAt != null)
: query.Where(user => user.LastLoginAt == null);
var total = await query.CountAsync(cancellationToken);
var users = await query
.OrderBy(x => x.UserName) .OrderBy(x => x.UserName)
.ThenBy(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -36,26 +81,34 @@ public sealed class UsersController(
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var result = new List<object>(); var userIds = users.Select(x => x.Id).ToArray();
foreach (var user in users) var roleRows = await (
{ from userRole in db.UserRoles.AsNoTracking()
var identityUser = await userManager.FindByIdAsync(user.Id.ToString()); join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
result.Add(new where userIds.Contains(userRole.UserId)
{ select new { userRole.UserId, RoleName = role.Name! })
user.Id, .ToListAsync(cancellationToken);
user.UserName, var rolesByUser = roleRows
user.DisplayName, .GroupBy(x => x.UserId)
user.StaffNumber, .ToDictionary(
user.CollegeId, group => group.Key,
user.IsEnabled, group => (IReadOnlyCollection<string>)group
user.LastLoginAt, .Select(x => x.RoleName)
user.CreatedAt, .OrderBy(x => x)
Roles = identityUser is null .ToArray());
? []
: await userManager.GetRolesAsync(identityUser) var items = users.Select(user => new UserListItem(
}); user.Id,
} user.UserName ?? string.Empty,
return Ok(result); user.DisplayName,
user.StaffNumber,
user.CollegeId,
user.IsEnabled,
user.LastLoginAt,
user.CreatedAt,
rolesByUser.GetValueOrDefault(user.Id, Array.Empty<string>())))
.ToArray();
return Ok(new PagedResult<UserListItem>(items, total, page, pageSize));
} }
[HttpGet("roles")] [HttpGet("roles")]
@@ -281,6 +334,16 @@ public sealed record CreateUserRequest(
[MinLength(1)] string[] Roles); [MinLength(1)] string[] Roles);
public sealed record SetUserStatusRequest(bool IsEnabled); public sealed record SetUserStatusRequest(bool IsEnabled);
public sealed record UserListItem(
Guid Id,
string UserName,
string DisplayName,
string? StaffNumber,
Guid? CollegeId,
bool IsEnabled,
DateTime? LastLoginAt,
DateTime CreatedAt,
IReadOnlyCollection<string> Roles);
public sealed record SetRolesRequest( public sealed record SetRolesRequest(
[MaxLength(30)] string? StaffNumber, [MaxLength(30)] string? StaffNumber,
Guid? CollegeId, Guid? CollegeId,
@@ -0,0 +1,61 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/venue-displays")]
public sealed class VenueDisplaysController(
AppDbContext db,
TimetableDataService timetableDataService,
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
{
[HttpGet("classrooms/{classroomId:guid}")]
public async Task<ActionResult> Classroom(Guid classroomId, CancellationToken cancellationToken)
{
var term = await CurrentTermAsync(cancellationToken);
var classroom = await db.Classrooms.AsNoTracking()
.Where(x => x.Id == classroomId && x.IsEnabled && x.Building!.IsEnabled)
.Select(x => new { x.Id, x.Name, x.Code, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name })
.FirstOrDefaultAsync(cancellationToken);
if (classroom is null) return NotFound();
if (term is null) return Ok(new { Classroom = classroom, Today = DateOnly.FromDateTime(DateTime.Now), Entries = Array.Empty<object>() });
var sheet = await timetableDataService.BuildAsync(TimetableResourceType.Classroom, classroomId,
term.Id, null, false, null, null, cancellationToken);
var today = DateOnly.FromDateTime(DateTime.Now);
var (week, day) = ClassroomReservationAvailabilityService.ResolveTeachingWeek(term, today);
var entries = sheet?.Entries.Where(x => x.DayOfWeek == day && x.StartWeek <= week && x.EndWeek >= week &&
(x.WeekPattern == WeekPattern.All || x.WeekPattern == WeekPattern.Odd && week % 2 == 1 || x.WeekPattern == WeekPattern.Even && week % 2 == 0))
.Select(x => new { x.CourseName, x.TeacherNames, x.ClassNames, x.StartPeriod, x.PeriodCount }) ?? [];
return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries });
}
[HttpGet("buildings/{buildingId:guid}")]
public async Task<ActionResult> Building(Guid buildingId, CancellationToken cancellationToken)
{
var term = await CurrentTermAsync(cancellationToken);
var building = await db.Buildings.AsNoTracking().Where(x => x.Id == buildingId && x.IsEnabled)
.Select(x => new { x.Id, x.Name, x.Code, CampusName = x.Campus!.Name }).FirstOrDefaultAsync(cancellationToken);
if (building is null) return NotFound();
if (term is null) return Ok(new { Building = building, Today = DateOnly.FromDateTime(DateTime.Now), Slots = Array.Empty<object>(), Rooms = Array.Empty<object>() });
var today = DateOnly.FromDateTime(DateTime.Now);
var slots = await db.ScheduleTimeSlots.AsNoTracking().Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
.OrderBy(x => x.PeriodNumber).Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }).ToListAsync(cancellationToken);
var rooms = await db.Classrooms.AsNoTracking().Where(x => x.BuildingId == buildingId && x.IsEnabled)
.OrderBy(x => x.Code).Select(x => new { x.Id, x.Code, x.Name, x.Capacity }).ToListAsync(cancellationToken);
var occupied = new Dictionary<int, HashSet<Guid>>();
foreach (var slot in slots)
occupied[slot.PeriodNumber] = await availabilityService.GetOccupiedClassroomIdsAsync(term, today, slot.PeriodNumber, 1, null, cancellationToken);
return Ok(new { Building = building, Term = term.Name, Today = today, Slots = slots, Rooms = rooms.Select(room => new { room.Code, room.Name, room.Capacity, FreePeriods = slots.Where(slot => !occupied[slot.PeriodNumber].Contains(room.Id)).Select(slot => slot.PeriodNumber) }) });
}
private Task<AcademicTerm?> CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking()
.Where(x => x.IsEnabled && x.IsCurrent).FirstOrDefaultAsync(cancellationToken);
}
+108 -53
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -100,17 +101,23 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
db.WarningRecords.AddRange(generated); db.WarningRecords.AddRange(generated);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var studentsById = studentInfos.ToDictionary(student => student.Id);
var rulesByType = rules.ToDictionary(rule => rule.Type);
var classIds = studentInfos.Select(student => student.ClassId).Distinct().ToArray();
var counselorsByClass = await db.AdministrativeClasses.AsNoTracking()
.WhereIn(classIds, item => item.Id)
.Where(item => item.CounselorUserId != null)
.Select(item => new { item.Id, CounselorUserId = item.CounselorUserId!.Value })
.ToDictionaryAsync(item => item.Id, item => item.CounselorUserId, ct);
foreach (var w in generated) foreach (var w in generated)
{ {
var si = studentInfos.First(s => s.Id == w.StudentId); var si = studentsById[w.StudentId];
var r = rules.First(r => r.Type == w.Type); var r = rulesByType[w.Type];
if (r.NotifyStudent && si.UserId.HasValue) if (r.NotifyStudent && si.UserId.HasValue)
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning); await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
if (r.NotifyCounselor) if (r.NotifyCounselor && counselorsByClass.TryGetValue(si.ClassId, out var counselorId))
{ {
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct); await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
if (counselorId != default)
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
} }
} }
} }
@@ -120,8 +127,16 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
[HttpGet("records")] [HttpGet("records")]
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)] [Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
public async Task<ActionResult> GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct) public async Task<ActionResult<PagedResult<WarningRecordListItem>>> GetRecords(
Guid? academicTermId,
WarningType? type,
int page = 1,
int pageSize = 20,
CancellationToken ct = default)
{ {
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var q = db.WarningRecords.AsNoTracking().AsQueryable(); var q = db.WarningRecords.AsNoTracking().AsQueryable();
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor)) if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
{ {
@@ -133,7 +148,28 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
} }
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId); if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
if (type.HasValue) q = q.Where(x => x.Type == type); if (type.HasValue) q = q.Where(x => x.Type == type);
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new WarningRecordListItem(
x.Id,
x.StudentId,
x.Student!.Name,
x.Student.StudentNumber,
x.Student.AdministrativeClass!.Name,
(int)x.Type,
(int)x.Status,
x.TriggerValue,
x.Detail,
x.AcknowledgedAt,
x.AcknowledgeComment,
x.CreatedAt))
.ToListAsync(ct);
return Ok(new PagedResult<WarningRecordListItem>(items, total, page, pageSize));
} }
// ═══════════ Student ═══════════ // ═══════════ Student ═══════════
@@ -167,71 +203,90 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
// ═══════════ Detection logic ═══════════ // ═══════════ Detection logic ═══════════
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct) private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{ {
var records = new List<WarningRecord>(); var studentIds = students.Select(student => student.Id).ToArray();
foreach (var s in students) var existingStudentIds = await db.WarningRecords.AsNoTracking()
{ .Where(record => record.AcademicTermId == termId && record.Type == rule.Type)
var failed = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct); .Select(record => record.StudentId)
if (failed >= rule.Threshold) .ToHashSetAsync(ct);
var failedByStudent = await db.GradeRecords.AsNoTracking()
.Where(record => studentIds.Contains(record.StudentId) &&
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
record.GradeSheet.Status == GradeSheetStatus.Published &&
record.TotalScore < 60)
.GroupBy(record => record.StudentId)
.Select(group => new { StudentId = group.Key, Failed = group.Count() })
.ToListAsync(ct);
return failedByStudent
.Where(item => item.Failed >= rule.Threshold &&
!existingStudentIds.Contains(item.StudentId))
.Select(item => new WarningRecord
{ {
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.FailedCredits, ct)) continue; StudentId = item.StudentId,
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }); Type = rule.Type,
} TriggerValue = item.Failed,
} Detail = $"不及格课程 {item.Failed} 门,达到预警阈值 {rule.Threshold} 门。",
return records; AcademicTermId = termId
})
.ToList();
} }
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct) private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{ {
var records = new List<WarningRecord>(); var studentIds = students.Select(student => student.Id).ToArray();
foreach (var s in students) var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
{ var gpaByStudent = await db.GradeRecords.AsNoTracking()
var grades = await db.GradeRecords.Where(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct); .Where(record => studentIds.Contains(record.StudentId) &&
if (grades.Count == 0) continue; record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
var gpa = grades.Average(x => x.GradePoint!.Value); record.GradeSheet.Status == GradeSheetStatus.Published &&
if (gpa < rule.Threshold) record.GradePoint != null)
{ .GroupBy(record => record.StudentId)
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue; .Select(group => new { StudentId = group.Key, Gpa = group.Average(record => record.GradePoint!.Value) })
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId }); .ToListAsync(ct);
} return gpaByStudent.Where(item => item.Gpa < rule.Threshold && !existingStudentIds.Contains(item.StudentId))
} .Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = Math.Round(item.Gpa, 2), Detail = $"平均绩点 {item.Gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId }).ToList();
return records;
} }
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct) private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{ {
var records = new List<WarningRecord>(); var studentIds = students.Select(student => student.Id).ToArray();
foreach (var s in students) var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
{ var absencesByStudent = await db.AttendanceRecords.AsNoTracking()
var absent = await db.AttendanceRecords.CountAsync(x => x.StudentId == s.Id && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct); .Where(record => studentIds.Contains(record.StudentId) && record.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && record.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (record.Status == AttendanceStatus.Absent || record.Status == AttendanceStatus.Late))
if (absent >= rule.Threshold) .GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Absent = group.Count() }).ToListAsync(ct);
{ return absencesByStudent.Where(item => item.Absent >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue; .Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Absent, Detail = $"缺勤/迟到 {item.Absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }).ToList();
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId });
}
}
return records;
} }
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct) private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
{ {
var records = new List<WarningRecord>(); var studentIds = students.Select(student => student.Id).ToArray();
foreach (var s in students) var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
{ var failedByStudent = await db.GradeRecords.AsNoTracking().Where(record => studentIds.Contains(record.StudentId) && record.GradeSheet!.Status == GradeSheetStatus.Published && record.TotalScore < 60).GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Failed = group.Count() }).ToListAsync(ct);
var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct); return failedByStudent.Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
if (total >= rule.Threshold) .Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"累计不及格 {item.Failed} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }).ToList();
{
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.GraduationDelay, ct)) continue;
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = total, Detail = $"累计不及格 {total} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
}
}
return records;
} }
private Task<HashSet<Guid>> ExistingWarningStudentIdsAsync(WarningType type, Guid termId, CancellationToken ct) =>
db.WarningRecords.AsNoTracking().Where(record => record.AcademicTermId == termId && record.Type == type).Select(record => record.StudentId).ToHashSetAsync(ct);
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct); private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 }); private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 });
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 }); private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 });
} }
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId); public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
public sealed record WarningRecordListItem(
Guid Id,
Guid StudentId,
string StudentName,
string StudentNumber,
string ClassName,
int Type,
int Status,
decimal TriggerValue,
string Detail,
DateTime? AcknowledgedAt,
string? AcknowledgeComment,
DateTime CreatedAt);
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute); public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
public sealed record AckBody([MaxLength(300)] string? Comment); public sealed record AckBody([MaxLength(300)] string? Comment);
@@ -18,6 +18,9 @@ public sealed class ExperimentProject : EntityBase
public string? Requirements { get; set; } public string? Requirements { get; set; }
public DateOnly StartDate { get; set; } public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; } public DateOnly EndDate { get; set; }
// 自主预约项目可单独控制学生可选场次的时间窗口(UTC 时刻)。
public DateTime? SelectionStartsAt { get; set; }
public DateTime? SelectionEndsAt { get; set; }
public ExperimentProjectStatus Status { get; set; } = public ExperimentProjectStatus Status { get; set; } =
ExperimentProjectStatus.Draft; ExperimentProjectStatus.Draft;
public DateTime? PublishedAt { get; set; } public DateTime? PublishedAt { get; set; }
@@ -43,6 +46,15 @@ public sealed class ExperimentSession : EntityBase
ExperimentSessionStatus.Scheduled; ExperimentSessionStatus.Scheduled;
public DateTime? CancelledAt { get; set; } public DateTime? CancelledAt { get; set; }
public ICollection<ExperimentBooking> Bookings { get; set; } = []; public ICollection<ExperimentBooking> Bookings { get; set; } = [];
public ICollection<ExperimentSessionInstructor> Instructors { get; set; } = [];
}
public sealed class ExperimentSessionInstructor : EntityBase
{
public Guid ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
} }
public sealed class ExperimentBooking : EntityBase public sealed class ExperimentBooking : EntityBase
@@ -0,0 +1,122 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Jiaowu.Api.Infrastructure.Analytics;
public sealed class ClickHouseAnalyticsClient(
HttpClient httpClient,
ClickHouseAnalyticsOptions options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public bool IsEnabled => options.Enabled;
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
await ExecuteAsync($"CREATE DATABASE IF NOT EXISTS {options.Database}", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.auditEvents
(
id UUID,
occurredAt DateTime64(3, 'UTC'),
userId Nullable(UUID),
method LowCardinality(String),
path String,
statusCode UInt16,
ipAddress Nullable(String),
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(occurredAt)
ORDER BY (id)
""", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.attendanceRecords
(
attendanceSheetId UUID,
studentId UUID,
attendanceDate Date,
teachingTaskId UUID,
academicTermId UUID,
collegeId UUID,
status UInt8,
checkInAt Nullable(DateTime64(3, 'UTC')),
checkedInMethod Nullable(UInt8),
appealStatus UInt8,
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(attendanceDate)
ORDER BY (attendanceSheetId, studentId)
""", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.gradeStatistics
(
gradeSheetId UUID,
teachingTaskId UUID,
courseId UUID,
academicTermId UUID,
collegeId UUID,
academicTermName LowCardinality(String),
studentCount UInt32,
passedCount UInt32,
excellentCount UInt32,
averageScore Decimal(8, 2),
passRate Decimal(8, 4),
excellentRate Decimal(8, 4),
calculatedAt DateTime64(3, 'UTC'),
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(calculatedAt)
ORDER BY (gradeSheetId)
""", cancellationToken);
}
public async Task InsertAsync<T>(string table, IReadOnlyCollection<T> rows, CancellationToken cancellationToken)
{
if (rows.Count == 0) return;
var payload = JsonSerializer.Serialize(rows, JsonOptions);
await ExecuteAsync(
$"INSERT INTO {options.Database}.{table} FORMAT JSONEachRow\n{ToJsonLines(payload)}",
cancellationToken);
}
public async Task<JsonElement[]> QueryAsync(string sql, CancellationToken cancellationToken)
{
using var response = await SendAsync(sql + " FORMAT JSON", cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"ClickHouse 查询失败 ({(int)response.StatusCode}){content}");
using var document = JsonDocument.Parse(content);
return document.RootElement.GetProperty("data")
.EnumerateArray().Select(x => x.Clone()).ToArray();
}
private async Task ExecuteAsync(string sql, CancellationToken cancellationToken)
{
using var response = await SendAsync(sql, cancellationToken);
if (response.IsSuccessStatusCode) return;
var content = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HttpRequestException($"ClickHouse 写入失败 ({(int)response.StatusCode}){content}");
}
private Task<HttpResponseMessage> SendAsync(string sql, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Post, "")
{
Content = new StringContent(sql, Encoding.UTF8, "text/plain")
};
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(
$"{options.UserName}:{options.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
return httpClient.SendAsync(request, cancellationToken);
}
private static string ToJsonLines(string json)
{
using var document = JsonDocument.Parse(json);
return string.Join('\n', document.RootElement.EnumerateArray().Select(x => x.GetRawText()));
}
}
@@ -0,0 +1,24 @@
using System.Text.RegularExpressions;
namespace Jiaowu.Api.Infrastructure.Analytics;
public sealed partial class ClickHouseAnalyticsOptions
{
public const string SectionName = "ClickHouseAnalytics";
public bool Enabled { get; set; }
public string Endpoint { get; set; } = "http://localhost:8123";
public string Database { get; set; } = "jiaowu_analytics";
public string UserName { get; set; } = "jiaowu_analytics";
public string Password { get; set; } = "";
public bool CreateSchemaOnStartup { get; set; } = true;
public int SyncIntervalSeconds { get; set; } = 60;
public int SourceLookbackDays { get; set; } = 90;
public int BatchSize { get; set; } = 1000;
[GeneratedRegex("^[a-zA-Z_][a-zA-Z0-9_]{0,62}$")]
private static partial Regex IdentifierPattern();
public bool HasValidIdentifiers() =>
IdentifierPattern().IsMatch(Database);
}
@@ -0,0 +1,123 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Analytics;
/// <summary>
/// Projects MySQL facts to ClickHouse. The projection is deliberately
/// best-effort: business writes never depend on an analytics database.
/// ReplacingMergeTree plus FINAL reads make repeated lookback batches safe.
/// </summary>
public sealed class ClickHouseAnalyticsProjectionWorker(
IServiceScopeFactory scopeFactory,
ClickHouseAnalyticsClient client,
ClickHouseAnalyticsOptions options,
ILogger<ClickHouseAnalyticsProjectionWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Enabled) return;
try
{
if (options.CreateSchemaOnStartup)
await client.EnsureSchemaAsync(stoppingToken);
}
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(exception, "ClickHouse 分析表初始化失败,将在下一轮重试。");
}
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(options.SyncIntervalSeconds));
do
{
try
{
await ProjectAsync(stoppingToken);
}
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(exception, "ClickHouse 分析投影失败,将在下一轮重试。");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task ProjectAsync(CancellationToken cancellationToken)
{
var projectedAt = DateTime.UtcNow;
var from = projectedAt.AddDays(-options.SourceLookbackDays);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var auditCount = await ProjectAuditsAsync(db, from, projectedAt, cancellationToken);
var attendanceCount = await ProjectAttendanceAsync(db, from.Date, projectedAt, cancellationToken);
var gradeCount = await ProjectGradesAsync(db, projectedAt, cancellationToken);
logger.LogInformation(
"ClickHouse 分析投影完成:审计 {AuditCount},考勤 {AttendanceCount},成绩 {GradeCount}。",
auditCount, attendanceCount, gradeCount);
}
private async Task<int> ProjectAuditsAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorAt = from;
var cursorId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.AuditLogs.AsNoTracking()
.Where(x => x.CreatedAt > cursorAt || x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) > 0)
.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(options.BatchSize)
.Select(x => new { x.Id, OccurredAt = x.CreatedAt, x.UserId, x.Method, x.Path, x.StatusCode, x.IpAddress, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("auditEvents", rows, cancellationToken);
count += rows.Count;
cursorAt = rows[^1].OccurredAt;
cursorId = rows[^1].Id;
}
}
private async Task<int> ProjectAttendanceAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorDate = from;
var cursorSheetId = Guid.Empty;
var cursorStudentId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.AttendanceRecords.AsNoTracking()
.Where(x => x.AttendanceSheet!.AttendanceDate > cursorDate ||
x.AttendanceSheet.AttendanceDate == cursorDate &&
(x.AttendanceSheetId.CompareTo(cursorSheetId) > 0 ||
x.AttendanceSheetId == cursorSheetId && x.StudentId.CompareTo(cursorStudentId) > 0))
.OrderBy(x => x.AttendanceSheet!.AttendanceDate).ThenBy(x => x.AttendanceSheetId).ThenBy(x => x.StudentId).Take(options.BatchSize)
.Select(x => new { x.AttendanceSheetId, x.StudentId, AttendanceDate = x.AttendanceSheet!.AttendanceDate, TeachingTaskId = x.AttendanceSheet.TeachingTaskId, AcademicTermId = x.AttendanceSheet.TeachingTask!.AcademicTermId, CollegeId = x.AttendanceSheet.TeachingTask.Course!.CollegeId, x.Status, x.CheckInAt, x.CheckedInMethod, x.AppealStatus, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("attendanceRecords", rows, cancellationToken);
count += rows.Count;
cursorDate = rows[^1].AttendanceDate;
cursorSheetId = rows[^1].AttendanceSheetId;
cursorStudentId = rows[^1].StudentId;
}
}
private async Task<int> ProjectGradesAsync(AppDbContext db, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorAt = DateTime.MinValue;
var cursorSheetId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CalculatedAt > cursorAt || x.CalculatedAt == cursorAt && x.GradeSheetId.CompareTo(cursorSheetId) > 0)
.OrderBy(x => x.CalculatedAt).ThenBy(x => x.GradeSheetId).Take(options.BatchSize)
.Select(x => new { x.GradeSheetId, x.TeachingTaskId, x.CourseId, x.AcademicTermId, CollegeId = x.TeachingTask!.Course!.CollegeId, AcademicTermName = x.TeachingTask.AcademicTerm!.Name, x.StudentCount, x.PassedCount, x.ExcellentCount, x.AverageScore, x.PassRate, x.ExcellentRate, x.CalculatedAt, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("gradeStatistics", rows, cancellationToken);
count += rows.Count;
cursorAt = rows[^1].CalculatedAt;
cursorSheetId = rows[^1].GradeSheetId;
}
}
}
@@ -263,6 +263,7 @@ public sealed class ExamPublishJobProcessor(
var projects = await db.ExperimentProjects var projects = await db.ExperimentProjects
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.Instructors)
.Include(x => x.TeachingTask) .Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course) .ThenInclude(x => x!.Course)
.Where(x => ids.Contains(x.Id)) .Where(x => ids.Contains(x.Id))
@@ -282,6 +283,14 @@ public sealed class ExamPublishJobProcessor(
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled && if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate))) (x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。"); throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
(project.SelectionStartsAt is null || project.SelectionEndsAt is null ||
project.SelectionStartsAt >= project.SelectionEndsAt))
throw new ExamPublishValidationException($"“{project.Name}”未设置有效的选课时间范围。");
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
x.Instructors.Count == 0))
throw new ExamPublishValidationException($"“{project.Name}”存在未指定指导老师的实验场次。");
} }
job.CurrentStep = "正在发布实验项目"; job.CurrentStep = "正在发布实验项目";
@@ -0,0 +1,49 @@
using System.Diagnostics;
using Jiaowu.Api.Infrastructure.Observability;
namespace Jiaowu.Api.Infrastructure.Middleware;
public sealed class SlowRequestLoggingMiddleware(
RequestDelegate next,
ObservabilityOptions options,
ILogger<SlowRequestLoggingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
if (!options.Enabled ||
!context.Request.Path.StartsWithSegments("/api"))
{
await next(context);
return;
}
var startedAt = Stopwatch.GetTimestamp();
await next(context);
var durationMilliseconds = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
var endpoint = context.GetEndpoint()?.DisplayName ?? context.Request.Path.Value ?? "/api";
if (durationMilliseconds >= options.SlowRequestThresholdMilliseconds ||
context.Response.StatusCode >= StatusCodes.Status500InternalServerError)
{
logger.LogWarning(
"Slow API request completed: {Method} {Path} ({Endpoint}) returned {StatusCode} in {DurationMs:F1} ms, request {RequestId}.",
context.Request.Method,
context.Request.Path.Value,
endpoint,
context.Response.StatusCode,
durationMilliseconds,
context.TraceIdentifier);
}
else if (options.LogAllApiRequests)
{
logger.LogInformation(
"API request completed: {Method} {Path} ({Endpoint}) returned {StatusCode} in {DurationMs:F1} ms, request {RequestId}.",
context.Request.Method,
context.Request.Path.Value,
endpoint,
context.Response.StatusCode,
durationMilliseconds,
context.TraceIdentifier);
}
}
}
@@ -1,6 +1,4 @@
using System.Data.Common; using System.Data.Common;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
@@ -9,31 +7,10 @@ namespace Jiaowu.Api.Infrastructure.Observability;
public sealed class DatabaseCommandTelemetryInterceptor( public sealed class DatabaseCommandTelemetryInterceptor(
ObservabilityOptions options, ObservabilityOptions options,
IHttpContextAccessor httpContextAccessor,
ILogger<DatabaseCommandTelemetryInterceptor> logger) ILogger<DatabaseCommandTelemetryInterceptor> logger)
: DbCommandInterceptor : DbCommandInterceptor
{ {
public const string ActivitySourceName = "Jiaowu.Api.Database";
public const string MeterName = "Jiaowu.Api.Database";
private static readonly ActivitySource ActivitySource =
new(ActivitySourceName);
private static readonly Meter Meter = new(MeterName);
private static readonly Histogram<double> CommandDuration =
Meter.CreateHistogram<double>(
"jiaowu.db.command.duration",
"ms",
"EF Core database command duration");
private static readonly Counter<long> SlowCommandCount =
Meter.CreateCounter<long>(
"jiaowu.db.command.slow",
"{command}",
"EF Core commands exceeding the configured slow-query threshold");
private static readonly Counter<long> FailedCommandCount =
Meter.CreateCounter<long>(
"jiaowu.db.command.failed",
"{command}",
"Failed EF Core database commands");
public override DbDataReader ReaderExecuted( public override DbDataReader ReaderExecuted(
DbCommand command, DbCommand command,
CommandExecutedEventData eventData, CommandExecutedEventData eventData,
@@ -138,82 +115,41 @@ public sealed class DatabaseCommandTelemetryInterceptor(
var queryName = GetQueryName(command.CommandText); var queryName = GetQueryName(command.CommandText);
var statementHash = GetStatementHash(command.CommandText); var statementHash = GetStatementHash(command.CommandText);
var provider = GetProviderName(command); var provider = GetProviderName(command);
var traceId = Activity.Current?.TraceId.ToString() ?? "none"; var requestId = httpContextAccessor.HttpContext?.TraceIdentifier ?? "background";
var tags = new TagList
{
{ "db.system.name", provider },
{ "db.operation.name", commandKind },
{ "db.query.name", queryName }
};
if (errorType is not null)
tags.Add("error.type", errorType);
var durationMilliseconds = duration.TotalMilliseconds; var durationMilliseconds = duration.TotalMilliseconds;
CommandDuration.Record(durationMilliseconds, tags);
if (errorType is not null)
FailedCommandCount.Add(1, tags);
using var activity = ActivitySource.StartActivity(
ActivityKind.Client,
Activity.Current?.Context ?? default,
startTime: DateTimeOffset.UtcNow - duration,
name: queryName);
if (activity is not null)
{
activity.SetTag("db.system.name", provider);
activity.SetTag("db.operation.name", commandKind);
activity.SetTag("db.query.name", queryName);
activity.SetTag("db.statement.hash", statementHash);
activity.SetTag(
"db.namespace",
EmptyToNull(command.Connection?.Database));
if (options.IncludeSqlText)
{
activity.SetTag(
"db.query.text",
Truncate(command.CommandText, options.MaximumSqlTextLength));
}
if (errorType is not null)
{
activity.SetTag("error.type", errorType);
activity.SetStatus(ActivityStatusCode.Error, errorType);
}
activity.SetEndTime(DateTime.UtcNow);
}
if (errorType is not null) if (errorType is not null)
{ {
logger.LogError( logger.LogError(
"Database command failed after {DurationMs:F1} ms: " + "Database command failed after {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " + "{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"error {ErrorType}, trace {TraceId}).", "error {ErrorType}, request {RequestId}).",
durationMilliseconds, durationMilliseconds,
queryName, queryName,
commandKind, commandKind,
provider, provider,
statementHash, statementHash,
errorType, errorType,
traceId); requestId);
return; return;
} }
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds) if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
return; return;
SlowCommandCount.Add(1, tags);
if (options.IncludeSqlText) if (options.IncludeSqlText)
{ {
logger.LogWarning( logger.LogWarning(
"Slow database command took {DurationMs:F1} ms: " + "Slow database command took {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " + "{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"trace {TraceId}). " + "request {RequestId}). " +
"SQL template: {SqlTemplate}", "SQL template: {SqlTemplate}",
durationMilliseconds, durationMilliseconds,
queryName, queryName,
commandKind, commandKind,
provider, provider,
statementHash, statementHash,
traceId, requestId,
Truncate(command.CommandText, options.MaximumSqlTextLength)); Truncate(command.CommandText, options.MaximumSqlTextLength));
} }
else else
@@ -221,13 +157,13 @@ public sealed class DatabaseCommandTelemetryInterceptor(
logger.LogWarning( logger.LogWarning(
"Slow database command took {DurationMs:F1} ms: " + "Slow database command took {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " + "{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"trace {TraceId}).", "request {RequestId}).",
durationMilliseconds, durationMilliseconds,
queryName, queryName,
commandKind, commandKind,
provider, provider,
statementHash, statementHash,
traceId); requestId);
} }
} }
@@ -271,9 +207,6 @@ public sealed class DatabaseCommandTelemetryInterceptor(
return "other_sql"; return "other_sql";
} }
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
private static string Truncate(string value, int maximumLength) => private static string Truncate(string value, int maximumLength) =>
value.Length <= maximumLength value.Length <= maximumLength
? value ? value
@@ -6,6 +6,8 @@ public sealed class ObservabilityOptions
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
public string ServiceName { get; set; } = "jiaowu-api"; public string ServiceName { get; set; } = "jiaowu-api";
public bool LogAllApiRequests { get; set; } = true;
public int SlowRequestThresholdMilliseconds { get; set; } = 1000;
public int SlowQueryThresholdMilliseconds { get; set; } = 500; public int SlowQueryThresholdMilliseconds { get; set; } = 500;
public bool IncludeSqlText { get; set; } public bool IncludeSqlText { get; set; }
public int MaximumSqlTextLength { get; set; } = 2000; public int MaximumSqlTextLength { get; set; } = 2000;
@@ -51,6 +51,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<ClassroomReservation>(); Set<ClassroomReservation>();
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>(); public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>(); public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
public DbSet<ExperimentSessionInstructor> ExperimentSessionInstructors =>
Set<ExperimentSessionInstructor>();
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>(); public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets => public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
Set<ExperimentGradeSheet>(); Set<ExperimentGradeSheet>();
@@ -166,6 +168,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
configurationBuilder.Properties<TimeOnly>() configurationBuilder.Properties<TimeOnly>()
.HaveConversion<TimeOnlyTimeSpanConverter>() .HaveConversion<TimeOnlyTimeSpanConverter>()
.HaveColumnType("time"); .HaveColumnType("time");
// MySQL DATETIME has no offset or DateTimeKind. All system timestamps
// are persisted as UTC, so restore that contract when materializing
// them. System.Text.Json will then emit the trailing "Z", allowing
// browsers to convert timestamps to the viewer's local time correctly.
configurationBuilder.Properties<DateTime>()
.HaveConversion<UtcDateTimeConverter>()
.HaveColumnType("datetime(6)");
} }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@@ -682,6 +692,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Description).HasMaxLength(1000); entity.Property(x => x.Description).HasMaxLength(1000);
entity.Property(x => x.Requirements).HasMaxLength(1000); entity.Property(x => x.Requirements).HasMaxLength(1000);
entity.Property(x => x.SelectionStartsAt).HasConversion<UtcDateTimeConverter>();
entity.Property(x => x.SelectionEndsAt).HasConversion<UtcDateTimeConverter>();
// 集中安排会为同一教学任务的每一条实验课表记录生成项目; // 集中安排会为同一教学任务的每一条实验课表记录生成项目;
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。 // 自行安排仍由控制器保持“教学任务 + 编码”唯一。
entity.HasIndex(x => new entity.HasIndex(x => new
@@ -797,6 +809,19 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExperimentSessionInstructor>(entity =>
{
entity.HasIndex(x => new { x.ExperimentSessionId, x.TeacherId })
.IsUnique();
entity.HasIndex(x => x.TeacherId);
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Instructors)
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentCourseGrade>(entity => builder.Entity<ExperimentCourseGrade>(entity =>
{ {
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1); entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
@@ -1086,6 +1111,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
}); });
builder.Entity<ExamSession>(entity => builder.Entity<ExamSession>(entity =>
{ {
// Exam slot times are China-local wall-clock times, not instants.
// Keep their existing API representation offset-free.
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }); entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
@@ -1112,6 +1141,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ExamRoomAssignment>(entity => builder.Entity<ExamRoomAssignment>(entity =>
{ {
entity.ToTable("ExamRooms"); entity.ToTable("ExamRooms");
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }) entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
.HasDatabaseName("IX_ExamRooms_Plan_Time"); .HasDatabaseName("IX_ExamRooms_Plan_Time");
entity.HasIndex(x => new entity.HasIndex(x => new
@@ -1200,6 +1231,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
}); });
builder.Entity<MakeupExamSession>(entity => builder.Entity<MakeupExamSession>(entity =>
{ {
// Makeup-exam slot times follow the same wall-clock convention.
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt }); entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
@@ -1475,8 +1509,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Title).HasMaxLength(200); entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasColumnType("longtext"); entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.LinkUrl).HasMaxLength(300); entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead }); // Each inbox query starts with its recipient. Keep the selected
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt }); // sort fields in the index so large inboxes do not need a filesort.
entity.HasIndex(x => new { x.UserId, x.CreatedAt, x.Id });
entity.HasIndex(x => new { x.UserId, x.IsRead, x.CreatedAt, x.Id });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt, x.Id });
entity.HasIndex(x => x.MessageDispatchId); entity.HasIndex(x => x.MessageDispatchId);
entity.HasIndex(x => x.CreatedAt); entity.HasIndex(x => x.CreatedAt);
entity.HasOne(x => x.MessageDispatch) entity.HasOne(x => x.MessageDispatch)
@@ -1643,3 +1680,15 @@ public sealed class TimeOnlyTimeSpanConverter()
: ValueConverter<TimeOnly, TimeSpan>( : ValueConverter<TimeOnly, TimeSpan>(
time => time.ToTimeSpan(), time => time.ToTimeSpan(),
value => TimeOnly.FromTimeSpan(value)); value => TimeOnly.FromTimeSpan(value));
public sealed class UtcDateTimeConverter()
: ValueConverter<DateTime, DateTime>(
value => value.Kind == DateTimeKind.Local
? value.ToUniversalTime()
: DateTime.SpecifyKind(value, DateTimeKind.Utc),
value => DateTime.SpecifyKind(value, DateTimeKind.Utc));
public sealed class UnspecifiedDateTimeConverter()
: ValueConverter<DateTime, DateTime>(
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified),
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified));
@@ -102,6 +102,10 @@ public sealed class DevelopmentSqliteMigrator(
"20260809_53_course_grade_statistics_refresh_settings"; "20260809_53_course_grade_statistics_refresh_settings";
private const string ExperimentCourseGradesMigration = private const string ExperimentCourseGradesMigration =
"20260809_54_experiment_course_grades"; "20260809_54_experiment_course_grades";
private const string NotificationInboxIndexesMigration =
"20260810_55_notification_inbox_indexes";
private const string SelfScheduledExperimentSelectionMigration =
"20260811_56_self_scheduled_experiment_selection";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -731,6 +735,29 @@ public sealed class DevelopmentSqliteMigrator(
ExperimentCourseGradesMigration, ExperimentCourseGradesMigration,
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements, experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
cancellationToken); cancellationToken);
await ApplyMigrationAsync(
NotificationInboxIndexesMigration,
NotificationInboxIndexesStatements,
cancellationToken);
var experimentProjectColumns = (await db.Database.SqlQueryRaw<string>(
"SELECT name AS \"Value\" FROM pragma_table_info('ExperimentProjects')")
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var experimentSessionInstructorsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExperimentSessionInstructors'")
.AnyAsync(value => value > 0, cancellationToken);
var selfScheduledExperimentSelectionStatements = new List<string>();
if (!experimentProjectColumns.Contains("SelectionStartsAt"))
selfScheduledExperimentSelectionStatements.Add(SelfScheduledExperimentSelectionStatements[0]);
if (!experimentProjectColumns.Contains("SelectionEndsAt"))
selfScheduledExperimentSelectionStatements.Add(SelfScheduledExperimentSelectionStatements[1]);
if (!experimentSessionInstructorsExist)
selfScheduledExperimentSelectionStatements.AddRange(SelfScheduledExperimentSelectionStatements.Skip(2));
await ApplyMigrationAsync(
SelfScheduledExperimentSelectionMigration,
selfScheduledExperimentSelectionStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -2999,6 +3026,15 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");""" """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
]; ];
private static readonly string[] NotificationInboxIndexesStatements =
[
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_IsRead";""",
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_Category_CreatedAt";""",
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_CreatedAt_Id" ON "Notifications" ("UserId", "CreatedAt", "Id");""",
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_IsRead_CreatedAt_Id" ON "Notifications" ("UserId", "IsRead", "CreatedAt", "Id");""",
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_Category_CreatedAt_Id" ON "Notifications" ("UserId", "Category", "CreatedAt", "Id");"""
];
private static readonly string[] ExperimentCourseGradesStatements = private static readonly string[] ExperimentCourseGradesStatements =
[ [
""" """
@@ -3114,6 +3150,27 @@ public sealed class DevelopmentSqliteMigrator(
""" """
]; ];
private static readonly string[] SelfScheduledExperimentSelectionStatements =
[
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionStartsAt\" TEXT NULL;",
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionEndsAt\" TEXT NULL;",
"""
CREATE TABLE "ExperimentSessionInstructors" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessionInstructors" PRIMARY KEY,
"ExperimentSessionId" TEXT NOT NULL,
"TeacherId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentSessionInstructors_ExperimentSessions_ExperimentSessionId"
FOREIGN KEY ("ExperimentSessionId") REFERENCES "ExperimentSessions" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentSessionInstructors_Teachers_TeacherId"
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT
);
""",
"CREATE UNIQUE INDEX \"IX_ExperimentSessionInstructors_ExperimentSessionId_TeacherId\" ON \"ExperimentSessionInstructors\" (\"ExperimentSessionId\", \"TeacherId\");",
"CREATE INDEX \"IX_ExperimentSessionInstructors_TeacherId\" ON \"ExperimentSessionInstructors\" (\"TeacherId\");"
];
private static readonly string[] ReusableCourseGroupsStatements = private static readonly string[] ReusableCourseGroupsStatements =
[ [
""" """
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class OptimizeNotificationInboxIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_IsRead",
table: "Notifications");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_Category_CreatedAt_Id",
table: "Notifications",
columns: new[] { "UserId", "Category", "CreatedAt", "Id" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_CreatedAt_Id",
table: "Notifications",
columns: new[] { "UserId", "CreatedAt", "Id" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_IsRead_CreatedAt_Id",
table: "Notifications",
columns: new[] { "UserId", "IsRead", "CreatedAt", "Id" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_Category_CreatedAt_Id",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_CreatedAt_Id",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_IsRead_CreatedAt_Id",
table: "Notifications");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications",
columns: new[] { "UserId", "Category", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_IsRead",
table: "Notifications",
columns: new[] { "UserId", "IsRead" });
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AddSelfScheduledExperimentSelectionWindowsAndInstructors : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "SelectionEndsAt",
table: "ExperimentProjects",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "SelectionStartsAt",
table: "ExperimentProjects",
type: "datetime(6)",
nullable: true);
migrationBuilder.CreateTable(
name: "ExperimentSessionInstructors",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentSessionInstructors", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentSessionInstructors_ExperimentSessions_ExperimentSe~",
column: x => x.ExperimentSessionId,
principalTable: "ExperimentSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExperimentSessionInstructors_Teachers_TeacherId",
column: x => x.TeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExperimentSessionInstructors_ExperimentSessionId_TeacherId",
table: "ExperimentSessionInstructors",
columns: new[] { "ExperimentSessionId", "TeacherId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExperimentSessionInstructors_TeacherId",
table: "ExperimentSessionInstructors",
column: "TeacherId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExperimentSessionInstructors");
migrationBuilder.DropColumn(
name: "SelectionEndsAt",
table: "ExperimentProjects");
migrationBuilder.DropColumn(
name: "SelectionStartsAt",
table: "ExperimentProjects");
}
}
}
@@ -2520,6 +2520,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int?>("ScheduleWeek") b.Property<int?>("ScheduleWeek")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime?>("SelectionEndsAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("SelectionStartsAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("StartDate") b.Property<DateTime>("StartDate")
.HasColumnType("date"); .HasColumnType("date");
@@ -2596,6 +2602,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("ExperimentSessions"); b.ToTable("ExperimentSessions");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSessionInstructor", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ExperimentSessionId")
.HasColumnType("char(36)");
b.Property<Guid>("TeacherId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("TeacherId");
b.HasIndex("ExperimentSessionId", "TeacherId")
.IsUnique();
b.ToTable("ExperimentSessionInstructors");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -3417,9 +3451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("MessageDispatchId"); b.HasIndex("MessageDispatchId");
b.HasIndex("UserId", "IsRead"); b.HasIndex("UserId", "CreatedAt", "Id");
b.HasIndex("UserId", "Category", "CreatedAt"); b.HasIndex("UserId", "Category", "CreatedAt", "Id");
b.HasIndex("UserId", "IsRead", "CreatedAt", "Id");
b.ToTable("Notifications"); b.ToTable("Notifications");
}); });
@@ -5968,6 +6004,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("ExperimentProject"); b.Navigation("ExperimentProject");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSessionInstructor", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentSession", "ExperimentSession")
.WithMany("Instructors")
.HasForeignKey("ExperimentSessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
.WithMany()
.HasForeignKey("TeacherId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ExperimentSession");
b.Navigation("Teacher");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
@@ -6841,6 +6896,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
{ {
b.Navigation("Bookings"); b.Navigation("Bookings");
b.Navigation("Instructors");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
@@ -7,6 +7,8 @@ namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService
{ {
private static readonly TimeZoneInfo ChinaTimeZone = ResolveChinaTimeZone();
protected override async Task ExecuteAsync(CancellationToken ct) protected override async Task ExecuteAsync(CancellationToken ct)
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
@@ -25,22 +27,32 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
private static async Task RunAsync(AppDbContext db, CancellationToken ct) private static async Task RunAsync(AppDbContext db, CancellationToken ct)
{ {
var now = DateTime.UtcNow; var nowUtc = DateTime.UtcNow;
var dow = (int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek; var chinaNow = TimeZoneInfo.ConvertTime(
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc); new DateTimeOffset(nowUtc),
ChinaTimeZone);
var dow = (int)chinaNow.DayOfWeek == 0 ? 7 : (int)chinaNow.DayOfWeek;
var currentMinuteUtc = new DateTime(
nowUtc.Year,
nowUtc.Month,
nowUtc.Day,
nowUtc.Hour,
nowUtc.Minute,
0,
DateTimeKind.Utc);
var rules = await db.WarningRules var rules = await db.WarningRules
.Where(r => r.IsEnabled && r.AutoCheckEnabled && .Where(r => r.IsEnabled && r.AutoCheckEnabled &&
r.CheckHour == now.Hour && r.CheckMinute == now.Minute && r.CheckHour == chinaNow.Hour && r.CheckMinute == chinaNow.Minute &&
(r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow)) (r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow))
.ToListAsync(ct); .ToListAsync(ct);
foreach (var rule in rules) foreach (var rule in rules)
{ {
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinute) if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinuteUtc)
continue; continue;
rule.LastCheckAt = now; rule.LastCheckAt = nowUtc;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct); var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct);
@@ -100,4 +112,23 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
var gpa = grades.Average(x => x.GradePoint!.Value); var gpa = grades.Average(x => x.GradePoint!.Value);
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null; return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
} }
private static TimeZoneInfo ResolveChinaTimeZone()
{
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (TimeZoneNotFoundException)
{
}
catch (InvalidTimeZoneException)
{
}
}
throw new InvalidOperationException("服务器未提供中国标准时间时区定义。");
}
} }
@@ -10,7 +10,33 @@ public static class TimetableExcelExporter
public static byte[] Create(TimetableData timetable) public static byte[] Create(TimetableData timetable)
{ {
using var workbook = new XLWorkbook(); using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("课表"); CreateWorksheet(workbook, timetable, "课表");
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
public static byte[] Create(IReadOnlyCollection<TimetableData> timetables)
{
if (timetables.Count == 0)
throw new ArgumentException("至少需要一张课表。", nameof(timetables));
using var workbook = new XLWorkbook();
var index = 1;
foreach (var timetable in timetables)
CreateWorksheet(workbook, timetable, WorksheetName(timetable.Subject.Name, index++));
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
private static void CreateWorksheet(
XLWorkbook workbook,
TimetableData timetable,
string worksheetName)
{
var sheet = workbook.Worksheets.Add(worksheetName);
sheet.Style.Font.FontName = "Microsoft YaHei"; sheet.Style.Font.FontName = "Microsoft YaHei";
sheet.Range("A1:H1").Merge(); sheet.Range("A1:H1").Merge();
@@ -153,9 +179,17 @@ public static class TimetableExcelExporter
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper; sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
sheet.PageSetup.FitToPages(1, 0); sheet.PageSetup.FitToPages(1, 0);
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35); sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
using var stream = new MemoryStream(); }
workbook.SaveAs(stream);
return stream.ToArray(); private static string WorksheetName(string subjectName, int index)
{
var suffix = $"-{index}";
var name = string.Concat(subjectName.Select(character =>
"[]:*?/\\".Contains(character) ? '-' : character)).Trim();
if (string.IsNullOrWhiteSpace(name)) name = "课表";
return name.Length > 31 - suffix.Length
? name[..(31 - suffix.Length)] + suffix
: name + suffix;
} }
private static string EntryText(TimetableEntryDto entry) => private static string EntryText(TimetableEntryDto entry) =>
@@ -0,0 +1,219 @@
using SkiaSharp;
namespace Jiaowu.Api.Infrastructure.Timetables;
public static class TimetablePdfExporter
{
private const float PageWidth = 842;
private const float PageHeight = 595;
private const float Margin = 24;
private static readonly SKColor Ink = new(27, 53, 74);
private static readonly SKColor Muted = new(92, 111, 126);
private static readonly SKColor Accent = new(32, 105, 99);
private static readonly SKColor Rule = new(206, 220, 226);
private static readonly SKColor Pale = new(241, 247, 247);
public static byte[] Create(TimetableData timetable) => Create([timetable]);
public static byte[] Create(IReadOnlyCollection<TimetableData> timetables)
{
if (timetables.Count == 0)
throw new ArgumentException("至少需要一张课表。", nameof(timetables));
using var typeface = ResolveTypeface();
using var stream = new MemoryStream();
using var document = SKDocument.CreatePdf(stream);
var page = 0;
foreach (var timetable in timetables)
{
var periods = Periods(timetable);
foreach (var periodPage in periods.Chunk(9))
DrawPage(document, typeface, timetable, periodPage, ++page);
}
document.Close();
return stream.ToArray();
}
private static void DrawPage(
SKDocument document,
SKTypeface typeface,
TimetableData timetable,
IReadOnlyList<int> periods,
int page)
{
using var canvas = document.BeginPage(PageWidth, PageHeight);
canvas.Clear(SKColors.White);
using var frame = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.1f };
canvas.DrawRect(14, 14, PageWidth - 28, PageHeight - 28, frame);
DrawText(canvas, typeface, timetable.Subject.Name, Margin, 53, 19, Ink, bold: true);
DrawText(canvas, typeface, "课程表", PageWidth - Margin, 53, 11, Accent, SKTextAlign.Right, true);
DrawText(canvas, typeface,
$"{timetable.Subject.Code} · {timetable.Term.Name}" +
(timetable.Plan is null ? "" : $" · {timetable.Plan.Version} · {PlanStatus(timetable.Plan.Status)}"),
Margin, 74, 9, Muted);
DrawText(canvas, typeface,
$"导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}",
PageWidth - Margin, 74, 8, Muted, SKTextAlign.Right);
using var titleRule = new SKPaint { Color = Rule, StrokeWidth = .8f };
canvas.DrawLine(Margin, 86, PageWidth - Margin, 86, titleRule);
var widths = new[] { 84f, 101f, 101f, 101f, 101f, 101f, 101f, 101f };
var headers = new[] { "节次 / 时间", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" };
const float headerHeight = 24;
const float rowHeight = 46;
var y = 103f;
using var header = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
using var grid = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
canvas.DrawRect(Margin, y, widths.Sum(), headerHeight, header);
var x = Margin;
for (var column = 0; column < headers.Length; column++)
{
DrawText(canvas, typeface, headers[column], x + widths[column] / 2, y + 16, 8.5f,
SKColors.White, SKTextAlign.Center, true);
x += widths[column];
}
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries).ToArray();
for (var rowIndex = 0; rowIndex < periods.Count; rowIndex++)
{
var period = periods[rowIndex];
var rowY = y + headerHeight + rowIndex * rowHeight;
using var leftFill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
canvas.DrawRect(Margin, rowY, widths[0], rowHeight, leftFill);
var slot = timetable.Slots.FirstOrDefault(item => item.PeriodNumber == period);
DrawText(canvas, typeface, $"第 {period} 节", Margin + widths[0] / 2, rowY + 18, 8.5f,
Ink, SKTextAlign.Center, true);
if (slot is not null)
DrawText(canvas, typeface, $"{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}",
Margin + widths[0] / 2, rowY + 32, 7, Muted, SKTextAlign.Center);
x = Margin + widths[0];
for (var day = 1; day <= 7; day++)
{
var cellEntries = entries.Where(item =>
item.DayOfWeek == day &&
item.StartPeriod <= period &&
item.StartPeriod + item.PeriodCount - 1 >= period).ToArray();
if (cellEntries.Length > 0)
{
using var fill = new SKPaint
{
Color = cellEntries.Any(item => item.IsExam)
? new SKColor(255, 243, 230)
: cellEntries.Any(item => item.IsExperiment)
? new SKColor(232, 246, 241)
: new SKColor(234, 245, 244),
Style = SKPaintStyle.Fill
};
canvas.DrawRect(x, rowY, widths[day], rowHeight, fill);
DrawCellText(canvas, typeface, cellEntries, x, rowY, widths[day], rowHeight);
}
x += widths[day];
}
}
var tableHeight = headerHeight + periods.Count * rowHeight;
canvas.DrawRect(Margin, y, widths.Sum(), tableHeight, grid);
x = Margin;
for (var column = 0; column < widths.Length - 1; column++)
{
x += widths[column];
canvas.DrawLine(x, y, x, y + tableHeight, grid);
}
for (var row = 0; row < periods.Count; row++)
canvas.DrawLine(Margin, y + headerHeight + row * rowHeight, Margin + widths.Sum(),
y + headerHeight + row * rowHeight, grid);
var footerY = PageHeight - 31;
canvas.DrawLine(Margin, footerY - 12, PageWidth - Margin, footerY - 12, titleRule);
DrawText(canvas, typeface, "明序教务 · 课表导出", Margin, footerY, 7.5f, Muted);
DrawText(canvas, typeface, $"第 {page} 页", PageWidth - Margin, footerY, 7.5f, Muted,
SKTextAlign.Right);
document.EndPage();
}
private static void DrawCellText(
SKCanvas canvas,
SKTypeface typeface,
IReadOnlyList<TimetableEntryDto> entries,
float x,
float y,
float width,
float height)
{
var text = string.Join("\n", entries.Select(EntryText));
var lines = WrapText(typeface, text, 6.6f, width - 8).Take(5).ToArray();
for (var index = 0; index < lines.Length; index++)
DrawText(canvas, typeface, lines[index], x + 4, y + 10 + index * 8, 6.6f, Ink);
}
private static IReadOnlyList<int> Periods(TimetableData timetable)
{
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries);
var periods = timetable.Slots.Select(item => item.PeriodNumber)
.Concat(entries.SelectMany(item => Enumerable.Range(item.StartPeriod, item.PeriodCount)))
.Distinct().Order().ToArray();
return periods.Length == 0 ? Enumerable.Range(1, 8).ToArray() : periods;
}
private static string EntryText(TimetableEntryDto entry) =>
$"{(entry.IsExam ? "" : entry.IsExperiment ? "" : "")}{entry.CourseName} · {string.Join('、', entry.TeacherNames)}" +
$" · {string.Join(" ", new[] { entry.BuildingName, entry.ClassroomName }.Where(item => !string.IsNullOrWhiteSpace(item)))}";
private static IReadOnlyList<string> WrapText(SKTypeface typeface, string text, float size, float width)
{
using var font = new SKFont(typeface, size);
var lines = new List<string>();
foreach (var paragraph in text.Split('\n'))
{
var current = "";
foreach (var character in paragraph)
{
var candidate = current + character;
if (current.Length > 0 && font.MeasureText(candidate) > width)
{
lines.Add(current);
current = character.ToString();
}
else current = candidate;
}
if (current.Length > 0) lines.Add(current);
}
return lines;
}
private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y,
float size, SKColor color, SKTextAlign align = SKTextAlign.Left, bool bold = false)
{
using var font = new SKFont(typeface, size) { Embolden = bold };
using var paint = new SKPaint { Color = color, IsAntialias = true };
canvas.DrawText(text, x, y, align, font, paint);
}
private static SKTypeface ResolveTypeface()
{
var candidates = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
};
foreach (var path in candidates)
{
if (File.Exists(path) && SKTypeface.FromFile(path) is { } typeface)
return typeface;
}
return SKTypeface.FromFamilyName("Microsoft YaHei") ??
SKTypeface.FromFamilyName("Noto Sans CJK SC") ??
throw new InvalidOperationException("未找到可用于课表 PDF 的中文字体。");
}
private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch
{
Domain.Academic.SchedulePlanStatus.Draft => "草稿",
Domain.Academic.SchedulePlanStatus.Published => "已发布",
_ => "已归档"
};
}
-5
View File
@@ -47,11 +47,6 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" /> <PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageReference Include="QRCoder" Version="1.8.0" /> <PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.2" /> <PackageReference Include="RabbitMQ.Client" Version="7.2.2" />
<PackageReference Include="SkiaSharp" Version="4.151.1" /> <PackageReference Include="SkiaSharp" Version="4.151.1" />
+57 -34
View File
@@ -1,4 +1,5 @@
using System.Text; using System.Text;
using System.Net;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
@@ -6,6 +7,7 @@ using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Configuration; using Jiaowu.Api.Infrastructure.Configuration;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Analytics;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Middleware;
@@ -19,15 +21,13 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Swashbuckle.AspNetCore.SwaggerUI; using Swashbuckle.AspNetCore.SwaggerUI;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
@@ -101,12 +101,30 @@ var observabilityOptions = builder.Configuration
var performanceReportingOptions = builder.Configuration var performanceReportingOptions = builder.Configuration
.GetSection(PerformanceReportingOptions.SectionName) .GetSection(PerformanceReportingOptions.SectionName)
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions(); .Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
var clickHouseAnalyticsOptions = builder.Configuration
.GetSection(ClickHouseAnalyticsOptions.SectionName)
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
var rabbitMqOptions = builder.Configuration var rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName) .GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions(); .Get<RabbitMqOptions>() ?? new RabbitMqOptions();
var ssoOptions = builder.Configuration var ssoOptions = builder.Configuration
.GetSection(SsoOptions.SectionName) .GetSection(SsoOptions.SectionName)
.Get<SsoOptions>() ?? new SsoOptions(); .Get<SsoOptions>() ?? new SsoOptions();
var trustedProxyAddresses = builder.Configuration
.GetSection("ReverseProxy:TrustedProxies")
.Get<string[]>() ?? [];
var trustedProxies = trustedProxyAddresses
.Select(value =>
{
if (!IPAddress.TryParse(value, out var address))
{
throw new InvalidOperationException(
$"ReverseProxy:TrustedProxies contains an invalid IP address: '{value}'.");
}
return address;
})
.ToArray();
if (ssoOptions.Enabled && if (ssoOptions.Enabled &&
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) || (string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
@@ -158,6 +176,7 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) || if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
observabilityOptions.ServiceName.Length > 100 || observabilityOptions.ServiceName.Length > 100 ||
observabilityOptions.SlowRequestThresholdMilliseconds is < 1 or > 60000 ||
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 || observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000) observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
{ {
@@ -187,15 +206,6 @@ if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。"); "PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
} }
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
parsedOtlpEndpoint.Scheme is not ("http" or "https")))
{
throw new InvalidOperationException(
"OTEL_EXPORTER_OTLP_ENDPOINT 必须是有效的 HTTP 或 HTTPS 绝对地址。");
}
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 || if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 || cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 || cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
@@ -252,6 +262,19 @@ if (backgroundJobOptions.UsesRabbitMq &&
{ {
throw new InvalidOperationException("RabbitMq 连接配置不完整。"); throw new InvalidOperationException("RabbitMq 连接配置不完整。");
} }
if (clickHouseAnalyticsOptions.Enabled &&
(!Uri.TryCreate(clickHouseAnalyticsOptions.Endpoint, UriKind.Absolute, out var clickHouseEndpoint) ||
clickHouseEndpoint.Scheme is not ("http" or "https") ||
!clickHouseAnalyticsOptions.HasValidIdentifiers() ||
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.UserName) ||
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.Password) ||
clickHouseAnalyticsOptions.SyncIntervalSeconds is < 10 or > 86400 ||
clickHouseAnalyticsOptions.SourceLookbackDays is < 1 or > 3650 ||
clickHouseAnalyticsOptions.BatchSize is < 1 or > 10000))
{
throw new InvalidOperationException("ClickHouseAnalytics 配置无效。");
}
if (backgroundJobOptions.UsesRabbitMq && if (backgroundJobOptions.UsesRabbitMq &&
!builder.Environment.IsDevelopment() && !builder.Environment.IsDevelopment() &&
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) || (rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
@@ -282,14 +305,21 @@ builder.Services.AddSingleton(backgroundJobOptions);
builder.Services.AddSingleton(operationsOptions); builder.Services.AddSingleton(operationsOptions);
builder.Services.AddSingleton(observabilityOptions); builder.Services.AddSingleton(observabilityOptions);
builder.Services.AddSingleton(performanceReportingOptions); builder.Services.AddSingleton(performanceReportingOptions);
builder.Services.AddSingleton(clickHouseAnalyticsOptions);
builder.Services.AddSingleton(rabbitMqOptions); builder.Services.AddSingleton(rabbitMqOptions);
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>(); builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<PerformanceReportService>((services, client) => builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
{ {
var reporting = services.GetRequiredService<PerformanceReportingOptions>(); var reporting = services.GetRequiredService<PerformanceReportingOptions>();
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds); client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
}); });
builder.Services.AddHttpClient<ClickHouseAnalyticsClient>((_, client) =>
{
client.BaseAddress = new Uri(clickHouseAnalyticsOptions.Endpoint.TrimEnd('/') + "/");
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.Configure<OfficialDocumentOptions>( builder.Services.Configure<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName)); builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
builder.Services.AddDbContextPool<AppDbContext>((services, options) => builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
@@ -339,28 +369,6 @@ builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
}); });
}); });
if (observabilityOptions.Enabled &&
!string.IsNullOrWhiteSpace(otlpEndpoint))
{
builder.Services
.AddOpenTelemetry()
.ConfigureResource(resource =>
resource.AddService(observabilityOptions.ServiceName))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter(DatabaseCommandTelemetryInterceptor.MeterName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
options.Filter = context =>
!context.Request.Path.StartsWithSegments("/health/live"))
.AddHttpClientInstrumentation()
.AddSource(DatabaseCommandTelemetryInterceptor.ActivitySourceName))
.WithMetrics(metrics => metrics.AddOtlpExporter())
.WithTracing(tracing => tracing.AddOtlpExporter());
}
var redisConnectionString = builder.Configuration.GetConnectionString("Redis"); var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString)) if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
{ {
@@ -441,6 +449,7 @@ builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>(); builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>(); builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
builder.Services.AddHostedService<ClickHouseAnalyticsProjectionWorker>();
builder.Services.AddSingleton<BackgroundJobTelemetry>(); builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>(); builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>(); builder.Services.AddScoped<OperationalHealthService>();
@@ -530,6 +539,18 @@ if (ssoOptions.Enabled)
}); });
} }
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 1;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
foreach (var proxy in trustedProxies)
{
options.KnownProxies.Add(proxy);
}
});
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
@@ -644,6 +665,7 @@ builder.Services.AddSwaggerGen(options =>
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseResponseCompression(); app.UseResponseCompression();
app.Use(async (context, next) => app.Use(async (context, next) =>
@@ -699,6 +721,7 @@ app.UseStaticFiles(new StaticFileOptions
}); });
app.UseCors("Web"); app.UseCors("Web");
app.UseRateLimiter(); app.UseRateLimiter();
app.UseMiddleware<SlowRequestLoggingMiddleware>();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<AuditMiddleware>(); app.UseMiddleware<AuditMiddleware>();
+19
View File
@@ -22,6 +22,8 @@
"Observability": { "Observability": {
"Enabled": true, "Enabled": true,
"ServiceName": "jiaowu-api", "ServiceName": "jiaowu-api",
"LogAllApiRequests": true,
"SlowRequestThresholdMilliseconds": 1000,
"SlowQueryThresholdMilliseconds": 500, "SlowQueryThresholdMilliseconds": 500,
"IncludeSqlText": false, "IncludeSqlText": false,
"MaximumSqlTextLength": 2000 "MaximumSqlTextLength": 2000
@@ -39,6 +41,17 @@
"SlowDatabaseMetric": "jiaowu_db_command_slow_total", "SlowDatabaseMetric": "jiaowu_db_command_slow_total",
"FailedDatabaseMetric": "jiaowu_db_command_failed_total" "FailedDatabaseMetric": "jiaowu_db_command_failed_total"
}, },
"ClickHouseAnalytics": {
"Enabled": false,
"Endpoint": "http://localhost:8123",
"Database": "jiaowu_analytics",
"UserName": "jiaowu_analytics",
"Password": "",
"CreateSchemaOnStartup": true,
"SyncIntervalSeconds": 60,
"SourceLookbackDays": 90,
"BatchSize": 1000
},
"Operations": { "Operations": {
"BackupDirectory": "data/backups", "BackupDirectory": "data/backups",
"BackupWarningHours": 24, "BackupWarningHours": 24,
@@ -94,6 +107,12 @@
"FrontendBaseUrl": "", "FrontendBaseUrl": "",
"CallbackUrl": "" "CallbackUrl": ""
}, },
"ReverseProxy": {
"TrustedProxies": [
"127.0.0.1",
"::1"
]
},
"Cors": { "Cors": {
"Origins": [ "Origins": [
"capacitor://localhost", "capacitor://localhost",
@@ -1,4 +1,5 @@
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -11,6 +12,40 @@ namespace Jiaowu.Api.Tests;
public sealed class ApprovalsControllerTests public sealed class ApprovalsControllerTests
{ {
[Fact]
public async Task Pending_returns_a_single_server_page_with_total()
{
await using var fixture = await ApprovalFixture.CreateAsync();
fixture.Db.AddRange(
new DeferredExam
{
StudentId = fixture.Student.Id,
TeachingTaskId = fixture.CurrentTask.Id,
Reason = "较早提交",
SubmittedAt = DateTime.UtcNow.AddMinutes(-2)
},
new CourseExemption
{
StudentId = fixture.Student.Id,
TeachingTaskId = fixture.CurrentTask.Id,
Reason = "较晚提交",
SubmittedAt = DateTime.UtcNow.AddMinutes(-1)
});
await fixture.Db.SaveChangesAsync();
var controller = new ApprovalsController(
fixture.Db,
new ManagerDataScope());
var result = await controller.GetPending(2, 1, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var page = Assert.IsType<PagedResult<ApprovalItem>>(ok.Value);
Assert.Equal(2, page.Total);
Assert.Equal(2, page.Page);
var item = Assert.Single(page.Items);
Assert.Equal("较早提交", item.Desc);
}
[Fact] [Fact]
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges() public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
{ {
@@ -298,4 +333,14 @@ public sealed class ApprovalsControllerTests
DataScope.Self, DataScope.Self,
new HashSet<string>([SystemRoles.Student])); new HashSet<string>([SystemRoles.Student]));
} }
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
} }
@@ -0,0 +1,77 @@
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class AttendancePaginationTests
{
[Fact]
public async Task TasksAndSheets_AreFilteredAndReturnedByPage()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var course = new Course
{
Code = "CS101", Name = "程序设计基础", CollegeId = college.Id,
Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16,
Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination
};
var term = new AcademicTerm
{
Code = "2026-1", Name = "2026—2027 学年第一学期", AcademicYear = "2026-2027",
Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var tasks = Enumerable.Range(1, 11).Select(number => new TeachingTask
{
TaskNumber = $"2026-1-CS101-{number:00}", Name = $"程序设计教学班 {number}",
AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60,
Status = TeachingTaskStatus.Published
}).ToList();
var sheets = Enumerable.Range(1, 11).Select(number => new AttendanceSheet
{
TeachingTaskId = tasks[0].Id,
Name = number == 11 ? "期末扫码点名" : $"第 {number} 周点名",
AttendanceDate = new DateTime(2026, 9, number),
Status = number == 11 ? AttendanceSheetStatus.Submitted : AttendanceSheetStatus.Draft,
CheckInMethod = number == 11 ? AttendanceCheckInMethod.QrCode : AttendanceCheckInMethod.Manual
}).ToList();
db.AddRange(college, course, term);
db.AddRange(tasks);
db.AddRange(sheets);
await db.SaveChangesAsync();
var controller = new AttendanceController(db, new AllScope());
var taskResult = Assert.IsType<OkObjectResult>(await controller.GetMyTasks(
term.Id, "程序设计", 1, 10, CancellationToken.None));
var taskPage = Assert.IsType<PagedResult<object>>(taskResult.Value);
Assert.Equal(11, taskPage.Total);
Assert.Equal(10, taskPage.Items.Count);
var sheetResult = Assert.IsType<OkObjectResult>(await controller.GetSheets(
tasks[0].Id, "期末", AttendanceSheetStatus.Submitted,
AttendanceCheckInMethod.QrCode, 1, 10, CancellationToken.None));
var sheetPage = Assert.IsType<PagedResult<object>>(sheetResult.Value);
Assert.Equal(1, sheetPage.Total);
Assert.Single(sheetPage.Items);
}
private sealed class AllScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(), "测试管理员", null, DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
}
@@ -1,6 +1,6 @@
using System.Diagnostics.Metrics;
using Jiaowu.Api.Infrastructure.Observability; using Jiaowu.Api.Infrastructure.Observability;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -40,7 +40,7 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
} }
[Fact] [Fact]
public async Task Ef_command_records_duration_metric() public async Task Ef_command_executes_with_logging_interceptor()
{ {
await using var connection = await using var connection =
new SqliteConnection("Data Source=:memory:"); new SqliteConnection("Data Source=:memory:");
@@ -52,25 +52,9 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
await using (var setupDb = new AppDbContext(setupOptions)) await using (var setupDb = new AppDbContext(setupOptions))
await setupDb.Database.EnsureCreatedAsync(); await setupDb.Database.EnsureCreatedAsync();
double? recordedDuration = null;
using var listener = new MeterListener
{
InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name ==
DatabaseCommandTelemetryInterceptor.MeterName &&
instrument.Name == "jiaowu.db.command.duration")
{
meterListener.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<double>(
(_, measurement, _, _) => recordedDuration = measurement);
listener.Start();
var interceptor = new DatabaseCommandTelemetryInterceptor( var interceptor = new DatabaseCommandTelemetryInterceptor(
new ObservabilityOptions(), new ObservabilityOptions(),
new HttpContextAccessor(),
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance); NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
var queryOptions = new DbContextOptionsBuilder<AppDbContext>() var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection) .UseSqlite(connection)
@@ -82,7 +66,6 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
.TagWith("Observability.Tests.TermCount") .TagWith("Observability.Tests.TermCount")
.CountAsync(); .CountAsync();
Assert.NotNull(recordedDuration); Assert.Equal(0, await db.AcademicTerms.CountAsync());
Assert.True(recordedDuration >= 0);
} }
} }
@@ -4,6 +4,8 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using ClosedXML.Excel;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -72,6 +74,56 @@ public sealed class ExperimentGradesControllerTests
CancellationToken.None)); CancellationToken.None));
} }
[Fact]
public async Task Teacher_CanDownloadAndImportExperimentGradeTemplate()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-EXCEL",
Name = "Excel 实验成绩",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
fixture.Db.ExperimentProjects.Add(project);
await fixture.Db.SaveChangesAsync();
var admin = fixture.ExperimentGrades(fixture.AdminScope);
await admin.CreateSheet(new ExperimentGradeSheetRequest(
project.Id, 1, 60,
[new ExperimentGradeItemRequest("操作", ExperimentGradeItemKind.Operation, 100)]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
fixture.Db.ChangeTracker.Clear();
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
var template = Assert.IsType<FileContentResult>(await teacher.DownloadTemplate(
sheet.Id, CancellationToken.None));
Assert.Equal("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
template.ContentType);
await using var templateStream = new MemoryStream(template.FileContents);
await using var stream = new MemoryStream();
using (var workbook = new XLWorkbook(templateStream))
{
var worksheet = workbook.Worksheet("实验成绩导入");
worksheet.Cell("D2").Value = "已完成";
worksheet.Cell("E2").Value = 85;
workbook.SaveAs(stream);
}
stream.Position = 0;
var file = new FormFile(stream, 0, stream.Length, "file", "实验成绩导入模板.xlsx");
Assert.IsType<OkObjectResult>(await teacher.ImportRecords(
sheet.Id, file, CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
Assert.Equal(85m, await fixture.Db.ExperimentGradeRecords
.Select(record => record.TotalScore)
.SingleAsync());
}
[Fact] [Fact]
public async Task ManagementList_IsPagedFilteredAndCollegeScoped() public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
{ {
@@ -146,10 +198,11 @@ public sealed class ExperimentGradesControllerTests
1, 1,
10, 10,
CancellationToken.None)); CancellationToken.None));
Assert.Equal(12, Property<int>(page.Value, "Total")); Assert.Equal(1, Property<int>(page.Value, "Total"));
Assert.Equal(10, Property<System.Collections.IEnumerable>( var course = Assert.Single(Property<System.Collections.IEnumerable>(
page.Value, page.Value, "Items").Cast<object>());
"Items").Cast<object>().Count()); Assert.Equal(12, Property<System.Collections.IEnumerable>(
course, "Projects").Cast<object>().Count());
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement( var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
null, null,
@@ -162,6 +215,89 @@ public sealed class ExperimentGradesControllerTests
Assert.Equal(1, Property<int>(filtered.Value, "Total")); Assert.Equal(1, Property<int>(filtered.Value, "Total"));
} }
[Fact]
public async Task ManagementList_OrdersProjectsByScheduledWeekAndPeriod()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var plan = new SchedulePlan
{
AcademicTermId = fixture.Term.Id,
Name = "实验课表",
Version = "v1",
Status = SchedulePlanStatus.Published
};
var laterEntry = new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.Classroom.Id,
Kind = ScheduleEntryKind.Experiment,
DayOfWeek = 4,
StartPeriod = 5,
PeriodCount = 2,
StartWeek = 5,
EndWeek = 5,
WeekPattern = WeekPattern.All
};
var earlierEntry = new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.Classroom.Id,
Kind = ScheduleEntryKind.Experiment,
DayOfWeek = 2,
StartPeriod = 3,
PeriodCount = 2,
StartWeek = 2,
EndWeek = 2,
WeekPattern = WeekPattern.All
};
fixture.Db.AddRange(plan, laterEntry, earlierEntry);
fixture.Db.AddRange(
new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
ScheduleEntryId = laterEntry.Id,
ScheduleWeek = 5,
Code = "LAB-A",
Name = "后面的实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published
},
new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
ScheduleEntryId = earlierEntry.Id,
ScheduleWeek = 2,
Code = "LAB-Z",
Name = "前面的实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published
});
await fixture.Db.SaveChangesAsync();
fixture.Db.ChangeTracker.Clear();
var result = Assert.IsType<OkObjectResult>(await fixture.ExperimentGrades(
fixture.AdminScope).GetManagement(
fixture.Term.Id, null, null, null, 1, 10, CancellationToken.None));
var course = Assert.Single(Property<System.Collections.IEnumerable>(
result.Value, "Items").Cast<object>());
var projects = Property<System.Collections.IEnumerable>(course, "Projects")
.Cast<object>()
.ToList();
Assert.Equal(["前面的实验", "后面的实验"],
projects.Select(project => Property<string>(project, "Name")));
var schedule = Property<object>(projects[0], "ScheduleEntry");
Assert.Equal(2, Property<int>(projects[0], "ScheduleWeek"));
Assert.Equal(2, Property<int>(schedule, "DayOfWeek"));
Assert.Equal(3, Property<int>(schedule, "StartPeriod"));
}
private static T Property<T>(object? value, string name) => private static T Property<T>(object? value, string name) =>
Assert.IsAssignableFrom<T>( Assert.IsAssignableFrom<T>(
value!.GetType().GetProperty(name)!.GetValue(value)); value!.GetType().GetProperty(name)!.GetValue(value));
@@ -161,7 +161,8 @@ public sealed class ExperimentsControllerTests
1, 1,
2, 2,
20, 20,
null), null,
[fixture.Teacher.Id]),
new ExperimentSessionBatchItem( new ExperimentSessionBatchItem(
projectIds[1], projectIds[1],
fixture.Classroom.Id, fixture.Classroom.Id,
@@ -169,7 +170,8 @@ public sealed class ExperimentsControllerTests
1, 1,
2, 2,
20, 20,
null) null,
[fixture.Teacher.Id])
]), ]),
CancellationToken.None); CancellationToken.None);
@@ -202,7 +204,8 @@ public sealed class ExperimentsControllerTests
1, 1,
2, 2,
20, 20,
null), null,
[fixture.Teacher.Id]),
new ExperimentSessionBatchItem( new ExperimentSessionBatchItem(
projectIds[1], projectIds[1],
fixture.SecondClassroom.Id, fixture.SecondClassroom.Id,
@@ -210,7 +213,8 @@ public sealed class ExperimentsControllerTests
1, 1,
2, 2,
20, 20,
null) null,
[fixture.Teacher.Id])
]), ]),
CancellationToken.None); CancellationToken.None);
@@ -218,6 +222,65 @@ public sealed class ExperimentsControllerTests
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync()); Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
} }
[Fact]
public async Task SelfScheduledProject_AllowsMultipleCandidateSessionsAndRequiresSelectionWindow()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
var request = fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled) with
{
SelectionStartsAt = DateTime.UtcNow.AddHours(1),
SelectionEndsAt = DateTime.UtcNow.AddDays(1)
};
await manager.CreateProject(request, CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
var result = await manager.CreateSessions(
new ExperimentSessionBatchRequest(
[
new ExperimentSessionBatchItem(project.Id, fixture.Classroom.Id,
fixture.Term.StartDate, 1, 2, 20, null, [fixture.Teacher.Id]),
new ExperimentSessionBatchItem(project.Id, fixture.SecondClassroom.Id,
fixture.Term.StartDate, 4, 2, 20, null, [fixture.Teacher.Id])
]),
CancellationToken.None);
Assert.IsType<CreatedResult>(result);
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
Assert.Equal(2, await fixture.Db.ExperimentSessionInstructors.CountAsync());
Assert.IsType<NoContentResult>(await manager.PublishProject(project.Id, CancellationToken.None));
var session = await fixture.Db.ExperimentSessions.OrderBy(x => x.StartPeriod).FirstAsync();
Assert.IsType<ConflictObjectResult>(
await fixture.Controller(fixture.StudentScope).Book(session.Id, CancellationToken.None));
}
[Fact]
public async Task BoundInstructor_CanCancelOwnSession()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
Assert.IsType<CreatedResult>(await manager.CreateSession(
project.Id, fixture.SessionRequest(1, 2, 20), CancellationToken.None));
var session = await fixture.Db.ExperimentSessions.SingleAsync();
Assert.IsType<NoContentResult>(await manager.PublishProject(project.Id, CancellationToken.None));
var instructorScope = new FixedScope(new CurrentUserScope(
fixture.Teacher.UserId!.Value,
fixture.Teacher.Name,
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Teacher])));
Assert.IsType<NoContentResult>(await fixture.Controller(instructorScope)
.CancelSession(session.Id, CancellationToken.None));
Assert.Equal(ExperimentSessionStatus.Cancelled,
(await fixture.Db.ExperimentSessions.SingleAsync()).Status);
}
[Fact] [Fact]
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule() public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
{ {
@@ -246,6 +309,35 @@ public sealed class ExperimentsControllerTests
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync()); Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
} }
[Fact]
public async Task PublishedProject_AllowsTeachingDetailCorrectionWithoutChangingSchedule()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
await controller.PublishProject(project.Id, CancellationToken.None);
fixture.Db.ChangeTracker.Clear();
Assert.IsType<NoContentResult>(await controller.CorrectPublishedProjectDetails(
project.Id,
new PublishedExperimentProjectCorrectionRequest(
"修正后的实验名称",
"按本班教学计划调整实验步骤。",
"请提前完成环境检查。"),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var corrected = await fixture.Db.ExperimentProjects.SingleAsync();
Assert.Equal("修正后的实验名称", corrected.Name);
Assert.Equal("按本班教学计划调整实验步骤。", corrected.Description);
Assert.Equal("请提前完成环境检查。", corrected.Requirements);
Assert.Equal("LAB-C", corrected.Code);
Assert.Equal(fixture.ScheduleEntry.Id, corrected.ScheduleEntryId);
}
[Fact] [Fact]
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged() public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
{ {
@@ -480,6 +572,7 @@ public sealed class ExperimentsControllerTests
AppDbContext db, AppDbContext db,
AcademicTerm term, AcademicTerm term,
TeachingTask task, TeachingTask task,
Teacher teacher,
Classroom classroom, Classroom classroom,
Classroom secondClassroom, Classroom secondClassroom,
ScheduleEntry scheduleEntry, ScheduleEntry scheduleEntry,
@@ -490,6 +583,7 @@ public sealed class ExperimentsControllerTests
Db = db; Db = db;
Term = term; Term = term;
Task = task; Task = task;
Teacher = teacher;
Classroom = classroom; Classroom = classroom;
SecondClassroom = secondClassroom; SecondClassroom = secondClassroom;
ScheduleEntry = scheduleEntry; ScheduleEntry = scheduleEntry;
@@ -501,6 +595,7 @@ public sealed class ExperimentsControllerTests
public AppDbContext Db { get; } public AppDbContext Db { get; }
public AcademicTerm Term { get; } public AcademicTerm Term { get; }
public TeachingTask Task { get; } public TeachingTask Task { get; }
public Teacher Teacher { get; }
public Classroom Classroom { get; } public Classroom Classroom { get; }
public Classroom SecondClassroom { get; } public Classroom SecondClassroom { get; }
public ScheduleEntry ScheduleEntry { get; } public ScheduleEntry ScheduleEntry { get; }
@@ -571,7 +666,8 @@ public sealed class ExperimentsControllerTests
{ {
TeacherNumber = "T2099", TeacherNumber = "T2099",
Name = "实验教师", Name = "实验教师",
CollegeId = college.Id CollegeId = college.Id,
UserId = manager.Id
}; };
var term = new AcademicTerm var term = new AcademicTerm
{ {
@@ -626,7 +722,15 @@ public sealed class ExperimentsControllerTests
AcademicTermId = term.Id, AcademicTermId = term.Id,
CourseId = course.Id, CourseId = course.Id,
Capacity = 40, Capacity = 40,
Status = TeachingTaskStatus.Published Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
]
}; };
db.AddRange( db.AddRange(
manager, manager,
@@ -683,6 +787,7 @@ public sealed class ExperimentsControllerTests
db, db,
term, term,
task, task,
teacher,
classroom, classroom,
secondClassroom, secondClassroom,
scheduleEntry, scheduleEntry,
@@ -715,7 +820,13 @@ public sealed class ExperimentsControllerTests
"携带校园卡。", "携带校园卡。",
Term.StartDate, Term.StartDate,
Term.StartDate.AddDays(14), Term.StartDate.AddDays(14),
mode == ExperimentArrangementMode.Centralized ? ScheduleEntry.Id : null); mode == ExperimentArrangementMode.Centralized ? ScheduleEntry.Id : null,
mode == ExperimentArrangementMode.SelfScheduled
? new DateTime(2020, 1, 1, 8, 0, 0)
: null,
mode == ExperimentArrangementMode.SelfScheduled
? new DateTime(2100, 1, 1, 18, 0, 0)
: null);
public ExperimentProjectBatchRequest BatchProjectRequest( public ExperimentProjectBatchRequest BatchProjectRequest(
ExperimentArrangementMode mode) => ExperimentArrangementMode mode) =>
@@ -742,7 +853,8 @@ public sealed class ExperimentsControllerTests
startPeriod, startPeriod,
periodCount, periodCount,
capacity, capacity,
null); null,
[Teacher.Id]);
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
@@ -1,3 +1,4 @@
using ClosedXML.Excel;
using Jiaowu.Api.Contracts; using Jiaowu.Api.Contracts;
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
@@ -83,8 +84,12 @@ public sealed class GradesPaginationTests
var sheet = new GradeSheet var sheet = new GradeSheet
{ {
TeachingTaskId = tasks[0].Id, TeachingTaskId = tasks[0].Id,
RegularWeight = 30, RegularWeight = 20,
FinalWeight = 70, FinalWeight = 60,
Items =
[
new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 }
],
Records = students.Select(student => new GradeRecord Records = students.Select(student => new GradeRecord
{ {
StudentId = student.Id StudentId = student.Id
@@ -129,6 +134,18 @@ public sealed class GradesPaginationTests
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")! var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
.GetValue(secondDetail.Value)!; .GetValue(secondDetail.Value)!;
Assert.Single(ReadItems(secondSheet, "Records")); Assert.Single(ReadItems(secondSheet, "Records"));
var templateResult = Assert.IsType<FileContentResult>(
await controller.DownloadTemplate(sheet.Id, CancellationToken.None));
using var stream = new MemoryStream(templateResult.FileContents);
using var workbook = new XLWorkbook(stream);
var worksheet = workbook.Worksheet("成绩导入");
Assert.Equal(
["学号", "姓名", "班级", "平时成绩", "实验", "期末成绩", "总分(自动计算)", "考试状态", "备注"],
worksheet.Row(1).CellsUsed().Select(cell => cell.GetString()));
Assert.Contains("D2*20/100", worksheet.Cell("G2").FormulaA1);
Assert.Contains("E2*20/100", worksheet.Cell("G2").FormulaA1);
Assert.Contains("F2*60/100", worksheet.Cell("G2").FormulaA1);
} }
private static int ReadInt(object value, string property) => private static int ReadInt(object value, string property) =>
@@ -234,6 +234,53 @@ public sealed class NotificationsControllerTests
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId); Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
} }
[Fact]
public async Task Mark_all_read_only_updates_the_current_users_unread_notifications()
{
await using var fixture = await NotificationFixture.CreateAsync();
fixture.Db.Notifications.AddRange(
new Notification
{
UserId = fixture.Sender.Id,
Title = "当前用户未读",
Content = "应被标为已读。"
},
new Notification
{
UserId = fixture.Sender.Id,
Title = "当前用户已读",
Content = "应保持已读。",
IsRead = true
},
new Notification
{
UserId = fixture.CollegeRecipient.Id,
Title = "其他用户未读",
Content = "不应被修改。"
});
await fixture.Db.SaveChangesAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var result = await controller.MarkAllRead(CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var notifications = await fixture.Db.Notifications.AsNoTracking()
.OrderBy(x => x.Title)
.ToListAsync();
Assert.All(
notifications.Where(x => x.UserId == fixture.Sender.Id),
notification => Assert.True(notification.IsRead));
Assert.False(Assert.Single(
notifications,
x => x.UserId == fixture.CollegeRecipient.Id).IsRead);
}
[Fact] [Fact]
public async Task Publishing_course_grades_automatically_notifies_roster_students() public async Task Publishing_course_grades_automatically_notifies_roster_students()
{ {
@@ -48,8 +48,9 @@ public sealed class OfficialDocumentTests
var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code"); var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code");
Assert.True(result.Content.Length > 5_000); Assert.True(result.Content.Length > 500, "生成的 PDF 不应为空。");
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), result.Content);
Assert.Equal( Assert.Equal(
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(), Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
result.Sha256); result.Sha256);
@@ -0,0 +1,134 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class OtherExamsControllerTests
{
[Fact]
public async Task Batches_AreFilteredAndReturnedByPage()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
await db.Database.EnsureCreatedAsync();
db.OtherExamBatches.AddRange(Enumerable.Range(1, 11).Select(number =>
new OtherExamBatch
{
ExamCode = $"CET-{number:00}",
Name = $"英语等级考试 {number}",
ExamDate = new DateOnly(2026, 6, number),
MetricKind = OtherExamMetricKind.Score,
MaxScore = 100,
Status = number == 11
? OtherExamBatchStatus.Published
: OtherExamBatchStatus.Draft
}));
await db.SaveChangesAsync();
var controller = new OtherExamsController(db, new TestDataScope(Guid.NewGuid()));
var result = Assert.IsType<OkObjectResult>(await controller.GetBatches(
keyword: "英语", status: OtherExamBatchStatus.Draft, page: 1,
pageSize: 10, ct: CancellationToken.None));
var page = Assert.IsType<PagedResult<object>>(result.Value);
Assert.Equal(10, page.Total);
Assert.Equal(10, page.Items.Count);
}
[Fact]
public async Task ReplaceResults_ReplacesExistingResultsInsideTransaction()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" };
var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 };
var student = new Student
{
StudentNumber = "202600001",
Name = "测试学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1),
Status = StudentStatus.Active
};
var batch = new OtherExamBatch
{
ExamCode = "CET4",
Name = "大学英语四级",
ExamDate = new DateOnly(2026, 6, 1),
MetricKind = OtherExamMetricKind.Score,
MaxScore = 710
};
var oldResult = new OtherExamResult
{
OtherExamBatchId = batch.Id,
StudentId = student.Id,
Score = 500,
AttemptNumber = 1
};
db.AddRange(college, major, administrativeClass, student, batch, oldResult);
await db.SaveChangesAsync();
var controller = new OtherExamsController(db, new TestDataScope(college.Id));
var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([
new OtherExamResultRequest(student.StudentNumber, 610.5m, null, null, "已复核")
]), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
db.ChangeTracker.Clear();
var saved = await db.OtherExamResults.SingleAsync(x => x.OtherExamBatchId == batch.Id);
Assert.Equal(610.5m, saved.Score);
Assert.Equal("已复核", saved.Notes);
Assert.Equal(1, saved.AttemptNumber);
}
[Fact]
public async Task ReplaceResults_RejectsOverlongNotesBeforeDatabaseSave()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" };
var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 };
var student = new Student { StudentNumber = "202600001", Name = "测试学生", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1), Status = StudentStatus.Active };
var batch = new OtherExamBatch { ExamCode = "CET4", Name = "大学英语四级", ExamDate = new DateOnly(2026, 6, 1), MetricKind = OtherExamMetricKind.Score, MaxScore = 710 };
db.AddRange(college, major, administrativeClass, student, batch);
await db.SaveChangesAsync();
var controller = new OtherExamsController(db, new TestDataScope(college.Id));
var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([
new OtherExamResultRequest(student.StudentNumber, 610, null, null, new string('注', 501))
]), CancellationToken.None);
var validation = Assert.IsType<ObjectResult>(result);
Assert.Contains("备注不能超过 500 个字符", Assert.IsType<ValidationProblemDetails>(validation.Value).Detail);
Assert.Empty(await db.OtherExamResults.ToListAsync());
}
private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
collegeId,
DataScope.College,
new HashSet<string>([SystemRoles.CollegeAdmin]));
}
}
@@ -160,5 +160,17 @@ public sealed class TimetableExcelExporterTests
Assert.Contains( Assert.Contains(
sheet.CellsUsed(), sheet.CellsUsed(),
cell => cell.GetString().Contains("非排时课程")); cell => cell.GetString().Contains("非排时课程"));
var batchBytes = TimetableExcelExporter.Create([timetable, timetable]);
using var batchStream = new MemoryStream(batchBytes);
using var batchWorkbook = new XLWorkbook(batchStream);
Assert.Equal(2, batchWorkbook.Worksheets.Count);
Assert.All(batchWorkbook.Worksheets, worksheet =>
Assert.Contains("计算机科学 2601 班", worksheet.Cell("A1").GetString()));
var pdf = TimetablePdfExporter.Create([timetable, timetable]);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), pdf);
Assert.True(pdf.Length > 1_000, "生成的 PDF 不应为空。");
} }
} }
+3 -3
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<JiaowuBackendVersion>2.3.2</JiaowuBackendVersion> <JiaowuBackendVersion>2.4.0</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.3.2</JiaowuFrontendVersion> <JiaowuFrontendVersion>2.4.0</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.3.2</JiaowuSwaggerVersion> <JiaowuSwaggerVersion>2.4.0</JiaowuSwaggerVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+9 -9
View File
@@ -19,7 +19,7 @@
"ckeditor5": "^48.3.1", "ckeditor5": "^48.3.1",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"element-plus": "^2.14.3", "element-plus": "2.14.3",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"pinia": "^4.0.2", "pinia": "^4.0.2",
@@ -2936,14 +2936,14 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/element-plus": { "node_modules/element-plus": {
"version": "2.14.4", "version": "2.14.3",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.4.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
"integrity": "sha512-vMKR9tFcLeNrJgFXA3zhUn6YuRKUQW9d0btakBR8U1Iq8MfzkjMOGcgs5de2VgiLldHt69brmuBHpxc3bK4ZgQ==", "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ctrl/tinycolor": "^4.2.0", "@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.8.0", "@floating-ui/dom": "^1.7.6",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
@@ -2955,7 +2955,7 @@
"lodash-unified": "^1.0.3", "lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0", "normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.9" "vue-component-type-helpers": "^3.3.5"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "^3.3.7" "vue": "^3.3.7"
@@ -4188,9 +4188,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.18.1", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
+4 -1
View File
@@ -28,7 +28,7 @@
"ckeditor5": "^48.3.1", "ckeditor5": "^48.3.1",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"element-plus": "^2.14.3", "element-plus": "2.14.3",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"pinia": "^4.0.2", "pinia": "^4.0.2",
@@ -48,5 +48,8 @@
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.1", "vite": "^8.1.1",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
},
"overrides": {
"lodash-es": "4.17.21"
} }
} }
+16
View File
@@ -28,6 +28,22 @@ export async function downloadApiFile(
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
export async function downloadApiPostFile(
path: string,
payload: unknown,
fallbackName: string,
) {
const response = await http.post(path, payload, { responseType: 'blob' })
const url = URL.createObjectURL(response.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = responseFileName(response.headers['content-disposition'], fallbackName)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
export async function importExcel(path: string, file: File) { export async function importExcel(path: string, file: File) {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
+1
View File
@@ -54,6 +54,7 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect'] ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
NotificationRichTextEditor: typeof import('./components/NotificationRichTextEditor.vue')['default']
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default'] PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default'] RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
import {
AutoLink,
BlockQuote,
Bold,
ClassicEditor,
Essentials,
Heading,
Image,
ImageCaption,
ImageInsert,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageToolbar,
Italic,
Link,
LinkImage,
List,
Paragraph,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
type EditorConfig,
} from 'ckeditor5'
import 'ckeditor5/ckeditor5.css'
const model = defineModel<string>({ required: true })
const editorConfig: EditorConfig = {
licenseKey: import.meta.env.VITE_CKEDITOR_LICENSE_KEY || 'GPL',
plugins: [
Essentials, Paragraph, Heading, Bold, Italic, Link, AutoLink, List, BlockQuote,
Image, ImageCaption, ImageInsert, ImageInsertViaUrl, ImageResize, ImageStyle,
ImageToolbar, LinkImage, Table, TableCaption, TableCellProperties,
TableColumnResize, TableProperties, TableToolbar,
],
toolbar: {
items: [
'heading', '|', 'bold', 'italic', 'link', '|', 'insertTable', 'insertImage',
'|', 'bulletedList', 'numberedList', 'blockQuote', '|', 'undo', 'redo',
],
shouldNotGroupWhenFull: false,
},
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: { addTargetToExternalLinks: true, defaultProtocol: 'https://' },
image: {
insert: { integrations: ['insertImageViaUrl'] },
toolbar: [
'toggleImageCaption', 'imageTextAlternative', '|', 'imageStyle:inline',
'imageStyle:wrapText', 'imageStyle:breakText', '|', 'resizeImage', 'linkImage',
],
},
table: {
contentToolbar: [
'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption',
'tableProperties', 'tableCellProperties',
],
},
}
</script>
<template>
<Ckeditor
v-model="model"
:editor="ClassicEditor"
:config="editorConfig"
/>
</template>
+15 -41
View File
@@ -2,31 +2,29 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import http from '../api/http' import http from '../api/http'
interface SystemVersionResponse {
version: string
}
const backendVersion = ref('获取中')
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const frontendVersion = __APP_VERSION__ const frontendVersion = __APP_VERSION__
const backendVersion = ref<string>()
onMounted(async () => { onMounted(async () => {
try { try {
const { data } = await http.get<SystemVersionResponse>('/system/version') const { data } = await http.get<{ version?: string }>('/system/version')
backendVersion.value = data.version ? `v${data.version}` : '未知' backendVersion.value = data.version?.trim() || undefined
} catch { } catch {
backendVersion.value = '未知' //
} }
}) })
</script> </script>
<template> <template>
<footer class="site-footer" aria-label="网站版权与版本信息"> <footer class="site-footer" aria-label="网站版权信息">
<div class="site-footer-inner"> <div class="site-footer-inner">
<p>© {{ currentYear }} 明序教务 · 版权所有</p> <p>© {{ currentYear }} 明序教务 · 版权所有</p>
<div class="version-list" aria-label="系统版本"> <div class="footer-meta">
<span>前端版本 <b>v{{ frontendVersion }}</b></span> <span class="footer-note">教务管理与学业服务平台</span>
<span>后端版本 <b>{{ backendVersion }}</b></span> <span class="footer-versions" aria-live="polite">
前端 v{{ frontendVersion }} · 后端 v{{ backendVersion ?? '—' }}
</span>
</div> </div>
</div> </div>
</footer> </footer>
@@ -61,27 +59,9 @@ onMounted(async () => {
letter-spacing: .04em; letter-spacing: .04em;
} }
.version-list { .footer-meta { display: flex; align-items: center; gap: 14px; }
display: flex; .footer-note { color: #aeb9d8; font-size: 11px; }
align-items: center; .footer-versions { color: #c3cce5; font-size: 11px; font-variant-numeric: tabular-nums; }
gap: 8px;
}
.version-list span {
padding: 5px 9px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 4px;
color: #aeb9d8;
background: rgba(255, 255, 255, .055);
font-size: 10px;
white-space: nowrap;
}
.version-list b {
margin-left: 5px;
color: #66d5c5;
font: 700 10px/1.2 Consolas, monospace;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.site-footer-inner { .site-footer-inner {
@@ -93,13 +73,7 @@ onMounted(async () => {
gap: 7px; gap: 7px;
} }
.version-list { .footer-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
width: 100%; .footer-note, .footer-versions { font-size: 10px; }
}
.version-list span {
flex: 1;
text-align: center;
}
} }
</style> </style>
+2 -2
View File
@@ -53,7 +53,7 @@ onBeforeUnmount(() => {
<div class="smart-launch-mark" aria-hidden="true"> <div class="smart-launch-mark" aria-hidden="true">
<i v-for="index in 9" :key="index" /> <i v-for="index in 9" :key="index" />
</div> </div>
<span class="smart-launch-brand">MINGXU ACADEMIC</span> <span class="smart-launch-brand">明序教务</span>
<p>{{ greeting.label }}</p> <p>{{ greeting.label }}</p>
<h1>{{ greeting.title }}</h1> <h1>{{ greeting.title }}</h1>
<small>{{ greeting.subtitle }}</small> <small>{{ greeting.subtitle }}</small>
@@ -117,7 +117,7 @@ onBeforeUnmount(() => {
color: #8fe4dc; color: #8fe4dc;
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.28em; letter-spacing: 0.16em;
} }
.smart-launch-content p { .smart-launch-content p {
+7 -2
View File
@@ -151,6 +151,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
), ),
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }), ...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
...whenVisible(isTimetableManager.value, { path: '/venue-displays', label: '场地信息展牌' }),
{ {
path: '/class-timetable', path: '/class-timetable',
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询', label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
@@ -194,6 +195,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']), hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' }, { path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
), ),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader']),
{ path: '/event-analytics', label: '运行数据分析' },
),
], ],
}, },
{ {
@@ -325,7 +330,7 @@ onMounted(() => {
</div> </div>
<div> <div>
<strong>明序教务</strong> <strong>明序教务</strong>
<small>ACADEMIC OFFICE</small> <small>教务管理与学业服务</small>
</div> </div>
</div> </div>
@@ -390,7 +395,7 @@ onMounted(() => {
</div> </div>
<div> <div>
<strong>明序教务</strong> <strong>明序教务</strong>
<small>ACADEMIC OFFICE</small> <small>教务管理与学业服务</small>
</div> </div>
</div> </div>
<button type="button" aria-label="关闭导航菜单" @click="mobileMenu = false">×</button> <button type="button" aria-label="关闭导航菜单" @click="mobileMenu = false">×</button>
+4
View File
@@ -1,5 +1,9 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
// 命令式调用不会经过组件自动按需引入,需显式保留其样式,避免提示退化为页面普通文本。
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/notification/style/css'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+20
View File
@@ -31,6 +31,12 @@ const router = createRouter({
component: () => import('../views/TimetableView.vue'), component: () => import('../views/TimetableView.vue'),
meta: { public: true }, meta: { public: true },
}, },
{
path: '/venue-display/:type(classroom|building)/:id',
name: 'venue-display',
component: () => import('../views/VenueDisplayView.vue'),
meta: { public: true },
},
{ {
path: '/activate', path: '/activate',
name: 'activate-account', name: 'activate-account',
@@ -210,6 +216,12 @@ const router = createRouter({
component: () => import('../views/FreeClassroomsView.vue'), component: () => import('../views/FreeClassroomsView.vue'),
meta: { roles: ['Student'] }, meta: { roles: ['Student'] },
}, },
{
path: 'venue-displays',
name: 'venue-displays',
component: () => import('../views/VenueDisplaysView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
},
{ {
path: 'classroom-reservations', path: 'classroom-reservations',
name: 'classroom-reservations', name: 'classroom-reservations',
@@ -263,6 +275,14 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'], roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
}, },
}, },
{
path: 'event-analytics',
name: 'event-analytics',
component: () => import('../views/EventAnalyticsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'],
},
},
{ {
path: 'other-exams', path: 'other-exams',
name: 'other-exams', name: 'other-exams',
+136 -2
View File
@@ -42,7 +42,7 @@ button { cursor: pointer; }
} }
.top-brand { min-width: 224px; padding-right: 28px; border-right: 1px solid rgba(255,255,255,.14); } .top-brand { min-width: 224px; padding-right: 28px; border-right: 1px solid rgba(255,255,255,.14); }
.brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; letter-spacing: .12em; color: white; } .brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; letter-spacing: .12em; color: white; }
.brand small { display: block; margin-top: 3px; font: 9px/1.2 Consolas, monospace; letter-spacing: .16em; color: #9faad1; } .brand small { display: block; margin-top: 3px; font: 10px/1.2 "Microsoft YaHei", sans-serif; letter-spacing: .08em; color: #9faad1; }
.brand-mark { flex: 0 0 auto; width: 31px; height: 31px; display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; } .brand-mark { flex: 0 0 auto; width: 31px; height: 31px; display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; }
.brand-mark span { border: 1px solid #9eabd8; } .brand-mark span { border: 1px solid #9eabd8; }
.brand-mark span:nth-child(2), .brand-mark span:nth-child(5), .brand-mark span:nth-child(8) { background: #3ec1ae; border-color: #3ec1ae; } .brand-mark span:nth-child(2), .brand-mark span:nth-child(5), .brand-mark span:nth-child(8) { background: #3ec1ae; border-color: #3ec1ae; }
@@ -122,7 +122,7 @@ button { cursor: pointer; }
.content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; } .content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; }
.mobile-only { display: none; } .mobile-only { display: none; }
.section-kicker { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; letter-spacing: .16em; } .section-kicker { color: var(--teal); font: 700 11px/1.2 "Microsoft YaHei", sans-serif; letter-spacing: .1em; }
.term-hero { min-height: 178px; padding: 32px 36px; display: flex; align-items: end; color: white; background: linear-gradient(118deg, #233876, #192d67 65%, #116d70); position: relative; overflow: hidden; } .term-hero { min-height: 178px; padding: 32px 36px; display: flex; align-items: end; color: white; background: linear-gradient(118deg, #233876, #192d67 65%, #116d70); position: relative; overflow: hidden; }
.term-hero::after { content: ""; position: absolute; right: -40px; top: -110px; width: 360px; height: 360px; border: 54px solid rgba(255,255,255,.055); border-radius: 50%; } .term-hero::after { content: ""; position: absolute; right: -40px; top: -110px; width: 360px; height: 360px; border: 54px solid rgba(255,255,255,.055); border-radius: 50%; }
.term-hero h2 { margin: 12px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(25px, 3vw, 38px); letter-spacing: .05em; } .term-hero h2 { margin: 12px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(25px, 3vw, 38px); letter-spacing: .05em; }
@@ -1496,3 +1496,137 @@ button { cursor: pointer; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
} }
/* Shared workspace surface: keeps independently-built modules visually aligned. */
:root {
--surface: #ffffff;
--surface-muted: #f8fafc;
--surface-accent: #f0f7f6;
--shadow-card: 0 8px 24px rgba(26, 47, 80, .055);
--el-border-radius-base: 8px;
--el-border-radius-small: 6px;
--el-fill-color-blank: #ffffff;
--el-bg-color-page: #f4f6fa;
--el-border-color: #dfe5ee;
--el-text-color-primary: #182033;
--el-text-color-regular: #4f5b6c;
}
body {
background:
linear-gradient(180deg, rgba(35, 56, 118, .035), transparent 20rem),
#f4f6fa;
}
::selection { color: #fff; background: var(--indigo); }
.content { max-width: 1480px; }
.page-stack { gap: 20px; }
.page-intro,
.page-heading {
position: relative;
min-height: 96px;
padding: 20px 0 16px 18px;
border-bottom: 1px solid #dfe5ee;
}
.page-intro::before,
.page-heading::before {
position: absolute;
top: 23px;
bottom: 20px;
left: 0;
width: 3px;
content: '';
border-radius: 3px;
background: linear-gradient(180deg, var(--teal), #55b9aa);
}
.page-intro h2,
.page-heading h2 {
letter-spacing: .02em;
}
.page-intro p,
.page-heading > div > p {
max-width: 720px;
line-height: 1.7;
}
.data-card,
.page-stack > .el-card,
.page-stack .el-card,
.application-card,
.result-board,
.batch-panel,
.editor-panel {
border-color: #dfe5ee;
border-radius: 10px;
box-shadow: var(--shadow-card);
}
.data-card:hover,
.page-stack > .el-card:hover,
.application-card:hover,
.result-board:hover,
.batch-panel:hover,
.editor-panel:hover {
border-color: #cbd8e7;
}
.table-toolbar,
.filter-bar {
min-height: 76px;
padding: 16px 20px;
border-color: #e1e7ef;
background: var(--surface-muted);
}
.table-toolbar > span,
.filter-bar > span,
.pagination-bar > span { color: #768296; }
.el-button { font-weight: 600; letter-spacing: .01em; }
.el-button--primary { box-shadow: 0 4px 10px rgba(35, 56, 118, .14); }
.el-button--primary:hover { box-shadow: 0 6px 14px rgba(35, 56, 118, .2); }
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper,
.el-date-editor.el-input__wrapper,
.el-date-editor.el-range-editor {
box-shadow: 0 0 0 1px #d7dfe9 inset;
background: #fff;
}
.el-input__wrapper:hover,
.el-select__wrapper:hover,
.el-date-editor.el-input__wrapper:hover,
.el-date-editor.el-range-editor:hover { box-shadow: 0 0 0 1px #afbfce inset; }
.el-card__header { padding: 18px 20px; border-bottom-color: #e5eaf1; }
.el-card__body { padding: 20px; }
.el-table { --el-table-border-color: #e4e9f0; --el-table-header-bg-color: #f7f9fc; }
.el-table th.el-table__cell { height: 46px; color: #536074; background: #f7f9fc; }
.el-table td.el-table__cell { padding: 12px 0; }
.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell { background: #f3f8f8; }
.el-pagination { padding: 14px 0 2px; justify-content: flex-end; }
.el-dialog { overflow: hidden; border-radius: 12px; box-shadow: 0 20px 60px rgba(18, 37, 63, .25); }
.el-dialog__header { margin-right: 0; padding: 20px 22px 16px; border-bottom: 1px solid #e6ebf1; }
.el-dialog__body { padding: 22px; }
.el-dialog__footer { padding: 14px 22px 20px; border-top: 1px solid #edf0f4; }
.el-tag { font-weight: 600; }
@media (max-width: 760px) {
.page-intro,
.page-heading { min-height: 0; padding: 12px 0 14px 14px; }
.page-intro::before,
.page-heading::before { top: 14px; bottom: 16px; }
.page-intro h2,
.page-heading h2 { font-size: 23px; }
.table-toolbar,
.filter-bar { min-height: 0; padding: 12px 14px; }
.el-card__body { padding: 16px; }
.el-dialog__header { padding: 18px 18px 14px; }
.el-dialog__body { padding: 18px; }
.el-dialog__footer { padding: 12px 18px 18px; }
}
+24 -2
View File
@@ -54,6 +54,9 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value) const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
const pending = ref<any[]>([]) const pending = ref<any[]>([])
const pendingPage = ref(1)
const pendingTotal = ref(0)
const pendingPageSize = 20
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const tab = ref(isManager.value ? 'pending' : 'mine') const tab = ref(isManager.value ? 'pending' : 'mine')
@@ -168,7 +171,17 @@ const recordCount = computed(() =>
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data if (isManager.value) {
const { data } = await http.get('/approvals/pending', {
params: { page: pendingPage.value, pageSize: pendingPageSize },
})
pending.value = data.items
pendingTotal.value = data.total
if (!pending.value.length && pendingPage.value > 1) {
pendingPage.value--
return await load()
}
}
if (isStudent.value) { if (isStudent.value) {
const [courses, grades, ex, df, substitutions] = await Promise.all([ const [courses, grades, ex, df, substitutions] = await Promise.all([
http.get('/approvals/my-courses'), http.get('/approvals/my-courses'),
@@ -358,7 +371,7 @@ onMounted(load)
<el-segmented <el-segmented
v-model="tab" v-model="tab"
:options="[ :options="[
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []), ...(isManager ? [{ label: `待审批 (${pendingTotal})`, value: 'pending' }] : []),
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' }, { label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
]" ]"
/> />
@@ -389,6 +402,15 @@ onMounted(load)
</div> </div>
</article> </article>
<el-empty v-if="!pending.length" description="暂无待审批" /> <el-empty v-if="!pending.length" description="暂无待审批" />
<el-pagination
v-if="pendingTotal > pendingPageSize"
v-model:current-page="pendingPage"
class="approval-pagination"
layout="prev, pager, next"
:page-size="pendingPageSize"
:total="pendingTotal"
@current-change="() => load()"
/>
</section> </section>
<section v-if="tab === 'mine'" v-loading="loading"> <section v-if="tab === 'mine'" v-loading="loading">
+102 -12
View File
@@ -77,6 +77,16 @@ const references = reactive<Record<string, any[]>>({
campuses: [], colleges: [], majors: [], buildings: [], counselors: [], campuses: [], colleges: [], majors: [], buildings: [], counselors: [],
}) })
const form = reactive<Record<string, any>>({}) const form = reactive<Record<string, any>>({})
const listFilters = reactive({
collegeId: undefined as string | undefined,
majorId: undefined as string | undefined,
grade: undefined as number | undefined,
campusId: undefined as string | undefined,
buildingId: undefined as string | undefined,
minimumCapacity: undefined as number | undefined,
teachingVenueNature: undefined as number | undefined,
isEnabled: undefined as boolean | undefined,
})
const title = computed(() => tabs.value.find((x) => x.key === active.value)?.label ?? '') const title = computed(() => tabs.value.find((x) => x.key === active.value)?.label ?? '')
const canManage = computed(() => const canManage = computed(() =>
@@ -90,12 +100,22 @@ const filteredRows = computed(() => {
.filter(Boolean).some((value) => String(value).toLowerCase().includes(q)), .filter(Boolean).some((value) => String(value).toLowerCase().includes(q)),
) )
}) })
const filteredMajors = computed(() => references.majors.filter((item: any) =>
!listFilters.collegeId || item.collegeId === listFilters.collegeId))
const filteredBuildings = computed(() => references.buildings.filter((item: any) =>
!listFilters.campusId || item.campusId === listFilters.campusId))
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const pagedRows = computed(() => filteredRows.value.slice( const total = ref(0)
(currentPage.value - 1) * pageSize.value, const serverPaged = computed(() =>
currentPage.value * pageSize.value, active.value === 'classes' || active.value === 'classrooms')
)) let keywordTimer: ReturnType<typeof setTimeout> | undefined
const pagedRows = computed(() => serverPaged.value
? filteredRows.value
: filteredRows.value.slice(
(currentPage.value - 1) * pageSize.value,
currentPage.value * pageSize.value,
))
const venueNatureOptions = [ const venueNatureOptions = [
{ value: 1, label: '普通教室' }, { value: 1, label: '普通教室' },
{ value: 2, label: '实验室' }, { value: 2, label: '实验室' },
@@ -138,11 +158,42 @@ function resetForm(row?: Row) {
} }
} }
async function load() { async function load(resetPage = true) {
loading.value = true loading.value = true
currentPage.value = 1 if (resetPage) currentPage.value = 1
try { try {
rows.value = (await http.get(`/base-data/${active.value}`)).data const response = await http.get(`/base-data/${active.value}`, {
params: serverPaged.value
? {
page: currentPage.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
...(active.value === 'classes' ? {
collegeId: listFilters.collegeId,
majorId: listFilters.majorId,
grade: listFilters.grade,
isEnabled: listFilters.isEnabled,
} : {
campusId: listFilters.campusId,
buildingId: listFilters.buildingId,
minimumCapacity: listFilters.minimumCapacity,
teachingVenueNature: listFilters.teachingVenueNature,
isEnabled: listFilters.isEnabled,
}),
}
: undefined,
})
if (serverPaged.value) {
rows.value = response.data.items
total.value = response.data.total
if (rows.value.length === 0 && currentPage.value > 1) {
currentPage.value--
await load(false)
}
} else {
rows.value = response.data
total.value = rows.value.length
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -162,6 +213,11 @@ async function loadReferences() {
async function changeTab() { async function changeTab() {
keyword.value = '' keyword.value = ''
Object.assign(listFilters, {
collegeId: undefined, majorId: undefined, grade: undefined,
campusId: undefined, buildingId: undefined, minimumCapacity: undefined,
teachingVenueNature: undefined, isEnabled: undefined,
})
await load() await load()
} }
@@ -308,7 +364,13 @@ onMounted(async () => {
watch( watch(
() => keyword.value, () => keyword.value,
() => { currentPage.value = 1 }, () => {
currentPage.value = 1
if (keywordTimer) clearTimeout(keywordTimer)
if (serverPaged.value) {
keywordTimer = setTimeout(() => { void load(false) }, 250)
}
},
) )
watch( watch(
@@ -357,7 +419,33 @@ watch(
<div class="table-toolbar"> <div class="table-toolbar">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" /> <el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" />
<el-button :icon="Refresh" @click="load">刷新</el-button> <template v-if="active === 'classes'">
<el-select v-model="listFilters.collegeId" clearable placeholder="全部学院" @change="() => { listFilters.majorId = undefined; load() }">
<el-option v-for="item in references.colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="listFilters.majorId" clearable filterable placeholder="全部专业" @change="load()">
<el-option v-for="item in filteredMajors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-input-number v-model="listFilters.grade" :min="1900" :max="2200" controls-position="right" placeholder="入学年级" @change="load()" />
</template>
<template v-else-if="active === 'classrooms'">
<el-select v-model="listFilters.campusId" clearable placeholder="全部校区" @change="() => { listFilters.buildingId = undefined; load() }">
<el-option v-for="item in references.campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="listFilters.buildingId" clearable filterable placeholder="全部教学楼" @change="load()">
<el-option v-for="item in filteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="listFilters.teachingVenueNature" clearable placeholder="场地性质" @change="load()">
<el-option v-for="item in venueNatureOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input-number v-model="listFilters.minimumCapacity" :min="1" controls-position="right" placeholder="最小容量" @change="load()" />
</template>
<el-select v-if="serverPaged" v-model="listFilters.isEnabled" clearable placeholder="全部状态" @change="load()">
<el-option label="启用" :value="true" />
<el-option label="停用" :value="false" />
</el-select>
<el-button :icon="Search" @click="load()">查询</el-button>
<el-button :icon="Refresh" @click="() => load(false)">刷新</el-button>
<template v-if="canManage"> <template v-if="canManage">
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button> <el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button> <el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
@@ -370,7 +458,7 @@ watch(
@change="handleImport" @change="handleImport"
/> />
</template> </template>
<span> {{ filteredRows.length }} </span> <span> {{ serverPaged ? total : filteredRows.length }} </span>
</div> </div>
<el-alert <el-alert
@@ -494,13 +582,15 @@ watch(
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template> <template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
</el-table> </el-table>
<el-pagination <el-pagination
v-if="filteredRows.length > pageSize" v-if="(serverPaged ? total : filteredRows.length) > pageSize"
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="pageSize" v-model:page-size="pageSize"
class="table-pagination" class="table-pagination"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
:page-sizes="[20, 50, 100]" :page-sizes="[20, 50, 100]"
:total="filteredRows.length" :total="serverPaged ? total : filteredRows.length"
@current-change="() => { if (serverPaged) void load(false) }"
@size-change="() => { if (serverPaged) void load(true) }"
/> />
</section> </section>
+1 -1
View File
@@ -438,7 +438,7 @@ onMounted(async () => {
<section class="reservation-page"> <section class="reservation-page">
<header class="page-heading"> <header class="page-heading">
<div> <div>
<p class="eyebrow">TEACHING SPACE</p> <p class="eyebrow">教学场地</p>
<h2>教室借用与预约</h2> <h2>教室借用与预约</h2>
<p>选择正式课表之外的空闲时段提交申请由申请人所在学院审核</p> <p>选择正式课表之外的空闲时段提交申请由申请人所在学院审核</p>
</div> </div>
+42 -10
View File
@@ -27,6 +27,11 @@ const sessionTask = ref<any>(null)
const sessionLoading = ref(false) const sessionLoading = ref(false)
const termId = ref<string>() const termId = ref<string>()
const tab = ref(isManager.value ? 'reviews' : 'mine') const tab = ref(isManager.value ? 'reviews' : 'mine')
const minePage = ref(1)
const mineTotal = ref(0)
const reviewPage = ref(1)
const reviewTotal = ref(0)
const pageSize = 20
const form = reactive({ const form = reactive({
teachingTaskId: undefined as string | undefined, teachingTaskId: undefined as string | undefined,
@@ -79,21 +84,30 @@ function sessionLabel(session: any) {
return `${session.week} 周 · ${session.date}${weekdayLabel(session.dayOfWeek)} · 第 ${session.startPeriod}${end}${room}` return `${session.week} 周 · ${session.date}${weekdayLabel(session.dayOfWeek)} · 第 ${session.startPeriod}${end}${room}`
} }
async function load() { async function load(resetPages = false) {
if (resetPages) {
minePage.value = 1
reviewPage.value = 1
}
loading.value = true loading.value = true
reviewLoading.value = isManager.value
try { try {
if (isTeacher.value || (!isManager.value)) { if (isTeacher.value || (!isManager.value)) {
adjustments.value = (await http.get('/course-adjustments/mine', { const response = await http.get('/course-adjustments/mine', {
params: { academicTermId: termId.value }, params: { academicTermId: termId.value, page: minePage.value, pageSize },
})).data })
adjustments.value = response.data.items
mineTotal.value = response.data.total
} }
if (isManager.value) { if (isManager.value) {
pendingReviews.value = (await http.get('/course-adjustments/pending-reviews', { const response = await http.get('/course-adjustments/pending-reviews', {
params: { academicTermId: termId.value }, params: { academicTermId: termId.value, page: reviewPage.value, pageSize },
})).data })
pendingReviews.value = response.data.items
reviewTotal.value = response.data.total
} }
} catch (e) { ElMessage.error(apiErrorMessage(e)) } } catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false } finally { loading.value = false; reviewLoading.value = false }
} }
function openCreate() { function openCreate() {
@@ -255,12 +269,12 @@ function showSub(type: string) { return type === 'Substitute' }
</div> </div>
<div style="display:flex;gap:8px;align-items:center"> <div style="display:flex;gap:8px;align-items:center">
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button> <el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="() => load()">刷新</el-button>
</div> </div>
</section> </section>
<section class="adj-toolbar"> <section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load(); loadTaskOptions()"> <el-select v-model="termId" clearable placeholder="全部学期" @change="() => { load(true); loadTaskOptions() }">
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
<el-segmented v-model="tab" :options="[ <el-segmented v-model="tab" :options="[
@@ -307,6 +321,15 @@ function showSub(type: string) { return type === 'Substitute' }
</footer> </footer>
</article> </article>
<el-empty v-if="!adjustments.length" description="暂无调停课申请" /> <el-empty v-if="!adjustments.length" description="暂无调停课申请" />
<el-pagination
v-if="mineTotal > pageSize"
v-model:current-page="minePage"
:page-size="pageSize"
:total="mineTotal"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section> </section>
<!-- Pending reviews --> <!-- Pending reviews -->
@@ -345,6 +368,15 @@ function showSub(type: string) { return type === 'Substitute' }
</footer> </footer>
</article> </article>
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" /> <el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
<el-pagination
v-if="reviewTotal > pageSize"
v-model:current-page="reviewPage"
:page-size="pageSize"
:total="reviewTotal"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section> </section>
<!-- Create dialog --> <!-- Create dialog -->
+23 -1
View File
@@ -1,10 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue' import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
import * as echarts from 'echarts' import * as echarts from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
echarts.use([
AriaComponent,
BarChart,
CanvasRenderer,
GridComponent,
LegendComponent,
LineChart,
TooltipComponent,
])
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@@ -24,6 +37,14 @@ const rows = computed(() => [
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0]) const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? []) const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
const recommendation = computed(() => {
const value = selectedRow.value?.value
if (!value) return ''
if (Number(value.passRate) < 70) return `合格率为 ${score(value.passRate)}%,建议优先复核低分段学生的平时与期末分项,并安排针对性答疑。`
if (Number(value.averageScore) < 70) return `平均分为 ${score(value.averageScore)},建议检查易失分知识点与教学进度,结合分数段安排补强。`
if (Number(value.standardDeviation) > 20) return `成绩离散度较高,建议关注不同教学班或学生群体的学习差异,核对评价标准与教学支持。`
return `平均分 ${score(value.averageScore)}、合格率 ${score(value.passRate)}%,当前表现稳定;可重点关注低分段学生的持续跟进。`
})
function score(value: unknown) { function score(value: unknown) {
return Number(value).toFixed(1) return Number(value).toFixed(1)
@@ -210,6 +231,7 @@ onBeforeUnmount(() => {
</dl> </dl>
</article> </article>
</section> </section>
<section v-if="recommendation" class="action-advice"><span>ACTION ADVICE</span><p>{{ recommendation }}</p></section>
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" /> <el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
<section v-if="rows.length" class="chart-panel"> <section v-if="rows.length" class="chart-panel">
+72 -1
View File
@@ -40,6 +40,13 @@ const editingRoundId = ref('')
const editingOfferingId = ref('') const editingOfferingId = ref('')
const roster = ref<any | null>(null) const roster = ref<any | null>(null)
const rosterLoading = ref(false) const rosterLoading = ref(false)
const rosterKeyword = ref('')
const rosterGrade = ref<number | undefined>()
const rosterMajorId = ref<string | undefined>()
const rosterClassId = ref<string | undefined>()
const rosterStudentPage = ref(1)
const rosterWaitlistPage = ref(1)
const rosterPageSize = 20
const eligibleStudents = ref<any[]>([]) const eligibleStudents = ref<any[]>([])
const eligibleTotal = ref(0) const eligibleTotal = ref(0)
const eligiblePage = ref(1) const eligiblePage = ref(1)
@@ -82,6 +89,8 @@ const selectedOfferings = computed(() =>
const previewOffering = computed(() => const previewOffering = computed(() =>
offerings.value.find((item) => item.id === previewOfferingId.value) ?? null, offerings.value.find((item) => item.id === previewOfferingId.value) ?? null,
) )
const rosterClasses = computed(() => (roster.value?.filterOptions?.classes ?? [])
.filter((item: any) => !rosterMajorId.value || item.majorId === rosterMajorId.value))
const selectableTasks = computed(() => { const selectableTasks = computed(() => {
const usedTaskIds = new Set( const usedTaskIds = new Set(
offerings.value offerings.value
@@ -540,6 +549,12 @@ async function deleteOffering(offering: any) {
} }
async function showRoster(offering: any) { async function showRoster(offering: any) {
rosterKeyword.value = ''
rosterGrade.value = undefined
rosterMajorId.value = undefined
rosterClassId.value = undefined
rosterStudentPage.value = 1
rosterWaitlistPage.value = 1
rosterDrawer.value = true rosterDrawer.value = true
await loadRoster(offering.id) await loadRoster(offering.id)
} }
@@ -548,7 +563,18 @@ async function loadRoster(offeringId: string) {
rosterLoading.value = true rosterLoading.value = true
try { try {
roster.value = ( roster.value = (
await http.get(`/course-selections/offerings/${offeringId}/roster`) await http.get(`/course-selections/offerings/${offeringId}/roster`, {
params: {
keyword: rosterKeyword.value.trim() || undefined,
grade: rosterGrade.value,
majorId: rosterMajorId.value,
administrativeClassId: rosterClassId.value,
studentPage: rosterStudentPage.value,
studentPageSize: rosterPageSize,
waitlistPage: rosterWaitlistPage.value,
waitlistPageSize: rosterPageSize,
},
})
).data ).data
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -557,6 +583,12 @@ async function loadRoster(offeringId: string) {
} }
} }
function filterRoster() {
rosterStudentPage.value = 1
rosterWaitlistPage.value = 1
if (roster.value) void loadRoster(roster.value.id)
}
async function openProxyEnrollment() { async function openProxyEnrollment() {
studentKeyword.value = '' studentKeyword.value = ''
selectedStudentIds.value = [] selectedStudentIds.value = []
@@ -1346,6 +1378,25 @@ onMounted(async () => {
:closable="false" :closable="false"
title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。" title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。"
/> />
<div class="roster-filter">
<el-input
v-model="rosterKeyword"
clearable
placeholder="按学号、姓名或行政班筛选"
@keyup.enter="filterRoster"
@clear="filterRoster"
/>
<el-select v-model="rosterGrade" clearable placeholder="全部年级" @change="filterRoster">
<el-option v-for="grade in roster.filterOptions?.grades" :key="grade" :label="`${grade} 级`" :value="grade" />
</el-select>
<el-select v-model="rosterMajorId" clearable placeholder="全部专业" @change="() => { rosterClassId = undefined; filterRoster() }">
<el-option v-for="major in roster.filterOptions?.majors" :key="major.majorId" :label="major.majorName" :value="major.majorId" />
</el-select>
<el-select v-model="rosterClassId" clearable placeholder="全部行政班" @change="filterRoster">
<el-option v-for="item in rosterClasses" :key="item.classId" :label="item.className" :value="item.classId" />
</el-select>
<el-button :icon="Search" @click="filterRoster">查询</el-button>
</div>
<el-table v-loading="rosterLoading" :data="roster.students"> <el-table v-loading="rosterLoading" :data="roster.students">
<el-table-column prop="studentNumber" label="学号" width="130" /> <el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" /> <el-table-column prop="name" label="姓名" width="90" />
@@ -1363,6 +1414,12 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无学生选课" /></template> <template #empty><el-empty description="暂无学生选课" /></template>
</el-table> </el-table>
<el-pagination
v-if="roster.studentTotal > rosterPageSize"
small background layout="total, prev, pager, next"
:current-page="rosterStudentPage" :page-size="rosterPageSize" :total="roster.studentTotal"
@current-change="(page: number) => { rosterStudentPage = page; loadRoster(roster.id) }"
/>
<section class="waitlist-panel"> <section class="waitlist-panel">
<div class="waitlist-panel-head"> <div class="waitlist-panel-head">
<div> <div>
@@ -1394,6 +1451,12 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无候补学生" /></template> <template #empty><el-empty description="暂无候补学生" /></template>
</el-table> </el-table>
<el-pagination
v-if="roster.waitlistTotal > rosterPageSize"
small background layout="total, prev, pager, next"
:current-page="rosterWaitlistPage" :page-size="rosterPageSize" :total="roster.waitlistTotal"
@current-change="(page: number) => { rosterWaitlistPage = page; loadRoster(roster.id) }"
/>
</section> </section>
</template> </template>
</el-drawer> </el-drawer>
@@ -1528,6 +1591,11 @@ onMounted(async () => {
.offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); } .offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); }
.seat-meter > small { display: block; margin-top: 5px; color: #b7791f; } .seat-meter > small { display: block; margin-top: 5px; color: #b7791f; }
.waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); } .waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
.roster-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
.roster-filter .el-input { max-width: 260px; }
.roster-filter .el-select { width: 140px; }
.roster-summary ~ :deep(.el-pagination),
.waitlist-panel :deep(.el-pagination) { justify-content: flex-end; margin-top: 10px; }
.waitlist-panel-head { .waitlist-panel-head {
display: flex; display: flex;
align-items: end; align-items: end;
@@ -1541,6 +1609,9 @@ onMounted(async () => {
.waitlist-panel-head small { color: var(--muted); text-align: right; } .waitlist-panel-head small { color: var(--muted); text-align: right; }
@media (max-width: 640px) { @media (max-width: 640px) {
.roster-filter { align-items: stretch; flex-direction: column; }
.roster-filter .el-input,
.roster-filter .el-select { width: 100%; max-width: none; }
.waitlist-panel-head { align-items: start; flex-direction: column; } .waitlist-panel-head { align-items: start; flex-direction: column; }
.waitlist-panel-head small { text-align: left; } .waitlist-panel-head small { text-align: left; }
} }
+201 -10
View File
@@ -21,6 +21,7 @@ import {
import http from '../api/http' import http from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import StudentDashboardView from './StudentDashboardView.vue' import StudentDashboardView from './StudentDashboardView.vue'
import TeacherDashboardView from './TeacherDashboardView.vue'
interface DashboardData { interface DashboardData {
audience: { audience: {
@@ -57,9 +58,24 @@ interface DashboardData {
classroomReservations: number classroomReservations: number
generalApprovals: number generalApprovals: number
} }
greeting: DashboardGreeting
generatedAt: string generatedAt: string
} }
interface DashboardGreeting {
role: string
title: string
subtitle: string
label: string
narrative: string
insights: Array<{
label: string
value: string
hint: string
tone: 'calm' | 'positive' | 'attention'
}>
}
interface DashboardLink { interface DashboardLink {
key: string key: string
label: string label: string
@@ -68,24 +84,46 @@ interface DashboardLink {
icon: Component icon: Component
} }
interface WarningRecord {
id: string
studentName: string
studentNumber: string
className: string
status: number
detail: string
}
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const roles = computed(() => auth.user?.roles ?? []) const roles = computed(() => auth.user?.roles ?? [])
const isStudentOverview = computed(() => const isStudentOverview = computed(() =>
roles.value.includes('Student') && roles.value.includes('Student') &&
!roles.value.some((role) => !roles.value.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role), ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role),
), ),
) )
const isTeacherOverview = computed(() =>
roles.value.includes('Teacher') &&
!roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role)),
)
const loading = ref(true) const loading = ref(true)
const loadError = ref('') const loadError = ref('')
const data = ref<DashboardData | null>(null) const data = ref<DashboardData | null>(null)
const counselorWarnings = ref<WarningRecord[]>([])
const now = ref(new Date()) const now = ref(new Date())
function hasRole(...allowedRoles: string[]) { function hasRole(...allowedRoles: string[]) {
return roles.value.some((role) => allowedRoles.includes(role)) return roles.value.some((role) => allowedRoles.includes(role))
} }
const isCounselorDashboard = computed(() =>
hasRole('Counselor') && !hasRole('SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'),
)
const activeCounselorWarnings = computed(() =>
counselorWarnings.value.filter((warning) => warning.status === 1),
)
function parseDateOnly(value?: string) { function parseDateOnly(value?: string) {
if (!value) return null if (!value) return null
const [year, month, day] = value.slice(0, 10).split('-').map(Number) const [year, month, day] = value.slice(0, 10).split('-').map(Number)
@@ -342,6 +380,19 @@ const readiness = computed(() => {
] ]
}) })
const operationAlerts = computed(() => {
const counts = data.value?.counts
if (!counts) return []
const alerts = [] as Array<{ level: 'critical' | 'warning'; title: string; detail: string; route: string }>
const unpublished = counts.teachingTasks - counts.publishedTeachingTasks
const unscheduled = counts.publishedTeachingTasks - counts.scheduledTeachingTasks
if (unpublished > 0) alerts.push({ level: 'warning', title: '教学任务尚未发布', detail: `${unpublished} 个教学班尚未发布,后续排课与选课无法推进。`, route: '/teaching-tasks' })
if (unscheduled > 0) alerts.push({ level: 'critical', title: '课表覆盖存在缺口', detail: `${unscheduled} 个已发布教学班尚未进入课表。`, route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable' })
if (counts.submittedGradeSheets > 0) alerts.push({ level: 'warning', title: '成绩审核等待处理', detail: `${counts.submittedGradeSheets} 张成绩登记册已提交,等待审核或发布。`, route: '/grades' })
if (counts.openCourseSelectionRounds > 0 && counts.courseEnrollments === 0) alerts.push({ level: 'warning', title: '开放选课尚无有效记录', detail: `${counts.openCourseSelectionRounds} 个选课批次开放中,但当前未发现有效选课。`, route: '/course-selections' })
return alerts
})
const primaryActionRoute = computed(() => const primaryActionRoute = computed(() =>
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications', todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
) )
@@ -351,6 +402,11 @@ async function loadDashboard() {
loadError.value = '' loadError.value = ''
try { try {
data.value = (await http.get<DashboardData>('/dashboard')).data data.value = (await http.get<DashboardData>('/dashboard')).data
if (isCounselorDashboard.value) {
counselorWarnings.value = (await http.get<WarningRecord[]>('/warnings/records', {
params: { academicTermId: data.value.currentTerm?.id },
})).data
}
} catch { } catch {
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。' loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
} finally { } finally {
@@ -359,7 +415,7 @@ async function loadDashboard() {
} }
onMounted(async () => { onMounted(async () => {
if (isStudentOverview.value) { if (isStudentOverview.value || isTeacherOverview.value) {
loading.value = false loading.value = false
return return
} }
@@ -369,6 +425,7 @@ onMounted(async () => {
<template> <template>
<StudentDashboardView v-if="isStudentOverview" /> <StudentDashboardView v-if="isStudentOverview" />
<TeacherDashboardView v-else-if="isTeacherOverview" />
<div v-else v-loading="loading" class="admin-dashboard"> <div v-else v-loading="loading" class="admin-dashboard">
<el-result <el-result
@@ -389,9 +446,9 @@ onMounted(async () => {
<span>{{ data.audience.scopeName }}</span> <span>{{ data.audience.scopeName }}</span>
<i>数据范围</i> <i>数据范围</i>
</div> </div>
<p class="overview-eyebrow">ACADEMIC OPERATIONS</p> <p class="overview-eyebrow">教务工作概览</p>
<h1>{{ data.audience.title }}</h1> <h1>{{ data.greeting.title }}</h1>
<p class="overview-description">{{ data.audience.description }}</p> <p class="overview-description">{{ data.greeting.subtitle }}</p>
</div> </div>
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)"> <button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
@@ -436,6 +493,23 @@ onMounted(async () => {
</div> </div>
</section> </section>
<section class="greeting-insights" :aria-label="data.greeting.label">
<span class="greeting-insights-label">{{ data.greeting.label }}</span>
<div>
<article
v-for="insight in data.greeting.insights"
:key="insight.label"
:class="`greeting-insight ${insight.tone}`"
>
<span>{{ insight.label }}</span>
<strong>{{ insight.value }}</strong>
<small>{{ insight.hint }}</small>
</article>
</div>
</section>
<p class="greeting-narrative">{{ data.greeting.narrative }}</p>
<section class="overview-metrics" aria-label="关键教学数据"> <section class="overview-metrics" aria-label="关键教学数据">
<button <button
v-for="metric in adminMetrics" v-for="metric in adminMetrics"
@@ -450,11 +524,42 @@ onMounted(async () => {
</button> </button>
</section> </section>
<section class="operation-alerts">
<header><div><span>运行提醒</span><h2>需要关注的事项</h2></div><small>{{ operationAlerts.length ? `${operationAlerts.length} 个事项需要关注` : '当前运行平稳' }}</small></header>
<div v-if="operationAlerts.length" class="operation-alert-list">
<button v-for="alert in operationAlerts" :key="alert.title" :class="alert.level" @click="router.push(alert.route)"><b>{{ alert.title }}</b><span>{{ alert.detail }}</span><i>立即处理 </i></button>
</div>
<p v-else>教学任务课表与成绩流程当前衔接正常</p>
</section>
<section class="dashboard-work-grid"> <section class="dashboard-work-grid">
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
<header class="panel-heading">
<div>
<span class="panel-index">学业预警</span>
<h2>需重点关注的学生</h2>
<p>{{ activeCounselorWarnings.length ? `当前有 ${activeCounselorWarnings.length} 条生效预警,按最新记录展示。` : '当前没有生效中的学业预警。' }}</p>
</div>
<button type="button" class="radar-link" @click="router.push('/warnings')">完整预警 </button>
</header>
<div v-if="activeCounselorWarnings.length" class="counselor-risk-list">
<button v-for="warning in activeCounselorWarnings.slice(0, 4)" :key="warning.id" type="button" @click="router.push('/warnings')">
<span>{{ warning.className }}</span>
<b>{{ warning.studentName }}</b>
<small>{{ warning.detail }}</small>
<i>查看 </i>
</button>
</div>
<div v-else class="todo-empty">
<el-icon><Checked /></el-icon>
<div><b>当前没有重点关注学生</b><span>新的学业预警会自动出现在这里</span></div>
</div>
</article>
<article class="dashboard-panel todo-panel"> <article class="dashboard-panel todo-panel">
<header class="panel-heading"> <header class="panel-heading">
<div> <div>
<span class="panel-index">01 / ACTION</span> <span class="panel-index">待办事项</span>
<h2>需要处理</h2> <h2>需要处理</h2>
<p>只显示当前角色和数据范围内可处理的事项</p> <p>只显示当前角色和数据范围内可处理的事项</p>
</div> </div>
@@ -490,7 +595,7 @@ onMounted(async () => {
<article class="dashboard-panel quick-panel"> <article class="dashboard-panel quick-panel">
<header class="panel-heading"> <header class="panel-heading">
<div> <div>
<span class="panel-index">02 / SHORTCUTS</span> <span class="panel-index">常用入口</span>
<h2>常用工作</h2> <h2>常用工作</h2>
<p>按当前角色整理的高频业务入口</p> <p>按当前角色整理的高频业务入口</p>
</div> </div>
@@ -517,9 +622,9 @@ onMounted(async () => {
<section class="dashboard-panel readiness-panel"> <section class="dashboard-panel readiness-panel">
<header class="panel-heading"> <header class="panel-heading">
<div> <div>
<span class="panel-index">03 / TERM READINESS</span> <span class="panel-index">学期进度</span>
<h2>本学期运行状态</h2> <h2>本学期教学运行</h2>
<p>从任务发布到课表落地成绩归档快速发现业务断点</p> <p>关注教学任务课表与成绩流程的完成情况</p>
</div> </div>
<span class="refresh-time">数据更新于 {{ new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}</span> <span class="refresh-time">数据更新于 {{ new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}</span>
</header> </header>
@@ -586,6 +691,62 @@ onMounted(async () => {
background-size: 32px 32px, 32px 32px, auto; background-size: 32px 32px, 32px 32px, auto;
} }
.greeting-insights {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 18px;
align-items: stretch;
padding: 17px 21px;
border: 1px solid #dce6eb;
background: #f7faf9;
}
.greeting-insights-label {
align-self: center;
color: var(--dashboard-teal);
font: 700 10px/1.5 Consolas, monospace;
letter-spacing: .12em;
}
.greeting-insights > div {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.greeting-insight {
min-width: 0;
padding-left: 13px;
display: grid;
gap: 3px;
border-left: 2px solid #a9bcc6;
}
.greeting-insight.positive { border-color: var(--dashboard-teal); }
.greeting-insight.attention { border-color: var(--dashboard-amber); }
.greeting-insight span { color: #687788; font-size: 11px; }
.greeting-insight strong { color: var(--dashboard-navy); font-size: 20px; }
.greeting-insight small { overflow: hidden; color: #84909d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.greeting-narrative {
margin: -7px 0 0;
padding: 0 3px;
color: #526078;
font-size: 12px;
line-height: 1.7;
}
.operation-alerts { padding: 21px 24px; border: 1px solid #e5e8ed; background: #fff; }
.operation-alerts header { display: flex; justify-content: space-between; gap: 16px; align-items: end; }
.operation-alerts header span { color: var(--dashboard-teal); font: 700 10px/1 Consolas,monospace; letter-spacing: .12em; }
.operation-alerts h2 { margin: 7px 0 0; color: var(--dashboard-navy); font-size: 19px; }
.operation-alerts header small { color: #697789; font-size: 11px; }
.operation-alert-list { margin-top: 16px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
.operation-alert-list button { padding: 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #e8e1d3; border-left: 3px solid var(--dashboard-amber); background: #fffcf6; }
.operation-alert-list button.critical { border-left-color: #ba5145; background: #fff9f8; }
.operation-alert-list b { color: #334254; font-size: 13px; }.operation-alert-list span { color: #6d7888; font-size: 11px; line-height: 1.5; }.operation-alert-list i { color: #8b6d3a; font-size: 11px; font-style: normal; }
.operation-alerts > p { margin: 15px 0 0; color: #627184; font-size: 12px; }
.overview-hero::after { .overview-hero::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -866,6 +1027,28 @@ onMounted(async () => {
background: var(--dashboard-paper); background: var(--dashboard-paper);
} }
.counselor-radar { border-top: 3px solid var(--dashboard-amber); }
.radar-link { padding: 5px 0; border: 0; color: #806136; background: transparent; font-size: 12px; white-space: nowrap; }
.radar-link:hover { color: var(--dashboard-blue); }
.counselor-risk-list { display: grid; }
.counselor-risk-list button {
padding: 13px 0;
display: grid;
grid-template-columns: 76px 68px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
text-align: left;
border: 0;
border-bottom: 1px solid #eee8dc;
background: transparent;
}
.counselor-risk-list button:last-child { border-bottom: 0; }
.counselor-risk-list button:hover b { color: var(--dashboard-blue); }
.counselor-risk-list span { color: #987337; font: 700 9px/1.3 Consolas, monospace; letter-spacing: .04em; }
.counselor-risk-list b { color: #3a4758; font-size: 13px; }
.counselor-risk-list small { overflow: hidden; color: #6c7788; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.counselor-risk-list i { color: #8d7042; font-size: 11px; font-style: normal; white-space: nowrap; }
.todo-panel, .todo-panel,
.quick-panel, .quick-panel,
.readiness-panel { .readiness-panel {
@@ -1155,6 +1338,7 @@ button:focus-visible {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
.dashboard-work-grid { .dashboard-work-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1170,6 +1354,13 @@ button:focus-visible {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.operation-alerts { padding: 18px; }
.operation-alert-list { grid-template-columns: 1fr; }
.greeting-insights { padding: 15px; }
.greeting-insights > div { grid-template-columns: 1fr; }
.counselor-risk-list button { grid-template-columns: 1fr auto; }
.counselor-risk-list span { grid-column: 1 / -1; }
.counselor-risk-list small { white-space: normal; }
.admin-dashboard { gap: 10px; } .admin-dashboard { gap: 10px; }
.overview-hero { .overview-hero {
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { DataAnalysis, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
interface Status {
enabled: boolean
syncIntervalSeconds: number
sourceLookbackDays: number
batchSize: number
database: string
}
const status = ref<Status>()
const attendance = ref<any[]>([])
const grades = ref<any[]>([])
const audit = ref<any[]>([])
const loading = ref(false)
const range = ref<[Date, Date]>([
new Date(Date.now() - 29 * 24 * 60 * 60 * 1000),
new Date(),
])
const attendanceTotals = computed(() => attendance.value.reduce((total, row) => total + Number(row.total ?? 0), 0))
const absentTotals = computed(() => attendance.value.reduce((total, row) => total + Number(row.absent ?? 0), 0))
const auditTotals = computed(() => audit.value.reduce((total, row) => total + Number(row.total ?? 0), 0))
function dateOnly(value: Date) {
const offset = value.getTimezoneOffset() * 60_000
return new Date(value.getTime() - offset).toISOString().slice(0, 10)
}
async function load() {
loading.value = true
try {
const [statusResponse, overviewResponse] = await Promise.all([
http.get<Status>('/clickhouse-analytics/status'),
http.get('/clickhouse-analytics/overview', {
params: { from: dateOnly(range.value[0]), to: dateOnly(range.value[1]) },
}),
])
status.value = statusResponse.data
attendance.value = overviewResponse.data.attendance ?? []
grades.value = overviewResponse.data.grades ?? []
audit.value = overviewResponse.data.audit ?? []
} catch (error) {
ElMessage.error(apiErrorMessage(error) || '加载运行数据分析失败。')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<main v-loading="loading" class="event-analytics page-stack">
<section class="page-intro">
<div>
<span class="eyebrow">CLICKHOUSE READ MODEL</span>
<h2>运行数据分析</h2>
<p>考勤教学班成绩与访问审计的只读聚合不会影响教务业务写入</p>
</div>
<div class="actions">
<el-date-picker v-model="range" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" />
<el-button type="primary" :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<el-alert v-if="status && !status.enabled" type="warning" :closable="false" show-icon title="ClickHouse 分析未启用">
请在服务配置中启用 ClickHouseAnalytics 后再查看分析数据
</el-alert>
<template v-else>
<section class="metrics">
<article><el-icon><DataAnalysis /></el-icon><span>考勤记录</span><b>{{ attendanceTotals.toLocaleString() }}</b></article>
<article><el-icon><DataAnalysis /></el-icon><span>缺勤记录</span><b>{{ absentTotals.toLocaleString() }}</b></article>
<article v-if="audit.length"><el-icon><DataAnalysis /></el-icon><span>访问审计</span><b>{{ auditTotals.toLocaleString() }}</b></article>
</section>
<section class="analysis-grid">
<el-card shadow="never">
<template #header>每日考勤</template>
<el-table :data="attendance" size="small" empty-text="所选范围暂无考勤投影数据">
<el-table-column prop="attendanceDate" label="日期" min-width="110" />
<el-table-column prop="total" label="总人次" align="right" />
<el-table-column prop="present" label="到课" align="right" />
<el-table-column prop="absent" label="缺勤" align="right" />
<el-table-column prop="late" label="迟到" align="right" />
</el-table>
</el-card>
<el-card shadow="never">
<template #header>学期成绩趋势</template>
<el-table :data="grades" size="small" empty-text="暂无已计算的教学班成绩统计">
<el-table-column prop="academicTermName" label="学期" min-width="130" />
<el-table-column prop="studentCount" label="学生数" align="right" />
<el-table-column prop="averageScore" label="加权平均分" align="right" />
<el-table-column prop="passRate" label="通过率" align="right">
<template #default="{ row }">{{ (Number(row.passRate) * 100).toFixed(1) }}%</template>
</el-table-column>
</el-table>
</el-card>
</section>
<el-card v-if="audit.length" shadow="never">
<template #header>访问审计仅全校数据范围</template>
<el-table :data="audit" size="small">
<el-table-column prop="date" label="日期" min-width="110" />
<el-table-column prop="total" label="操作量" align="right" />
<el-table-column prop="failed" label="异常响应" align="right" />
</el-table>
</el-card>
</template>
</main>
</template>
<style scoped>
.event-analytics { --ink: #18324f; --line: #dce3ec; }
.page-intro { display: flex; justify-content: space-between; align-items: end; gap: 18px; }
.eyebrow { color: #3d75aa; font-size: 12px; letter-spacing: .12em; font-weight: 700; }
h2 { margin: 5px 0; color: var(--ink); } p { margin: 0; color: #6b7b8d; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.metrics, .analysis-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.analysis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metrics article { padding: 20px; border: 1px solid var(--line); background: #fff; border-radius: 8px; display: grid; grid-template-columns: 26px 1fr; gap: 4px 9px; }
.metrics .el-icon { color: #3574a8; grid-row: span 2; font-size: 21px; } .metrics span { color: #657689; font-size: 13px; } .metrics b { color: var(--ink); font-size: 24px; }
@media (max-width: 760px) { .page-intro { align-items: start; flex-direction: column; } .metrics, .analysis-grid { grid-template-columns: 1fr; } }
</style>
+117 -53
View File
@@ -2,14 +2,17 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
Check, Check,
Download,
EditPen, EditPen,
Plus, Plus,
Promotion, Promotion,
Refresh, Refresh,
Setting, Setting,
Upload,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { import {
academicTermLabel, academicTermLabel,
@@ -33,8 +36,8 @@ const collegeId = ref('')
const projectKeyword = ref('') const projectKeyword = ref('')
const projectPage = ref(1) const projectPage = ref(1)
const projectPageSize = ref(20) const projectPageSize = ref(20)
const projectTotal = ref(0) const courseTotal = ref(0)
const projects = ref<any[]>([]) const courses = ref<any[]>([])
const studentResults = ref<any[]>([]) const studentResults = ref<any[]>([])
const detailDrawer = ref(false) const detailDrawer = ref(false)
const detail = ref<any | null>(null) const detail = ref<any | null>(null)
@@ -44,6 +47,7 @@ const recordKeyword = ref('')
const schemeDialog = ref(false) const schemeDialog = ref(false)
const editingScheme = ref(false) const editingScheme = ref(false)
const schemeProject = ref<any | null>(null) const schemeProject = ref<any | null>(null)
const importFileInput = ref<HTMLInputElement>()
const schemeForm = reactive({ const schemeForm = reactive({
contributionWeight: 1, contributionWeight: 1,
@@ -86,6 +90,18 @@ const modeLabels: Record<string, string> = {
Centralized: '集中安排', Centralized: '集中安排',
SelfScheduled: '自主预约', SelfScheduled: '自主预约',
} }
const weekdayLabels = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
function projectScheduleLabel(project: any) {
const entry = project.scheduleEntry
if (!entry) return modeLabels[project.arrangementMode] ?? '待安排'
const week = project.scheduleWeek ?? '—'
const endPeriod = entry.startPeriod + entry.periodCount - 1
const periods = entry.periodCount === 1
? `${entry.startPeriod}`
: `${entry.startPeriod}${endPeriod}`
return `${week} 周 · ${weekdayLabels[entry.dayOfWeek] ?? `${entry.dayOfWeek}`} · ${periods}`
}
const schemeWeightTotal = computed(() => const schemeWeightTotal = computed(() =>
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0), schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
@@ -196,8 +212,8 @@ async function load() {
pageSize: projectPageSize.value, pageSize: projectPageSize.value,
}, },
})).data })).data
projects.value = data.items courses.value = data.items
projectTotal.value = data.total courseTotal.value = data.total
projectPage.value = data.page projectPage.value = data.page
projectPageSize.value = data.pageSize projectPageSize.value = data.pageSize
} }
@@ -290,6 +306,38 @@ async function saveRecords() {
} }
} }
function downloadTemplate() {
if (!detail.value) return
downloadApiFile(
`/experiment-grades/sheets/${detail.value.id}/template`,
'实验成绩导入模板.xlsx',
)
}
function chooseImportFile() {
importFileInput.value?.click()
}
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !detail.value) return
saving.value = true
try {
const result = await importExcel(
`/experiment-grades/sheets/${detail.value.id}/import`,
file,
)
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条实验成绩记录`)
await loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
input.value = ''
saving.value = false
}
}
async function syncParticipants() { async function syncParticipants() {
if (!detail.value) return if (!detail.value) return
try { try {
@@ -528,45 +576,52 @@ onMounted(async () => {
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button> <el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
</section> </section>
<section v-loading="loading" class="grade-project-list"> <section v-loading="loading" class="grade-course-list">
<article v-for="project in projects" :key="project.id" class="grade-project-card"> <article v-for="course in courses" :key="course.teachingTaskId" class="grade-course-card">
<div class="project-mark"> <header class="grade-course-head">
<span>{{ project.code }}</span> <div>
<b>{{ modeLabels[project.arrangementMode] }}</b> <span>{{ course.termName }} · {{ course.taskNumber }}</span>
</div> <h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
<div class="project-summary"> <small>{{ course.teacherNames.join('、') || '教师待定' }} · {{ course.collegeName }}</small>
<h3>{{ project.name }}</h3>
<p>{{ project.courseCode }} · {{ project.courseName }}</p>
<small>{{ project.taskNumber }} · {{ project.termName }} · {{ project.teacherNames.join('、') || '教师待定' }}</small>
</div>
<template v-if="project.sheet">
<div class="sheet-progress">
<el-tag :type="statusTypes[project.sheet.status] as any" effect="plain">
{{ statusLabels[project.sheet.status] }}
</el-tag>
<span><b>{{ project.sheet.scoredCount }}</b> / {{ project.sheet.studentCount }} 已计分</span>
<span>评分项 {{ project.sheet.itemCount }} · 合格线 {{ project.sheet.passScore }}</span>
</div> </div>
<el-button type="primary" plain :icon="EditPen" @click="openDetail(project)"> <div class="course-rollup">
打开成绩单 <b>{{ course.projectCount }}</b><span>个实验项目</span>
</el-button> <small v-if="course.sheetCount">{{ course.sheetCount }} 份成绩单 · {{ course.scoredCount }}/{{ course.studentCount }} 已计分</small>
</template> <small v-else>尚未建立成绩单</small>
<template v-else>
<div class="sheet-empty">
<b>尚未建立实验成绩单</b>
<span>建立时会按集中名单或有效预约生成评分对象</span>
</div> </div>
<el-button type="primary" :icon="Plus" @click="openCreate(project)"> </header>
建立成绩单 <el-table :data="course.projects" size="small" class="course-project-table">
</el-button> <el-table-column label="实验项目" min-width="260">
</template> <template #default="{ row }">
<div class="course-project-name">
<b>{{ row.code }} · {{ row.name }}</b>
<small>{{ projectScheduleLabel(row) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="成绩进度" min-width="190">
<template #default="{ row }">
<div v-if="row.sheet" class="course-project-progress">
<el-tag :type="statusTypes[row.sheet.status] as any" size="small" effect="plain">{{ statusLabels[row.sheet.status] }}</el-tag>
<span>{{ row.sheet.scoredCount }}/{{ row.sheet.studentCount }} 已计分 · {{ row.sheet.itemCount }} </span>
</div>
<span v-else class="table-muted">尚未建立成绩单</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="right">
<template #default="{ row }">
<el-button v-if="row.sheet" type="primary" text :icon="EditPen" @click="openDetail(row)">打开成绩单</el-button>
<el-button v-else type="primary" text :icon="Plus" @click="openCreate(row)">建立成绩单</el-button>
</template>
</el-table-column>
</el-table>
</article> </article>
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" /> <el-empty v-if="!loading && !courses.length" description="当前筛选条件下没有可评分实验课程" />
</section> </section>
<el-pagination <el-pagination
v-model:current-page="projectPage" v-model:current-page="projectPage"
v-model:page-size="projectPageSize" v-model:page-size="projectPageSize"
:total="projectTotal" :total="courseTotal"
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
@current-change="load" @current-change="load"
@@ -637,6 +692,7 @@ onMounted(async () => {
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span> <span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
<h3>{{ detail.projectName }}</h3> <h3>{{ detail.projectName }}</h3>
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p> <p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
<p class="project-schedule">{{ projectScheduleLabel(detail) }}</p>
</div> </div>
<div class="workbench-status"> <div class="workbench-status">
<el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag> <el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag>
@@ -777,12 +833,16 @@ onMounted(async () => {
@size-change="recordPage = 1; loadDetail()" @size-change="recordPage = 1; loadDetail()"
/> />
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
<footer class="workbench-actions"> <footer class="workbench-actions">
<div> <div>
<b v-if="detail.reviewComment">审核意见{{ detail.reviewComment }}</b> <b v-if="detail.reviewComment">审核意见{{ detail.reviewComment }}</b>
<span>开课学院{{ detail.courseCollegeName }}</span> <span>开课学院{{ detail.courseCollegeName }}</span>
</div> </div>
<div> <div>
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" :loading="saving" @click="chooseImportFile">Excel 导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button> <el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button> <el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button> <el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button>
@@ -810,18 +870,24 @@ onMounted(async () => {
.grade-toolbar .el-select { width: 220px; } .grade-toolbar .el-select { width: 220px; }
.grade-toolbar .el-input { width: min(320px, 100%); } .grade-toolbar .el-input { width: min(320px, 100%); }
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; } .grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
.grade-project-list { display: grid; gap: 12px; min-height: 180px; } .grade-course-list { display: grid; gap: 10px; min-height: 180px; }
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); } .grade-course-card { overflow: hidden; border: 1px solid #d9e4e8; border-left: 4px solid var(--grade-blue); background: #fff; box-shadow: 0 4px 13px rgb(30 68 86 / 4%); }
.project-mark { display: grid; gap: 7px; } .grade-course-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: #f3f7f8; }
.project-mark span { overflow: hidden; color: #607b88; font: 700 10px/1.3 Consolas, monospace; text-overflow: ellipsis; } .grade-course-head > div:first-child { min-width: 0; }
.project-mark b { color: var(--grade-blue); font-size: 12px; } .grade-course-head span { color: #607b88; font: 700 10px/1.3 Consolas, monospace; }
.project-summary { min-width: 0; } .grade-course-head h3 { margin: 3px 0; color: var(--grade-ink); font-size: 15px; }
.project-summary h3 { margin: 0 0 5px; color: var(--grade-ink); font-size: 17px; } .grade-course-head small { display: block; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.project-summary p { margin: 0 0 4px; color: #365f73; font-size: 13px; } .course-rollup { display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 5px; flex: none; text-align: right; }
.project-summary small { display: block; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } .course-rollup b { color: var(--grade-teal); font: 750 22px/1 Consolas, monospace; }
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; } .course-rollup > span { color: #55707b; font: 600 11px/1.2 inherit; }
.sheet-progress b { color: var(--grade-teal); } .course-rollup small { grid-column: 1 / -1; margin-top: 3px; color: #70858d; font-size: 10px; }
.sheet-empty b { color: #5d7480; } .course-project-table { width: 100%; --el-table-border-color: #e0e8ea; }
.course-project-table :deep(td.el-table__cell), .course-project-table :deep(th.el-table__cell) { padding: 6px 0; }
.course-project-table :deep(th.el-table__cell) { color: #71858d; font-size: 10px; }
.course-project-name { display: grid; gap: 2px; }
.course-project-name b { color: #294d5c; font-size: 12px; }
.course-project-name small, .course-project-progress { color: #6d838b; font-size: 10px; }
.course-project-progress { display: flex; align-items: center; gap: 7px; }
.student-course-results { display: grid; gap: 12px; min-height: 180px; } .student-course-results { display: grid; gap: 12px; min-height: 180px; }
.student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; } .student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
.student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; } .student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; }
@@ -902,9 +968,7 @@ onMounted(async () => {
.workbench-actions b { color: #a24f36; font-size: 12px; } .workbench-actions b { color: #a24f36; font-size: 12px; }
.workbench-actions span { color: var(--muted); font-size: 12px; } .workbench-actions span { color: var(--muted); font-size: 12px; }
@media (max-width: 980px) { @media (max-width: 980px) {
.grade-project-card { grid-template-columns: 110px minmax(0, 1fr) auto; } .grade-course-head { align-items: flex-start; }
.sheet-progress, .sheet-empty { grid-column: 2; }
.grade-project-card > .el-button { grid-column: 3; grid-row: 1 / span 2; }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.assessment-flow { grid-template-columns: 1fr; } .assessment-flow { grid-template-columns: 1fr; }
@@ -912,8 +976,8 @@ onMounted(async () => {
.grade-toolbar { align-items: stretch; flex-direction: column; } .grade-toolbar { align-items: stretch; flex-direction: column; }
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; } .grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
.grade-toolbar > span { margin-left: 0; } .grade-toolbar > span { margin-left: 0; }
.grade-project-card { grid-template-columns: 1fr; } .grade-course-head { align-items: stretch; flex-direction: column; }
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; } .course-rollup { align-self: flex-start; text-align: left; }
.student-result-grid { grid-template-columns: 1fr; } .student-result-grid { grid-template-columns: 1fr; }
.student-result-card > header { align-items: stretch; flex-direction: column; } .student-result-card > header { align-items: stretch; flex-direction: column; }
.score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; } .score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; }
+150 -42
View File
@@ -25,6 +25,7 @@ const options = reactive({
colleges: [] as any[], colleges: [] as any[],
scheduleEntries: [] as any[], scheduleEntries: [] as any[],
classrooms: [] as any[], classrooms: [] as any[],
teachers: [] as any[],
periods: [] as any[], periods: [] as any[],
}) })
const modeFilter = ref('') const modeFilter = ref('')
@@ -37,6 +38,7 @@ const taskKeyword = ref('')
const projectDialog = ref(false) const projectDialog = ref(false)
const editingProjectId = ref('') const editingProjectId = ref('')
const correctingPublishedProject = ref(false)
const projectForm = reactive({ const projectForm = reactive({
teachingTaskIds: [] as string[], teachingTaskIds: [] as string[],
scheduleEntryId: '', scheduleEntryId: '',
@@ -48,6 +50,7 @@ const projectForm = reactive({
description: '', description: '',
requirements: '', requirements: '',
dates: [] as string[], dates: [] as string[],
selectionTimes: [] as string[],
}) })
const batchRenameDialog = ref(false) const batchRenameDialog = ref(false)
@@ -63,6 +66,7 @@ const sessionForm = reactive({
periodCount: 2, periodCount: 2,
capacity: 30, capacity: 30,
notes: '', notes: '',
instructorTeacherIds: [] as string[],
}) })
const batchSessionDialog = ref(false) const batchSessionDialog = ref(false)
@@ -158,6 +162,7 @@ const statusLabels: Record<string, string> = {
function resetProjectForm() { function resetProjectForm() {
editingProjectId.value = '' editingProjectId.value = ''
correctingPublishedProject.value = false
Object.assign(projectForm, { Object.assign(projectForm, {
teachingTaskIds: [], teachingTaskIds: [],
scheduleEntryId: '', scheduleEntryId: '',
@@ -169,6 +174,7 @@ function resetProjectForm() {
description: '', description: '',
requirements: '', requirements: '',
dates: [], dates: [],
selectionTimes: [],
}) })
} }
@@ -197,6 +203,7 @@ function openCreateProject() {
function openEditProject(project: any) { function openEditProject(project: any) {
editingProjectId.value = project.id editingProjectId.value = project.id
correctingPublishedProject.value = false
Object.assign(projectForm, { Object.assign(projectForm, {
teachingTaskIds: [project.teachingTaskId], teachingTaskIds: [project.teachingTaskId],
scheduleEntryId: project.scheduleEntryId ?? '', scheduleEntryId: project.scheduleEntryId ?? '',
@@ -208,28 +215,36 @@ function openEditProject(project: any) {
description: project.description ?? '', description: project.description ?? '',
requirements: project.requirements ?? '', requirements: project.requirements ?? '',
dates: [project.startDate, project.endDate], dates: [project.startDate, project.endDate],
selectionTimes: project.selectionStartsAt && project.selectionEndsAt
? [project.selectionStartsAt, project.selectionEndsAt] : [],
}) })
projectDialog.value = true projectDialog.value = true
} }
function openCorrectPublishedProject(project: any) {
openEditProject(project)
correctingPublishedProject.value = true
}
function projectNameLines(value: string) { function projectNameLines(value: string) {
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean) return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
} }
async function saveProject() { async function saveProject() {
const names = projectNameLines(projectForm.projectNames) const names = projectNameLines(projectForm.projectNames)
const hasValidName = editingProjectId.value const hasValidName = correctingPublishedProject.value || editingProjectId.value
? !!projectForm.name.trim() ? !!projectForm.name.trim()
: projectForm.nameMode === 'InputNames' : projectForm.nameMode === 'InputNames'
? names.length > 0 ? names.length > 0
: !!projectForm.name.trim() : !!projectForm.name.trim()
if (!projectForm.teachingTaskIds.length || if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) || (editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
!projectForm.code.trim() !projectForm.code.trim()
|| !hasValidName || projectForm.dates.length !== 2) { || !hasValidName || projectForm.dates.length !== 2
|| (projectForm.arrangementMode === 'SelfScheduled' && projectForm.selectionTimes.length !== 2)) {
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
? '请选择课表实验课,并填写项目编码、名称和开放日期' ? '请选择课表实验课,并填写项目编码、名称和开放日期'
: '请填写教学任务、项目编码、名称开放日期') : '请填写教学任务、项目编码、名称开放日期和选课时间')
return return
} }
const payload = { const payload = {
@@ -242,9 +257,20 @@ async function saveProject() {
requirements: projectForm.requirements || null, requirements: projectForm.requirements || null,
startDate: projectForm.dates[0], startDate: projectForm.dates[0],
endDate: projectForm.dates[1], endDate: projectForm.dates[1],
selectionStartsAt: projectForm.arrangementMode === 'SelfScheduled'
? projectForm.selectionTimes[0] || null : null,
selectionEndsAt: projectForm.arrangementMode === 'SelfScheduled'
? projectForm.selectionTimes[1] || null : null,
} }
try { try {
if (editingProjectId.value) { if (correctingPublishedProject.value) {
await http.put(`/experiments/${editingProjectId.value}/published-details`, {
name: projectForm.name,
description: projectForm.description || null,
requirements: projectForm.requirements || null,
})
ElMessage.success('已修正已发布项目的教学内容,并通知学生查看')
} else if (editingProjectId.value) {
await http.put(`/experiments/${editingProjectId.value}`, payload) await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新') ElMessage.success('实验项目已更新')
} else { } else {
@@ -273,24 +299,47 @@ function openBatchSession() {
} }
function syncBatchSessionRows() { function syncBatchSessionRows() {
const existing = new Map(batchSessionRows.value.map((row) => [row.projectId, row])) const existing = new Map<string, any[]>()
batchSessionRows.value = batchSessionProjectIds.value.map((projectId) => { batchSessionRows.value.forEach((row) => {
const current = existing.get(projectId) const rows = existing.get(row.projectId) ?? []
if (current) return current rows.push(row)
const project = projects.value.find((item) => item.id === projectId) existing.set(row.projectId, rows)
const firstPeriod = options.periods.find((item) =>
item.academicTermId === project?.academicTermId,
)
return {
projectId,
classroomId: '',
sessionDate: project?.startDate ?? '',
startPeriod: firstPeriod?.periodNumber,
periodCount: 2,
capacity: 30,
notes: '',
}
}) })
batchSessionRows.value = batchSessionProjectIds.value.flatMap((projectId) => {
const current = existing.get(projectId)
if (current?.length) return current
return [newBatchSessionRow(projectId)]
})
}
function newBatchSessionRow(projectId: string, source?: any) {
const project = projects.value.find((item) => item.id === projectId)
const firstPeriod = options.periods.find((item) =>
item.academicTermId === project?.academicTermId,
)
return {
rowKey: crypto.randomUUID(),
projectId,
classroomId: source?.classroomId ?? '',
sessionDate: source?.sessionDate ?? project?.startDate ?? '',
startPeriod: source?.startPeriod ?? firstPeriod?.periodNumber,
periodCount: source?.periodCount ?? 2,
capacity: source?.capacity ?? 30,
notes: '',
instructorTeacherIds: source?.instructorTeacherIds ? [...source.instructorTeacherIds] : [],
}
}
function addBatchSessionRow(projectId: string, source: any) {
batchSessionRows.value.push(newBatchSessionRow(projectId, source))
}
function removeBatchSessionRow(row: any) {
const sameProjectRows = batchSessionRows.value.filter((item) => item.projectId === row.projectId)
if (sameProjectRows.length === 1) {
batchSessionProjectIds.value = batchSessionProjectIds.value.filter((id) => id !== row.projectId)
}
batchSessionRows.value = batchSessionRows.value.filter((item) => item.rowKey !== row.rowKey)
} }
function applyFirstBatchTime() { function applyFirstBatchTime() {
@@ -321,9 +370,9 @@ async function saveBatchSessions() {
return return
} }
if (batchSessionRows.value.some((row) => if (batchSessionRows.value.some((row) =>
!row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount, !row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount || !row.instructorTeacherIds?.length,
)) { )) {
ElMessage.warning('请完整填写每个项目的日期、节次、实验室和容量') ElMessage.warning('请完整填写每个项目的日期、节次、实验室、指导老师和容量')
return return
} }
try { try {
@@ -350,14 +399,15 @@ function openSession(project: any) {
periodCount: 2, periodCount: 2,
capacity: 30, capacity: 30,
notes: '', notes: '',
instructorTeacherIds: [],
}) })
sessionDialog.value = true sessionDialog.value = true
} }
async function saveSession() { async function saveSession() {
if (!selectedProject.value || !sessionForm.classroomId if (!selectedProject.value || !sessionForm.classroomId
|| !sessionForm.sessionDate || !sessionForm.startPeriod) { || !sessionForm.sessionDate || !sessionForm.startPeriod || !sessionForm.instructorTeacherIds.length) {
ElMessage.warning('请选择实验日期、节次和教室') ElMessage.warning('请选择实验日期、节次、实验室和指导老师')
return return
} }
try { try {
@@ -420,6 +470,15 @@ async function deleteProject(project: any) {
} }
} }
function instructorsForProject(projectId: string) {
return options.teachers.filter((teacher) => teacher.teachingTaskId === projectId
|| teacher.teachingTaskId === batchProject(projectId)?.teachingTaskId)
}
function instructorsForSelectedProject() {
return selectedProject.value ? instructorsForProject(selectedProject.value.id) : []
}
function openBatchRename() { function openBatchRename() {
batchRenameProjectIds.value = projects.value batchRenameProjectIds.value = projects.value
.filter((project) => draftSelectedProjectIds.value.includes(project.id)) .filter((project) => draftSelectedProjectIds.value.includes(project.id))
@@ -612,11 +671,13 @@ async function loadOptions() {
options.colleges = data.colleges options.colleges = data.colleges
options.scheduleEntries = data.scheduleEntries options.scheduleEntries = data.scheduleEntries
options.classrooms = data.classrooms options.classrooms = data.classrooms
options.teachers = data.teachers
options.periods = data.periods options.periods = data.periods
} catch (error) { } catch (error) {
options.tasks = [] options.tasks = []
options.colleges = [] options.colleges = []
options.classrooms = [] options.classrooms = []
options.teachers = []
options.periods = [] options.periods = []
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} }
@@ -814,9 +875,9 @@ onMounted(async () => {
<template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template> <template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template>
<div class="student-session-menu"> <div class="student-session-menu">
<div v-for="session in row.sessions" :key="session.id" class="student-session-option"> <div v-for="session in row.sessions" :key="session.id" class="student-session-option">
<div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span></div> <div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }} · 指导{{ session.instructorNames?.join('、') || '待指定' }}</span></div>
<el-button size="small" text @click="showParticipants(row, session)">预约名单</el-button> <el-button v-if="session.canManage" size="small" text @click="showParticipants(row, session)">预约名单</el-button>
<el-button v-if="session.status !== 'Cancelled'" size="small" text type="danger" @click="cancelSession(row, session)">{{ row.status === 'Draft' ? '删除' : '取消' }}</el-button> <el-button v-if="session.canManage && session.status !== 'Cancelled'" size="small" text type="danger" @click="cancelSession(row, session)">{{ row.status === 'Draft' ? '删除' : '取消' }}</el-button>
</div> </div>
</div> </div>
</el-popover> </el-popover>
@@ -842,7 +903,10 @@ onMounted(async () => {
@click="publishProject(row)" @click="publishProject(row)"
>发布</el-button> >发布</el-button>
</template> </template>
<el-button v-else-if="row.status === 'Published'" size="small" text @click="closeProject(row)">关闭项目</el-button> <template v-else-if="row.status === 'Published'">
<el-button size="small" text @click="openCorrectPublishedProject(row)">修正内容</el-button>
<el-button size="small" text @click="closeProject(row)">关闭项目</el-button>
</template>
<el-button <el-button
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'" v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
size="small" size="small"
@@ -909,7 +973,7 @@ onMounted(async () => {
<div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }"> <div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }">
<div> <div>
<b>{{ formatSessionTime(session) }}</b> <b>{{ formatSessionTime(session) }}</b>
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span> <span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }} · 指导{{ session.instructorNames?.join('、') || '待指定' }}</span>
</div> </div>
<el-tag :type="sessionTagType(session)" size="small" effect="plain"> {{ session.remainingCount }} / {{ session.capacity }}</el-tag> <el-tag :type="sessionTagType(session)" size="small" effect="plain"> {{ session.remainingCount }} / {{ session.capacity }}</el-tag>
<el-button v-if="row.myBooking?.experimentSessionId === session.id" size="small" type="success" :icon="Check" disabled>已预约</el-button> <el-button v-if="row.myBooking?.experimentSessionId === session.id" size="small" type="success" :icon="Check" disabled>已预约</el-button>
@@ -952,11 +1016,17 @@ onMounted(async () => {
<el-dialog <el-dialog
v-model="projectDialog" v-model="projectDialog"
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'" :title="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
width="720px" width="720px"
top="5vh" top="5vh"
> >
<el-form label-position="top" class="experiment-form"> <el-form label-position="top" class="experiment-form">
<el-alert
v-if="correctingPublishedProject"
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
type="info"
:closable="false"
/>
<div class="form-section"> <div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header> <header><span>PROJECT</span><b>规定实验项目</b></header>
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required> <el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
@@ -1009,9 +1079,10 @@ onMounted(async () => {
v-for="task in group.tasks" v-for="task in group.tasks"
:key="task.id" :key="task.id"
:value="task.id" :value="task.id"
:disabled="projectForm.arrangementMode === 'SelfScheduled' && !!selectedTask :disabled="projectForm.arrangementMode === 'SelfScheduled'
&& (task.academicTermId !== selectedTask.academicTermId && (task.schedulingMode !== 'Flexible'
|| task.courseId !== selectedTask.courseId)" || (!!selectedTask && (task.academicTermId !== selectedTask.academicTermId
|| task.courseId !== selectedTask.courseId)))"
> >
<b>{{ task.taskNumber }}</b> <b>{{ task.taskNumber }}</b>
<span>{{ task.classNames.join('、') || task.name }}</span> <span>{{ task.classNames.join('、') || task.name }}</span>
@@ -1022,12 +1093,12 @@ onMounted(async () => {
</el-checkbox-group> </el-checkbox-group>
<small v-if="!editingProjectId" class="form-help"> <small v-if="!editingProjectId" class="form-help">
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班系统会为每个教学班的全部已发布实验课生成项目</template> <template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班系统会为每个教学班的全部已发布实验课生成项目</template>
<template v-else>已选 {{ projectForm.teachingTaskIds.length }} 个教学班只能勾选同一学期同一课程实验编码名称内容和开放日期将一次应用到这些教学任务</template> <template v-else>自行安排仅可选择非排时课程的教学任务只能勾选同一学期同一课程实验编码名称内容和开放日期将一次应用到这些教学任务</template>
</small> </small>
</el-form-item> </el-form-item>
<div class="form-grid two"> <div class="form-grid two">
<el-form-item label="项目编码" required> <el-form-item label="项目编码" required>
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" /> <el-input v-model="projectForm.code" :disabled="correctingPublishedProject" maxlength="40" placeholder="如 LAB-01" />
</el-form-item> </el-form-item>
<el-form-item v-if="editingProjectId" label="项目名称" required> <el-form-item v-if="editingProjectId" label="项目名称" required>
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" /> <el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
@@ -1058,7 +1129,7 @@ onMounted(async () => {
<div class="form-section"> <div class="form-section">
<header><span>ROUTE</span><b>选择运行轨道</b></header> <header><span>ROUTE</span><b>选择运行轨道</b></header>
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId"> <el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId || correctingPublishedProject">
<el-radio-button value="Centralized"> <el-radio-button value="Centralized">
<b>集中安排</b><small>复用已发布课表的实验课</small> <b>集中安排</b><small>复用已发布课表的实验课</small>
</el-radio-button> </el-radio-button>
@@ -1069,6 +1140,7 @@ onMounted(async () => {
<el-form-item label="项目开放日期" required> <el-form-item label="项目开放日期" required>
<el-date-picker <el-date-picker
v-model="projectForm.dates" v-model="projectForm.dates"
:disabled="correctingPublishedProject"
type="daterange" type="daterange"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
range-separator="至" range-separator="至"
@@ -1076,6 +1148,17 @@ onMounted(async () => {
end-placeholder="结束日期" end-placeholder="结束日期"
/> />
</el-form-item> </el-form-item>
<el-form-item v-if="projectForm.arrangementMode === 'SelfScheduled'" label="学生选课时间" required>
<el-date-picker
v-model="projectForm.selectionTimes"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ss"
range-separator="至"
start-placeholder="开始选课"
end-placeholder="截至选课"
/>
<small class="form-help">仅在此时间段内学生可以从该项目的有效实验场次中选择一次退选或场次取消后可重新选择</small>
</el-form-item>
</div> </div>
<div class="form-section"> <div class="form-section">
@@ -1105,7 +1188,9 @@ onMounted(async () => {
<template #footer> <template #footer>
<el-button @click="projectDialog = false">取消</el-button> <el-button @click="projectDialog = false">取消</el-button>
<el-button type="primary" @click="saveProject"> <el-button type="primary" @click="saveProject">
{{ editingProjectId {{ correctingPublishedProject
? '保存修正'
: editingProjectId
? '保存项目' ? '保存项目'
: projectForm.teachingTaskIds.length : projectForm.teachingTaskIds.length
? `创建 ${projectForm.teachingTaskIds.length} 个项目` ? `创建 ${projectForm.teachingTaskIds.length} 个项目`
@@ -1183,6 +1268,16 @@ onMounted(async () => {
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="指导老师(可多选)" required>
<el-select v-model="sessionForm.instructorTeacherIds" multiple filterable placeholder="选择该教学任务的指导老师">
<el-option
v-for="teacher in instructorsForSelectedProject()"
:key="teacher.teacherId"
:label="`${teacher.teacherNumber} · ${teacher.teacherName}`"
:value="teacher.teacherId"
/>
</el-select>
</el-form-item>
<el-form-item label="场次备注"> <el-form-item label="场次备注">
<el-input <el-input
v-model="sessionForm.notes" v-model="sessionForm.notes"
@@ -1229,11 +1324,11 @@ onMounted(async () => {
</el-select> </el-select>
</el-form-item> </el-form-item>
<div v-if="batchSessionRows.length" class="batch-session-tools"> <div v-if="batchSessionRows.length" class="batch-session-tools">
<span> {{ batchSessionRows.length }} 排课</span> <span> {{ batchSessionRows.length }} 候选时段可为同一项目追加多个时段</span>
<el-button size="small" @click="applyFirstBatchTime">套用首行日期与节次</el-button> <el-button size="small" @click="applyFirstBatchTime">套用首行日期与节次</el-button>
</div> </div>
<div class="batch-session-table"> <div class="batch-session-table">
<article v-for="(row, index) in batchSessionRows" :key="row.projectId" class="batch-session-row"> <article v-for="(row, index) in batchSessionRows" :key="row.rowKey" class="batch-session-row">
<div class="batch-project-cell"> <div class="batch-project-cell">
<span>{{ index + 1 }}</span> <span>{{ index + 1 }}</span>
<div> <div>
@@ -1264,7 +1359,19 @@ onMounted(async () => {
:value="room.id" :value="room.id"
/> />
</el-select> </el-select>
<el-select v-model="row.instructorTeacherIds" multiple filterable collapse-tags placeholder="指导老师">
<el-option
v-for="teacher in instructorsForProject(row.projectId)"
:key="teacher.teacherId"
:label="teacher.teacherName"
:value="teacher.teacherId"
/>
</el-select>
<el-input-number v-model="row.capacity" :min="1" :max="10000" controls-position="right" /> <el-input-number v-model="row.capacity" :min="1" :max="10000" controls-position="right" />
<span class="batch-session-actions">
<el-button size="small" text type="primary" @click="addBatchSessionRow(row.projectId, row)">追加时段</el-button>
<el-button size="small" text type="danger" @click="removeBatchSessionRow(row)">移除</el-button>
</span>
</article> </article>
</div> </div>
<el-empty v-if="!batchSessionRows.length" :image-size="60" description="选择实验项目后,在同一张表中完成排课" /> <el-empty v-if="!batchSessionRows.length" :image-size="60" description="选择实验项目后,在同一张表中完成排课" />
@@ -1471,7 +1578,7 @@ onMounted(async () => {
.batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; } .batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.batch-session-row { .batch-session-row {
display: grid; display: grid;
grid-template-columns: minmax(230px, 1.4fr) 150px 120px 110px minmax(230px, 1.3fr) 110px; grid-template-columns: minmax(210px, 1.2fr) 145px 110px 100px minmax(200px, 1.1fr) minmax(150px, .8fr) 100px auto;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
min-width: 1000px; min-width: 1000px;
@@ -1479,6 +1586,7 @@ onMounted(async () => {
border: 1px solid #dce5e9; border: 1px solid #dce5e9;
background: #f9fbfc; background: #f9fbfc;
} }
.batch-session-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
.batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; } .batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
.batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; } .batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; }
.batch-project-cell > div { display: grid; gap: 3px; min-width: 0; } .batch-project-cell > div { display: grid; gap: 3px; min-width: 0; }
+32 -2
View File
@@ -1,12 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue' import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
import * as echarts from 'echarts' import * as echarts from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms' import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
echarts.use([
AriaComponent,
BarChart,
CanvasRenderer,
GridComponent,
LegendComponent,
LineChart,
TooltipComponent,
])
interface TeachingClassItem { interface TeachingClassItem {
gradeSheetId: string gradeSheetId: string
teachingTaskId: string teachingTaskId: string
@@ -26,6 +39,7 @@ interface TeachingClassItem {
} }
const terms = ref<any[]>([]) const terms = ref<any[]>([])
const colleges = ref<any[]>([])
const auth = useAuthStore() const auth = useAuthStore()
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false) const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const classes = ref<TeachingClassItem[]>([]) const classes = ref<TeachingClassItem[]>([])
@@ -33,6 +47,9 @@ const selected = ref<TeachingClassItem>()
const report = ref<any>() const report = ref<any>()
const termId = ref<string>() const termId = ref<string>()
const keyword = ref('') const keyword = ref('')
const collegeId = ref<string>()
const teacherKeyword = ref('')
const riskOnly = ref(false)
const page = ref(1) const page = ref(1)
const total = ref(0) const total = ref(0)
const pageSize = 12 const pageSize = 12
@@ -96,6 +113,9 @@ async function loadClasses(reset = false) {
params: { params: {
academicTermId: termId.value, academicTermId: termId.value,
keyword: keyword.value.trim() || undefined, keyword: keyword.value.trim() || undefined,
collegeId: collegeId.value,
teacherKeyword: teacherKeyword.value.trim() || undefined,
riskOnly: riskOnly.value || undefined,
page: page.value, page: page.value,
pageSize, pageSize,
}, },
@@ -330,7 +350,12 @@ watch(historyMetric, async () => {
onMounted(async () => { onMounted(async () => {
try { try {
terms.value = (await http.get('/base-data/terms')).data const [termResponse, collegeResponse] = await Promise.all([
http.get('/base-data/terms'),
http.get('/base-data/colleges'),
])
terms.value = termResponse.data
colleges.value = collegeResponse.data
termId.value = defaultAcademicTermId(terms.value) termId.value = defaultAcademicTermId(terms.value)
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -396,7 +421,12 @@ onBeforeUnmount(() => {
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)"> <el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
</el-select> </el-select>
<el-select v-model="collegeId" clearable placeholder="全部开课学院" style="width: 190px" @change="loadClasses(true)">
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
</el-select>
<el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" /> <el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" />
<el-input v-model="teacherKeyword" clearable placeholder="任课教师姓名或工号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" @clear="loadClasses(true)" />
<el-checkbox v-model="riskOnly" @change="loadClasses(true)">仅看预警班</el-checkbox>
<el-button type="primary" @click="loadClasses(true)">查询</el-button> <el-button type="primary" @click="loadClasses(true)">查询</el-button>
</section> </section>
+1 -1
View File
@@ -69,7 +69,7 @@ onMounted(async () => {
</div> </div>
<div> <div>
<strong>明序教务</strong> <strong>明序教务</strong>
<small>MINGXU ACADEMIC SYSTEM</small> <small>教务管理与学业服务</small>
</div> </div>
</div> </div>
<div class="story-copy"> <div class="story-copy">
+20 -124
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
import { import {
Bell, Bell,
Check, Check,
@@ -11,35 +11,6 @@ import {
Search, Search,
User, User,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
import {
AutoLink,
BlockQuote,
Bold,
ClassicEditor,
Essentials,
Heading,
Image,
ImageCaption,
ImageInsert,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageToolbar,
Italic,
Link,
LinkImage,
List,
Paragraph,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
type EditorConfig,
} from 'ckeditor5'
import 'ckeditor5/ckeditor5.css'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import RichMessageContent from '../components/RichMessageContent.vue' import RichMessageContent from '../components/RichMessageContent.vue'
@@ -47,6 +18,8 @@ import { useAuthStore } from '../stores/auth'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const NotificationRichTextEditor = defineAsyncComponent(
() => import('../components/NotificationRichTextEditor.vue'))
const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher'] const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
const canSend = computed(() => const canSend = computed(() =>
auth.user?.roles.some(role => senderRoles.includes(role)) ?? false) auth.user?.roles.some(role => senderRoles.includes(role)) ?? false)
@@ -58,6 +31,7 @@ const unreadCount = ref(0)
const total = ref(0) const total = ref(0)
const sentTotal = ref(0) const sentTotal = ref(0)
const loading = ref(false) const loading = ref(false)
const markingAllRead = ref(false)
const sending = ref(false) const sending = ref(false)
const composerLoading = ref(false) const composerLoading = ref(false)
const recipientLoading = ref(false) const recipientLoading = ref(false)
@@ -90,92 +64,6 @@ const recipientFilters = reactive({
keyword: '', keyword: '',
}) })
const editorConfig: EditorConfig = {
licenseKey: import.meta.env.VITE_CKEDITOR_LICENSE_KEY || 'GPL',
plugins: [
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Link,
AutoLink,
List,
BlockQuote,
Image,
ImageCaption,
ImageInsert,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageToolbar,
LinkImage,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
],
toolbar: {
items: [
'heading',
'|',
'bold',
'italic',
'link',
'|',
'insertTable',
'insertImage',
'|',
'bulletedList',
'numberedList',
'blockQuote',
'|',
'undo',
'redo',
],
shouldNotGroupWhenFull: false,
},
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: {
addTargetToExternalLinks: true,
defaultProtocol: 'https://',
},
image: {
insert: {
integrations: ['insertImageViaUrl'],
},
toolbar: [
'toggleImageCaption',
'imageTextAlternative',
'|',
'imageStyle:inline',
'imageStyle:wrapText',
'imageStyle:breakText',
'|',
'resizeImage',
'linkImage',
],
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells',
'toggleTableCaption',
'tableProperties',
'tableCellProperties',
],
},
}
const categories: Record<string, { label: string; className: string }> = { const categories: Record<string, { label: string; className: string }> = {
General: { label: '一般通知', className: 'general' }, General: { label: '一般通知', className: 'general' },
Approval: { label: '审核待办', className: 'approval' }, Approval: { label: '审核待办', className: 'approval' },
@@ -380,6 +268,8 @@ async function openNotification(notification: any) {
} }
async function markAllRead() { async function markAllRead() {
if (markingAllRead.value) return
markingAllRead.value = true
try { try {
await http.post('/notifications/read-all') await http.post('/notifications/read-all')
notifications.value.forEach(notification => { notifications.value.forEach(notification => {
@@ -389,6 +279,8 @@ async function markAllRead() {
ElMessage.success('全部消息已标为已读') ElMessage.success('全部消息已标为已读')
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally {
markingAllRead.value = false
} }
} }
@@ -464,7 +356,7 @@ onMounted(() => load())
<div class="page-stack message-center"> <div class="page-stack message-center">
<section class="page-intro message-intro"> <section class="page-intro message-intro">
<div> <div>
<span class="section-kicker">MESSAGE CENTER</span> <span class="section-kicker">消息中心</span>
<h2>消息中心</h2> <h2>消息中心</h2>
<p>系统通知审核待办课程变动与成绩消息统一汇总重要信息不再散落在业务页面</p> <p>系统通知审核待办课程变动与成绩消息统一汇总重要信息不再散落在业务页面</p>
</div> </div>
@@ -481,6 +373,15 @@ onMounted(() => load())
> >
发消息 发消息
</el-button> </el-button>
<el-button
:icon="Check"
:loading="markingAllRead"
:disabled="!unreadCount"
:title="unreadCount ? '将全部未读消息标为已读' : '当前没有未读消息'"
@click="markAllRead"
>
全部已读
</el-button>
<el-button :icon="Refresh" @click="activeTab === 'sent' ? loadSent() : load()"> <el-button :icon="Refresh" @click="activeTab === 'sent' ? loadSent() : load()">
刷新 刷新
</el-button> </el-button>
@@ -541,9 +442,6 @@ onMounted(() => load())
@clear="load(true)" @clear="load(true)"
/> />
<el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox> <el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox>
<el-button v-if="unreadCount" :icon="Check" text @click="markAllRead">
全部已读
</el-button>
</section> </section>
<section v-loading="loading" class="message-list"> <section v-loading="loading" class="message-list">
@@ -610,7 +508,7 @@ onMounted(() => load())
> >
<header class="compose-overview"> <header class="compose-overview">
<div> <div>
<span class="panel-kicker">AUTHORIZED DELIVERY</span> <span class="panel-kicker">发送范围</span>
<h3>编写并发送消息</h3> <h3>编写并发送消息</h3>
<p>收件范围始终受当前账号的数据权限约束发送后不可撤回</p> <p>收件范围始终受当前账号的数据权限约束发送后不可撤回</p>
</div> </div>
@@ -861,10 +759,8 @@ onMounted(() => load())
</el-form-item> </el-form-item>
<el-form-item label="消息正文" required class="editor-form-item"> <el-form-item label="消息正文" required class="editor-form-item">
<Ckeditor <NotificationRichTextEditor
v-model="messageForm.content" v-model="messageForm.content"
:editor="ClassicEditor"
:config="editorConfig"
/> />
<div class="editor-footnote"> <div class="editor-footnote">
<span>支持表格超链接和外链图片图片请填写 HTTPS 地址不上传本地文件</span> <span>支持表格超链接和外链图片图片请填写 HTTPS 地址不上传本地文件</span>
+53 -11
View File
@@ -49,8 +49,14 @@ const issueDialog = ref(false)
const historyDrawer = ref(false) const historyDrawer = ref(false)
const historyLoading = ref(false) const historyLoading = ref(false)
const downloadHistory = ref<any[]>([]) const downloadHistory = ref<any[]>([])
const historyPage = ref(1)
const historyTotal = ref(0)
const historyPageSize = 20
const selectedDocument = ref<OfficialDocumentRow>() const selectedDocument = ref<OfficialDocumentRow>()
const filters = reactive<{ type?: DocumentType; status?: DocumentStatus }>({}) const filters = reactive<{ type?: DocumentType; status?: DocumentStatus }>({})
const page = ref(1)
const total = ref(0)
const pageSize = 20
const issueForm = reactive<{ studentId: string; type: DocumentType; purpose: string }>({ const issueForm = reactive<{ studentId: string; type: DocumentType; purpose: string }>({
studentId: '', studentId: '',
type: 'Transcript', type: 'Transcript',
@@ -67,11 +73,19 @@ const statusLabels: Record<DocumentStatus, string> = {
Superseded: '已重签', Superseded: '已重签',
} }
async function load() { async function load(resetPage = false) {
if (resetPage) page.value = 1
loading.value = true loading.value = true
try { try {
const { data } = await http.get('/official-documents', { params: filters }) const { data } = await http.get('/official-documents', {
documents.value = data params: { ...filters, page: page.value, pageSize },
})
documents.value = data.items
total.value = data.total
if (documents.value.length === 0 && page.value > 1) {
page.value--
await load()
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -83,9 +97,9 @@ async function loadStudents(keyword = '') {
if (!isManager.value) return if (!isManager.value) return
try { try {
const { data } = await http.get('/official-documents/students/options', { const { data } = await http.get('/official-documents/students/options', {
params: { keyword: keyword || undefined }, params: { keyword: keyword || undefined, page: 1, pageSize: 30 },
}) })
students.value = data students.value = data.items
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} }
@@ -181,10 +195,20 @@ async function reissue(row: OfficialDocumentRow) {
async function showHistory(row: OfficialDocumentRow) { async function showHistory(row: OfficialDocumentRow) {
selectedDocument.value = row selectedDocument.value = row
historyDrawer.value = true historyDrawer.value = true
historyPage.value = 1
await loadHistory()
}
async function loadHistory() {
if (!selectedDocument.value) return
historyLoading.value = true historyLoading.value = true
try { try {
const { data } = await http.get(`/official-documents/${row.id}/downloads`) const { data } = await http.get(
downloadHistory.value = data `/official-documents/${selectedDocument.value.id}/downloads`,
{ params: { page: historyPage.value, pageSize: historyPageSize } },
)
downloadHistory.value = data.items
historyTotal.value = data.total
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -224,14 +248,14 @@ onMounted(load)
</section> </section>
<section class="document-toolbar"> <section class="document-toolbar">
<el-select v-model="filters.type" clearable placeholder="全部凭证类型" @change="load"> <el-select v-model="filters.type" clearable placeholder="全部凭证类型" @change="load(true)">
<el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" />
</el-select> </el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load"> <el-select v-model="filters.status" clearable placeholder="全部状态" @change="load(true)">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select> </el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="() => load()">刷新</el-button>
<span> {{ documents.length }} 份凭证</span> <span> {{ total }} 份凭证</span>
</section> </section>
<section class="document-list" v-loading="loading"> <section class="document-list" v-loading="loading">
@@ -274,6 +298,15 @@ onMounted(load)
v-if="!documents.length" v-if="!documents.length"
:description="isManager ? '暂无符合条件的官方凭证' : '暂无电子凭证,可点击上方按钮在线申请'" :description="isManager ? '暂无符合条件的官方凭证' : '暂无电子凭证,可点击上方按钮在线申请'"
/> />
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="document-pagination"
@current-change="() => load()"
/>
</section> </section>
<el-dialog v-model="issueDialog" :title="isManager ? '签发官方凭证' : '申请电子凭证'" width="620px"> <el-dialog v-model="issueDialog" :title="isManager ? '签发官方凭证' : '申请电子凭证'" width="620px">
@@ -335,6 +368,15 @@ onMounted(load)
<el-table-column label="来源 IP" prop="ipAddress" min-width="130" /> <el-table-column label="来源 IP" prop="ipAddress" min-width="130" />
</el-table> </el-table>
<el-empty v-if="!historyLoading && !downloadHistory.length" description="尚无下载记录" /> <el-empty v-if="!historyLoading && !downloadHistory.length" description="尚无下载记录" />
<el-pagination
v-if="historyTotal > historyPageSize"
v-model:current-page="historyPage"
:page-size="historyPageSize"
:total="historyTotal"
layout="total, prev, pager, next"
class="document-pagination"
@current-change="loadHistory"
/>
</el-drawer> </el-drawer>
</div> </div>
</template> </template>
+30 -7
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Download, Plus, Promotion, Upload } from '@element-plus/icons-vue' import { Download, Plus, Promotion, Refresh, Search, Upload } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -10,6 +10,13 @@ const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student')) const isStudent = computed(() => auth.user?.roles.includes('Student'))
const loading = ref(false) const loading = ref(false)
const batches = ref<any[]>([]) const batches = ref<any[]>([])
const batchKeyword = ref('')
const batchStatus = ref<number | undefined>()
const batchMetricKind = ref<number | undefined>()
const batchDateRange = ref<string[]>([])
const batchPage = ref(1)
const batchPageSize = 20
const batchTotal = ref(0)
const selected = ref<any>(null) const selected = ref<any>(null)
const results = ref<ResultRow[]>([]) const results = ref<ResultRow[]>([])
const mine = reactive({ best: [] as any[], history: [] as any[] }) const mine = reactive({ best: [] as any[], history: [] as any[] })
@@ -22,11 +29,26 @@ const isScoreMetric = computed(() => selectedMetricKind.value === 3)
const isLevelMetric = computed(() => selectedMetricKind.value === 2) const isLevelMetric = computed(() => selectedMetricKind.value === 2)
const isPassMetric = computed(() => selectedMetricKind.value === 1) const isPassMetric = computed(() => selectedMetricKind.value === 1)
async function load() { async function load(resetPage = false) {
loading.value = true loading.value = true
try { try {
if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data) if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data)
else batches.value = (await http.get('/other-exams/batches')).data else {
if (resetPage) batchPage.value = 1
const response = (await http.get('/other-exams/batches', {
params: {
keyword: batchKeyword.value.trim() || undefined,
status: batchStatus.value,
metricKind: batchMetricKind.value,
examDateFrom: batchDateRange.value[0] || undefined,
examDateTo: batchDateRange.value[1] || undefined,
page: batchPage.value,
pageSize: batchPageSize,
},
})).data
batches.value = response.items
batchTotal.value = response.total
}
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false } } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false }
} }
async function openBatch(row: any) { async function openBatch(row: any) {
@@ -93,13 +115,13 @@ onMounted(load)
<template> <template>
<div class="other-exams-page"> <div class="other-exams-page">
<section class="exam-hero"><div><span class="kicker">ASSESSMENT ARCHIVE</span><h1>其他考试成绩</h1><p>{{ isStudent ? '你的证书、等级考试与校外考试,按考试归档,最优结果一目了然。' : '用考试编码归并同一考试,按场次维护成绩,系统自动记录每位学生的参加次数。' }}</p></div><el-button v-if="!isStudent" type="primary" :icon="Plus" @click="dialog = true">新建考试场次</el-button></section> <section class="exam-hero"><div><span class="kicker">考试成绩</span><h1>其他考试成绩</h1><p>{{ isStudent ? '你的证书、等级考试与校外考试,按考试归档,最优结果一目了然。' : '用考试编码归并同一考试,按场次维护成绩,系统自动记录每位学生的参加次数。' }}</p></div><el-button v-if="!isStudent" type="primary" :icon="Plus" @click="dialog = true">新建考试场次</el-button></section>
<template v-if="isStudent"> <template v-if="isStudent">
<section class="result-board"><div class="board-title"><div><span class="kicker">BEST OUTCOME</span><h2>我的最优结果</h2></div><span class="board-note">同一考试编码下自动比较</span></div><el-table :data="mine.best" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="最优结果" min-width="150"><template #default="{ row }"><strong class="best-value">{{ displayResult(row) }}</strong></template></el-table-column><el-table-column prop="attemptNumber" label="参加次数" width="100"/></el-table><el-empty v-if="!loading && !mine.best.length" description="暂无已发布的其他考试成绩"/></section> <section class="result-board"><div class="board-title"><div><span class="kicker">成绩汇总</span><h2>我的最优结果</h2></div><span class="board-note">同一考试编码下自动比较</span></div><el-table :data="mine.best" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="最优结果" min-width="150"><template #default="{ row }"><strong class="best-value">{{ displayResult(row) }}</strong></template></el-table-column><el-table-column prop="attemptNumber" label="参加次数" width="100"/></el-table><el-empty v-if="!loading && !mine.best.length" description="暂无已发布的其他考试成绩"/></section>
<section class="result-board history-board"><div class="board-title"><div><span class="kicker">FULL HISTORY</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section> <section class="result-board history-board"><div class="board-title"><div><span class="kicker">历史记录</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section>
</template> </template>
<template v-else> <template v-else>
<section class="batch-panel"><div class="panel-heading"><div><span class="kicker">EXAM SESSIONS</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} ` : '草稿' }}</el-tag></template></el-table-column></el-table></section> <section class="batch-panel"><div class="panel-heading"><div><span class="kicker">考试场次</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><div class="batch-filters"><el-input v-model="batchKeyword" clearable placeholder="考试编码、名称或组织方" @keyup.enter="load(true)" @clear="load(true)"/><el-select v-model="batchStatus" clearable placeholder="全部状态" @change="load(true)"><el-option label="草稿" :value="1"/><el-option label="已发布" :value="2"/></el-select><el-select v-model="batchMetricKind" clearable placeholder="全部方式" @change="load(true)"><el-option label="合格制" :value="1"/><el-option label="等级制" :value="2"/><el-option label="分数制" :value="3"/></el-select><el-date-picker v-model="batchDateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" @change="load(true)"/><el-button :icon="Search" @click="load(true)">查询</el-button><el-button :icon="Refresh" @click="() => load()">刷新</el-button></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} 次` : '草稿' }}</el-tag></template></el-table-column></el-table><el-pagination v-if="batchTotal > batchPageSize" small background layout="total, prev, pager, next" :current-page="batchPage" :page-size="batchPageSize" :total="batchTotal" @current-change="(page: number) => { batchPage = page; load() }"/></section>
<section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section> <section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section>
</template> </template>
<el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog> <el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog>
@@ -113,6 +135,7 @@ onMounted(load)
.kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; } .kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; }
@media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } } @media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } }
.editor-heading > div:first-child { min-width: 0; } .editor-heading > div:first-child { min-width: 0; }
.batch-filters { display:flex; flex-wrap:wrap; gap:8px; padding:0 22px 16px; }.batch-filters .el-input { max-width:280px; }.batch-filters .el-select { width:120px; }.batch-panel :deep(.el-pagination) { justify-content:flex-end; padding:14px 22px; }
.editor-actions { flex: 0 0 auto; } .editor-actions { flex: 0 0 auto; }
.upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; } .upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; }
.publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; } .publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; }
+17
View File
@@ -807,6 +807,16 @@ function changeEntryKind() {
} }
} }
async function preflightSchedule() {
try {
const { data } = await http.get(`/schedules/plans/${selected.value.id}/preflight`)
const message = data.unscheduledTasks
? `尚有 ${data.unscheduledTasks} 个教学班未安排:\n${data.messages.join('\n')}`
: '当前草稿已覆盖全部需要排课的教学班。'
await ElMessageBox.alert(message, `排课前检查 · 已覆盖 ${data.scheduledTasks}/${data.totalTasks}`, { confirmButtonText: '知道了' })
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function saveEntry() { async function saveEntry() {
if (!entryForm.teachingTaskId || if (!entryForm.teachingTaskId ||
((entryForm.kind === 'Experiment' || ((entryForm.kind === 'Experiment' ||
@@ -937,6 +947,13 @@ onBeforeUnmount(() => {
> >
复制调整 复制调整
</el-button> </el-button>
<el-button
v-if="isDraft"
:disabled="scheduleJobLoading"
@click="preflightSchedule"
>
排课前检查
</el-button>
<el-button <el-button
v-if="isDraft" v-if="isDraft"
type="warning" type="warning"
+140 -3
View File
@@ -70,6 +70,27 @@ interface GradeItem {
publishedAt?: string publishedAt?: string
} }
interface DashboardGreeting {
title: string
subtitle: string
label: string
narrative: string
insights: Array<{
label: string
value: string
hint: string
tone: 'calm' | 'positive' | 'attention'
}>
}
interface WarningItem {
id: string
type: number
status: number
detail: string
createdAt: string
}
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(true) const loading = ref(true)
@@ -79,6 +100,9 @@ const notifications = ref<NotificationItem[]>([])
const unreadCount = ref(0) const unreadCount = ref(0)
const exams = ref<ExamItem[]>([]) const exams = ref<ExamItem[]>([])
const grades = ref<GradeItem[]>([]) const grades = ref<GradeItem[]>([])
const dashboardGreeting = ref<DashboardGreeting | null>(null)
const warnings = ref<WarningItem[]>([])
const graduation = ref<any>(null)
const now = ref(new Date()) const now = ref(new Date())
const categoryLabels: Record<string, string> = { const categoryLabels: Record<string, string> = {
@@ -159,6 +183,11 @@ const recentGrades = computed(() =>
.slice(0, 4), .slice(0, 4),
) )
const activeWarnings = computed(() => warnings.value.filter((warning) => warning.status === 1))
const radarSummary = computed(() => activeWarnings.value.length
? `发现 ${activeWarnings.value.length} 项需要你关注的学习风险,建议优先处理下方提示。`
: '系统暂未发现需要你处理的学业风险,继续保持当前学习节奏。')
function parseDateOnly(value?: string) { function parseDateOnly(value?: string) {
if (!value) return null if (!value) return null
const [year, month, day] = value.slice(0, 10).split('-').map(Number) const [year, month, day] = value.slice(0, 10).split('-').map(Number)
@@ -281,6 +310,9 @@ async function loadOverview() {
http.get('/notifications', { params: { page: 1, pageSize: 5 } }), http.get('/notifications', { params: { page: 1, pageSize: 5 } }),
http.get('/exams/my-schedule'), http.get('/exams/my-schedule'),
http.get('/grades/student/transcript'), http.get('/grades/student/transcript'),
http.get<DashboardGreeting>('/dashboard/greeting'),
http.get<WarningItem[]>('/warnings/my-warnings'),
http.get('/student/academic-planning'),
]) ])
if (results[0].status === 'fulfilled') { if (results[0].status === 'fulfilled') {
@@ -304,6 +336,15 @@ async function loadOverview() {
} else { } else {
failedSections.value.push('考试成绩') failedSections.value.push('考试成绩')
} }
if (results[4].status === 'fulfilled') {
dashboardGreeting.value = results[4].value.data
}
if (results[5].status === 'fulfilled') {
warnings.value = results[5].value.data
} else {
failedSections.value.push('学业风险雷达')
}
if (results[6].status === 'fulfilled') graduation.value = results[6].value.data
loading.value = false loading.value = false
} }
@@ -314,9 +355,9 @@ onMounted(loadOverview)
<div v-loading="loading" class="student-overview"> <div v-loading="loading" class="student-overview">
<section class="student-overview-hero"> <section class="student-overview-hero">
<div class="student-hero-copy"> <div class="student-hero-copy">
<span class="section-kicker">MY ACADEMIC DAY</span> <span class="section-kicker">我的学习安排</span>
<h2>{{ greeting }}{{ auth.user?.displayName ?? '同学' }}</h2> <h2>{{ dashboardGreeting?.title ?? `${greeting}${auth.user?.displayName ?? '同学'}` }}</h2>
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p> <p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
</div> </div>
<div class="today-status"> <div class="today-status">
<span>今日课程</span> <span>今日课程</span>
@@ -325,6 +366,39 @@ onMounted(loadOverview)
</div> </div>
</section> </section>
<section v-if="dashboardGreeting?.insights.length" class="student-greeting-insights" :aria-label="dashboardGreeting.label">
<span>{{ dashboardGreeting.label }}</span>
<article v-for="insight in dashboardGreeting.insights" :key="insight.label" :class="insight.tone">
<small>{{ insight.label }}</small>
<strong>{{ insight.value }}</strong>
<em>{{ insight.hint }}</em>
</article>
</section>
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
<section class="student-risk-radar" :class="{ attention: activeWarnings.length }">
<header>
<div>
<span class="panel-kicker">学业提醒</span>
<h3>学业风险雷达</h3>
</div>
<button type="button" @click="router.push('/warnings')">查看全部 <el-icon><ArrowRight /></el-icon></button>
</header>
<p>{{ radarSummary }}</p>
<div v-if="activeWarnings.length" class="risk-list">
<button v-for="warning in activeWarnings.slice(0, 3)" :key="warning.id" type="button" @click="router.push('/warnings')">
<span>{{ categoryLabels.Warning }}</span>
<strong>{{ warning.detail }}</strong>
<i>去处理 </i>
</button>
</div>
</section>
<section v-if="graduation?.baseline" class="graduation-nav">
<div><span class="panel-kicker">毕业进度</span><h3>毕业导航</h3><p>已完成 {{ graduation.baseline.planCompletedCredits ?? 0 }} 学分距离培养方案要求还差 {{ graduation.baseline.creditGap ?? 0 }} 学分</p></div>
<button type="button" @click="router.push('/academic-planning')">查看毕业航线 <el-icon><ArrowRight /></el-icon></button>
</section>
<el-alert <el-alert
v-if="failedSections.length" v-if="failedSections.length"
type="warning" type="warning"
@@ -539,6 +613,63 @@ onMounted(loadOverview)
font-size: 13px; font-size: 13px;
} }
.student-greeting-insights {
padding: 15px 21px;
display: grid;
grid-template-columns: 105px repeat(3, minmax(0, 1fr));
gap: 12px;
border: 1px solid #dce6eb;
background: #f7faf9;
}
.student-greeting-insights > span {
align-self: center;
color: var(--teal);
font: 700 10px/1.5 Consolas, monospace;
letter-spacing: .11em;
}
.student-greeting-insights article {
min-width: 0;
padding-left: 12px;
display: grid;
gap: 3px;
border-left: 2px solid #a9bcc6;
}
.student-greeting-insights article.positive { border-color: var(--teal); }
.student-greeting-insights article.attention { border-color: #c4812a; }
.student-greeting-insights small { color: #667488; font-size: 10px; }
.student-greeting-insights strong { color: var(--ink); font-size: 19px; }
.student-greeting-insights em { overflow: hidden; color: var(--muted); font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.student-greeting-narrative {
margin: -5px 0 0;
padding: 0 3px;
color: #526078;
font-size: 12px;
line-height: 1.7;
}
.student-risk-radar {
padding: 20px 22px;
border: 1px solid #dce6eb;
background: #fbfdfd;
}
.graduation-nav{padding:19px 22px;display:flex;justify-content:space-between;align-items:center;gap:18px;border:1px solid #dce6eb;background:#f7faf9}.graduation-nav h3{margin:7px 0;color:var(--ink);font-size:17px}.graduation-nav p{margin:0;color:#59677a;font-size:12px;line-height:1.6}.graduation-nav button{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--indigo);font-size:12px;white-space:nowrap}
.student-risk-radar.attention { border-left: 3px solid #c4812a; background: #fffcf6; }
.student-risk-radar header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.student-risk-radar h3 { margin: 6px 0 0; color: var(--ink); font-size: 17px; }
.student-risk-radar header button { padding: 4px 0; display: inline-flex; align-items: center; gap: 4px; border: 0; color: #526078; background: transparent; font-size: 12px; white-space: nowrap; }
.student-risk-radar header button:hover { color: var(--indigo); }
.student-risk-radar > p { margin: 13px 0 0; color: #59677a; font-size: 12px; line-height: 1.7; }
.risk-list { margin-top: 13px; display: grid; }
.risk-list button { padding: 12px 0; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; text-align: left; border: 0; border-top: 1px solid #eee6d8; background: transparent; }
.risk-list span { align-self: center; color: #a16b1c; font: 700 9px/1.4 Consolas, monospace; letter-spacing: .06em; }
.risk-list strong { color: #38475a; font-size: 12px; font-weight: 600; line-height: 1.55; }
.risk-list i { align-self: center; color: #9a743d; font-size: 11px; font-style: normal; white-space: nowrap; }
.today-status { .today-status {
min-width: 180px; min-width: 180px;
margin-left: auto; margin-left: auto;
@@ -871,12 +1002,18 @@ onMounted(loadOverview)
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.student-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
.student-overview-grid { .student-overview-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
.student-risk-radar { padding: 18px; }
.graduation-nav{padding:18px;align-items:flex-start;flex-direction:column}
.risk-list button { grid-template-columns: 1fr auto; }
.risk-list span { grid-column: 1 / -1; }
.student-overview { .student-overview {
gap: 12px; gap: 12px;
} }
+32 -12
View File
@@ -23,6 +23,11 @@ const selected = ref<any | null>(null)
const reviewApproved = ref(true) const reviewApproved = ref(true)
const applyForm = reactive({ type: '', reason: '' }) const applyForm = reactive({ type: '', reason: '' })
const reviewForm = reactive({ comment: '' }) const reviewForm = reactive({ comment: '' })
const page = ref(1)
const total = ref(0)
const actionableTotal = ref(0)
const finishedTotal = ref(0)
const pageSize = 20
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
Suspension: '休学', Suspension: '休学',
@@ -48,10 +53,6 @@ const steps = [
{ label: '学院审核', state: 'CounselorApproved' }, { label: '学院审核', state: 'CounselorApproved' },
{ label: '校级审批', state: 'CollegeApproved' }, { label: '校级审批', state: 'CollegeApproved' },
] ]
const currentQueue = computed(() => changes.value.filter(canReview))
const finishedCount = computed(() =>
changes.value.filter((x) => ['Approved', 'Rejected', 'Cancelled'].includes(x.state)).length)
function dateText(value?: string) { function dateText(value?: string) {
if (!value) return '—' if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat('zh-CN', {
@@ -90,14 +91,24 @@ function stateTagType(state: ChangeState) {
return 'warning' return 'warning'
} }
async function load() { async function load(resetPage = false) {
if (resetPage) page.value = 1
loading.value = true loading.value = true
try { try {
const requests = [http.get('/student-status-changes')] const requests = [http.get('/student-status-changes', {
params: { page: page.value, pageSize },
})]
if (isStudent.value) requests.push(http.get('/student-status-changes/options')) if (isStudent.value) requests.push(http.get('/student-status-changes/options'))
const [changeResponse, optionResponse] = await Promise.all(requests) const [changeResponse, optionResponse] = await Promise.all(requests)
changes.value = changeResponse.data changes.value = changeResponse.data.items
total.value = changeResponse.data.total
actionableTotal.value = changeResponse.data.actionableTotal
finishedTotal.value = changeResponse.data.finishedTotal
if (optionResponse) options.value = optionResponse.data if (optionResponse) options.value = optionResponse.data
if (changes.value.length === 0 && page.value > 1) {
page.value--
await load()
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -118,7 +129,7 @@ async function submitApply() {
await http.post('/student-status-changes', applyForm) await http.post('/student-status-changes', applyForm)
applyDialog.value = false applyDialog.value = false
ElMessage.success('申请已提交,等待辅导员审核。') ElMessage.success('申请已提交,等待辅导员审核。')
await load() await load(true)
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} }
@@ -177,7 +188,7 @@ onMounted(load)
:disabled="options?.hasPending || !options?.types?.length" :disabled="options?.hasPending || !options?.types?.length"
@click="openApply" @click="openApply"
>发起申请</el-button> >发起申请</el-button>
<el-button v-else :icon="RefreshRight" @click="load">刷新队列</el-button> <el-button v-else :icon="RefreshRight" @click="() => load()">刷新队列</el-button>
</section> </section>
<section v-if="isStudent && options" class="status-identity"> <section v-if="isStudent && options" class="status-identity">
@@ -194,9 +205,9 @@ onMounted(load)
</section> </section>
<section v-if="!isStudent" class="status-review-summary"> <section v-if="!isStudent" class="status-review-summary">
<div><span>当前待我审核</span><b>{{ currentQueue.length }}</b><small></small></div> <div><span>当前待我审核</span><b>{{ actionableTotal }}</b><small></small></div>
<div><span>辖区申请总数</span><b>{{ changes.length }}</b><small></small></div> <div><span>辖区申请总数</span><b>{{ total }}</b><small></small></div>
<div><span>已结束</span><b>{{ finishedCount }}</b><small></small></div> <div><span>已结束</span><b>{{ finishedTotal }}</b><small></small></div>
<p>系统仅开放当前审核层级的操作所有越级请求都会由服务端拒绝</p> <p>系统仅开放当前审核层级的操作所有越级请求都会由服务端拒绝</p>
</section> </section>
@@ -243,6 +254,15 @@ onMounted(load)
</footer> </footer>
</article> </article>
<el-empty v-if="!changes.length && !loading" :description="isStudent ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" /> <el-empty v-if="!changes.length && !loading" :description="isStudent ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" />
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section> </section>
<el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px"> <el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px">
+120 -10
View File
@@ -53,6 +53,16 @@ const isNativeApp = Capacitor.isNativePlatform()
const now = ref(Date.now()) const now = ref(Date.now())
const fileInput = ref<HTMLInputElement>() const fileInput = ref<HTMLInputElement>()
const termId = ref<string>() const termId = ref<string>()
const taskKeyword = ref('')
const taskPage = ref(1)
const taskPageSize = 12
const taskTotal = ref(0)
const sheetKeyword = ref('')
const sheetStatus = ref<string>()
const sheetMethod = ref<string>()
const sheetPage = ref(1)
const sheetPageSize = 12
const sheetTotal = ref(0)
const activeMode = ref<'rollcall' | 'statistics'>('rollcall') const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
const studentKeyword = ref('') const studentKeyword = ref('')
const classFilter = ref('') const classFilter = ref('')
@@ -108,18 +118,27 @@ const filteredStudents = computed(() => {
}) })
}) })
async function loadTasks() { async function loadTasks(resetPage = false) {
loading.value = true loading.value = true
try { try {
tasks.value = (await http.get('/attendance/my-tasks', { if (resetPage) taskPage.value = 1
params: { academicTermId: termId.value || undefined }, const response = (await http.get('/attendance/my-tasks', {
params: {
academicTermId: termId.value || undefined,
keyword: taskKeyword.value.trim() || undefined,
page: taskPage.value,
pageSize: taskPageSize,
},
})).data })).data
tasks.value = response.items
taskTotal.value = response.total
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id) const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
?? tasks.value[0] ?? tasks.value[0]
if (preferred) await selectTask(preferred) if (preferred) await selectTask(preferred)
else { else {
selectedTask.value = null selectedTask.value = null
sheets.value = [] sheets.value = []
sheetTotal.value = 0
statistics.value = null statistics.value = null
} }
} catch (error) { } catch (error) {
@@ -137,13 +156,37 @@ async function selectTask(task: any) {
studentKeyword.value = '' studentKeyword.value = ''
classFilter.value = '' classFilter.value = ''
attentionFilter.value = '' attentionFilter.value = ''
sheetPage.value = 1
sheetKeyword.value = ''
sheetStatus.value = undefined
sheetMethod.value = undefined
await loadSheets()
if (activeMode.value === 'statistics') await loadStatistics()
}
async function loadSheets(resetPage = false) {
if (!selectedTask.value) return
try { try {
sheets.value = (await http.get('/attendance/sheets', { if (resetPage) sheetPage.value = 1
params: { teachingTaskId: task.id }, const response = (await http.get('/attendance/sheets', {
params: {
teachingTaskId: selectedTask.value.id,
keyword: sheetKeyword.value.trim() || undefined,
status: sheetStatus.value,
checkInMethod: sheetMethod.value,
page: sheetPage.value,
pageSize: sheetPageSize,
},
})).data })).data
if (activeMode.value === 'statistics') await loadStatistics() sheets.value = response.items
sheetTotal.value = response.total
if (!sheets.value.some((sheet: any) => sheet.id === selectedSheet.value?.id)) {
selectedSheet.value = null
sheetDetail.value = null
}
} catch (error) { } catch (error) {
sheets.value = [] sheets.value = []
sheetTotal.value = 0
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} }
} }
@@ -680,14 +723,22 @@ onUnmounted(() => {
<h2>教学点名</h2> <h2>教学点名</h2>
<p>完成课堂点名并持续查看本课程每位学生的出勤表现</p> <p>完成课堂点名并持续查看本课程每位学生的出勤表现</p>
</div> </div>
<el-button :icon="Refresh" @click="loadTasks">刷新</el-button> <el-button :icon="Refresh" @click="() => loadTasks()">刷新</el-button>
</section> </section>
<section class="attendance-toolbar"> <section class="attendance-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks"> <el-input
v-model="taskKeyword"
clearable
placeholder="课程、任务号或教学班"
@keyup.enter="loadTasks(true)"
@clear="loadTasks(true)"
/>
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<span> {{ tasks.length }} 个教学班</span> <el-button :icon="Search" @click="loadTasks(true)">查询</el-button>
<span> {{ taskTotal }} 个教学班</span>
<el-radio-group v-model="activeMode" class="mode-switch" size="small"> <el-radio-group v-model="activeMode" class="mode-switch" size="small">
<el-radio-button value="rollcall">点名记录</el-radio-button> <el-radio-button value="rollcall">点名记录</el-radio-button>
<el-radio-button value="statistics">课程统计</el-radio-button> <el-radio-button value="statistics">课程统计</el-radio-button>
@@ -712,6 +763,16 @@ onUnmounted(() => {
<small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small> <small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small>
</button> </button>
<el-empty v-if="!tasks.length" description="没有可点名的教学班" /> <el-empty v-if="!tasks.length" description="没有可点名的教学班" />
<el-pagination
v-if="taskTotal > taskPageSize"
small
background
layout="prev, pager, next"
:current-page="taskPage"
:page-size="taskPageSize"
:total="taskTotal"
@current-change="(page: number) => { taskPage = page; loadTasks() }"
/>
</aside> </aside>
<template v-if="activeMode === 'rollcall'"> <template v-if="activeMode === 'rollcall'">
@@ -727,6 +788,25 @@ onUnmounted(() => {
</div> </div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button> <el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button>
</header> </header>
<div class="attendance-sheet-filters">
<el-input
v-model="sheetKeyword"
clearable
placeholder="考勤表名称或备注"
@keyup.enter="loadSheets(true)"
@clear="loadSheets(true)"
/>
<el-select v-model="sheetStatus" clearable placeholder="全部状态" @change="loadSheets(true)">
<el-option label="草稿" value="Draft" />
<el-option label="已提交" value="Submitted" />
</el-select>
<el-select v-model="sheetMethod" clearable placeholder="全部方式" @change="loadSheets(true)">
<el-option label="教师点名" value="Manual" />
<el-option label="扫码签到" value="QrCode" />
<el-option label="定位签到" value="Location" />
</el-select>
<el-button :icon="Search" @click="loadSheets(true)">筛选</el-button>
</div>
<div class="attendance-sheet-list"> <div class="attendance-sheet-list">
<button <button
v-for="sheet in sheets" v-for="sheet in sheets"
@@ -766,6 +846,16 @@ onUnmounted(() => {
</button> </button>
<el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" /> <el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" />
</div> </div>
<el-pagination
v-if="sheetTotal > sheetPageSize"
small
background
layout="total, prev, pager, next"
:current-page="sheetPage"
:page-size="sheetPageSize"
:total="sheetTotal"
@current-change="(page: number) => { sheetPage = page; loadSheets() }"
/>
</template> </template>
</main> </main>
@@ -1192,7 +1282,8 @@ onUnmounted(() => {
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fbfcfd; background: #fbfcfd;
} }
.attendance-toolbar .el-select { width: 260px; } .attendance-toolbar .el-input,
.attendance-toolbar .el-select { width: 220px; }
.attendance-toolbar > span { color: var(--muted); font-size: 12px; } .attendance-toolbar > span { color: var(--muted); font-size: 12px; }
.mode-switch { margin-left: auto; } .mode-switch { margin-left: auto; }
.attendance-workspace { .attendance-workspace {
@@ -1256,6 +1347,21 @@ onUnmounted(() => {
display: block; display: block;
} }
.attendance-sheet-list { flex: 1; overflow-y: auto; } .attendance-sheet-list { flex: 1; overflow-y: auto; }
.attendance-sheet-filters {
display: flex;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
background: #fbfcfd;
}
.attendance-sheet-filters .el-input { flex: 1; min-width: 140px; }
.attendance-sheet-filters .el-select { width: 115px; }
.attendance-task-list :deep(.el-pagination),
.attendance-detail :deep(.el-pagination) {
justify-content: center;
padding: 10px;
border-top: 1px solid var(--line);
}
.attendance-sheet-list > button { .attendance-sheet-list > button {
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px;
@@ -1708,7 +1814,11 @@ onUnmounted(() => {
@media (max-width: 720px) { @media (max-width: 720px) {
.attendance-toolbar { align-items: stretch; flex-wrap: wrap; } .attendance-toolbar { align-items: stretch; flex-wrap: wrap; }
.attendance-toolbar .el-input,
.attendance-toolbar .el-select { width: 100%; } .attendance-toolbar .el-select { width: 100%; }
.attendance-sheet-filters { flex-wrap: wrap; }
.attendance-sheet-filters .el-input,
.attendance-sheet-filters .el-select { width: 100%; flex-basis: 100%; }
.attendance-toolbar > span { align-self: center; } .attendance-toolbar > span { align-self: center; }
.mode-switch { margin-left: auto; } .mode-switch { margin-left: auto; }
.statistics-head { align-items: flex-start; flex-direction: column; } .statistics-head { align-items: flex-start; flex-direction: column; }
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ArrowRight, Calendar, DocumentChecked, Reading } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import http from '../api/http'
interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> }
interface Sheet { id: string; courseName: string; taskName: string; classNames: string[]; sheet?: { status: string; studentCount: number; completedCount: number } }
const router = useRouter()
const greeting = ref<Greeting | null>(null)
const sheets = ref<Sheet[]>([])
const loading = ref(true)
const failed = ref(false)
const statusLabel: Record<string, string> = { Draft: '待登记', Returned: '已退回', Submitted: '审核中', Approved: '已审核', Published: '已发布' }
const progress = (sheet?: Sheet['sheet']) => sheet?.studentCount ? Math.round(sheet.completedCount / sheet.studentCount * 100) : 0
const pendingSheets = computed(() => sheets.value.filter(x => x.sheet?.status === 'Draft' || x.sheet?.status === 'Returned'))
async function load() {
loading.value = true; failed.value = false
try {
const dashboard = await http.get<{ currentTerm?: { id: string }; greeting: Greeting }>('/dashboard')
greeting.value = dashboard.data.greeting
const result = await http.get<{ items: Sheet[] }>('/grades/sheets', { params: { academicTermId: dashboard.data.currentTerm?.id, pageSize: 50 } })
sheets.value = result.data.items
} catch { failed.value = true } finally { loading.value = false }
}
onMounted(load)
</script>
<template>
<div v-loading="loading" class="teacher-cockpit">
<el-result v-if="failed" icon="warning" title="教学驾驶舱加载失败" sub-title="请检查服务连接后重新加载。"><template #extra><el-button type="primary" @click="load">重新加载</el-button></template></el-result>
<template v-else-if="greeting">
<section class="cockpit-hero">
<span>教学工作台</span><h2>{{ greeting.title }}</h2><p>{{ greeting.subtitle }}</p><small>{{ greeting.narrative }}</small>
</section>
<section class="cockpit-metrics"><article v-for="item in greeting.insights" :key="item.label" :class="item.tone"><span>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.hint }}</small></article></section>
<section class="cockpit-grid">
<article class="cockpit-panel">
<header><div><span><el-icon><DocumentChecked /></el-icon> GRADE PROGRESS</span><h3>成绩登记进度</h3></div><button @click="router.push('/grades')">成绩管理 <el-icon><ArrowRight /></el-icon></button></header>
<div v-if="pendingSheets.length" class="sheet-list"><button v-for="item in pendingSheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><div><b>{{ item.courseName }}</b><small>{{ item.classNames.join('、') || item.taskName }} · {{ statusLabel[item.sheet?.status ?? ''] ?? '待登记' }}</small></div><strong>{{ progress(item.sheet) }}%</strong></button></div>
<div v-else class="cockpit-empty"><el-icon><DocumentChecked /></el-icon><b>当前没有待提交成绩</b><span>成绩登记册会在这里按优先级显示</span></div>
</article>
<article class="cockpit-panel">
<header><div><span><el-icon><Reading /></el-icon> TEACHING CLASSES</span><h3>本学期教学班</h3></div><button @click="router.push('/teaching-tasks')">教学任务 <el-icon><ArrowRight /></el-icon></button></header>
<div v-if="sheets.length" class="class-list"><button v-for="item in sheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><b>{{ item.courseName }}</b><span>{{ item.classNames.join('、') || item.taskName }}</span><small>{{ item.sheet ? `${item.sheet.studentCount} 人 · 已完成 ${item.sheet.completedCount}` : '尚未建立成绩登记册' }}</small></button></div>
<div v-else class="cockpit-empty"><el-icon><Calendar /></el-icon><b>本学期暂未分配教学班</b><span>教学任务发布后会自动汇总到这里</span></div>
</article>
</section>
</template>
</div>
</template>
<style scoped>
.teacher-cockpit{display:grid;gap:18px}.cockpit-hero{padding:31px 35px;color:#fff;background:linear-gradient(120deg,#17284f,#244276 62%,#08726d);}.cockpit-hero>span,.cockpit-panel header>div>span{color:#71d9ca;font:700 10px/1 Consolas,monospace;letter-spacing:.12em}.cockpit-hero h2{margin:13px 0 8px;font:700 clamp(27px,3vw,38px)/1.2 "STZhongsong","Songti SC",serif}.cockpit-hero p{margin:0;color:#d1daf0}.cockpit-hero small{display:block;margin-top:15px;color:#aebee0;font-size:12px}.cockpit-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cockpit-metrics article{padding:17px;border-left:3px solid #a9bcc6;background:#f7faf9;display:grid;gap:4px}.cockpit-metrics .positive{border-color:#098174}.cockpit-metrics .attention{border-color:#ce8b2c}.cockpit-metrics span,.cockpit-metrics small{color:#657488;font-size:11px}.cockpit-metrics strong{color:#17284f;font-size:23px}.cockpit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.cockpit-panel{padding:23px 26px;border:1px solid #e2e7ec;background:#fff}.cockpit-panel header{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #e9edf0}.cockpit-panel h3{margin:8px 0 16px;color:#263648;font-size:18px}.cockpit-panel header button{border:0;background:transparent;color:#526078;font-size:12px;white-space:nowrap}.cockpit-panel button{cursor:pointer}.sheet-list button,.class-list button{width:100%;padding:14px 0;display:flex;justify-content:space-between;gap:12px;text-align:left;border:0;border-bottom:1px solid #edf0f3;background:transparent}.sheet-list b,.class-list b{display:block;color:#2e3c4c;font-size:14px}.sheet-list small,.class-list span,.class-list small{display:block;margin-top:5px;color:#748093;font-size:11px}.sheet-list strong{align-self:center;color:#0b8175;font:700 18px Consolas,monospace}.cockpit-empty{min-height:170px;display:grid;place-content:center;justify-items:center;color:#8c96a5;gap:8px;text-align:center;font-size:12px}.cockpit-empty .el-icon{font-size:26px}.cockpit-empty b{color:#5e6c7e}@media(max-width:760px){.cockpit-hero{padding:25px 21px}.cockpit-metrics,.cockpit-grid{grid-template-columns:1fr}.cockpit-panel{padding:20px 18px}}
</style>
+27 -7
View File
@@ -19,6 +19,9 @@ const teachers = ref<any[]>([])
const submitDialog = ref(false) const submitDialog = ref(false)
const reviewDialog = ref(false) const reviewDialog = ref(false)
const assignmentDialog = ref(false) const assignmentDialog = ref(false)
const page = ref(1)
const total = ref(0)
const pageSize = 20
const form = reactive<Record<string, any>>({}) const form = reactive<Record<string, any>>({})
const reviewForm = reactive<Record<string, any>>({}) const reviewForm = reactive<Record<string, any>>({})
const assignmentForm = reactive<Record<string, any>>({}) const assignmentForm = reactive<Record<string, any>>({})
@@ -39,14 +42,22 @@ const statusTypes: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
Withdrawn: 'info', Withdrawn: 'info',
} }
async function load() { async function load(resetPage = false) {
if (resetPage) page.value = 1
loading.value = true loading.value = true
try { try {
const endpoint = isTeacher.value const endpoint = isTeacher.value
? '/teacher-course-applications/mine' ? '/teacher-course-applications/mine'
: '/teacher-course-applications/reviews' : '/teacher-course-applications/reviews'
const { data } = await http.get(endpoint, { params: filters }) const { data } = await http.get(endpoint, {
rows.value = data params: { ...filters, page: page.value, pageSize },
})
rows.value = data.items
total.value = data.total
if (rows.value.length === 0 && page.value > 1) {
page.value--
await load()
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -195,14 +206,14 @@ onMounted(async () => {
<section class="data-card"> <section class="data-card">
<div class="filter-bar"> <div class="filter-bar">
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load"> <el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load(true)">
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /> <el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select> </el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load"> <el-select v-model="filters.status" clearable placeholder="全部状态" @change="load(true)">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select> </el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="() => load()">刷新</el-button>
<span> {{ rows.length }} </span> <span> {{ total }} </span>
</div> </div>
<el-table v-loading="loading" :data="rows" class="data-table"> <el-table v-loading="loading" :data="rows" class="data-table">
@@ -235,6 +246,15 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<template #empty><el-empty :description="isTeacher ? '尚未申报授课科目' : '没有待处理的授课申报'" /></template> <template #empty><el-empty :description="isTeacher ? '尚未申报授课科目' : '没有待处理的授课申报'" /></template>
</el-table> </el-table>
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section> </section>
<el-dialog v-model="submitDialog" title="申报授课科目" width="600px"> <el-dialog v-model="submitDialog" title="申报授课科目" width="600px">
+141 -36
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@@ -12,7 +12,7 @@ import {
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile, downloadApiPostFile } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms' import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
@@ -32,6 +32,8 @@ const isManager = computed(() =>
) )
const loading = ref(false) const loading = ref(false)
const exportingPdf = ref(false) const exportingPdf = ref(false)
const batchExporting = ref(false)
const batchResourceIds = ref<string[]>([])
const calendarDialogVisible = ref(false) const calendarDialogVisible = ref(false)
const calendarLoading = ref(false) const calendarLoading = ref(false)
const calendarActionLoading = ref(false) const calendarActionLoading = ref(false)
@@ -61,7 +63,6 @@ const viewMode = ref<'overview' | 'week' | 'day'>('week')
const selectedWeek = ref(1) const selectedWeek = ref(1)
const selectedDay = ref(1) const selectedDay = ref(1)
const loadedTermId = ref('') const loadedTermId = ref('')
const exportArea = ref<HTMLElement | null>(null)
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'] const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' } const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
const planStatusLabels: Record<string, string> = { const planStatusLabels: Record<string, string> = {
@@ -104,6 +105,11 @@ const selectedResourceId = computed(() => {
if (resourceType.value === 'Classroom') return classroomId.value if (resourceType.value === 'Classroom') return classroomId.value
return classId.value return classId.value
}) })
const batchResources = computed(() => {
if (resourceType.value === 'Teacher') return filteredTeachers.value
if (resourceType.value === 'Classroom') return filteredClassrooms.value
return filteredClasses.value
})
const slotMap = computed<Map<number, any>>(() => const slotMap = computed<Map<number, any>>(() =>
new Map<number, any>( new Map<number, any>(
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]), (timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
@@ -403,7 +409,11 @@ async function loadPublicOptions() {
termId.value = defaultAcademicTermId(data.terms) termId.value = defaultAcademicTermId(data.terms)
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id ?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
?? '' ?? ''
const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable) const studentClassId = auth.user?.roles.includes('Student')
? (await http.get('/timetables/my-class')).data.administrativeClassId
: undefined
const initialClass = data.classes.find((item: any) => item.id === studentClassId)
?? data.classes.find((item: any) => item.hasPublishedTimetable)
?? data.classes[0] ?? data.classes[0]
classId.value = initialClass?.id ?? '' classId.value = initialClass?.id ?? ''
setClassFilters(initialClass) setClassFilters(initialClass)
@@ -440,6 +450,7 @@ async function loadManagementOptions() {
} }
async function onTermChanged() { async function onTermChanged() {
batchResourceIds.value = []
if (isManager.value) await loadManagementOptions() if (isManager.value) await loadManagementOptions()
await loadTimetable() await loadTimetable()
} }
@@ -468,6 +479,7 @@ function onBuildingChanged() {
} }
function onResourceTypeChanged() { function onResourceTypeChanged() {
batchResourceIds.value = []
collegeId.value = '' collegeId.value = ''
majorId.value = '' majorId.value = ''
grade.value = undefined grade.value = undefined
@@ -483,6 +495,67 @@ function onResourceTypeChanged() {
} }
} }
function resourceLabel(item: any) {
if (resourceType.value === 'Teacher') return `${item.teacherNumber} · ${item.name}`
if (resourceType.value === 'Classroom') return `${item.buildingName} · ${item.name}`
return `${item.code} · ${item.name}`
}
function selectFilteredResources() {
if (batchResources.value.length > 100) {
ElMessage.warning('单次最多导出 100 项,请进一步缩小筛选范围。')
return
}
batchResourceIds.value = batchResources.value.map((item: any) => item.id)
}
function batchExportPayload() {
if (!batchResourceIds.value.length) {
ElMessage.warning('请先从筛选结果中选择要导出的课表。')
return null
}
return {
resourceType: resourceType.value,
resourceIds: batchResourceIds.value,
academicTermId: termId.value,
schedulePlanId: planId.value || undefined,
}
}
async function exportBatchExcel() {
const payload = batchExportPayload()
if (!payload) return
batchExporting.value = true
try {
await downloadApiPostFile(
'/timetables/management/export/batch.xlsx',
payload,
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
batchExporting.value = false
}
}
async function exportBatchPdf() {
const payload = batchExportPayload()
if (!payload) return
batchExporting.value = true
try {
await downloadApiPostFile(
'/timetables/management/export/batch.pdf',
payload,
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.pdf`,
)
} catch (error) {
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
} finally {
batchExporting.value = false
}
}
async function loadTimetable() { async function loadTimetable() {
if (!termId.value) { if (!termId.value) {
timetable.value = null timetable.value = null
@@ -560,35 +633,23 @@ async function exportExcel() {
} }
async function exportPdf() { async function exportPdf() {
if (!exportArea.value || !timetable.value) return
exportingPdf.value = true exportingPdf.value = true
try { try {
await nextTick() if (isMine.value) {
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ await downloadApiFile(`/timetables/mine/export.pdf?academicTermId=${termId.value}`, '我的课表.pdf')
import('html2canvas'), } else if (isTeacherView.value && teacherIdParam.value) {
import('jspdf'), await downloadApiFile(`/timetables/teachers/${teacherIdParam.value}/export.pdf?academicTermId=${termId.value}`, '教师课表.pdf')
]) } else if (isManager.value) {
const canvas = await html2canvas(exportArea.value, { const query = new URLSearchParams({
scale: 2, resourceType: resourceType.value,
useCORS: true, resourceId: selectedResourceId.value,
backgroundColor: '#ffffff', academicTermId: termId.value,
}) })
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }) if (planId.value) query.set('schedulePlanId', planId.value)
const pageWidth = 297 await downloadApiFile(`/timetables/management/export.pdf?${query}`, '课表.pdf')
const pageHeight = 210 } else {
const imageHeight = canvas.height * pageWidth / canvas.width await downloadApiFile(`/timetables/classes/${classId.value}/export.pdf?academicTermId=${termId.value}`, '班级课表.pdf')
const image = canvas.toDataURL('image/png')
let remaining = imageHeight
let position = 0
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
remaining -= pageHeight
while (remaining > 0) {
position = remaining - imageHeight
pdf.addPage()
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
remaining -= pageHeight
} }
pdf.save(`${timetable.value.subject?.name ?? '课表'}-${timetable.value.term.name}.pdf`)
} catch (error) { } catch (error) {
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`) ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
} finally { } finally {
@@ -753,7 +814,7 @@ onMounted(async () => {
<section class="timetable-heading"> <section class="timetable-heading">
<div> <div>
<span class="section-kicker">{{ isMine && isTeacher ? 'MY TEACHING SCHEDULE' : isMine ? 'MY TIMETABLE' : isTeacherView ? 'TEACHER TIMETABLE' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span> <span class="section-kicker">{{ isMine && isTeacher ? '我的授课安排' : isMine ? '我的课程安排' : isTeacherView ? '教师课表' : isManager ? '课表管理' : '班级课表' }}</span>
<h2>{{ isMine && isTeacher ? '我的授课课表' : isMine ? '我的课表' : isTeacherView ? '教师课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2> <h2>{{ isMine && isTeacher ? '我的授课课表' : isMine ? '我的课表' : isTeacherView ? '教师课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
<p v-if="isMine && isTeacher">展示本人本学期所有授课安排仅采用教务处已发布课表</p> <p v-if="isMine && isTeacher">展示本人本学期所有授课安排仅采用教务处已发布课表</p>
<p v-else-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p> <p v-else-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
@@ -849,6 +910,46 @@ onMounted(async () => {
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }} {{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
</small> </small>
<div v-if="isManager" class="batch-export-panel">
<div class="batch-export-heading">
<strong>批量导出</strong>
<span>按当前筛选结果多选最多 100 </span>
</div>
<el-select
v-model="batchResourceIds"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
placeholder="选择要批量导出的课表"
>
<el-option
v-for="item in batchResources"
:key="item.id"
:label="resourceLabel(item)"
:value="item.id"
/>
</el-select>
<div class="batch-export-actions">
<el-button size="small" @click="selectFilteredResources">全选筛选结果</el-button>
<el-button size="small" :disabled="!batchResourceIds.length" @click="batchResourceIds = []">清空</el-button>
<span>已选 {{ batchResourceIds.length }} </span>
<el-button
size="small"
type="primary"
:loading="batchExporting"
:disabled="!batchResourceIds.length"
@click="exportBatchExcel"
>批量导出 Excel</el-button>
<el-button
size="small"
:loading="batchExporting"
:disabled="!batchResourceIds.length"
@click="exportBatchPdf"
>批量导出 PDF</el-button>
</div>
</div>
</section> </section>
<section v-loading="loading" class="timetable-sheet"> <section v-loading="loading" class="timetable-sheet">
@@ -906,7 +1007,7 @@ onMounted(async () => {
</div> </div>
</div> </div>
<div v-if="timetable" ref="exportArea" class="timetable-export-area"> <div v-if="timetable" class="timetable-export-area">
<div class="sheet-meta"> <div class="sheet-meta">
<div> <div>
<strong>{{ timetable.subject.name }}</strong> <strong>{{ timetable.subject.name }}</strong>
@@ -1097,7 +1198,7 @@ onMounted(async () => {
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview"> <section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
<header> <header>
<div> <div>
<span>EXAM AGENDA</span> <span>考试安排</span>
<strong>已发布考试安排</strong> <strong>已发布考试安排</strong>
</div> </div>
<small> {{ sortedExamEntries.length }} · 按考试日期排序</small> <small> {{ sortedExamEntries.length }} · 按考试日期排序</small>
@@ -1133,7 +1234,7 @@ onMounted(async () => {
> >
<header> <header>
<div> <div>
<span>LAB SESSIONS</span> <span>实验安排</span>
<strong>个人实验安排</strong> <strong>个人实验安排</strong>
</div> </div>
<small> {{ sortedExperimentEntries.length }} · 预约变化会自动同步</small> <small> {{ sortedExperimentEntries.length }} · 预约变化会自动同步</small>
@@ -1170,7 +1271,7 @@ onMounted(async () => {
<div v-loading="calendarLoading" class="calendar-subscription"> <div v-loading="calendarLoading" class="calendar-subscription">
<template v-if="calendarSubscription?.isEnabled"> <template v-if="calendarSubscription?.isEnabled">
<div class="calendar-status active"> <div class="calendar-status active">
<span>LIVE FEED</span> <span>实时变动</span>
<div> <div>
<strong>订阅已启用</strong> <strong>订阅已启用</strong>
<small>课表考试和实验安排变更后日历客户端会在下次同步时自动更新</small> <small>课表考试和实验安排变更后日历客户端会在下次同步时自动更新</small>
@@ -1214,7 +1315,7 @@ onMounted(async () => {
<template v-else-if="calendarSubscription"> <template v-else-if="calendarSubscription">
<div class="calendar-status"> <div class="calendar-status">
<span>PRIVATE CALENDAR</span> <span>个人日历</span>
<div> <div>
<strong>将教学安排同步到常用日历</strong> <strong>将教学安排同步到常用日历</strong>
<small>启用后会生成仅属于你的长期订阅地址可随时重置或停用</small> <small>启用后会生成仅属于你的长期订阅地址可随时重置或停用</small>
@@ -1286,6 +1387,10 @@ onMounted(async () => {
.calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; } .calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; }
.calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; } .calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; } .timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
.batch-export-panel { display: grid; gap: 10px; width: min(760px, 100%); margin-top: 8px; padding: 14px; border: 1px solid #d7e3ea; border-radius: 8px; background: #f8fbfc; }
.batch-export-heading { display: flex; align-items: baseline; gap: 10px; color: #17324d; }
.batch-export-heading span, .batch-export-actions span { color: #718191; font-size: 12px; }
.batch-export-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; } .sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
.sheet-meta div { display: grid; gap: 3px; } .sheet-meta div { display: grid; gap: 3px; }
.sheet-meta strong { color: #17324d; } .sheet-meta strong { color: #17324d; }
+62 -16
View File
@@ -28,6 +28,13 @@ const editingRoles = ref<string[]>([])
const editingStaffNumber = ref('') const editingStaffNumber = ref('')
const editingCollegeId = ref<string>() const editingCollegeId = ref<string>()
const keyword = ref('') const keyword = ref('')
const roleFilter = ref<string>()
const collegeFilter = ref<string>()
const enabledFilter = ref<boolean>()
const loginFilter = ref<boolean>()
const page = ref(1)
const total = ref(0)
const pageSize = 20
const passwordForm = reactive({ newPassword: '', confirmPassword: '' }) const passwordForm = reactive({ newPassword: '', confirmPassword: '' })
const form = reactive({ const form = reactive({
userName: '', displayName: '', password: '', staffNumber: '', userName: '', displayName: '', password: '', staffNumber: '',
@@ -56,25 +63,33 @@ const editingScope = computed(() => {
) )
return scopeNames[effective] return scopeNames[effective]
}) })
const filteredUsers = computed(() => { async function load(resetPage = false) {
const value = keyword.value.trim().toLowerCase() if (resetPage) page.value = 1
if (!value) return users.value
return users.value.filter((user) =>
[user.userName, user.displayName, user.staffNumber, ...user.roles]
.filter(Boolean)
.some((item) => String(item).toLowerCase().includes(value)),
)
})
async function load() {
loading.value = true loading.value = true
try { try {
const [userRes, roleRes, collegeRes] = await Promise.all([ const [userRes, roleRes, collegeRes] = await Promise.all([
http.get('/users'), http.get('/users/roles'), http.get('/base-data/colleges'), http.get('/users', {
params: {
page: page.value,
pageSize,
keyword: keyword.value.trim() || undefined,
roleName: roleFilter.value,
collegeId: collegeFilter.value,
isEnabled: enabledFilter.value,
hasLoggedIn: loginFilter.value,
},
}),
http.get('/users/roles'),
http.get('/base-data/colleges'),
]) ])
users.value = userRes.data users.value = userRes.data.items
total.value = userRes.data.total
roles.value = roleRes.data roles.value = roleRes.data
colleges.value = collegeRes.data colleges.value = collegeRes.data
if (users.value.length === 0 && page.value > 1) {
page.value--
await load()
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -216,7 +231,29 @@ onMounted(load)
<section class="data-card"> <section class="data-card">
<div class="table-toolbar"> <div class="table-toolbar">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索账号、姓名或工号" /> <el-input
v-model="keyword"
:prefix-icon="Search"
clearable
placeholder="搜索账号、姓名、工号或角色"
@clear="load(true)"
@keyup.enter="load(true)"
/>
<el-select v-model="roleFilter" clearable placeholder="全部角色" @change="load(true)">
<el-option v-for="role in roles" :key="role.name" :label="role.name" :value="role.name" />
</el-select>
<el-select v-model="collegeFilter" clearable placeholder="全部学院" @change="load(true)">
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
</el-select>
<el-select v-model="enabledFilter" clearable placeholder="全部账号状态" @change="load(true)">
<el-option label="启用" :value="true" />
<el-option label="停用" :value="false" />
</el-select>
<el-select v-model="loginFilter" clearable placeholder="全部登录情况" @change="load(true)">
<el-option label="已登录过" :value="true" />
<el-option label="从未登录" :value="false" />
</el-select>
<el-button :icon="Search" @click="load(true)">查询</el-button>
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button> <el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button> <el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
<el-button :icon="Download" @click="exportRows">导出全部</el-button> <el-button :icon="Download" @click="exportRows">导出全部</el-button>
@@ -227,11 +264,11 @@ onMounted(load)
accept=".xlsx" accept=".xlsx"
@change="handleImport" @change="handleImport"
/> />
<span> {{ filteredUsers.length }} / {{ users.length }} 个账号</span> <span> {{ total }} 个账号</span>
</div> </div>
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="filteredUsers" :data="users"
> >
<el-table-column prop="userName" label="账号" min-width="130" /> <el-table-column prop="userName" label="账号" min-width="130" />
<el-table-column prop="displayName" label="姓名" min-width="120" /> <el-table-column prop="displayName" label="姓名" min-width="120" />
@@ -263,6 +300,15 @@ onMounted(load)
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section> </section>
<el-dialog v-model="dialogVisible" title="创建账号" width="540px"> <el-dialog v-model="dialogVisible" title="创建账号" width="540px">
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import http from '../api/http'
const route = useRoute()
const type = computed(() => route.params.type as 'classroom' | 'building')
const id = computed(() => route.params.id as string)
const data = ref<any>()
const now = ref(new Date())
const page = ref(0)
const pageSize = ref(1)
let timer = 0
let pageTimer = 0
const title = computed(() => type.value === 'classroom' ? data.value?.classroom : data.value?.building)
const timeText = computed(() => now.value.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
const displayItems = computed(() => type.value === 'classroom' ? data.value?.entries ?? [] : data.value?.rooms ?? [])
const pageCount = computed(() => Math.max(1, Math.ceil(displayItems.value.length / pageSize.value)))
const visibleItems = computed(() => displayItems.value.slice(page.value * pageSize.value, (page.value + 1) * pageSize.value))
async function load() { data.value = (await http.get(`/venue-displays/${type.value}s/${id.value}`)).data }
function fitPage() {
const header = window.innerWidth < 620 ? 220 : 170
const available = Math.max(1, window.innerHeight - header)
if (type.value === 'classroom') {
const columns = window.innerWidth >= 1280 ? 3 : window.innerWidth >= 760 ? 2 : 1
pageSize.value = Math.max(1, Math.floor(available / 190) * columns)
} else pageSize.value = Math.max(1, Math.floor(available / 58))
page.value = Math.min(page.value, pageCount.value - 1)
}
function nextPage() { page.value = (page.value + 1) % pageCount.value }
onMounted(async () => { await load(); fitPage(); window.addEventListener('resize', fitPage); timer = window.setInterval(() => { now.value = new Date(); if (now.value.getMinutes() % 5 === 0 && now.value.getSeconds() === 0) void load() }, 1000); pageTimer = window.setInterval(nextPage, 25000) })
onBeforeUnmount(() => { window.clearInterval(timer); window.clearInterval(pageTimer); window.removeEventListener('resize', fitPage) })
</script>
<template>
<main class="venue-display" :class="`venue-${type}`">
<header><div><span>明序大学 · 智慧教学空间</span><h1>{{ title?.name ?? '正在加载' }}</h1><p>{{ title?.campusName }} · {{ title?.buildingName ?? title?.code }} · {{ data?.term ?? '当前教学日' }}</p></div><time>{{ timeText }}<small>{{ data?.today }}</small></time></header>
<section v-if="type === 'classroom'" class="course-board">
<article v-for="entry in visibleItems" :key="`${entry.startPeriod}-${entry.courseName}`"><b> {{ entry.startPeriod }}-{{ entry.startPeriod + entry.periodCount - 1 }} </b><h2>{{ entry.courseName }}</h2><p>{{ entry.classNames?.join('、') }} · {{ entry.teacherNames?.join('、') }}</p></article>
<div v-if="data && !data.entries?.length" class="empty">今日暂无排课<br><small>此教室可供安排使用</small></div>
</section>
<section v-else class="room-board"><div class="periods"><span v-for="slot in data?.slots" :key="slot.periodNumber">{{ slot.periodNumber }}<small>{{ slot.startsAt?.slice(0,5) }}</small></span></div><article v-for="room in visibleItems" :key="room.code"><b>{{ room.name }}</b><span v-for="slot in data?.slots" :key="slot.periodNumber" :class="{ free: room.freePeriods?.includes(slot.periodNumber) }">{{ room.freePeriods?.includes(slot.periodNumber) ? '空闲' : '占用' }}</span></article></section>
<footer v-if="pageCount > 1"><span v-for="index in pageCount" :key="index" :class="{ active: index - 1 === page }"></span><b>{{ page + 1 }} / {{ pageCount }}</b></footer>
</main>
</template>
<style scoped>
.venue-display{min-height:100vh;padding:clamp(22px,4vw,62px);background:#0b1e2b;color:#eef7f5;font-family:"Microsoft YaHei",sans-serif}.venue-display header{display:flex;justify-content:space-between;gap:28px;padding-bottom:28px;border-bottom:1px solid #315464}.venue-display header span{color:#63d6bc;letter-spacing:.16em;font-size:12px}.venue-display h1{font-size:clamp(32px,5vw,68px);margin:10px 0}.venue-display p{margin:0;color:#abc5cf}.venue-display time{font:clamp(26px,4vw,52px) ui-monospace,monospace;text-align:right;color:#63d6bc}.venue-display time small{display:block;font:13px "Microsoft YaHei";color:#abc5cf;margin-top:8px}.course-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:18px;margin-top:32px}.course-board article{padding:26px;background:#123243;border-left:5px solid #63d6bc}.course-board b{color:#63d6bc}.course-board h2{font-size:clamp(24px,3vw,40px);margin:24px 0 12px}.empty{grid-column:1/-1;padding:80px 20px;text-align:center;font-size:30px;background:#102a38;color:#63d6bc}.room-board{margin-top:30px;overflow:auto}.periods,.room-board article{display:grid;grid-template-columns:minmax(150px,2fr) repeat(12,minmax(65px,1fr));gap:4px;min-width:950px}.periods{margin-left:0}.periods span{grid-column:span 1;text-align:center;color:#abc5cf}.periods span:first-child{grid-column:1}.periods small{display:block}.room-board article{margin-top:5px}.room-board article b,.room-board article span{padding:16px 8px;background:#213c49;text-align:center}.room-board article span.free{background:#1d6b5d;color:white;font-weight:bold}@media(max-width:620px){.venue-display header{display:block}.venue-display time{text-align:left;margin-top:18px}.venue-display{padding:22px}.course-board article{padding:20px}}
.venue-display footer{display:flex;align-items:center;justify-content:center;gap:7px;margin-top:20px;color:#abc5cf}.venue-display footer span{width:7px;height:7px;border-radius:50%;background:#42616d}.venue-display footer span.active{width:22px;border-radius:5px;background:#63d6bc}.venue-display footer b{margin-left:8px;font:12px ui-monospace,monospace}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import http from '../api/http'
import { downloadApiPostFile } from '../api/excel'
import { defaultAcademicTermId } from '../utils/academicTerms'
const terms=ref<any[]>([]); const buildings=ref<any[]>([]); const classrooms=ref<any[]>([]); const termId=ref(''); const buildingId=ref(''); const classroomId=ref('')
const selectedClassrooms=ref<string[]>([]); const selectedBuildings=ref<string[]>([])
const origin=window.location.origin
const classroomUrl=computed(()=>classroomId.value?`${origin}/venue-display/classroom/${classroomId.value}`:'')
const buildingUrl=computed(()=>buildingId.value?`${origin}/venue-display/building/${buildingId.value}`:'')
async function load(){const publicData=(await http.get('/timetables/options')).data;terms.value=publicData.terms;termId.value=defaultAcademicTermId(terms.value)??'';const data=(await http.get('/timetables/management/options',{params:{academicTermId:termId.value}})).data;buildings.value=data.buildings;classrooms.value=data.classrooms}
async function copy(value:string){await window.navigator.clipboard.writeText(value);ElMessage.success('展牌链接已复制')}
async function exportLinks(){if(!selectedClassrooms.value.length&&!selectedBuildings.value.length){ElMessage.warning('请先选择要导出的展牌链接');return}await downloadApiPostFile('/timetables/management/display-links/export.xlsx',{classroomIds:selectedClassrooms.value,buildingIds:selectedBuildings.value},'场地信息展牌链接.xlsx')}
onMounted(load)
</script>
<template><main class="venue-manager"><h2>场地信息展牌</h2><p>以下链接适用于教室屏幕和教学楼大屏它们不出现在普通用户导航中仅管理员在此配置与投放</p><section><h3>教室当天课程</h3><el-select v-model="classroomId" filterable placeholder="选择教室"><el-option v-for="x in classrooms" :key="x.id" :value="x.id" :label="`${x.buildingName} · ${x.name}`"/></el-select><el-input v-if="classroomUrl" :model-value="classroomUrl" readonly><template #append><el-button @click="copy(classroomUrl)">复制链接</el-button></template></el-input></section><section><h3>教学楼空余教室</h3><el-select v-model="buildingId" filterable placeholder="选择教学楼"><el-option v-for="x in buildings" :key="x.id" :value="x.id" :label="x.name"/></el-select><el-input v-if="buildingUrl" :model-value="buildingUrl" readonly><template #append><el-button @click="copy(buildingUrl)">复制链接</el-button></template></el-input></section><section><h3>批量导出展牌链接</h3><el-select v-model="selectedClassrooms" multiple filterable collapse-tags placeholder="选择教室"><el-option v-for="x in classrooms" :key="x.id" :value="x.id" :label="`${x.buildingName} · ${x.name}`"/></el-select><el-select v-model="selectedBuildings" multiple filterable collapse-tags placeholder="选择教学楼"><el-option v-for="x in buildings" :key="x.id" :value="x.id" :label="x.name"/></el-select><el-button type="primary" @click="exportLinks">导出 Excel 链接{{ selectedClassrooms.length + selectedBuildings.length }}</el-button></section></main></template>
<style scoped>.venue-manager{max-width:900px}.venue-manager>p{color:#637587}.venue-manager section{display:grid;gap:14px;margin-top:24px;padding:22px;border:1px solid #dce6eb;background:#fff}.venue-manager h3{margin:0;color:#17324d}</style>

Some files were not shown because too many files have changed in this diff Show More