Compare commits
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
|
||||
CLICKHOUSE_USER=jiaowu_analytics
|
||||
CLICKHOUSE_PASSWORD=
|
||||
|
||||
RABBITMQ_USER=jiaowu
|
||||
RABBITMQ_PASSWORD=
|
||||
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||
|
||||
+22
-3
@@ -2,6 +2,13 @@
|
||||
ASPNETCORE_ENVIRONMENT=Production
|
||||
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__ApplyMigrationsOnStartup=false
|
||||
Database__CommandTimeoutSeconds=30
|
||||
@@ -37,15 +44,15 @@ Cache__AnalyticsExpirationMinutes=3
|
||||
Cache__AnalyticsLocalExpirationSeconds=30
|
||||
Cache__MaximumPayloadKilobytes=2048
|
||||
|
||||
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
|
||||
# 应用日志建立接口与慢 SQL 基线;日志系统可按 DurationMs、RequestId、QueryName 聚合。
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__LogAllApiRequests=true
|
||||
Observability__SlowRequestThresholdMilliseconds=1000
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
||||
Observability__IncludeSqlText=false
|
||||
Observability__MaximumSqlTextLength=2000
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
||||
|
||||
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||
PerformanceReporting__Enabled=false
|
||||
@@ -55,6 +62,18 @@ PerformanceReporting__Enabled=false
|
||||
PerformanceReporting__CacheSeconds=30
|
||||
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__BackupWarningHours=24
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build self-contained platform packages
|
||||
- name: Build framework-dependent platform packages
|
||||
shell: bash
|
||||
run: |
|
||||
bash ./scripts/publish-platform-packages.sh \
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- Windows x64:`.zip`
|
||||
- Linux x64 / ARM64:`.tar.gz`
|
||||
- `SHA256SUMS`:发布包校验值
|
||||
- 自包含程序包无需预装 .NET 或 ASP.NET Core Runtime
|
||||
- 二进制程序包不包含 .NET 或 ASP.NET Core Runtime;请先安装匹配版本的 ASP.NET Core Runtime
|
||||
|
||||
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
|
||||
|
||||
|
||||
Submodule
+1
Submodule Academic-Affairs-System.wiki added at fe88e71716
@@ -2,6 +2,7 @@
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
|
||||
WORKDIR /source
|
||||
COPY versions.props ./
|
||||
COPY web/package.json web/package-lock.json ./web/
|
||||
RUN npm --prefix web ci
|
||||
COPY web/ ./web/
|
||||
@@ -11,6 +12,7 @@ FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
ARG TARGETARCH
|
||||
WORKDIR /source
|
||||
COPY global.json ./
|
||||
COPY versions.props ./
|
||||
COPY .env.example ./
|
||||
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
|
||||
RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH"
|
||||
|
||||
@@ -366,39 +366,36 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
|
||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||
|
||||
### OpenTelemetry 与慢查询定位
|
||||
### 慢接口与慢查询基线
|
||||
|
||||
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
||||
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
||||
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
||||
仍然有效。
|
||||
应用不依赖 OpenTelemetry。启用 `Observability` 后,会为每个 `/api` 请求写入结构化的
|
||||
方法、路径、终结点、状态码、耗时和请求号;超过阈值或返回 5xx 的请求会提升为 Warning。
|
||||
EF Core 数据库命令超过阈值时同样写入查询名称、SQL 模板哈希、数据库类型、耗时和相同的
|
||||
请求号。用日志平台按 `DurationMs` 聚合即可得到真实的 P50/P95/P99 和慢接口排行。
|
||||
|
||||
```text
|
||||
Observability__Enabled=true
|
||||
Observability__ServiceName=jiaowu-api
|
||||
Observability__LogAllApiRequests=true
|
||||
Observability__SlowRequestThresholdMilliseconds=1000
|
||||
Observability__SlowQueryThresholdMilliseconds=500
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
||||
```
|
||||
|
||||
数据库指标包括 `jiaowu.db.command.duration`、`jiaowu.db.command.slow` 和
|
||||
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
|
||||
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
|
||||
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
|
||||
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
|
||||
为关键 EF 查询添加 `TagWith("模块.查询名")` 后,慢 SQL 日志会直接显示稳定名称;无标签
|
||||
查询只显示操作类型和 SQL 模板哈希。默认 `Observability__IncludeSqlText=false`,不会把
|
||||
SQL 模板、参数值或连接串写入日志。仅在受控诊断窗口内临时启用 SQL 模板记录,并限制日志
|
||||
访问权限与保留时间。
|
||||
|
||||
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||
应用侧请求号用于关联接口和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
||||
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
||||
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
||||
写操作。参考 [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)。
|
||||
|
||||
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
||||
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
||||
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
||||
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
||||
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
||||
如部署环境另行提供 Prometheus 兼容指标源,超级管理员仍可在“组织与权限 → 运维与审计 →
|
||||
系统性能”查看其汇总数据。该页面只读查询外部指标源,浏览器不会接触其地址或令牌;结果
|
||||
默认缓存 30 秒。应用本身不会再通过 OpenTelemetry 向该指标源写入数据。
|
||||
|
||||
```text
|
||||
PerformanceReporting__Enabled=true
|
||||
@@ -415,6 +412,22 @@ Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置
|
||||
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||
`*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
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
@@ -455,7 +468,7 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
||||
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
||||
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
||||
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
||||
可接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||
可由现有日志平台或运行时指标采集器汇总。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
||||
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||
Cache__Enabled: "true"
|
||||
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__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||
@@ -47,6 +52,25 @@ x-json-logging: &json-logging
|
||||
max-file: "3"
|
||||
|
||||
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:
|
||||
image: rabbitmq:4.2-management-alpine
|
||||
restart: unless-stopped
|
||||
@@ -135,6 +159,8 @@ services:
|
||||
condition: service_started
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
@@ -165,5 +191,6 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
clickhouse-data:
|
||||
rabbitmq-data:
|
||||
backup-data:
|
||||
|
||||
@@ -39,7 +39,7 @@ for runtime in "${runtimes[@]}"; do
|
||||
dotnet publish "$project" \
|
||||
--configuration Release \
|
||||
--runtime "$runtime" \
|
||||
--self-contained true \
|
||||
--self-contained false \
|
||||
--output "$package_root" \
|
||||
-p:BuildFrontendOnPublish=false \
|
||||
-p:Version="$version" \
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
@@ -23,43 +24,73 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
// ═══════════════ Aggregated pending ═══════════════
|
||||
[HttpGet("pending")]
|
||||
[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 必须大于 0,pageSize 必须在 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)
|
||||
.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))
|
||||
.ToListAsync(ct));
|
||||
total += await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||
|
||||
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))
|
||||
.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)
|
||||
.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))
|
||||
.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)
|
||||
.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))
|
||||
.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)
|
||||
.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))
|
||||
.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)
|
||||
.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))
|
||||
.ToListAsync(ct));
|
||||
total += await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted).CountAsync(ct);
|
||||
|
||||
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))
|
||||
.ToListAsync(ct));
|
||||
total += await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted).CountAsync(ct);
|
||||
|
||||
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))
|
||||
.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 ═══════════════
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -33,14 +34,32 @@ public sealed class AttendanceController(
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> GetMyTasks(
|
||||
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()
|
||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||
if (academicTermId.HasValue)
|
||||
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
|
||||
.OrderBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -63,22 +82,44 @@ public sealed class AttendanceController(
|
||||
sheet.TeachingTaskId == x.Id)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
return Ok(new PagedResult<object>(result, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("sheets")]
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> GetSheets(
|
||||
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()
|
||||
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
|
||||
var sheets = await db.AttendanceSheets.AsNoTracking()
|
||||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||
var source = db.AttendanceSheets.AsNoTracking()
|
||||
.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)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -99,7 +140,7 @@ public sealed class AttendanceController(
|
||||
TotalCount = x.Records.Count
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(sheets);
|
||||
return Ok(new PagedResult<object>(sheets, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("sheets")]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
@@ -232,8 +233,49 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
}
|
||||
|
||||
[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(
|
||||
AppCacheKeys.BaseData("classes"),
|
||||
token => db.AdministrativeClasses.AsNoTracking()
|
||||
@@ -474,8 +516,54 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
||||
}
|
||||
|
||||
[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(
|
||||
AppCacheKeys.BaseData("classrooms"),
|
||||
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 Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -176,8 +177,13 @@ public sealed class CourseAdjustmentsController(
|
||||
public async Task<ActionResult> GetMine(
|
||||
Guid? academicTermId,
|
||||
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 source = db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x => x.ApplicantUserId == userId);
|
||||
@@ -187,10 +193,15 @@ public sealed class CourseAdjustmentsController(
|
||||
if (status.HasValue)
|
||||
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)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(AdjustmentProjection())
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
// ═══════════════ Pending reviews ═══════════════
|
||||
@@ -199,8 +210,13 @@ public sealed class CourseAdjustmentsController(
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> GetPendingReviews(
|
||||
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 source = db.CourseAdjustments.AsNoTracking()
|
||||
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
|
||||
@@ -211,10 +227,15 @@ public sealed class CourseAdjustmentsController(
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||
|
||||
return Ok(await source
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.SubmittedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(AdjustmentProjection())
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
// ═══════════════ Detail ═══════════════
|
||||
|
||||
@@ -399,8 +399,22 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
[HttpGet("offerings/{id:guid}/roster")]
|
||||
[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()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
@@ -428,11 +442,64 @@ public sealed class CourseSelectionsController(
|
||||
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
|
||||
return Forbid();
|
||||
|
||||
var students = await db.CourseEnrollments.AsNoTracking()
|
||||
var enrolledSource = db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
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)
|
||||
.Skip((studentPage - 1) * studentPageSize)
|
||||
.Take(studentPageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -445,12 +512,11 @@ public sealed class CourseSelectionsController(
|
||||
x.EnrolledAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var waitlistedRows = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.CourseSelectionOfferingId == id &&
|
||||
x.Status == CourseEnrollmentStatus.Waitlisted)
|
||||
var waitlistedRows = await waitlistSource
|
||||
.OrderBy(x => x.WaitlistedAt)
|
||||
.ThenBy(x => x.CreatedAt)
|
||||
.Skip((waitlistPage - 1) * waitlistPageSize)
|
||||
.Take(waitlistPageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -474,7 +540,7 @@ public sealed class CourseSelectionsController(
|
||||
item.MajorName,
|
||||
item.Grade,
|
||||
item.WaitlistedAt,
|
||||
Position = index + 1
|
||||
Position = (waitlistPage - 1) * waitlistPageSize + index + 1
|
||||
})
|
||||
.ToList();
|
||||
return Ok(new
|
||||
@@ -487,10 +553,24 @@ public sealed class CourseSelectionsController(
|
||||
offering.CourseName,
|
||||
offering.CourseNature,
|
||||
offering.Capacity,
|
||||
EnrolledCount = students.Count,
|
||||
EnrolledCount = enrolledCount,
|
||||
Students = students,
|
||||
WaitlistedCount = waitlist.Count,
|
||||
StudentPage = studentPage,
|
||||
StudentPageSize = studentPageSize,
|
||||
StudentTotal = enrolledTotal,
|
||||
WaitlistedCount = waitlistedCount,
|
||||
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 =
|
||||
offering.RoundStatus == CourseSelectionRoundStatus.Open &&
|
||||
(scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
|
||||
@@ -135,9 +135,179 @@ public sealed class DashboardController(
|
||||
currentTerm,
|
||||
counts,
|
||||
pending,
|
||||
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
|
||||
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(
|
||||
CurrentUserScope scope,
|
||||
Guid? restrictedCollegeId,
|
||||
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
|
||||
DashboardTerm? CurrentTerm,
|
||||
DashboardCounts Counts,
|
||||
DashboardPending Pending,
|
||||
DashboardGreeting Greeting,
|
||||
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(
|
||||
string Level,
|
||||
string Title,
|
||||
|
||||
@@ -391,81 +391,90 @@ public sealed class EvaluationsController(
|
||||
if (taskIds.Count == 0)
|
||||
return Ok(new { Tasks = Array.Empty<object>() });
|
||||
|
||||
var setups = await db.EvaluationSetups.AsNoTracking()
|
||||
.Where(x => x.Status == EvaluationSetupStatus.Closed)
|
||||
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var setup in setups)
|
||||
var taskSummaries = await db.EvaluationRecords.AsNoTracking()
|
||||
.WhereIn(taskIds, record => record.TeachingTaskId)
|
||||
.Where(record => record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed)
|
||||
.GroupBy(record => new
|
||||
{
|
||||
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
|
||||
.Where(r => r.EvaluationSetupId == setup.Id)
|
||||
.WhereIn(taskIds, r => r.TeachingTaskId)
|
||||
.Select(r => r.TeachingTaskId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var taskId in setupTaskIds)
|
||||
{
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.Id == taskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name
|
||||
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
|
||||
})
|
||||
.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)
|
||||
.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);
|
||||
|
||||
if (records.Count == 0) continue;
|
||||
|
||||
var dimensionResults = setup.Dimensions.Select(dim =>
|
||||
var dimensionSummaries = await (
|
||||
from score in db.EvaluationScores.AsNoTracking()
|
||||
join record in db.EvaluationRecords.AsNoTracking()
|
||||
on score.EvaluationRecordId equals record.Id
|
||||
join dimension in db.EvaluationDimensions.AsNoTracking()
|
||||
on score.EvaluationDimensionId equals dimension.Id
|
||||
where taskIds.Contains(record.TeachingTaskId) &&
|
||||
record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed
|
||||
group score by new
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
record.EvaluationSetupId,
|
||||
record.TeachingTaskId,
|
||||
dimension.Id,
|
||||
dimension.Name,
|
||||
dimension.MaxScore,
|
||||
dimension.SortOrder
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -594,3 +603,27 @@ public sealed record SubmitEvaluationRequest(
|
||||
public sealed record EvaluationScoreRequest(
|
||||
Guid DimensionId,
|
||||
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.Data;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
@@ -74,19 +76,36 @@ public sealed class ExperimentGradesController(
|
||||
item.Teacher!.TeacherNumber.Contains(keyword) ||
|
||||
item.Teacher.Name.Contains(keyword)));
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ThenBy(x => x.Code)
|
||||
var total = await source.Select(x => x.TeachingTaskId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
var taskIds = await source
|
||||
.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)
|
||||
.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
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
x.ScheduleWeek,
|
||||
ProjectStatus = x.Status,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
@@ -99,6 +118,14 @@ public sealed class ExperimentGradesController(
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ScheduleEntry = x.ScheduleEntry == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
x.ScheduleEntry.DayOfWeek,
|
||||
x.ScheduleEntry.StartPeriod,
|
||||
x.ScheduleEntry.PeriodCount
|
||||
},
|
||||
Sheet = x.GradeSheet == null
|
||||
? null
|
||||
: new
|
||||
@@ -122,6 +149,32 @@ public sealed class ExperimentGradesController(
|
||||
}
|
||||
})
|
||||
.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
|
||||
{
|
||||
Items = items,
|
||||
@@ -316,6 +369,15 @@ public sealed class ExperimentGradesController(
|
||||
ProjectCode = x.ExperimentProject!.Code,
|
||||
ProjectName = x.ExperimentProject.Name,
|
||||
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.TeachingTask!.TaskNumber,
|
||||
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||
@@ -357,6 +419,14 @@ public sealed class ExperimentGradesController(
|
||||
|
||||
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
||||
.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)
|
||||
recordsSource = recordsSource.Where(x =>
|
||||
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||
@@ -450,7 +520,7 @@ public sealed class ExperimentGradesController(
|
||||
sheet.CreatedAt,
|
||||
sheet.UpdatedAt
|
||||
},
|
||||
CanEdit = CanEdit(task) &&
|
||||
CanEdit = (CanEdit(task) || isBoundInstructor) &&
|
||||
sheet.Status is
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned,
|
||||
@@ -522,13 +592,18 @@ public sealed class ExperimentGradesController(
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ItemScores)
|
||||
.Include(x => x.Records)
|
||||
.ThenInclude(x => x.ExperimentSession)
|
||||
.ThenInclude(x => x!.Instructors)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
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 (
|
||||
ExperimentGradeSheetStatus.Draft or
|
||||
ExperimentGradeSheetStatus.Returned))
|
||||
@@ -574,6 +649,166 @@ public sealed class ExperimentGradesController(
|
||||
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")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> SyncParticipants(
|
||||
@@ -834,6 +1069,22 @@ public sealed class ExperimentGradesController(
|
||||
.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(
|
||||
ExperimentProject project,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -900,6 +1151,15 @@ public sealed class ExperimentGradesController(
|
||||
|
||||
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);
|
||||
return db.ExperimentProjects.Where(x =>
|
||||
taskIds.Contains(x.TeachingTaskId));
|
||||
@@ -922,6 +1182,22 @@ public sealed class ExperimentGradesController(
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
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) =>
|
||||
task.Teachers.Any(x =>
|
||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||
@@ -974,6 +1250,93 @@ public sealed class ExperimentGradesController(
|
||||
private static bool ValidScore(decimal? score) =>
|
||||
!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) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ public sealed class ExperimentsController(
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
x.CourseId,
|
||||
x.SchedulingMode,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermStartDate = x.AcademicTerm.StartDate,
|
||||
TermEndDate = x.AcademicTerm.EndDate,
|
||||
@@ -163,6 +164,19 @@ public sealed class ExperimentsController(
|
||||
,x.TeachingVenueNature
|
||||
})
|
||||
.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
|
||||
});
|
||||
}
|
||||
@@ -183,6 +197,11 @@ public sealed class ExperimentsController(
|
||||
int pageSize = 20)
|
||||
{
|
||||
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)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||
@@ -249,6 +268,8 @@ public sealed class ExperimentsController(
|
||||
x.Requirements,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.SelectionStartsAt,
|
||||
x.SelectionEndsAt,
|
||||
x.Status,
|
||||
x.PublishedAt,
|
||||
x.ClosedAt,
|
||||
@@ -294,6 +315,14 @@ public sealed class ExperimentsController(
|
||||
ClassroomName = item.Classroom!.Name,
|
||||
BuildingName = item.Classroom.Building!.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);
|
||||
@@ -337,6 +366,8 @@ public sealed class ExperimentsController(
|
||||
x.Requirements,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.SelectionStartsAt,
|
||||
x.SelectionEndsAt,
|
||||
x.Status,
|
||||
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||
@@ -376,6 +407,9 @@ public sealed class ExperimentsController(
|
||||
ClassroomName = item.Classroom!.Name,
|
||||
BuildingName = item.Classroom.Building!.Name,
|
||||
CampusName = item.Classroom.Building.Campus!.Name
|
||||
,InstructorNames = item.Instructors
|
||||
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
|
||||
.Select(instructor => instructor.Teacher!.Name)
|
||||
}),
|
||||
MyBooking = x.Bookings
|
||||
.Where(item =>
|
||||
@@ -430,7 +464,9 @@ public sealed class ExperimentsController(
|
||||
Description = Normalize(request.Description),
|
||||
Requirements = Normalize(request.Requirements),
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate
|
||||
EndDate = request.EndDate,
|
||||
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
||||
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
||||
};
|
||||
db.ExperimentProjects.Add(project);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -569,7 +605,9 @@ public sealed class ExperimentsController(
|
||||
Description = Normalize(request.Description),
|
||||
Requirements = Normalize(request.Requirements),
|
||||
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.StartDate = request.StartDate;
|
||||
project.EndDate = request.EndDate;
|
||||
project.SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt);
|
||||
project.SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
@@ -666,6 +706,39 @@ public sealed class ExperimentsController(
|
||||
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")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateProjectNames(
|
||||
@@ -722,6 +795,7 @@ public sealed class ExperimentsController(
|
||||
{
|
||||
var project = await ScopedProjects()
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Instructors)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
@@ -741,6 +815,14 @@ public sealed class ExperimentsController(
|
||||
(x.SessionDate < project.StartDate ||
|
||||
x.SessionDate > project.EndDate)))
|
||||
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.PublishedAt = DateTime.UtcNow;
|
||||
@@ -839,6 +921,10 @@ public sealed class ExperimentsController(
|
||||
cancellationToken),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
session.Instructors = request.InstructorTeacherIds!
|
||||
.Distinct()
|
||||
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||
.ToList();
|
||||
db.ExperimentSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -874,16 +960,14 @@ public sealed class ExperimentsController(
|
||||
if (request.Items.Count > 100)
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("单次最多安排 100 条实验场次。"));
|
||||
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
|
||||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
|
||||
request.Items.Count)
|
||||
if (request.Items.Any(x => x.ProjectId == Guid.Empty))
|
||||
return Task.FromResult<ActionResult>(
|
||||
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
|
||||
ValidationProblem("实验项目不能为空。"));
|
||||
|
||||
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
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()
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.AcademicTerm)
|
||||
@@ -927,6 +1011,10 @@ public sealed class ExperimentsController(
|
||||
cancellationToken),
|
||||
Notes = Normalize(item.Notes)
|
||||
};
|
||||
session.Instructors = item.InstructorTeacherIds!
|
||||
.Distinct()
|
||||
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||
.ToList();
|
||||
db.ExperimentSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
createdSessions.Add((project, session));
|
||||
@@ -970,12 +1058,11 @@ public sealed class ExperimentsController(
|
||||
.Include(x => x.ExperimentProject)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Include(x => x.Instructors)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null ||
|
||||
!await ScopedProjects().AnyAsync(
|
||||
x => x.Id == session.ExperimentProjectId,
|
||||
cancellationToken))
|
||||
return NotFound();
|
||||
if (session is null) return NotFound();
|
||||
if (!CanManageSession(session)) return Forbid();
|
||||
if (session.Status == ExperimentSessionStatus.Cancelled)
|
||||
return NoContent();
|
||||
|
||||
@@ -1037,12 +1124,11 @@ public sealed class ExperimentsController(
|
||||
{
|
||||
var session = await db.ExperimentSessions.AsNoTracking()
|
||||
.Include(x => x.ExperimentProject)
|
||||
.Include(x => x.Instructors)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (session is null ||
|
||||
!await ScopedProjects().AnyAsync(
|
||||
x => x.Id == session.ExperimentProjectId,
|
||||
cancellationToken))
|
||||
return NotFound();
|
||||
if (session is null) return NotFound();
|
||||
if (!CanManageSession(session)) return Forbid();
|
||||
|
||||
if (session.ExperimentProject!.ArrangementMode ==
|
||||
ExperimentArrangementMode.Centralized)
|
||||
@@ -1105,6 +1191,12 @@ public sealed class ExperimentsController(
|
||||
if (session.ExperimentProject.ArrangementMode !=
|
||||
ExperimentArrangementMode.SelfScheduled)
|
||||
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
|
||||
.TaskIdsForStudent(db, student.Id)
|
||||
.ContainsAsync(
|
||||
@@ -1210,11 +1302,34 @@ public sealed class ExperimentsController(
|
||||
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(
|
||||
ExperimentProject project,
|
||||
ExperimentSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var instructorProblem = await ValidateInstructorTeacherIdsAsync(
|
||||
project, request.InstructorTeacherIds, cancellationToken);
|
||||
if (instructorProblem is not null) return instructorProblem;
|
||||
if (request.SessionDate < project.StartDate ||
|
||||
request.SessionDate > project.EndDate)
|
||||
return "实验场次日期必须在项目开放日期范围内。";
|
||||
@@ -1442,6 +1557,26 @@ public sealed class ExperimentsController(
|
||||
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(
|
||||
CancellationToken cancellationToken) =>
|
||||
db.Students.FirstOrDefaultAsync(x =>
|
||||
@@ -1492,6 +1627,22 @@ public sealed class ExperimentsController(
|
||||
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)
|
||||
{
|
||||
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
||||
@@ -1510,6 +1661,12 @@ public sealed class ExperimentsController(
|
||||
return "实验安排方式无效。";
|
||||
if (request.StartDate > request.EndDate)
|
||||
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 ||
|
||||
request.EndDate > term.EndDate)
|
||||
return "实验项目日期必须在所属学期起止日期内。";
|
||||
@@ -1543,6 +1700,15 @@ public sealed class ExperimentsController(
|
||||
private static string? Normalize(string? value) =>
|
||||
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)
|
||||
{
|
||||
if (names is null || names.Count == 0) return null;
|
||||
@@ -1567,7 +1733,14 @@ public sealed record ExperimentProjectRequest(
|
||||
[MaxLength(1000)] string? Requirements,
|
||||
DateOnly StartDate,
|
||||
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(
|
||||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||
@@ -1579,7 +1752,9 @@ public sealed record ExperimentProjectBatchRequest(
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
IReadOnlyList<string>? Names = null,
|
||||
bool GenerateSequentialNames = false)
|
||||
bool GenerateSequentialNames = false,
|
||||
DateTime? SelectionStartsAt = null,
|
||||
DateTime? SelectionEndsAt = null)
|
||||
{
|
||||
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||||
new(
|
||||
@@ -1590,7 +1765,10 @@ public sealed record ExperimentProjectBatchRequest(
|
||||
Description,
|
||||
Requirements,
|
||||
StartDate,
|
||||
EndDate);
|
||||
EndDate,
|
||||
null,
|
||||
SelectionStartsAt,
|
||||
SelectionEndsAt);
|
||||
}
|
||||
|
||||
public sealed record ExperimentProjectBulkRequest(
|
||||
@@ -1606,7 +1784,8 @@ public sealed record ExperimentSessionRequest(
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 30)] int PeriodCount,
|
||||
[Range(1, 10000)] int Capacity,
|
||||
[MaxLength(500)] string? Notes);
|
||||
[MaxLength(500)] string? Notes,
|
||||
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
|
||||
|
||||
public sealed record ExperimentSessionBatchRequest(
|
||||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||
@@ -1618,7 +1797,8 @@ public sealed record ExperimentSessionBatchItem(
|
||||
[Range(1, 30)] int StartPeriod,
|
||||
[Range(1, 30)] int PeriodCount,
|
||||
[Range(1, 10000)] int Capacity,
|
||||
[MaxLength(500)] string? Notes)
|
||||
[MaxLength(500)] string? Notes,
|
||||
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null)
|
||||
{
|
||||
public ExperimentSessionRequest ToSessionRequest() =>
|
||||
new(
|
||||
@@ -1627,7 +1807,8 @@ public sealed record ExperimentSessionBatchItem(
|
||||
StartPeriod,
|
||||
PeriodCount,
|
||||
Capacity,
|
||||
Notes);
|
||||
Notes,
|
||||
InstructorTeacherIds);
|
||||
}
|
||||
|
||||
public sealed record ExperimentPeriodOption(
|
||||
|
||||
@@ -93,6 +93,9 @@ public sealed class GradeAnalyticsController(
|
||||
public async Task<ActionResult> GetTeachingClasses(
|
||||
Guid? academicTermId,
|
||||
string? keyword,
|
||||
Guid? collegeId = null,
|
||||
string? teacherKeyword = null,
|
||||
bool? riskOnly = null,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -105,12 +108,23 @@ public sealed class GradeAnalyticsController(
|
||||
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||
if (academicTermId.HasValue)
|
||||
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)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||
x.TeachingTask.Name.Contains(keyword) ||
|
||||
x.TeachingTask.Course!.Code.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 items = await source
|
||||
|
||||
@@ -706,7 +706,7 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||
|
||||
var rows = sheet.Records.Select(record =>
|
||||
{
|
||||
@@ -723,8 +723,8 @@ public sealed class GradesController(
|
||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||
values.Add(score);
|
||||
}
|
||||
values.Add(null);
|
||||
values.Add(record.FinalScore);
|
||||
values.Add(null);
|
||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||||
@@ -745,9 +745,9 @@ public sealed class GradesController(
|
||||
|
||||
var regularColumn = 4;
|
||||
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||
var totalColumn = regularColumn + itemColumns.Length + 1;
|
||||
var finalColumn = totalColumn + 1;
|
||||
var statusColumn = finalColumn + 1;
|
||||
var finalColumn = regularColumn + itemColumns.Length + 1;
|
||||
var totalColumn = finalColumn + 1;
|
||||
var statusColumn = totalColumn + 1;
|
||||
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||
(itemColumns[index], item.Weight)));
|
||||
@@ -819,7 +819,7 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class NotificationsController(
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -32,15 +33,24 @@ public sealed class OfficialDocumentsController(
|
||||
OfficialDocumentType? type,
|
||||
OfficialDocumentStatus? status,
|
||||
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();
|
||||
if (type.HasValue) source = source.Where(x => x.Type == type);
|
||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||
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)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -62,15 +72,21 @@ public sealed class OfficialDocumentsController(
|
||||
DownloadCount = x.Downloads.Count,
|
||||
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("students/options")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> StudentOptions(
|
||||
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();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
@@ -79,7 +95,10 @@ public sealed class OfficialDocumentsController(
|
||||
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
|
||||
{
|
||||
x.Id,
|
||||
@@ -90,7 +109,8 @@ public sealed class OfficialDocumentsController(
|
||||
MajorName = x.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -207,14 +227,24 @@ public sealed class OfficialDocumentsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> DownloadHistory(
|
||||
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))
|
||||
return NotFound();
|
||||
|
||||
return Ok(await db.OfficialDocumentDownloads.AsNoTracking()
|
||||
var source = db.OfficialDocumentDownloads.AsNoTracking()
|
||||
.Where(x => x.OfficialDocumentId == id)
|
||||
.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
|
||||
{
|
||||
x.Id,
|
||||
@@ -224,7 +254,8 @@ public sealed class OfficialDocumentsController(
|
||||
x.IpAddress,
|
||||
x.UserAgent
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/invalidate")]
|
||||
|
||||
@@ -330,7 +330,7 @@ public sealed class OperationsController(
|
||||
{
|
||||
var ids = pageItems.Select(x => x.Id).ToArray();
|
||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.JobId))
|
||||
.WhereIn(ids, x => x.JobId)
|
||||
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
||||
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
||||
cancellationToken);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -20,10 +21,38 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
|
||||
[HttpGet("batches")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
||||
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
||||
public async Task<ActionResult> GetBatches(
|
||||
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 })
|
||||
.ToListAsync(ct));
|
||||
.ToListAsync(ct);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("batches")]
|
||||
[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);
|
||||
if (batch is null) return NotFound();
|
||||
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")]
|
||||
@@ -153,7 +182,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||
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);
|
||||
}
|
||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||
@@ -166,7 +195,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
batch.Status = OtherExamBatchStatus.Draft;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
batch.Results = inputs.Select(x =>
|
||||
var replacementResults = inputs.Select(x =>
|
||||
{
|
||||
var student = students[x.StudentNumber.Trim()];
|
||||
return new OtherExamResult
|
||||
@@ -180,6 +209,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
Notes = Normalize(x.Notes)
|
||||
};
|
||||
}).ToList();
|
||||
db.OtherExamResults.AddRange(replacementResults);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return null;
|
||||
@@ -209,13 +239,20 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||
_ => 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)
|
||||
{
|
||||
if (Normalize(notes)?.Length > 500) return "备注不能超过 500 个字符。";
|
||||
return b.MetricKind switch
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
||||
|
||||
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
|
||||
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}")]
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
||||
GetAutomaticScheduleJob(
|
||||
|
||||
@@ -17,17 +17,53 @@ public sealed class StudentStatusChangesController(
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
[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();
|
||||
return Ok(await source.OrderByDescending(x => x.SubmittedAt).Select(x => new
|
||||
{
|
||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
CollegeName = 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));
|
||||
var userScope = currentUserDataScope.Current;
|
||||
var total = await source.CountAsync(token);
|
||||
var actionableTotal = await source.CountAsync(change =>
|
||||
(change.State == StudentStatusChangeState.Submitted &&
|
||||
userScope.IsInRole(SystemRoles.Counselor)) ||
|
||||
(change.State == StudentStatusChangeState.CounselorApproved &&
|
||||
userScope.IsInRole(SystemRoles.CollegeAdmin)) ||
|
||||
(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")]
|
||||
@@ -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(
|
||||
StudentStatusChangeType Type,
|
||||
[Required, MinLength(10), MaxLength(1000)] string Reason);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -46,17 +47,26 @@ public sealed class TeacherCourseApplicationsController(
|
||||
[Authorize(Roles = SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMine(
|
||||
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);
|
||||
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
||||
var source = db.TeacherCourseApplications.AsNoTracking()
|
||||
.Where(x => x.TeacherId == teacher.Id);
|
||||
if (academicTermId.HasValue)
|
||||
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)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -72,7 +82,8 @@ public sealed class TeacherCourseApplicationsController(
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpPost("mine")]
|
||||
@@ -144,15 +155,24 @@ public sealed class TeacherCourseApplicationsController(
|
||||
public async Task<ActionResult> GetReviews(
|
||||
Guid? academicTermId,
|
||||
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();
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
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)
|
||||
.ThenByDescending(x => x.SubmittedAt)
|
||||
.ThenBy(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -172,7 +192,8 @@ public sealed class TeacherCourseApplicationsController(
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("assignment-options")]
|
||||
|
||||
@@ -171,6 +171,95 @@ public sealed class TimetableManagementController(
|
||||
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(
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
@@ -205,8 +294,22 @@ public sealed class TimetableManagementController(
|
||||
value = value.Replace(character, '-');
|
||||
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]
|
||||
[Route("api/timetables")]
|
||||
public sealed class FreeClassroomsController(
|
||||
|
||||
@@ -45,6 +45,30 @@ public sealed class TimetablesController(
|
||||
CancellationToken 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}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetTeacherTimetable(
|
||||
@@ -76,6 +100,21 @@ public sealed class TimetablesController(
|
||||
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")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> ExportClassTimetable(
|
||||
@@ -92,6 +131,21 @@ public sealed class TimetablesController(
|
||||
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")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyTimetable(
|
||||
@@ -203,6 +257,39 @@ public sealed class TimetablesController(
|
||||
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")]
|
||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||
@@ -426,6 +513,13 @@ public sealed class TimetablesController(
|
||||
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)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -19,10 +20,54 @@ public sealed class UsersController(
|
||||
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
||||
{
|
||||
[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)
|
||||
.ThenBy(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
@@ -36,26 +81,34 @@ public sealed class UsersController(
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var user in users)
|
||||
{
|
||||
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
|
||||
result.Add(new
|
||||
{
|
||||
var userIds = users.Select(x => x.Id).ToArray();
|
||||
var roleRows = await (
|
||||
from userRole in db.UserRoles.AsNoTracking()
|
||||
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
where userIds.Contains(userRole.UserId)
|
||||
select new { userRole.UserId, RoleName = role.Name! })
|
||||
.ToListAsync(cancellationToken);
|
||||
var rolesByUser = roleRows
|
||||
.GroupBy(x => x.UserId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => (IReadOnlyCollection<string>)group
|
||||
.Select(x => x.RoleName)
|
||||
.OrderBy(x => x)
|
||||
.ToArray());
|
||||
|
||||
var items = users.Select(user => new UserListItem(
|
||||
user.Id,
|
||||
user.UserName,
|
||||
user.UserName ?? string.Empty,
|
||||
user.DisplayName,
|
||||
user.StaffNumber,
|
||||
user.CollegeId,
|
||||
user.IsEnabled,
|
||||
user.LastLoginAt,
|
||||
user.CreatedAt,
|
||||
Roles = identityUser is null
|
||||
? []
|
||||
: await userManager.GetRolesAsync(identityUser)
|
||||
});
|
||||
}
|
||||
return Ok(result);
|
||||
rolesByUser.GetValueOrDefault(user.Id, Array.Empty<string>())))
|
||||
.ToArray();
|
||||
return Ok(new PagedResult<UserListItem>(items, total, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("roles")]
|
||||
@@ -281,6 +334,16 @@ public sealed record CreateUserRequest(
|
||||
[MinLength(1)] string[] Roles);
|
||||
|
||||
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(
|
||||
[MaxLength(30)] string? StaffNumber,
|
||||
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);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -100,16 +101,22 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
db.WarningRecords.AddRange(generated);
|
||||
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)
|
||||
{
|
||||
var si = studentInfos.First(s => s.Id == w.StudentId);
|
||||
var r = rules.First(r => r.Type == w.Type);
|
||||
var si = studentsById[w.StudentId];
|
||||
var r = rulesByType[w.Type];
|
||||
if (r.NotifyStudent && si.UserId.HasValue)
|
||||
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);
|
||||
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")]
|
||||
[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();
|
||||
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 (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 ═══════════
|
||||
@@ -167,71 +203,90 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
// ═══════════ Detection logic ═══════════
|
||||
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
var existingStudentIds = await db.WarningRecords.AsNoTracking()
|
||||
.Where(record => record.AcademicTermId == termId && record.Type == rule.Type)
|
||||
.Select(record => record.StudentId)
|
||||
.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
|
||||
{
|
||||
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);
|
||||
if (failed >= rule.Threshold)
|
||||
{
|
||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.FailedCredits, ct)) continue;
|
||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
StudentId = item.StudentId,
|
||||
Type = rule.Type,
|
||||
TriggerValue = item.Failed,
|
||||
Detail = $"不及格课程 {item.Failed} 门,达到预警阈值 {rule.Threshold} 门。",
|
||||
AcademicTermId = termId
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
{
|
||||
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);
|
||||
if (grades.Count == 0) continue;
|
||||
var gpa = grades.Average(x => x.GradePoint!.Value);
|
||||
if (gpa < rule.Threshold)
|
||||
{
|
||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue;
|
||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||
var gpaByStudent = await db.GradeRecords.AsNoTracking()
|
||||
.Where(record => studentIds.Contains(record.StudentId) &&
|
||||
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
|
||||
record.GradeSheet.Status == GradeSheetStatus.Published &&
|
||||
record.GradePoint != null)
|
||||
.GroupBy(record => record.StudentId)
|
||||
.Select(group => new { StudentId = group.Key, Gpa = group.Average(record => record.GradePoint!.Value) })
|
||||
.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();
|
||||
}
|
||||
|
||||
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
{
|
||||
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);
|
||||
if (absent >= rule.Threshold)
|
||||
{
|
||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue;
|
||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||
var absencesByStudent = await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(record => studentIds.Contains(record.StudentId) && record.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && record.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (record.Status == AttendanceStatus.Absent || record.Status == AttendanceStatus.Late))
|
||||
.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))
|
||||
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Absent, Detail = $"缺勤/迟到 {item.Absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||
{
|
||||
var records = new List<WarningRecord>();
|
||||
foreach (var s in students)
|
||||
{
|
||||
var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
||||
if (total >= rule.Threshold)
|
||||
{
|
||||
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;
|
||||
var studentIds = students.Select(student => student.Id).ToArray();
|
||||
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);
|
||||
return failedByStudent.Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"累计不及格 {item.Failed} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }).ToList();
|
||||
}
|
||||
|
||||
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 ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", 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 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 AckBody([MaxLength(300)] string? Comment);
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed class ExperimentProject : EntityBase
|
||||
public string? Requirements { get; set; }
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
// 自主预约项目可单独控制学生可选场次的时间窗口(UTC 时刻)。
|
||||
public DateTime? SelectionStartsAt { get; set; }
|
||||
public DateTime? SelectionEndsAt { get; set; }
|
||||
public ExperimentProjectStatus Status { get; set; } =
|
||||
ExperimentProjectStatus.Draft;
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
@@ -43,6 +46,15 @@ public sealed class ExperimentSession : EntityBase
|
||||
ExperimentSessionStatus.Scheduled;
|
||||
public DateTime? CancelledAt { 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
|
||||
|
||||
@@ -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
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Instructors)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Where(x => ids.Contains(x.Id))
|
||||
@@ -282,6 +283,14 @@ public sealed class ExamPublishJobProcessor(
|
||||
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
|
||||
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 = "正在发布实验项目";
|
||||
|
||||
@@ -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.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
@@ -9,31 +7,10 @@ namespace Jiaowu.Api.Infrastructure.Observability;
|
||||
|
||||
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
ObservabilityOptions options,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||
: 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(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
@@ -138,82 +115,41 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
var queryName = GetQueryName(command.CommandText);
|
||||
var statementHash = GetStatementHash(command.CommandText);
|
||||
var provider = GetProviderName(command);
|
||||
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
||||
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 requestId = httpContextAccessor.HttpContext?.TraceIdentifier ?? "background";
|
||||
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)
|
||||
{
|
||||
logger.LogError(
|
||||
"Database command failed after {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"error {ErrorType}, trace {TraceId}).",
|
||||
"error {ErrorType}, request {RequestId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
errorType,
|
||||
traceId);
|
||||
requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||
return;
|
||||
|
||||
SlowCommandCount.Add(1, tags);
|
||||
if (options.IncludeSqlText)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}). " +
|
||||
"request {RequestId}). " +
|
||||
"SQL template: {SqlTemplate}",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId,
|
||||
requestId,
|
||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||
}
|
||||
else
|
||||
@@ -221,13 +157,13 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
logger.LogWarning(
|
||||
"Slow database command took {DurationMs:F1} ms: " +
|
||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||
"trace {TraceId}).",
|
||||
"request {RequestId}).",
|
||||
durationMilliseconds,
|
||||
queryName,
|
||||
commandKind,
|
||||
provider,
|
||||
statementHash,
|
||||
traceId);
|
||||
requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,9 +207,6 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
||||
return "other_sql";
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
private static string Truncate(string value, int maximumLength) =>
|
||||
value.Length <= maximumLength
|
||||
? value
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class ObservabilityOptions
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
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 bool IncludeSqlText { get; set; }
|
||||
public int MaximumSqlTextLength { get; set; } = 2000;
|
||||
|
||||
@@ -51,6 +51,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<ClassroomReservation>();
|
||||
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||
public DbSet<ExperimentSessionInstructor> ExperimentSessionInstructors =>
|
||||
Set<ExperimentSessionInstructor>();
|
||||
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
||||
Set<ExperimentGradeSheet>();
|
||||
@@ -166,6 +168,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
configurationBuilder.Properties<TimeOnly>()
|
||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||
.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)
|
||||
@@ -682,6 +692,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
entity.Property(x => x.Description).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
|
||||
@@ -797,6 +809,19 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.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 =>
|
||||
{
|
||||
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
|
||||
@@ -1086,6 +1111,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
});
|
||||
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.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
@@ -1112,6 +1141,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<ExamRoomAssignment>(entity =>
|
||||
{
|
||||
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 })
|
||||
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||
entity.HasIndex(x => new
|
||||
@@ -1200,6 +1231,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
});
|
||||
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.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
||||
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.Content).HasColumnType("longtext");
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
||||
// Each inbox query starts with its recipient. Keep the selected
|
||||
// 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.CreatedAt);
|
||||
entity.HasOne(x => x.MessageDispatch)
|
||||
@@ -1643,3 +1680,15 @@ public sealed class TimeOnlyTimeSpanConverter()
|
||||
: ValueConverter<TimeOnly, TimeSpan>(
|
||||
time => time.ToTimeSpan(),
|
||||
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";
|
||||
private const string ExperimentCourseGradesMigration =
|
||||
"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)
|
||||
{
|
||||
@@ -731,6 +735,29 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ExperimentCourseGradesMigration,
|
||||
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
|
||||
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(
|
||||
@@ -2999,6 +3026,15 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""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 =
|
||||
[
|
||||
"""
|
||||
@@ -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 =
|
||||
[
|
||||
"""
|
||||
|
||||
+6936
File diff suppressed because it is too large
Load Diff
+63
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
+6991
File diff suppressed because it is too large
Load Diff
+81
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-2
@@ -2520,6 +2520,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int?>("ScheduleWeek")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("SelectionEndsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("SelectionStartsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
@@ -2596,6 +2602,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -3417,9 +3451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -5968,6 +6004,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
b.Navigation("Bookings");
|
||||
|
||||
b.Navigation("Instructors");
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
private static readonly TimeZoneInfo ChinaTimeZone = ResolveChinaTimeZone();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -25,22 +27,32 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
||||
|
||||
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var dow = (int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek;
|
||||
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var chinaNow = TimeZoneInfo.ConvertTime(
|
||||
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
|
||||
.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))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinute)
|
||||
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinuteUtc)
|
||||
continue;
|
||||
|
||||
rule.LastCheckAt = now;
|
||||
rule.LastCheckAt = nowUtc;
|
||||
await db.SaveChangesAsync(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);
|
||||
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)
|
||||
{
|
||||
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.Range("A1:H1").Merge();
|
||||
@@ -153,9 +179,17 @@ public static class TimetableExcelExporter
|
||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||
sheet.PageSetup.FitToPages(1, 0);
|
||||
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) =>
|
||||
|
||||
@@ -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 => "已发布",
|
||||
_ => "已归档"
|
||||
};
|
||||
}
|
||||
@@ -47,11 +47,6 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<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="RabbitMQ.Client" Version="7.2.2" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||
|
||||
+57
-34
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
@@ -6,6 +7,7 @@ using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Configuration;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Analytics;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Middleware;
|
||||
@@ -19,15 +21,13 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
@@ -101,12 +101,30 @@ var observabilityOptions = builder.Configuration
|
||||
var performanceReportingOptions = builder.Configuration
|
||||
.GetSection(PerformanceReportingOptions.SectionName)
|
||||
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||
var clickHouseAnalyticsOptions = builder.Configuration
|
||||
.GetSection(ClickHouseAnalyticsOptions.SectionName)
|
||||
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
|
||||
var rabbitMqOptions = builder.Configuration
|
||||
.GetSection(RabbitMqOptions.SectionName)
|
||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||
var ssoOptions = builder.Configuration
|
||||
.GetSection(SsoOptions.SectionName)
|
||||
.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 &&
|
||||
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
||||
@@ -158,6 +176,7 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
||||
observabilityOptions.ServiceName.Length > 100 ||
|
||||
observabilityOptions.SlowRequestThresholdMilliseconds is < 1 or > 60000 ||
|
||||
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
||||
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
||||
{
|
||||
@@ -187,15 +206,6 @@ if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
|
||||
"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 ||
|
||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||
@@ -252,6 +262,19 @@ if (backgroundJobOptions.UsesRabbitMq &&
|
||||
{
|
||||
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 &&
|
||||
!builder.Environment.IsDevelopment() &&
|
||||
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -282,14 +305,21 @@ builder.Services.AddSingleton(backgroundJobOptions);
|
||||
builder.Services.AddSingleton(operationsOptions);
|
||||
builder.Services.AddSingleton(observabilityOptions);
|
||||
builder.Services.AddSingleton(performanceReportingOptions);
|
||||
builder.Services.AddSingleton(clickHouseAnalyticsOptions);
|
||||
builder.Services.AddSingleton(rabbitMqOptions);
|
||||
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||
{
|
||||
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||
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.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||
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");
|
||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
@@ -441,6 +449,7 @@ builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
||||
builder.Services.AddHostedService<ClickHouseAnalyticsProjectionWorker>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
@@ -530,6 +539,18 @@ if (ssoOptions.Enabled)
|
||||
});
|
||||
}
|
||||
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 =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
@@ -644,6 +665,7 @@ builder.Services.AddSwaggerGen(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseResponseCompression();
|
||||
app.Use(async (context, next) =>
|
||||
@@ -699,6 +721,7 @@ app.UseStaticFiles(new StaticFileOptions
|
||||
});
|
||||
app.UseCors("Web");
|
||||
app.UseRateLimiter();
|
||||
app.UseMiddleware<SlowRequestLoggingMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<AuditMiddleware>();
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"Observability": {
|
||||
"Enabled": true,
|
||||
"ServiceName": "jiaowu-api",
|
||||
"LogAllApiRequests": true,
|
||||
"SlowRequestThresholdMilliseconds": 1000,
|
||||
"SlowQueryThresholdMilliseconds": 500,
|
||||
"IncludeSqlText": false,
|
||||
"MaximumSqlTextLength": 2000
|
||||
@@ -39,6 +41,17 @@
|
||||
"SlowDatabaseMetric": "jiaowu_db_command_slow_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": {
|
||||
"BackupDirectory": "data/backups",
|
||||
"BackupWarningHours": 24,
|
||||
@@ -94,6 +107,12 @@
|
||||
"FrontendBaseUrl": "",
|
||||
"CallbackUrl": ""
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"TrustedProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"capacitor://localhost",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
@@ -11,6 +12,40 @@ namespace Jiaowu.Api.Tests;
|
||||
|
||||
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]
|
||||
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||
{
|
||||
@@ -298,4 +333,14 @@ public sealed class ApprovalsControllerTests
|
||||
DataScope.Self,
|
||||
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.Persistence;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -40,7 +40,7 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ef_command_records_duration_metric()
|
||||
public async Task Ef_command_executes_with_logging_interceptor()
|
||||
{
|
||||
await using var connection =
|
||||
new SqliteConnection("Data Source=:memory:");
|
||||
@@ -52,25 +52,9 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||
await using (var setupDb = new AppDbContext(setupOptions))
|
||||
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(
|
||||
new ObservabilityOptions(),
|
||||
new HttpContextAccessor(),
|
||||
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
||||
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
@@ -82,7 +66,6 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
||||
.TagWith("Observability.Tests.TermCount")
|
||||
.CountAsync();
|
||||
|
||||
Assert.NotNull(recordedDuration);
|
||||
Assert.True(recordedDuration >= 0);
|
||||
Assert.Equal(0, await db.AcademicTerms.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -72,6 +74,56 @@ public sealed class ExperimentGradesControllerTests
|
||||
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]
|
||||
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
|
||||
{
|
||||
@@ -146,10 +198,11 @@ public sealed class ExperimentGradesControllerTests
|
||||
1,
|
||||
10,
|
||||
CancellationToken.None));
|
||||
Assert.Equal(12, Property<int>(page.Value, "Total"));
|
||||
Assert.Equal(10, Property<System.Collections.IEnumerable>(
|
||||
page.Value,
|
||||
"Items").Cast<object>().Count());
|
||||
Assert.Equal(1, Property<int>(page.Value, "Total"));
|
||||
var course = Assert.Single(Property<System.Collections.IEnumerable>(
|
||||
page.Value, "Items").Cast<object>());
|
||||
Assert.Equal(12, Property<System.Collections.IEnumerable>(
|
||||
course, "Projects").Cast<object>().Count());
|
||||
|
||||
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
||||
null,
|
||||
@@ -162,6 +215,89 @@ public sealed class ExperimentGradesControllerTests
|
||||
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) =>
|
||||
Assert.IsAssignableFrom<T>(
|
||||
value!.GetType().GetProperty(name)!.GetValue(value));
|
||||
|
||||
@@ -161,7 +161,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null),
|
||||
null,
|
||||
[fixture.Teacher.Id]),
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[1],
|
||||
fixture.Classroom.Id,
|
||||
@@ -169,7 +170,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null)
|
||||
null,
|
||||
[fixture.Teacher.Id])
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -202,7 +204,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null),
|
||||
null,
|
||||
[fixture.Teacher.Id]),
|
||||
new ExperimentSessionBatchItem(
|
||||
projectIds[1],
|
||||
fixture.SecondClassroom.Id,
|
||||
@@ -210,7 +213,8 @@ public sealed class ExperimentsControllerTests
|
||||
1,
|
||||
2,
|
||||
20,
|
||||
null)
|
||||
null,
|
||||
[fixture.Teacher.Id])
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -218,6 +222,65 @@ public sealed class ExperimentsControllerTests
|
||||
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]
|
||||
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
|
||||
{
|
||||
@@ -246,6 +309,35 @@ public sealed class ExperimentsControllerTests
|
||||
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]
|
||||
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||
{
|
||||
@@ -480,6 +572,7 @@ public sealed class ExperimentsControllerTests
|
||||
AppDbContext db,
|
||||
AcademicTerm term,
|
||||
TeachingTask task,
|
||||
Teacher teacher,
|
||||
Classroom classroom,
|
||||
Classroom secondClassroom,
|
||||
ScheduleEntry scheduleEntry,
|
||||
@@ -490,6 +583,7 @@ public sealed class ExperimentsControllerTests
|
||||
Db = db;
|
||||
Term = term;
|
||||
Task = task;
|
||||
Teacher = teacher;
|
||||
Classroom = classroom;
|
||||
SecondClassroom = secondClassroom;
|
||||
ScheduleEntry = scheduleEntry;
|
||||
@@ -501,6 +595,7 @@ public sealed class ExperimentsControllerTests
|
||||
public AppDbContext Db { get; }
|
||||
public AcademicTerm Term { get; }
|
||||
public TeachingTask Task { get; }
|
||||
public Teacher Teacher { get; }
|
||||
public Classroom Classroom { get; }
|
||||
public Classroom SecondClassroom { get; }
|
||||
public ScheduleEntry ScheduleEntry { get; }
|
||||
@@ -571,7 +666,8 @@ public sealed class ExperimentsControllerTests
|
||||
{
|
||||
TeacherNumber = "T2099",
|
||||
Name = "实验教师",
|
||||
CollegeId = college.Id
|
||||
CollegeId = college.Id,
|
||||
UserId = manager.Id
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
@@ -626,7 +722,15 @@ public sealed class ExperimentsControllerTests
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 40,
|
||||
Status = TeachingTaskStatus.Published
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
]
|
||||
};
|
||||
db.AddRange(
|
||||
manager,
|
||||
@@ -683,6 +787,7 @@ public sealed class ExperimentsControllerTests
|
||||
db,
|
||||
term,
|
||||
task,
|
||||
teacher,
|
||||
classroom,
|
||||
secondClassroom,
|
||||
scheduleEntry,
|
||||
@@ -715,7 +820,13 @@ public sealed class ExperimentsControllerTests
|
||||
"携带校园卡。",
|
||||
Term.StartDate,
|
||||
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(
|
||||
ExperimentArrangementMode mode) =>
|
||||
@@ -742,7 +853,8 @@ public sealed class ExperimentsControllerTests
|
||||
startPeriod,
|
||||
periodCount,
|
||||
capacity,
|
||||
null);
|
||||
null,
|
||||
[Teacher.Id]);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
@@ -83,8 +84,12 @@ public sealed class GradesPaginationTests
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = tasks[0].Id,
|
||||
RegularWeight = 30,
|
||||
FinalWeight = 70,
|
||||
RegularWeight = 20,
|
||||
FinalWeight = 60,
|
||||
Items =
|
||||
[
|
||||
new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 }
|
||||
],
|
||||
Records = students.Select(student => new GradeRecord
|
||||
{
|
||||
StudentId = student.Id
|
||||
@@ -129,6 +134,18 @@ public sealed class GradesPaginationTests
|
||||
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
|
||||
.GetValue(secondDetail.Value)!;
|
||||
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) =>
|
||||
|
||||
@@ -234,6 +234,53 @@ public sealed class NotificationsControllerTests
|
||||
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]
|
||||
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");
|
||||
|
||||
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.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), result.Content);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
|
||||
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(
|
||||
sheet.CellsUsed(),
|
||||
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
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<JiaowuBackendVersion>2.3.2</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.3.2</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.3.2</JiaowuSwaggerVersion>
|
||||
<JiaowuBackendVersion>2.4.0</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.4.0</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.4.0</JiaowuSwaggerVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
Generated
+9
-9
@@ -19,7 +19,7 @@
|
||||
"ckeditor5": "^48.3.1",
|
||||
"dompurify": "^3.4.12",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"element-plus": "2.14.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"pinia": "^4.0.2",
|
||||
@@ -2936,14 +2936,14 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.14.4",
|
||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.4.tgz",
|
||||
"integrity": "sha512-vMKR9tFcLeNrJgFXA3zhUn6YuRKUQW9d0btakBR8U1Iq8MfzkjMOGcgs5de2VgiLldHt69brmuBHpxc3bK4ZgQ==",
|
||||
"version": "2.14.3",
|
||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
|
||||
"integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.2.0",
|
||||
"@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",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -2955,7 +2955,7 @@
|
||||
"lodash-unified": "^1.0.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-wheel-es": "^1.2.0",
|
||||
"vue-component-type-helpers": "^3.3.9"
|
||||
"vue-component-type-helpers": "^3.3.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.7"
|
||||
@@ -4188,9 +4188,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
|
||||
+4
-1
@@ -28,7 +28,7 @@
|
||||
"ckeditor5": "^48.3.1",
|
||||
"dompurify": "^3.4.12",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"element-plus": "2.14.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"pinia": "^4.0.2",
|
||||
@@ -48,5 +48,8 @@
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.1.1",
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"overrides": {
|
||||
"lodash-es": "4.17.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ export async function downloadApiFile(
|
||||
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) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
Vendored
+1
@@ -54,6 +54,7 @@ declare module 'vue' {
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
NotificationRichTextEditor: typeof import('./components/NotificationRichTextEditor.vue')['default']
|
||||
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
|
||||
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
||||
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>
|
||||
@@ -2,31 +2,29 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import http from '../api/http'
|
||||
|
||||
interface SystemVersionResponse {
|
||||
version: string
|
||||
}
|
||||
|
||||
const backendVersion = ref('获取中')
|
||||
const currentYear = new Date().getFullYear()
|
||||
const frontendVersion = __APP_VERSION__
|
||||
const backendVersion = ref<string>()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await http.get<SystemVersionResponse>('/system/version')
|
||||
backendVersion.value = data.version ? `v${data.version}` : '未知'
|
||||
const { data } = await http.get<{ version?: string }>('/system/version')
|
||||
backendVersion.value = data.version?.trim() || undefined
|
||||
} catch {
|
||||
backendVersion.value = '未知'
|
||||
// 页脚版本信息不应影响业务页面,也不应为暂时的网络异常额外弹出提示。
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="site-footer" aria-label="网站版权与版本信息">
|
||||
<footer class="site-footer" aria-label="网站版权信息">
|
||||
<div class="site-footer-inner">
|
||||
<p>© {{ currentYear }} 明序教务 · 版权所有</p>
|
||||
<div class="version-list" aria-label="系统版本">
|
||||
<span>前端版本 <b>v{{ frontendVersion }}</b></span>
|
||||
<span>后端版本 <b>{{ backendVersion }}</b></span>
|
||||
<div class="footer-meta">
|
||||
<span class="footer-note">教务管理与学业服务平台</span>
|
||||
<span class="footer-versions" aria-live="polite">
|
||||
前端 v{{ frontendVersion }} · 后端 v{{ backendVersion ?? '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -61,27 +59,9 @@ onMounted(async () => {
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
.footer-meta { display: flex; align-items: center; gap: 14px; }
|
||||
.footer-note { color: #aeb9d8; font-size: 11px; }
|
||||
.footer-versions { color: #c3cce5; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.site-footer-inner {
|
||||
@@ -93,13 +73,7 @@ onMounted(async () => {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-list span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.footer-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.footer-note, .footer-versions { font-size: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,7 @@ onBeforeUnmount(() => {
|
||||
<div class="smart-launch-mark" aria-hidden="true">
|
||||
<i v-for="index in 9" :key="index" />
|
||||
</div>
|
||||
<span class="smart-launch-brand">MINGXU ACADEMIC</span>
|
||||
<span class="smart-launch-brand">明序教务</span>
|
||||
<p>{{ greeting.label }}</p>
|
||||
<h1>{{ greeting.title }}</h1>
|
||||
<small>{{ greeting.subtitle }}</small>
|
||||
@@ -117,7 +117,7 @@ onBeforeUnmount(() => {
|
||||
color: #8fe4dc;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.smart-launch-content p {
|
||||
|
||||
@@ -151,6 +151,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
),
|
||||
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
||||
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
||||
...whenVisible(isTimetableManager.value, { path: '/venue-displays', label: '场地信息展牌' }),
|
||||
{
|
||||
path: '/class-timetable',
|
||||
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
|
||||
@@ -194,6 +195,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
|
||||
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader']),
|
||||
{ path: '/event-analytics', label: '运行数据分析' },
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -325,7 +330,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<strong>明序教务</strong>
|
||||
<small>ACADEMIC OFFICE</small>
|
||||
<small>教务管理与学业服务</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,7 +395,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<strong>明序教务</strong>
|
||||
<small>ACADEMIC OFFICE</small>
|
||||
<small>教务管理与学业服务</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭导航菜单" @click="mobileMenu = false">×</button>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
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 App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
@@ -31,6 +31,12 @@ const router = createRouter({
|
||||
component: () => import('../views/TimetableView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/venue-display/:type(classroom|building)/:id',
|
||||
name: 'venue-display',
|
||||
component: () => import('../views/VenueDisplayView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/activate',
|
||||
name: 'activate-account',
|
||||
@@ -210,6 +216,12 @@ const router = createRouter({
|
||||
component: () => import('../views/FreeClassroomsView.vue'),
|
||||
meta: { roles: ['Student'] },
|
||||
},
|
||||
{
|
||||
path: 'venue-displays',
|
||||
name: 'venue-displays',
|
||||
component: () => import('../views/VenueDisplaysView.vue'),
|
||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
||||
},
|
||||
{
|
||||
path: 'classroom-reservations',
|
||||
name: 'classroom-reservations',
|
||||
@@ -263,6 +275,14 @@ const router = createRouter({
|
||||
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',
|
||||
name: 'other-exams',
|
||||
|
||||
+136
-2
@@ -42,7 +42,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.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 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 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; }
|
||||
@@ -122,7 +122,7 @@ button { cursor: pointer; }
|
||||
.content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; }
|
||||
.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::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; }
|
||||
@@ -1496,3 +1496,137 @@ button { cursor: pointer; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::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; }
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
|
||||
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
|
||||
|
||||
const pending = ref<any[]>([])
|
||||
const pendingPage = ref(1)
|
||||
const pendingTotal = ref(0)
|
||||
const pendingPageSize = 20
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(isManager.value ? 'pending' : 'mine')
|
||||
@@ -168,7 +171,17 @@ const recordCount = computed(() =>
|
||||
async function load() {
|
||||
loading.value = true
|
||||
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) {
|
||||
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
||||
http.get('/approvals/my-courses'),
|
||||
@@ -358,7 +371,7 @@ onMounted(load)
|
||||
<el-segmented
|
||||
v-model="tab"
|
||||
:options="[
|
||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
||||
...(isManager ? [{ label: `待审批 (${pendingTotal})`, value: 'pending' }] : []),
|
||||
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
||||
]"
|
||||
/>
|
||||
@@ -389,6 +402,15 @@ onMounted(load)
|
||||
</div>
|
||||
</article>
|
||||
<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 v-if="tab === 'mine'" v-loading="loading">
|
||||
|
||||
@@ -77,6 +77,16 @@ const references = reactive<Record<string, any[]>>({
|
||||
campuses: [], colleges: [], majors: [], buildings: [], counselors: [],
|
||||
})
|
||||
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 canManage = computed(() =>
|
||||
@@ -90,9 +100,19 @@ const filteredRows = computed(() => {
|
||||
.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 pageSize = ref(20)
|
||||
const pagedRows = computed(() => filteredRows.value.slice(
|
||||
const total = ref(0)
|
||||
const serverPaged = computed(() =>
|
||||
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,
|
||||
))
|
||||
@@ -138,11 +158,42 @@ function resetForm(row?: Row) {
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(resetPage = true) {
|
||||
loading.value = true
|
||||
currentPage.value = 1
|
||||
if (resetPage) currentPage.value = 1
|
||||
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) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
@@ -162,6 +213,11 @@ async function loadReferences() {
|
||||
|
||||
async function changeTab() {
|
||||
keyword.value = ''
|
||||
Object.assign(listFilters, {
|
||||
collegeId: undefined, majorId: undefined, grade: undefined,
|
||||
campusId: undefined, buildingId: undefined, minimumCapacity: undefined,
|
||||
teachingVenueNature: undefined, isEnabled: undefined,
|
||||
})
|
||||
await load()
|
||||
}
|
||||
|
||||
@@ -308,7 +364,13 @@ onMounted(async () => {
|
||||
|
||||
watch(
|
||||
() => keyword.value,
|
||||
() => { currentPage.value = 1 },
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
if (keywordTimer) clearTimeout(keywordTimer)
|
||||
if (serverPaged.value) {
|
||||
keywordTimer = setTimeout(() => { void load(false) }, 250)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -357,7 +419,33 @@ watch(
|
||||
|
||||
<div class="table-toolbar">
|
||||
<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">
|
||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||
@@ -370,7 +458,7 @@ watch(
|
||||
@change="handleImport"
|
||||
/>
|
||||
</template>
|
||||
<span>共 {{ filteredRows.length }} 条</span>
|
||||
<span>共 {{ serverPaged ? total : filteredRows.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
@@ -494,13 +582,15 @@ watch(
|
||||
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="filteredRows.length > pageSize"
|
||||
v-if="(serverPaged ? total : filteredRows.length) > pageSize"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
class="table-pagination"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
: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>
|
||||
|
||||
|
||||
@@ -438,7 +438,7 @@ onMounted(async () => {
|
||||
<section class="reservation-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">TEACHING SPACE</p>
|
||||
<p class="eyebrow">教学场地</p>
|
||||
<h2>教室借用与预约</h2>
|
||||
<p>选择正式课表之外的空闲时段提交申请,由申请人所在学院审核。</p>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,11 @@ const sessionTask = ref<any>(null)
|
||||
const sessionLoading = ref(false)
|
||||
const termId = ref<string>()
|
||||
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({
|
||||
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}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(resetPages = false) {
|
||||
if (resetPages) {
|
||||
minePage.value = 1
|
||||
reviewPage.value = 1
|
||||
}
|
||||
loading.value = true
|
||||
reviewLoading.value = isManager.value
|
||||
try {
|
||||
if (isTeacher.value || (!isManager.value)) {
|
||||
adjustments.value = (await http.get('/course-adjustments/mine', {
|
||||
params: { academicTermId: termId.value },
|
||||
})).data
|
||||
const response = await http.get('/course-adjustments/mine', {
|
||||
params: { academicTermId: termId.value, page: minePage.value, pageSize },
|
||||
})
|
||||
adjustments.value = response.data.items
|
||||
mineTotal.value = response.data.total
|
||||
}
|
||||
if (isManager.value) {
|
||||
pendingReviews.value = (await http.get('/course-adjustments/pending-reviews', {
|
||||
params: { academicTermId: termId.value },
|
||||
})).data
|
||||
const response = await http.get('/course-adjustments/pending-reviews', {
|
||||
params: { academicTermId: termId.value, page: reviewPage.value, pageSize },
|
||||
})
|
||||
pendingReviews.value = response.data.items
|
||||
reviewTotal.value = response.data.total
|
||||
}
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
finally { loading.value = false }
|
||||
finally { loading.value = false; reviewLoading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@@ -255,12 +269,12 @@ function showSub(type: string) { return type === 'Substitute' }
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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-select>
|
||||
<el-segmented v-model="tab" :options="[
|
||||
@@ -307,6 +321,15 @@ function showSub(type: string) { return type === 'Substitute' }
|
||||
</footer>
|
||||
</article>
|
||||
<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>
|
||||
|
||||
<!-- Pending reviews -->
|
||||
@@ -345,6 +368,15 @@ function showSub(type: string) { return type === 'Substitute' }
|
||||
</footer>
|
||||
</article>
|
||||
<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>
|
||||
|
||||
<!-- Create dialog -->
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from '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 http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
echarts.use([
|
||||
AriaComponent,
|
||||
BarChart,
|
||||
CanvasRenderer,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
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 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) {
|
||||
return Number(value).toFixed(1)
|
||||
@@ -210,6 +231,7 @@ onBeforeUnmount(() => {
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="recommendation" class="action-advice"><span>ACTION ADVICE</span><p>{{ recommendation }}</p></section>
|
||||
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
|
||||
|
||||
<section v-if="rows.length" class="chart-panel">
|
||||
|
||||
@@ -40,6 +40,13 @@ const editingRoundId = ref('')
|
||||
const editingOfferingId = ref('')
|
||||
const roster = ref<any | null>(null)
|
||||
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 eligibleTotal = ref(0)
|
||||
const eligiblePage = ref(1)
|
||||
@@ -82,6 +89,8 @@ const selectedOfferings = computed(() =>
|
||||
const previewOffering = computed(() =>
|
||||
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 usedTaskIds = new Set(
|
||||
offerings.value
|
||||
@@ -540,6 +549,12 @@ async function deleteOffering(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
|
||||
await loadRoster(offering.id)
|
||||
}
|
||||
@@ -548,7 +563,18 @@ async function loadRoster(offeringId: string) {
|
||||
rosterLoading.value = true
|
||||
try {
|
||||
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
|
||||
} catch (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() {
|
||||
studentKeyword.value = ''
|
||||
selectedStudentIds.value = []
|
||||
@@ -1346,6 +1378,25 @@ onMounted(async () => {
|
||||
:closable="false"
|
||||
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-column prop="studentNumber" label="学号" width="130" />
|
||||
<el-table-column prop="name" label="姓名" width="90" />
|
||||
@@ -1363,6 +1414,12 @@ onMounted(async () => {
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无学生选课" /></template>
|
||||
</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">
|
||||
<div class="waitlist-panel-head">
|
||||
<div>
|
||||
@@ -1394,6 +1451,12 @@ onMounted(async () => {
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无候补学生" /></template>
|
||||
</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>
|
||||
</template>
|
||||
</el-drawer>
|
||||
@@ -1528,6 +1591,11 @@ onMounted(async () => {
|
||||
.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; }
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
@@ -1541,6 +1609,9 @@ onMounted(async () => {
|
||||
.waitlist-panel-head small { color: var(--muted); text-align: right; }
|
||||
|
||||
@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 small { text-align: left; }
|
||||
}
|
||||
|
||||
+201
-10
@@ -21,6 +21,7 @@ import {
|
||||
import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import StudentDashboardView from './StudentDashboardView.vue'
|
||||
import TeacherDashboardView from './TeacherDashboardView.vue'
|
||||
|
||||
interface DashboardData {
|
||||
audience: {
|
||||
@@ -57,9 +58,24 @@ interface DashboardData {
|
||||
classroomReservations: number
|
||||
generalApprovals: number
|
||||
}
|
||||
greeting: DashboardGreeting
|
||||
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 {
|
||||
key: string
|
||||
label: string
|
||||
@@ -68,24 +84,46 @@ interface DashboardLink {
|
||||
icon: Component
|
||||
}
|
||||
|
||||
interface WarningRecord {
|
||||
id: string
|
||||
studentName: string
|
||||
studentNumber: string
|
||||
className: string
|
||||
status: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const roles = computed(() => auth.user?.roles ?? [])
|
||||
const isStudentOverview = computed(() =>
|
||||
roles.value.includes('Student') &&
|
||||
!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 loadError = ref('')
|
||||
const data = ref<DashboardData | null>(null)
|
||||
const counselorWarnings = ref<WarningRecord[]>([])
|
||||
const now = ref(new Date())
|
||||
|
||||
function hasRole(...allowedRoles: string[]) {
|
||||
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) {
|
||||
if (!value) return null
|
||||
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(() =>
|
||||
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
|
||||
)
|
||||
@@ -351,6 +402,11 @@ async function loadDashboard() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
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 {
|
||||
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
|
||||
} finally {
|
||||
@@ -359,7 +415,7 @@ async function loadDashboard() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isStudentOverview.value) {
|
||||
if (isStudentOverview.value || isTeacherOverview.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
@@ -369,6 +425,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<StudentDashboardView v-if="isStudentOverview" />
|
||||
<TeacherDashboardView v-else-if="isTeacherOverview" />
|
||||
|
||||
<div v-else v-loading="loading" class="admin-dashboard">
|
||||
<el-result
|
||||
@@ -389,9 +446,9 @@ onMounted(async () => {
|
||||
<span>{{ data.audience.scopeName }}</span>
|
||||
<i>数据范围</i>
|
||||
</div>
|
||||
<p class="overview-eyebrow">ACADEMIC OPERATIONS</p>
|
||||
<h1>{{ data.audience.title }}</h1>
|
||||
<p class="overview-description">{{ data.audience.description }}</p>
|
||||
<p class="overview-eyebrow">教务工作概览</p>
|
||||
<h1>{{ data.greeting.title }}</h1>
|
||||
<p class="overview-description">{{ data.greeting.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
|
||||
@@ -436,6 +493,23 @@ onMounted(async () => {
|
||||
</div>
|
||||
</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="关键教学数据">
|
||||
<button
|
||||
v-for="metric in adminMetrics"
|
||||
@@ -450,11 +524,42 @@ onMounted(async () => {
|
||||
</button>
|
||||
</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">
|
||||
<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">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-index">01 / ACTION</span>
|
||||
<span class="panel-index">待办事项</span>
|
||||
<h2>需要处理</h2>
|
||||
<p>只显示当前角色和数据范围内可处理的事项。</p>
|
||||
</div>
|
||||
@@ -490,7 +595,7 @@ onMounted(async () => {
|
||||
<article class="dashboard-panel quick-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-index">02 / SHORTCUTS</span>
|
||||
<span class="panel-index">常用入口</span>
|
||||
<h2>常用工作</h2>
|
||||
<p>按当前角色整理的高频业务入口。</p>
|
||||
</div>
|
||||
@@ -517,9 +622,9 @@ onMounted(async () => {
|
||||
<section class="dashboard-panel readiness-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-index">03 / TERM READINESS</span>
|
||||
<h2>本学期运行状态</h2>
|
||||
<p>从任务发布到课表落地、成绩归档,快速发现业务断点。</p>
|
||||
<span class="panel-index">学期进度</span>
|
||||
<h2>本学期教学运行</h2>
|
||||
<p>关注教学任务、课表与成绩流程的完成情况。</p>
|
||||
</div>
|
||||
<span class="refresh-time">数据更新于 {{ new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
||||
</header>
|
||||
@@ -586,6 +691,62 @@ onMounted(async () => {
|
||||
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 {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -866,6 +1027,28 @@ onMounted(async () => {
|
||||
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,
|
||||
.quick-panel,
|
||||
.readiness-panel {
|
||||
@@ -1155,6 +1338,7 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
|
||||
.dashboard-work-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1170,6 +1354,13 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@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; }
|
||||
|
||||
.overview-hero {
|
||||
|
||||
@@ -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>
|
||||
@@ -2,14 +2,17 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
EditPen,
|
||||
Plus,
|
||||
Promotion,
|
||||
Refresh,
|
||||
Setting,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import {
|
||||
academicTermLabel,
|
||||
@@ -33,8 +36,8 @@ const collegeId = ref('')
|
||||
const projectKeyword = ref('')
|
||||
const projectPage = ref(1)
|
||||
const projectPageSize = ref(20)
|
||||
const projectTotal = ref(0)
|
||||
const projects = ref<any[]>([])
|
||||
const courseTotal = ref(0)
|
||||
const courses = ref<any[]>([])
|
||||
const studentResults = ref<any[]>([])
|
||||
const detailDrawer = ref(false)
|
||||
const detail = ref<any | null>(null)
|
||||
@@ -44,6 +47,7 @@ const recordKeyword = ref('')
|
||||
const schemeDialog = ref(false)
|
||||
const editingScheme = ref(false)
|
||||
const schemeProject = ref<any | null>(null)
|
||||
const importFileInput = ref<HTMLInputElement>()
|
||||
|
||||
const schemeForm = reactive({
|
||||
contributionWeight: 1,
|
||||
@@ -86,6 +90,18 @@ const modeLabels: Record<string, string> = {
|
||||
Centralized: '集中安排',
|
||||
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(() =>
|
||||
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
|
||||
@@ -196,8 +212,8 @@ async function load() {
|
||||
pageSize: projectPageSize.value,
|
||||
},
|
||||
})).data
|
||||
projects.value = data.items
|
||||
projectTotal.value = data.total
|
||||
courses.value = data.items
|
||||
courseTotal.value = data.total
|
||||
projectPage.value = data.page
|
||||
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() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
@@ -528,45 +576,52 @@ onMounted(async () => {
|
||||
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="grade-project-list">
|
||||
<article v-for="project in projects" :key="project.id" class="grade-project-card">
|
||||
<div class="project-mark">
|
||||
<span>{{ project.code }}</span>
|
||||
<b>{{ modeLabels[project.arrangementMode] }}</b>
|
||||
<section v-loading="loading" class="grade-course-list">
|
||||
<article v-for="course in courses" :key="course.teachingTaskId" class="grade-course-card">
|
||||
<header class="grade-course-head">
|
||||
<div>
|
||||
<span>{{ course.termName }} · {{ course.taskNumber }}</span>
|
||||
<h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
|
||||
<small>{{ course.teacherNames.join('、') || '教师待定' }} · {{ course.collegeName }}</small>
|
||||
</div>
|
||||
<div class="project-summary">
|
||||
<h3>{{ project.name }}</h3>
|
||||
<p>{{ project.courseCode }} · {{ project.courseName }}</p>
|
||||
<small>{{ project.taskNumber }} · {{ project.termName }} · {{ project.teacherNames.join('、') || '教师待定' }}</small>
|
||||
<div class="course-rollup">
|
||||
<b>{{ course.projectCount }}</b><span>个实验项目</span>
|
||||
<small v-if="course.sheetCount">{{ course.sheetCount }} 份成绩单 · {{ course.scoredCount }}/{{ course.studentCount }} 已计分</small>
|
||||
<small v-else>尚未建立成绩单</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>
|
||||
</header>
|
||||
<el-table :data="course.projects" size="small" class="course-project-table">
|
||||
<el-table-column label="实验项目" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="course-project-name">
|
||||
<b>{{ row.code }} · {{ row.name }}</b>
|
||||
<small>{{ projectScheduleLabel(row) }}</small>
|
||||
</div>
|
||||
<el-button type="primary" plain :icon="EditPen" @click="openDetail(project)">
|
||||
打开成绩单
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="sheet-empty">
|
||||
<b>尚未建立实验成绩单</b>
|
||||
<span>建立时会按集中名单或有效预约生成评分对象。</span>
|
||||
</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>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate(project)">
|
||||
建立成绩单
|
||||
</el-button>
|
||||
<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>
|
||||
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
|
||||
<el-empty v-if="!loading && !courses.length" description="当前筛选条件下没有可评分实验课程" />
|
||||
</section>
|
||||
<el-pagination
|
||||
v-model:current-page="projectPage"
|
||||
v-model:page-size="projectPageSize"
|
||||
:total="projectTotal"
|
||||
:total="courseTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@@ -637,6 +692,7 @@ onMounted(async () => {
|
||||
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
|
||||
<h3>{{ detail.projectName }}</h3>
|
||||
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
|
||||
<p class="project-schedule">{{ projectScheduleLabel(detail) }}</p>
|
||||
</div>
|
||||
<div class="workbench-status">
|
||||
<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()"
|
||||
/>
|
||||
|
||||
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
|
||||
|
||||
<footer class="workbench-actions">
|
||||
<div>
|
||||
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
|
||||
<span>开课学院:{{ detail.courseCollegeName }}</span>
|
||||
</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" type="primary" :icon="Promotion" @click="submitSheet">提交审核</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-input { width: min(320px, 100%); }
|
||||
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||
.grade-project-list { display: grid; gap: 12px; 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%); }
|
||||
.project-mark { display: grid; gap: 7px; }
|
||||
.project-mark span { overflow: hidden; color: #607b88; font: 700 10px/1.3 Consolas, monospace; text-overflow: ellipsis; }
|
||||
.project-mark b { color: var(--grade-blue); font-size: 12px; }
|
||||
.project-summary { min-width: 0; }
|
||||
.project-summary h3 { margin: 0 0 5px; color: var(--grade-ink); font-size: 17px; }
|
||||
.project-summary p { margin: 0 0 4px; color: #365f73; font-size: 13px; }
|
||||
.project-summary small { display: block; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
|
||||
.sheet-progress b { color: var(--grade-teal); }
|
||||
.sheet-empty b { color: #5d7480; }
|
||||
.grade-course-list { display: grid; gap: 10px; min-height: 180px; }
|
||||
.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%); }
|
||||
.grade-course-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: #f3f7f8; }
|
||||
.grade-course-head > div:first-child { min-width: 0; }
|
||||
.grade-course-head span { color: #607b88; font: 700 10px/1.3 Consolas, monospace; }
|
||||
.grade-course-head h3 { margin: 3px 0; color: var(--grade-ink); font-size: 15px; }
|
||||
.grade-course-head small { display: block; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.course-rollup { display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 5px; flex: none; text-align: right; }
|
||||
.course-rollup b { color: var(--grade-teal); font: 750 22px/1 Consolas, monospace; }
|
||||
.course-rollup > span { color: #55707b; font: 600 11px/1.2 inherit; }
|
||||
.course-rollup small { grid-column: 1 / -1; margin-top: 3px; color: #70858d; font-size: 10px; }
|
||||
.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-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; }
|
||||
@@ -902,9 +968,7 @@ onMounted(async () => {
|
||||
.workbench-actions b { color: #a24f36; font-size: 12px; }
|
||||
.workbench-actions span { color: var(--muted); font-size: 12px; }
|
||||
@media (max-width: 980px) {
|
||||
.grade-project-card { grid-template-columns: 110px minmax(0, 1fr) auto; }
|
||||
.sheet-progress, .sheet-empty { grid-column: 2; }
|
||||
.grade-project-card > .el-button { grid-column: 3; grid-row: 1 / span 2; }
|
||||
.grade-course-head { align-items: flex-start; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.assessment-flow { grid-template-columns: 1fr; }
|
||||
@@ -912,8 +976,8 @@ onMounted(async () => {
|
||||
.grade-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
|
||||
.grade-toolbar > span { margin-left: 0; }
|
||||
.grade-project-card { grid-template-columns: 1fr; }
|
||||
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
|
||||
.grade-course-head { align-items: stretch; flex-direction: column; }
|
||||
.course-rollup { align-self: flex-start; text-align: left; }
|
||||
.student-result-grid { grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
|
||||
@@ -25,6 +25,7 @@ const options = reactive({
|
||||
colleges: [] as any[],
|
||||
scheduleEntries: [] as any[],
|
||||
classrooms: [] as any[],
|
||||
teachers: [] as any[],
|
||||
periods: [] as any[],
|
||||
})
|
||||
const modeFilter = ref('')
|
||||
@@ -37,6 +38,7 @@ const taskKeyword = ref('')
|
||||
|
||||
const projectDialog = ref(false)
|
||||
const editingProjectId = ref('')
|
||||
const correctingPublishedProject = ref(false)
|
||||
const projectForm = reactive({
|
||||
teachingTaskIds: [] as string[],
|
||||
scheduleEntryId: '',
|
||||
@@ -48,6 +50,7 @@ const projectForm = reactive({
|
||||
description: '',
|
||||
requirements: '',
|
||||
dates: [] as string[],
|
||||
selectionTimes: [] as string[],
|
||||
})
|
||||
|
||||
const batchRenameDialog = ref(false)
|
||||
@@ -63,6 +66,7 @@ const sessionForm = reactive({
|
||||
periodCount: 2,
|
||||
capacity: 30,
|
||||
notes: '',
|
||||
instructorTeacherIds: [] as string[],
|
||||
})
|
||||
|
||||
const batchSessionDialog = ref(false)
|
||||
@@ -158,6 +162,7 @@ const statusLabels: Record<string, string> = {
|
||||
|
||||
function resetProjectForm() {
|
||||
editingProjectId.value = ''
|
||||
correctingPublishedProject.value = false
|
||||
Object.assign(projectForm, {
|
||||
teachingTaskIds: [],
|
||||
scheduleEntryId: '',
|
||||
@@ -169,6 +174,7 @@ function resetProjectForm() {
|
||||
description: '',
|
||||
requirements: '',
|
||||
dates: [],
|
||||
selectionTimes: [],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,6 +203,7 @@ function openCreateProject() {
|
||||
|
||||
function openEditProject(project: any) {
|
||||
editingProjectId.value = project.id
|
||||
correctingPublishedProject.value = false
|
||||
Object.assign(projectForm, {
|
||||
teachingTaskIds: [project.teachingTaskId],
|
||||
scheduleEntryId: project.scheduleEntryId ?? '',
|
||||
@@ -208,28 +215,36 @@ function openEditProject(project: any) {
|
||||
description: project.description ?? '',
|
||||
requirements: project.requirements ?? '',
|
||||
dates: [project.startDate, project.endDate],
|
||||
selectionTimes: project.selectionStartsAt && project.selectionEndsAt
|
||||
? [project.selectionStartsAt, project.selectionEndsAt] : [],
|
||||
})
|
||||
projectDialog.value = true
|
||||
}
|
||||
|
||||
function openCorrectPublishedProject(project: any) {
|
||||
openEditProject(project)
|
||||
correctingPublishedProject.value = true
|
||||
}
|
||||
|
||||
function projectNameLines(value: string) {
|
||||
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
const names = projectNameLines(projectForm.projectNames)
|
||||
const hasValidName = editingProjectId.value
|
||||
const hasValidName = correctingPublishedProject.value || editingProjectId.value
|
||||
? !!projectForm.name.trim()
|
||||
: projectForm.nameMode === 'InputNames'
|
||||
? names.length > 0
|
||||
: !!projectForm.name.trim()
|
||||
if (!projectForm.teachingTaskIds.length ||
|
||||
if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
|
||||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||
!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
|
||||
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
||||
: '请填写教学任务、项目编码、名称和开放日期')
|
||||
: '请填写教学任务、项目编码、名称、开放日期和选课时间')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
@@ -242,9 +257,20 @@ async function saveProject() {
|
||||
requirements: projectForm.requirements || null,
|
||||
startDate: projectForm.dates[0],
|
||||
endDate: projectForm.dates[1],
|
||||
selectionStartsAt: projectForm.arrangementMode === 'SelfScheduled'
|
||||
? projectForm.selectionTimes[0] || null : null,
|
||||
selectionEndsAt: projectForm.arrangementMode === 'SelfScheduled'
|
||||
? projectForm.selectionTimes[1] || null : null,
|
||||
}
|
||||
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)
|
||||
ElMessage.success('实验项目已更新')
|
||||
} else {
|
||||
@@ -273,24 +299,47 @@ function openBatchSession() {
|
||||
}
|
||||
|
||||
function syncBatchSessionRows() {
|
||||
const existing = new Map(batchSessionRows.value.map((row) => [row.projectId, row]))
|
||||
batchSessionRows.value = batchSessionProjectIds.value.map((projectId) => {
|
||||
const existing = new Map<string, any[]>()
|
||||
batchSessionRows.value.forEach((row) => {
|
||||
const rows = existing.get(row.projectId) ?? []
|
||||
rows.push(row)
|
||||
existing.set(row.projectId, rows)
|
||||
})
|
||||
batchSessionRows.value = batchSessionProjectIds.value.flatMap((projectId) => {
|
||||
const current = existing.get(projectId)
|
||||
if (current) return current
|
||||
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: '',
|
||||
sessionDate: project?.startDate ?? '',
|
||||
startPeriod: firstPeriod?.periodNumber,
|
||||
periodCount: 2,
|
||||
capacity: 30,
|
||||
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() {
|
||||
@@ -321,9 +370,9 @@ async function saveBatchSessions() {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
try {
|
||||
@@ -350,14 +399,15 @@ function openSession(project: any) {
|
||||
periodCount: 2,
|
||||
capacity: 30,
|
||||
notes: '',
|
||||
instructorTeacherIds: [],
|
||||
})
|
||||
sessionDialog.value = true
|
||||
}
|
||||
|
||||
async function saveSession() {
|
||||
if (!selectedProject.value || !sessionForm.classroomId
|
||||
|| !sessionForm.sessionDate || !sessionForm.startPeriod) {
|
||||
ElMessage.warning('请选择实验日期、节次和教室')
|
||||
|| !sessionForm.sessionDate || !sessionForm.startPeriod || !sessionForm.instructorTeacherIds.length) {
|
||||
ElMessage.warning('请选择实验日期、节次、实验室和指导老师')
|
||||
return
|
||||
}
|
||||
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() {
|
||||
batchRenameProjectIds.value = projects.value
|
||||
.filter((project) => draftSelectedProjectIds.value.includes(project.id))
|
||||
@@ -612,11 +671,13 @@ async function loadOptions() {
|
||||
options.colleges = data.colleges
|
||||
options.scheduleEntries = data.scheduleEntries
|
||||
options.classrooms = data.classrooms
|
||||
options.teachers = data.teachers
|
||||
options.periods = data.periods
|
||||
} catch (error) {
|
||||
options.tasks = []
|
||||
options.colleges = []
|
||||
options.classrooms = []
|
||||
options.teachers = []
|
||||
options.periods = []
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
@@ -814,9 +875,9 @@ onMounted(async () => {
|
||||
<template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template>
|
||||
<div class="student-session-menu">
|
||||
<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>
|
||||
<el-button 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>
|
||||
<div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }} · 指导:{{ session.instructorNames?.join('、') || '待指定' }}</span></div>
|
||||
<el-button v-if="session.canManage" size="small" text @click="showParticipants(row, session)">预约名单</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>
|
||||
</el-popover>
|
||||
@@ -842,7 +903,10 @@ onMounted(async () => {
|
||||
@click="publishProject(row)"
|
||||
>发布</el-button>
|
||||
</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
|
||||
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
|
||||
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>
|
||||
<b>{{ formatSessionTime(session) }}</b>
|
||||
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
|
||||
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }} · 指导:{{ session.instructorNames?.join('、') || '待指定' }}</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -952,11 +1016,17 @@ onMounted(async () => {
|
||||
|
||||
<el-dialog
|
||||
v-model="projectDialog"
|
||||
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||
:title="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||
width="720px"
|
||||
top="5vh"
|
||||
>
|
||||
<el-form label-position="top" class="experiment-form">
|
||||
<el-alert
|
||||
v-if="correctingPublishedProject"
|
||||
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<div class="form-section">
|
||||
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
||||
@@ -1009,9 +1079,10 @@ onMounted(async () => {
|
||||
v-for="task in group.tasks"
|
||||
:key="task.id"
|
||||
:value="task.id"
|
||||
:disabled="projectForm.arrangementMode === 'SelfScheduled' && !!selectedTask
|
||||
&& (task.academicTermId !== selectedTask.academicTermId
|
||||
|| task.courseId !== selectedTask.courseId)"
|
||||
:disabled="projectForm.arrangementMode === 'SelfScheduled'
|
||||
&& (task.schedulingMode !== 'Flexible'
|
||||
|| (!!selectedTask && (task.academicTermId !== selectedTask.academicTermId
|
||||
|| task.courseId !== selectedTask.courseId)))"
|
||||
>
|
||||
<b>{{ task.taskNumber }}</b>
|
||||
<span>{{ task.classNames.join('、') || task.name }}</span>
|
||||
@@ -1022,12 +1093,12 @@ onMounted(async () => {
|
||||
</el-checkbox-group>
|
||||
<small v-if="!editingProjectId" class="form-help">
|
||||
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班;系统会为每个教学班的全部已发布实验课生成项目。</template>
|
||||
<template v-else>已选 {{ projectForm.teachingTaskIds.length }} 个教学班;只能勾选同一学期、同一课程,实验编码、名称、内容和开放日期将一次应用到这些教学任务。</template>
|
||||
<template v-else>自行安排仅可选择“非排时课程”的教学任务;只能勾选同一学期、同一课程,实验编码、名称、内容和开放日期将一次应用到这些教学任务。</template>
|
||||
</small>
|
||||
</el-form-item>
|
||||
<div class="form-grid two">
|
||||
<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 v-if="editingProjectId" label="项目名称" required>
|
||||
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
|
||||
@@ -1058,7 +1129,7 @@ onMounted(async () => {
|
||||
|
||||
<div class="form-section">
|
||||
<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">
|
||||
<b>集中安排</b><small>复用已发布课表的实验课</small>
|
||||
</el-radio-button>
|
||||
@@ -1069,6 +1140,7 @@ onMounted(async () => {
|
||||
<el-form-item label="项目开放日期" required>
|
||||
<el-date-picker
|
||||
v-model="projectForm.dates"
|
||||
:disabled="correctingPublishedProject"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
@@ -1076,6 +1148,17 @@ onMounted(async () => {
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
</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 class="form-section">
|
||||
@@ -1105,7 +1188,9 @@ onMounted(async () => {
|
||||
<template #footer>
|
||||
<el-button @click="projectDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProject">
|
||||
{{ editingProjectId
|
||||
{{ correctingPublishedProject
|
||||
? '保存修正'
|
||||
: editingProjectId
|
||||
? '保存项目'
|
||||
: projectForm.teachingTaskIds.length
|
||||
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
|
||||
@@ -1183,6 +1268,16 @@ onMounted(async () => {
|
||||
/>
|
||||
</el-select>
|
||||
</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-input
|
||||
v-model="sessionForm.notes"
|
||||
@@ -1229,11 +1324,11 @@ onMounted(async () => {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<span>{{ index + 1 }}</span>
|
||||
<div>
|
||||
@@ -1264,7 +1359,19 @@ onMounted(async () => {
|
||||
:value="room.id"
|
||||
/>
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
<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-row {
|
||||
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;
|
||||
align-items: center;
|
||||
min-width: 1000px;
|
||||
@@ -1479,6 +1586,7 @@ onMounted(async () => {
|
||||
border: 1px solid #dce5e9;
|
||||
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 > 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; }
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from '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 { downloadApiFile } from '../api/excel'
|
||||
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
echarts.use([
|
||||
AriaComponent,
|
||||
BarChart,
|
||||
CanvasRenderer,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
])
|
||||
|
||||
interface TeachingClassItem {
|
||||
gradeSheetId: string
|
||||
teachingTaskId: string
|
||||
@@ -26,6 +39,7 @@ interface TeachingClassItem {
|
||||
}
|
||||
|
||||
const terms = ref<any[]>([])
|
||||
const colleges = ref<any[]>([])
|
||||
const auth = useAuthStore()
|
||||
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
|
||||
const classes = ref<TeachingClassItem[]>([])
|
||||
@@ -33,6 +47,9 @@ const selected = ref<TeachingClassItem>()
|
||||
const report = ref<any>()
|
||||
const termId = ref<string>()
|
||||
const keyword = ref('')
|
||||
const collegeId = ref<string>()
|
||||
const teacherKeyword = ref('')
|
||||
const riskOnly = ref(false)
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const pageSize = 12
|
||||
@@ -96,6 +113,9 @@ async function loadClasses(reset = false) {
|
||||
params: {
|
||||
academicTermId: termId.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
collegeId: collegeId.value,
|
||||
teacherKeyword: teacherKeyword.value.trim() || undefined,
|
||||
riskOnly: riskOnly.value || undefined,
|
||||
page: page.value,
|
||||
pageSize,
|
||||
},
|
||||
@@ -330,7 +350,12 @@ watch(historyMetric, async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
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)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
@@ -396,7 +421,12 @@ onBeforeUnmount(() => {
|
||||
<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-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="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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div>
|
||||
<strong>明序教务</strong>
|
||||
<small>MINGXU ACADEMIC SYSTEM</small>
|
||||
<small>教务管理与学业服务</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="story-copy">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
@@ -11,35 +11,6 @@ import {
|
||||
Search,
|
||||
User,
|
||||
} 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 http, { apiErrorMessage } from '../api/http'
|
||||
import RichMessageContent from '../components/RichMessageContent.vue'
|
||||
@@ -47,6 +18,8 @@ import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const NotificationRichTextEditor = defineAsyncComponent(
|
||||
() => import('../components/NotificationRichTextEditor.vue'))
|
||||
const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
|
||||
const canSend = computed(() =>
|
||||
auth.user?.roles.some(role => senderRoles.includes(role)) ?? false)
|
||||
@@ -58,6 +31,7 @@ const unreadCount = ref(0)
|
||||
const total = ref(0)
|
||||
const sentTotal = ref(0)
|
||||
const loading = ref(false)
|
||||
const markingAllRead = ref(false)
|
||||
const sending = ref(false)
|
||||
const composerLoading = ref(false)
|
||||
const recipientLoading = ref(false)
|
||||
@@ -90,92 +64,6 @@ const recipientFilters = reactive({
|
||||
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 }> = {
|
||||
General: { label: '一般通知', className: 'general' },
|
||||
Approval: { label: '审核待办', className: 'approval' },
|
||||
@@ -380,6 +268,8 @@ async function openNotification(notification: any) {
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (markingAllRead.value) return
|
||||
markingAllRead.value = true
|
||||
try {
|
||||
await http.post('/notifications/read-all')
|
||||
notifications.value.forEach(notification => {
|
||||
@@ -389,6 +279,8 @@ async function markAllRead() {
|
||||
ElMessage.success('全部消息已标为已读')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
markingAllRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +356,7 @@ onMounted(() => load())
|
||||
<div class="page-stack message-center">
|
||||
<section class="page-intro message-intro">
|
||||
<div>
|
||||
<span class="section-kicker">MESSAGE CENTER</span>
|
||||
<span class="section-kicker">消息中心</span>
|
||||
<h2>消息中心</h2>
|
||||
<p>系统通知、审核待办、课程变动与成绩消息统一汇总,重要信息不再散落在业务页面。</p>
|
||||
</div>
|
||||
@@ -481,6 +373,15 @@ onMounted(() => load())
|
||||
>
|
||||
发消息
|
||||
</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>
|
||||
@@ -541,9 +442,6 @@ onMounted(() => load())
|
||||
@clear="load(true)"
|
||||
/>
|
||||
<el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox>
|
||||
<el-button v-if="unreadCount" :icon="Check" text @click="markAllRead">
|
||||
全部已读
|
||||
</el-button>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="message-list">
|
||||
@@ -610,7 +508,7 @@ onMounted(() => load())
|
||||
>
|
||||
<header class="compose-overview">
|
||||
<div>
|
||||
<span class="panel-kicker">AUTHORIZED DELIVERY</span>
|
||||
<span class="panel-kicker">发送范围</span>
|
||||
<h3>编写并发送消息</h3>
|
||||
<p>收件范围始终受当前账号的数据权限约束,发送后不可撤回。</p>
|
||||
</div>
|
||||
@@ -861,10 +759,8 @@ onMounted(() => load())
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="消息正文" required class="editor-form-item">
|
||||
<Ckeditor
|
||||
<NotificationRichTextEditor
|
||||
v-model="messageForm.content"
|
||||
:editor="ClassicEditor"
|
||||
:config="editorConfig"
|
||||
/>
|
||||
<div class="editor-footnote">
|
||||
<span>支持表格、超链接和外链图片;图片请填写 HTTPS 地址,不上传本地文件。</span>
|
||||
|
||||
@@ -49,8 +49,14 @@ const issueDialog = ref(false)
|
||||
const historyDrawer = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const downloadHistory = ref<any[]>([])
|
||||
const historyPage = ref(1)
|
||||
const historyTotal = ref(0)
|
||||
const historyPageSize = 20
|
||||
const selectedDocument = ref<OfficialDocumentRow>()
|
||||
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 }>({
|
||||
studentId: '',
|
||||
type: 'Transcript',
|
||||
@@ -67,11 +73,19 @@ const statusLabels: Record<DocumentStatus, string> = {
|
||||
Superseded: '已重签',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await http.get('/official-documents', { params: filters })
|
||||
documents.value = data
|
||||
const { data } = await http.get('/official-documents', {
|
||||
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) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
@@ -83,9 +97,9 @@ async function loadStudents(keyword = '') {
|
||||
if (!isManager.value) return
|
||||
try {
|
||||
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) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
@@ -181,10 +195,20 @@ async function reissue(row: OfficialDocumentRow) {
|
||||
async function showHistory(row: OfficialDocumentRow) {
|
||||
selectedDocument.value = row
|
||||
historyDrawer.value = true
|
||||
historyPage.value = 1
|
||||
await loadHistory()
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (!selectedDocument.value) return
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const { data } = await http.get(`/official-documents/${row.id}/downloads`)
|
||||
downloadHistory.value = data
|
||||
const { data } = await http.get(
|
||||
`/official-documents/${selectedDocument.value.id}/downloads`,
|
||||
{ params: { page: historyPage.value, pageSize: historyPageSize } },
|
||||
)
|
||||
downloadHistory.value = data.items
|
||||
historyTotal.value = data.total
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
@@ -224,14 +248,14 @@ onMounted(load)
|
||||
</section>
|
||||
|
||||
<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-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-select>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
<span>共 {{ documents.length }} 份凭证</span>
|
||||
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
|
||||
<span>共 {{ total }} 份凭证</span>
|
||||
</section>
|
||||
|
||||
<section class="document-list" v-loading="loading">
|
||||
@@ -274,6 +298,15 @@ onMounted(load)
|
||||
v-if="!documents.length"
|
||||
: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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
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 { useAuthStore } from '../stores/auth'
|
||||
|
||||
@@ -10,6 +10,13 @@ const auth = useAuthStore()
|
||||
const isStudent = computed(() => auth.user?.roles.includes('Student'))
|
||||
const loading = ref(false)
|
||||
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 results = ref<ResultRow[]>([])
|
||||
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 isPassMetric = computed(() => selectedMetricKind.value === 1)
|
||||
|
||||
async function load() {
|
||||
async function load(resetPage = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
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 }
|
||||
}
|
||||
async function openBatch(row: any) {
|
||||
@@ -93,13 +115,13 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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 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"><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">历史记录</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 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>
|
||||
</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>
|
||||
@@ -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; }
|
||||
@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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -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() {
|
||||
if (!entryForm.teachingTaskId ||
|
||||
((entryForm.kind === 'Experiment' ||
|
||||
@@ -937,6 +947,13 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
复制调整
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDraft"
|
||||
:disabled="scheduleJobLoading"
|
||||
@click="preflightSchedule"
|
||||
>
|
||||
排课前检查
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDraft"
|
||||
type="warning"
|
||||
|
||||
@@ -70,6 +70,27 @@ interface GradeItem {
|
||||
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 auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
@@ -79,6 +100,9 @@ const notifications = ref<NotificationItem[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const exams = ref<ExamItem[]>([])
|
||||
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 categoryLabels: Record<string, string> = {
|
||||
@@ -159,6 +183,11 @@ const recentGrades = computed(() =>
|
||||
.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) {
|
||||
if (!value) return null
|
||||
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('/exams/my-schedule'),
|
||||
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') {
|
||||
@@ -304,6 +336,15 @@ async function loadOverview() {
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -314,9 +355,9 @@ onMounted(loadOverview)
|
||||
<div v-loading="loading" class="student-overview">
|
||||
<section class="student-overview-hero">
|
||||
<div class="student-hero-copy">
|
||||
<span class="section-kicker">MY ACADEMIC DAY</span>
|
||||
<h2>{{ greeting }},{{ auth.user?.displayName ?? '同学' }}</h2>
|
||||
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||
<span class="section-kicker">我的学习安排</span>
|
||||
<h2>{{ dashboardGreeting?.title ?? `${greeting},${auth.user?.displayName ?? '同学'}` }}</h2>
|
||||
<p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||
</div>
|
||||
<div class="today-status">
|
||||
<span>今日课程</span>
|
||||
@@ -325,6 +366,39 @@ onMounted(loadOverview)
|
||||
</div>
|
||||
</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
|
||||
v-if="failedSections.length"
|
||||
type="warning"
|
||||
@@ -539,6 +613,63 @@ onMounted(loadOverview)
|
||||
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 {
|
||||
min-width: 180px;
|
||||
margin-left: auto;
|
||||
@@ -871,12 +1002,18 @@ onMounted(loadOverview)
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.student-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
|
||||
.student-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ const selected = ref<any | null>(null)
|
||||
const reviewApproved = ref(true)
|
||||
const applyForm = reactive({ type: '', reason: '' })
|
||||
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> = {
|
||||
Suspension: '休学',
|
||||
@@ -48,10 +53,6 @@ const steps = [
|
||||
{ label: '学院审核', state: 'CounselorApproved' },
|
||||
{ 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) {
|
||||
if (!value) return '—'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
@@ -90,14 +91,24 @@ function stateTagType(state: ChangeState) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1
|
||||
loading.value = true
|
||||
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'))
|
||||
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 (changes.value.length === 0 && page.value > 1) {
|
||||
page.value--
|
||||
await load()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
@@ -118,7 +129,7 @@ async function submitApply() {
|
||||
await http.post('/student-status-changes', applyForm)
|
||||
applyDialog.value = false
|
||||
ElMessage.success('申请已提交,等待辅导员审核。')
|
||||
await load()
|
||||
await load(true)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
@@ -177,7 +188,7 @@ onMounted(load)
|
||||
:disabled="options?.hasPending || !options?.types?.length"
|
||||
@click="openApply"
|
||||
>发起申请</el-button>
|
||||
<el-button v-else :icon="RefreshRight" @click="load">刷新队列</el-button>
|
||||
<el-button v-else :icon="RefreshRight" @click="() => load()">刷新队列</el-button>
|
||||
</section>
|
||||
|
||||
<section v-if="isStudent && options" class="status-identity">
|
||||
@@ -194,9 +205,9 @@ onMounted(load)
|
||||
</section>
|
||||
|
||||
<section v-if="!isStudent" class="status-review-summary">
|
||||
<div><span>当前待我审核</span><b>{{ currentQueue.length }}</b><small>件</small></div>
|
||||
<div><span>辖区申请总数</span><b>{{ changes.length }}</b><small>件</small></div>
|
||||
<div><span>已结束</span><b>{{ finishedCount }}</b><small>件</small></div>
|
||||
<div><span>当前待我审核</span><b>{{ actionableTotal }}</b><small>件</small></div>
|
||||
<div><span>辖区申请总数</span><b>{{ total }}</b><small>件</small></div>
|
||||
<div><span>已结束</span><b>{{ finishedTotal }}</b><small>件</small></div>
|
||||
<p>系统仅开放当前审核层级的操作,所有越级请求都会由服务端拒绝。</p>
|
||||
</section>
|
||||
|
||||
@@ -243,6 +254,15 @@ onMounted(load)
|
||||
</footer>
|
||||
</article>
|
||||
<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>
|
||||
|
||||
<el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px">
|
||||
|
||||
@@ -53,6 +53,16 @@ const isNativeApp = Capacitor.isNativePlatform()
|
||||
const now = ref(Date.now())
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
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 studentKeyword = ref('')
|
||||
const classFilter = ref('')
|
||||
@@ -108,18 +118,27 @@ const filteredStudents = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
async function loadTasks() {
|
||||
async function loadTasks(resetPage = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
tasks.value = (await http.get('/attendance/my-tasks', {
|
||||
params: { academicTermId: termId.value || undefined },
|
||||
if (resetPage) taskPage.value = 1
|
||||
const response = (await http.get('/attendance/my-tasks', {
|
||||
params: {
|
||||
academicTermId: termId.value || undefined,
|
||||
keyword: taskKeyword.value.trim() || undefined,
|
||||
page: taskPage.value,
|
||||
pageSize: taskPageSize,
|
||||
},
|
||||
})).data
|
||||
tasks.value = response.items
|
||||
taskTotal.value = response.total
|
||||
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
|
||||
?? tasks.value[0]
|
||||
if (preferred) await selectTask(preferred)
|
||||
else {
|
||||
selectedTask.value = null
|
||||
sheets.value = []
|
||||
sheetTotal.value = 0
|
||||
statistics.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -137,13 +156,37 @@ async function selectTask(task: any) {
|
||||
studentKeyword.value = ''
|
||||
classFilter.value = ''
|
||||
attentionFilter.value = ''
|
||||
try {
|
||||
sheets.value = (await http.get('/attendance/sheets', {
|
||||
params: { teachingTaskId: task.id },
|
||||
})).data
|
||||
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 {
|
||||
if (resetPage) sheetPage.value = 1
|
||||
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
|
||||
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) {
|
||||
sheets.value = []
|
||||
sheetTotal.value = 0
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
@@ -680,14 +723,22 @@ onUnmounted(() => {
|
||||
<h2>教学点名</h2>
|
||||
<p>完成课堂点名,并持续查看本课程每位学生的出勤表现。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadTasks">刷新</el-button>
|
||||
<el-button :icon="Refresh" @click="() => loadTasks()">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<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-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-button value="rollcall">点名记录</el-radio-button>
|
||||
<el-radio-button value="statistics">课程统计</el-radio-button>
|
||||
@@ -712,6 +763,16 @@ onUnmounted(() => {
|
||||
<small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<template v-if="activeMode === 'rollcall'">
|
||||
@@ -727,6 +788,25 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button>
|
||||
</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">
|
||||
<button
|
||||
v-for="sheet in sheets"
|
||||
@@ -766,6 +846,16 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" />
|
||||
</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>
|
||||
</main>
|
||||
|
||||
@@ -1192,7 +1282,8 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--line);
|
||||
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; }
|
||||
.mode-switch { margin-left: auto; }
|
||||
.attendance-workspace {
|
||||
@@ -1256,6 +1347,21 @@ onUnmounted(() => {
|
||||
display: block;
|
||||
}
|
||||
.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 {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
@@ -1708,7 +1814,11 @@ onUnmounted(() => {
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.attendance-toolbar { align-items: stretch; flex-wrap: wrap; }
|
||||
.attendance-toolbar .el-input,
|
||||
.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; }
|
||||
.mode-switch { margin-left: auto; }
|
||||
.statistics-head { align-items: flex-start; flex-direction: column; }
|
||||
|
||||
@@ -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>
|
||||
@@ -19,6 +19,9 @@ const teachers = ref<any[]>([])
|
||||
const submitDialog = ref(false)
|
||||
const reviewDialog = ref(false)
|
||||
const assignmentDialog = ref(false)
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const pageSize = 20
|
||||
const form = reactive<Record<string, any>>({})
|
||||
const reviewForm = reactive<Record<string, any>>({})
|
||||
const assignmentForm = reactive<Record<string, any>>({})
|
||||
@@ -39,14 +42,22 @@ const statusTypes: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
Withdrawn: 'info',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1
|
||||
loading.value = true
|
||||
try {
|
||||
const endpoint = isTeacher.value
|
||||
? '/teacher-course-applications/mine'
|
||||
: '/teacher-course-applications/reviews'
|
||||
const { data } = await http.get(endpoint, { params: filters })
|
||||
rows.value = data
|
||||
const { data } = await http.get(endpoint, {
|
||||
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) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
@@ -195,14 +206,14 @@ onMounted(async () => {
|
||||
|
||||
<section class="data-card">
|
||||
<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-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-select>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
<span>共 {{ rows.length }} 条</span>
|
||||
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
|
||||
<span>共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" class="data-table">
|
||||
@@ -235,6 +246,15 @@ onMounted(async () => {
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :description="isTeacher ? '尚未申报授课科目' : '没有待处理的授课申报'" /></template>
|
||||
</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>
|
||||
|
||||
<el-dialog v-model="submitDialog" title="申报授课科目" width="600px">
|
||||
|
||||
+140
-35
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
import { downloadApiFile, downloadApiPostFile } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
@@ -32,6 +32,8 @@ const isManager = computed(() =>
|
||||
)
|
||||
const loading = ref(false)
|
||||
const exportingPdf = ref(false)
|
||||
const batchExporting = ref(false)
|
||||
const batchResourceIds = ref<string[]>([])
|
||||
const calendarDialogVisible = ref(false)
|
||||
const calendarLoading = ref(false)
|
||||
const calendarActionLoading = ref(false)
|
||||
@@ -61,7 +63,6 @@ const viewMode = ref<'overview' | 'week' | 'day'>('week')
|
||||
const selectedWeek = ref(1)
|
||||
const selectedDay = ref(1)
|
||||
const loadedTermId = ref('')
|
||||
const exportArea = ref<HTMLElement | null>(null)
|
||||
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
||||
const planStatusLabels: Record<string, string> = {
|
||||
@@ -104,6 +105,11 @@ const selectedResourceId = computed(() => {
|
||||
if (resourceType.value === 'Classroom') return classroomId.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>>(() =>
|
||||
new Map<number, any>(
|
||||
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
||||
@@ -403,7 +409,11 @@ async function loadPublicOptions() {
|
||||
termId.value = defaultAcademicTermId(data.terms)
|
||||
?? 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]
|
||||
classId.value = initialClass?.id ?? ''
|
||||
setClassFilters(initialClass)
|
||||
@@ -440,6 +450,7 @@ async function loadManagementOptions() {
|
||||
}
|
||||
|
||||
async function onTermChanged() {
|
||||
batchResourceIds.value = []
|
||||
if (isManager.value) await loadManagementOptions()
|
||||
await loadTimetable()
|
||||
}
|
||||
@@ -468,6 +479,7 @@ function onBuildingChanged() {
|
||||
}
|
||||
|
||||
function onResourceTypeChanged() {
|
||||
batchResourceIds.value = []
|
||||
collegeId.value = ''
|
||||
majorId.value = ''
|
||||
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() {
|
||||
if (!termId.value) {
|
||||
timetable.value = null
|
||||
@@ -560,35 +633,23 @@ async function exportExcel() {
|
||||
}
|
||||
|
||||
async function exportPdf() {
|
||||
if (!exportArea.value || !timetable.value) return
|
||||
exportingPdf.value = true
|
||||
try {
|
||||
await nextTick()
|
||||
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
||||
import('html2canvas'),
|
||||
import('jspdf'),
|
||||
])
|
||||
const canvas = await html2canvas(exportArea.value, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
if (isMine.value) {
|
||||
await downloadApiFile(`/timetables/mine/export.pdf?academicTermId=${termId.value}`, '我的课表.pdf')
|
||||
} else if (isTeacherView.value && teacherIdParam.value) {
|
||||
await downloadApiFile(`/timetables/teachers/${teacherIdParam.value}/export.pdf?academicTermId=${termId.value}`, '教师课表.pdf')
|
||||
} else if (isManager.value) {
|
||||
const query = new URLSearchParams({
|
||||
resourceType: resourceType.value,
|
||||
resourceId: selectedResourceId.value,
|
||||
academicTermId: termId.value,
|
||||
})
|
||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||
const pageWidth = 297
|
||||
const pageHeight = 210
|
||||
const imageHeight = canvas.height * pageWidth / canvas.width
|
||||
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
|
||||
if (planId.value) query.set('schedulePlanId', planId.value)
|
||||
await downloadApiFile(`/timetables/management/export.pdf?${query}`, '课表.pdf')
|
||||
} else {
|
||||
await downloadApiFile(`/timetables/classes/${classId.value}/export.pdf?academicTermId=${termId.value}`, '班级课表.pdf')
|
||||
}
|
||||
pdf.save(`${timetable.value.subject?.name ?? '课表'}-${timetable.value.term.name}.pdf`)
|
||||
} catch (error) {
|
||||
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
||||
} finally {
|
||||
@@ -753,7 +814,7 @@ onMounted(async () => {
|
||||
|
||||
<section class="timetable-heading">
|
||||
<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>
|
||||
<p v-if="isMine && isTeacher">展示本人本学期所有授课安排,仅采用教务处已发布课表。</p>
|
||||
<p v-else-if="isMine">行政班课程与本人已选课程统一展示,仅采用教务处已发布课表。</p>
|
||||
@@ -849,6 +910,46 @@ onMounted(async () => {
|
||||
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
|
||||
项
|
||||
</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 v-loading="loading" class="timetable-sheet">
|
||||
@@ -906,7 +1007,7 @@ onMounted(async () => {
|
||||
</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>
|
||||
<strong>{{ timetable.subject.name }}</strong>
|
||||
@@ -1097,7 +1198,7 @@ onMounted(async () => {
|
||||
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
|
||||
<header>
|
||||
<div>
|
||||
<span>EXAM AGENDA</span>
|
||||
<span>考试安排</span>
|
||||
<strong>已发布考试安排</strong>
|
||||
</div>
|
||||
<small>共 {{ sortedExamEntries.length }} 场 · 按考试日期排序</small>
|
||||
@@ -1133,7 +1234,7 @@ onMounted(async () => {
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span>LAB SESSIONS</span>
|
||||
<span>实验安排</span>
|
||||
<strong>个人实验安排</strong>
|
||||
</div>
|
||||
<small>共 {{ sortedExperimentEntries.length }} 场 · 预约变化会自动同步</small>
|
||||
@@ -1170,7 +1271,7 @@ onMounted(async () => {
|
||||
<div v-loading="calendarLoading" class="calendar-subscription">
|
||||
<template v-if="calendarSubscription?.isEnabled">
|
||||
<div class="calendar-status active">
|
||||
<span>LIVE FEED</span>
|
||||
<span>实时变动</span>
|
||||
<div>
|
||||
<strong>订阅已启用</strong>
|
||||
<small>课表、考试和实验安排变更后,日历客户端会在下次同步时自动更新。</small>
|
||||
@@ -1214,7 +1315,7 @@ onMounted(async () => {
|
||||
|
||||
<template v-else-if="calendarSubscription">
|
||||
<div class="calendar-status">
|
||||
<span>PRIVATE CALENDAR</span>
|
||||
<span>个人日历</span>
|
||||
<div>
|
||||
<strong>将教学安排同步到常用日历</strong>
|
||||
<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 p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
|
||||
.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 div { display: grid; gap: 3px; }
|
||||
.sheet-meta strong { color: #17324d; }
|
||||
|
||||
+62
-16
@@ -28,6 +28,13 @@ const editingRoles = ref<string[]>([])
|
||||
const editingStaffNumber = ref('')
|
||||
const editingCollegeId = ref<string>()
|
||||
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 form = reactive({
|
||||
userName: '', displayName: '', password: '', staffNumber: '',
|
||||
@@ -56,25 +63,33 @@ const editingScope = computed(() => {
|
||||
)
|
||||
return scopeNames[effective]
|
||||
})
|
||||
const filteredUsers = computed(() => {
|
||||
const value = keyword.value.trim().toLowerCase()
|
||||
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() {
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1
|
||||
loading.value = true
|
||||
try {
|
||||
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
|
||||
colleges.value = collegeRes.data
|
||||
if (users.value.length === 0 && page.value > 1) {
|
||||
page.value--
|
||||
await load()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
@@ -216,7 +231,29 @@ onMounted(load)
|
||||
|
||||
<section class="data-card">
|
||||
<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="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||
<el-button :icon="Download" @click="exportRows">导出全部</el-button>
|
||||
@@ -227,11 +264,11 @@ onMounted(load)
|
||||
accept=".xlsx"
|
||||
@change="handleImport"
|
||||
/>
|
||||
<span>共 {{ filteredUsers.length }} / {{ users.length }} 个账号</span>
|
||||
<span>共 {{ total }} 个账号</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="filteredUsers"
|
||||
:data="users"
|
||||
>
|
||||
<el-table-column prop="userName" label="账号" min-width="130" />
|
||||
<el-table-column prop="displayName" label="姓名" min-width="120" />
|
||||
@@ -263,6 +300,15 @@ onMounted(load)
|
||||
</template>
|
||||
</el-table-column>
|
||||
</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>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="创建账号" width="540px">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user