Compare commits
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_ROOT_PASSWORD=
|
MYSQL_ROOT_PASSWORD=
|
||||||
|
|
||||||
|
CLICKHOUSE_USER=jiaowu_analytics
|
||||||
|
CLICKHOUSE_PASSWORD=
|
||||||
|
|
||||||
RABBITMQ_USER=jiaowu
|
RABBITMQ_USER=jiaowu
|
||||||
RABBITMQ_PASSWORD=
|
RABBITMQ_PASSWORD=
|
||||||
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||||
|
|||||||
+22
-3
@@ -2,6 +2,13 @@
|
|||||||
ASPNETCORE_ENVIRONMENT=Production
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
ASPNETCORE_URLS=http://0.0.0.0:8080
|
ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||||
|
|
||||||
|
# 反向代理必须在转发请求时设置 X-Forwarded-For 和 X-Forwarded-Proto。
|
||||||
|
# 仅填写实际直接连接 API 的代理 IP;多个代理依次使用 __0、__1……。
|
||||||
|
# 使用 Docker 时通常是宿主机/代理容器在 Docker 网络中的 IP,而非访客 IP。
|
||||||
|
# 默认仅信任 127.0.0.1 和 ::1。
|
||||||
|
# ReverseProxy__TrustedProxies__0=127.0.0.1
|
||||||
|
# ReverseProxy__TrustedProxies__1=::1
|
||||||
|
|
||||||
Database__Provider=MySql
|
Database__Provider=MySql
|
||||||
Database__ApplyMigrationsOnStartup=false
|
Database__ApplyMigrationsOnStartup=false
|
||||||
Database__CommandTimeoutSeconds=30
|
Database__CommandTimeoutSeconds=30
|
||||||
@@ -37,15 +44,15 @@ Cache__AnalyticsExpirationMinutes=3
|
|||||||
Cache__AnalyticsLocalExpirationSeconds=30
|
Cache__AnalyticsLocalExpirationSeconds=30
|
||||||
Cache__MaximumPayloadKilobytes=2048
|
Cache__MaximumPayloadKilobytes=2048
|
||||||
|
|
||||||
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
|
# 应用日志建立接口与慢 SQL 基线;日志系统可按 DurationMs、RequestId、QueryName 聚合。
|
||||||
Observability__Enabled=true
|
Observability__Enabled=true
|
||||||
Observability__ServiceName=jiaowu-api
|
Observability__ServiceName=jiaowu-api
|
||||||
|
Observability__LogAllApiRequests=true
|
||||||
|
Observability__SlowRequestThresholdMilliseconds=1000
|
||||||
Observability__SlowQueryThresholdMilliseconds=500
|
Observability__SlowQueryThresholdMilliseconds=500
|
||||||
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
|
||||||
Observability__IncludeSqlText=false
|
Observability__IncludeSqlText=false
|
||||||
Observability__MaximumSqlTextLength=2000
|
Observability__MaximumSqlTextLength=2000
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
|
||||||
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
|
|
||||||
|
|
||||||
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
|
||||||
PerformanceReporting__Enabled=false
|
PerformanceReporting__Enabled=false
|
||||||
@@ -55,6 +62,18 @@ PerformanceReporting__Enabled=false
|
|||||||
PerformanceReporting__CacheSeconds=30
|
PerformanceReporting__CacheSeconds=30
|
||||||
PerformanceReporting__TimeoutSeconds=10
|
PerformanceReporting__TimeoutSeconds=10
|
||||||
|
|
||||||
|
# ClickHouse 仅作为异步分析读模型,不参与教务事务写入。启用前请为应用创建
|
||||||
|
# 仅能操作该分析库的独立账号,并通过 TLS 或受信任的内网访问。
|
||||||
|
ClickHouseAnalytics__Enabled=false
|
||||||
|
# ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
|
||||||
|
# ClickHouseAnalytics__Database=jiaowu_analytics
|
||||||
|
# ClickHouseAnalytics__UserName=jiaowu_analytics
|
||||||
|
# ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||||
|
ClickHouseAnalytics__CreateSchemaOnStartup=true
|
||||||
|
ClickHouseAnalytics__SyncIntervalSeconds=60
|
||||||
|
ClickHouseAnalytics__SourceLookbackDays=90
|
||||||
|
ClickHouseAnalytics__BatchSize=1000
|
||||||
|
|
||||||
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
|
||||||
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
Operations__BackupDirectory=/var/lib/jiaowu/backups
|
||||||
Operations__BackupWarningHours=24
|
Operations__BackupWarningHours=24
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
echo "profile=$profile" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build self-contained platform packages
|
- name: Build framework-dependent platform packages
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
bash ./scripts/publish-platform-packages.sh \
|
bash ./scripts/publish-platform-packages.sh \
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
- Windows x64:`.zip`
|
- Windows x64:`.zip`
|
||||||
- Linux x64 / ARM64:`.tar.gz`
|
- Linux x64 / ARM64:`.tar.gz`
|
||||||
- `SHA256SUMS`:发布包校验值
|
- `SHA256SUMS`:发布包校验值
|
||||||
- 自包含程序包无需预装 .NET 或 ASP.NET Core Runtime
|
- 二进制程序包不包含 .NET 或 ASP.NET Core Runtime;请先安装匹配版本的 ASP.NET Core Runtime
|
||||||
|
|
||||||
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
|
Windows ARM64 与 macOS Intel / Apple Silicon 包可通过手动运行工作流并启用扩展平台生成。
|
||||||
|
|
||||||
|
|||||||
Submodule
+1
Submodule Academic-Affairs-System.wiki added at fe88e71716
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
|
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
|
||||||
WORKDIR /source
|
WORKDIR /source
|
||||||
|
COPY versions.props ./
|
||||||
COPY web/package.json web/package-lock.json ./web/
|
COPY web/package.json web/package-lock.json ./web/
|
||||||
RUN npm --prefix web ci
|
RUN npm --prefix web ci
|
||||||
COPY web/ ./web/
|
COPY web/ ./web/
|
||||||
@@ -11,6 +12,7 @@ FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
WORKDIR /source
|
WORKDIR /source
|
||||||
COPY global.json ./
|
COPY global.json ./
|
||||||
|
COPY versions.props ./
|
||||||
COPY .env.example ./
|
COPY .env.example ./
|
||||||
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
|
COPY src/Jiaowu.Api/Jiaowu.Api.csproj ./src/Jiaowu.Api/
|
||||||
RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH"
|
RUN dotnet restore ./src/Jiaowu.Api/Jiaowu.Api.csproj --arch "$TARGETARCH"
|
||||||
|
|||||||
@@ -366,39 +366,36 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
|
|||||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||||
|
|
||||||
### OpenTelemetry 与慢查询定位
|
### 慢接口与慢查询基线
|
||||||
|
|
||||||
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
|
应用不依赖 OpenTelemetry。启用 `Observability` 后,会为每个 `/api` 请求写入结构化的
|
||||||
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
|
方法、路径、终结点、状态码、耗时和请求号;超过阈值或返回 5xx 的请求会提升为 Warning。
|
||||||
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
|
EF Core 数据库命令超过阈值时同样写入查询名称、SQL 模板哈希、数据库类型、耗时和相同的
|
||||||
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
|
请求号。用日志平台按 `DurationMs` 聚合即可得到真实的 P50/P95/P99 和慢接口排行。
|
||||||
仍然有效。
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Observability__Enabled=true
|
Observability__Enabled=true
|
||||||
Observability__ServiceName=jiaowu-api
|
Observability__ServiceName=jiaowu-api
|
||||||
|
Observability__LogAllApiRequests=true
|
||||||
|
Observability__SlowRequestThresholdMilliseconds=1000
|
||||||
Observability__SlowQueryThresholdMilliseconds=500
|
Observability__SlowQueryThresholdMilliseconds=500
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
|
|
||||||
```
|
```
|
||||||
|
|
||||||
数据库指标包括 `jiaowu.db.command.duration`、`jiaowu.db.command.slow` 和
|
为关键 EF 查询添加 `TagWith("模块.查询名")` 后,慢 SQL 日志会直接显示稳定名称;无标签
|
||||||
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
|
查询只显示操作类型和 SQL 模板哈希。默认 `Observability__IncludeSqlText=false`,不会把
|
||||||
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
|
SQL 模板、参数值或连接串写入日志。仅在受控诊断窗口内临时启用 SQL 模板记录,并限制日志
|
||||||
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
|
访问权限与保留时间。
|
||||||
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
|
|
||||||
|
|
||||||
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
应用侧请求号用于关联接口和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
|
||||||
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
|
||||||
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
|
||||||
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
|
||||||
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
|
||||||
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
|
||||||
|
|
||||||
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
|
如部署环境另行提供 Prometheus 兼容指标源,超级管理员仍可在“组织与权限 → 运维与审计 →
|
||||||
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
|
系统性能”查看其汇总数据。该页面只读查询外部指标源,浏览器不会接触其地址或令牌;结果
|
||||||
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
|
默认缓存 30 秒。应用本身不会再通过 OpenTelemetry 向该指标源写入数据。
|
||||||
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
|
|
||||||
Grafana 中下钻,配置其地址后页面会显示跳转入口。
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PerformanceReporting__Enabled=true
|
PerformanceReporting__Enabled=true
|
||||||
@@ -415,6 +412,22 @@ Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置
|
|||||||
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||||
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
`*MetricName` 和 `ServiceNameLabel` 配置项适配,无需改前端。
|
||||||
|
|
||||||
|
### ClickHouse 分析读模型
|
||||||
|
|
||||||
|
ClickHouse 仅用于考勤、操作审计和成绩趋势的多维聚合,MySQL 仍是所有教务业务的唯一写入源。默认关闭;启用后,后台工作器以可重试的滚动窗口投影 MySQL 当前事实到 `ReplacingMergeTree` 表,重复投递不会改变读结果。
|
||||||
|
|
||||||
|
生产环境请为分析库创建独立账号,并限制其只能访问 `ClickHouseAnalytics__Database`。推荐通过 HTTPS 或内网连接:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ClickHouseAnalytics__Enabled=true
|
||||||
|
ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
|
||||||
|
ClickHouseAnalytics__Database=jiaowu_analytics
|
||||||
|
ClickHouseAnalytics__UserName=jiaowu_analytics
|
||||||
|
ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
分析概览通过 `GET /api/clickhouse-analytics/overview` 提供;学院管理员只能读取本学院的考勤和成绩趋势,跨学院的操作审计仅对全校数据范围角色开放。ClickHouse 暂时不可用时,业务写入不会失败,工作器会在下一个周期重试。
|
||||||
|
|
||||||
### 后台任务与 RabbitMQ
|
### 后台任务与 RabbitMQ
|
||||||
|
|
||||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||||
@@ -455,7 +468,7 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
|
|||||||
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
消息默认保留 14 天并按每批 500 条清理,可使用 `CompletedRetentionDays`、
|
||||||
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
`MaintenanceIntervalSeconds` 和 `CleanupBatchSize` 调整。应用暴露
|
||||||
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
`Jiaowu.BackgroundJobs` Meter,其中包含发布量、处理量、发布耗时、处理耗时和清理量,
|
||||||
可接入现有 OpenTelemetry/运行时指标采集器。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
可由现有日志平台或运行时指标采集器汇总。MySQL 或 RabbitMQ 暂时不可用时,未完成
|
||||||
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
|
||||||
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ x-jiaowu-environment: &jiaowu-environment
|
|||||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||||
Cache__Enabled: "true"
|
Cache__Enabled: "true"
|
||||||
Cache__KeyPrefix: "jiaowu:v1"
|
Cache__KeyPrefix: "jiaowu:v1"
|
||||||
|
ClickHouseAnalytics__Enabled: "true"
|
||||||
|
ClickHouseAnalytics__Endpoint: "http://clickhouse:8123"
|
||||||
|
ClickHouseAnalytics__Database: "jiaowu_analytics"
|
||||||
|
ClickHouseAnalytics__UserName: "${CLICKHOUSE_USER:-jiaowu_analytics}"
|
||||||
|
ClickHouseAnalytics__Password: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
|
||||||
BackgroundJobs__Transport: RabbitMq
|
BackgroundJobs__Transport: RabbitMq
|
||||||
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||||
@@ -47,6 +52,25 @@ x-json-logging: &json-logging
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:25.8-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_DB: jiaowu_analytics
|
||||||
|
CLICKHOUSE_USER: "${CLICKHOUSE_USER:-jiaowu_analytics}"
|
||||||
|
CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- wget -qO- http://localhost:8123/ping | grep -q Ok
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
volumes:
|
||||||
|
- clickhouse-data:/var/lib/clickhouse
|
||||||
|
logging: *json-logging
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:4.2-management-alpine
|
image: rabbitmq:4.2-management-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -135,6 +159,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
clickhouse:
|
||||||
|
condition: service_healthy
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
ports:
|
ports:
|
||||||
@@ -165,5 +191,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
clickhouse-data:
|
||||||
rabbitmq-data:
|
rabbitmq-data:
|
||||||
backup-data:
|
backup-data:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ for runtime in "${runtimes[@]}"; do
|
|||||||
dotnet publish "$project" \
|
dotnet publish "$project" \
|
||||||
--configuration Release \
|
--configuration Release \
|
||||||
--runtime "$runtime" \
|
--runtime "$runtime" \
|
||||||
--self-contained true \
|
--self-contained false \
|
||||||
--output "$package_root" \
|
--output "$package_root" \
|
||||||
-p:BuildFrontendOnPublish=false \
|
-p:BuildFrontendOnPublish=false \
|
||||||
-p:Version="$version" \
|
-p:Version="$version" \
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
@@ -23,43 +24,73 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
// ═══════════════ Aggregated pending ═══════════════
|
// ═══════════════ Aggregated pending ═══════════════
|
||||||
[HttpGet("pending")]
|
[HttpGet("pending")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetPending(CancellationToken ct)
|
public async Task<ActionResult<PagedResult<ApprovalItem>>> GetPending(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var items = new List<ApprovalItem>();
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("page 必须大于 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)
|
items.AddRange(await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseExemption>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<DeferredExam>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
|
items.AddRange(await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<GradeModification>(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
|
items.AddRange(await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseSubstitution>(x => x.Status == ApprovalStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
|
items.AddRange(await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<StudentStatusChange>(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
|
items.AddRange(await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<CourseAdjustment>(x => x.Status == CourseAdjustmentStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
|
items.AddRange(await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted)
|
||||||
|
.OrderByDescending(x => x.SubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
.Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<GradeSheet>(x => x.Status == GradeSheetStatus.Submitted).CountAsync(ct);
|
||||||
|
|
||||||
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
|
items.AddRange(await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending)
|
||||||
|
.OrderByDescending(x => x.AppealSubmittedAt).Take(take)
|
||||||
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
|
.Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name))
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct));
|
||||||
|
total += await Scoped<AttendanceRecord>(x => x.AppealStatus == AttendanceAppealStatus.Pending).CountAsync(ct);
|
||||||
|
|
||||||
return Ok(items.OrderByDescending(x => x.Time).ToList());
|
var pageItems = items
|
||||||
|
.OrderByDescending(x => x.Time)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToArray();
|
||||||
|
return Ok(new PagedResult<ApprovalItem>(pageItems, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Course Exemption ═══════════════
|
// ═══════════════ Course Exemption ═══════════════
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -33,14 +34,32 @@ public sealed class AttendanceController(
|
|||||||
[Authorize(Roles = AttendanceRoles)]
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> GetMyTasks(
|
public async Task<ActionResult> GetMyTasks(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
string? keyword = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||||
var tasks = AccessibleTasks().AsNoTracking()
|
var tasks = AccessibleTasks().AsNoTracking()
|
||||||
.Where(x => x.Status == TeachingTaskStatus.Published);
|
.Where(x => x.Status == TeachingTaskStatus.Published);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
tasks = tasks.Where(x =>
|
||||||
|
x.TaskNumber.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
x.Course!.Code.Contains(keyword) ||
|
||||||
|
x.Course.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var total = await tasks.CountAsync(cancellationToken);
|
||||||
var result = await tasks
|
var result = await tasks
|
||||||
.OrderBy(x => x.Course!.Code)
|
.OrderBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -63,22 +82,44 @@ public sealed class AttendanceController(
|
|||||||
sheet.TeachingTaskId == x.Id)
|
sheet.TeachingTaskId == x.Id)
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return Ok(result);
|
return Ok(new PagedResult<object>(result, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sheets")]
|
[HttpGet("sheets")]
|
||||||
[Authorize(Roles = AttendanceRoles)]
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
Guid teachingTaskId,
|
Guid teachingTaskId,
|
||||||
CancellationToken cancellationToken)
|
string? keyword = null,
|
||||||
|
AttendanceSheetStatus? status = null,
|
||||||
|
AttendanceCheckInMethod? checkInMethod = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 50);
|
||||||
var task = await AccessibleTasks().AsNoTracking()
|
var task = await AccessibleTasks().AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
|
||||||
if (task is null) return NotFound();
|
if (task is null) return NotFound();
|
||||||
|
|
||||||
var sheets = await db.AttendanceSheets.AsNoTracking()
|
var source = db.AttendanceSheets.AsNoTracking()
|
||||||
.Where(x => x.TeachingTaskId == teachingTaskId)
|
.Where(x => x.TeachingTaskId == teachingTaskId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x => x.Name.Contains(keyword) || x.Notes!.Contains(keyword));
|
||||||
|
}
|
||||||
|
if (status.HasValue)
|
||||||
|
source = source.Where(x => x.Status == status.Value);
|
||||||
|
if (checkInMethod.HasValue)
|
||||||
|
source = source.Where(x => x.CheckInMethod == checkInMethod.Value);
|
||||||
|
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var sheets = await source
|
||||||
.OrderByDescending(x => x.AttendanceDate)
|
.OrderByDescending(x => x.AttendanceDate)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -99,7 +140,7 @@ public sealed class AttendanceController(
|
|||||||
TotalCount = x.Records.Count
|
TotalCount = x.Records.Count
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
return Ok(sheets);
|
return Ok(new PagedResult<object>(sheets, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets")]
|
[HttpPost("sheets")]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Common;
|
using Jiaowu.Api.Domain.Common;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
@@ -232,8 +233,49 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classes")]
|
[HttpGet("classes")]
|
||||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken)
|
public async Task<ActionResult<object>> GetClasses(
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
string? keyword,
|
||||||
|
Guid? collegeId,
|
||||||
|
Guid? majorId,
|
||||||
|
int? grade,
|
||||||
|
bool? isEnabled,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (page.HasValue || pageSize.HasValue)
|
||||||
|
{
|
||||||
|
if (page is < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
|
var source = db.AdministrativeClasses.AsNoTracking();
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
source = source.Where(x => x.Major!.CollegeId == collegeId.Value);
|
||||||
|
if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId.Value);
|
||||||
|
if (grade.HasValue) source = source.Where(x => x.Grade == grade.Value);
|
||||||
|
if (isEnabled.HasValue) source = source.Where(x => x.IsEnabled == isEnabled.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.Code.Contains(keyword) || x.Name.Contains(keyword) ||
|
||||||
|
x.Major!.Name.Contains(keyword) ||
|
||||||
|
x.Major.College!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||||
|
.Skip((page!.Value - 1) * pageSize!.Value)
|
||||||
|
.Take(pageSize.Value)
|
||||||
|
.Select(x => new AdministrativeClassListItem(
|
||||||
|
x.Id, x.Code, x.Name, x.MajorId, x.Major!.Name,
|
||||||
|
x.Major.College!.Name, x.Grade, x.CounselorUserId,
|
||||||
|
x.CounselorName, x.IsEnabled, x.SortOrder))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<AdministrativeClassListItem>(
|
||||||
|
items, total, page.Value, pageSize.Value));
|
||||||
|
}
|
||||||
|
|
||||||
var result = await cache.GetOrCreateAsync(
|
var result = await cache.GetOrCreateAsync(
|
||||||
AppCacheKeys.BaseData("classes"),
|
AppCacheKeys.BaseData("classes"),
|
||||||
token => db.AdministrativeClasses.AsNoTracking()
|
token => db.AdministrativeClasses.AsNoTracking()
|
||||||
@@ -474,8 +516,54 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("classrooms")]
|
[HttpGet("classrooms")]
|
||||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
|
public async Task<ActionResult<object>> GetClassrooms(
|
||||||
|
int? page,
|
||||||
|
int? pageSize,
|
||||||
|
string? keyword,
|
||||||
|
Guid? campusId,
|
||||||
|
Guid? buildingId,
|
||||||
|
int? minimumCapacity,
|
||||||
|
TeachingVenueNature? teachingVenueNature,
|
||||||
|
bool? isEnabled,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (page.HasValue || pageSize.HasValue)
|
||||||
|
{
|
||||||
|
if (page is < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
|
var source = db.Classrooms.AsNoTracking();
|
||||||
|
if (campusId.HasValue)
|
||||||
|
source = source.Where(x => x.Building!.CampusId == campusId.Value);
|
||||||
|
if (buildingId.HasValue) source = source.Where(x => x.BuildingId == buildingId.Value);
|
||||||
|
if (minimumCapacity.HasValue)
|
||||||
|
source = source.Where(x => x.Capacity >= minimumCapacity.Value);
|
||||||
|
if (teachingVenueNature.HasValue)
|
||||||
|
source = source.Where(x => (x.TeachingVenueNature & teachingVenueNature.Value) != 0);
|
||||||
|
if (isEnabled.HasValue) source = source.Where(x => x.IsEnabled == isEnabled.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.Code.Contains(keyword) || x.Name.Contains(keyword) ||
|
||||||
|
x.Building!.Name.Contains(keyword) ||
|
||||||
|
x.Building.Campus!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderBy(x => x.Building!.Campus!.SortOrder).ThenBy(x => x.Code)
|
||||||
|
.Skip((page!.Value - 1) * pageSize!.Value)
|
||||||
|
.Take(pageSize.Value)
|
||||||
|
.Select(x => new ClassroomListItem(
|
||||||
|
x.Id, x.Code, x.Name, x.BuildingId, x.Building!.CampusId,
|
||||||
|
x.Building.Name, x.Building.Campus!.Name, x.Capacity,
|
||||||
|
x.RoomType, x.TeachingVenueNature, x.Equipment,
|
||||||
|
x.IsEnabled, x.SortOrder))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<ClassroomListItem>(
|
||||||
|
items, total, page.Value, pageSize.Value));
|
||||||
|
}
|
||||||
|
|
||||||
var result = await cache.GetOrCreateAsync(
|
var result = await cache.GetOrCreateAsync(
|
||||||
AppCacheKeys.BaseData("classrooms"),
|
AppCacheKeys.BaseData("classrooms"),
|
||||||
token => db.Classrooms.AsNoTracking()
|
token => db.Classrooms.AsNoTracking()
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = AnalyticsUsers)]
|
||||||
|
[Route("api/clickhouse-analytics")]
|
||||||
|
public sealed class ClickHouseAnalyticsController(
|
||||||
|
ClickHouseAnalyticsClient client,
|
||||||
|
ClickHouseAnalyticsOptions options,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string AnalyticsUsers =
|
||||||
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin + "," + SystemRoles.Leader;
|
||||||
|
|
||||||
|
[HttpGet("status")]
|
||||||
|
public ActionResult GetStatus() => Ok(new
|
||||||
|
{
|
||||||
|
options.Enabled,
|
||||||
|
options.SyncIntervalSeconds,
|
||||||
|
options.SourceLookbackDays,
|
||||||
|
options.BatchSize,
|
||||||
|
options.Database
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("overview")]
|
||||||
|
public async Task<ActionResult> GetOverview(
|
||||||
|
DateOnly? from,
|
||||||
|
DateOnly? to,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Enabled)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "ClickHouse 分析未启用",
|
||||||
|
Detail = "请先配置 ClickHouseAnalytics 并启动分析库。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var start = from ?? end.AddDays(-29);
|
||||||
|
if (start > end || end.DayNumber - start.DayNumber > 366)
|
||||||
|
return ValidationProblem("分析时间范围应为 1 到 366 天,且开始日期不能晚于结束日期。");
|
||||||
|
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var collegeFilter = scope.RestrictedCollegeId is { } collegeId
|
||||||
|
? $" AND collegeId = toUUID('{collegeId:D}')"
|
||||||
|
: string.Empty;
|
||||||
|
var dateFilter = $"attendanceDate >= toDate('{start:yyyy-MM-dd}') AND attendanceDate <= toDate('{end:yyyy-MM-dd}')";
|
||||||
|
|
||||||
|
var attendance = await client.QueryAsync($"""
|
||||||
|
SELECT attendanceDate, count() AS total, countIf(status = 1) AS present,
|
||||||
|
countIf(status = 2) AS absent, countIf(status = 3) AS late
|
||||||
|
FROM {options.Database}.attendanceRecords FINAL
|
||||||
|
WHERE {dateFilter}{collegeFilter}
|
||||||
|
GROUP BY attendanceDate ORDER BY attendanceDate
|
||||||
|
""", cancellationToken);
|
||||||
|
var grades = await client.QueryAsync($"""
|
||||||
|
SELECT academicTermId, academicTermName,
|
||||||
|
totalStudentCount AS studentCount,
|
||||||
|
round(totalWeightedScore / nullIf(totalStudentCount, 0), 2) AS averageScore,
|
||||||
|
round(totalPassedCount / nullIf(totalStudentCount, 0), 4) AS passRate
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
SELECT academicTermId, any(academicTermName) AS academicTermName,
|
||||||
|
sum(studentCount) AS totalStudentCount,
|
||||||
|
sum(averageScore * studentCount) AS totalWeightedScore,
|
||||||
|
sum(passedCount) AS totalPassedCount
|
||||||
|
FROM {options.Database}.gradeStatistics FINAL
|
||||||
|
WHERE 1 = 1{collegeFilter}
|
||||||
|
GROUP BY academicTermId
|
||||||
|
) AS gradeTotals
|
||||||
|
ORDER BY academicTermName
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Audit data has no college dimension, so it is never exposed to a
|
||||||
|
// college-scoped administrator.
|
||||||
|
var audit = scope.RestrictedCollegeId is null
|
||||||
|
? await client.QueryAsync($"""
|
||||||
|
SELECT toDate(occurredAt) AS date, count() AS total,
|
||||||
|
countIf(statusCode >= 400) AS failed
|
||||||
|
FROM {options.Database}.auditEvents FINAL
|
||||||
|
WHERE occurredAt >= toDateTime('{start:yyyy-MM-dd}')
|
||||||
|
AND occurredAt < toDateTime('{end.AddDays(1):yyyy-MM-dd}')
|
||||||
|
GROUP BY date ORDER BY date
|
||||||
|
""", cancellationToken)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return Ok(new { Start = start, End = end, Attendance = attendance, Grades = grades, Audit = audit });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -176,8 +177,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
public async Task<ActionResult> GetMine(
|
public async Task<ActionResult> GetMine(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CourseAdjustmentStatus? status,
|
CourseAdjustmentStatus? status,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var userId = currentUserDataScope.Current.UserId;
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
var source = db.CourseAdjustments.AsNoTracking()
|
var source = db.CourseAdjustments.AsNoTracking()
|
||||||
.Where(x => x.ApplicantUserId == userId);
|
.Where(x => x.ApplicantUserId == userId);
|
||||||
@@ -187,10 +193,15 @@ public sealed class CourseAdjustmentsController(
|
|||||||
if (status.HasValue)
|
if (status.HasValue)
|
||||||
source = source.Where(x => x.Status == status);
|
source = source.Where(x => x.Status == status);
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(AdjustmentProjection())
|
.Select(AdjustmentProjection())
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Pending reviews ═══════════════
|
// ═══════════════ Pending reviews ═══════════════
|
||||||
@@ -199,8 +210,13 @@ public sealed class CourseAdjustmentsController(
|
|||||||
[Authorize(Roles = Reviewers)]
|
[Authorize(Roles = Reviewers)]
|
||||||
public async Task<ActionResult> GetPendingReviews(
|
public async Task<ActionResult> GetPendingReviews(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var scope = currentUserDataScope.Current;
|
var scope = currentUserDataScope.Current;
|
||||||
var source = db.CourseAdjustments.AsNoTracking()
|
var source = db.CourseAdjustments.AsNoTracking()
|
||||||
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
|
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
|
||||||
@@ -211,10 +227,15 @@ public sealed class CourseAdjustmentsController(
|
|||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.SubmittedAt)
|
.OrderByDescending(x => x.SubmittedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(AdjustmentProjection())
|
.Select(AdjustmentProjection())
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Detail ═══════════════
|
// ═══════════════ Detail ═══════════════
|
||||||
|
|||||||
@@ -399,8 +399,22 @@ public sealed class CourseSelectionsController(
|
|||||||
|
|
||||||
[HttpGet("offerings/{id:guid}/roster")]
|
[HttpGet("offerings/{id:guid}/roster")]
|
||||||
[Authorize(Roles = RosterReaders)]
|
[Authorize(Roles = RosterReaders)]
|
||||||
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> GetRoster(
|
||||||
|
Guid id,
|
||||||
|
string? keyword = null,
|
||||||
|
int? grade = null,
|
||||||
|
Guid? majorId = null,
|
||||||
|
Guid? administrativeClassId = null,
|
||||||
|
int studentPage = 1,
|
||||||
|
int studentPageSize = 20,
|
||||||
|
int waitlistPage = 1,
|
||||||
|
int waitlistPageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
studentPage = Math.Max(1, studentPage);
|
||||||
|
studentPageSize = Math.Clamp(studentPageSize, 10, 100);
|
||||||
|
waitlistPage = Math.Max(1, waitlistPage);
|
||||||
|
waitlistPageSize = Math.Clamp(waitlistPageSize, 10, 100);
|
||||||
var offering = await db.CourseSelectionOfferings.AsNoTracking()
|
var offering = await db.CourseSelectionOfferings.AsNoTracking()
|
||||||
.Where(x => x.Id == id)
|
.Where(x => x.Id == id)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
@@ -428,11 +442,64 @@ public sealed class CourseSelectionsController(
|
|||||||
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
|
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
var students = await db.CourseEnrollments.AsNoTracking()
|
var enrolledSource = db.CourseEnrollments.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.CourseSelectionOfferingId == id &&
|
x.CourseSelectionOfferingId == id &&
|
||||||
x.Status == CourseEnrollmentStatus.Enrolled)
|
x.Status == CourseEnrollmentStatus.Enrolled);
|
||||||
|
var waitlistSource = db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Waitlisted);
|
||||||
|
var enrolledCount = await enrolledSource.CountAsync(cancellationToken);
|
||||||
|
var waitlistedCount = await waitlistSource.CountAsync(cancellationToken);
|
||||||
|
var rosterSource = db.CourseEnrollments.AsNoTracking().Where(x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
(x.Status == CourseEnrollmentStatus.Enrolled ||
|
||||||
|
x.Status == CourseEnrollmentStatus.Waitlisted));
|
||||||
|
var filterOptions = await rosterSource
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
Grade = x.Student!.AdministrativeClass!.Grade,
|
||||||
|
MajorId = x.Student.AdministrativeClass.MajorId,
|
||||||
|
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||||
|
ClassId = x.Student.AdministrativeClassId,
|
||||||
|
ClassName = x.Student.AdministrativeClass.Name
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (grade.HasValue)
|
||||||
|
{
|
||||||
|
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
|
||||||
|
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
|
||||||
|
}
|
||||||
|
if (majorId.HasValue)
|
||||||
|
{
|
||||||
|
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
|
||||||
|
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
|
||||||
|
}
|
||||||
|
if (administrativeClassId.HasValue)
|
||||||
|
{
|
||||||
|
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
|
||||||
|
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
enrolledSource = enrolledSource.Where(x =>
|
||||||
|
x.Student!.StudentNumber.Contains(keyword) ||
|
||||||
|
x.Student.Name.Contains(keyword) ||
|
||||||
|
x.Student.AdministrativeClass!.Name.Contains(keyword));
|
||||||
|
waitlistSource = waitlistSource.Where(x =>
|
||||||
|
x.Student!.StudentNumber.Contains(keyword) ||
|
||||||
|
x.Student.Name.Contains(keyword) ||
|
||||||
|
x.Student.AdministrativeClass!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
var enrolledTotal = await enrolledSource.CountAsync(cancellationToken);
|
||||||
|
var waitlistedTotal = await waitlistSource.CountAsync(cancellationToken);
|
||||||
|
var students = await enrolledSource
|
||||||
.OrderBy(x => x.Student!.StudentNumber)
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Skip((studentPage - 1) * studentPageSize)
|
||||||
|
.Take(studentPageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -445,12 +512,11 @@ public sealed class CourseSelectionsController(
|
|||||||
x.EnrolledAt
|
x.EnrolledAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var waitlistedRows = await db.CourseEnrollments.AsNoTracking()
|
var waitlistedRows = await waitlistSource
|
||||||
.Where(x =>
|
|
||||||
x.CourseSelectionOfferingId == id &&
|
|
||||||
x.Status == CourseEnrollmentStatus.Waitlisted)
|
|
||||||
.OrderBy(x => x.WaitlistedAt)
|
.OrderBy(x => x.WaitlistedAt)
|
||||||
.ThenBy(x => x.CreatedAt)
|
.ThenBy(x => x.CreatedAt)
|
||||||
|
.Skip((waitlistPage - 1) * waitlistPageSize)
|
||||||
|
.Take(waitlistPageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -474,7 +540,7 @@ public sealed class CourseSelectionsController(
|
|||||||
item.MajorName,
|
item.MajorName,
|
||||||
item.Grade,
|
item.Grade,
|
||||||
item.WaitlistedAt,
|
item.WaitlistedAt,
|
||||||
Position = index + 1
|
Position = (waitlistPage - 1) * waitlistPageSize + index + 1
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -487,10 +553,24 @@ public sealed class CourseSelectionsController(
|
|||||||
offering.CourseName,
|
offering.CourseName,
|
||||||
offering.CourseNature,
|
offering.CourseNature,
|
||||||
offering.Capacity,
|
offering.Capacity,
|
||||||
EnrolledCount = students.Count,
|
EnrolledCount = enrolledCount,
|
||||||
Students = students,
|
Students = students,
|
||||||
WaitlistedCount = waitlist.Count,
|
StudentPage = studentPage,
|
||||||
|
StudentPageSize = studentPageSize,
|
||||||
|
StudentTotal = enrolledTotal,
|
||||||
|
WaitlistedCount = waitlistedCount,
|
||||||
Waitlist = waitlist,
|
Waitlist = waitlist,
|
||||||
|
WaitlistPage = waitlistPage,
|
||||||
|
WaitlistPageSize = waitlistPageSize,
|
||||||
|
WaitlistTotal = waitlistedTotal,
|
||||||
|
FilterOptions = new
|
||||||
|
{
|
||||||
|
Grades = filterOptions.Select(x => x.Grade).Distinct().OrderBy(x => x),
|
||||||
|
Majors = filterOptions.Select(x => new { x.MajorId, x.MajorName }).Distinct()
|
||||||
|
.OrderBy(x => x.MajorName),
|
||||||
|
Classes = filterOptions.Select(x => new { x.ClassId, x.ClassName, x.MajorId })
|
||||||
|
.Distinct().OrderBy(x => x.ClassName)
|
||||||
|
},
|
||||||
CanManageWaitlist =
|
CanManageWaitlist =
|
||||||
offering.RoundStatus == CourseSelectionRoundStatus.Open &&
|
offering.RoundStatus == CourseSelectionRoundStatus.Open &&
|
||||||
(scope.IsInRole(SystemRoles.SuperAdmin) ||
|
(scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
|||||||
@@ -135,9 +135,179 @@ public sealed class DashboardController(
|
|||||||
currentTerm,
|
currentTerm,
|
||||||
counts,
|
counts,
|
||||||
pending,
|
pending,
|
||||||
|
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
|
||||||
DateTime.UtcNow));
|
DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("greeting")]
|
||||||
|
public async Task<ActionResult<DashboardGreeting>> GetGreeting(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentTermId = await db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(x => x.IsCurrent)
|
||||||
|
.Select(x => (Guid?)x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(await BuildGreetingAsync(
|
||||||
|
currentUserDataScope.Current,
|
||||||
|
currentTermId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DashboardGreeting> BuildGreetingAsync(
|
||||||
|
CurrentUserScope scope,
|
||||||
|
Guid? currentTermId,
|
||||||
|
DashboardCounts? counts,
|
||||||
|
DashboardPending? pending,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var name = string.IsNullOrWhiteSpace(scope.DisplayName) ? "" : $"{scope.DisplayName},";
|
||||||
|
var greeting = GetTimeGreeting();
|
||||||
|
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.Leader) ||
|
||||||
|
scope.IsInRole(SystemRoles.Counselor);
|
||||||
|
|
||||||
|
if (!isManager && scope.IsInRole(SystemRoles.Student))
|
||||||
|
{
|
||||||
|
var studentId = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == scope.UserId)
|
||||||
|
.Select(x => (Guid?)x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!studentId.HasValue)
|
||||||
|
return new DashboardGreeting("Student", $"{name}{greeting}", "绑定学籍后,将为你生成课程与成绩学习概览。", "学习节奏", "关联学生档案后,可从课程安排、成绩和考试中生成学习状态摘要。", []);
|
||||||
|
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.Id == studentId.Value)
|
||||||
|
.Select(x => new { x.AdministrativeClassId })
|
||||||
|
.FirstAsync(cancellationToken);
|
||||||
|
var currentTasks = db.TeachingTasks.AsNoTracking().Where(task =>
|
||||||
|
currentTermId.HasValue &&
|
||||||
|
task.AcademicTermId == currentTermId.Value &&
|
||||||
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == studentId.Value &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)));
|
||||||
|
var taskWorkload = await currentTasks.Select(task => new
|
||||||
|
{
|
||||||
|
task.Id,
|
||||||
|
task.CourseId,
|
||||||
|
Credits = task.Course!.Credits,
|
||||||
|
IsClassAssigned = task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId)
|
||||||
|
}).ToListAsync(cancellationToken);
|
||||||
|
var currentCourses = taskWorkload
|
||||||
|
.GroupBy(x => x.CourseId)
|
||||||
|
.Select(x => x.First())
|
||||||
|
.ToList();
|
||||||
|
var courseCount = currentCourses.Count;
|
||||||
|
var courseCredits = currentCourses.Sum(x => x.Credits);
|
||||||
|
var classAssignedCount = taskWorkload.Count(x => x.IsClassAssigned);
|
||||||
|
var selfSelectedCount = taskWorkload.Count(x => !x.IsClassAssigned);
|
||||||
|
var publishedGrades = db.GradeRecords.AsNoTracking().Where(x =>
|
||||||
|
x.StudentId == studentId.Value &&
|
||||||
|
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
currentTermId.HasValue &&
|
||||||
|
x.GradeSheet.TeachingTask!.AcademicTermId == currentTermId.Value);
|
||||||
|
var gradeCount = await publishedGrades.CountAsync(cancellationToken);
|
||||||
|
var average = await publishedGrades
|
||||||
|
.Where(x => x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue)
|
||||||
|
.AverageAsync(x => (decimal?)x.TotalScore, cancellationToken);
|
||||||
|
var failed = await publishedGrades.CountAsync(x =>
|
||||||
|
x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue && x.TotalScore < 60,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var subtitle = failed > 0
|
||||||
|
? $"已发布成绩中有 {failed} 门课程需要重点关注,建议优先查看课程反馈。"
|
||||||
|
: courseCount > 0
|
||||||
|
? $"本学期已有 {courseCount} 门课程、{courseCredits:0.#} 学分进入你的学习安排。"
|
||||||
|
: "本学期暂未发现为你安排或确认选课的课程,可先查看培养方案和选课安排。";
|
||||||
|
var narrative = courseCount == 0
|
||||||
|
? "你的当前学习安排尚未形成:系统还没有找到行政班已安排课程或已确认选课。"
|
||||||
|
: failed > 0
|
||||||
|
? $"本学期已形成 {courseCount} 门课程安排,其中 {classAssignedCount} 个教学班来自行政班安排;已发布成绩中有 {failed} 门需要重点关注。"
|
||||||
|
: gradeCount > 0
|
||||||
|
? $"本学期有 {courseCount} 门课程进入学习安排,已发布 {gradeCount} 门成绩,当前没有不及格记录。"
|
||||||
|
: $"本学期有 {courseCount} 门课程进入学习安排,包含 {classAssignedCount} 个行政班教学班和 {selfSelectedCount} 个自主选课教学班,成绩发布后会在这里更新。";
|
||||||
|
return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", narrative,
|
||||||
|
[
|
||||||
|
new DashboardGreetingInsight("本学期课程", $"{courseCount} 门", $"共 {courseCredits:0.#} 学分", "calm"),
|
||||||
|
new DashboardGreetingInsight("已发布成绩", $"{gradeCount} 门", average.HasValue ? $"平均分 {average.Value:0.0}" : "等待成绩发布", "calm"),
|
||||||
|
new DashboardGreetingInsight("重点关注", $"{failed} 门", failed > 0 ? "建议尽早安排复习与答疑" : "当前无不及格记录", failed > 0 ? "attention" : "positive")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isManager && scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
{
|
||||||
|
var teacherId = await db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == scope.UserId && x.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => (Guid?)x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!teacherId.HasValue)
|
||||||
|
return new DashboardGreeting("Teacher", $"{name}{greeting}", "绑定教师档案后,将为你生成本学期教学负荷概览。", "教学节奏", "关联教师档案后,可从教学班、授课学时和成绩进度生成今日工作摘要。", []);
|
||||||
|
|
||||||
|
var tasks = db.TeachingTasks.AsNoTracking().Where(x =>
|
||||||
|
currentTermId.HasValue && x.AcademicTermId == currentTermId.Value &&
|
||||||
|
x.Teachers.Any(t => t.TeacherId == teacherId.Value));
|
||||||
|
var teachingClasses = await tasks.CountAsync(cancellationToken);
|
||||||
|
var estimatedHours = await tasks.SumAsync(
|
||||||
|
x => (int?)(x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), cancellationToken) ?? 0;
|
||||||
|
var gradeSheets = db.GradeSheets.AsNoTracking().Where(x =>
|
||||||
|
x.TeachingTask!.Teachers.Any(t => t.TeacherId == teacherId.Value) &&
|
||||||
|
currentTermId.HasValue && x.TeachingTask.AcademicTermId == currentTermId.Value);
|
||||||
|
var pendingGrades = await gradeSheets.CountAsync(x =>
|
||||||
|
x.Status == GradeSheetStatus.Draft ||
|
||||||
|
x.Status == GradeSheetStatus.Returned, cancellationToken);
|
||||||
|
var submittedGrades = await gradeSheets.CountAsync(x => x.Status == GradeSheetStatus.Submitted, cancellationToken);
|
||||||
|
var subtitle = pendingGrades > 0
|
||||||
|
? $"有 {pendingGrades} 张成绩登记册尚待提交,完成后可进入审核流程。"
|
||||||
|
: teachingClasses > 0
|
||||||
|
? $"本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时。"
|
||||||
|
: "本学期暂未分配教学班,请留意教学任务安排。";
|
||||||
|
var narrative = pendingGrades > 0
|
||||||
|
? $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时;有 {pendingGrades} 张成绩登记册等待提交。"
|
||||||
|
: $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时,目前没有待提交的成绩登记册。";
|
||||||
|
return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", narrative,
|
||||||
|
[
|
||||||
|
new DashboardGreetingInsight("教学班", $"{teachingClasses} 个", $"预计 {estimatedHours} 学时", "calm"),
|
||||||
|
new DashboardGreetingInsight("待提交成绩", $"{pendingGrades} 张", pendingGrades > 0 ? "请在截止日前完成登记" : "当前无需提交", pendingGrades > 0 ? "attention" : "positive"),
|
||||||
|
new DashboardGreetingInsight("审核中成绩", $"{submittedGrades} 张", submittedGrades > 0 ? "等待审核结果" : "暂无审核中登记册", "calm")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionable = pending is null ? 0 : pending.TeacherApplications + pending.GradeSheets +
|
||||||
|
pending.CourseAdjustments + pending.StudentStatusChanges + pending.GradeModifications +
|
||||||
|
pending.ClassroomReservations + pending.GeneralApprovals;
|
||||||
|
var taskCount = counts?.TeachingTasks ?? 0;
|
||||||
|
var scheduledCount = counts?.ScheduledTeachingTasks ?? 0;
|
||||||
|
var subtitleForManager = actionable > 0
|
||||||
|
? $"当前有 {actionable} 项待办需要跟进,优先处理时效性审核事项。"
|
||||||
|
: taskCount > 0
|
||||||
|
? $"本学期 {taskCount} 个教学班正在运行,当前没有积压待办。"
|
||||||
|
: "当前学期运行数据已就绪,可从教学任务开始推进。";
|
||||||
|
var managerNarrative = actionable > 0
|
||||||
|
? $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表;{actionable} 项待办正等待处理。"
|
||||||
|
: $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表,暂未发现需要你处理的积压事项。";
|
||||||
|
return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", managerNarrative,
|
||||||
|
[
|
||||||
|
new DashboardGreetingInsight("当前待办", $"{actionable} 项", actionable > 0 ? "优先处理可操作事项" : "暂无积压", actionable > 0 ? "attention" : "positive"),
|
||||||
|
new DashboardGreetingInsight("本学期教学班", $"{taskCount} 个", "教学运行规模", "calm"),
|
||||||
|
new DashboardGreetingInsight("已进入课表", $"{scheduledCount} 个", taskCount > 0 ? $"覆盖 {Math.Round(scheduledCount * 100d / taskCount)}% 教学班" : "等待教学任务发布", "calm")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTimeGreeting()
|
||||||
|
{
|
||||||
|
var hour = DateTime.UtcNow.AddHours(8).Hour;
|
||||||
|
return hour < 11 ? "早上好" : hour < 14 ? "中午好" : hour < 18 ? "下午好" : "晚上好";
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<DashboardPending> LoadPendingAsync(
|
private async Task<DashboardPending> LoadPendingAsync(
|
||||||
CurrentUserScope scope,
|
CurrentUserScope scope,
|
||||||
Guid? restrictedCollegeId,
|
Guid? restrictedCollegeId,
|
||||||
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
|
|||||||
DashboardTerm? CurrentTerm,
|
DashboardTerm? CurrentTerm,
|
||||||
DashboardCounts Counts,
|
DashboardCounts Counts,
|
||||||
DashboardPending Pending,
|
DashboardPending Pending,
|
||||||
|
DashboardGreeting Greeting,
|
||||||
DateTime GeneratedAt);
|
DateTime GeneratedAt);
|
||||||
|
|
||||||
|
public sealed record DashboardGreeting(
|
||||||
|
string Role,
|
||||||
|
string Title,
|
||||||
|
string Subtitle,
|
||||||
|
string Label,
|
||||||
|
string Narrative,
|
||||||
|
IReadOnlyList<DashboardGreetingInsight> Insights);
|
||||||
|
|
||||||
|
public sealed record DashboardGreetingInsight(
|
||||||
|
string Label,
|
||||||
|
string Value,
|
||||||
|
string Hint,
|
||||||
|
string Tone);
|
||||||
|
|
||||||
public sealed record DashboardAudience(
|
public sealed record DashboardAudience(
|
||||||
string Level,
|
string Level,
|
||||||
string Title,
|
string Title,
|
||||||
|
|||||||
@@ -391,81 +391,90 @@ public sealed class EvaluationsController(
|
|||||||
if (taskIds.Count == 0)
|
if (taskIds.Count == 0)
|
||||||
return Ok(new { Tasks = Array.Empty<object>() });
|
return Ok(new { Tasks = Array.Empty<object>() });
|
||||||
|
|
||||||
var setups = await db.EvaluationSetups.AsNoTracking()
|
var taskSummaries = await db.EvaluationRecords.AsNoTracking()
|
||||||
.Where(x => x.Status == EvaluationSetupStatus.Closed)
|
.WhereIn(taskIds, record => record.TeachingTaskId)
|
||||||
.Include(x => x.Dimensions.OrderBy(d => d.SortOrder))
|
.Where(record => record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed)
|
||||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
.GroupBy(record => new
|
||||||
|
{
|
||||||
|
record.EvaluationSetupId,
|
||||||
|
SetupName = record.EvaluationSetup!.Name,
|
||||||
|
SetupStartDate = record.EvaluationSetup.AcademicTerm!.StartDate,
|
||||||
|
record.TeachingTaskId,
|
||||||
|
TaskNumber = record.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = record.TeachingTask.Name,
|
||||||
|
record.TeachingTask.AcademicTermId,
|
||||||
|
CourseCode = record.TeachingTask.Course!.Code,
|
||||||
|
CourseName = record.TeachingTask.Course.Name
|
||||||
|
})
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
group.Key,
|
||||||
|
StudentCount = group.Count(),
|
||||||
|
AverageTotal = group.Average(record =>
|
||||||
|
record.Scores.Sum(score => score.Score))
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Key.SetupStartDate)
|
||||||
|
.ThenBy(x => x.Key.CourseCode)
|
||||||
|
.ThenBy(x => x.Key.TaskNumber)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var result = new List<object>();
|
var dimensionSummaries = await (
|
||||||
foreach (var setup in setups)
|
from score in db.EvaluationScores.AsNoTracking()
|
||||||
{
|
join record in db.EvaluationRecords.AsNoTracking()
|
||||||
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
|
on score.EvaluationRecordId equals record.Id
|
||||||
.Where(r => r.EvaluationSetupId == setup.Id)
|
join dimension in db.EvaluationDimensions.AsNoTracking()
|
||||||
.WhereIn(taskIds, r => r.TeachingTaskId)
|
on score.EvaluationDimensionId equals dimension.Id
|
||||||
.Select(r => r.TeachingTaskId)
|
where taskIds.Contains(record.TeachingTaskId) &&
|
||||||
.Distinct()
|
record.EvaluationSetup!.Status == EvaluationSetupStatus.Closed
|
||||||
.ToListAsync(cancellationToken);
|
group score by new
|
||||||
|
|
||||||
foreach (var taskId in setupTaskIds)
|
|
||||||
{
|
{
|
||||||
var task = await db.TeachingTasks.AsNoTracking()
|
record.EvaluationSetupId,
|
||||||
.Where(x => x.Id == taskId)
|
record.TeachingTaskId,
|
||||||
.Select(x => new
|
dimension.Id,
|
||||||
{
|
dimension.Name,
|
||||||
x.Id,
|
dimension.MaxScore,
|
||||||
x.TaskNumber,
|
dimension.SortOrder
|
||||||
x.Name,
|
|
||||||
x.AcademicTermId,
|
|
||||||
CourseCode = x.Course!.Code,
|
|
||||||
CourseName = x.Course.Name
|
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
if (task is null) continue;
|
|
||||||
|
|
||||||
var records = await db.EvaluationRecords.AsNoTracking()
|
|
||||||
.Where(r => r.EvaluationSetupId == setup.Id &&
|
|
||||||
r.TeachingTaskId == taskId)
|
|
||||||
.Include(r => r.Scores)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (records.Count == 0) continue;
|
|
||||||
|
|
||||||
var dimensionResults = setup.Dimensions.Select(dim =>
|
|
||||||
{
|
|
||||||
var scores = records
|
|
||||||
.SelectMany(r => r.Scores)
|
|
||||||
.Where(s => s.EvaluationDimensionId == dim.Id)
|
|
||||||
.Select(s => (double)s.Score)
|
|
||||||
.ToList();
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
DimensionId = dim.Id,
|
|
||||||
DimensionName = dim.Name,
|
|
||||||
MaxScore = dim.MaxScore,
|
|
||||||
AverageScore = scores.Count > 0
|
|
||||||
? Math.Round(scores.Average(), 1) : 0,
|
|
||||||
TotalScore = scores.Sum(),
|
|
||||||
Count = scores.Count
|
|
||||||
};
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
var totalAvg = records
|
|
||||||
.Select(r => (double)r.Scores.Sum(s => s.Score))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
result.Add(new
|
|
||||||
{
|
|
||||||
SetupId = setup.Id,
|
|
||||||
setup.Name,
|
|
||||||
Task = task,
|
|
||||||
StudentCount = records.Count,
|
|
||||||
AverageTotal = totalAvg.Count > 0
|
|
||||||
? Math.Round(totalAvg.Average(), 1) : 0,
|
|
||||||
Dimensions = dimensionResults
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
into scoreGroup
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
scoreGroup.Key,
|
||||||
|
AverageScore = scoreGroup.Average(score => score.Score),
|
||||||
|
TotalScore = scoreGroup.Sum(score => score.Score),
|
||||||
|
Count = scoreGroup.Count()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var dimensionsByTask = dimensionSummaries
|
||||||
|
.GroupBy(item => (item.Key.EvaluationSetupId, item.Key.TeachingTaskId))
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => (IReadOnlyCollection<TeacherEvaluationDimensionResult>)group
|
||||||
|
.OrderBy(item => item.Key.SortOrder)
|
||||||
|
.Select(item => new TeacherEvaluationDimensionResult(
|
||||||
|
item.Key.Id,
|
||||||
|
item.Key.Name,
|
||||||
|
item.Key.MaxScore,
|
||||||
|
Math.Round((double)item.AverageScore, 1),
|
||||||
|
item.TotalScore,
|
||||||
|
item.Count))
|
||||||
|
.ToArray());
|
||||||
|
var result = taskSummaries.Select(item => new TeacherEvaluationTaskResult(
|
||||||
|
item.Key.EvaluationSetupId,
|
||||||
|
item.Key.SetupName,
|
||||||
|
new TeacherEvaluationTask(
|
||||||
|
item.Key.TeachingTaskId,
|
||||||
|
item.Key.TaskNumber,
|
||||||
|
item.Key.TaskName,
|
||||||
|
item.Key.AcademicTermId,
|
||||||
|
item.Key.CourseCode,
|
||||||
|
item.Key.CourseName),
|
||||||
|
item.StudentCount,
|
||||||
|
Math.Round((double)item.AverageTotal, 1),
|
||||||
|
dimensionsByTask.GetValueOrDefault(
|
||||||
|
(item.Key.EvaluationSetupId, item.Key.TeachingTaskId),
|
||||||
|
Array.Empty<TeacherEvaluationDimensionResult>())))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
return Ok(new { Tasks = result });
|
return Ok(new { Tasks = result });
|
||||||
}
|
}
|
||||||
@@ -594,3 +603,27 @@ public sealed record SubmitEvaluationRequest(
|
|||||||
public sealed record EvaluationScoreRequest(
|
public sealed record EvaluationScoreRequest(
|
||||||
Guid DimensionId,
|
Guid DimensionId,
|
||||||
int Score);
|
int Score);
|
||||||
|
|
||||||
|
public sealed record TeacherEvaluationTaskResult(
|
||||||
|
Guid SetupId,
|
||||||
|
string SetupName,
|
||||||
|
TeacherEvaluationTask Task,
|
||||||
|
int StudentCount,
|
||||||
|
double AverageTotal,
|
||||||
|
IReadOnlyCollection<TeacherEvaluationDimensionResult> Dimensions);
|
||||||
|
|
||||||
|
public sealed record TeacherEvaluationTask(
|
||||||
|
Guid Id,
|
||||||
|
string TaskNumber,
|
||||||
|
string Name,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName);
|
||||||
|
|
||||||
|
public sealed record TeacherEvaluationDimensionResult(
|
||||||
|
Guid DimensionId,
|
||||||
|
string DimensionName,
|
||||||
|
int MaxScore,
|
||||||
|
double AverageScore,
|
||||||
|
decimal TotalScore,
|
||||||
|
int Count);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
@@ -74,19 +76,36 @@ public sealed class ExperimentGradesController(
|
|||||||
item.Teacher!.TeacherNumber.Contains(keyword) ||
|
item.Teacher!.TeacherNumber.Contains(keyword) ||
|
||||||
item.Teacher.Name.Contains(keyword)));
|
item.Teacher.Name.Contains(keyword)));
|
||||||
|
|
||||||
var total = await source.CountAsync(cancellationToken);
|
var total = await source.Select(x => x.TeachingTaskId)
|
||||||
var items = await source
|
.Distinct()
|
||||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
.CountAsync(cancellationToken);
|
||||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
var taskIds = await source
|
||||||
.ThenBy(x => x.Code)
|
.GroupBy(x => new
|
||||||
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
|
StartDate = x.TeachingTask!.AcademicTerm!.StartDate,
|
||||||
|
x.TeachingTask.TaskNumber
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Key.StartDate)
|
||||||
|
.ThenBy(x => x.Key.TaskNumber)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
|
.Select(x => x.Key.TeachingTaskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var projects = await source
|
||||||
|
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.OrderBy(x => x.ScheduleWeek ?? int.MaxValue)
|
||||||
|
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.DayOfWeek)
|
||||||
|
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.StartPeriod)
|
||||||
|
.ThenBy(x => x.StartDate)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
x.Code,
|
x.Code,
|
||||||
x.Name,
|
x.Name,
|
||||||
x.ArrangementMode,
|
x.ArrangementMode,
|
||||||
|
x.ScheduleWeek,
|
||||||
ProjectStatus = x.Status,
|
ProjectStatus = x.Status,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
@@ -99,6 +118,14 @@ public sealed class ExperimentGradesController(
|
|||||||
TeacherNames = x.TeachingTask.Teachers
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
.Select(item => item.Teacher!.Name),
|
.Select(item => item.Teacher!.Name),
|
||||||
|
ScheduleEntry = x.ScheduleEntry == null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
x.ScheduleEntry.DayOfWeek,
|
||||||
|
x.ScheduleEntry.StartPeriod,
|
||||||
|
x.ScheduleEntry.PeriodCount
|
||||||
|
},
|
||||||
Sheet = x.GradeSheet == null
|
Sheet = x.GradeSheet == null
|
||||||
? null
|
? null
|
||||||
: new
|
: new
|
||||||
@@ -122,6 +149,32 @@ public sealed class ExperimentGradesController(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
var order = taskIds.Select((id, index) => new { id, index })
|
||||||
|
.ToDictionary(x => x.id, x => x.index);
|
||||||
|
var items = projects.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.OrderBy(group => order[group.Key])
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
TeachingTaskId = group.Key,
|
||||||
|
first.TaskNumber,
|
||||||
|
first.TaskName,
|
||||||
|
first.AcademicTermId,
|
||||||
|
first.TermName,
|
||||||
|
first.CourseCode,
|
||||||
|
first.CourseName,
|
||||||
|
first.CollegeName,
|
||||||
|
first.TeacherNames,
|
||||||
|
ProjectCount = group.Count(),
|
||||||
|
SheetCount = group.Count(project => project.Sheet != null),
|
||||||
|
ScoredCount = group.Sum(project => project.Sheet?.ScoredCount ?? 0),
|
||||||
|
StudentCount = group.Sum(project => project.Sheet?.StudentCount ?? 0),
|
||||||
|
Projects = group.ToList()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Items = items,
|
Items = items,
|
||||||
@@ -316,6 +369,15 @@ public sealed class ExperimentGradesController(
|
|||||||
ProjectCode = x.ExperimentProject!.Code,
|
ProjectCode = x.ExperimentProject!.Code,
|
||||||
ProjectName = x.ExperimentProject.Name,
|
ProjectName = x.ExperimentProject.Name,
|
||||||
x.ExperimentProject.ArrangementMode,
|
x.ExperimentProject.ArrangementMode,
|
||||||
|
x.ExperimentProject.ScheduleWeek,
|
||||||
|
ScheduleEntry = x.ExperimentProject.ScheduleEntry == null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
x.ExperimentProject.ScheduleEntry.DayOfWeek,
|
||||||
|
x.ExperimentProject.ScheduleEntry.StartPeriod,
|
||||||
|
x.ExperimentProject.ScheduleEntry.PeriodCount
|
||||||
|
},
|
||||||
x.ExperimentProject.TeachingTaskId,
|
x.ExperimentProject.TeachingTaskId,
|
||||||
x.ExperimentProject.TeachingTask!.TaskNumber,
|
x.ExperimentProject.TeachingTask!.TaskNumber,
|
||||||
TaskName = x.ExperimentProject.TeachingTask.Name,
|
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||||
@@ -357,6 +419,14 @@ public sealed class ExperimentGradesController(
|
|||||||
|
|
||||||
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
||||||
.Where(x => x.ExperimentGradeSheetId == id);
|
.Where(x => x.ExperimentGradeSheetId == id);
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var isBoundInstructor = scope.Scope == DataScope.Self &&
|
||||||
|
scope.IsInRole(SystemRoles.Teacher) &&
|
||||||
|
sheet.ArrangementMode == ExperimentArrangementMode.SelfScheduled;
|
||||||
|
if (isBoundInstructor)
|
||||||
|
recordsSource = recordsSource.Where(x =>
|
||||||
|
x.ExperimentSession != null && x.ExperimentSession.Instructors.Any(
|
||||||
|
instructor => instructor.Teacher!.UserId == scope.UserId));
|
||||||
if (studentKeyword is not null)
|
if (studentKeyword is not null)
|
||||||
recordsSource = recordsSource.Where(x =>
|
recordsSource = recordsSource.Where(x =>
|
||||||
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||||
@@ -450,7 +520,7 @@ public sealed class ExperimentGradesController(
|
|||||||
sheet.CreatedAt,
|
sheet.CreatedAt,
|
||||||
sheet.UpdatedAt
|
sheet.UpdatedAt
|
||||||
},
|
},
|
||||||
CanEdit = CanEdit(task) &&
|
CanEdit = (CanEdit(task) || isBoundInstructor) &&
|
||||||
sheet.Status is
|
sheet.Status is
|
||||||
ExperimentGradeSheetStatus.Draft or
|
ExperimentGradeSheetStatus.Draft or
|
||||||
ExperimentGradeSheetStatus.Returned,
|
ExperimentGradeSheetStatus.Returned,
|
||||||
@@ -522,13 +592,18 @@ public sealed class ExperimentGradesController(
|
|||||||
.Include(x => x.Items)
|
.Include(x => x.Items)
|
||||||
.Include(x => x.Records)
|
.Include(x => x.Records)
|
||||||
.ThenInclude(x => x.ItemScores)
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ExperimentSession)
|
||||||
|
.ThenInclude(x => x!.Instructors)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Teachers)
|
.ThenInclude(x => x!.Teachers)
|
||||||
.ThenInclude(x => x.Teacher)
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
if (sheet is null) return NotFound();
|
||||||
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!) &&
|
||||||
|
!CanEditBoundSessionRecords(sheet, request.Records)) return Forbid();
|
||||||
if (sheet.Status is not (
|
if (sheet.Status is not (
|
||||||
ExperimentGradeSheetStatus.Draft or
|
ExperimentGradeSheetStatus.Draft or
|
||||||
ExperimentGradeSheetStatus.Returned))
|
ExperimentGradeSheetStatus.Returned))
|
||||||
@@ -574,6 +649,166 @@ public sealed class ExperimentGradesController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/template")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<IActionResult> DownloadTemplate(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
|
||||||
|
var headers = ExperimentImportHeaders(sheet.Items);
|
||||||
|
var rows = sheet.Records
|
||||||
|
.OrderBy(record => record.Student!.StudentNumber)
|
||||||
|
.Select(record =>
|
||||||
|
{
|
||||||
|
var values = new List<object?>
|
||||||
|
{
|
||||||
|
record.Student!.StudentNumber,
|
||||||
|
record.Student.Name,
|
||||||
|
record.Student.AdministrativeClass!.Name,
|
||||||
|
ParticipationLabel(record.ParticipationStatus)
|
||||||
|
};
|
||||||
|
foreach (var item in sheet.Items.OrderBy(item => item.SortOrder))
|
||||||
|
values.Add(record.ItemScores.FirstOrDefault(score =>
|
||||||
|
score.ExperimentGradeItemId == item.Id)?.Score);
|
||||||
|
values.Add(record.SafetyViolation);
|
||||||
|
values.Add(record.AttemptNumber);
|
||||||
|
values.Add(null);
|
||||||
|
values.Add(record.TeacherComment);
|
||||||
|
return (IReadOnlyList<object?>)values;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var itemCount = sheet.Items.Count;
|
||||||
|
var totalColumn = 7 + itemCount;
|
||||||
|
var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
|
||||||
|
var instructions = new List<string>
|
||||||
|
{
|
||||||
|
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。",
|
||||||
|
"参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。",
|
||||||
|
"评分项填写 0—100 的数值,留空表示暂未录入。",
|
||||||
|
"安全违规填写 是 或 否;实验次数填写 1—20。",
|
||||||
|
"实验总评(自动计算)仅供 Excel 预览;上传时系统会按评分项、参与状态和安全违规重新计算。",
|
||||||
|
$"本实验共 {itemCount} 个评分项:{string.Join("、", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。"
|
||||||
|
};
|
||||||
|
var bytes = ExcelWorkbookHelper.Create(
|
||||||
|
"实验成绩导入", headers, rows, instructions,
|
||||||
|
(worksheet, rowNumber) =>
|
||||||
|
{
|
||||||
|
var itemReferences = Enumerable.Range(5, itemCount)
|
||||||
|
.Select(column => $"{ColumnLetter(column)}{rowNumber}")
|
||||||
|
.ToArray();
|
||||||
|
var weightedExpression = string.Join("+", sheet.Items
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.Select((item, index) =>
|
||||||
|
$"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
|
||||||
|
var statusReference = $"D{rowNumber}";
|
||||||
|
var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}";
|
||||||
|
worksheet.Cell(rowNumber, totalColumn).FormulaA1 =
|
||||||
|
$"=IF(OR({statusReference}=\"缺席\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))";
|
||||||
|
var totalCell = worksheet.Cell(rowNumber, totalColumn);
|
||||||
|
totalCell.Style.NumberFormat.Format = "0.0";
|
||||||
|
totalCell.Style.Font.Bold = true;
|
||||||
|
totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
|
||||||
|
foreach (var column in scoreColumns)
|
||||||
|
{
|
||||||
|
var format = worksheet.Range(rowNumber, column, rowNumber, column)
|
||||||
|
.AddConditionalFormat().WhenLessThan(60);
|
||||||
|
format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
|
||||||
|
format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||||
|
$"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/import")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<ActionResult> ImportRecords(
|
||||||
|
Guid id,
|
||||||
|
IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("只有录入中或已退回实验成绩单可以导入成绩。");
|
||||||
|
|
||||||
|
var headers = ExperimentImportHeaders(sheet.Items);
|
||||||
|
IReadOnlyList<ExcelRow> rows;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = await ExcelWorkbookHelper.ReadAsync(file,
|
||||||
|
headers.Where(header => header != "实验总评(自动计算)").ToArray(),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException exception)
|
||||||
|
{
|
||||||
|
return ValidationProblem(exception.Message);
|
||||||
|
}
|
||||||
|
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。");
|
||||||
|
|
||||||
|
var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var items = sheet.Items.OrderBy(item => item.SortOrder).ToList();
|
||||||
|
var errors = new List<string>();
|
||||||
|
var updated = 0;
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var studentNumber = row["学号"];
|
||||||
|
if (string.IsNullOrWhiteSpace(studentNumber))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!records.TryGetValue(studentNumber, out var record))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本实验成绩单中。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:{participationError}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TryParseYesNo(row["安全违规"], out var safetyViolation))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“安全违规”请填写是或否。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20)
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“实验次数”请填写 1—20 的整数。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var scores = new List<decimal?>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors);
|
||||||
|
if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break;
|
||||||
|
scores.Add(score);
|
||||||
|
}
|
||||||
|
if (scores.Count != items.Count) continue;
|
||||||
|
|
||||||
|
record.ParticipationStatus = participationStatus;
|
||||||
|
record.SafetyViolation = safetyViolation;
|
||||||
|
record.AttemptNumber = attemptNumber;
|
||||||
|
record.TeacherComment = Normalize(row["教师评语"]);
|
||||||
|
var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId);
|
||||||
|
for (var index = 0; index < items.Count; index++)
|
||||||
|
scoreMap[items[index].Id].Score = scores[index];
|
||||||
|
Recalculate(sheet, record);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
if (errors.Count > 0) return ImportValidationProblem(errors);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new { Updated = updated, Total = rows.Count });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/sync-participants")]
|
[HttpPost("sheets/{id:guid}/sync-participants")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> SyncParticipants(
|
public async Task<ActionResult> SyncParticipants(
|
||||||
@@ -834,6 +1069,22 @@ public sealed class ExperimentGradesController(
|
|||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ExperimentGradeSheet?> LoadEditableSheetAsync(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ScopedSheets()
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.Student)
|
||||||
|
.ThenInclude(x => x!.AdministrativeClass)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
|
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
|
||||||
ExperimentProject project,
|
ExperimentProject project,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -900,6 +1151,15 @@ public sealed class ExperimentGradesController(
|
|||||||
|
|
||||||
private IQueryable<ExperimentProject> ScopedProjects()
|
private IQueryable<ExperimentProject> ScopedProjects()
|
||||||
{
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
return db.ExperimentProjects.Where(x =>
|
||||||
|
(x.ArrangementMode == ExperimentArrangementMode.Centralized &&
|
||||||
|
x.TeachingTask!.Teachers.Any(item =>
|
||||||
|
item.Teacher!.UserId == scope.UserId)) ||
|
||||||
|
(x.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
x.Sessions.Any(session => session.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher!.UserId == scope.UserId))));
|
||||||
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
||||||
return db.ExperimentProjects.Where(x =>
|
return db.ExperimentProjects.Where(x =>
|
||||||
taskIds.Contains(x.TeachingTaskId));
|
taskIds.Contains(x.TeachingTaskId));
|
||||||
@@ -922,6 +1182,22 @@ public sealed class ExperimentGradesController(
|
|||||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
IsAssignedTeacher(task);
|
IsAssignedTeacher(task);
|
||||||
|
|
||||||
|
private bool CanEditBoundSessionRecords(
|
||||||
|
ExperimentGradeSheet sheet,
|
||||||
|
IReadOnlyCollection<ExperimentGradeRecordRequest> requestedRecords)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled ||
|
||||||
|
scope.Scope != DataScope.Self || !scope.IsInRole(SystemRoles.Teacher) ||
|
||||||
|
requestedRecords.Count == 0)
|
||||||
|
return false;
|
||||||
|
var requestedIds = requestedRecords.Select(x => x.Id).ToHashSet();
|
||||||
|
return requestedIds.All(recordId => sheet.Records.Any(record =>
|
||||||
|
record.Id == recordId &&
|
||||||
|
record.ExperimentSession?.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher?.UserId == scope.UserId) == true));
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsAssignedTeacher(TeachingTask task) =>
|
private bool IsAssignedTeacher(TeachingTask task) =>
|
||||||
task.Teachers.Any(x =>
|
task.Teachers.Any(x =>
|
||||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||||
@@ -974,6 +1250,93 @@ public sealed class ExperimentGradesController(
|
|||||||
private static bool ValidScore(decimal? score) =>
|
private static bool ValidScore(decimal? score) =>
|
||||||
!score.HasValue || score.Value is >= 0 and <= 100;
|
!score.HasValue || score.Value is >= 0 and <= 100;
|
||||||
|
|
||||||
|
private static List<string> ExperimentImportHeaders(
|
||||||
|
IEnumerable<ExperimentGradeItem> items)
|
||||||
|
{
|
||||||
|
var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
|
||||||
|
headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name));
|
||||||
|
headers.AddRange(["安全违规", "实验次数", "实验总评(自动计算)", "教师评语"]);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParticipationLabel(
|
||||||
|
ExperimentParticipationStatus status) => status switch
|
||||||
|
{
|
||||||
|
ExperimentParticipationStatus.Pending => "待登记",
|
||||||
|
ExperimentParticipationStatus.Completed => "已完成",
|
||||||
|
ExperimentParticipationStatus.Absent => "缺席",
|
||||||
|
ExperimentParticipationStatus.Excused => "请假",
|
||||||
|
ExperimentParticipationStatus.Makeup => "补做",
|
||||||
|
ExperimentParticipationStatus.Exempt => "免做",
|
||||||
|
_ => "待登记"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool TryParseParticipationStatus(
|
||||||
|
ExcelRow row,
|
||||||
|
out ExperimentParticipationStatus status,
|
||||||
|
out string error)
|
||||||
|
{
|
||||||
|
error = string.Empty;
|
||||||
|
switch (row["参与状态"])
|
||||||
|
{
|
||||||
|
case "待登记": status = ExperimentParticipationStatus.Pending; return true;
|
||||||
|
case "已完成": status = ExperimentParticipationStatus.Completed; return true;
|
||||||
|
case "缺席": status = ExperimentParticipationStatus.Absent; return true;
|
||||||
|
case "请假": status = ExperimentParticipationStatus.Excused; return true;
|
||||||
|
case "补做": status = ExperimentParticipationStatus.Makeup; return true;
|
||||||
|
case "免做": status = ExperimentParticipationStatus.Exempt; return true;
|
||||||
|
default:
|
||||||
|
status = default;
|
||||||
|
error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseYesNo(string value, out bool result)
|
||||||
|
{
|
||||||
|
if (value == "是") { result = true; return true; }
|
||||||
|
if (value == "否") { result = false; return true; }
|
||||||
|
result = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ParseOptionalDecimal(
|
||||||
|
ExcelRow row,
|
||||||
|
string header,
|
||||||
|
decimal minimum,
|
||||||
|
decimal maximum,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
var value = row[header];
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
if (decimal.TryParse(value, NumberStyles.Number,
|
||||||
|
CultureInfo.InvariantCulture, out var result) &&
|
||||||
|
result >= minimum && result <= maximum)
|
||||||
|
return result;
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||||
|
{
|
||||||
|
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
||||||
|
if (errors.Count > 50)
|
||||||
|
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
|
||||||
|
return ValidationProblem(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ColumnLetter(int column)
|
||||||
|
{
|
||||||
|
var result = string.Empty;
|
||||||
|
while (column > 0)
|
||||||
|
{
|
||||||
|
column--;
|
||||||
|
result = (char)('A' + column % 26) + result;
|
||||||
|
column /= 26;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ public sealed class ExperimentsController(
|
|||||||
x.Name,
|
x.Name,
|
||||||
x.AcademicTermId,
|
x.AcademicTermId,
|
||||||
x.CourseId,
|
x.CourseId,
|
||||||
|
x.SchedulingMode,
|
||||||
TermName = x.AcademicTerm!.Name,
|
TermName = x.AcademicTerm!.Name,
|
||||||
TermStartDate = x.AcademicTerm.StartDate,
|
TermStartDate = x.AcademicTerm.StartDate,
|
||||||
TermEndDate = x.AcademicTerm.EndDate,
|
TermEndDate = x.AcademicTerm.EndDate,
|
||||||
@@ -163,6 +164,19 @@ public sealed class ExperimentsController(
|
|||||||
,x.TeachingVenueNature
|
,x.TeachingVenueNature
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken),
|
.ToListAsync(cancellationToken),
|
||||||
|
Teachers = await AccessibleTeachingTasks().AsNoTracking()
|
||||||
|
.SelectMany(x => x.Teachers)
|
||||||
|
.Where(x => x.Teacher != null && x.Teacher.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeacherId,
|
||||||
|
TeacherNumber = x.Teacher!.TeacherNumber,
|
||||||
|
TeacherName = x.Teacher.Name
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x.TeacherNumber)
|
||||||
|
.ToListAsync(cancellationToken),
|
||||||
Periods = periodItems
|
Periods = periodItems
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,6 +197,11 @@ public sealed class ExperimentsController(
|
|||||||
int pageSize = 20)
|
int pageSize = 20)
|
||||||
{
|
{
|
||||||
var source = ScopedProjects().AsNoTracking();
|
var source = ScopedProjects().AsNoTracking();
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
source = source.Where(x => x.Sessions.Any(session =>
|
||||||
|
session.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher!.UserId == scope.UserId)));
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.TeachingTask!.AcademicTermId == academicTermId);
|
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||||
@@ -249,6 +268,8 @@ public sealed class ExperimentsController(
|
|||||||
x.Requirements,
|
x.Requirements,
|
||||||
x.StartDate,
|
x.StartDate,
|
||||||
x.EndDate,
|
x.EndDate,
|
||||||
|
x.SelectionStartsAt,
|
||||||
|
x.SelectionEndsAt,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.PublishedAt,
|
x.PublishedAt,
|
||||||
x.ClosedAt,
|
x.ClosedAt,
|
||||||
@@ -294,6 +315,14 @@ public sealed class ExperimentsController(
|
|||||||
ClassroomName = item.Classroom!.Name,
|
ClassroomName = item.Classroom!.Name,
|
||||||
BuildingName = item.Classroom.Building!.Name,
|
BuildingName = item.Classroom.Building!.Name,
|
||||||
CampusName = item.Classroom.Building.Campus!.Name
|
CampusName = item.Classroom.Building.Campus!.Name
|
||||||
|
,InstructorNames = item.Instructors
|
||||||
|
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
|
||||||
|
.Select(instructor => instructor.Teacher!.Name)
|
||||||
|
,InstructorTeacherIds = item.Instructors
|
||||||
|
.Select(instructor => instructor.TeacherId)
|
||||||
|
,CanManage = scope.Scope != DataScope.Self ||
|
||||||
|
item.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher!.UserId == scope.UserId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -337,6 +366,8 @@ public sealed class ExperimentsController(
|
|||||||
x.Requirements,
|
x.Requirements,
|
||||||
x.StartDate,
|
x.StartDate,
|
||||||
x.EndDate,
|
x.EndDate,
|
||||||
|
x.SelectionStartsAt,
|
||||||
|
x.SelectionEndsAt,
|
||||||
x.Status,
|
x.Status,
|
||||||
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
AcademicTermId = x.TeachingTask!.AcademicTermId,
|
||||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||||
@@ -376,6 +407,9 @@ public sealed class ExperimentsController(
|
|||||||
ClassroomName = item.Classroom!.Name,
|
ClassroomName = item.Classroom!.Name,
|
||||||
BuildingName = item.Classroom.Building!.Name,
|
BuildingName = item.Classroom.Building!.Name,
|
||||||
CampusName = item.Classroom.Building.Campus!.Name
|
CampusName = item.Classroom.Building.Campus!.Name
|
||||||
|
,InstructorNames = item.Instructors
|
||||||
|
.OrderBy(instructor => instructor.Teacher!.TeacherNumber)
|
||||||
|
.Select(instructor => instructor.Teacher!.Name)
|
||||||
}),
|
}),
|
||||||
MyBooking = x.Bookings
|
MyBooking = x.Bookings
|
||||||
.Where(item =>
|
.Where(item =>
|
||||||
@@ -430,7 +464,9 @@ public sealed class ExperimentsController(
|
|||||||
Description = Normalize(request.Description),
|
Description = Normalize(request.Description),
|
||||||
Requirements = Normalize(request.Requirements),
|
Requirements = Normalize(request.Requirements),
|
||||||
StartDate = request.StartDate,
|
StartDate = request.StartDate,
|
||||||
EndDate = request.EndDate
|
EndDate = request.EndDate,
|
||||||
|
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
||||||
|
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
||||||
};
|
};
|
||||||
db.ExperimentProjects.Add(project);
|
db.ExperimentProjects.Add(project);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -569,7 +605,9 @@ public sealed class ExperimentsController(
|
|||||||
Description = Normalize(request.Description),
|
Description = Normalize(request.Description),
|
||||||
Requirements = Normalize(request.Requirements),
|
Requirements = Normalize(request.Requirements),
|
||||||
StartDate = request.StartDate,
|
StartDate = request.StartDate,
|
||||||
EndDate = request.EndDate
|
EndDate = request.EndDate,
|
||||||
|
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
||||||
|
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +683,8 @@ public sealed class ExperimentsController(
|
|||||||
project.Requirements = Normalize(request.Requirements);
|
project.Requirements = Normalize(request.Requirements);
|
||||||
project.StartDate = request.StartDate;
|
project.StartDate = request.StartDate;
|
||||||
project.EndDate = request.EndDate;
|
project.EndDate = request.EndDate;
|
||||||
|
project.SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt);
|
||||||
|
project.SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -666,6 +706,39 @@ public sealed class ExperimentsController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/published-details")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CorrectPublishedProjectDetails(
|
||||||
|
Guid id,
|
||||||
|
PublishedExperimentProjectCorrectionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var project = await ScopedProjects()
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (project is null) return NotFound();
|
||||||
|
if (project.Status != ExperimentProjectStatus.Published)
|
||||||
|
return ConflictProblem("只有已发布实验项目可以修正教学内容。");
|
||||||
|
if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid();
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
return ValidationProblem("请填写实验项目名称。");
|
||||||
|
|
||||||
|
var changed = project.Name != request.Name.Trim() ||
|
||||||
|
project.Description != Normalize(request.Description) ||
|
||||||
|
project.Requirements != Normalize(request.Requirements);
|
||||||
|
project.Name = request.Name.Trim();
|
||||||
|
project.Description = Normalize(request.Description);
|
||||||
|
project.Requirements = Normalize(request.Requirements);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
if (changed)
|
||||||
|
await NotifyProjectCorrectionAsync(project, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("batch/names")]
|
[HttpPut("batch/names")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> UpdateProjectNames(
|
public async Task<ActionResult> UpdateProjectNames(
|
||||||
@@ -722,6 +795,7 @@ public sealed class ExperimentsController(
|
|||||||
{
|
{
|
||||||
var project = await ScopedProjects()
|
var project = await ScopedProjects()
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.Instructors)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
@@ -741,6 +815,14 @@ public sealed class ExperimentsController(
|
|||||||
(x.SessionDate < project.StartDate ||
|
(x.SessionDate < project.StartDate ||
|
||||||
x.SessionDate > project.EndDate)))
|
x.SessionDate > project.EndDate)))
|
||||||
return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
|
return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
(project.SelectionStartsAt is null || project.SelectionEndsAt is null ||
|
||||||
|
project.SelectionStartsAt >= project.SelectionEndsAt))
|
||||||
|
return ConflictProblem("请先设置有效的开始选课和截至选课时间。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
x.Instructors.Count == 0))
|
||||||
|
return ConflictProblem("请为每个有效实验场次至少指定一位指导老师。");
|
||||||
|
|
||||||
project.Status = ExperimentProjectStatus.Published;
|
project.Status = ExperimentProjectStatus.Published;
|
||||||
project.PublishedAt = DateTime.UtcNow;
|
project.PublishedAt = DateTime.UtcNow;
|
||||||
@@ -839,6 +921,10 @@ public sealed class ExperimentsController(
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
|
session.Instructors = request.InstructorTeacherIds!
|
||||||
|
.Distinct()
|
||||||
|
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||||
|
.ToList();
|
||||||
db.ExperimentSessions.Add(session);
|
db.ExperimentSessions.Add(session);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -874,16 +960,14 @@ public sealed class ExperimentsController(
|
|||||||
if (request.Items.Count > 100)
|
if (request.Items.Count > 100)
|
||||||
return Task.FromResult<ActionResult>(
|
return Task.FromResult<ActionResult>(
|
||||||
ValidationProblem("单次最多安排 100 条实验场次。"));
|
ValidationProblem("单次最多安排 100 条实验场次。"));
|
||||||
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
|
if (request.Items.Any(x => x.ProjectId == Guid.Empty))
|
||||||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
|
|
||||||
request.Items.Count)
|
|
||||||
return Task.FromResult<ActionResult>(
|
return Task.FromResult<ActionResult>(
|
||||||
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
|
ValidationProblem("实验项目不能为空。"));
|
||||||
|
|
||||||
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
async transaction =>
|
async transaction =>
|
||||||
{
|
{
|
||||||
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
|
var projectIds = request.Items.Select(x => x.ProjectId).Distinct().ToList();
|
||||||
var projects = await ScopedProjects()
|
var projects = await ScopedProjects()
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.AcademicTerm)
|
.ThenInclude(x => x!.AcademicTerm)
|
||||||
@@ -927,6 +1011,10 @@ public sealed class ExperimentsController(
|
|||||||
cancellationToken),
|
cancellationToken),
|
||||||
Notes = Normalize(item.Notes)
|
Notes = Normalize(item.Notes)
|
||||||
};
|
};
|
||||||
|
session.Instructors = item.InstructorTeacherIds!
|
||||||
|
.Distinct()
|
||||||
|
.Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
|
||||||
|
.ToList();
|
||||||
db.ExperimentSessions.Add(session);
|
db.ExperimentSessions.Add(session);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
createdSessions.Add((project, session));
|
createdSessions.Add((project, session));
|
||||||
@@ -970,12 +1058,11 @@ public sealed class ExperimentsController(
|
|||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
.ThenInclude(x => x!.TeachingTask)
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.Instructors)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (session is null ||
|
if (session is null) return NotFound();
|
||||||
!await ScopedProjects().AnyAsync(
|
if (!CanManageSession(session)) return Forbid();
|
||||||
x => x.Id == session.ExperimentProjectId,
|
|
||||||
cancellationToken))
|
|
||||||
return NotFound();
|
|
||||||
if (session.Status == ExperimentSessionStatus.Cancelled)
|
if (session.Status == ExperimentSessionStatus.Cancelled)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
||||||
@@ -1037,12 +1124,11 @@ public sealed class ExperimentsController(
|
|||||||
{
|
{
|
||||||
var session = await db.ExperimentSessions.AsNoTracking()
|
var session = await db.ExperimentSessions.AsNoTracking()
|
||||||
.Include(x => x.ExperimentProject)
|
.Include(x => x.ExperimentProject)
|
||||||
|
.Include(x => x.Instructors)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (session is null ||
|
if (session is null) return NotFound();
|
||||||
!await ScopedProjects().AnyAsync(
|
if (!CanManageSession(session)) return Forbid();
|
||||||
x => x.Id == session.ExperimentProjectId,
|
|
||||||
cancellationToken))
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
if (session.ExperimentProject!.ArrangementMode ==
|
if (session.ExperimentProject!.ArrangementMode ==
|
||||||
ExperimentArrangementMode.Centralized)
|
ExperimentArrangementMode.Centralized)
|
||||||
@@ -1105,6 +1191,12 @@ public sealed class ExperimentsController(
|
|||||||
if (session.ExperimentProject.ArrangementMode !=
|
if (session.ExperimentProject.ArrangementMode !=
|
||||||
ExperimentArrangementMode.SelfScheduled)
|
ExperimentArrangementMode.SelfScheduled)
|
||||||
return ConflictProblem("集中安排实验无需学生预约。");
|
return ConflictProblem("集中安排实验无需学生预约。");
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (session.ExperimentProject.SelectionStartsAt is null ||
|
||||||
|
session.ExperimentProject.SelectionEndsAt is null ||
|
||||||
|
now < session.ExperimentProject.SelectionStartsAt ||
|
||||||
|
now > session.ExperimentProject.SelectionEndsAt)
|
||||||
|
return ConflictProblem("当前不在该实验项目的选课时间范围内。");
|
||||||
if (!await TeachingTaskRosterQuery
|
if (!await TeachingTaskRosterQuery
|
||||||
.TaskIdsForStudent(db, student.Id)
|
.TaskIdsForStudent(db, student.Id)
|
||||||
.ContainsAsync(
|
.ContainsAsync(
|
||||||
@@ -1210,11 +1302,34 @@ public sealed class ExperimentsController(
|
|||||||
IsolationLevel.Serializable);
|
IsolationLevel.Serializable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ValidateInstructorTeacherIdsAsync(
|
||||||
|
ExperimentProject project,
|
||||||
|
IReadOnlyList<Guid>? instructorTeacherIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = instructorTeacherIds?.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
|
||||||
|
if (ids.Count == 0) return "请至少选择一位指导老师。";
|
||||||
|
if (ids.Count > 20) return "单个实验场次最多选择 20 位指导老师。";
|
||||||
|
var validCount = await db.TeachingTaskTeachers.AsNoTracking()
|
||||||
|
.Where(x => x.TeachingTaskId == project.TeachingTaskId &&
|
||||||
|
ids.Contains(x.TeacherId) &&
|
||||||
|
x.Teacher!.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
return validCount == ids.Count
|
||||||
|
? null
|
||||||
|
: "指导老师必须是该教学任务的在职任课教师。";
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string?> ValidateSessionAsync(
|
private async Task<string?> ValidateSessionAsync(
|
||||||
ExperimentProject project,
|
ExperimentProject project,
|
||||||
ExperimentSessionRequest request,
|
ExperimentSessionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var instructorProblem = await ValidateInstructorTeacherIdsAsync(
|
||||||
|
project, request.InstructorTeacherIds, cancellationToken);
|
||||||
|
if (instructorProblem is not null) return instructorProblem;
|
||||||
if (request.SessionDate < project.StartDate ||
|
if (request.SessionDate < project.StartDate ||
|
||||||
request.SessionDate > project.EndDate)
|
request.SessionDate > project.EndDate)
|
||||||
return "实验场次日期必须在项目开放日期范围内。";
|
return "实验场次日期必须在项目开放日期范围内。";
|
||||||
@@ -1442,6 +1557,26 @@ public sealed class ExperimentsController(
|
|||||||
taskIds.Contains(x.TeachingTaskId));
|
taskIds.Contains(x.TeachingTaskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanManageSession(ExperimentSession session)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope is DataScope.All or DataScope.College)
|
||||||
|
return true;
|
||||||
|
return scope.IsInRole(SystemRoles.Teacher) &&
|
||||||
|
session.Instructors.Any(instructor =>
|
||||||
|
instructor.Teacher?.UserId == scope.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanCorrectPublishedProject(TeachingTask task)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
return scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||||
|
scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||||
|
task.Teachers.Any(item =>
|
||||||
|
item.Teacher?.UserId == scope.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
private Task<Student?> CurrentStudentAsync(
|
private Task<Student?> CurrentStudentAsync(
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
db.Students.FirstOrDefaultAsync(x =>
|
db.Students.FirstOrDefaultAsync(x =>
|
||||||
@@ -1492,6 +1627,22 @@ public sealed class ExperimentsController(
|
|||||||
NotificationCategory.Schedule);
|
NotificationCategory.Schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task NotifyProjectCorrectionAsync(
|
||||||
|
ExperimentProject project,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
|
||||||
|
if (userIds.Count == 0) return;
|
||||||
|
await NotificationService.SendToUserIdsAsync(
|
||||||
|
db,
|
||||||
|
userIds,
|
||||||
|
"实验项目内容已更新",
|
||||||
|
$"《{project.TeachingTask!.Course!.Name}》的“{project.Name}”教学内容或要求已修正,请重新查看。",
|
||||||
|
"/experiments",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Schedule);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
|
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
|
||||||
{
|
{
|
||||||
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
||||||
@@ -1510,6 +1661,12 @@ public sealed class ExperimentsController(
|
|||||||
return "实验安排方式无效。";
|
return "实验安排方式无效。";
|
||||||
if (request.StartDate > request.EndDate)
|
if (request.StartDate > request.EndDate)
|
||||||
return "项目开始日期不能晚于结束日期。";
|
return "项目开始日期不能晚于结束日期。";
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
request.SelectionStartsAt.HasValue != request.SelectionEndsAt.HasValue)
|
||||||
|
return "开始选课和截至选课时间需要同时填写。";
|
||||||
|
if (request.SelectionStartsAt.HasValue && request.SelectionEndsAt.HasValue &&
|
||||||
|
request.SelectionStartsAt >= request.SelectionEndsAt)
|
||||||
|
return "开始选课时间必须早于截至选课时间。";
|
||||||
if (request.StartDate < term.StartDate ||
|
if (request.StartDate < term.StartDate ||
|
||||||
request.EndDate > term.EndDate)
|
request.EndDate > term.EndDate)
|
||||||
return "实验项目日期必须在所属学期起止日期内。";
|
return "实验项目日期必须在所属学期起止日期内。";
|
||||||
@@ -1543,6 +1700,15 @@ public sealed class ExperimentsController(
|
|||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static DateTime? NormalizeSelectionTime(DateTime? value)
|
||||||
|
{
|
||||||
|
if (!value.HasValue) return null;
|
||||||
|
if (value.Value.Kind == DateTimeKind.Utc) return value.Value;
|
||||||
|
var china = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
|
||||||
|
return TimeZoneInfo.ConvertTimeToUtc(
|
||||||
|
DateTime.SpecifyKind(value.Value, DateTimeKind.Unspecified), china);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? names)
|
private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? names)
|
||||||
{
|
{
|
||||||
if (names is null || names.Count == 0) return null;
|
if (names is null || names.Count == 0) return null;
|
||||||
@@ -1567,7 +1733,14 @@ public sealed record ExperimentProjectRequest(
|
|||||||
[MaxLength(1000)] string? Requirements,
|
[MaxLength(1000)] string? Requirements,
|
||||||
DateOnly StartDate,
|
DateOnly StartDate,
|
||||||
DateOnly EndDate,
|
DateOnly EndDate,
|
||||||
Guid? ScheduleEntryId = null);
|
Guid? ScheduleEntryId = null,
|
||||||
|
DateTime? SelectionStartsAt = null,
|
||||||
|
DateTime? SelectionEndsAt = null);
|
||||||
|
|
||||||
|
public sealed record PublishedExperimentProjectCorrectionRequest(
|
||||||
|
[Required, MaxLength(120)] string Name,
|
||||||
|
[MaxLength(1000)] string? Description,
|
||||||
|
[MaxLength(1000)] string? Requirements);
|
||||||
|
|
||||||
public sealed record ExperimentProjectBatchRequest(
|
public sealed record ExperimentProjectBatchRequest(
|
||||||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||||
@@ -1579,7 +1752,9 @@ public sealed record ExperimentProjectBatchRequest(
|
|||||||
DateOnly StartDate,
|
DateOnly StartDate,
|
||||||
DateOnly EndDate,
|
DateOnly EndDate,
|
||||||
IReadOnlyList<string>? Names = null,
|
IReadOnlyList<string>? Names = null,
|
||||||
bool GenerateSequentialNames = false)
|
bool GenerateSequentialNames = false,
|
||||||
|
DateTime? SelectionStartsAt = null,
|
||||||
|
DateTime? SelectionEndsAt = null)
|
||||||
{
|
{
|
||||||
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||||||
new(
|
new(
|
||||||
@@ -1590,7 +1765,10 @@ public sealed record ExperimentProjectBatchRequest(
|
|||||||
Description,
|
Description,
|
||||||
Requirements,
|
Requirements,
|
||||||
StartDate,
|
StartDate,
|
||||||
EndDate);
|
EndDate,
|
||||||
|
null,
|
||||||
|
SelectionStartsAt,
|
||||||
|
SelectionEndsAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ExperimentProjectBulkRequest(
|
public sealed record ExperimentProjectBulkRequest(
|
||||||
@@ -1606,7 +1784,8 @@ public sealed record ExperimentSessionRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 30)] int PeriodCount,
|
[Range(1, 30)] int PeriodCount,
|
||||||
[Range(1, 10000)] int Capacity,
|
[Range(1, 10000)] int Capacity,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes,
|
||||||
|
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
|
||||||
|
|
||||||
public sealed record ExperimentSessionBatchRequest(
|
public sealed record ExperimentSessionBatchRequest(
|
||||||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||||
@@ -1618,7 +1797,8 @@ public sealed record ExperimentSessionBatchItem(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 30)] int PeriodCount,
|
[Range(1, 30)] int PeriodCount,
|
||||||
[Range(1, 10000)] int Capacity,
|
[Range(1, 10000)] int Capacity,
|
||||||
[MaxLength(500)] string? Notes)
|
[MaxLength(500)] string? Notes,
|
||||||
|
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null)
|
||||||
{
|
{
|
||||||
public ExperimentSessionRequest ToSessionRequest() =>
|
public ExperimentSessionRequest ToSessionRequest() =>
|
||||||
new(
|
new(
|
||||||
@@ -1627,7 +1807,8 @@ public sealed record ExperimentSessionBatchItem(
|
|||||||
StartPeriod,
|
StartPeriod,
|
||||||
PeriodCount,
|
PeriodCount,
|
||||||
Capacity,
|
Capacity,
|
||||||
Notes);
|
Notes,
|
||||||
|
InstructorTeacherIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ExperimentPeriodOption(
|
public sealed record ExperimentPeriodOption(
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ public sealed class GradeAnalyticsController(
|
|||||||
public async Task<ActionResult> GetTeachingClasses(
|
public async Task<ActionResult> GetTeachingClasses(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
string? keyword,
|
string? keyword,
|
||||||
|
Guid? collegeId = null,
|
||||||
|
string? teacherKeyword = null,
|
||||||
|
bool? riskOnly = null,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -105,12 +108,23 @@ public sealed class GradeAnalyticsController(
|
|||||||
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value);
|
||||||
if (keyword is not null)
|
if (keyword is not null)
|
||||||
source = source.Where(x =>
|
source = source.Where(x =>
|
||||||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||||
x.TeachingTask.Name.Contains(keyword) ||
|
x.TeachingTask.Name.Contains(keyword) ||
|
||||||
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||||
x.TeachingTask.Course.Name.Contains(keyword));
|
x.TeachingTask.Course.Name.Contains(keyword));
|
||||||
|
if (!string.IsNullOrWhiteSpace(teacherKeyword))
|
||||||
|
{
|
||||||
|
teacherKeyword = teacherKeyword.Trim();
|
||||||
|
source = source.Where(x => x.TeachingTask!.Teachers.Any(item =>
|
||||||
|
item.Teacher!.Name.Contains(teacherKeyword) ||
|
||||||
|
item.Teacher.TeacherNumber.Contains(teacherKeyword)));
|
||||||
|
}
|
||||||
|
if (riskOnly == true)
|
||||||
|
source = source.Where(x => x.PassRate < 60 || x.AverageScore < 60);
|
||||||
|
|
||||||
var total = await source.CountAsync(cancellationToken);
|
var total = await source.CountAsync(cancellationToken);
|
||||||
var items = await source
|
var items = await source
|
||||||
|
|||||||
@@ -706,7 +706,7 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||||
|
|
||||||
var rows = sheet.Records.Select(record =>
|
var rows = sheet.Records.Select(record =>
|
||||||
{
|
{
|
||||||
@@ -723,8 +723,8 @@ public sealed class GradesController(
|
|||||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||||
values.Add(score);
|
values.Add(score);
|
||||||
}
|
}
|
||||||
values.Add(null);
|
|
||||||
values.Add(record.FinalScore);
|
values.Add(record.FinalScore);
|
||||||
|
values.Add(null);
|
||||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||||
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||||||
@@ -745,9 +745,9 @@ public sealed class GradesController(
|
|||||||
|
|
||||||
var regularColumn = 4;
|
var regularColumn = 4;
|
||||||
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||||
var totalColumn = regularColumn + itemColumns.Length + 1;
|
var finalColumn = regularColumn + itemColumns.Length + 1;
|
||||||
var finalColumn = totalColumn + 1;
|
var totalColumn = finalColumn + 1;
|
||||||
var statusColumn = finalColumn + 1;
|
var statusColumn = totalColumn + 1;
|
||||||
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||||
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||||
(itemColumns[index], item.Weight)));
|
(itemColumns[index], item.Weight)));
|
||||||
@@ -819,7 +819,7 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||||
|
|
||||||
IReadOnlyList<ExcelRow> rows;
|
IReadOnlyList<ExcelRow> rows;
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public sealed class NotificationsController(
|
|||||||
var total = await source.CountAsync(cancellationToken);
|
var total = await source.CountAsync(cancellationToken);
|
||||||
var items = await source
|
var items = await source
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -32,15 +33,24 @@ public sealed class OfficialDocumentsController(
|
|||||||
OfficialDocumentType? type,
|
OfficialDocumentType? type,
|
||||||
OfficialDocumentStatus? status,
|
OfficialDocumentStatus? status,
|
||||||
Guid? studentId,
|
Guid? studentId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = AccessibleDocuments().AsNoTracking();
|
var source = AccessibleDocuments().AsNoTracking();
|
||||||
if (type.HasValue) source = source.Where(x => x.Type == type);
|
if (type.HasValue) source = source.Where(x => x.Type == type);
|
||||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||||
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
|
if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId);
|
||||||
|
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.IssuedAt)
|
.OrderByDescending(x => x.IssuedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -62,15 +72,21 @@ public sealed class OfficialDocumentsController(
|
|||||||
DownloadCount = x.Downloads.Count,
|
DownloadCount = x.Downloads.Count,
|
||||||
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt)
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("students/options")]
|
[HttpGet("students/options")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> StudentOptions(
|
public async Task<ActionResult> StudentOptions(
|
||||||
string? keyword,
|
string? keyword,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 30)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = AccessibleStudents().AsNoTracking();
|
var source = AccessibleStudents().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
@@ -79,7 +95,10 @@ public sealed class OfficialDocumentsController(
|
|||||||
x.StudentNumber.Contains(value) || x.Name.Contains(value));
|
x.StudentNumber.Contains(value) || x.Name.Contains(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(await source.OrderBy(x => x.StudentNumber)
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source.OrderBy(x => x.StudentNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -90,7 +109,8 @@ public sealed class OfficialDocumentsController(
|
|||||||
MajorName = x.AdministrativeClass.Major!.Name,
|
MajorName = x.AdministrativeClass.Major!.Name,
|
||||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -207,14 +227,24 @@ public sealed class OfficialDocumentsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> DownloadHistory(
|
public async Task<ActionResult> DownloadHistory(
|
||||||
Guid id,
|
Guid id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
|
if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken))
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
return Ok(await db.OfficialDocumentDownloads.AsNoTracking()
|
var source = db.OfficialDocumentDownloads.AsNoTracking()
|
||||||
.Where(x => x.OfficialDocumentId == id)
|
.Where(x => x.OfficialDocumentId == id)
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id);
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -224,7 +254,8 @@ public sealed class OfficialDocumentsController(
|
|||||||
x.IpAddress,
|
x.IpAddress,
|
||||||
x.UserAgent
|
x.UserAgent
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/invalidate")]
|
[HttpPost("{id:guid}/invalidate")]
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ public sealed class OperationsController(
|
|||||||
{
|
{
|
||||||
var ids = pageItems.Select(x => x.Id).ToArray();
|
var ids = pageItems.Select(x => x.Id).ToArray();
|
||||||
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
|
||||||
.Where(x => ids.Contains(x.JobId))
|
.WhereIn(ids, x => x.JobId)
|
||||||
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
.Select(x => new { x.JobId, x.ProcessingAttempts })
|
||||||
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -20,10 +21,38 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
|
|
||||||
[HttpGet("batches")]
|
[HttpGet("batches")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches
|
public async Task<ActionResult> GetBatches(
|
||||||
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
string? keyword = null,
|
||||||
|
OtherExamBatchStatus? status = null,
|
||||||
|
OtherExamMetricKind? metricKind = null,
|
||||||
|
DateOnly? examDateFrom = null,
|
||||||
|
DateOnly? examDateTo = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
|
var source = db.OtherExamBatches.AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
(x.ExamCode != null && x.ExamCode.Contains(keyword)) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
(x.Organizer != null && x.Organizer.Contains(keyword)));
|
||||||
|
}
|
||||||
|
if (status.HasValue) source = source.Where(x => x.Status == status.Value);
|
||||||
|
if (metricKind.HasValue) source = source.Where(x => x.MetricKind == metricKind.Value);
|
||||||
|
if (examDateFrom.HasValue) source = source.Where(x => x.ExamDate >= examDateFrom.Value);
|
||||||
|
if (examDateTo.HasValue) source = source.Where(x => x.ExamDate <= examDateTo.Value);
|
||||||
|
var total = await source.CountAsync(ct);
|
||||||
|
var items = await source.OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
|
||||||
.ToListAsync(ct));
|
.ToListAsync(ct);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("batches")]
|
[HttpPost("batches")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
@@ -74,7 +103,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
var batch = await db.OtherExamBatches.Include(x => x.Results).FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (batch is null) return NotFound();
|
if (batch is null) return NotFound();
|
||||||
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
var result = await ReplaceResultsAsync(batch, request.Results, ct);
|
||||||
return result is null ? Ok(new { updated = batch.Results.Count }) : result;
|
return result is null ? Ok(new { updated = request.Results.Count }) : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("batches/{id:guid}/template")]
|
[HttpGet("batches/{id:guid}/template")]
|
||||||
@@ -153,7 +182,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||||
foreach (var item in inputs)
|
foreach (var item in inputs)
|
||||||
{
|
{
|
||||||
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed);
|
var error = ValidateResult(batch, item.Score, item.Level, item.IsPassed, item.Notes);
|
||||||
if (error is not null) return ValidationProblem(error);
|
if (error is not null) return ValidationProblem(error);
|
||||||
}
|
}
|
||||||
var studentIds = students.Values.Select(x => x.Id).ToList();
|
var studentIds = students.Values.Select(x => x.Id).ToList();
|
||||||
@@ -166,7 +195,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
batch.Status = OtherExamBatchStatus.Draft;
|
batch.Status = OtherExamBatchStatus.Draft;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
batch.Results = inputs.Select(x =>
|
var replacementResults = inputs.Select(x =>
|
||||||
{
|
{
|
||||||
var student = students[x.StudentNumber.Trim()];
|
var student = students[x.StudentNumber.Trim()];
|
||||||
return new OtherExamResult
|
return new OtherExamResult
|
||||||
@@ -180,6 +209,7 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
Notes = Normalize(x.Notes)
|
Notes = Normalize(x.Notes)
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
db.OtherExamResults.AddRange(replacementResults);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
return null;
|
return null;
|
||||||
@@ -209,13 +239,20 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
|||||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(levels) => "等级制必须填写等级选项。",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass) => b.MetricKind switch
|
private static string? ValidateResult(OtherExamBatch b, decimal? score, string? level, bool? pass, string? notes)
|
||||||
{
|
{
|
||||||
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
if (Normalize(notes)?.Length > 500) return "备注不能超过 500 个字符。";
|
||||||
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
return b.MetricKind switch
|
||||||
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
{
|
||||||
_ => null
|
OtherExamMetricKind.Score when !score.HasValue || score < 0 || score > b.MaxScore => "分数必须在 0 到满分之间。",
|
||||||
};
|
OtherExamMetricKind.Score when decimal.Round(score.Value, 2) != score.Value => "分数最多保留两位小数。",
|
||||||
|
OtherExamMetricKind.Level when string.IsNullOrWhiteSpace(level) => "等级制必须填写等级。",
|
||||||
|
OtherExamMetricKind.Level when Normalize(level)!.Length > 50 => "等级不能超过 50 个字符。",
|
||||||
|
OtherExamMetricKind.Level when !(b.LevelOptions ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Contains(Normalize(level)!, StringComparer.Ordinal) => "等级必须从该考试场次配置的等级选项中选择。",
|
||||||
|
OtherExamMetricKind.PassFail when !pass.HasValue => "合格/不合格考试必须填写结果。",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
private static int Rank(OtherExamMetricKind kind, decimal? score, string? level, bool? pass, string? options)
|
||||||
{
|
{
|
||||||
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
if (kind == OtherExamMetricKind.Score) return (int)((score ?? -1) * 1000);
|
||||||
|
|||||||
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
|
|||||||
ToResponse(job));
|
ToResponse(job));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("plans/{planId:guid}/preflight")]
|
||||||
|
public async Task<ActionResult> Preflight(Guid planId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
var tasks = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published &&
|
||||||
|
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||||
|
.Select(x => new { x.Id, x.Name, CourseName = x.Course!.Name })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var scheduled = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x => x.SchedulePlanId == planId)
|
||||||
|
.Select(x => x.TeachingTaskId).Distinct().ToListAsync(cancellationToken);
|
||||||
|
var missing = tasks.Where(x => !scheduled.Contains(x.Id))
|
||||||
|
.Select(x => $"《{x.CourseName}》{x.Name}").Take(20).ToList();
|
||||||
|
return Ok(new { totalTasks = tasks.Count, scheduledTasks = scheduled.Count, unscheduledTasks = missing.Count, messages = missing });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
|
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
|
||||||
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
||||||
GetAutomaticScheduleJob(
|
GetAutomaticScheduleJob(
|
||||||
|
|||||||
@@ -17,17 +17,53 @@ public sealed class StudentStatusChangesController(
|
|||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult> Get(CancellationToken token)
|
public async Task<ActionResult<StudentStatusChangePage>> Get(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken token = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = ScopedChanges().AsNoTracking();
|
var source = ScopedChanges().AsNoTracking();
|
||||||
return Ok(await source.OrderByDescending(x => x.SubmittedAt).Select(x => new
|
var userScope = currentUserDataScope.Current;
|
||||||
{
|
var total = await source.CountAsync(token);
|
||||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
var actionableTotal = await source.CountAsync(change =>
|
||||||
ClassName = x.Student.AdministrativeClass!.Name,
|
(change.State == StudentStatusChangeState.Submitted &&
|
||||||
CollegeName = x.Student.AdministrativeClass.Major!.College!.Name,
|
userScope.IsInRole(SystemRoles.Counselor)) ||
|
||||||
x.Type, x.OriginalStatus, x.TargetStatus, x.Reason, x.State,
|
(change.State == StudentStatusChangeState.CounselorApproved &&
|
||||||
x.ReviewComment, x.SubmittedAt, x.ReviewedAt, x.ApprovedAt
|
userScope.IsInRole(SystemRoles.CollegeAdmin)) ||
|
||||||
}).ToListAsync(token));
|
(change.State == StudentStatusChangeState.CollegeApproved &&
|
||||||
|
(userScope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||||
|
userScope.IsInRole(SystemRoles.SuperAdmin))), token);
|
||||||
|
var finishedTotal = await source.CountAsync(change =>
|
||||||
|
change.State == StudentStatusChangeState.Approved ||
|
||||||
|
change.State == StudentStatusChangeState.Rejected ||
|
||||||
|
change.State == StudentStatusChangeState.Cancelled, token);
|
||||||
|
var items = await source
|
||||||
|
.OrderByDescending(x => x.SubmittedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new StudentStatusChangeListItem(
|
||||||
|
x.Id,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber,
|
||||||
|
x.Student.Name,
|
||||||
|
x.Student.AdministrativeClass!.Name,
|
||||||
|
x.Student.AdministrativeClass.Major!.College!.Name,
|
||||||
|
x.Type,
|
||||||
|
x.OriginalStatus,
|
||||||
|
x.TargetStatus,
|
||||||
|
x.Reason,
|
||||||
|
x.State,
|
||||||
|
x.ReviewComment,
|
||||||
|
x.SubmittedAt,
|
||||||
|
x.ReviewedAt,
|
||||||
|
x.ApprovedAt))
|
||||||
|
.ToListAsync(token);
|
||||||
|
return Ok(new StudentStatusChangePage(
|
||||||
|
items, total, page, pageSize, actionableTotal, finishedTotal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("options")]
|
[HttpGet("options")]
|
||||||
@@ -199,6 +235,31 @@ public sealed class StudentStatusChangesController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record StudentStatusChangePage(
|
||||||
|
IReadOnlyCollection<StudentStatusChangeListItem> Items,
|
||||||
|
int Total,
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
int ActionableTotal,
|
||||||
|
int FinishedTotal);
|
||||||
|
|
||||||
|
public sealed record StudentStatusChangeListItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
string ClassName,
|
||||||
|
string CollegeName,
|
||||||
|
StudentStatusChangeType Type,
|
||||||
|
StudentStatus OriginalStatus,
|
||||||
|
StudentStatus TargetStatus,
|
||||||
|
string Reason,
|
||||||
|
StudentStatusChangeState State,
|
||||||
|
string? ReviewComment,
|
||||||
|
DateTime SubmittedAt,
|
||||||
|
DateTime? ReviewedAt,
|
||||||
|
DateTime? ApprovedAt);
|
||||||
|
|
||||||
public sealed record StudentStatusChangeRequest(
|
public sealed record StudentStatusChangeRequest(
|
||||||
StudentStatusChangeType Type,
|
StudentStatusChangeType Type,
|
||||||
[Required, MinLength(10), MaxLength(1000)] string Reason);
|
[Required, MinLength(10), MaxLength(1000)] string Reason);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -46,17 +47,26 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
[Authorize(Roles = SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMine(
|
public async Task<ActionResult> GetMine(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var teacher = await CurrentTeacherAsync(cancellationToken);
|
var teacher = await CurrentTeacherAsync(cancellationToken);
|
||||||
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
|
||||||
var source = db.TeacherCourseApplications.AsNoTracking()
|
var source = db.TeacherCourseApplications.AsNoTracking()
|
||||||
.Where(x => x.TeacherId == teacher.Id);
|
.Where(x => x.TeacherId == teacher.Id);
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
.ThenBy(x => x.Course!.Code)
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -72,7 +82,8 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
x.ReviewedAt
|
x.ReviewedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("mine")]
|
[HttpPost("mine")]
|
||||||
@@ -144,15 +155,24 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
public async Task<ActionResult> GetReviews(
|
public async Task<ActionResult> GetReviews(
|
||||||
Guid? academicTermId,
|
Guid? academicTermId,
|
||||||
TeacherCourseApplicationStatus? status,
|
TeacherCourseApplicationStatus? status,
|
||||||
CancellationToken cancellationToken)
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var source = ScopedApplications().AsNoTracking();
|
var source = ScopedApplications().AsNoTracking();
|
||||||
if (academicTermId.HasValue)
|
if (academicTermId.HasValue)
|
||||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||||
if (status.HasValue) source = source.Where(x => x.Status == status);
|
if (status.HasValue) source = source.Where(x => x.Status == status);
|
||||||
return Ok(await source
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
.OrderBy(x => x.Status)
|
.OrderBy(x => x.Status)
|
||||||
.ThenByDescending(x => x.SubmittedAt)
|
.ThenByDescending(x => x.SubmittedAt)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -172,7 +192,8 @@ public sealed class TeacherCourseApplicationsController(
|
|||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
x.ReviewedAt
|
x.ReviewedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("assignment-options")]
|
[HttpGet("assignment-options")]
|
||||||
|
|||||||
@@ -171,6 +171,95 @@ public sealed class TimetableManagementController(
|
|||||||
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("export/batch.xlsx")]
|
||||||
|
public async Task<ActionResult> ExportBatch(
|
||||||
|
TimetableBatchExportRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var resourceIds = request.ResourceIds.Distinct().ToArray();
|
||||||
|
if (resourceIds.Length is 0 or > 100)
|
||||||
|
return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var timetables = new List<TimetableData>(resourceIds.Length);
|
||||||
|
foreach (var resourceId in resourceIds)
|
||||||
|
{
|
||||||
|
var result = await LoadAuthorizedAsync(
|
||||||
|
request.ResourceType,
|
||||||
|
resourceId,
|
||||||
|
request.AcademicTermId,
|
||||||
|
request.SchedulePlanId,
|
||||||
|
cancellationToken);
|
||||||
|
if (result.Result is null) return result.Error!;
|
||||||
|
timetables.Add(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = TimetableExcelExporter.Create(timetables);
|
||||||
|
var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.xlsx";
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export.pdf")]
|
||||||
|
public async Task<ActionResult> ExportPdf(
|
||||||
|
TimetableResourceType resourceType,
|
||||||
|
Guid resourceId,
|
||||||
|
Guid academicTermId,
|
||||||
|
Guid? schedulePlanId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await LoadAuthorizedAsync(
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
academicTermId,
|
||||||
|
schedulePlanId,
|
||||||
|
cancellationToken);
|
||||||
|
if (result.Result is null) return result.Error!;
|
||||||
|
return PdfFile(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("export/batch.pdf")]
|
||||||
|
public async Task<ActionResult> ExportBatchPdf(
|
||||||
|
TimetableBatchExportRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var resourceIds = request.ResourceIds.Distinct().ToArray();
|
||||||
|
if (resourceIds.Length is 0 or > 100)
|
||||||
|
return ValidationProblem("请选择 1 至 100 个课表对象进行导出。", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var timetables = new List<TimetableData>(resourceIds.Length);
|
||||||
|
foreach (var resourceId in resourceIds)
|
||||||
|
{
|
||||||
|
var result = await LoadAuthorizedAsync(
|
||||||
|
request.ResourceType,
|
||||||
|
resourceId,
|
||||||
|
request.AcademicTermId,
|
||||||
|
request.SchedulePlanId,
|
||||||
|
cancellationToken);
|
||||||
|
if (result.Result is null) return result.Error!;
|
||||||
|
timetables.Add(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = $"课表批量导出-{FileName(timetables[0].Term.Name)}.pdf";
|
||||||
|
return File(TimetablePdfExporter.Create(timetables), "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("display-links/export.xlsx")]
|
||||||
|
public async Task<ActionResult> ExportDisplayLinks(VenueDisplayLinkExportRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var classroomIds = request.ClassroomIds.Distinct().Take(200).ToArray();
|
||||||
|
var buildingIds = request.BuildingIds.Distinct().Take(100).ToArray();
|
||||||
|
if (classroomIds.Length + buildingIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一个教室或教学楼。", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
var classrooms = await db.Classrooms.AsNoTracking().WhereIn(classroomIds, x => x.Id)
|
||||||
|
.OrderBy(x => x.Building!.Name).ThenBy(x => x.Code)
|
||||||
|
.Select(x => new object?[] { "教室当天课程", x.Building!.Name + " · " + x.Name, "/venue-display/classroom/" + x.Id })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var buildings = await db.Buildings.AsNoTracking().WhereIn(buildingIds, x => x.Id).OrderBy(x => x.Name)
|
||||||
|
.Select(x => new object?[] { "教学楼空余教室", x.Name, "/venue-display/building/" + x.Id })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var bytes = ExcelWorkbookHelper.Create("展牌链接", ["展牌类型", "场地", "相对链接"], classrooms.Concat(buildings).ToList());
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType, "场地信息展牌链接.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync(
|
private async Task<(TimetableData? Result, ActionResult? Error)> LoadAuthorizedAsync(
|
||||||
TimetableResourceType resourceType,
|
TimetableResourceType resourceType,
|
||||||
Guid resourceId,
|
Guid resourceId,
|
||||||
@@ -205,8 +294,22 @@ public sealed class TimetableManagementController(
|
|||||||
value = value.Replace(character, '-');
|
value = value.Replace(character, '-');
|
||||||
return value.Trim();
|
return value.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ActionResult PdfFile(TimetableData timetable) =>
|
||||||
|
File(
|
||||||
|
TimetablePdfExporter.Create(timetable),
|
||||||
|
"application/pdf",
|
||||||
|
$"{FileName(timetable.Subject.Name)}-{FileName(timetable.Term.Name)}-课表.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record TimetableBatchExportRequest(
|
||||||
|
TimetableResourceType ResourceType,
|
||||||
|
[param: Required, MinLength(1), MaxLength(100)] IReadOnlyCollection<Guid> ResourceIds,
|
||||||
|
Guid AcademicTermId,
|
||||||
|
Guid? SchedulePlanId);
|
||||||
|
|
||||||
|
public sealed record VenueDisplayLinkExportRequest(IReadOnlyCollection<Guid> ClassroomIds, IReadOnlyCollection<Guid> BuildingIds);
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/timetables")]
|
[Route("api/timetables")]
|
||||||
public sealed class FreeClassroomsController(
|
public sealed class FreeClassroomsController(
|
||||||
|
|||||||
@@ -45,6 +45,30 @@ public sealed class TimetablesController(
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
|
BuildTimetableAsync(classId, academicTermId, null, cancellationToken);
|
||||||
|
|
||||||
|
[HttpGet("my-class")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMyClass(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var administrativeClassId = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => (Guid?)x.AdministrativeClassId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (!administrativeClassId.HasValue)
|
||||||
|
{
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "档案未关联",
|
||||||
|
Detail = "当前登录账号没有关联学生档案,请联系教务管理员。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { AdministrativeClassId = administrativeClassId.Value });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("teachers/{teacherId:guid}")]
|
[HttpGet("teachers/{teacherId:guid}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult> GetTeacherTimetable(
|
public async Task<ActionResult> GetTeacherTimetable(
|
||||||
@@ -76,6 +100,21 @@ public sealed class TimetablesController(
|
|||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("teachers/{teacherId:guid}/export.pdf")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> ExportTeacherTimetablePdf(
|
||||||
|
Guid teacherId,
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await GetPublishedTimetableAsync(
|
||||||
|
TimetableResourceType.Teacher,
|
||||||
|
teacherId,
|
||||||
|
academicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
return result is null ? NotFound() : PdfFile(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("classes/{classId:guid}/export.xlsx")]
|
[HttpGet("classes/{classId:guid}/export.xlsx")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult> ExportClassTimetable(
|
public async Task<ActionResult> ExportClassTimetable(
|
||||||
@@ -92,6 +131,21 @@ public sealed class TimetablesController(
|
|||||||
return ExcelFile(result);
|
return ExcelFile(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("classes/{classId:guid}/export.pdf")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult> ExportClassTimetablePdf(
|
||||||
|
Guid classId,
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await GetPublishedTimetableAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
classId,
|
||||||
|
academicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
return result is null ? NotFound() : PdfFile(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMyTimetable(
|
public async Task<ActionResult> GetMyTimetable(
|
||||||
@@ -203,6 +257,39 @@ public sealed class TimetablesController(
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine/export.pdf")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
|
public async Task<ActionResult> ExportMyTimetablePdf(
|
||||||
|
Guid? academicTermId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var student = await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => new { x.Id, x.AdministrativeClassId, x.StudentNumber, x.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (student is not null)
|
||||||
|
{
|
||||||
|
var result = await timetableDataService.BuildAsync(
|
||||||
|
TimetableResourceType.Class, student.AdministrativeClassId, academicTermId, null,
|
||||||
|
false, student.Id, new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||||
|
cancellationToken);
|
||||||
|
return result is null ? NotFound() : PdfFile(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var teacher = await db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId && x.Status == TeacherStatus.Active)
|
||||||
|
.Select(x => new { x.Id })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (teacher is null) return NotFound();
|
||||||
|
var teacherResult = await timetableDataService.BuildAsync(
|
||||||
|
TimetableResourceType.Teacher, teacher.Id, academicTermId, null, false, null, null,
|
||||||
|
cancellationToken);
|
||||||
|
return teacherResult is null ? NotFound() : PdfFile(teacherResult);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("mine/calendar-subscription")]
|
[HttpGet("mine/calendar-subscription")]
|
||||||
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
[Authorize(Roles = SystemRoles.Student + "," + SystemRoles.Teacher)]
|
||||||
public async Task<ActionResult> GetMyCalendarSubscription(
|
public async Task<ActionResult> GetMyCalendarSubscription(
|
||||||
@@ -426,6 +513,13 @@ public sealed class TimetablesController(
|
|||||||
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
return File(bytes, ExcelWorkbookHelper.ContentType, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ActionResult PdfFile(TimetableData result)
|
||||||
|
{
|
||||||
|
var fileName =
|
||||||
|
$"{SafeFileName(result.Subject.Name)}-{SafeFileName(result.Term.Name)}-课表.pdf";
|
||||||
|
return File(TimetablePdfExporter.Create(result), "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
private static string SafeFileName(string value)
|
private static string SafeFileName(string value)
|
||||||
{
|
{
|
||||||
foreach (var character in Path.GetInvalidFileNameChars())
|
foreach (var character in Path.GetInvalidFileNameChars())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -19,10 +20,54 @@ public sealed class UsersController(
|
|||||||
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
RoleManager<ApplicationRole> roleManager) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<object>> GetUsers(CancellationToken cancellationToken)
|
public async Task<ActionResult<PagedResult<UserListItem>>> GetUsers(
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? keyword = null,
|
||||||
|
string? roleName = null,
|
||||||
|
Guid? collegeId = null,
|
||||||
|
bool? isEnabled = null,
|
||||||
|
bool? hasLoggedIn = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var users = await userManager.Users.AsNoTracking()
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
|
var query = userManager.Users.AsNoTracking();
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
query = query.Where(user =>
|
||||||
|
user.UserName!.Contains(keyword) ||
|
||||||
|
user.DisplayName.Contains(keyword) ||
|
||||||
|
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
|
||||||
|
(from userRole in db.UserRoles
|
||||||
|
join role in db.Roles on userRole.RoleId equals role.Id
|
||||||
|
where userRole.UserId == user.Id && role.Name!.Contains(keyword)
|
||||||
|
select role.Id).Any());
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(roleName))
|
||||||
|
{
|
||||||
|
roleName = roleName.Trim();
|
||||||
|
query = query.Where(user =>
|
||||||
|
(from userRole in db.UserRoles
|
||||||
|
join candidateRole in db.Roles on userRole.RoleId equals candidateRole.Id
|
||||||
|
where userRole.UserId == user.Id && candidateRole.Name == roleName
|
||||||
|
select userRole.RoleId).Any());
|
||||||
|
}
|
||||||
|
if (collegeId.HasValue) query = query.Where(user => user.CollegeId == collegeId.Value);
|
||||||
|
if (isEnabled.HasValue) query = query.Where(user => user.IsEnabled == isEnabled.Value);
|
||||||
|
if (hasLoggedIn.HasValue)
|
||||||
|
query = hasLoggedIn.Value
|
||||||
|
? query.Where(user => user.LastLoginAt != null)
|
||||||
|
: query.Where(user => user.LastLoginAt == null);
|
||||||
|
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var users = await query
|
||||||
.OrderBy(x => x.UserName)
|
.OrderBy(x => x.UserName)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
@@ -36,26 +81,34 @@ public sealed class UsersController(
|
|||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var result = new List<object>();
|
var userIds = users.Select(x => x.Id).ToArray();
|
||||||
foreach (var user in users)
|
var roleRows = await (
|
||||||
{
|
from userRole in db.UserRoles.AsNoTracking()
|
||||||
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
|
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||||
result.Add(new
|
where userIds.Contains(userRole.UserId)
|
||||||
{
|
select new { userRole.UserId, RoleName = role.Name! })
|
||||||
user.Id,
|
.ToListAsync(cancellationToken);
|
||||||
user.UserName,
|
var rolesByUser = roleRows
|
||||||
user.DisplayName,
|
.GroupBy(x => x.UserId)
|
||||||
user.StaffNumber,
|
.ToDictionary(
|
||||||
user.CollegeId,
|
group => group.Key,
|
||||||
user.IsEnabled,
|
group => (IReadOnlyCollection<string>)group
|
||||||
user.LastLoginAt,
|
.Select(x => x.RoleName)
|
||||||
user.CreatedAt,
|
.OrderBy(x => x)
|
||||||
Roles = identityUser is null
|
.ToArray());
|
||||||
? []
|
|
||||||
: await userManager.GetRolesAsync(identityUser)
|
var items = users.Select(user => new UserListItem(
|
||||||
});
|
user.Id,
|
||||||
}
|
user.UserName ?? string.Empty,
|
||||||
return Ok(result);
|
user.DisplayName,
|
||||||
|
user.StaffNumber,
|
||||||
|
user.CollegeId,
|
||||||
|
user.IsEnabled,
|
||||||
|
user.LastLoginAt,
|
||||||
|
user.CreatedAt,
|
||||||
|
rolesByUser.GetValueOrDefault(user.Id, Array.Empty<string>())))
|
||||||
|
.ToArray();
|
||||||
|
return Ok(new PagedResult<UserListItem>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("roles")]
|
[HttpGet("roles")]
|
||||||
@@ -281,6 +334,16 @@ public sealed record CreateUserRequest(
|
|||||||
[MinLength(1)] string[] Roles);
|
[MinLength(1)] string[] Roles);
|
||||||
|
|
||||||
public sealed record SetUserStatusRequest(bool IsEnabled);
|
public sealed record SetUserStatusRequest(bool IsEnabled);
|
||||||
|
public sealed record UserListItem(
|
||||||
|
Guid Id,
|
||||||
|
string UserName,
|
||||||
|
string DisplayName,
|
||||||
|
string? StaffNumber,
|
||||||
|
Guid? CollegeId,
|
||||||
|
bool IsEnabled,
|
||||||
|
DateTime? LastLoginAt,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
IReadOnlyCollection<string> Roles);
|
||||||
public sealed record SetRolesRequest(
|
public sealed record SetRolesRequest(
|
||||||
[MaxLength(30)] string? StaffNumber,
|
[MaxLength(30)] string? StaffNumber,
|
||||||
Guid? CollegeId,
|
Guid? CollegeId,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/venue-displays")]
|
||||||
|
public sealed class VenueDisplaysController(
|
||||||
|
AppDbContext db,
|
||||||
|
TimetableDataService timetableDataService,
|
||||||
|
ClassroomReservationAvailabilityService availabilityService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("classrooms/{classroomId:guid}")]
|
||||||
|
public async Task<ActionResult> Classroom(Guid classroomId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var term = await CurrentTermAsync(cancellationToken);
|
||||||
|
var classroom = await db.Classrooms.AsNoTracking()
|
||||||
|
.Where(x => x.Id == classroomId && x.IsEnabled && x.Building!.IsEnabled)
|
||||||
|
.Select(x => new { x.Id, x.Name, x.Code, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (classroom is null) return NotFound();
|
||||||
|
if (term is null) return Ok(new { Classroom = classroom, Today = DateOnly.FromDateTime(DateTime.Now), Entries = Array.Empty<object>() });
|
||||||
|
|
||||||
|
var sheet = await timetableDataService.BuildAsync(TimetableResourceType.Classroom, classroomId,
|
||||||
|
term.Id, null, false, null, null, cancellationToken);
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var (week, day) = ClassroomReservationAvailabilityService.ResolveTeachingWeek(term, today);
|
||||||
|
var entries = sheet?.Entries.Where(x => x.DayOfWeek == day && x.StartWeek <= week && x.EndWeek >= week &&
|
||||||
|
(x.WeekPattern == WeekPattern.All || x.WeekPattern == WeekPattern.Odd && week % 2 == 1 || x.WeekPattern == WeekPattern.Even && week % 2 == 0))
|
||||||
|
.Select(x => new { x.CourseName, x.TeacherNames, x.ClassNames, x.StartPeriod, x.PeriodCount }) ?? [];
|
||||||
|
return Ok(new { Classroom = classroom, Term = term.Name, Today = today, Week = week, Slots = sheet?.Slots ?? [], Entries = entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("buildings/{buildingId:guid}")]
|
||||||
|
public async Task<ActionResult> Building(Guid buildingId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var term = await CurrentTermAsync(cancellationToken);
|
||||||
|
var building = await db.Buildings.AsNoTracking().Where(x => x.Id == buildingId && x.IsEnabled)
|
||||||
|
.Select(x => new { x.Id, x.Name, x.Code, CampusName = x.Campus!.Name }).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (building is null) return NotFound();
|
||||||
|
if (term is null) return Ok(new { Building = building, Today = DateOnly.FromDateTime(DateTime.Now), Slots = Array.Empty<object>(), Rooms = Array.Empty<object>() });
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var slots = await db.ScheduleTimeSlots.AsNoTracking().Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.PeriodNumber).Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }).ToListAsync(cancellationToken);
|
||||||
|
var rooms = await db.Classrooms.AsNoTracking().Where(x => x.BuildingId == buildingId && x.IsEnabled)
|
||||||
|
.OrderBy(x => x.Code).Select(x => new { x.Id, x.Code, x.Name, x.Capacity }).ToListAsync(cancellationToken);
|
||||||
|
var occupied = new Dictionary<int, HashSet<Guid>>();
|
||||||
|
foreach (var slot in slots)
|
||||||
|
occupied[slot.PeriodNumber] = await availabilityService.GetOccupiedClassroomIdsAsync(term, today, slot.PeriodNumber, 1, null, cancellationToken);
|
||||||
|
return Ok(new { Building = building, Term = term.Name, Today = today, Slots = slots, Rooms = rooms.Select(room => new { room.Code, room.Name, room.Capacity, FreePeriods = slots.Where(slot => !occupied[slot.PeriodNumber].Contains(room.Id)).Select(slot => slot.PeriodNumber) }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<AcademicTerm?> CurrentTermAsync(CancellationToken cancellationToken) => db.AcademicTerms.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.IsCurrent).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -100,17 +101,23 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
db.WarningRecords.AddRange(generated);
|
db.WarningRecords.AddRange(generated);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var studentsById = studentInfos.ToDictionary(student => student.Id);
|
||||||
|
var rulesByType = rules.ToDictionary(rule => rule.Type);
|
||||||
|
var classIds = studentInfos.Select(student => student.ClassId).Distinct().ToArray();
|
||||||
|
var counselorsByClass = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.WhereIn(classIds, item => item.Id)
|
||||||
|
.Where(item => item.CounselorUserId != null)
|
||||||
|
.Select(item => new { item.Id, CounselorUserId = item.CounselorUserId!.Value })
|
||||||
|
.ToDictionaryAsync(item => item.Id, item => item.CounselorUserId, ct);
|
||||||
foreach (var w in generated)
|
foreach (var w in generated)
|
||||||
{
|
{
|
||||||
var si = studentInfos.First(s => s.Id == w.StudentId);
|
var si = studentsById[w.StudentId];
|
||||||
var r = rules.First(r => r.Type == w.Type);
|
var r = rulesByType[w.Type];
|
||||||
if (r.NotifyStudent && si.UserId.HasValue)
|
if (r.NotifyStudent && si.UserId.HasValue)
|
||||||
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
|
||||||
if (r.NotifyCounselor)
|
if (r.NotifyCounselor && counselorsByClass.TryGetValue(si.ClassId, out var counselorId))
|
||||||
{
|
{
|
||||||
var counselorId = await db.AdministrativeClasses.Where(c => c.Id == si.ClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
|
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
||||||
if (counselorId != default)
|
|
||||||
await NotificationService.SendAsync(db, counselorId, "学生学业预警", $"{si.Name}:{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,8 +127,16 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
|
|
||||||
[HttpGet("records")]
|
[HttpGet("records")]
|
||||||
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
[Authorize(Roles = Managers + "," + SystemRoles.Counselor)]
|
||||||
public async Task<ActionResult> GetRecords(Guid? academicTermId, WarningType? type, CancellationToken ct)
|
public async Task<ActionResult<PagedResult<WarningRecordListItem>>> GetRecords(
|
||||||
|
Guid? academicTermId,
|
||||||
|
WarningType? type,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
|
||||||
|
|
||||||
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
var q = db.WarningRecords.AsNoTracking().AsQueryable();
|
||||||
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
if (scope.Current.Scope == DataScope.College || scope.Current.IsInRole(SystemRoles.Counselor))
|
||||||
{
|
{
|
||||||
@@ -133,7 +148,28 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
}
|
}
|
||||||
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
|
||||||
if (type.HasValue) q = q.Where(x => x.Type == type);
|
if (type.HasValue) q = q.Where(x => x.Type == type);
|
||||||
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new WarningRecordListItem(
|
||||||
|
x.Id,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.Name,
|
||||||
|
x.Student.StudentNumber,
|
||||||
|
x.Student.AdministrativeClass!.Name,
|
||||||
|
(int)x.Type,
|
||||||
|
(int)x.Status,
|
||||||
|
x.TriggerValue,
|
||||||
|
x.Detail,
|
||||||
|
x.AcknowledgedAt,
|
||||||
|
x.AcknowledgeComment,
|
||||||
|
x.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return Ok(new PagedResult<WarningRecordListItem>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════ Student ═══════════
|
// ═══════════ Student ═══════════
|
||||||
@@ -167,71 +203,90 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
|||||||
// ═══════════ Detection logic ═══════════
|
// ═══════════ Detection logic ═══════════
|
||||||
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectFailedCredits(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await db.WarningRecords.AsNoTracking()
|
||||||
{
|
.Where(record => record.AcademicTermId == termId && record.Type == rule.Type)
|
||||||
var failed = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
.Select(record => record.StudentId)
|
||||||
if (failed >= rule.Threshold)
|
.ToHashSetAsync(ct);
|
||||||
|
var failedByStudent = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(record => studentIds.Contains(record.StudentId) &&
|
||||||
|
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
|
||||||
|
record.GradeSheet.Status == GradeSheetStatus.Published &&
|
||||||
|
record.TotalScore < 60)
|
||||||
|
.GroupBy(record => record.StudentId)
|
||||||
|
.Select(group => new { StudentId = group.Key, Failed = group.Count() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return failedByStudent
|
||||||
|
.Where(item => item.Failed >= rule.Threshold &&
|
||||||
|
!existingStudentIds.Contains(item.StudentId))
|
||||||
|
.Select(item => new WarningRecord
|
||||||
{
|
{
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.FailedCredits, ct)) continue;
|
StudentId = item.StudentId,
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = failed, Detail = $"不及格课程 {failed} 门,达到预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
Type = rule.Type,
|
||||||
}
|
TriggerValue = item.Failed,
|
||||||
}
|
Detail = $"不及格课程 {item.Failed} 门,达到预警阈值 {rule.Threshold} 门。",
|
||||||
return records;
|
AcademicTermId = termId
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectLowGPA(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||||
{
|
var gpaByStudent = await db.GradeRecords.AsNoTracking()
|
||||||
var grades = await db.GradeRecords.Where(x => x.StudentId == s.Id && x.GradeSheet!.TeachingTask!.AcademicTermId == termId && x.GradeSheet.Status == GradeSheetStatus.Published && x.GradePoint != null).ToListAsync(ct);
|
.Where(record => studentIds.Contains(record.StudentId) &&
|
||||||
if (grades.Count == 0) continue;
|
record.GradeSheet!.TeachingTask!.AcademicTermId == termId &&
|
||||||
var gpa = grades.Average(x => x.GradePoint!.Value);
|
record.GradeSheet.Status == GradeSheetStatus.Published &&
|
||||||
if (gpa < rule.Threshold)
|
record.GradePoint != null)
|
||||||
{
|
.GroupBy(record => record.StudentId)
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.LowGPA, ct)) continue;
|
.Select(group => new { StudentId = group.Key, Gpa = group.Average(record => record.GradePoint!.Value) })
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId });
|
.ToListAsync(ct);
|
||||||
}
|
return gpaByStudent.Where(item => item.Gpa < rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||||
}
|
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = Math.Round(item.Gpa, 2), Detail = $"平均绩点 {item.Gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId }).ToList();
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectAbsenteeism(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||||
{
|
var absencesByStudent = await db.AttendanceRecords.AsNoTracking()
|
||||||
var absent = await db.AttendanceRecords.CountAsync(x => x.StudentId == s.Id && x.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && x.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (x.Status == AttendanceStatus.Absent || x.Status == AttendanceStatus.Late), ct);
|
.Where(record => studentIds.Contains(record.StudentId) && record.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && record.AttendanceSheet.TeachingTask!.AcademicTermId == termId && (record.Status == AttendanceStatus.Absent || record.Status == AttendanceStatus.Late))
|
||||||
if (absent >= rule.Threshold)
|
.GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Absent = group.Count() }).ToListAsync(ct);
|
||||||
{
|
return absencesByStudent.Where(item => item.Absent >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.Absenteeism, ct)) continue;
|
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Absent, Detail = $"缺勤/迟到 {item.Absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId }).ToList();
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = absent, Detail = $"缺勤/迟到 {absent} 次,达到预警阈值 {rule.Threshold} 次。", AcademicTermId = termId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
private async Task<List<WarningRecord>> DetectGraduationDelay(WarningRule rule, Guid termId, List<StudentInfo> students, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var records = new List<WarningRecord>();
|
var studentIds = students.Select(student => student.Id).ToArray();
|
||||||
foreach (var s in students)
|
var existingStudentIds = await ExistingWarningStudentIdsAsync(rule.Type, termId, ct);
|
||||||
{
|
var failedByStudent = await db.GradeRecords.AsNoTracking().Where(record => studentIds.Contains(record.StudentId) && record.GradeSheet!.Status == GradeSheetStatus.Published && record.TotalScore < 60).GroupBy(record => record.StudentId).Select(group => new { StudentId = group.Key, Failed = group.Count() }).ToListAsync(ct);
|
||||||
var total = await db.GradeRecords.CountAsync(x => x.StudentId == s.Id && x.GradeSheet!.Status == GradeSheetStatus.Published && x.TotalScore < 60, ct);
|
return failedByStudent.Where(item => item.Failed >= rule.Threshold && !existingStudentIds.Contains(item.StudentId))
|
||||||
if (total >= rule.Threshold)
|
.Select(item => new WarningRecord { StudentId = item.StudentId, Type = rule.Type, TriggerValue = item.Failed, Detail = $"累计不及格 {item.Failed} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId }).ToList();
|
||||||
{
|
|
||||||
if (await db.WarningRecords.AnyAsync(x => x.StudentId == s.Id && x.AcademicTermId == termId && x.Type == WarningType.GraduationDelay, ct)) continue;
|
|
||||||
records.Add(new WarningRecord { StudentId = s.Id, Type = rule.Type, TriggerValue = total, Detail = $"累计不及格 {total} 门,达到延毕预警阈值 {rule.Threshold} 门。", AcademicTermId = termId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task<HashSet<Guid>> ExistingWarningStudentIdsAsync(WarningType type, Guid termId, CancellationToken ct) =>
|
||||||
|
db.WarningRecords.AsNoTracking().Where(record => record.AcademicTermId == termId && record.Type == type).Select(record => record.StudentId).ToHashSetAsync(ct);
|
||||||
|
|
||||||
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 });
|
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Status = 409 });
|
||||||
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 });
|
private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "操作失败", Detail = d, Status = 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
public sealed record StudentInfo(Guid Id, Guid? UserId, string Name, Guid ClassId);
|
||||||
|
public sealed record WarningRecordListItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentName,
|
||||||
|
string StudentNumber,
|
||||||
|
string ClassName,
|
||||||
|
int Type,
|
||||||
|
int Status,
|
||||||
|
decimal TriggerValue,
|
||||||
|
string Detail,
|
||||||
|
DateTime? AcknowledgedAt,
|
||||||
|
string? AcknowledgeComment,
|
||||||
|
DateTime CreatedAt);
|
||||||
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
|
public sealed record WarningRuleDto(WarningType Type, [MaxLength(100)] string Name, decimal Threshold, bool IsEnabled, bool NotifyStudent, bool NotifyCounselor, [MaxLength(300)] string? Description, bool AutoCheckEnabled, int? CheckDayOfWeek, int CheckHour, int CheckMinute);
|
||||||
public sealed record AckBody([MaxLength(300)] string? Comment);
|
public sealed record AckBody([MaxLength(300)] string? Comment);
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ public sealed class ExperimentProject : EntityBase
|
|||||||
public string? Requirements { get; set; }
|
public string? Requirements { get; set; }
|
||||||
public DateOnly StartDate { get; set; }
|
public DateOnly StartDate { get; set; }
|
||||||
public DateOnly EndDate { get; set; }
|
public DateOnly EndDate { get; set; }
|
||||||
|
// 自主预约项目可单独控制学生可选场次的时间窗口(UTC 时刻)。
|
||||||
|
public DateTime? SelectionStartsAt { get; set; }
|
||||||
|
public DateTime? SelectionEndsAt { get; set; }
|
||||||
public ExperimentProjectStatus Status { get; set; } =
|
public ExperimentProjectStatus Status { get; set; } =
|
||||||
ExperimentProjectStatus.Draft;
|
ExperimentProjectStatus.Draft;
|
||||||
public DateTime? PublishedAt { get; set; }
|
public DateTime? PublishedAt { get; set; }
|
||||||
@@ -43,6 +46,15 @@ public sealed class ExperimentSession : EntityBase
|
|||||||
ExperimentSessionStatus.Scheduled;
|
ExperimentSessionStatus.Scheduled;
|
||||||
public DateTime? CancelledAt { get; set; }
|
public DateTime? CancelledAt { get; set; }
|
||||||
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
public ICollection<ExperimentSessionInstructor> Instructors { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentSessionInstructor : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentSessionId { get; set; }
|
||||||
|
public ExperimentSession? ExperimentSession { get; set; }
|
||||||
|
public Guid TeacherId { get; set; }
|
||||||
|
public Teacher? Teacher { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ExperimentBooking : EntityBase
|
public sealed class ExperimentBooking : EntityBase
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
|
||||||
|
public sealed class ClickHouseAnalyticsClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ClickHouseAnalyticsOptions options)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public bool IsEnabled => options.Enabled;
|
||||||
|
|
||||||
|
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ExecuteAsync($"CREATE DATABASE IF NOT EXISTS {options.Database}", cancellationToken);
|
||||||
|
|
||||||
|
await ExecuteAsync($"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {options.Database}.auditEvents
|
||||||
|
(
|
||||||
|
id UUID,
|
||||||
|
occurredAt DateTime64(3, 'UTC'),
|
||||||
|
userId Nullable(UUID),
|
||||||
|
method LowCardinality(String),
|
||||||
|
path String,
|
||||||
|
statusCode UInt16,
|
||||||
|
ipAddress Nullable(String),
|
||||||
|
projectedAt DateTime64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(projectedAt)
|
||||||
|
PARTITION BY toYYYYMM(occurredAt)
|
||||||
|
ORDER BY (id)
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
await ExecuteAsync($"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {options.Database}.attendanceRecords
|
||||||
|
(
|
||||||
|
attendanceSheetId UUID,
|
||||||
|
studentId UUID,
|
||||||
|
attendanceDate Date,
|
||||||
|
teachingTaskId UUID,
|
||||||
|
academicTermId UUID,
|
||||||
|
collegeId UUID,
|
||||||
|
status UInt8,
|
||||||
|
checkInAt Nullable(DateTime64(3, 'UTC')),
|
||||||
|
checkedInMethod Nullable(UInt8),
|
||||||
|
appealStatus UInt8,
|
||||||
|
projectedAt DateTime64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(projectedAt)
|
||||||
|
PARTITION BY toYYYYMM(attendanceDate)
|
||||||
|
ORDER BY (attendanceSheetId, studentId)
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
await ExecuteAsync($"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {options.Database}.gradeStatistics
|
||||||
|
(
|
||||||
|
gradeSheetId UUID,
|
||||||
|
teachingTaskId UUID,
|
||||||
|
courseId UUID,
|
||||||
|
academicTermId UUID,
|
||||||
|
collegeId UUID,
|
||||||
|
academicTermName LowCardinality(String),
|
||||||
|
studentCount UInt32,
|
||||||
|
passedCount UInt32,
|
||||||
|
excellentCount UInt32,
|
||||||
|
averageScore Decimal(8, 2),
|
||||||
|
passRate Decimal(8, 4),
|
||||||
|
excellentRate Decimal(8, 4),
|
||||||
|
calculatedAt DateTime64(3, 'UTC'),
|
||||||
|
projectedAt DateTime64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(projectedAt)
|
||||||
|
PARTITION BY toYYYYMM(calculatedAt)
|
||||||
|
ORDER BY (gradeSheetId)
|
||||||
|
""", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InsertAsync<T>(string table, IReadOnlyCollection<T> rows, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return;
|
||||||
|
var payload = JsonSerializer.Serialize(rows, JsonOptions);
|
||||||
|
await ExecuteAsync(
|
||||||
|
$"INSERT INTO {options.Database}.{table} FORMAT JSONEachRow\n{ToJsonLines(payload)}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonElement[]> QueryAsync(string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(sql + " FORMAT JSON", cancellationToken);
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"ClickHouse 查询失败 ({(int)response.StatusCode}):{content}");
|
||||||
|
using var document = JsonDocument.Parse(content);
|
||||||
|
return document.RootElement.GetProperty("data")
|
||||||
|
.EnumerateArray().Select(x => x.Clone()).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteAsync(string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(sql, cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode) return;
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
throw new HttpRequestException($"ClickHouse 写入失败 ({(int)response.StatusCode}):{content}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<HttpResponseMessage> SendAsync(string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "")
|
||||||
|
{
|
||||||
|
Content = new StringContent(sql, Encoding.UTF8, "text/plain")
|
||||||
|
};
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(
|
||||||
|
$"{options.UserName}:{options.Password}"));
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
return httpClient.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToJsonLines(string json)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
return string.Join('\n', document.RootElement.EnumerateArray().Select(x => x.GetRawText()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
|
||||||
|
public sealed partial class ClickHouseAnalyticsOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "ClickHouseAnalytics";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string Endpoint { get; set; } = "http://localhost:8123";
|
||||||
|
public string Database { get; set; } = "jiaowu_analytics";
|
||||||
|
public string UserName { get; set; } = "jiaowu_analytics";
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
public bool CreateSchemaOnStartup { get; set; } = true;
|
||||||
|
public int SyncIntervalSeconds { get; set; } = 60;
|
||||||
|
public int SourceLookbackDays { get; set; } = 90;
|
||||||
|
public int BatchSize { get; set; } = 1000;
|
||||||
|
|
||||||
|
[GeneratedRegex("^[a-zA-Z_][a-zA-Z0-9_]{0,62}$")]
|
||||||
|
private static partial Regex IdentifierPattern();
|
||||||
|
|
||||||
|
public bool HasValidIdentifiers() =>
|
||||||
|
IdentifierPattern().IsMatch(Database);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Analytics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects MySQL facts to ClickHouse. The projection is deliberately
|
||||||
|
/// best-effort: business writes never depend on an analytics database.
|
||||||
|
/// ReplacingMergeTree plus FINAL reads make repeated lookback batches safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClickHouseAnalyticsProjectionWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ClickHouseAnalyticsClient client,
|
||||||
|
ClickHouseAnalyticsOptions options,
|
||||||
|
ILogger<ClickHouseAnalyticsProjectionWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!options.Enabled) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (options.CreateSchemaOnStartup)
|
||||||
|
await client.EnsureSchemaAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "ClickHouse 分析表初始化失败,将在下一轮重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(options.SyncIntervalSeconds));
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProjectAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "ClickHouse 分析投影失败,将在下一轮重试。");
|
||||||
|
}
|
||||||
|
} while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProjectAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var projectedAt = DateTime.UtcNow;
|
||||||
|
var from = projectedAt.AddDays(-options.SourceLookbackDays);
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var auditCount = await ProjectAuditsAsync(db, from, projectedAt, cancellationToken);
|
||||||
|
var attendanceCount = await ProjectAttendanceAsync(db, from.Date, projectedAt, cancellationToken);
|
||||||
|
var gradeCount = await ProjectGradesAsync(db, projectedAt, cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"ClickHouse 分析投影完成:审计 {AuditCount},考勤 {AttendanceCount},成绩 {GradeCount}。",
|
||||||
|
auditCount, attendanceCount, gradeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ProjectAuditsAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cursorAt = from;
|
||||||
|
var cursorId = Guid.Empty;
|
||||||
|
var count = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var rows = await db.AuditLogs.AsNoTracking()
|
||||||
|
.Where(x => x.CreatedAt > cursorAt || x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) > 0)
|
||||||
|
.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(options.BatchSize)
|
||||||
|
.Select(x => new { x.Id, OccurredAt = x.CreatedAt, x.UserId, x.Method, x.Path, x.StatusCode, x.IpAddress, ProjectedAt = projectedAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (rows.Count == 0) return count;
|
||||||
|
await client.InsertAsync("auditEvents", rows, cancellationToken);
|
||||||
|
count += rows.Count;
|
||||||
|
cursorAt = rows[^1].OccurredAt;
|
||||||
|
cursorId = rows[^1].Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ProjectAttendanceAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cursorDate = from;
|
||||||
|
var cursorSheetId = Guid.Empty;
|
||||||
|
var cursorStudentId = Guid.Empty;
|
||||||
|
var count = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var rows = await db.AttendanceRecords.AsNoTracking()
|
||||||
|
.Where(x => x.AttendanceSheet!.AttendanceDate > cursorDate ||
|
||||||
|
x.AttendanceSheet.AttendanceDate == cursorDate &&
|
||||||
|
(x.AttendanceSheetId.CompareTo(cursorSheetId) > 0 ||
|
||||||
|
x.AttendanceSheetId == cursorSheetId && x.StudentId.CompareTo(cursorStudentId) > 0))
|
||||||
|
.OrderBy(x => x.AttendanceSheet!.AttendanceDate).ThenBy(x => x.AttendanceSheetId).ThenBy(x => x.StudentId).Take(options.BatchSize)
|
||||||
|
.Select(x => new { x.AttendanceSheetId, x.StudentId, AttendanceDate = x.AttendanceSheet!.AttendanceDate, TeachingTaskId = x.AttendanceSheet.TeachingTaskId, AcademicTermId = x.AttendanceSheet.TeachingTask!.AcademicTermId, CollegeId = x.AttendanceSheet.TeachingTask.Course!.CollegeId, x.Status, x.CheckInAt, x.CheckedInMethod, x.AppealStatus, ProjectedAt = projectedAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (rows.Count == 0) return count;
|
||||||
|
await client.InsertAsync("attendanceRecords", rows, cancellationToken);
|
||||||
|
count += rows.Count;
|
||||||
|
cursorDate = rows[^1].AttendanceDate;
|
||||||
|
cursorSheetId = rows[^1].AttendanceSheetId;
|
||||||
|
cursorStudentId = rows[^1].StudentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ProjectGradesAsync(AppDbContext db, DateTime projectedAt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cursorAt = DateTime.MinValue;
|
||||||
|
var cursorSheetId = Guid.Empty;
|
||||||
|
var count = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var rows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CalculatedAt > cursorAt || x.CalculatedAt == cursorAt && x.GradeSheetId.CompareTo(cursorSheetId) > 0)
|
||||||
|
.OrderBy(x => x.CalculatedAt).ThenBy(x => x.GradeSheetId).Take(options.BatchSize)
|
||||||
|
.Select(x => new { x.GradeSheetId, x.TeachingTaskId, x.CourseId, x.AcademicTermId, CollegeId = x.TeachingTask!.Course!.CollegeId, AcademicTermName = x.TeachingTask.AcademicTerm!.Name, x.StudentCount, x.PassedCount, x.ExcellentCount, x.AverageScore, x.PassRate, x.ExcellentRate, x.CalculatedAt, ProjectedAt = projectedAt })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (rows.Count == 0) return count;
|
||||||
|
await client.InsertAsync("gradeStatistics", rows, cancellationToken);
|
||||||
|
count += rows.Count;
|
||||||
|
cursorAt = rows[^1].CalculatedAt;
|
||||||
|
cursorSheetId = rows[^1].GradeSheetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -263,6 +263,7 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
|
|
||||||
var projects = await db.ExperimentProjects
|
var projects = await db.ExperimentProjects
|
||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
|
.ThenInclude(x => x.Instructors)
|
||||||
.Include(x => x.TeachingTask)
|
.Include(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
.Where(x => ids.Contains(x.Id))
|
.Where(x => ids.Contains(x.Id))
|
||||||
@@ -282,6 +283,14 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
|
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
|
||||||
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
|
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
(project.SelectionStartsAt is null || project.SelectionEndsAt is null ||
|
||||||
|
project.SelectionStartsAt >= project.SelectionEndsAt))
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”未设置有效的选课时间范围。");
|
||||||
|
if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
x.Instructors.Count == 0))
|
||||||
|
throw new ExamPublishValidationException($"“{project.Name}”存在未指定指导老师的实验场次。");
|
||||||
}
|
}
|
||||||
|
|
||||||
job.CurrentStep = "正在发布实验项目";
|
job.CurrentStep = "正在发布实验项目";
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Middleware;
|
||||||
|
|
||||||
|
public sealed class SlowRequestLoggingMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ObservabilityOptions options,
|
||||||
|
ILogger<SlowRequestLoggingMiddleware> logger)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
if (!options.Enabled ||
|
||||||
|
!context.Request.Path.StartsWithSegments("/api"))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startedAt = Stopwatch.GetTimestamp();
|
||||||
|
await next(context);
|
||||||
|
|
||||||
|
var durationMilliseconds = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||||
|
var endpoint = context.GetEndpoint()?.DisplayName ?? context.Request.Path.Value ?? "/api";
|
||||||
|
if (durationMilliseconds >= options.SlowRequestThresholdMilliseconds ||
|
||||||
|
context.Response.StatusCode >= StatusCodes.Status500InternalServerError)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Slow API request completed: {Method} {Path} ({Endpoint}) returned {StatusCode} in {DurationMs:F1} ms, request {RequestId}.",
|
||||||
|
context.Request.Method,
|
||||||
|
context.Request.Path.Value,
|
||||||
|
endpoint,
|
||||||
|
context.Response.StatusCode,
|
||||||
|
durationMilliseconds,
|
||||||
|
context.TraceIdentifier);
|
||||||
|
}
|
||||||
|
else if (options.LogAllApiRequests)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"API request completed: {Method} {Path} ({Endpoint}) returned {StatusCode} in {DurationMs:F1} ms, request {RequestId}.",
|
||||||
|
context.Request.Method,
|
||||||
|
context.Request.Path.Value,
|
||||||
|
endpoint,
|
||||||
|
context.Response.StatusCode,
|
||||||
|
durationMilliseconds,
|
||||||
|
context.TraceIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Diagnostics.Metrics;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
@@ -9,31 +7,10 @@ namespace Jiaowu.Api.Infrastructure.Observability;
|
|||||||
|
|
||||||
public sealed class DatabaseCommandTelemetryInterceptor(
|
public sealed class DatabaseCommandTelemetryInterceptor(
|
||||||
ObservabilityOptions options,
|
ObservabilityOptions options,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
ILogger<DatabaseCommandTelemetryInterceptor> logger)
|
||||||
: DbCommandInterceptor
|
: DbCommandInterceptor
|
||||||
{
|
{
|
||||||
public const string ActivitySourceName = "Jiaowu.Api.Database";
|
|
||||||
public const string MeterName = "Jiaowu.Api.Database";
|
|
||||||
|
|
||||||
private static readonly ActivitySource ActivitySource =
|
|
||||||
new(ActivitySourceName);
|
|
||||||
private static readonly Meter Meter = new(MeterName);
|
|
||||||
private static readonly Histogram<double> CommandDuration =
|
|
||||||
Meter.CreateHistogram<double>(
|
|
||||||
"jiaowu.db.command.duration",
|
|
||||||
"ms",
|
|
||||||
"EF Core database command duration");
|
|
||||||
private static readonly Counter<long> SlowCommandCount =
|
|
||||||
Meter.CreateCounter<long>(
|
|
||||||
"jiaowu.db.command.slow",
|
|
||||||
"{command}",
|
|
||||||
"EF Core commands exceeding the configured slow-query threshold");
|
|
||||||
private static readonly Counter<long> FailedCommandCount =
|
|
||||||
Meter.CreateCounter<long>(
|
|
||||||
"jiaowu.db.command.failed",
|
|
||||||
"{command}",
|
|
||||||
"Failed EF Core database commands");
|
|
||||||
|
|
||||||
public override DbDataReader ReaderExecuted(
|
public override DbDataReader ReaderExecuted(
|
||||||
DbCommand command,
|
DbCommand command,
|
||||||
CommandExecutedEventData eventData,
|
CommandExecutedEventData eventData,
|
||||||
@@ -138,82 +115,41 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
|||||||
var queryName = GetQueryName(command.CommandText);
|
var queryName = GetQueryName(command.CommandText);
|
||||||
var statementHash = GetStatementHash(command.CommandText);
|
var statementHash = GetStatementHash(command.CommandText);
|
||||||
var provider = GetProviderName(command);
|
var provider = GetProviderName(command);
|
||||||
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
|
var requestId = httpContextAccessor.HttpContext?.TraceIdentifier ?? "background";
|
||||||
var tags = new TagList
|
|
||||||
{
|
|
||||||
{ "db.system.name", provider },
|
|
||||||
{ "db.operation.name", commandKind },
|
|
||||||
{ "db.query.name", queryName }
|
|
||||||
};
|
|
||||||
if (errorType is not null)
|
|
||||||
tags.Add("error.type", errorType);
|
|
||||||
|
|
||||||
var durationMilliseconds = duration.TotalMilliseconds;
|
var durationMilliseconds = duration.TotalMilliseconds;
|
||||||
CommandDuration.Record(durationMilliseconds, tags);
|
|
||||||
if (errorType is not null)
|
|
||||||
FailedCommandCount.Add(1, tags);
|
|
||||||
|
|
||||||
using var activity = ActivitySource.StartActivity(
|
|
||||||
ActivityKind.Client,
|
|
||||||
Activity.Current?.Context ?? default,
|
|
||||||
startTime: DateTimeOffset.UtcNow - duration,
|
|
||||||
name: queryName);
|
|
||||||
if (activity is not null)
|
|
||||||
{
|
|
||||||
activity.SetTag("db.system.name", provider);
|
|
||||||
activity.SetTag("db.operation.name", commandKind);
|
|
||||||
activity.SetTag("db.query.name", queryName);
|
|
||||||
activity.SetTag("db.statement.hash", statementHash);
|
|
||||||
activity.SetTag(
|
|
||||||
"db.namespace",
|
|
||||||
EmptyToNull(command.Connection?.Database));
|
|
||||||
if (options.IncludeSqlText)
|
|
||||||
{
|
|
||||||
activity.SetTag(
|
|
||||||
"db.query.text",
|
|
||||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
|
||||||
}
|
|
||||||
if (errorType is not null)
|
|
||||||
{
|
|
||||||
activity.SetTag("error.type", errorType);
|
|
||||||
activity.SetStatus(ActivityStatusCode.Error, errorType);
|
|
||||||
}
|
|
||||||
activity.SetEndTime(DateTime.UtcNow);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorType is not null)
|
if (errorType is not null)
|
||||||
{
|
{
|
||||||
logger.LogError(
|
logger.LogError(
|
||||||
"Database command failed after {DurationMs:F1} ms: " +
|
"Database command failed after {DurationMs:F1} ms: " +
|
||||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
"error {ErrorType}, trace {TraceId}).",
|
"error {ErrorType}, request {RequestId}).",
|
||||||
durationMilliseconds,
|
durationMilliseconds,
|
||||||
queryName,
|
queryName,
|
||||||
commandKind,
|
commandKind,
|
||||||
provider,
|
provider,
|
||||||
statementHash,
|
statementHash,
|
||||||
errorType,
|
errorType,
|
||||||
traceId);
|
requestId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
SlowCommandCount.Add(1, tags);
|
|
||||||
if (options.IncludeSqlText)
|
if (options.IncludeSqlText)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Slow database command took {DurationMs:F1} ms: " +
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
"trace {TraceId}). " +
|
"request {RequestId}). " +
|
||||||
"SQL template: {SqlTemplate}",
|
"SQL template: {SqlTemplate}",
|
||||||
durationMilliseconds,
|
durationMilliseconds,
|
||||||
queryName,
|
queryName,
|
||||||
commandKind,
|
commandKind,
|
||||||
provider,
|
provider,
|
||||||
statementHash,
|
statementHash,
|
||||||
traceId,
|
requestId,
|
||||||
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
Truncate(command.CommandText, options.MaximumSqlTextLength));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -221,13 +157,13 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
|||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Slow database command took {DurationMs:F1} ms: " +
|
"Slow database command took {DurationMs:F1} ms: " +
|
||||||
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
|
||||||
"trace {TraceId}).",
|
"request {RequestId}).",
|
||||||
durationMilliseconds,
|
durationMilliseconds,
|
||||||
queryName,
|
queryName,
|
||||||
commandKind,
|
commandKind,
|
||||||
provider,
|
provider,
|
||||||
statementHash,
|
statementHash,
|
||||||
traceId);
|
requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +207,6 @@ public sealed class DatabaseCommandTelemetryInterceptor(
|
|||||||
return "other_sql";
|
return "other_sql";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? EmptyToNull(string? value) =>
|
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
|
||||||
|
|
||||||
private static string Truncate(string value, int maximumLength) =>
|
private static string Truncate(string value, int maximumLength) =>
|
||||||
value.Length <= maximumLength
|
value.Length <= maximumLength
|
||||||
? value
|
? value
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public sealed class ObservabilityOptions
|
|||||||
|
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
public string ServiceName { get; set; } = "jiaowu-api";
|
public string ServiceName { get; set; } = "jiaowu-api";
|
||||||
|
public bool LogAllApiRequests { get; set; } = true;
|
||||||
|
public int SlowRequestThresholdMilliseconds { get; set; } = 1000;
|
||||||
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
|
||||||
public bool IncludeSqlText { get; set; }
|
public bool IncludeSqlText { get; set; }
|
||||||
public int MaximumSqlTextLength { get; set; } = 2000;
|
public int MaximumSqlTextLength { get; set; } = 2000;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<ClassroomReservation>();
|
Set<ClassroomReservation>();
|
||||||
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||||
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||||
|
public DbSet<ExperimentSessionInstructor> ExperimentSessionInstructors =>
|
||||||
|
Set<ExperimentSessionInstructor>();
|
||||||
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||||
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
||||||
Set<ExperimentGradeSheet>();
|
Set<ExperimentGradeSheet>();
|
||||||
@@ -166,6 +168,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
configurationBuilder.Properties<TimeOnly>()
|
configurationBuilder.Properties<TimeOnly>()
|
||||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||||
.HaveColumnType("time");
|
.HaveColumnType("time");
|
||||||
|
|
||||||
|
// MySQL DATETIME has no offset or DateTimeKind. All system timestamps
|
||||||
|
// are persisted as UTC, so restore that contract when materializing
|
||||||
|
// them. System.Text.Json will then emit the trailing "Z", allowing
|
||||||
|
// browsers to convert timestamps to the viewer's local time correctly.
|
||||||
|
configurationBuilder.Properties<DateTime>()
|
||||||
|
.HaveConversion<UtcDateTimeConverter>()
|
||||||
|
.HaveColumnType("datetime(6)");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
@@ -682,6 +692,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||||
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.SelectionStartsAt).HasConversion<UtcDateTimeConverter>();
|
||||||
|
entity.Property(x => x.SelectionEndsAt).HasConversion<UtcDateTimeConverter>();
|
||||||
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
|
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
|
||||||
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
|
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
|
||||||
entity.HasIndex(x => new
|
entity.HasIndex(x => new
|
||||||
@@ -797,6 +809,19 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentSessionInstructor>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.ExperimentSessionId, x.TeacherId })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasIndex(x => x.TeacherId);
|
||||||
|
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Instructors)
|
||||||
|
.HasForeignKey(x => x.ExperimentSessionId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeacherId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<ExperimentCourseGrade>(entity =>
|
builder.Entity<ExperimentCourseGrade>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
|
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
|
||||||
@@ -1086,6 +1111,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
});
|
});
|
||||||
builder.Entity<ExamSession>(entity =>
|
builder.Entity<ExamSession>(entity =>
|
||||||
{
|
{
|
||||||
|
// Exam slot times are China-local wall-clock times, not instants.
|
||||||
|
// Keep their existing API representation offset-free.
|
||||||
|
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
|
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
|
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
|
||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
@@ -1112,6 +1141,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
builder.Entity<ExamRoomAssignment>(entity =>
|
builder.Entity<ExamRoomAssignment>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("ExamRooms");
|
entity.ToTable("ExamRooms");
|
||||||
|
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
|
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
||||||
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||||
entity.HasIndex(x => new
|
entity.HasIndex(x => new
|
||||||
@@ -1200,6 +1231,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
});
|
});
|
||||||
builder.Entity<MakeupExamSession>(entity =>
|
builder.Entity<MakeupExamSession>(entity =>
|
||||||
{
|
{
|
||||||
|
// Makeup-exam slot times follow the same wall-clock convention.
|
||||||
|
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
|
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
|
||||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
@@ -1475,8 +1509,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Title).HasMaxLength(200);
|
entity.Property(x => x.Title).HasMaxLength(200);
|
||||||
entity.Property(x => x.Content).HasColumnType("longtext");
|
entity.Property(x => x.Content).HasColumnType("longtext");
|
||||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
// Each inbox query starts with its recipient. Keep the selected
|
||||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
// sort fields in the index so large inboxes do not need a filesort.
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.CreatedAt, x.Id });
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.IsRead, x.CreatedAt, x.Id });
|
||||||
|
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt, x.Id });
|
||||||
entity.HasIndex(x => x.MessageDispatchId);
|
entity.HasIndex(x => x.MessageDispatchId);
|
||||||
entity.HasIndex(x => x.CreatedAt);
|
entity.HasIndex(x => x.CreatedAt);
|
||||||
entity.HasOne(x => x.MessageDispatch)
|
entity.HasOne(x => x.MessageDispatch)
|
||||||
@@ -1643,3 +1680,15 @@ public sealed class TimeOnlyTimeSpanConverter()
|
|||||||
: ValueConverter<TimeOnly, TimeSpan>(
|
: ValueConverter<TimeOnly, TimeSpan>(
|
||||||
time => time.ToTimeSpan(),
|
time => time.ToTimeSpan(),
|
||||||
value => TimeOnly.FromTimeSpan(value));
|
value => TimeOnly.FromTimeSpan(value));
|
||||||
|
|
||||||
|
public sealed class UtcDateTimeConverter()
|
||||||
|
: ValueConverter<DateTime, DateTime>(
|
||||||
|
value => value.Kind == DateTimeKind.Local
|
||||||
|
? value.ToUniversalTime()
|
||||||
|
: DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||||
|
value => DateTime.SpecifyKind(value, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
public sealed class UnspecifiedDateTimeConverter()
|
||||||
|
: ValueConverter<DateTime, DateTime>(
|
||||||
|
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified),
|
||||||
|
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified));
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260809_53_course_grade_statistics_refresh_settings";
|
"20260809_53_course_grade_statistics_refresh_settings";
|
||||||
private const string ExperimentCourseGradesMigration =
|
private const string ExperimentCourseGradesMigration =
|
||||||
"20260809_54_experiment_course_grades";
|
"20260809_54_experiment_course_grades";
|
||||||
|
private const string NotificationInboxIndexesMigration =
|
||||||
|
"20260810_55_notification_inbox_indexes";
|
||||||
|
private const string SelfScheduledExperimentSelectionMigration =
|
||||||
|
"20260811_56_self_scheduled_experiment_selection";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -731,6 +735,29 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ExperimentCourseGradesMigration,
|
ExperimentCourseGradesMigration,
|
||||||
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
|
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
NotificationInboxIndexesMigration,
|
||||||
|
NotificationInboxIndexesStatements,
|
||||||
|
cancellationToken);
|
||||||
|
var experimentProjectColumns = (await db.Database.SqlQueryRaw<string>(
|
||||||
|
"SELECT name AS \"Value\" FROM pragma_table_info('ExperimentProjects')")
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var experimentSessionInstructorsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExperimentSessionInstructors'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
var selfScheduledExperimentSelectionStatements = new List<string>();
|
||||||
|
if (!experimentProjectColumns.Contains("SelectionStartsAt"))
|
||||||
|
selfScheduledExperimentSelectionStatements.Add(SelfScheduledExperimentSelectionStatements[0]);
|
||||||
|
if (!experimentProjectColumns.Contains("SelectionEndsAt"))
|
||||||
|
selfScheduledExperimentSelectionStatements.Add(SelfScheduledExperimentSelectionStatements[1]);
|
||||||
|
if (!experimentSessionInstructorsExist)
|
||||||
|
selfScheduledExperimentSelectionStatements.AddRange(SelfScheduledExperimentSelectionStatements.Skip(2));
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
SelfScheduledExperimentSelectionMigration,
|
||||||
|
selfScheduledExperimentSelectionStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -2999,6 +3026,15 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
|
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] NotificationInboxIndexesStatements =
|
||||||
|
[
|
||||||
|
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_IsRead";""",
|
||||||
|
"""DROP INDEX IF EXISTS "IX_Notifications_UserId_Category_CreatedAt";""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_CreatedAt_Id" ON "Notifications" ("UserId", "CreatedAt", "Id");""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_IsRead_CreatedAt_Id" ON "Notifications" ("UserId", "IsRead", "CreatedAt", "Id");""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS "IX_Notifications_UserId_Category_CreatedAt_Id" ON "Notifications" ("UserId", "Category", "CreatedAt", "Id");"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ExperimentCourseGradesStatements =
|
private static readonly string[] ExperimentCourseGradesStatements =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
@@ -3114,6 +3150,27 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] SelfScheduledExperimentSelectionStatements =
|
||||||
|
[
|
||||||
|
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionStartsAt\" TEXT NULL;",
|
||||||
|
"ALTER TABLE \"ExperimentProjects\" ADD COLUMN \"SelectionEndsAt\" TEXT NULL;",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentSessionInstructors" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessionInstructors" PRIMARY KEY,
|
||||||
|
"ExperimentSessionId" TEXT NOT NULL,
|
||||||
|
"TeacherId" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentSessionInstructors_ExperimentSessions_ExperimentSessionId"
|
||||||
|
FOREIGN KEY ("ExperimentSessionId") REFERENCES "ExperimentSessions" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentSessionInstructors_Teachers_TeacherId"
|
||||||
|
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"CREATE UNIQUE INDEX \"IX_ExperimentSessionInstructors_ExperimentSessionId_TeacherId\" ON \"ExperimentSessionInstructors\" (\"ExperimentSessionId\", \"TeacherId\");",
|
||||||
|
"CREATE INDEX \"IX_ExperimentSessionInstructors_TeacherId\" ON \"ExperimentSessionInstructors\" (\"TeacherId\");"
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ReusableCourseGroupsStatements =
|
private static readonly string[] ReusableCourseGroupsStatements =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
|
|||||||
+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")
|
b.Property<int?>("ScheduleWeek")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SelectionEndsAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SelectionStartsAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<DateTime>("StartDate")
|
b.Property<DateTime>("StartDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
@@ -2596,6 +2602,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("ExperimentSessions");
|
b.ToTable("ExperimentSessions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSessionInstructor", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<Guid>("ExperimentSessionId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TeacherId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TeacherId");
|
||||||
|
|
||||||
|
b.HasIndex("ExperimentSessionId", "TeacherId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ExperimentSessionInstructors");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -3417,9 +3451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
|
|
||||||
b.HasIndex("MessageDispatchId");
|
b.HasIndex("MessageDispatchId");
|
||||||
|
|
||||||
b.HasIndex("UserId", "IsRead");
|
b.HasIndex("UserId", "CreatedAt", "Id");
|
||||||
|
|
||||||
b.HasIndex("UserId", "Category", "CreatedAt");
|
b.HasIndex("UserId", "Category", "CreatedAt", "Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "IsRead", "CreatedAt", "Id");
|
||||||
|
|
||||||
b.ToTable("Notifications");
|
b.ToTable("Notifications");
|
||||||
});
|
});
|
||||||
@@ -5968,6 +6004,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("ExperimentProject");
|
b.Navigation("ExperimentProject");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSessionInstructor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentSession", "ExperimentSession")
|
||||||
|
.WithMany("Instructors")
|
||||||
|
.HasForeignKey("ExperimentSessionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeacherId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ExperimentSession");
|
||||||
|
|
||||||
|
b.Navigation("Teacher");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
||||||
@@ -6841,6 +6896,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentSession", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Bookings");
|
b.Navigation("Bookings");
|
||||||
|
|
||||||
|
b.Navigation("Instructors");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace Jiaowu.Api.Infrastructure.Scheduling;
|
|||||||
|
|
||||||
public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService
|
public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogger<WarningCheckWorker> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
|
private static readonly TimeZoneInfo ChinaTimeZone = ResolveChinaTimeZone();
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
@@ -25,22 +27,32 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
|||||||
|
|
||||||
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
|
private static async Task RunAsync(AppDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var nowUtc = DateTime.UtcNow;
|
||||||
var dow = (int)now.DayOfWeek == 0 ? 7 : (int)now.DayOfWeek;
|
var chinaNow = TimeZoneInfo.ConvertTime(
|
||||||
var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
|
new DateTimeOffset(nowUtc),
|
||||||
|
ChinaTimeZone);
|
||||||
|
var dow = (int)chinaNow.DayOfWeek == 0 ? 7 : (int)chinaNow.DayOfWeek;
|
||||||
|
var currentMinuteUtc = new DateTime(
|
||||||
|
nowUtc.Year,
|
||||||
|
nowUtc.Month,
|
||||||
|
nowUtc.Day,
|
||||||
|
nowUtc.Hour,
|
||||||
|
nowUtc.Minute,
|
||||||
|
0,
|
||||||
|
DateTimeKind.Utc);
|
||||||
|
|
||||||
var rules = await db.WarningRules
|
var rules = await db.WarningRules
|
||||||
.Where(r => r.IsEnabled && r.AutoCheckEnabled &&
|
.Where(r => r.IsEnabled && r.AutoCheckEnabled &&
|
||||||
r.CheckHour == now.Hour && r.CheckMinute == now.Minute &&
|
r.CheckHour == chinaNow.Hour && r.CheckMinute == chinaNow.Minute &&
|
||||||
(r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow))
|
(r.CheckDayOfWeek == null || r.CheckDayOfWeek == dow))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var rule in rules)
|
foreach (var rule in rules)
|
||||||
{
|
{
|
||||||
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinute)
|
if (rule.LastCheckAt.HasValue && rule.LastCheckAt.Value >= currentMinuteUtc)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
rule.LastCheckAt = now;
|
rule.LastCheckAt = nowUtc;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct);
|
var studentIds = await db.Students.Where(x => x.Status == StudentStatus.Active).Select(x => x.Id).ToListAsync(ct);
|
||||||
@@ -100,4 +112,23 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
|
|||||||
var gpa = grades.Average(x => x.GradePoint!.Value);
|
var gpa = grades.Average(x => x.GradePoint!.Value);
|
||||||
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
|
return gpa < rule.Threshold ? new WarningRecord { StudentId = sid, Type = WarningType.LowGPA, TriggerValue = Math.Round(gpa, 2), Detail = $"平均绩点 {gpa:F2},低于预警阈值 {rule.Threshold}。", AcademicTermId = termId } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo ResolveChinaTimeZone()
|
||||||
|
{
|
||||||
|
foreach (var id in new[] { "Asia/Shanghai", "China Standard Time" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("服务器未提供中国标准时间时区定义。");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,33 @@ public static class TimetableExcelExporter
|
|||||||
public static byte[] Create(TimetableData timetable)
|
public static byte[] Create(TimetableData timetable)
|
||||||
{
|
{
|
||||||
using var workbook = new XLWorkbook();
|
using var workbook = new XLWorkbook();
|
||||||
var sheet = workbook.Worksheets.Add("课表");
|
CreateWorksheet(workbook, timetable, "课表");
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Create(IReadOnlyCollection<TimetableData> timetables)
|
||||||
|
{
|
||||||
|
if (timetables.Count == 0)
|
||||||
|
throw new ArgumentException("至少需要一张课表。", nameof(timetables));
|
||||||
|
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
var index = 1;
|
||||||
|
foreach (var timetable in timetables)
|
||||||
|
CreateWorksheet(workbook, timetable, WorksheetName(timetable.Subject.Name, index++));
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateWorksheet(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
TimetableData timetable,
|
||||||
|
string worksheetName)
|
||||||
|
{
|
||||||
|
var sheet = workbook.Worksheets.Add(worksheetName);
|
||||||
sheet.Style.Font.FontName = "Microsoft YaHei";
|
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||||
|
|
||||||
sheet.Range("A1:H1").Merge();
|
sheet.Range("A1:H1").Merge();
|
||||||
@@ -153,9 +179,17 @@ public static class TimetableExcelExporter
|
|||||||
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||||
sheet.PageSetup.FitToPages(1, 0);
|
sheet.PageSetup.FitToPages(1, 0);
|
||||||
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
sheet.PageSetup.Margins.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
||||||
using var stream = new MemoryStream();
|
}
|
||||||
workbook.SaveAs(stream);
|
|
||||||
return stream.ToArray();
|
private static string WorksheetName(string subjectName, int index)
|
||||||
|
{
|
||||||
|
var suffix = $"-{index}";
|
||||||
|
var name = string.Concat(subjectName.Select(character =>
|
||||||
|
"[]:*?/\\".Contains(character) ? '-' : character)).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) name = "课表";
|
||||||
|
return name.Length > 31 - suffix.Length
|
||||||
|
? name[..(31 - suffix.Length)] + suffix
|
||||||
|
: name + suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EntryText(TimetableEntryDto entry) =>
|
private static string EntryText(TimetableEntryDto entry) =>
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
|
||||||
|
public static class TimetablePdfExporter
|
||||||
|
{
|
||||||
|
private const float PageWidth = 842;
|
||||||
|
private const float PageHeight = 595;
|
||||||
|
private const float Margin = 24;
|
||||||
|
private static readonly SKColor Ink = new(27, 53, 74);
|
||||||
|
private static readonly SKColor Muted = new(92, 111, 126);
|
||||||
|
private static readonly SKColor Accent = new(32, 105, 99);
|
||||||
|
private static readonly SKColor Rule = new(206, 220, 226);
|
||||||
|
private static readonly SKColor Pale = new(241, 247, 247);
|
||||||
|
|
||||||
|
public static byte[] Create(TimetableData timetable) => Create([timetable]);
|
||||||
|
|
||||||
|
public static byte[] Create(IReadOnlyCollection<TimetableData> timetables)
|
||||||
|
{
|
||||||
|
if (timetables.Count == 0)
|
||||||
|
throw new ArgumentException("至少需要一张课表。", nameof(timetables));
|
||||||
|
|
||||||
|
using var typeface = ResolveTypeface();
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var document = SKDocument.CreatePdf(stream);
|
||||||
|
var page = 0;
|
||||||
|
foreach (var timetable in timetables)
|
||||||
|
{
|
||||||
|
var periods = Periods(timetable);
|
||||||
|
foreach (var periodPage in periods.Chunk(9))
|
||||||
|
DrawPage(document, typeface, timetable, periodPage, ++page);
|
||||||
|
}
|
||||||
|
document.Close();
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawPage(
|
||||||
|
SKDocument document,
|
||||||
|
SKTypeface typeface,
|
||||||
|
TimetableData timetable,
|
||||||
|
IReadOnlyList<int> periods,
|
||||||
|
int page)
|
||||||
|
{
|
||||||
|
using var canvas = document.BeginPage(PageWidth, PageHeight);
|
||||||
|
canvas.Clear(SKColors.White);
|
||||||
|
using var frame = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.1f };
|
||||||
|
canvas.DrawRect(14, 14, PageWidth - 28, PageHeight - 28, frame);
|
||||||
|
|
||||||
|
DrawText(canvas, typeface, timetable.Subject.Name, Margin, 53, 19, Ink, bold: true);
|
||||||
|
DrawText(canvas, typeface, "课程表", PageWidth - Margin, 53, 11, Accent, SKTextAlign.Right, true);
|
||||||
|
DrawText(canvas, typeface,
|
||||||
|
$"{timetable.Subject.Code} · {timetable.Term.Name}" +
|
||||||
|
(timetable.Plan is null ? "" : $" · {timetable.Plan.Version} · {PlanStatus(timetable.Plan.Status)}"),
|
||||||
|
Margin, 74, 9, Muted);
|
||||||
|
DrawText(canvas, typeface,
|
||||||
|
$"导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}",
|
||||||
|
PageWidth - Margin, 74, 8, Muted, SKTextAlign.Right);
|
||||||
|
using var titleRule = new SKPaint { Color = Rule, StrokeWidth = .8f };
|
||||||
|
canvas.DrawLine(Margin, 86, PageWidth - Margin, 86, titleRule);
|
||||||
|
|
||||||
|
var widths = new[] { 84f, 101f, 101f, 101f, 101f, 101f, 101f, 101f };
|
||||||
|
var headers = new[] { "节次 / 时间", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" };
|
||||||
|
const float headerHeight = 24;
|
||||||
|
const float rowHeight = 46;
|
||||||
|
var y = 103f;
|
||||||
|
using var header = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill };
|
||||||
|
using var grid = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f };
|
||||||
|
canvas.DrawRect(Margin, y, widths.Sum(), headerHeight, header);
|
||||||
|
var x = Margin;
|
||||||
|
for (var column = 0; column < headers.Length; column++)
|
||||||
|
{
|
||||||
|
DrawText(canvas, typeface, headers[column], x + widths[column] / 2, y + 16, 8.5f,
|
||||||
|
SKColors.White, SKTextAlign.Center, true);
|
||||||
|
x += widths[column];
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries).ToArray();
|
||||||
|
for (var rowIndex = 0; rowIndex < periods.Count; rowIndex++)
|
||||||
|
{
|
||||||
|
var period = periods[rowIndex];
|
||||||
|
var rowY = y + headerHeight + rowIndex * rowHeight;
|
||||||
|
using var leftFill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill };
|
||||||
|
canvas.DrawRect(Margin, rowY, widths[0], rowHeight, leftFill);
|
||||||
|
var slot = timetable.Slots.FirstOrDefault(item => item.PeriodNumber == period);
|
||||||
|
DrawText(canvas, typeface, $"第 {period} 节", Margin + widths[0] / 2, rowY + 18, 8.5f,
|
||||||
|
Ink, SKTextAlign.Center, true);
|
||||||
|
if (slot is not null)
|
||||||
|
DrawText(canvas, typeface, $"{slot.StartsAt:HH\\:mm}-{slot.EndsAt:HH\\:mm}",
|
||||||
|
Margin + widths[0] / 2, rowY + 32, 7, Muted, SKTextAlign.Center);
|
||||||
|
|
||||||
|
x = Margin + widths[0];
|
||||||
|
for (var day = 1; day <= 7; day++)
|
||||||
|
{
|
||||||
|
var cellEntries = entries.Where(item =>
|
||||||
|
item.DayOfWeek == day &&
|
||||||
|
item.StartPeriod <= period &&
|
||||||
|
item.StartPeriod + item.PeriodCount - 1 >= period).ToArray();
|
||||||
|
if (cellEntries.Length > 0)
|
||||||
|
{
|
||||||
|
using var fill = new SKPaint
|
||||||
|
{
|
||||||
|
Color = cellEntries.Any(item => item.IsExam)
|
||||||
|
? new SKColor(255, 243, 230)
|
||||||
|
: cellEntries.Any(item => item.IsExperiment)
|
||||||
|
? new SKColor(232, 246, 241)
|
||||||
|
: new SKColor(234, 245, 244),
|
||||||
|
Style = SKPaintStyle.Fill
|
||||||
|
};
|
||||||
|
canvas.DrawRect(x, rowY, widths[day], rowHeight, fill);
|
||||||
|
DrawCellText(canvas, typeface, cellEntries, x, rowY, widths[day], rowHeight);
|
||||||
|
}
|
||||||
|
x += widths[day];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableHeight = headerHeight + periods.Count * rowHeight;
|
||||||
|
canvas.DrawRect(Margin, y, widths.Sum(), tableHeight, grid);
|
||||||
|
x = Margin;
|
||||||
|
for (var column = 0; column < widths.Length - 1; column++)
|
||||||
|
{
|
||||||
|
x += widths[column];
|
||||||
|
canvas.DrawLine(x, y, x, y + tableHeight, grid);
|
||||||
|
}
|
||||||
|
for (var row = 0; row < periods.Count; row++)
|
||||||
|
canvas.DrawLine(Margin, y + headerHeight + row * rowHeight, Margin + widths.Sum(),
|
||||||
|
y + headerHeight + row * rowHeight, grid);
|
||||||
|
|
||||||
|
var footerY = PageHeight - 31;
|
||||||
|
canvas.DrawLine(Margin, footerY - 12, PageWidth - Margin, footerY - 12, titleRule);
|
||||||
|
DrawText(canvas, typeface, "明序教务 · 课表导出", Margin, footerY, 7.5f, Muted);
|
||||||
|
DrawText(canvas, typeface, $"第 {page} 页", PageWidth - Margin, footerY, 7.5f, Muted,
|
||||||
|
SKTextAlign.Right);
|
||||||
|
document.EndPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawCellText(
|
||||||
|
SKCanvas canvas,
|
||||||
|
SKTypeface typeface,
|
||||||
|
IReadOnlyList<TimetableEntryDto> entries,
|
||||||
|
float x,
|
||||||
|
float y,
|
||||||
|
float width,
|
||||||
|
float height)
|
||||||
|
{
|
||||||
|
var text = string.Join("\n", entries.Select(EntryText));
|
||||||
|
var lines = WrapText(typeface, text, 6.6f, width - 8).Take(5).ToArray();
|
||||||
|
for (var index = 0; index < lines.Length; index++)
|
||||||
|
DrawText(canvas, typeface, lines[index], x + 4, y + 10 + index * 8, 6.6f, Ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<int> Periods(TimetableData timetable)
|
||||||
|
{
|
||||||
|
var entries = timetable.Entries.Concat(timetable.ExamEntries).Concat(timetable.ExperimentEntries);
|
||||||
|
var periods = timetable.Slots.Select(item => item.PeriodNumber)
|
||||||
|
.Concat(entries.SelectMany(item => Enumerable.Range(item.StartPeriod, item.PeriodCount)))
|
||||||
|
.Distinct().Order().ToArray();
|
||||||
|
return periods.Length == 0 ? Enumerable.Range(1, 8).ToArray() : periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EntryText(TimetableEntryDto entry) =>
|
||||||
|
$"{(entry.IsExam ? "【考试】" : entry.IsExperiment ? "【实验】" : "")}{entry.CourseName} · {string.Join('、', entry.TeacherNames)}" +
|
||||||
|
$" · {string.Join(" ", new[] { entry.BuildingName, entry.ClassroomName }.Where(item => !string.IsNullOrWhiteSpace(item)))}";
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> WrapText(SKTypeface typeface, string text, float size, float width)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size);
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var paragraph in text.Split('\n'))
|
||||||
|
{
|
||||||
|
var current = "";
|
||||||
|
foreach (var character in paragraph)
|
||||||
|
{
|
||||||
|
var candidate = current + character;
|
||||||
|
if (current.Length > 0 && font.MeasureText(candidate) > width)
|
||||||
|
{
|
||||||
|
lines.Add(current);
|
||||||
|
current = character.ToString();
|
||||||
|
}
|
||||||
|
else current = candidate;
|
||||||
|
}
|
||||||
|
if (current.Length > 0) lines.Add(current);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawText(SKCanvas canvas, SKTypeface typeface, string text, float x, float y,
|
||||||
|
float size, SKColor color, SKTextAlign align = SKTextAlign.Left, bool bold = false)
|
||||||
|
{
|
||||||
|
using var font = new SKFont(typeface, size) { Embolden = bold };
|
||||||
|
using var paint = new SKPaint { Color = color, IsAntialias = true };
|
||||||
|
canvas.DrawText(text, x, y, align, font, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SKTypeface ResolveTypeface()
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
|
||||||
|
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
|
||||||
|
};
|
||||||
|
foreach (var path in candidates)
|
||||||
|
{
|
||||||
|
if (File.Exists(path) && SKTypeface.FromFile(path) is { } typeface)
|
||||||
|
return typeface;
|
||||||
|
}
|
||||||
|
return SKTypeface.FromFamilyName("Microsoft YaHei") ??
|
||||||
|
SKTypeface.FromFamilyName("Noto Sans CJK SC") ??
|
||||||
|
throw new InvalidOperationException("未找到可用于课表 PDF 的中文字体。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PlanStatus(Domain.Academic.SchedulePlanStatus status) => status switch
|
||||||
|
{
|
||||||
|
Domain.Academic.SchedulePlanStatus.Draft => "草稿",
|
||||||
|
Domain.Academic.SchedulePlanStatus.Published => "已发布",
|
||||||
|
_ => "已归档"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,11 +47,6 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
|
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.2" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.2" />
|
||||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||||
|
|||||||
+57
-34
@@ -1,4 +1,5 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Net;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
@@ -6,6 +7,7 @@ using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
|||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Configuration;
|
using Jiaowu.Api.Infrastructure.Configuration;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Analytics;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Exams;
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Middleware;
|
using Jiaowu.Api.Infrastructure.Middleware;
|
||||||
@@ -19,15 +21,13 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using OpenTelemetry.Metrics;
|
|
||||||
using OpenTelemetry.Resources;
|
|
||||||
using OpenTelemetry.Trace;
|
|
||||||
using Swashbuckle.AspNetCore.SwaggerUI;
|
using Swashbuckle.AspNetCore.SwaggerUI;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
@@ -101,12 +101,30 @@ var observabilityOptions = builder.Configuration
|
|||||||
var performanceReportingOptions = builder.Configuration
|
var performanceReportingOptions = builder.Configuration
|
||||||
.GetSection(PerformanceReportingOptions.SectionName)
|
.GetSection(PerformanceReportingOptions.SectionName)
|
||||||
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||||
|
var clickHouseAnalyticsOptions = builder.Configuration
|
||||||
|
.GetSection(ClickHouseAnalyticsOptions.SectionName)
|
||||||
|
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
|
||||||
var rabbitMqOptions = builder.Configuration
|
var rabbitMqOptions = builder.Configuration
|
||||||
.GetSection(RabbitMqOptions.SectionName)
|
.GetSection(RabbitMqOptions.SectionName)
|
||||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||||
var ssoOptions = builder.Configuration
|
var ssoOptions = builder.Configuration
|
||||||
.GetSection(SsoOptions.SectionName)
|
.GetSection(SsoOptions.SectionName)
|
||||||
.Get<SsoOptions>() ?? new SsoOptions();
|
.Get<SsoOptions>() ?? new SsoOptions();
|
||||||
|
var trustedProxyAddresses = builder.Configuration
|
||||||
|
.GetSection("ReverseProxy:TrustedProxies")
|
||||||
|
.Get<string[]>() ?? [];
|
||||||
|
var trustedProxies = trustedProxyAddresses
|
||||||
|
.Select(value =>
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(value, out var address))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"ReverseProxy:TrustedProxies contains an invalid IP address: '{value}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
if (ssoOptions.Enabled &&
|
if (ssoOptions.Enabled &&
|
||||||
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
||||||
@@ -158,6 +176,7 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
|
||||||
observabilityOptions.ServiceName.Length > 100 ||
|
observabilityOptions.ServiceName.Length > 100 ||
|
||||||
|
observabilityOptions.SlowRequestThresholdMilliseconds is < 1 or > 60000 ||
|
||||||
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
|
||||||
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
|
||||||
{
|
{
|
||||||
@@ -187,15 +206,6 @@ if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
|
|||||||
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
|
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
|
||||||
}
|
}
|
||||||
|
|
||||||
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
|
|
||||||
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
|
|
||||||
parsedOtlpEndpoint.Scheme is not ("http" or "https")))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT 必须是有效的 HTTP 或 HTTPS 绝对地址。");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||||
@@ -252,6 +262,19 @@ if (backgroundJobOptions.UsesRabbitMq &&
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException("RabbitMq 连接配置不完整。");
|
throw new InvalidOperationException("RabbitMq 连接配置不完整。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clickHouseAnalyticsOptions.Enabled &&
|
||||||
|
(!Uri.TryCreate(clickHouseAnalyticsOptions.Endpoint, UriKind.Absolute, out var clickHouseEndpoint) ||
|
||||||
|
clickHouseEndpoint.Scheme is not ("http" or "https") ||
|
||||||
|
!clickHouseAnalyticsOptions.HasValidIdentifiers() ||
|
||||||
|
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.UserName) ||
|
||||||
|
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.Password) ||
|
||||||
|
clickHouseAnalyticsOptions.SyncIntervalSeconds is < 10 or > 86400 ||
|
||||||
|
clickHouseAnalyticsOptions.SourceLookbackDays is < 1 or > 3650 ||
|
||||||
|
clickHouseAnalyticsOptions.BatchSize is < 1 or > 10000))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ClickHouseAnalytics 配置无效。");
|
||||||
|
}
|
||||||
if (backgroundJobOptions.UsesRabbitMq &&
|
if (backgroundJobOptions.UsesRabbitMq &&
|
||||||
!builder.Environment.IsDevelopment() &&
|
!builder.Environment.IsDevelopment() &&
|
||||||
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
||||||
@@ -282,14 +305,21 @@ builder.Services.AddSingleton(backgroundJobOptions);
|
|||||||
builder.Services.AddSingleton(operationsOptions);
|
builder.Services.AddSingleton(operationsOptions);
|
||||||
builder.Services.AddSingleton(observabilityOptions);
|
builder.Services.AddSingleton(observabilityOptions);
|
||||||
builder.Services.AddSingleton(performanceReportingOptions);
|
builder.Services.AddSingleton(performanceReportingOptions);
|
||||||
|
builder.Services.AddSingleton(clickHouseAnalyticsOptions);
|
||||||
builder.Services.AddSingleton(rabbitMqOptions);
|
builder.Services.AddSingleton(rabbitMqOptions);
|
||||||
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||||
{
|
{
|
||||||
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||||
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
|
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
|
||||||
});
|
});
|
||||||
|
builder.Services.AddHttpClient<ClickHouseAnalyticsClient>((_, client) =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(clickHouseAnalyticsOptions.Endpoint.TrimEnd('/') + "/");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
});
|
||||||
builder.Services.Configure<OfficialDocumentOptions>(
|
builder.Services.Configure<OfficialDocumentOptions>(
|
||||||
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||||
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
||||||
@@ -339,28 +369,6 @@ builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (observabilityOptions.Enabled &&
|
|
||||||
!string.IsNullOrWhiteSpace(otlpEndpoint))
|
|
||||||
{
|
|
||||||
builder.Services
|
|
||||||
.AddOpenTelemetry()
|
|
||||||
.ConfigureResource(resource =>
|
|
||||||
resource.AddService(observabilityOptions.ServiceName))
|
|
||||||
.WithMetrics(metrics => metrics
|
|
||||||
.AddAspNetCoreInstrumentation()
|
|
||||||
.AddHttpClientInstrumentation()
|
|
||||||
.AddRuntimeInstrumentation()
|
|
||||||
.AddMeter(DatabaseCommandTelemetryInterceptor.MeterName))
|
|
||||||
.WithTracing(tracing => tracing
|
|
||||||
.AddAspNetCoreInstrumentation(options =>
|
|
||||||
options.Filter = context =>
|
|
||||||
!context.Request.Path.StartsWithSegments("/health/live"))
|
|
||||||
.AddHttpClientInstrumentation()
|
|
||||||
.AddSource(DatabaseCommandTelemetryInterceptor.ActivitySourceName))
|
|
||||||
.WithMetrics(metrics => metrics.AddOtlpExporter())
|
|
||||||
.WithTracing(tracing => tracing.AddOtlpExporter());
|
|
||||||
}
|
|
||||||
|
|
||||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
||||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||||
{
|
{
|
||||||
@@ -441,6 +449,7 @@ builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
|||||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
||||||
|
builder.Services.AddHostedService<ClickHouseAnalyticsProjectionWorker>();
|
||||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||||
builder.Services.AddScoped<OperationalHealthService>();
|
builder.Services.AddScoped<OperationalHealthService>();
|
||||||
@@ -530,6 +539,18 @@ if (ssoOptions.Enabled)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders =
|
||||||
|
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.ForwardLimit = 1;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
foreach (var proxy in trustedProxies)
|
||||||
|
{
|
||||||
|
options.KnownProxies.Add(proxy);
|
||||||
|
}
|
||||||
|
});
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
@@ -644,6 +665,7 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseForwardedHeaders();
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
app.UseResponseCompression();
|
app.UseResponseCompression();
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
@@ -699,6 +721,7 @@ app.UseStaticFiles(new StaticFileOptions
|
|||||||
});
|
});
|
||||||
app.UseCors("Web");
|
app.UseCors("Web");
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
app.UseMiddleware<SlowRequestLoggingMiddleware>();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<AuditMiddleware>();
|
app.UseMiddleware<AuditMiddleware>();
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"Observability": {
|
"Observability": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ServiceName": "jiaowu-api",
|
"ServiceName": "jiaowu-api",
|
||||||
|
"LogAllApiRequests": true,
|
||||||
|
"SlowRequestThresholdMilliseconds": 1000,
|
||||||
"SlowQueryThresholdMilliseconds": 500,
|
"SlowQueryThresholdMilliseconds": 500,
|
||||||
"IncludeSqlText": false,
|
"IncludeSqlText": false,
|
||||||
"MaximumSqlTextLength": 2000
|
"MaximumSqlTextLength": 2000
|
||||||
@@ -39,6 +41,17 @@
|
|||||||
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
|
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
|
||||||
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
|
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
|
||||||
},
|
},
|
||||||
|
"ClickHouseAnalytics": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Endpoint": "http://localhost:8123",
|
||||||
|
"Database": "jiaowu_analytics",
|
||||||
|
"UserName": "jiaowu_analytics",
|
||||||
|
"Password": "",
|
||||||
|
"CreateSchemaOnStartup": true,
|
||||||
|
"SyncIntervalSeconds": 60,
|
||||||
|
"SourceLookbackDays": 90,
|
||||||
|
"BatchSize": 1000
|
||||||
|
},
|
||||||
"Operations": {
|
"Operations": {
|
||||||
"BackupDirectory": "data/backups",
|
"BackupDirectory": "data/backups",
|
||||||
"BackupWarningHours": 24,
|
"BackupWarningHours": 24,
|
||||||
@@ -94,6 +107,12 @@
|
|||||||
"FrontendBaseUrl": "",
|
"FrontendBaseUrl": "",
|
||||||
"CallbackUrl": ""
|
"CallbackUrl": ""
|
||||||
},
|
},
|
||||||
|
"ReverseProxy": {
|
||||||
|
"TrustedProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
]
|
||||||
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": [
|
"Origins": [
|
||||||
"capacitor://localhost",
|
"capacitor://localhost",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -11,6 +12,40 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class ApprovalsControllerTests
|
public sealed class ApprovalsControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Pending_returns_a_single_server_page_with_total()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
fixture.Db.AddRange(
|
||||||
|
new DeferredExam
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
Reason = "较早提交",
|
||||||
|
SubmittedAt = DateTime.UtcNow.AddMinutes(-2)
|
||||||
|
},
|
||||||
|
new CourseExemption
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
Reason = "较晚提交",
|
||||||
|
SubmittedAt = DateTime.UtcNow.AddMinutes(-1)
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ApprovalsController(
|
||||||
|
fixture.Db,
|
||||||
|
new ManagerDataScope());
|
||||||
|
var result = await controller.GetPending(2, 1, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var page = Assert.IsType<PagedResult<ApprovalItem>>(ok.Value);
|
||||||
|
Assert.Equal(2, page.Total);
|
||||||
|
Assert.Equal(2, page.Page);
|
||||||
|
var item = Assert.Single(page.Items);
|
||||||
|
Assert.Equal("较早提交", item.Desc);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||||
{
|
{
|
||||||
@@ -298,4 +333,14 @@ public sealed class ApprovalsControllerTests
|
|||||||
DataScope.Self,
|
DataScope.Self,
|
||||||
new HashSet<string>([SystemRoles.Student]));
|
new HashSet<string>([SystemRoles.Student]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ManagerDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Jiaowu.Api.Contracts;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AttendancePaginationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task TasksAndSheets_AreFilteredAndReturnedByPage()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var db = new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101", Name = "程序设计基础", CollegeId = college.Id,
|
||||||
|
Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-1", Name = "2026—2027 学年第一学期", AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 20)
|
||||||
|
};
|
||||||
|
var tasks = Enumerable.Range(1, 11).Select(number => new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"2026-1-CS101-{number:00}", Name = $"程序设计教学班 {number}",
|
||||||
|
AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
}).ToList();
|
||||||
|
var sheets = Enumerable.Range(1, 11).Select(number => new AttendanceSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = tasks[0].Id,
|
||||||
|
Name = number == 11 ? "期末扫码点名" : $"第 {number} 周点名",
|
||||||
|
AttendanceDate = new DateTime(2026, 9, number),
|
||||||
|
Status = number == 11 ? AttendanceSheetStatus.Submitted : AttendanceSheetStatus.Draft,
|
||||||
|
CheckInMethod = number == 11 ? AttendanceCheckInMethod.QrCode : AttendanceCheckInMethod.Manual
|
||||||
|
}).ToList();
|
||||||
|
db.AddRange(college, course, term);
|
||||||
|
db.AddRange(tasks);
|
||||||
|
db.AddRange(sheets);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new AttendanceController(db, new AllScope());
|
||||||
|
var taskResult = Assert.IsType<OkObjectResult>(await controller.GetMyTasks(
|
||||||
|
term.Id, "程序设计", 1, 10, CancellationToken.None));
|
||||||
|
var taskPage = Assert.IsType<PagedResult<object>>(taskResult.Value);
|
||||||
|
Assert.Equal(11, taskPage.Total);
|
||||||
|
Assert.Equal(10, taskPage.Items.Count);
|
||||||
|
|
||||||
|
var sheetResult = Assert.IsType<OkObjectResult>(await controller.GetSheets(
|
||||||
|
tasks[0].Id, "期末", AttendanceSheetStatus.Submitted,
|
||||||
|
AttendanceCheckInMethod.QrCode, 1, 10, CancellationToken.None));
|
||||||
|
var sheetPage = Assert.IsType<PagedResult<object>>(sheetResult.Value);
|
||||||
|
Assert.Equal(1, sheetPage.Total);
|
||||||
|
Assert.Single(sheetPage.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AllScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(), "测试管理员", null, DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Diagnostics.Metrics;
|
|
||||||
using Jiaowu.Api.Infrastructure.Observability;
|
using Jiaowu.Api.Infrastructure.Observability;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -40,7 +40,7 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Ef_command_records_duration_metric()
|
public async Task Ef_command_executes_with_logging_interceptor()
|
||||||
{
|
{
|
||||||
await using var connection =
|
await using var connection =
|
||||||
new SqliteConnection("Data Source=:memory:");
|
new SqliteConnection("Data Source=:memory:");
|
||||||
@@ -52,25 +52,9 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
|||||||
await using (var setupDb = new AppDbContext(setupOptions))
|
await using (var setupDb = new AppDbContext(setupOptions))
|
||||||
await setupDb.Database.EnsureCreatedAsync();
|
await setupDb.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
double? recordedDuration = null;
|
|
||||||
using var listener = new MeterListener
|
|
||||||
{
|
|
||||||
InstrumentPublished = (instrument, meterListener) =>
|
|
||||||
{
|
|
||||||
if (instrument.Meter.Name ==
|
|
||||||
DatabaseCommandTelemetryInterceptor.MeterName &&
|
|
||||||
instrument.Name == "jiaowu.db.command.duration")
|
|
||||||
{
|
|
||||||
meterListener.EnableMeasurementEvents(instrument);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
listener.SetMeasurementEventCallback<double>(
|
|
||||||
(_, measurement, _, _) => recordedDuration = measurement);
|
|
||||||
listener.Start();
|
|
||||||
|
|
||||||
var interceptor = new DatabaseCommandTelemetryInterceptor(
|
var interceptor = new DatabaseCommandTelemetryInterceptor(
|
||||||
new ObservabilityOptions(),
|
new ObservabilityOptions(),
|
||||||
|
new HttpContextAccessor(),
|
||||||
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
|
||||||
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseSqlite(connection)
|
.UseSqlite(connection)
|
||||||
@@ -82,7 +66,6 @@ public sealed class DatabaseCommandTelemetryInterceptorTests
|
|||||||
.TagWith("Observability.Tests.TermCount")
|
.TagWith("Observability.Tests.TermCount")
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
|
|
||||||
Assert.NotNull(recordedDuration);
|
Assert.Equal(0, await db.AcademicTerms.CountAsync());
|
||||||
Assert.True(recordedDuration >= 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Jiaowu.Api.Domain.Identity;
|
|||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using ClosedXML.Excel;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -72,6 +74,56 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
CancellationToken.None));
|
CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Teacher_CanDownloadAndImportExperimentGradeTemplate()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var project = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-EXCEL",
|
||||||
|
Name = "Excel 实验成绩",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
fixture.Db.ExperimentProjects.Add(project);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var admin = fixture.ExperimentGrades(fixture.AdminScope);
|
||||||
|
await admin.CreateSheet(new ExperimentGradeSheetRequest(
|
||||||
|
project.Id, 1, 60,
|
||||||
|
[new ExperimentGradeItemRequest("操作", ExperimentGradeItemKind.Operation, 100)]),
|
||||||
|
CancellationToken.None);
|
||||||
|
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
|
||||||
|
var template = Assert.IsType<FileContentResult>(await teacher.DownloadTemplate(
|
||||||
|
sheet.Id, CancellationToken.None));
|
||||||
|
Assert.Equal("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
template.ContentType);
|
||||||
|
await using var templateStream = new MemoryStream(template.FileContents);
|
||||||
|
await using var stream = new MemoryStream();
|
||||||
|
using (var workbook = new XLWorkbook(templateStream))
|
||||||
|
{
|
||||||
|
var worksheet = workbook.Worksheet("实验成绩导入");
|
||||||
|
worksheet.Cell("D2").Value = "已完成";
|
||||||
|
worksheet.Cell("E2").Value = 85;
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
}
|
||||||
|
stream.Position = 0;
|
||||||
|
var file = new FormFile(stream, 0, stream.Length, "file", "实验成绩导入模板.xlsx");
|
||||||
|
Assert.IsType<OkObjectResult>(await teacher.ImportRecords(
|
||||||
|
sheet.Id, file, CancellationToken.None));
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
Assert.Equal(85m, await fixture.Db.ExperimentGradeRecords
|
||||||
|
.Select(record => record.TotalScore)
|
||||||
|
.SingleAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
|
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
|
||||||
{
|
{
|
||||||
@@ -146,10 +198,11 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
1,
|
1,
|
||||||
10,
|
10,
|
||||||
CancellationToken.None));
|
CancellationToken.None));
|
||||||
Assert.Equal(12, Property<int>(page.Value, "Total"));
|
Assert.Equal(1, Property<int>(page.Value, "Total"));
|
||||||
Assert.Equal(10, Property<System.Collections.IEnumerable>(
|
var course = Assert.Single(Property<System.Collections.IEnumerable>(
|
||||||
page.Value,
|
page.Value, "Items").Cast<object>());
|
||||||
"Items").Cast<object>().Count());
|
Assert.Equal(12, Property<System.Collections.IEnumerable>(
|
||||||
|
course, "Projects").Cast<object>().Count());
|
||||||
|
|
||||||
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
||||||
null,
|
null,
|
||||||
@@ -162,6 +215,89 @@ public sealed class ExperimentGradesControllerTests
|
|||||||
Assert.Equal(1, Property<int>(filtered.Value, "Total"));
|
Assert.Equal(1, Property<int>(filtered.Value, "Total"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManagementList_OrdersProjectsByScheduledWeekAndPeriod()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
Name = "实验课表",
|
||||||
|
Version = "v1",
|
||||||
|
Status = SchedulePlanStatus.Published
|
||||||
|
};
|
||||||
|
var laterEntry = new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = plan.Id,
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ClassroomId = fixture.Classroom.Id,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
DayOfWeek = 4,
|
||||||
|
StartPeriod = 5,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 5,
|
||||||
|
EndWeek = 5,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
};
|
||||||
|
var earlierEntry = new ScheduleEntry
|
||||||
|
{
|
||||||
|
SchedulePlanId = plan.Id,
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ClassroomId = fixture.Classroom.Id,
|
||||||
|
Kind = ScheduleEntryKind.Experiment,
|
||||||
|
DayOfWeek = 2,
|
||||||
|
StartPeriod = 3,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 2,
|
||||||
|
EndWeek = 2,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(plan, laterEntry, earlierEntry);
|
||||||
|
fixture.Db.AddRange(
|
||||||
|
new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ScheduleEntryId = laterEntry.Id,
|
||||||
|
ScheduleWeek = 5,
|
||||||
|
Code = "LAB-A",
|
||||||
|
Name = "后面的实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published
|
||||||
|
},
|
||||||
|
new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ScheduleEntryId = earlierEntry.Id,
|
||||||
|
ScheduleWeek = 2,
|
||||||
|
Code = "LAB-Z",
|
||||||
|
Name = "前面的实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(await fixture.ExperimentGrades(
|
||||||
|
fixture.AdminScope).GetManagement(
|
||||||
|
fixture.Term.Id, null, null, null, 1, 10, CancellationToken.None));
|
||||||
|
var course = Assert.Single(Property<System.Collections.IEnumerable>(
|
||||||
|
result.Value, "Items").Cast<object>());
|
||||||
|
var projects = Property<System.Collections.IEnumerable>(course, "Projects")
|
||||||
|
.Cast<object>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Equal(["前面的实验", "后面的实验"],
|
||||||
|
projects.Select(project => Property<string>(project, "Name")));
|
||||||
|
var schedule = Property<object>(projects[0], "ScheduleEntry");
|
||||||
|
Assert.Equal(2, Property<int>(projects[0], "ScheduleWeek"));
|
||||||
|
Assert.Equal(2, Property<int>(schedule, "DayOfWeek"));
|
||||||
|
Assert.Equal(3, Property<int>(schedule, "StartPeriod"));
|
||||||
|
}
|
||||||
|
|
||||||
private static T Property<T>(object? value, string name) =>
|
private static T Property<T>(object? value, string name) =>
|
||||||
Assert.IsAssignableFrom<T>(
|
Assert.IsAssignableFrom<T>(
|
||||||
value!.GetType().GetProperty(name)!.GetValue(value));
|
value!.GetType().GetProperty(name)!.GetValue(value));
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null),
|
null,
|
||||||
|
[fixture.Teacher.Id]),
|
||||||
new ExperimentSessionBatchItem(
|
new ExperimentSessionBatchItem(
|
||||||
projectIds[1],
|
projectIds[1],
|
||||||
fixture.Classroom.Id,
|
fixture.Classroom.Id,
|
||||||
@@ -169,7 +170,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null)
|
null,
|
||||||
|
[fixture.Teacher.Id])
|
||||||
]),
|
]),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -202,7 +204,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null),
|
null,
|
||||||
|
[fixture.Teacher.Id]),
|
||||||
new ExperimentSessionBatchItem(
|
new ExperimentSessionBatchItem(
|
||||||
projectIds[1],
|
projectIds[1],
|
||||||
fixture.SecondClassroom.Id,
|
fixture.SecondClassroom.Id,
|
||||||
@@ -210,7 +213,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
20,
|
20,
|
||||||
null)
|
null,
|
||||||
|
[fixture.Teacher.Id])
|
||||||
]),
|
]),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -218,6 +222,65 @@ public sealed class ExperimentsControllerTests
|
|||||||
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelfScheduledProject_AllowsMultipleCandidateSessionsAndRequiresSelectionWindow()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
var request = fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled) with
|
||||||
|
{
|
||||||
|
SelectionStartsAt = DateTime.UtcNow.AddHours(1),
|
||||||
|
SelectionEndsAt = DateTime.UtcNow.AddDays(1)
|
||||||
|
};
|
||||||
|
await manager.CreateProject(request, CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
|
||||||
|
var result = await manager.CreateSessions(
|
||||||
|
new ExperimentSessionBatchRequest(
|
||||||
|
[
|
||||||
|
new ExperimentSessionBatchItem(project.Id, fixture.Classroom.Id,
|
||||||
|
fixture.Term.StartDate, 1, 2, 20, null, [fixture.Teacher.Id]),
|
||||||
|
new ExperimentSessionBatchItem(project.Id, fixture.SecondClassroom.Id,
|
||||||
|
fixture.Term.StartDate, 4, 2, 20, null, [fixture.Teacher.Id])
|
||||||
|
]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
|
||||||
|
Assert.Equal(2, await fixture.Db.ExperimentSessionInstructors.CountAsync());
|
||||||
|
Assert.IsType<NoContentResult>(await manager.PublishProject(project.Id, CancellationToken.None));
|
||||||
|
var session = await fixture.Db.ExperimentSessions.OrderBy(x => x.StartPeriod).FirstAsync();
|
||||||
|
Assert.IsType<ConflictObjectResult>(
|
||||||
|
await fixture.Controller(fixture.StudentScope).Book(session.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoundInstructor_CanCancelOwnSession()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
Assert.IsType<CreatedResult>(await manager.CreateSession(
|
||||||
|
project.Id, fixture.SessionRequest(1, 2, 20), CancellationToken.None));
|
||||||
|
var session = await fixture.Db.ExperimentSessions.SingleAsync();
|
||||||
|
Assert.IsType<NoContentResult>(await manager.PublishProject(project.Id, CancellationToken.None));
|
||||||
|
|
||||||
|
var instructorScope = new FixedScope(new CurrentUserScope(
|
||||||
|
fixture.Teacher.UserId!.Value,
|
||||||
|
fixture.Teacher.Name,
|
||||||
|
null,
|
||||||
|
DataScope.Self,
|
||||||
|
new HashSet<string>([SystemRoles.Teacher])));
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(await fixture.Controller(instructorScope)
|
||||||
|
.CancelSession(session.Id, CancellationToken.None));
|
||||||
|
Assert.Equal(ExperimentSessionStatus.Cancelled,
|
||||||
|
(await fixture.Db.ExperimentSessions.SingleAsync()).Status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
|
public async Task CentralizedProject_PublishesWhenBoundToPublishedExperimentSchedule()
|
||||||
{
|
{
|
||||||
@@ -246,6 +309,35 @@ public sealed class ExperimentsControllerTests
|
|||||||
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishedProject_AllowsTeachingDetailCorrectionWithoutChangingSchedule()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await controller.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
await controller.PublishProject(project.Id, CancellationToken.None);
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(await controller.CorrectPublishedProjectDetails(
|
||||||
|
project.Id,
|
||||||
|
new PublishedExperimentProjectCorrectionRequest(
|
||||||
|
"修正后的实验名称",
|
||||||
|
"按本班教学计划调整实验步骤。",
|
||||||
|
"请提前完成环境检查。"),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
var corrected = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
Assert.Equal("修正后的实验名称", corrected.Name);
|
||||||
|
Assert.Equal("按本班教学计划调整实验步骤。", corrected.Description);
|
||||||
|
Assert.Equal("请提前完成环境检查。", corrected.Requirements);
|
||||||
|
Assert.Equal("LAB-C", corrected.Code);
|
||||||
|
Assert.Equal(fixture.ScheduleEntry.Id, corrected.ScheduleEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||||
{
|
{
|
||||||
@@ -480,6 +572,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
AcademicTerm term,
|
AcademicTerm term,
|
||||||
TeachingTask task,
|
TeachingTask task,
|
||||||
|
Teacher teacher,
|
||||||
Classroom classroom,
|
Classroom classroom,
|
||||||
Classroom secondClassroom,
|
Classroom secondClassroom,
|
||||||
ScheduleEntry scheduleEntry,
|
ScheduleEntry scheduleEntry,
|
||||||
@@ -490,6 +583,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
Db = db;
|
Db = db;
|
||||||
Term = term;
|
Term = term;
|
||||||
Task = task;
|
Task = task;
|
||||||
|
Teacher = teacher;
|
||||||
Classroom = classroom;
|
Classroom = classroom;
|
||||||
SecondClassroom = secondClassroom;
|
SecondClassroom = secondClassroom;
|
||||||
ScheduleEntry = scheduleEntry;
|
ScheduleEntry = scheduleEntry;
|
||||||
@@ -501,6 +595,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
public AppDbContext Db { get; }
|
public AppDbContext Db { get; }
|
||||||
public AcademicTerm Term { get; }
|
public AcademicTerm Term { get; }
|
||||||
public TeachingTask Task { get; }
|
public TeachingTask Task { get; }
|
||||||
|
public Teacher Teacher { get; }
|
||||||
public Classroom Classroom { get; }
|
public Classroom Classroom { get; }
|
||||||
public Classroom SecondClassroom { get; }
|
public Classroom SecondClassroom { get; }
|
||||||
public ScheduleEntry ScheduleEntry { get; }
|
public ScheduleEntry ScheduleEntry { get; }
|
||||||
@@ -571,7 +666,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
{
|
{
|
||||||
TeacherNumber = "T2099",
|
TeacherNumber = "T2099",
|
||||||
Name = "实验教师",
|
Name = "实验教师",
|
||||||
CollegeId = college.Id
|
CollegeId = college.Id,
|
||||||
|
UserId = manager.Id
|
||||||
};
|
};
|
||||||
var term = new AcademicTerm
|
var term = new AcademicTerm
|
||||||
{
|
{
|
||||||
@@ -626,7 +722,15 @@ public sealed class ExperimentsControllerTests
|
|||||||
AcademicTermId = term.Id,
|
AcademicTermId = term.Id,
|
||||||
CourseId = course.Id,
|
CourseId = course.Id,
|
||||||
Capacity = 40,
|
Capacity = 40,
|
||||||
Status = TeachingTaskStatus.Published
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Teachers =
|
||||||
|
[
|
||||||
|
new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
db.AddRange(
|
db.AddRange(
|
||||||
manager,
|
manager,
|
||||||
@@ -683,6 +787,7 @@ public sealed class ExperimentsControllerTests
|
|||||||
db,
|
db,
|
||||||
term,
|
term,
|
||||||
task,
|
task,
|
||||||
|
teacher,
|
||||||
classroom,
|
classroom,
|
||||||
secondClassroom,
|
secondClassroom,
|
||||||
scheduleEntry,
|
scheduleEntry,
|
||||||
@@ -715,7 +820,13 @@ public sealed class ExperimentsControllerTests
|
|||||||
"携带校园卡。",
|
"携带校园卡。",
|
||||||
Term.StartDate,
|
Term.StartDate,
|
||||||
Term.StartDate.AddDays(14),
|
Term.StartDate.AddDays(14),
|
||||||
mode == ExperimentArrangementMode.Centralized ? ScheduleEntry.Id : null);
|
mode == ExperimentArrangementMode.Centralized ? ScheduleEntry.Id : null,
|
||||||
|
mode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? new DateTime(2020, 1, 1, 8, 0, 0)
|
||||||
|
: null,
|
||||||
|
mode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? new DateTime(2100, 1, 1, 18, 0, 0)
|
||||||
|
: null);
|
||||||
|
|
||||||
public ExperimentProjectBatchRequest BatchProjectRequest(
|
public ExperimentProjectBatchRequest BatchProjectRequest(
|
||||||
ExperimentArrangementMode mode) =>
|
ExperimentArrangementMode mode) =>
|
||||||
@@ -742,7 +853,8 @@ public sealed class ExperimentsControllerTests
|
|||||||
startPeriod,
|
startPeriod,
|
||||||
periodCount,
|
periodCount,
|
||||||
capacity,
|
capacity,
|
||||||
null);
|
null,
|
||||||
|
[Teacher.Id]);
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
@@ -83,8 +84,12 @@ public sealed class GradesPaginationTests
|
|||||||
var sheet = new GradeSheet
|
var sheet = new GradeSheet
|
||||||
{
|
{
|
||||||
TeachingTaskId = tasks[0].Id,
|
TeachingTaskId = tasks[0].Id,
|
||||||
RegularWeight = 30,
|
RegularWeight = 20,
|
||||||
FinalWeight = 70,
|
FinalWeight = 60,
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 }
|
||||||
|
],
|
||||||
Records = students.Select(student => new GradeRecord
|
Records = students.Select(student => new GradeRecord
|
||||||
{
|
{
|
||||||
StudentId = student.Id
|
StudentId = student.Id
|
||||||
@@ -129,6 +134,18 @@ public sealed class GradesPaginationTests
|
|||||||
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
|
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
|
||||||
.GetValue(secondDetail.Value)!;
|
.GetValue(secondDetail.Value)!;
|
||||||
Assert.Single(ReadItems(secondSheet, "Records"));
|
Assert.Single(ReadItems(secondSheet, "Records"));
|
||||||
|
|
||||||
|
var templateResult = Assert.IsType<FileContentResult>(
|
||||||
|
await controller.DownloadTemplate(sheet.Id, CancellationToken.None));
|
||||||
|
using var stream = new MemoryStream(templateResult.FileContents);
|
||||||
|
using var workbook = new XLWorkbook(stream);
|
||||||
|
var worksheet = workbook.Worksheet("成绩导入");
|
||||||
|
Assert.Equal(
|
||||||
|
["学号", "姓名", "班级", "平时成绩", "实验", "期末成绩", "总分(自动计算)", "考试状态", "备注"],
|
||||||
|
worksheet.Row(1).CellsUsed().Select(cell => cell.GetString()));
|
||||||
|
Assert.Contains("D2*20/100", worksheet.Cell("G2").FormulaA1);
|
||||||
|
Assert.Contains("E2*20/100", worksheet.Cell("G2").FormulaA1);
|
||||||
|
Assert.Contains("F2*60/100", worksheet.Cell("G2").FormulaA1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ReadInt(object value, string property) =>
|
private static int ReadInt(object value, string property) =>
|
||||||
|
|||||||
@@ -234,6 +234,53 @@ public sealed class NotificationsControllerTests
|
|||||||
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
|
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mark_all_read_only_updates_the_current_users_unread_notifications()
|
||||||
|
{
|
||||||
|
await using var fixture = await NotificationFixture.CreateAsync();
|
||||||
|
fixture.Db.Notifications.AddRange(
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
UserId = fixture.Sender.Id,
|
||||||
|
Title = "当前用户未读",
|
||||||
|
Content = "应被标为已读。"
|
||||||
|
},
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
UserId = fixture.Sender.Id,
|
||||||
|
Title = "当前用户已读",
|
||||||
|
Content = "应保持已读。",
|
||||||
|
IsRead = true
|
||||||
|
},
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
UserId = fixture.CollegeRecipient.Id,
|
||||||
|
Title = "其他用户未读",
|
||||||
|
Content = "不应被修改。"
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new NotificationsController(
|
||||||
|
fixture.Db,
|
||||||
|
new TestDataScope(
|
||||||
|
fixture.Sender.Id,
|
||||||
|
fixture.FirstCollege.Id,
|
||||||
|
SystemRoles.AcademicAdmin));
|
||||||
|
|
||||||
|
var result = await controller.MarkAllRead(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
var notifications = await fixture.Db.Notifications.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Title)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.All(
|
||||||
|
notifications.Where(x => x.UserId == fixture.Sender.Id),
|
||||||
|
notification => Assert.True(notification.IsRead));
|
||||||
|
Assert.False(Assert.Single(
|
||||||
|
notifications,
|
||||||
|
x => x.UserId == fixture.CollegeRecipient.Id).IsRead);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Publishing_course_grades_automatically_notifies_roster_students()
|
public async Task Publishing_course_grades_automatically_notifies_roster_students()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ public sealed class OfficialDocumentTests
|
|||||||
|
|
||||||
var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code");
|
var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code");
|
||||||
|
|
||||||
Assert.True(result.Content.Length > 5_000);
|
Assert.True(result.Content.Length > 500, "生成的 PDF 不应为空。");
|
||||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
|
||||||
|
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), result.Content);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
|
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
|
||||||
result.Sha256);
|
result.Sha256);
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class OtherExamsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Batches_AreFilteredAndReturnedByPage()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var db = new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
db.OtherExamBatches.AddRange(Enumerable.Range(1, 11).Select(number =>
|
||||||
|
new OtherExamBatch
|
||||||
|
{
|
||||||
|
ExamCode = $"CET-{number:00}",
|
||||||
|
Name = $"英语等级考试 {number}",
|
||||||
|
ExamDate = new DateOnly(2026, 6, number),
|
||||||
|
MetricKind = OtherExamMetricKind.Score,
|
||||||
|
MaxScore = 100,
|
||||||
|
Status = number == 11
|
||||||
|
? OtherExamBatchStatus.Published
|
||||||
|
: OtherExamBatchStatus.Draft
|
||||||
|
}));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new OtherExamsController(db, new TestDataScope(Guid.NewGuid()));
|
||||||
|
var result = Assert.IsType<OkObjectResult>(await controller.GetBatches(
|
||||||
|
keyword: "英语", status: OtherExamBatchStatus.Draft, page: 1,
|
||||||
|
pageSize: 10, ct: CancellationToken.None));
|
||||||
|
var page = Assert.IsType<PagedResult<object>>(result.Value);
|
||||||
|
Assert.Equal(10, page.Total);
|
||||||
|
Assert.Equal(10, page.Items.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplaceResults_ReplacesExistingResultsInsideTransaction()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" };
|
||||||
|
var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 };
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "202600001",
|
||||||
|
Name = "测试学生",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1),
|
||||||
|
Status = StudentStatus.Active
|
||||||
|
};
|
||||||
|
var batch = new OtherExamBatch
|
||||||
|
{
|
||||||
|
ExamCode = "CET4",
|
||||||
|
Name = "大学英语四级",
|
||||||
|
ExamDate = new DateOnly(2026, 6, 1),
|
||||||
|
MetricKind = OtherExamMetricKind.Score,
|
||||||
|
MaxScore = 710
|
||||||
|
};
|
||||||
|
var oldResult = new OtherExamResult
|
||||||
|
{
|
||||||
|
OtherExamBatchId = batch.Id,
|
||||||
|
StudentId = student.Id,
|
||||||
|
Score = 500,
|
||||||
|
AttemptNumber = 1
|
||||||
|
};
|
||||||
|
db.AddRange(college, major, administrativeClass, student, batch, oldResult);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new OtherExamsController(db, new TestDataScope(college.Id));
|
||||||
|
var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([
|
||||||
|
new OtherExamResultRequest(student.StudentNumber, 610.5m, null, null, "已复核")
|
||||||
|
]), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var saved = await db.OtherExamResults.SingleAsync(x => x.OtherExamBatchId == batch.Id);
|
||||||
|
Assert.Equal(610.5m, saved.Score);
|
||||||
|
Assert.Equal("已复核", saved.Notes);
|
||||||
|
Assert.Equal(1, saved.AttemptNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplaceResults_RejectsOverlongNotesBeforeDatabaseSave()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major { Code = "SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" };
|
||||||
|
var administrativeClass = new AdministrativeClass { Code = "SE202601", Name = "软件工程 2026 级 1 班", MajorId = major.Id, Grade = 2026 };
|
||||||
|
var student = new Student { StudentNumber = "202600001", Name = "测试学生", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1), Status = StudentStatus.Active };
|
||||||
|
var batch = new OtherExamBatch { ExamCode = "CET4", Name = "大学英语四级", ExamDate = new DateOnly(2026, 6, 1), MetricKind = OtherExamMetricKind.Score, MaxScore = 710 };
|
||||||
|
db.AddRange(college, major, administrativeClass, student, batch);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new OtherExamsController(db, new TestDataScope(college.Id));
|
||||||
|
var result = await controller.ReplaceResults(batch.Id, new ReplaceOtherExamResultsRequest([
|
||||||
|
new OtherExamResultRequest(student.StudentNumber, 610, null, null, new string('注', 501))
|
||||||
|
]), CancellationToken.None);
|
||||||
|
|
||||||
|
var validation = Assert.IsType<ObjectResult>(result);
|
||||||
|
Assert.Contains("备注不能超过 500 个字符", Assert.IsType<ValidationProblemDetails>(validation.Value).Detail);
|
||||||
|
Assert.Empty(await db.OtherExamResults.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
collegeId,
|
||||||
|
DataScope.College,
|
||||||
|
new HashSet<string>([SystemRoles.CollegeAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,5 +160,17 @@ public sealed class TimetableExcelExporterTests
|
|||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
sheet.CellsUsed(),
|
sheet.CellsUsed(),
|
||||||
cell => cell.GetString().Contains("非排时课程"));
|
cell => cell.GetString().Contains("非排时课程"));
|
||||||
|
|
||||||
|
var batchBytes = TimetableExcelExporter.Create([timetable, timetable]);
|
||||||
|
using var batchStream = new MemoryStream(batchBytes);
|
||||||
|
using var batchWorkbook = new XLWorkbook(batchStream);
|
||||||
|
Assert.Equal(2, batchWorkbook.Worksheets.Count);
|
||||||
|
Assert.All(batchWorkbook.Worksheets, worksheet =>
|
||||||
|
Assert.Contains("计算机科学 2601 班", worksheet.Cell("A1").GetString()));
|
||||||
|
|
||||||
|
var pdf = TimetablePdfExporter.Create([timetable, timetable]);
|
||||||
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||||
|
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), pdf);
|
||||||
|
Assert.True(pdf.Length > 1_000, "生成的 PDF 不应为空。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<JiaowuBackendVersion>2.3.2</JiaowuBackendVersion>
|
<JiaowuBackendVersion>2.4.0</JiaowuBackendVersion>
|
||||||
<JiaowuFrontendVersion>2.3.2</JiaowuFrontendVersion>
|
<JiaowuFrontendVersion>2.4.0</JiaowuFrontendVersion>
|
||||||
<JiaowuSwaggerVersion>2.3.2</JiaowuSwaggerVersion>
|
<JiaowuSwaggerVersion>2.4.0</JiaowuSwaggerVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Generated
+9
-9
@@ -19,7 +19,7 @@
|
|||||||
"ckeditor5": "^48.3.1",
|
"ckeditor5": "^48.3.1",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "2.14.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
@@ -2936,14 +2936,14 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/element-plus": {
|
"node_modules/element-plus": {
|
||||||
"version": "2.14.4",
|
"version": "2.14.3",
|
||||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.4.tgz",
|
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
|
||||||
"integrity": "sha512-vMKR9tFcLeNrJgFXA3zhUn6YuRKUQW9d0btakBR8U1Iq8MfzkjMOGcgs5de2VgiLldHt69brmuBHpxc3bK4ZgQ==",
|
"integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ctrl/tinycolor": "^4.2.0",
|
"@ctrl/tinycolor": "^4.2.0",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@floating-ui/dom": "^1.8.0",
|
"@floating-ui/dom": "^1.7.6",
|
||||||
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
|
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
@@ -2955,7 +2955,7 @@
|
|||||||
"lodash-unified": "^1.0.3",
|
"lodash-unified": "^1.0.3",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"normalize-wheel-es": "^1.2.0",
|
"normalize-wheel-es": "^1.2.0",
|
||||||
"vue-component-type-helpers": "^3.3.9"
|
"vue-component-type-helpers": "^3.3.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.3.7"
|
"vue": "^3.3.7"
|
||||||
@@ -4188,9 +4188,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.18.1",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
|
|||||||
+4
-1
@@ -28,7 +28,7 @@
|
|||||||
"ckeditor5": "^48.3.1",
|
"ckeditor5": "^48.3.1",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "2.14.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
@@ -48,5 +48,8 @@
|
|||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.1.1",
|
"vite": "^8.1.1",
|
||||||
"vue-tsc": "^3.3.5"
|
"vue-tsc": "^3.3.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"lodash-es": "4.17.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ export async function downloadApiFile(
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadApiPostFile(
|
||||||
|
path: string,
|
||||||
|
payload: unknown,
|
||||||
|
fallbackName: string,
|
||||||
|
) {
|
||||||
|
const response = await http.post(path, payload, { responseType: 'blob' })
|
||||||
|
const url = URL.createObjectURL(response.data)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = responseFileName(response.headers['content-disposition'], fallbackName)
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
export async function importExcel(path: string, file: File) {
|
export async function importExcel(path: string, file: File) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
|
|||||||
Vendored
+1
@@ -54,6 +54,7 @@ declare module 'vue' {
|
|||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
|
NotificationRichTextEditor: typeof import('./components/NotificationRichTextEditor.vue')['default']
|
||||||
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
|
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
|
||||||
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
|
||||||
|
import {
|
||||||
|
AutoLink,
|
||||||
|
BlockQuote,
|
||||||
|
Bold,
|
||||||
|
ClassicEditor,
|
||||||
|
Essentials,
|
||||||
|
Heading,
|
||||||
|
Image,
|
||||||
|
ImageCaption,
|
||||||
|
ImageInsert,
|
||||||
|
ImageInsertViaUrl,
|
||||||
|
ImageResize,
|
||||||
|
ImageStyle,
|
||||||
|
ImageToolbar,
|
||||||
|
Italic,
|
||||||
|
Link,
|
||||||
|
LinkImage,
|
||||||
|
List,
|
||||||
|
Paragraph,
|
||||||
|
Table,
|
||||||
|
TableCaption,
|
||||||
|
TableCellProperties,
|
||||||
|
TableColumnResize,
|
||||||
|
TableProperties,
|
||||||
|
TableToolbar,
|
||||||
|
type EditorConfig,
|
||||||
|
} from 'ckeditor5'
|
||||||
|
import 'ckeditor5/ckeditor5.css'
|
||||||
|
|
||||||
|
const model = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
const editorConfig: EditorConfig = {
|
||||||
|
licenseKey: import.meta.env.VITE_CKEDITOR_LICENSE_KEY || 'GPL',
|
||||||
|
plugins: [
|
||||||
|
Essentials, Paragraph, Heading, Bold, Italic, Link, AutoLink, List, BlockQuote,
|
||||||
|
Image, ImageCaption, ImageInsert, ImageInsertViaUrl, ImageResize, ImageStyle,
|
||||||
|
ImageToolbar, LinkImage, Table, TableCaption, TableCellProperties,
|
||||||
|
TableColumnResize, TableProperties, TableToolbar,
|
||||||
|
],
|
||||||
|
toolbar: {
|
||||||
|
items: [
|
||||||
|
'heading', '|', 'bold', 'italic', 'link', '|', 'insertTable', 'insertImage',
|
||||||
|
'|', 'bulletedList', 'numberedList', 'blockQuote', '|', 'undo', 'redo',
|
||||||
|
],
|
||||||
|
shouldNotGroupWhenFull: false,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
options: [
|
||||||
|
{ model: 'paragraph', title: '正文', class: 'ck-heading_paragraph' },
|
||||||
|
{ model: 'heading2', view: 'h2', title: '标题', class: 'ck-heading_heading2' },
|
||||||
|
{ model: 'heading3', view: 'h3', title: '小标题', class: 'ck-heading_heading3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
link: { addTargetToExternalLinks: true, defaultProtocol: 'https://' },
|
||||||
|
image: {
|
||||||
|
insert: { integrations: ['insertImageViaUrl'] },
|
||||||
|
toolbar: [
|
||||||
|
'toggleImageCaption', 'imageTextAlternative', '|', 'imageStyle:inline',
|
||||||
|
'imageStyle:wrapText', 'imageStyle:breakText', '|', 'resizeImage', 'linkImage',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
contentToolbar: [
|
||||||
|
'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption',
|
||||||
|
'tableProperties', 'tableCellProperties',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Ckeditor
|
||||||
|
v-model="model"
|
||||||
|
:editor="ClassicEditor"
|
||||||
|
:config="editorConfig"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -2,31 +2,29 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import http from '../api/http'
|
import http from '../api/http'
|
||||||
|
|
||||||
interface SystemVersionResponse {
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendVersion = ref('获取中')
|
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const frontendVersion = __APP_VERSION__
|
const frontendVersion = __APP_VERSION__
|
||||||
|
const backendVersion = ref<string>()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get<SystemVersionResponse>('/system/version')
|
const { data } = await http.get<{ version?: string }>('/system/version')
|
||||||
backendVersion.value = data.version ? `v${data.version}` : '未知'
|
backendVersion.value = data.version?.trim() || undefined
|
||||||
} catch {
|
} catch {
|
||||||
backendVersion.value = '未知'
|
// 页脚版本信息不应影响业务页面,也不应为暂时的网络异常额外弹出提示。
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<footer class="site-footer" aria-label="网站版权与版本信息">
|
<footer class="site-footer" aria-label="网站版权信息">
|
||||||
<div class="site-footer-inner">
|
<div class="site-footer-inner">
|
||||||
<p>© {{ currentYear }} 明序教务 · 版权所有</p>
|
<p>© {{ currentYear }} 明序教务 · 版权所有</p>
|
||||||
<div class="version-list" aria-label="系统版本">
|
<div class="footer-meta">
|
||||||
<span>前端版本 <b>v{{ frontendVersion }}</b></span>
|
<span class="footer-note">教务管理与学业服务平台</span>
|
||||||
<span>后端版本 <b>{{ backendVersion }}</b></span>
|
<span class="footer-versions" aria-live="polite">
|
||||||
|
前端 v{{ frontendVersion }} · 后端 v{{ backendVersion ?? '—' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -61,27 +59,9 @@ onMounted(async () => {
|
|||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-list {
|
.footer-meta { display: flex; align-items: center; gap: 14px; }
|
||||||
display: flex;
|
.footer-note { color: #aeb9d8; font-size: 11px; }
|
||||||
align-items: center;
|
.footer-versions { color: #c3cce5; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list span {
|
|
||||||
padding: 5px 9px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, .14);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #aeb9d8;
|
|
||||||
background: rgba(255, 255, 255, .055);
|
|
||||||
font-size: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list b {
|
|
||||||
margin-left: 5px;
|
|
||||||
color: #66d5c5;
|
|
||||||
font: 700 10px/1.2 Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.site-footer-inner {
|
.site-footer-inner {
|
||||||
@@ -93,13 +73,7 @@ onMounted(async () => {
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-list {
|
.footer-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||||
width: 100%;
|
.footer-note, .footer-versions { font-size: 10px; }
|
||||||
}
|
|
||||||
|
|
||||||
.version-list span {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="smart-launch-mark" aria-hidden="true">
|
<div class="smart-launch-mark" aria-hidden="true">
|
||||||
<i v-for="index in 9" :key="index" />
|
<i v-for="index in 9" :key="index" />
|
||||||
</div>
|
</div>
|
||||||
<span class="smart-launch-brand">MINGXU ACADEMIC</span>
|
<span class="smart-launch-brand">明序教务</span>
|
||||||
<p>{{ greeting.label }}</p>
|
<p>{{ greeting.label }}</p>
|
||||||
<h1>{{ greeting.title }}</h1>
|
<h1>{{ greeting.title }}</h1>
|
||||||
<small>{{ greeting.subtitle }}</small>
|
<small>{{ greeting.subtitle }}</small>
|
||||||
@@ -117,7 +117,7 @@ onBeforeUnmount(() => {
|
|||||||
color: #8fe4dc;
|
color: #8fe4dc;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.16em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.smart-launch-content p {
|
.smart-launch-content p {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
),
|
),
|
||||||
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
||||||
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
||||||
|
...whenVisible(isTimetableManager.value, { path: '/venue-displays', label: '场地信息展牌' }),
|
||||||
{
|
{
|
||||||
path: '/class-timetable',
|
path: '/class-timetable',
|
||||||
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
|
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
|
||||||
@@ -194,6 +195,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
|
||||||
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
|
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
|
||||||
),
|
),
|
||||||
|
...whenVisible(
|
||||||
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader']),
|
||||||
|
{ path: '/event-analytics', label: '运行数据分析' },
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -325,7 +330,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>明序教务</strong>
|
<strong>明序教务</strong>
|
||||||
<small>ACADEMIC OFFICE</small>
|
<small>教务管理与学业服务</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,7 +395,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>明序教务</strong>
|
<strong>明序教务</strong>
|
||||||
<small>ACADEMIC OFFICE</small>
|
<small>教务管理与学业服务</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="关闭导航菜单" @click="mobileMenu = false">×</button>
|
<button type="button" aria-label="关闭导航菜单" @click="mobileMenu = false">×</button>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
// 命令式调用不会经过组件自动按需引入,需显式保留其样式,避免提示退化为页面普通文本。
|
||||||
|
import 'element-plus/es/components/message/style/css'
|
||||||
|
import 'element-plus/es/components/message-box/style/css'
|
||||||
|
import 'element-plus/es/components/notification/style/css'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/TimetableView.vue'),
|
component: () => import('../views/TimetableView.vue'),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/venue-display/:type(classroom|building)/:id',
|
||||||
|
name: 'venue-display',
|
||||||
|
component: () => import('../views/VenueDisplayView.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/activate',
|
path: '/activate',
|
||||||
name: 'activate-account',
|
name: 'activate-account',
|
||||||
@@ -210,6 +216,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/FreeClassroomsView.vue'),
|
component: () => import('../views/FreeClassroomsView.vue'),
|
||||||
meta: { roles: ['Student'] },
|
meta: { roles: ['Student'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'venue-displays',
|
||||||
|
name: 'venue-displays',
|
||||||
|
component: () => import('../views/VenueDisplaysView.vue'),
|
||||||
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'classroom-reservations',
|
path: 'classroom-reservations',
|
||||||
name: 'classroom-reservations',
|
name: 'classroom-reservations',
|
||||||
@@ -263,6 +275,14 @@ const router = createRouter({
|
|||||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'event-analytics',
|
||||||
|
name: 'event-analytics',
|
||||||
|
component: () => import('../views/EventAnalyticsView.vue'),
|
||||||
|
meta: {
|
||||||
|
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'other-exams',
|
path: 'other-exams',
|
||||||
name: 'other-exams',
|
name: 'other-exams',
|
||||||
|
|||||||
+136
-2
@@ -42,7 +42,7 @@ button { cursor: pointer; }
|
|||||||
}
|
}
|
||||||
.top-brand { min-width: 224px; padding-right: 28px; border-right: 1px solid rgba(255,255,255,.14); }
|
.top-brand { min-width: 224px; padding-right: 28px; border-right: 1px solid rgba(255,255,255,.14); }
|
||||||
.brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; letter-spacing: .12em; color: white; }
|
.brand strong { display: block; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; letter-spacing: .12em; color: white; }
|
||||||
.brand small { display: block; margin-top: 3px; font: 9px/1.2 Consolas, monospace; letter-spacing: .16em; color: #9faad1; }
|
.brand small { display: block; margin-top: 3px; font: 10px/1.2 "Microsoft YaHei", sans-serif; letter-spacing: .08em; color: #9faad1; }
|
||||||
.brand-mark { flex: 0 0 auto; width: 31px; height: 31px; display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; }
|
.brand-mark { flex: 0 0 auto; width: 31px; height: 31px; display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; }
|
||||||
.brand-mark span { border: 1px solid #9eabd8; }
|
.brand-mark span { border: 1px solid #9eabd8; }
|
||||||
.brand-mark span:nth-child(2), .brand-mark span:nth-child(5), .brand-mark span:nth-child(8) { background: #3ec1ae; border-color: #3ec1ae; }
|
.brand-mark span:nth-child(2), .brand-mark span:nth-child(5), .brand-mark span:nth-child(8) { background: #3ec1ae; border-color: #3ec1ae; }
|
||||||
@@ -122,7 +122,7 @@ button { cursor: pointer; }
|
|||||||
.content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; }
|
.content { padding: 28px 32px 48px; max-width: 1540px; margin: 0 auto; }
|
||||||
.mobile-only { display: none; }
|
.mobile-only { display: none; }
|
||||||
|
|
||||||
.section-kicker { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; letter-spacing: .16em; }
|
.section-kicker { color: var(--teal); font: 700 11px/1.2 "Microsoft YaHei", sans-serif; letter-spacing: .1em; }
|
||||||
.term-hero { min-height: 178px; padding: 32px 36px; display: flex; align-items: end; color: white; background: linear-gradient(118deg, #233876, #192d67 65%, #116d70); position: relative; overflow: hidden; }
|
.term-hero { min-height: 178px; padding: 32px 36px; display: flex; align-items: end; color: white; background: linear-gradient(118deg, #233876, #192d67 65%, #116d70); position: relative; overflow: hidden; }
|
||||||
.term-hero::after { content: ""; position: absolute; right: -40px; top: -110px; width: 360px; height: 360px; border: 54px solid rgba(255,255,255,.055); border-radius: 50%; }
|
.term-hero::after { content: ""; position: absolute; right: -40px; top: -110px; width: 360px; height: 360px; border: 54px solid rgba(255,255,255,.055); border-radius: 50%; }
|
||||||
.term-hero h2 { margin: 12px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(25px, 3vw, 38px); letter-spacing: .05em; }
|
.term-hero h2 { margin: 12px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: clamp(25px, 3vw, 38px); letter-spacing: .05em; }
|
||||||
@@ -1496,3 +1496,137 @@ button { cursor: pointer; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
|
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared workspace surface: keeps independently-built modules visually aligned. */
|
||||||
|
:root {
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-muted: #f8fafc;
|
||||||
|
--surface-accent: #f0f7f6;
|
||||||
|
--shadow-card: 0 8px 24px rgba(26, 47, 80, .055);
|
||||||
|
--el-border-radius-base: 8px;
|
||||||
|
--el-border-radius-small: 6px;
|
||||||
|
--el-fill-color-blank: #ffffff;
|
||||||
|
--el-bg-color-page: #f4f6fa;
|
||||||
|
--el-border-color: #dfe5ee;
|
||||||
|
--el-text-color-primary: #182033;
|
||||||
|
--el-text-color-regular: #4f5b6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(35, 56, 118, .035), transparent 20rem),
|
||||||
|
#f4f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { color: #fff; background: var(--indigo); }
|
||||||
|
|
||||||
|
.content { max-width: 1480px; }
|
||||||
|
.page-stack { gap: 20px; }
|
||||||
|
|
||||||
|
.page-intro,
|
||||||
|
.page-heading {
|
||||||
|
position: relative;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 20px 0 16px 18px;
|
||||||
|
border-bottom: 1px solid #dfe5ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro::before,
|
||||||
|
.page-heading::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 23px;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
content: '';
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(180deg, var(--teal), #55b9aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro h2,
|
||||||
|
.page-heading h2 {
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro p,
|
||||||
|
.page-heading > div > p {
|
||||||
|
max-width: 720px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card,
|
||||||
|
.page-stack > .el-card,
|
||||||
|
.page-stack .el-card,
|
||||||
|
.application-card,
|
||||||
|
.result-board,
|
||||||
|
.batch-panel,
|
||||||
|
.editor-panel {
|
||||||
|
border-color: #dfe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card:hover,
|
||||||
|
.page-stack > .el-card:hover,
|
||||||
|
.application-card:hover,
|
||||||
|
.result-board:hover,
|
||||||
|
.batch-panel:hover,
|
||||||
|
.editor-panel:hover {
|
||||||
|
border-color: #cbd8e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar,
|
||||||
|
.filter-bar {
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-color: #e1e7ef;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar > span,
|
||||||
|
.filter-bar > span,
|
||||||
|
.pagination-bar > span { color: #768296; }
|
||||||
|
|
||||||
|
.el-button { font-weight: 600; letter-spacing: .01em; }
|
||||||
|
.el-button--primary { box-shadow: 0 4px 10px rgba(35, 56, 118, .14); }
|
||||||
|
.el-button--primary:hover { box-shadow: 0 6px 14px rgba(35, 56, 118, .2); }
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-textarea__inner,
|
||||||
|
.el-select__wrapper,
|
||||||
|
.el-date-editor.el-input__wrapper,
|
||||||
|
.el-date-editor.el-range-editor {
|
||||||
|
box-shadow: 0 0 0 1px #d7dfe9 inset;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.el-input__wrapper:hover,
|
||||||
|
.el-select__wrapper:hover,
|
||||||
|
.el-date-editor.el-input__wrapper:hover,
|
||||||
|
.el-date-editor.el-range-editor:hover { box-shadow: 0 0 0 1px #afbfce inset; }
|
||||||
|
|
||||||
|
.el-card__header { padding: 18px 20px; border-bottom-color: #e5eaf1; }
|
||||||
|
.el-card__body { padding: 20px; }
|
||||||
|
.el-table { --el-table-border-color: #e4e9f0; --el-table-header-bg-color: #f7f9fc; }
|
||||||
|
.el-table th.el-table__cell { height: 46px; color: #536074; background: #f7f9fc; }
|
||||||
|
.el-table td.el-table__cell { padding: 12px 0; }
|
||||||
|
.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell { background: #f3f8f8; }
|
||||||
|
.el-pagination { padding: 14px 0 2px; justify-content: flex-end; }
|
||||||
|
.el-dialog { overflow: hidden; border-radius: 12px; box-shadow: 0 20px 60px rgba(18, 37, 63, .25); }
|
||||||
|
.el-dialog__header { margin-right: 0; padding: 20px 22px 16px; border-bottom: 1px solid #e6ebf1; }
|
||||||
|
.el-dialog__body { padding: 22px; }
|
||||||
|
.el-dialog__footer { padding: 14px 22px 20px; border-top: 1px solid #edf0f4; }
|
||||||
|
.el-tag { font-weight: 600; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page-intro,
|
||||||
|
.page-heading { min-height: 0; padding: 12px 0 14px 14px; }
|
||||||
|
.page-intro::before,
|
||||||
|
.page-heading::before { top: 14px; bottom: 16px; }
|
||||||
|
.page-intro h2,
|
||||||
|
.page-heading h2 { font-size: 23px; }
|
||||||
|
.table-toolbar,
|
||||||
|
.filter-bar { min-height: 0; padding: 12px 14px; }
|
||||||
|
.el-card__body { padding: 16px; }
|
||||||
|
.el-dialog__header { padding: 18px 18px 14px; }
|
||||||
|
.el-dialog__body { padding: 18px; }
|
||||||
|
.el-dialog__footer { padding: 12px 18px 18px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
|
|||||||
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
|
const isStudent = computed(() => auth.user?.roles.includes('Student') && !isManager.value)
|
||||||
|
|
||||||
const pending = ref<any[]>([])
|
const pending = ref<any[]>([])
|
||||||
|
const pendingPage = ref(1)
|
||||||
|
const pendingTotal = ref(0)
|
||||||
|
const pendingPageSize = 20
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const tab = ref(isManager.value ? 'pending' : 'mine')
|
const tab = ref(isManager.value ? 'pending' : 'mine')
|
||||||
@@ -168,7 +171,17 @@ const recordCount = computed(() =>
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data
|
if (isManager.value) {
|
||||||
|
const { data } = await http.get('/approvals/pending', {
|
||||||
|
params: { page: pendingPage.value, pageSize: pendingPageSize },
|
||||||
|
})
|
||||||
|
pending.value = data.items
|
||||||
|
pendingTotal.value = data.total
|
||||||
|
if (!pending.value.length && pendingPage.value > 1) {
|
||||||
|
pendingPage.value--
|
||||||
|
return await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isStudent.value) {
|
if (isStudent.value) {
|
||||||
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
||||||
http.get('/approvals/my-courses'),
|
http.get('/approvals/my-courses'),
|
||||||
@@ -358,7 +371,7 @@ onMounted(load)
|
|||||||
<el-segmented
|
<el-segmented
|
||||||
v-model="tab"
|
v-model="tab"
|
||||||
:options="[
|
:options="[
|
||||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
...(isManager ? [{ label: `待审批 (${pendingTotal})`, value: 'pending' }] : []),
|
||||||
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -389,6 +402,15 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!pending.length" description="暂无待审批" />
|
<el-empty v-if="!pending.length" description="暂无待审批" />
|
||||||
|
<el-pagination
|
||||||
|
v-if="pendingTotal > pendingPageSize"
|
||||||
|
v-model:current-page="pendingPage"
|
||||||
|
class="approval-pagination"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:page-size="pendingPageSize"
|
||||||
|
:total="pendingTotal"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="tab === 'mine'" v-loading="loading">
|
<section v-if="tab === 'mine'" v-loading="loading">
|
||||||
|
|||||||
+102
-12
@@ -77,6 +77,16 @@ const references = reactive<Record<string, any[]>>({
|
|||||||
campuses: [], colleges: [], majors: [], buildings: [], counselors: [],
|
campuses: [], colleges: [], majors: [], buildings: [], counselors: [],
|
||||||
})
|
})
|
||||||
const form = reactive<Record<string, any>>({})
|
const form = reactive<Record<string, any>>({})
|
||||||
|
const listFilters = reactive({
|
||||||
|
collegeId: undefined as string | undefined,
|
||||||
|
majorId: undefined as string | undefined,
|
||||||
|
grade: undefined as number | undefined,
|
||||||
|
campusId: undefined as string | undefined,
|
||||||
|
buildingId: undefined as string | undefined,
|
||||||
|
minimumCapacity: undefined as number | undefined,
|
||||||
|
teachingVenueNature: undefined as number | undefined,
|
||||||
|
isEnabled: undefined as boolean | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const title = computed(() => tabs.value.find((x) => x.key === active.value)?.label ?? '')
|
const title = computed(() => tabs.value.find((x) => x.key === active.value)?.label ?? '')
|
||||||
const canManage = computed(() =>
|
const canManage = computed(() =>
|
||||||
@@ -90,12 +100,22 @@ const filteredRows = computed(() => {
|
|||||||
.filter(Boolean).some((value) => String(value).toLowerCase().includes(q)),
|
.filter(Boolean).some((value) => String(value).toLowerCase().includes(q)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
const filteredMajors = computed(() => references.majors.filter((item: any) =>
|
||||||
|
!listFilters.collegeId || item.collegeId === listFilters.collegeId))
|
||||||
|
const filteredBuildings = computed(() => references.buildings.filter((item: any) =>
|
||||||
|
!listFilters.campusId || item.campusId === listFilters.campusId))
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const pagedRows = computed(() => filteredRows.value.slice(
|
const total = ref(0)
|
||||||
(currentPage.value - 1) * pageSize.value,
|
const serverPaged = computed(() =>
|
||||||
currentPage.value * pageSize.value,
|
active.value === 'classes' || active.value === 'classrooms')
|
||||||
))
|
let keywordTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const pagedRows = computed(() => serverPaged.value
|
||||||
|
? filteredRows.value
|
||||||
|
: filteredRows.value.slice(
|
||||||
|
(currentPage.value - 1) * pageSize.value,
|
||||||
|
currentPage.value * pageSize.value,
|
||||||
|
))
|
||||||
const venueNatureOptions = [
|
const venueNatureOptions = [
|
||||||
{ value: 1, label: '普通教室' },
|
{ value: 1, label: '普通教室' },
|
||||||
{ value: 2, label: '实验室' },
|
{ value: 2, label: '实验室' },
|
||||||
@@ -138,11 +158,42 @@ function resetForm(row?: Row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load(resetPage = true) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
currentPage.value = 1
|
if (resetPage) currentPage.value = 1
|
||||||
try {
|
try {
|
||||||
rows.value = (await http.get(`/base-data/${active.value}`)).data
|
const response = await http.get(`/base-data/${active.value}`, {
|
||||||
|
params: serverPaged.value
|
||||||
|
? {
|
||||||
|
page: currentPage.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
...(active.value === 'classes' ? {
|
||||||
|
collegeId: listFilters.collegeId,
|
||||||
|
majorId: listFilters.majorId,
|
||||||
|
grade: listFilters.grade,
|
||||||
|
isEnabled: listFilters.isEnabled,
|
||||||
|
} : {
|
||||||
|
campusId: listFilters.campusId,
|
||||||
|
buildingId: listFilters.buildingId,
|
||||||
|
minimumCapacity: listFilters.minimumCapacity,
|
||||||
|
teachingVenueNature: listFilters.teachingVenueNature,
|
||||||
|
isEnabled: listFilters.isEnabled,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
if (serverPaged.value) {
|
||||||
|
rows.value = response.data.items
|
||||||
|
total.value = response.data.total
|
||||||
|
if (rows.value.length === 0 && currentPage.value > 1) {
|
||||||
|
currentPage.value--
|
||||||
|
await load(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows.value = response.data
|
||||||
|
total.value = rows.value.length
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -162,6 +213,11 @@ async function loadReferences() {
|
|||||||
|
|
||||||
async function changeTab() {
|
async function changeTab() {
|
||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
|
Object.assign(listFilters, {
|
||||||
|
collegeId: undefined, majorId: undefined, grade: undefined,
|
||||||
|
campusId: undefined, buildingId: undefined, minimumCapacity: undefined,
|
||||||
|
teachingVenueNature: undefined, isEnabled: undefined,
|
||||||
|
})
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +364,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => keyword.value,
|
() => keyword.value,
|
||||||
() => { currentPage.value = 1 },
|
() => {
|
||||||
|
currentPage.value = 1
|
||||||
|
if (keywordTimer) clearTimeout(keywordTimer)
|
||||||
|
if (serverPaged.value) {
|
||||||
|
keywordTimer = setTimeout(() => { void load(false) }, 250)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -357,7 +419,33 @@ watch(
|
|||||||
|
|
||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" />
|
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" />
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<template v-if="active === 'classes'">
|
||||||
|
<el-select v-model="listFilters.collegeId" clearable placeholder="全部学院" @change="() => { listFilters.majorId = undefined; load() }">
|
||||||
|
<el-option v-for="item in references.colleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="listFilters.majorId" clearable filterable placeholder="全部专业" @change="load()">
|
||||||
|
<el-option v-for="item in filteredMajors" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="listFilters.grade" :min="1900" :max="2200" controls-position="right" placeholder="入学年级" @change="load()" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="active === 'classrooms'">
|
||||||
|
<el-select v-model="listFilters.campusId" clearable placeholder="全部校区" @change="() => { listFilters.buildingId = undefined; load() }">
|
||||||
|
<el-option v-for="item in references.campuses" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="listFilters.buildingId" clearable filterable placeholder="全部教学楼" @change="load()">
|
||||||
|
<el-option v-for="item in filteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="listFilters.teachingVenueNature" clearable placeholder="场地性质" @change="load()">
|
||||||
|
<el-option v-for="item in venueNatureOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="listFilters.minimumCapacity" :min="1" controls-position="right" placeholder="最小容量" @change="load()" />
|
||||||
|
</template>
|
||||||
|
<el-select v-if="serverPaged" v-model="listFilters.isEnabled" clearable placeholder="全部状态" @change="load()">
|
||||||
|
<el-option label="启用" :value="true" />
|
||||||
|
<el-option label="停用" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Search" @click="load()">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="() => load(false)">刷新</el-button>
|
||||||
<template v-if="canManage">
|
<template v-if="canManage">
|
||||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||||
@@ -370,7 +458,7 @@ watch(
|
|||||||
@change="handleImport"
|
@change="handleImport"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<span>共 {{ filteredRows.length }} 条</span>
|
<span>共 {{ serverPaged ? total : filteredRows.length }} 条</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -494,13 +582,15 @@ watch(
|
|||||||
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
|
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-if="filteredRows.length > pageSize"
|
v-if="(serverPaged ? total : filteredRows.length) > pageSize"
|
||||||
v-model:current-page="currentPage"
|
v-model:current-page="currentPage"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
class="table-pagination"
|
class="table-pagination"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
:page-sizes="[20, 50, 100]"
|
:page-sizes="[20, 50, 100]"
|
||||||
:total="filteredRows.length"
|
:total="serverPaged ? total : filteredRows.length"
|
||||||
|
@current-change="() => { if (serverPaged) void load(false) }"
|
||||||
|
@size-change="() => { if (serverPaged) void load(true) }"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ onMounted(async () => {
|
|||||||
<section class="reservation-page">
|
<section class="reservation-page">
|
||||||
<header class="page-heading">
|
<header class="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">TEACHING SPACE</p>
|
<p class="eyebrow">教学场地</p>
|
||||||
<h2>教室借用与预约</h2>
|
<h2>教室借用与预约</h2>
|
||||||
<p>选择正式课表之外的空闲时段提交申请,由申请人所在学院审核。</p>
|
<p>选择正式课表之外的空闲时段提交申请,由申请人所在学院审核。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const sessionTask = ref<any>(null)
|
|||||||
const sessionLoading = ref(false)
|
const sessionLoading = ref(false)
|
||||||
const termId = ref<string>()
|
const termId = ref<string>()
|
||||||
const tab = ref(isManager.value ? 'reviews' : 'mine')
|
const tab = ref(isManager.value ? 'reviews' : 'mine')
|
||||||
|
const minePage = ref(1)
|
||||||
|
const mineTotal = ref(0)
|
||||||
|
const reviewPage = ref(1)
|
||||||
|
const reviewTotal = ref(0)
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
teachingTaskId: undefined as string | undefined,
|
teachingTaskId: undefined as string | undefined,
|
||||||
@@ -79,21 +84,30 @@ function sessionLabel(session: any) {
|
|||||||
return `第 ${session.week} 周 · ${session.date} 周${weekdayLabel(session.dayOfWeek)} · 第 ${session.startPeriod}–${end} 节${room}`
|
return `第 ${session.week} 周 · ${session.date} 周${weekdayLabel(session.dayOfWeek)} · 第 ${session.startPeriod}–${end} 节${room}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load(resetPages = false) {
|
||||||
|
if (resetPages) {
|
||||||
|
minePage.value = 1
|
||||||
|
reviewPage.value = 1
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
reviewLoading.value = isManager.value
|
||||||
try {
|
try {
|
||||||
if (isTeacher.value || (!isManager.value)) {
|
if (isTeacher.value || (!isManager.value)) {
|
||||||
adjustments.value = (await http.get('/course-adjustments/mine', {
|
const response = await http.get('/course-adjustments/mine', {
|
||||||
params: { academicTermId: termId.value },
|
params: { academicTermId: termId.value, page: minePage.value, pageSize },
|
||||||
})).data
|
})
|
||||||
|
adjustments.value = response.data.items
|
||||||
|
mineTotal.value = response.data.total
|
||||||
}
|
}
|
||||||
if (isManager.value) {
|
if (isManager.value) {
|
||||||
pendingReviews.value = (await http.get('/course-adjustments/pending-reviews', {
|
const response = await http.get('/course-adjustments/pending-reviews', {
|
||||||
params: { academicTermId: termId.value },
|
params: { academicTermId: termId.value, page: reviewPage.value, pageSize },
|
||||||
})).data
|
})
|
||||||
|
pendingReviews.value = response.data.items
|
||||||
|
reviewTotal.value = response.data.total
|
||||||
}
|
}
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false; reviewLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -255,12 +269,12 @@ function showSub(type: string) { return type === 'Substitute' }
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
|
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="adj-toolbar">
|
<section class="adj-toolbar">
|
||||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load(); loadTaskOptions()">
|
<el-select v-model="termId" clearable placeholder="全部学期" @change="() => { load(true); loadTaskOptions() }">
|
||||||
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
|
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-segmented v-model="tab" :options="[
|
<el-segmented v-model="tab" :options="[
|
||||||
@@ -307,6 +321,15 @@ function showSub(type: string) { return type === 'Substitute' }
|
|||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!adjustments.length" description="暂无调停课申请" />
|
<el-empty v-if="!adjustments.length" description="暂无调停课申请" />
|
||||||
|
<el-pagination
|
||||||
|
v-if="mineTotal > pageSize"
|
||||||
|
v-model:current-page="minePage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="mineTotal"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="table-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Pending reviews -->
|
<!-- Pending reviews -->
|
||||||
@@ -345,6 +368,15 @@ function showSub(type: string) { return type === 'Substitute' }
|
|||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
|
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
|
||||||
|
<el-pagination
|
||||||
|
v-if="reviewTotal > pageSize"
|
||||||
|
v-model:current-page="reviewPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="reviewTotal"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="table-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Create dialog -->
|
<!-- Create dialog -->
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts/core'
|
||||||
|
import { BarChart, LineChart } from 'echarts/charts'
|
||||||
|
import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
AriaComponent,
|
||||||
|
BarChart,
|
||||||
|
CanvasRenderer,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
LineChart,
|
||||||
|
TooltipComponent,
|
||||||
|
])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -24,6 +37,14 @@ const rows = computed(() => [
|
|||||||
|
|
||||||
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
|
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
|
||||||
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
|
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
|
||||||
|
const recommendation = computed(() => {
|
||||||
|
const value = selectedRow.value?.value
|
||||||
|
if (!value) return ''
|
||||||
|
if (Number(value.passRate) < 70) return `合格率为 ${score(value.passRate)}%,建议优先复核低分段学生的平时与期末分项,并安排针对性答疑。`
|
||||||
|
if (Number(value.averageScore) < 70) return `平均分为 ${score(value.averageScore)},建议检查易失分知识点与教学进度,结合分数段安排补强。`
|
||||||
|
if (Number(value.standardDeviation) > 20) return `成绩离散度较高,建议关注不同教学班或学生群体的学习差异,核对评价标准与教学支持。`
|
||||||
|
return `平均分 ${score(value.averageScore)}、合格率 ${score(value.passRate)}%,当前表现稳定;可重点关注低分段学生的持续跟进。`
|
||||||
|
})
|
||||||
|
|
||||||
function score(value: unknown) {
|
function score(value: unknown) {
|
||||||
return Number(value).toFixed(1)
|
return Number(value).toFixed(1)
|
||||||
@@ -210,6 +231,7 @@ onBeforeUnmount(() => {
|
|||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
<section v-if="recommendation" class="action-advice"><span>ACTION ADVICE</span><p>{{ recommendation }}</p></section>
|
||||||
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
|
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
|
||||||
|
|
||||||
<section v-if="rows.length" class="chart-panel">
|
<section v-if="rows.length" class="chart-panel">
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ const editingRoundId = ref('')
|
|||||||
const editingOfferingId = ref('')
|
const editingOfferingId = ref('')
|
||||||
const roster = ref<any | null>(null)
|
const roster = ref<any | null>(null)
|
||||||
const rosterLoading = ref(false)
|
const rosterLoading = ref(false)
|
||||||
|
const rosterKeyword = ref('')
|
||||||
|
const rosterGrade = ref<number | undefined>()
|
||||||
|
const rosterMajorId = ref<string | undefined>()
|
||||||
|
const rosterClassId = ref<string | undefined>()
|
||||||
|
const rosterStudentPage = ref(1)
|
||||||
|
const rosterWaitlistPage = ref(1)
|
||||||
|
const rosterPageSize = 20
|
||||||
const eligibleStudents = ref<any[]>([])
|
const eligibleStudents = ref<any[]>([])
|
||||||
const eligibleTotal = ref(0)
|
const eligibleTotal = ref(0)
|
||||||
const eligiblePage = ref(1)
|
const eligiblePage = ref(1)
|
||||||
@@ -82,6 +89,8 @@ const selectedOfferings = computed(() =>
|
|||||||
const previewOffering = computed(() =>
|
const previewOffering = computed(() =>
|
||||||
offerings.value.find((item) => item.id === previewOfferingId.value) ?? null,
|
offerings.value.find((item) => item.id === previewOfferingId.value) ?? null,
|
||||||
)
|
)
|
||||||
|
const rosterClasses = computed(() => (roster.value?.filterOptions?.classes ?? [])
|
||||||
|
.filter((item: any) => !rosterMajorId.value || item.majorId === rosterMajorId.value))
|
||||||
const selectableTasks = computed(() => {
|
const selectableTasks = computed(() => {
|
||||||
const usedTaskIds = new Set(
|
const usedTaskIds = new Set(
|
||||||
offerings.value
|
offerings.value
|
||||||
@@ -540,6 +549,12 @@ async function deleteOffering(offering: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showRoster(offering: any) {
|
async function showRoster(offering: any) {
|
||||||
|
rosterKeyword.value = ''
|
||||||
|
rosterGrade.value = undefined
|
||||||
|
rosterMajorId.value = undefined
|
||||||
|
rosterClassId.value = undefined
|
||||||
|
rosterStudentPage.value = 1
|
||||||
|
rosterWaitlistPage.value = 1
|
||||||
rosterDrawer.value = true
|
rosterDrawer.value = true
|
||||||
await loadRoster(offering.id)
|
await loadRoster(offering.id)
|
||||||
}
|
}
|
||||||
@@ -548,7 +563,18 @@ async function loadRoster(offeringId: string) {
|
|||||||
rosterLoading.value = true
|
rosterLoading.value = true
|
||||||
try {
|
try {
|
||||||
roster.value = (
|
roster.value = (
|
||||||
await http.get(`/course-selections/offerings/${offeringId}/roster`)
|
await http.get(`/course-selections/offerings/${offeringId}/roster`, {
|
||||||
|
params: {
|
||||||
|
keyword: rosterKeyword.value.trim() || undefined,
|
||||||
|
grade: rosterGrade.value,
|
||||||
|
majorId: rosterMajorId.value,
|
||||||
|
administrativeClassId: rosterClassId.value,
|
||||||
|
studentPage: rosterStudentPage.value,
|
||||||
|
studentPageSize: rosterPageSize,
|
||||||
|
waitlistPage: rosterWaitlistPage.value,
|
||||||
|
waitlistPageSize: rosterPageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
).data
|
).data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -557,6 +583,12 @@ async function loadRoster(offeringId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterRoster() {
|
||||||
|
rosterStudentPage.value = 1
|
||||||
|
rosterWaitlistPage.value = 1
|
||||||
|
if (roster.value) void loadRoster(roster.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function openProxyEnrollment() {
|
async function openProxyEnrollment() {
|
||||||
studentKeyword.value = ''
|
studentKeyword.value = ''
|
||||||
selectedStudentIds.value = []
|
selectedStudentIds.value = []
|
||||||
@@ -1346,6 +1378,25 @@ onMounted(async () => {
|
|||||||
:closable="false"
|
:closable="false"
|
||||||
title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。"
|
title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。"
|
||||||
/>
|
/>
|
||||||
|
<div class="roster-filter">
|
||||||
|
<el-input
|
||||||
|
v-model="rosterKeyword"
|
||||||
|
clearable
|
||||||
|
placeholder="按学号、姓名或行政班筛选"
|
||||||
|
@keyup.enter="filterRoster"
|
||||||
|
@clear="filterRoster"
|
||||||
|
/>
|
||||||
|
<el-select v-model="rosterGrade" clearable placeholder="全部年级" @change="filterRoster">
|
||||||
|
<el-option v-for="grade in roster.filterOptions?.grades" :key="grade" :label="`${grade} 级`" :value="grade" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="rosterMajorId" clearable placeholder="全部专业" @change="() => { rosterClassId = undefined; filterRoster() }">
|
||||||
|
<el-option v-for="major in roster.filterOptions?.majors" :key="major.majorId" :label="major.majorName" :value="major.majorId" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="rosterClassId" clearable placeholder="全部行政班" @change="filterRoster">
|
||||||
|
<el-option v-for="item in rosterClasses" :key="item.classId" :label="item.className" :value="item.classId" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Search" @click="filterRoster">查询</el-button>
|
||||||
|
</div>
|
||||||
<el-table v-loading="rosterLoading" :data="roster.students">
|
<el-table v-loading="rosterLoading" :data="roster.students">
|
||||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="90" />
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
@@ -1363,6 +1414,12 @@ onMounted(async () => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无学生选课" /></template>
|
<template #empty><el-empty description="暂无学生选课" /></template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="roster.studentTotal > rosterPageSize"
|
||||||
|
small background layout="total, prev, pager, next"
|
||||||
|
:current-page="rosterStudentPage" :page-size="rosterPageSize" :total="roster.studentTotal"
|
||||||
|
@current-change="(page: number) => { rosterStudentPage = page; loadRoster(roster.id) }"
|
||||||
|
/>
|
||||||
<section class="waitlist-panel">
|
<section class="waitlist-panel">
|
||||||
<div class="waitlist-panel-head">
|
<div class="waitlist-panel-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -1394,6 +1451,12 @@ onMounted(async () => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无候补学生" /></template>
|
<template #empty><el-empty description="暂无候补学生" /></template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="roster.waitlistTotal > rosterPageSize"
|
||||||
|
small background layout="total, prev, pager, next"
|
||||||
|
:current-page="rosterWaitlistPage" :page-size="rosterPageSize" :total="roster.waitlistTotal"
|
||||||
|
@current-change="(page: number) => { rosterWaitlistPage = page; loadRoster(roster.id) }"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@@ -1528,6 +1591,11 @@ onMounted(async () => {
|
|||||||
.offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); }
|
.offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); }
|
||||||
.seat-meter > small { display: block; margin-top: 5px; color: #b7791f; }
|
.seat-meter > small { display: block; margin-top: 5px; color: #b7791f; }
|
||||||
.waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
|
.waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
|
||||||
|
.roster-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
|
||||||
|
.roster-filter .el-input { max-width: 260px; }
|
||||||
|
.roster-filter .el-select { width: 140px; }
|
||||||
|
.roster-summary ~ :deep(.el-pagination),
|
||||||
|
.waitlist-panel :deep(.el-pagination) { justify-content: flex-end; margin-top: 10px; }
|
||||||
.waitlist-panel-head {
|
.waitlist-panel-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
@@ -1541,6 +1609,9 @@ onMounted(async () => {
|
|||||||
.waitlist-panel-head small { color: var(--muted); text-align: right; }
|
.waitlist-panel-head small { color: var(--muted); text-align: right; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.roster-filter { align-items: stretch; flex-direction: column; }
|
||||||
|
.roster-filter .el-input,
|
||||||
|
.roster-filter .el-select { width: 100%; max-width: none; }
|
||||||
.waitlist-panel-head { align-items: start; flex-direction: column; }
|
.waitlist-panel-head { align-items: start; flex-direction: column; }
|
||||||
.waitlist-panel-head small { text-align: left; }
|
.waitlist-panel-head small { text-align: left; }
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-10
@@ -21,6 +21,7 @@ import {
|
|||||||
import http from '../api/http'
|
import http from '../api/http'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import StudentDashboardView from './StudentDashboardView.vue'
|
import StudentDashboardView from './StudentDashboardView.vue'
|
||||||
|
import TeacherDashboardView from './TeacherDashboardView.vue'
|
||||||
|
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
audience: {
|
audience: {
|
||||||
@@ -57,9 +58,24 @@ interface DashboardData {
|
|||||||
classroomReservations: number
|
classroomReservations: number
|
||||||
generalApprovals: number
|
generalApprovals: number
|
||||||
}
|
}
|
||||||
|
greeting: DashboardGreeting
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DashboardGreeting {
|
||||||
|
role: string
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
label: string
|
||||||
|
narrative: string
|
||||||
|
insights: Array<{
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
hint: string
|
||||||
|
tone: 'calm' | 'positive' | 'attention'
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardLink {
|
interface DashboardLink {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@@ -68,24 +84,46 @@ interface DashboardLink {
|
|||||||
icon: Component
|
icon: Component
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WarningRecord {
|
||||||
|
id: string
|
||||||
|
studentName: string
|
||||||
|
studentNumber: string
|
||||||
|
className: string
|
||||||
|
status: number
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const roles = computed(() => auth.user?.roles ?? [])
|
const roles = computed(() => auth.user?.roles ?? [])
|
||||||
const isStudentOverview = computed(() =>
|
const isStudentOverview = computed(() =>
|
||||||
roles.value.includes('Student') &&
|
roles.value.includes('Student') &&
|
||||||
!roles.value.some((role) =>
|
!roles.value.some((role) =>
|
||||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
|
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const isTeacherOverview = computed(() =>
|
||||||
|
roles.value.includes('Teacher') &&
|
||||||
|
!roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role)),
|
||||||
|
)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const data = ref<DashboardData | null>(null)
|
const data = ref<DashboardData | null>(null)
|
||||||
|
const counselorWarnings = ref<WarningRecord[]>([])
|
||||||
const now = ref(new Date())
|
const now = ref(new Date())
|
||||||
|
|
||||||
function hasRole(...allowedRoles: string[]) {
|
function hasRole(...allowedRoles: string[]) {
|
||||||
return roles.value.some((role) => allowedRoles.includes(role))
|
return roles.value.some((role) => allowedRoles.includes(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCounselorDashboard = computed(() =>
|
||||||
|
hasRole('Counselor') && !hasRole('SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeCounselorWarnings = computed(() =>
|
||||||
|
counselorWarnings.value.filter((warning) => warning.status === 1),
|
||||||
|
)
|
||||||
|
|
||||||
function parseDateOnly(value?: string) {
|
function parseDateOnly(value?: string) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||||
@@ -342,6 +380,19 @@ const readiness = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const operationAlerts = computed(() => {
|
||||||
|
const counts = data.value?.counts
|
||||||
|
if (!counts) return []
|
||||||
|
const alerts = [] as Array<{ level: 'critical' | 'warning'; title: string; detail: string; route: string }>
|
||||||
|
const unpublished = counts.teachingTasks - counts.publishedTeachingTasks
|
||||||
|
const unscheduled = counts.publishedTeachingTasks - counts.scheduledTeachingTasks
|
||||||
|
if (unpublished > 0) alerts.push({ level: 'warning', title: '教学任务尚未发布', detail: `${unpublished} 个教学班尚未发布,后续排课与选课无法推进。`, route: '/teaching-tasks' })
|
||||||
|
if (unscheduled > 0) alerts.push({ level: 'critical', title: '课表覆盖存在缺口', detail: `${unscheduled} 个已发布教学班尚未进入课表。`, route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable' })
|
||||||
|
if (counts.submittedGradeSheets > 0) alerts.push({ level: 'warning', title: '成绩审核等待处理', detail: `${counts.submittedGradeSheets} 张成绩登记册已提交,等待审核或发布。`, route: '/grades' })
|
||||||
|
if (counts.openCourseSelectionRounds > 0 && counts.courseEnrollments === 0) alerts.push({ level: 'warning', title: '开放选课尚无有效记录', detail: `${counts.openCourseSelectionRounds} 个选课批次开放中,但当前未发现有效选课。`, route: '/course-selections' })
|
||||||
|
return alerts
|
||||||
|
})
|
||||||
|
|
||||||
const primaryActionRoute = computed(() =>
|
const primaryActionRoute = computed(() =>
|
||||||
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
|
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
|
||||||
)
|
)
|
||||||
@@ -351,6 +402,11 @@ async function loadDashboard() {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
data.value = (await http.get<DashboardData>('/dashboard')).data
|
data.value = (await http.get<DashboardData>('/dashboard')).data
|
||||||
|
if (isCounselorDashboard.value) {
|
||||||
|
counselorWarnings.value = (await http.get<WarningRecord[]>('/warnings/records', {
|
||||||
|
params: { academicTermId: data.value.currentTerm?.id },
|
||||||
|
})).data
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
|
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -359,7 +415,7 @@ async function loadDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (isStudentOverview.value) {
|
if (isStudentOverview.value || isTeacherOverview.value) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -369,6 +425,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<StudentDashboardView v-if="isStudentOverview" />
|
<StudentDashboardView v-if="isStudentOverview" />
|
||||||
|
<TeacherDashboardView v-else-if="isTeacherOverview" />
|
||||||
|
|
||||||
<div v-else v-loading="loading" class="admin-dashboard">
|
<div v-else v-loading="loading" class="admin-dashboard">
|
||||||
<el-result
|
<el-result
|
||||||
@@ -389,9 +446,9 @@ onMounted(async () => {
|
|||||||
<span>{{ data.audience.scopeName }}</span>
|
<span>{{ data.audience.scopeName }}</span>
|
||||||
<i>数据范围</i>
|
<i>数据范围</i>
|
||||||
</div>
|
</div>
|
||||||
<p class="overview-eyebrow">ACADEMIC OPERATIONS</p>
|
<p class="overview-eyebrow">教务工作概览</p>
|
||||||
<h1>{{ data.audience.title }}</h1>
|
<h1>{{ data.greeting.title }}</h1>
|
||||||
<p class="overview-description">{{ data.audience.description }}</p>
|
<p class="overview-description">{{ data.greeting.subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
|
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
|
||||||
@@ -436,6 +493,23 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="greeting-insights" :aria-label="data.greeting.label">
|
||||||
|
<span class="greeting-insights-label">{{ data.greeting.label }}</span>
|
||||||
|
<div>
|
||||||
|
<article
|
||||||
|
v-for="insight in data.greeting.insights"
|
||||||
|
:key="insight.label"
|
||||||
|
:class="`greeting-insight ${insight.tone}`"
|
||||||
|
>
|
||||||
|
<span>{{ insight.label }}</span>
|
||||||
|
<strong>{{ insight.value }}</strong>
|
||||||
|
<small>{{ insight.hint }}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="greeting-narrative">{{ data.greeting.narrative }}</p>
|
||||||
|
|
||||||
<section class="overview-metrics" aria-label="关键教学数据">
|
<section class="overview-metrics" aria-label="关键教学数据">
|
||||||
<button
|
<button
|
||||||
v-for="metric in adminMetrics"
|
v-for="metric in adminMetrics"
|
||||||
@@ -450,11 +524,42 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="operation-alerts">
|
||||||
|
<header><div><span>运行提醒</span><h2>需要关注的事项</h2></div><small>{{ operationAlerts.length ? `${operationAlerts.length} 个事项需要关注` : '当前运行平稳' }}</small></header>
|
||||||
|
<div v-if="operationAlerts.length" class="operation-alert-list">
|
||||||
|
<button v-for="alert in operationAlerts" :key="alert.title" :class="alert.level" @click="router.push(alert.route)"><b>{{ alert.title }}</b><span>{{ alert.detail }}</span><i>立即处理 →</i></button>
|
||||||
|
</div>
|
||||||
|
<p v-else>教学任务、课表与成绩流程当前衔接正常。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="dashboard-work-grid">
|
<section class="dashboard-work-grid">
|
||||||
|
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
|
||||||
|
<header class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="panel-index">学业预警</span>
|
||||||
|
<h2>需重点关注的学生</h2>
|
||||||
|
<p>{{ activeCounselorWarnings.length ? `当前有 ${activeCounselorWarnings.length} 条生效预警,按最新记录展示。` : '当前没有生效中的学业预警。' }}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="radar-link" @click="router.push('/warnings')">完整预警 →</button>
|
||||||
|
</header>
|
||||||
|
<div v-if="activeCounselorWarnings.length" class="counselor-risk-list">
|
||||||
|
<button v-for="warning in activeCounselorWarnings.slice(0, 4)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||||
|
<span>{{ warning.className }}</span>
|
||||||
|
<b>{{ warning.studentName }}</b>
|
||||||
|
<small>{{ warning.detail }}</small>
|
||||||
|
<i>查看 →</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="todo-empty">
|
||||||
|
<el-icon><Checked /></el-icon>
|
||||||
|
<div><b>当前没有重点关注学生</b><span>新的学业预警会自动出现在这里。</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="dashboard-panel todo-panel">
|
<article class="dashboard-panel todo-panel">
|
||||||
<header class="panel-heading">
|
<header class="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="panel-index">01 / ACTION</span>
|
<span class="panel-index">待办事项</span>
|
||||||
<h2>需要处理</h2>
|
<h2>需要处理</h2>
|
||||||
<p>只显示当前角色和数据范围内可处理的事项。</p>
|
<p>只显示当前角色和数据范围内可处理的事项。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,7 +595,7 @@ onMounted(async () => {
|
|||||||
<article class="dashboard-panel quick-panel">
|
<article class="dashboard-panel quick-panel">
|
||||||
<header class="panel-heading">
|
<header class="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="panel-index">02 / SHORTCUTS</span>
|
<span class="panel-index">常用入口</span>
|
||||||
<h2>常用工作</h2>
|
<h2>常用工作</h2>
|
||||||
<p>按当前角色整理的高频业务入口。</p>
|
<p>按当前角色整理的高频业务入口。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -517,9 +622,9 @@ onMounted(async () => {
|
|||||||
<section class="dashboard-panel readiness-panel">
|
<section class="dashboard-panel readiness-panel">
|
||||||
<header class="panel-heading">
|
<header class="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="panel-index">03 / TERM READINESS</span>
|
<span class="panel-index">学期进度</span>
|
||||||
<h2>本学期运行状态</h2>
|
<h2>本学期教学运行</h2>
|
||||||
<p>从任务发布到课表落地、成绩归档,快速发现业务断点。</p>
|
<p>关注教学任务、课表与成绩流程的完成情况。</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="refresh-time">数据更新于 {{ new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
<span class="refresh-time">数据更新于 {{ new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -586,6 +691,62 @@ onMounted(async () => {
|
|||||||
background-size: 32px 32px, 32px 32px, auto;
|
background-size: 32px 32px, 32px 32px, auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.greeting-insights {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 17px 21px;
|
||||||
|
border: 1px solid #dce6eb;
|
||||||
|
background: #f7faf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting-insights-label {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--dashboard-teal);
|
||||||
|
font: 700 10px/1.5 Consolas, monospace;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting-insights > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting-insight {
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 13px;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
border-left: 2px solid #a9bcc6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting-insight.positive { border-color: var(--dashboard-teal); }
|
||||||
|
.greeting-insight.attention { border-color: var(--dashboard-amber); }
|
||||||
|
.greeting-insight span { color: #687788; font-size: 11px; }
|
||||||
|
.greeting-insight strong { color: var(--dashboard-navy); font-size: 20px; }
|
||||||
|
.greeting-insight small { overflow: hidden; color: #84909d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.greeting-narrative {
|
||||||
|
margin: -7px 0 0;
|
||||||
|
padding: 0 3px;
|
||||||
|
color: #526078;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-alerts { padding: 21px 24px; border: 1px solid #e5e8ed; background: #fff; }
|
||||||
|
.operation-alerts header { display: flex; justify-content: space-between; gap: 16px; align-items: end; }
|
||||||
|
.operation-alerts header span { color: var(--dashboard-teal); font: 700 10px/1 Consolas,monospace; letter-spacing: .12em; }
|
||||||
|
.operation-alerts h2 { margin: 7px 0 0; color: var(--dashboard-navy); font-size: 19px; }
|
||||||
|
.operation-alerts header small { color: #697789; font-size: 11px; }
|
||||||
|
.operation-alert-list { margin-top: 16px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
|
||||||
|
.operation-alert-list button { padding: 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #e8e1d3; border-left: 3px solid var(--dashboard-amber); background: #fffcf6; }
|
||||||
|
.operation-alert-list button.critical { border-left-color: #ba5145; background: #fff9f8; }
|
||||||
|
.operation-alert-list b { color: #334254; font-size: 13px; }.operation-alert-list span { color: #6d7888; font-size: 11px; line-height: 1.5; }.operation-alert-list i { color: #8b6d3a; font-size: 11px; font-style: normal; }
|
||||||
|
.operation-alerts > p { margin: 15px 0 0; color: #627184; font-size: 12px; }
|
||||||
|
|
||||||
.overview-hero::after {
|
.overview-hero::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -866,6 +1027,28 @@ onMounted(async () => {
|
|||||||
background: var(--dashboard-paper);
|
background: var(--dashboard-paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.counselor-radar { border-top: 3px solid var(--dashboard-amber); }
|
||||||
|
.radar-link { padding: 5px 0; border: 0; color: #806136; background: transparent; font-size: 12px; white-space: nowrap; }
|
||||||
|
.radar-link:hover { color: var(--dashboard-blue); }
|
||||||
|
.counselor-risk-list { display: grid; }
|
||||||
|
.counselor-risk-list button {
|
||||||
|
padding: 13px 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px 68px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #eee8dc;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.counselor-risk-list button:last-child { border-bottom: 0; }
|
||||||
|
.counselor-risk-list button:hover b { color: var(--dashboard-blue); }
|
||||||
|
.counselor-risk-list span { color: #987337; font: 700 9px/1.3 Consolas, monospace; letter-spacing: .04em; }
|
||||||
|
.counselor-risk-list b { color: #3a4758; font-size: 13px; }
|
||||||
|
.counselor-risk-list small { overflow: hidden; color: #6c7788; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.counselor-risk-list i { color: #8d7042; font-size: 11px; font-style: normal; white-space: nowrap; }
|
||||||
|
|
||||||
.todo-panel,
|
.todo-panel,
|
||||||
.quick-panel,
|
.quick-panel,
|
||||||
.readiness-panel {
|
.readiness-panel {
|
||||||
@@ -1155,6 +1338,7 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
|
||||||
.dashboard-work-grid {
|
.dashboard-work-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1354,13 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.operation-alerts { padding: 18px; }
|
||||||
|
.operation-alert-list { grid-template-columns: 1fr; }
|
||||||
|
.greeting-insights { padding: 15px; }
|
||||||
|
.greeting-insights > div { grid-template-columns: 1fr; }
|
||||||
|
.counselor-risk-list button { grid-template-columns: 1fr auto; }
|
||||||
|
.counselor-risk-list span { grid-column: 1 / -1; }
|
||||||
|
.counselor-risk-list small { white-space: normal; }
|
||||||
.admin-dashboard { gap: 10px; }
|
.admin-dashboard { gap: 10px; }
|
||||||
|
|
||||||
.overview-hero {
|
.overview-hero {
|
||||||
|
|||||||
@@ -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 { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
Download,
|
||||||
EditPen,
|
EditPen,
|
||||||
Plus,
|
Plus,
|
||||||
Promotion,
|
Promotion,
|
||||||
Refresh,
|
Refresh,
|
||||||
Setting,
|
Setting,
|
||||||
|
Upload,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import { downloadApiFile, importExcel } from '../api/excel'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import {
|
import {
|
||||||
academicTermLabel,
|
academicTermLabel,
|
||||||
@@ -33,8 +36,8 @@ const collegeId = ref('')
|
|||||||
const projectKeyword = ref('')
|
const projectKeyword = ref('')
|
||||||
const projectPage = ref(1)
|
const projectPage = ref(1)
|
||||||
const projectPageSize = ref(20)
|
const projectPageSize = ref(20)
|
||||||
const projectTotal = ref(0)
|
const courseTotal = ref(0)
|
||||||
const projects = ref<any[]>([])
|
const courses = ref<any[]>([])
|
||||||
const studentResults = ref<any[]>([])
|
const studentResults = ref<any[]>([])
|
||||||
const detailDrawer = ref(false)
|
const detailDrawer = ref(false)
|
||||||
const detail = ref<any | null>(null)
|
const detail = ref<any | null>(null)
|
||||||
@@ -44,6 +47,7 @@ const recordKeyword = ref('')
|
|||||||
const schemeDialog = ref(false)
|
const schemeDialog = ref(false)
|
||||||
const editingScheme = ref(false)
|
const editingScheme = ref(false)
|
||||||
const schemeProject = ref<any | null>(null)
|
const schemeProject = ref<any | null>(null)
|
||||||
|
const importFileInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
const schemeForm = reactive({
|
const schemeForm = reactive({
|
||||||
contributionWeight: 1,
|
contributionWeight: 1,
|
||||||
@@ -86,6 +90,18 @@ const modeLabels: Record<string, string> = {
|
|||||||
Centralized: '集中安排',
|
Centralized: '集中安排',
|
||||||
SelfScheduled: '自主预约',
|
SelfScheduled: '自主预约',
|
||||||
}
|
}
|
||||||
|
const weekdayLabels = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
|
|
||||||
|
function projectScheduleLabel(project: any) {
|
||||||
|
const entry = project.scheduleEntry
|
||||||
|
if (!entry) return modeLabels[project.arrangementMode] ?? '待安排'
|
||||||
|
const week = project.scheduleWeek ?? '—'
|
||||||
|
const endPeriod = entry.startPeriod + entry.periodCount - 1
|
||||||
|
const periods = entry.periodCount === 1
|
||||||
|
? `第 ${entry.startPeriod} 节`
|
||||||
|
: `第 ${entry.startPeriod}—${endPeriod} 节`
|
||||||
|
return `第 ${week} 周 · ${weekdayLabels[entry.dayOfWeek] ?? `周${entry.dayOfWeek}`} · ${periods}`
|
||||||
|
}
|
||||||
|
|
||||||
const schemeWeightTotal = computed(() =>
|
const schemeWeightTotal = computed(() =>
|
||||||
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
|
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
|
||||||
@@ -196,8 +212,8 @@ async function load() {
|
|||||||
pageSize: projectPageSize.value,
|
pageSize: projectPageSize.value,
|
||||||
},
|
},
|
||||||
})).data
|
})).data
|
||||||
projects.value = data.items
|
courses.value = data.items
|
||||||
projectTotal.value = data.total
|
courseTotal.value = data.total
|
||||||
projectPage.value = data.page
|
projectPage.value = data.page
|
||||||
projectPageSize.value = data.pageSize
|
projectPageSize.value = data.pageSize
|
||||||
}
|
}
|
||||||
@@ -290,6 +306,38 @@ async function saveRecords() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadTemplate() {
|
||||||
|
if (!detail.value) return
|
||||||
|
downloadApiFile(
|
||||||
|
`/experiment-grades/sheets/${detail.value.id}/template`,
|
||||||
|
'实验成绩导入模板.xlsx',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseImportFile() {
|
||||||
|
importFileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file || !detail.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const result = await importExcel(
|
||||||
|
`/experiment-grades/sheets/${detail.value.id}/import`,
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条实验成绩记录`)
|
||||||
|
await loadDetail()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
input.value = ''
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncParticipants() {
|
async function syncParticipants() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
try {
|
try {
|
||||||
@@ -528,45 +576,52 @@ onMounted(async () => {
|
|||||||
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
|
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="grade-project-list">
|
<section v-loading="loading" class="grade-course-list">
|
||||||
<article v-for="project in projects" :key="project.id" class="grade-project-card">
|
<article v-for="course in courses" :key="course.teachingTaskId" class="grade-course-card">
|
||||||
<div class="project-mark">
|
<header class="grade-course-head">
|
||||||
<span>{{ project.code }}</span>
|
<div>
|
||||||
<b>{{ modeLabels[project.arrangementMode] }}</b>
|
<span>{{ course.termName }} · {{ course.taskNumber }}</span>
|
||||||
</div>
|
<h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
|
||||||
<div class="project-summary">
|
<small>{{ course.teacherNames.join('、') || '教师待定' }} · {{ course.collegeName }}</small>
|
||||||
<h3>{{ project.name }}</h3>
|
|
||||||
<p>{{ project.courseCode }} · {{ project.courseName }}</p>
|
|
||||||
<small>{{ project.taskNumber }} · {{ project.termName }} · {{ project.teacherNames.join('、') || '教师待定' }}</small>
|
|
||||||
</div>
|
|
||||||
<template v-if="project.sheet">
|
|
||||||
<div class="sheet-progress">
|
|
||||||
<el-tag :type="statusTypes[project.sheet.status] as any" effect="plain">
|
|
||||||
{{ statusLabels[project.sheet.status] }}
|
|
||||||
</el-tag>
|
|
||||||
<span><b>{{ project.sheet.scoredCount }}</b> / {{ project.sheet.studentCount }} 已计分</span>
|
|
||||||
<span>评分项 {{ project.sheet.itemCount }} 个 · 合格线 {{ project.sheet.passScore }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" plain :icon="EditPen" @click="openDetail(project)">
|
<div class="course-rollup">
|
||||||
打开成绩单
|
<b>{{ course.projectCount }}</b><span>个实验项目</span>
|
||||||
</el-button>
|
<small v-if="course.sheetCount">{{ course.sheetCount }} 份成绩单 · {{ course.scoredCount }}/{{ course.studentCount }} 已计分</small>
|
||||||
</template>
|
<small v-else>尚未建立成绩单</small>
|
||||||
<template v-else>
|
|
||||||
<div class="sheet-empty">
|
|
||||||
<b>尚未建立实验成绩单</b>
|
|
||||||
<span>建立时会按集中名单或有效预约生成评分对象。</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate(project)">
|
</header>
|
||||||
建立成绩单
|
<el-table :data="course.projects" size="small" class="course-project-table">
|
||||||
</el-button>
|
<el-table-column label="实验项目" min-width="260">
|
||||||
</template>
|
<template #default="{ row }">
|
||||||
|
<div class="course-project-name">
|
||||||
|
<b>{{ row.code }} · {{ row.name }}</b>
|
||||||
|
<small>{{ projectScheduleLabel(row) }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="成绩进度" min-width="190">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.sheet" class="course-project-progress">
|
||||||
|
<el-tag :type="statusTypes[row.sheet.status] as any" size="small" effect="plain">{{ statusLabels[row.sheet.status] }}</el-tag>
|
||||||
|
<span>{{ row.sheet.scoredCount }}/{{ row.sheet.studentCount }} 已计分 · {{ row.sheet.itemCount }} 项</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="table-muted">尚未建立成绩单</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="row.sheet" type="primary" text :icon="EditPen" @click="openDetail(row)">打开成绩单</el-button>
|
||||||
|
<el-button v-else type="primary" text :icon="Plus" @click="openCreate(row)">建立成绩单</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
|
<el-empty v-if="!loading && !courses.length" description="当前筛选条件下没有可评分实验课程" />
|
||||||
</section>
|
</section>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="projectPage"
|
v-model:current-page="projectPage"
|
||||||
v-model:page-size="projectPageSize"
|
v-model:page-size="projectPageSize"
|
||||||
:total="projectTotal"
|
:total="courseTotal"
|
||||||
:page-sizes="[10, 20, 50]"
|
:page-sizes="[10, 20, 50]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
@current-change="load"
|
@current-change="load"
|
||||||
@@ -637,6 +692,7 @@ onMounted(async () => {
|
|||||||
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
|
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
|
||||||
<h3>{{ detail.projectName }}</h3>
|
<h3>{{ detail.projectName }}</h3>
|
||||||
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
|
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
|
||||||
|
<p class="project-schedule">{{ projectScheduleLabel(detail) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="workbench-status">
|
<div class="workbench-status">
|
||||||
<el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag>
|
<el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag>
|
||||||
@@ -777,12 +833,16 @@ onMounted(async () => {
|
|||||||
@size-change="recordPage = 1; loadDetail()"
|
@size-change="recordPage = 1; loadDetail()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
|
||||||
|
|
||||||
<footer class="workbench-actions">
|
<footer class="workbench-actions">
|
||||||
<div>
|
<div>
|
||||||
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
|
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
|
||||||
<span>开课学院:{{ detail.courseCollegeName }}</span>
|
<span>开课学院:{{ detail.courseCollegeName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
|
||||||
|
<el-button v-if="detail.canEdit" :icon="Upload" :loading="saving" @click="chooseImportFile">Excel 导入</el-button>
|
||||||
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button>
|
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button>
|
||||||
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
|
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
|
||||||
<el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button>
|
<el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button>
|
||||||
@@ -810,18 +870,24 @@ onMounted(async () => {
|
|||||||
.grade-toolbar .el-select { width: 220px; }
|
.grade-toolbar .el-select { width: 220px; }
|
||||||
.grade-toolbar .el-input { width: min(320px, 100%); }
|
.grade-toolbar .el-input { width: min(320px, 100%); }
|
||||||
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
.grade-project-list { display: grid; gap: 12px; min-height: 180px; }
|
.grade-course-list { display: grid; gap: 10px; min-height: 180px; }
|
||||||
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); }
|
.grade-course-card { overflow: hidden; border: 1px solid #d9e4e8; border-left: 4px solid var(--grade-blue); background: #fff; box-shadow: 0 4px 13px rgb(30 68 86 / 4%); }
|
||||||
.project-mark { display: grid; gap: 7px; }
|
.grade-course-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: #f3f7f8; }
|
||||||
.project-mark span { overflow: hidden; color: #607b88; font: 700 10px/1.3 Consolas, monospace; text-overflow: ellipsis; }
|
.grade-course-head > div:first-child { min-width: 0; }
|
||||||
.project-mark b { color: var(--grade-blue); font-size: 12px; }
|
.grade-course-head span { color: #607b88; font: 700 10px/1.3 Consolas, monospace; }
|
||||||
.project-summary { min-width: 0; }
|
.grade-course-head h3 { margin: 3px 0; color: var(--grade-ink); font-size: 15px; }
|
||||||
.project-summary h3 { margin: 0 0 5px; color: var(--grade-ink); font-size: 17px; }
|
.grade-course-head small { display: block; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.project-summary p { margin: 0 0 4px; color: #365f73; font-size: 13px; }
|
.course-rollup { display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 5px; flex: none; text-align: right; }
|
||||||
.project-summary small { display: block; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
.course-rollup b { color: var(--grade-teal); font: 750 22px/1 Consolas, monospace; }
|
||||||
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
|
.course-rollup > span { color: #55707b; font: 600 11px/1.2 inherit; }
|
||||||
.sheet-progress b { color: var(--grade-teal); }
|
.course-rollup small { grid-column: 1 / -1; margin-top: 3px; color: #70858d; font-size: 10px; }
|
||||||
.sheet-empty b { color: #5d7480; }
|
.course-project-table { width: 100%; --el-table-border-color: #e0e8ea; }
|
||||||
|
.course-project-table :deep(td.el-table__cell), .course-project-table :deep(th.el-table__cell) { padding: 6px 0; }
|
||||||
|
.course-project-table :deep(th.el-table__cell) { color: #71858d; font-size: 10px; }
|
||||||
|
.course-project-name { display: grid; gap: 2px; }
|
||||||
|
.course-project-name b { color: #294d5c; font-size: 12px; }
|
||||||
|
.course-project-name small, .course-project-progress { color: #6d838b; font-size: 10px; }
|
||||||
|
.course-project-progress { display: flex; align-items: center; gap: 7px; }
|
||||||
.student-course-results { display: grid; gap: 12px; min-height: 180px; }
|
.student-course-results { display: grid; gap: 12px; min-height: 180px; }
|
||||||
.student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
|
.student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
|
||||||
.student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; }
|
.student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; }
|
||||||
@@ -902,9 +968,7 @@ onMounted(async () => {
|
|||||||
.workbench-actions b { color: #a24f36; font-size: 12px; }
|
.workbench-actions b { color: #a24f36; font-size: 12px; }
|
||||||
.workbench-actions span { color: var(--muted); font-size: 12px; }
|
.workbench-actions span { color: var(--muted); font-size: 12px; }
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.grade-project-card { grid-template-columns: 110px minmax(0, 1fr) auto; }
|
.grade-course-head { align-items: flex-start; }
|
||||||
.sheet-progress, .sheet-empty { grid-column: 2; }
|
|
||||||
.grade-project-card > .el-button { grid-column: 3; grid-row: 1 / span 2; }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.assessment-flow { grid-template-columns: 1fr; }
|
.assessment-flow { grid-template-columns: 1fr; }
|
||||||
@@ -912,8 +976,8 @@ onMounted(async () => {
|
|||||||
.grade-toolbar { align-items: stretch; flex-direction: column; }
|
.grade-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
|
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
|
||||||
.grade-toolbar > span { margin-left: 0; }
|
.grade-toolbar > span { margin-left: 0; }
|
||||||
.grade-project-card { grid-template-columns: 1fr; }
|
.grade-course-head { align-items: stretch; flex-direction: column; }
|
||||||
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
|
.course-rollup { align-self: flex-start; text-align: left; }
|
||||||
.student-result-grid { grid-template-columns: 1fr; }
|
.student-result-grid { grid-template-columns: 1fr; }
|
||||||
.student-result-card > header { align-items: stretch; flex-direction: column; }
|
.student-result-card > header { align-items: stretch; flex-direction: column; }
|
||||||
.score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; }
|
.score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; }
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const options = reactive({
|
|||||||
colleges: [] as any[],
|
colleges: [] as any[],
|
||||||
scheduleEntries: [] as any[],
|
scheduleEntries: [] as any[],
|
||||||
classrooms: [] as any[],
|
classrooms: [] as any[],
|
||||||
|
teachers: [] as any[],
|
||||||
periods: [] as any[],
|
periods: [] as any[],
|
||||||
})
|
})
|
||||||
const modeFilter = ref('')
|
const modeFilter = ref('')
|
||||||
@@ -37,6 +38,7 @@ const taskKeyword = ref('')
|
|||||||
|
|
||||||
const projectDialog = ref(false)
|
const projectDialog = ref(false)
|
||||||
const editingProjectId = ref('')
|
const editingProjectId = ref('')
|
||||||
|
const correctingPublishedProject = ref(false)
|
||||||
const projectForm = reactive({
|
const projectForm = reactive({
|
||||||
teachingTaskIds: [] as string[],
|
teachingTaskIds: [] as string[],
|
||||||
scheduleEntryId: '',
|
scheduleEntryId: '',
|
||||||
@@ -48,6 +50,7 @@ const projectForm = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
requirements: '',
|
requirements: '',
|
||||||
dates: [] as string[],
|
dates: [] as string[],
|
||||||
|
selectionTimes: [] as string[],
|
||||||
})
|
})
|
||||||
|
|
||||||
const batchRenameDialog = ref(false)
|
const batchRenameDialog = ref(false)
|
||||||
@@ -63,6 +66,7 @@ const sessionForm = reactive({
|
|||||||
periodCount: 2,
|
periodCount: 2,
|
||||||
capacity: 30,
|
capacity: 30,
|
||||||
notes: '',
|
notes: '',
|
||||||
|
instructorTeacherIds: [] as string[],
|
||||||
})
|
})
|
||||||
|
|
||||||
const batchSessionDialog = ref(false)
|
const batchSessionDialog = ref(false)
|
||||||
@@ -158,6 +162,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
|
|
||||||
function resetProjectForm() {
|
function resetProjectForm() {
|
||||||
editingProjectId.value = ''
|
editingProjectId.value = ''
|
||||||
|
correctingPublishedProject.value = false
|
||||||
Object.assign(projectForm, {
|
Object.assign(projectForm, {
|
||||||
teachingTaskIds: [],
|
teachingTaskIds: [],
|
||||||
scheduleEntryId: '',
|
scheduleEntryId: '',
|
||||||
@@ -169,6 +174,7 @@ function resetProjectForm() {
|
|||||||
description: '',
|
description: '',
|
||||||
requirements: '',
|
requirements: '',
|
||||||
dates: [],
|
dates: [],
|
||||||
|
selectionTimes: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +203,7 @@ function openCreateProject() {
|
|||||||
|
|
||||||
function openEditProject(project: any) {
|
function openEditProject(project: any) {
|
||||||
editingProjectId.value = project.id
|
editingProjectId.value = project.id
|
||||||
|
correctingPublishedProject.value = false
|
||||||
Object.assign(projectForm, {
|
Object.assign(projectForm, {
|
||||||
teachingTaskIds: [project.teachingTaskId],
|
teachingTaskIds: [project.teachingTaskId],
|
||||||
scheduleEntryId: project.scheduleEntryId ?? '',
|
scheduleEntryId: project.scheduleEntryId ?? '',
|
||||||
@@ -208,28 +215,36 @@ function openEditProject(project: any) {
|
|||||||
description: project.description ?? '',
|
description: project.description ?? '',
|
||||||
requirements: project.requirements ?? '',
|
requirements: project.requirements ?? '',
|
||||||
dates: [project.startDate, project.endDate],
|
dates: [project.startDate, project.endDate],
|
||||||
|
selectionTimes: project.selectionStartsAt && project.selectionEndsAt
|
||||||
|
? [project.selectionStartsAt, project.selectionEndsAt] : [],
|
||||||
})
|
})
|
||||||
projectDialog.value = true
|
projectDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCorrectPublishedProject(project: any) {
|
||||||
|
openEditProject(project)
|
||||||
|
correctingPublishedProject.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function projectNameLines(value: string) {
|
function projectNameLines(value: string) {
|
||||||
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
|
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
const names = projectNameLines(projectForm.projectNames)
|
const names = projectNameLines(projectForm.projectNames)
|
||||||
const hasValidName = editingProjectId.value
|
const hasValidName = correctingPublishedProject.value || editingProjectId.value
|
||||||
? !!projectForm.name.trim()
|
? !!projectForm.name.trim()
|
||||||
: projectForm.nameMode === 'InputNames'
|
: projectForm.nameMode === 'InputNames'
|
||||||
? names.length > 0
|
? names.length > 0
|
||||||
: !!projectForm.name.trim()
|
: !!projectForm.name.trim()
|
||||||
if (!projectForm.teachingTaskIds.length ||
|
if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
|
||||||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||||
!projectForm.code.trim()
|
!projectForm.code.trim()
|
||||||
|| !hasValidName || projectForm.dates.length !== 2) {
|
|| !hasValidName || projectForm.dates.length !== 2
|
||||||
|
|| (projectForm.arrangementMode === 'SelfScheduled' && projectForm.selectionTimes.length !== 2)) {
|
||||||
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
|
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
|
||||||
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
? '请选择课表实验课,并填写项目编码、名称和开放日期'
|
||||||
: '请填写教学任务、项目编码、名称和开放日期')
|
: '请填写教学任务、项目编码、名称、开放日期和选课时间')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -242,9 +257,20 @@ async function saveProject() {
|
|||||||
requirements: projectForm.requirements || null,
|
requirements: projectForm.requirements || null,
|
||||||
startDate: projectForm.dates[0],
|
startDate: projectForm.dates[0],
|
||||||
endDate: projectForm.dates[1],
|
endDate: projectForm.dates[1],
|
||||||
|
selectionStartsAt: projectForm.arrangementMode === 'SelfScheduled'
|
||||||
|
? projectForm.selectionTimes[0] || null : null,
|
||||||
|
selectionEndsAt: projectForm.arrangementMode === 'SelfScheduled'
|
||||||
|
? projectForm.selectionTimes[1] || null : null,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editingProjectId.value) {
|
if (correctingPublishedProject.value) {
|
||||||
|
await http.put(`/experiments/${editingProjectId.value}/published-details`, {
|
||||||
|
name: projectForm.name,
|
||||||
|
description: projectForm.description || null,
|
||||||
|
requirements: projectForm.requirements || null,
|
||||||
|
})
|
||||||
|
ElMessage.success('已修正已发布项目的教学内容,并通知学生查看')
|
||||||
|
} else if (editingProjectId.value) {
|
||||||
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
await http.put(`/experiments/${editingProjectId.value}`, payload)
|
||||||
ElMessage.success('实验项目已更新')
|
ElMessage.success('实验项目已更新')
|
||||||
} else {
|
} else {
|
||||||
@@ -273,24 +299,47 @@ function openBatchSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncBatchSessionRows() {
|
function syncBatchSessionRows() {
|
||||||
const existing = new Map(batchSessionRows.value.map((row) => [row.projectId, row]))
|
const existing = new Map<string, any[]>()
|
||||||
batchSessionRows.value = batchSessionProjectIds.value.map((projectId) => {
|
batchSessionRows.value.forEach((row) => {
|
||||||
const current = existing.get(projectId)
|
const rows = existing.get(row.projectId) ?? []
|
||||||
if (current) return current
|
rows.push(row)
|
||||||
const project = projects.value.find((item) => item.id === projectId)
|
existing.set(row.projectId, rows)
|
||||||
const firstPeriod = options.periods.find((item) =>
|
|
||||||
item.academicTermId === project?.academicTermId,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
projectId,
|
|
||||||
classroomId: '',
|
|
||||||
sessionDate: project?.startDate ?? '',
|
|
||||||
startPeriod: firstPeriod?.periodNumber,
|
|
||||||
periodCount: 2,
|
|
||||||
capacity: 30,
|
|
||||||
notes: '',
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
batchSessionRows.value = batchSessionProjectIds.value.flatMap((projectId) => {
|
||||||
|
const current = existing.get(projectId)
|
||||||
|
if (current?.length) return current
|
||||||
|
return [newBatchSessionRow(projectId)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function newBatchSessionRow(projectId: string, source?: any) {
|
||||||
|
const project = projects.value.find((item) => item.id === projectId)
|
||||||
|
const firstPeriod = options.periods.find((item) =>
|
||||||
|
item.academicTermId === project?.academicTermId,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
rowKey: crypto.randomUUID(),
|
||||||
|
projectId,
|
||||||
|
classroomId: source?.classroomId ?? '',
|
||||||
|
sessionDate: source?.sessionDate ?? project?.startDate ?? '',
|
||||||
|
startPeriod: source?.startPeriod ?? firstPeriod?.periodNumber,
|
||||||
|
periodCount: source?.periodCount ?? 2,
|
||||||
|
capacity: source?.capacity ?? 30,
|
||||||
|
notes: '',
|
||||||
|
instructorTeacherIds: source?.instructorTeacherIds ? [...source.instructorTeacherIds] : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBatchSessionRow(projectId: string, source: any) {
|
||||||
|
batchSessionRows.value.push(newBatchSessionRow(projectId, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBatchSessionRow(row: any) {
|
||||||
|
const sameProjectRows = batchSessionRows.value.filter((item) => item.projectId === row.projectId)
|
||||||
|
if (sameProjectRows.length === 1) {
|
||||||
|
batchSessionProjectIds.value = batchSessionProjectIds.value.filter((id) => id !== row.projectId)
|
||||||
|
}
|
||||||
|
batchSessionRows.value = batchSessionRows.value.filter((item) => item.rowKey !== row.rowKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFirstBatchTime() {
|
function applyFirstBatchTime() {
|
||||||
@@ -321,9 +370,9 @@ async function saveBatchSessions() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (batchSessionRows.value.some((row) =>
|
if (batchSessionRows.value.some((row) =>
|
||||||
!row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount,
|
!row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount || !row.instructorTeacherIds?.length,
|
||||||
)) {
|
)) {
|
||||||
ElMessage.warning('请完整填写每个项目的日期、节次、实验室和容量')
|
ElMessage.warning('请完整填写每个项目的日期、节次、实验室、指导老师和容量')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -350,14 +399,15 @@ function openSession(project: any) {
|
|||||||
periodCount: 2,
|
periodCount: 2,
|
||||||
capacity: 30,
|
capacity: 30,
|
||||||
notes: '',
|
notes: '',
|
||||||
|
instructorTeacherIds: [],
|
||||||
})
|
})
|
||||||
sessionDialog.value = true
|
sessionDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSession() {
|
async function saveSession() {
|
||||||
if (!selectedProject.value || !sessionForm.classroomId
|
if (!selectedProject.value || !sessionForm.classroomId
|
||||||
|| !sessionForm.sessionDate || !sessionForm.startPeriod) {
|
|| !sessionForm.sessionDate || !sessionForm.startPeriod || !sessionForm.instructorTeacherIds.length) {
|
||||||
ElMessage.warning('请选择实验日期、节次和教室')
|
ElMessage.warning('请选择实验日期、节次、实验室和指导老师')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -420,6 +470,15 @@ async function deleteProject(project: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function instructorsForProject(projectId: string) {
|
||||||
|
return options.teachers.filter((teacher) => teacher.teachingTaskId === projectId
|
||||||
|
|| teacher.teachingTaskId === batchProject(projectId)?.teachingTaskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function instructorsForSelectedProject() {
|
||||||
|
return selectedProject.value ? instructorsForProject(selectedProject.value.id) : []
|
||||||
|
}
|
||||||
|
|
||||||
function openBatchRename() {
|
function openBatchRename() {
|
||||||
batchRenameProjectIds.value = projects.value
|
batchRenameProjectIds.value = projects.value
|
||||||
.filter((project) => draftSelectedProjectIds.value.includes(project.id))
|
.filter((project) => draftSelectedProjectIds.value.includes(project.id))
|
||||||
@@ -612,11 +671,13 @@ async function loadOptions() {
|
|||||||
options.colleges = data.colleges
|
options.colleges = data.colleges
|
||||||
options.scheduleEntries = data.scheduleEntries
|
options.scheduleEntries = data.scheduleEntries
|
||||||
options.classrooms = data.classrooms
|
options.classrooms = data.classrooms
|
||||||
|
options.teachers = data.teachers
|
||||||
options.periods = data.periods
|
options.periods = data.periods
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.tasks = []
|
options.tasks = []
|
||||||
options.colleges = []
|
options.colleges = []
|
||||||
options.classrooms = []
|
options.classrooms = []
|
||||||
|
options.teachers = []
|
||||||
options.periods = []
|
options.periods = []
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
@@ -814,9 +875,9 @@ onMounted(async () => {
|
|||||||
<template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template>
|
<template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template>
|
||||||
<div class="student-session-menu">
|
<div class="student-session-menu">
|
||||||
<div v-for="session in row.sessions" :key="session.id" class="student-session-option">
|
<div v-for="session in row.sessions" :key="session.id" class="student-session-option">
|
||||||
<div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span></div>
|
<div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }} · 指导:{{ session.instructorNames?.join('、') || '待指定' }}</span></div>
|
||||||
<el-button size="small" text @click="showParticipants(row, session)">预约名单</el-button>
|
<el-button v-if="session.canManage" size="small" text @click="showParticipants(row, session)">预约名单</el-button>
|
||||||
<el-button v-if="session.status !== 'Cancelled'" size="small" text type="danger" @click="cancelSession(row, session)">{{ row.status === 'Draft' ? '删除' : '取消' }}</el-button>
|
<el-button v-if="session.canManage && session.status !== 'Cancelled'" size="small" text type="danger" @click="cancelSession(row, session)">{{ row.status === 'Draft' ? '删除' : '取消' }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
@@ -842,7 +903,10 @@ onMounted(async () => {
|
|||||||
@click="publishProject(row)"
|
@click="publishProject(row)"
|
||||||
>发布</el-button>
|
>发布</el-button>
|
||||||
</template>
|
</template>
|
||||||
<el-button v-else-if="row.status === 'Published'" size="small" text @click="closeProject(row)">关闭项目</el-button>
|
<template v-else-if="row.status === 'Published'">
|
||||||
|
<el-button size="small" text @click="openCorrectPublishedProject(row)">修正内容</el-button>
|
||||||
|
<el-button size="small" text @click="closeProject(row)">关闭项目</el-button>
|
||||||
|
</template>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
|
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -909,7 +973,7 @@ onMounted(async () => {
|
|||||||
<div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }">
|
<div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }">
|
||||||
<div>
|
<div>
|
||||||
<b>{{ formatSessionTime(session) }}</b>
|
<b>{{ formatSessionTime(session) }}</b>
|
||||||
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
|
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }} · 指导:{{ session.instructorNames?.join('、') || '待指定' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tag :type="sessionTagType(session)" size="small" effect="plain">余 {{ session.remainingCount }} / {{ session.capacity }}</el-tag>
|
<el-tag :type="sessionTagType(session)" size="small" effect="plain">余 {{ session.remainingCount }} / {{ session.capacity }}</el-tag>
|
||||||
<el-button v-if="row.myBooking?.experimentSessionId === session.id" size="small" type="success" :icon="Check" disabled>已预约</el-button>
|
<el-button v-if="row.myBooking?.experimentSessionId === session.id" size="small" type="success" :icon="Check" disabled>已预约</el-button>
|
||||||
@@ -952,11 +1016,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="projectDialog"
|
v-model="projectDialog"
|
||||||
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
:title="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||||
width="720px"
|
width="720px"
|
||||||
top="5vh"
|
top="5vh"
|
||||||
>
|
>
|
||||||
<el-form label-position="top" class="experiment-form">
|
<el-form label-position="top" class="experiment-form">
|
||||||
|
<el-alert
|
||||||
|
v-if="correctingPublishedProject"
|
||||||
|
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||||
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
||||||
@@ -1009,9 +1079,10 @@ onMounted(async () => {
|
|||||||
v-for="task in group.tasks"
|
v-for="task in group.tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:value="task.id"
|
:value="task.id"
|
||||||
:disabled="projectForm.arrangementMode === 'SelfScheduled' && !!selectedTask
|
:disabled="projectForm.arrangementMode === 'SelfScheduled'
|
||||||
&& (task.academicTermId !== selectedTask.academicTermId
|
&& (task.schedulingMode !== 'Flexible'
|
||||||
|| task.courseId !== selectedTask.courseId)"
|
|| (!!selectedTask && (task.academicTermId !== selectedTask.academicTermId
|
||||||
|
|| task.courseId !== selectedTask.courseId)))"
|
||||||
>
|
>
|
||||||
<b>{{ task.taskNumber }}</b>
|
<b>{{ task.taskNumber }}</b>
|
||||||
<span>{{ task.classNames.join('、') || task.name }}</span>
|
<span>{{ task.classNames.join('、') || task.name }}</span>
|
||||||
@@ -1022,12 +1093,12 @@ onMounted(async () => {
|
|||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
<small v-if="!editingProjectId" class="form-help">
|
<small v-if="!editingProjectId" class="form-help">
|
||||||
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班;系统会为每个教学班的全部已发布实验课生成项目。</template>
|
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班;系统会为每个教学班的全部已发布实验课生成项目。</template>
|
||||||
<template v-else>已选 {{ projectForm.teachingTaskIds.length }} 个教学班;只能勾选同一学期、同一课程,实验编码、名称、内容和开放日期将一次应用到这些教学任务。</template>
|
<template v-else>自行安排仅可选择“非排时课程”的教学任务;只能勾选同一学期、同一课程,实验编码、名称、内容和开放日期将一次应用到这些教学任务。</template>
|
||||||
</small>
|
</small>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid two">
|
<div class="form-grid two">
|
||||||
<el-form-item label="项目编码" required>
|
<el-form-item label="项目编码" required>
|
||||||
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" />
|
<el-input v-model="projectForm.code" :disabled="correctingPublishedProject" maxlength="40" placeholder="如 LAB-01" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="editingProjectId" label="项目名称" required>
|
<el-form-item v-if="editingProjectId" label="项目名称" required>
|
||||||
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
|
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
|
||||||
@@ -1058,7 +1129,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<header><span>ROUTE</span><b>选择运行轨道</b></header>
|
<header><span>ROUTE</span><b>选择运行轨道</b></header>
|
||||||
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId">
|
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId || correctingPublishedProject">
|
||||||
<el-radio-button value="Centralized">
|
<el-radio-button value="Centralized">
|
||||||
<b>集中安排</b><small>复用已发布课表的实验课</small>
|
<b>集中安排</b><small>复用已发布课表的实验课</small>
|
||||||
</el-radio-button>
|
</el-radio-button>
|
||||||
@@ -1069,6 +1140,7 @@ onMounted(async () => {
|
|||||||
<el-form-item label="项目开放日期" required>
|
<el-form-item label="项目开放日期" required>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="projectForm.dates"
|
v-model="projectForm.dates"
|
||||||
|
:disabled="correctingPublishedProject"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
range-separator="至"
|
range-separator="至"
|
||||||
@@ -1076,6 +1148,17 @@ onMounted(async () => {
|
|||||||
end-placeholder="结束日期"
|
end-placeholder="结束日期"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="projectForm.arrangementMode === 'SelfScheduled'" label="学生选课时间" required>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="projectForm.selectionTimes"
|
||||||
|
type="datetimerange"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始选课"
|
||||||
|
end-placeholder="截至选课"
|
||||||
|
/>
|
||||||
|
<small class="form-help">仅在此时间段内,学生可以从该项目的有效实验场次中选择一次;退选或场次取消后可重新选择。</small>
|
||||||
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
@@ -1105,7 +1188,9 @@ onMounted(async () => {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="projectDialog = false">取消</el-button>
|
<el-button @click="projectDialog = false">取消</el-button>
|
||||||
<el-button type="primary" @click="saveProject">
|
<el-button type="primary" @click="saveProject">
|
||||||
{{ editingProjectId
|
{{ correctingPublishedProject
|
||||||
|
? '保存修正'
|
||||||
|
: editingProjectId
|
||||||
? '保存项目'
|
? '保存项目'
|
||||||
: projectForm.teachingTaskIds.length
|
: projectForm.teachingTaskIds.length
|
||||||
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
|
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
|
||||||
@@ -1183,6 +1268,16 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="指导老师(可多选)" required>
|
||||||
|
<el-select v-model="sessionForm.instructorTeacherIds" multiple filterable placeholder="选择该教学任务的指导老师">
|
||||||
|
<el-option
|
||||||
|
v-for="teacher in instructorsForSelectedProject()"
|
||||||
|
:key="teacher.teacherId"
|
||||||
|
:label="`${teacher.teacherNumber} · ${teacher.teacherName}`"
|
||||||
|
:value="teacher.teacherId"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="场次备注">
|
<el-form-item label="场次备注">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="sessionForm.notes"
|
v-model="sessionForm.notes"
|
||||||
@@ -1229,11 +1324,11 @@ onMounted(async () => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div v-if="batchSessionRows.length" class="batch-session-tools">
|
<div v-if="batchSessionRows.length" class="batch-session-tools">
|
||||||
<span>共 {{ batchSessionRows.length }} 条排课</span>
|
<span>共 {{ batchSessionRows.length }} 条候选时段;可为同一项目追加多个时段。</span>
|
||||||
<el-button size="small" @click="applyFirstBatchTime">套用首行日期与节次</el-button>
|
<el-button size="small" @click="applyFirstBatchTime">套用首行日期与节次</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="batch-session-table">
|
<div class="batch-session-table">
|
||||||
<article v-for="(row, index) in batchSessionRows" :key="row.projectId" class="batch-session-row">
|
<article v-for="(row, index) in batchSessionRows" :key="row.rowKey" class="batch-session-row">
|
||||||
<div class="batch-project-cell">
|
<div class="batch-project-cell">
|
||||||
<span>{{ index + 1 }}</span>
|
<span>{{ index + 1 }}</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -1264,7 +1359,19 @@ onMounted(async () => {
|
|||||||
:value="room.id"
|
:value="room.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select v-model="row.instructorTeacherIds" multiple filterable collapse-tags placeholder="指导老师">
|
||||||
|
<el-option
|
||||||
|
v-for="teacher in instructorsForProject(row.projectId)"
|
||||||
|
:key="teacher.teacherId"
|
||||||
|
:label="teacher.teacherName"
|
||||||
|
:value="teacher.teacherId"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
<el-input-number v-model="row.capacity" :min="1" :max="10000" controls-position="right" />
|
<el-input-number v-model="row.capacity" :min="1" :max="10000" controls-position="right" />
|
||||||
|
<span class="batch-session-actions">
|
||||||
|
<el-button size="small" text type="primary" @click="addBatchSessionRow(row.projectId, row)">追加时段</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="removeBatchSessionRow(row)">移除</el-button>
|
||||||
|
</span>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!batchSessionRows.length" :image-size="60" description="选择实验项目后,在同一张表中完成排课" />
|
<el-empty v-if="!batchSessionRows.length" :image-size="60" description="选择实验项目后,在同一张表中完成排课" />
|
||||||
@@ -1471,7 +1578,7 @@ onMounted(async () => {
|
|||||||
.batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
.batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
||||||
.batch-session-row {
|
.batch-session-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(230px, 1.4fr) 150px 120px 110px minmax(230px, 1.3fr) 110px;
|
grid-template-columns: minmax(210px, 1.2fr) 145px 110px 100px minmax(200px, 1.1fr) minmax(150px, .8fr) 100px auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 1000px;
|
min-width: 1000px;
|
||||||
@@ -1479,6 +1586,7 @@ onMounted(async () => {
|
|||||||
border: 1px solid #dce5e9;
|
border: 1px solid #dce5e9;
|
||||||
background: #f9fbfc;
|
background: #f9fbfc;
|
||||||
}
|
}
|
||||||
|
.batch-session-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||||
.batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
.batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
.batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; }
|
.batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; }
|
||||||
.batch-project-cell > div { display: grid; gap: 3px; min-width: 0; }
|
.batch-project-cell > div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
|
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts/core'
|
||||||
|
import { BarChart, LineChart } from 'echarts/charts'
|
||||||
|
import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { downloadApiFile } from '../api/excel'
|
import { downloadApiFile } from '../api/excel'
|
||||||
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
AriaComponent,
|
||||||
|
BarChart,
|
||||||
|
CanvasRenderer,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
LineChart,
|
||||||
|
TooltipComponent,
|
||||||
|
])
|
||||||
|
|
||||||
interface TeachingClassItem {
|
interface TeachingClassItem {
|
||||||
gradeSheetId: string
|
gradeSheetId: string
|
||||||
teachingTaskId: string
|
teachingTaskId: string
|
||||||
@@ -26,6 +39,7 @@ interface TeachingClassItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
|
const colleges = ref<any[]>([])
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
|
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
|
||||||
const classes = ref<TeachingClassItem[]>([])
|
const classes = ref<TeachingClassItem[]>([])
|
||||||
@@ -33,6 +47,9 @@ const selected = ref<TeachingClassItem>()
|
|||||||
const report = ref<any>()
|
const report = ref<any>()
|
||||||
const termId = ref<string>()
|
const termId = ref<string>()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
const collegeId = ref<string>()
|
||||||
|
const teacherKeyword = ref('')
|
||||||
|
const riskOnly = ref(false)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const pageSize = 12
|
const pageSize = 12
|
||||||
@@ -96,6 +113,9 @@ async function loadClasses(reset = false) {
|
|||||||
params: {
|
params: {
|
||||||
academicTermId: termId.value,
|
academicTermId: termId.value,
|
||||||
keyword: keyword.value.trim() || undefined,
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
collegeId: collegeId.value,
|
||||||
|
teacherKeyword: teacherKeyword.value.trim() || undefined,
|
||||||
|
riskOnly: riskOnly.value || undefined,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize,
|
pageSize,
|
||||||
},
|
},
|
||||||
@@ -330,7 +350,12 @@ watch(historyMetric, async () => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
terms.value = (await http.get('/base-data/terms')).data
|
const [termResponse, collegeResponse] = await Promise.all([
|
||||||
|
http.get('/base-data/terms'),
|
||||||
|
http.get('/base-data/colleges'),
|
||||||
|
])
|
||||||
|
terms.value = termResponse.data
|
||||||
|
colleges.value = collegeResponse.data
|
||||||
termId.value = defaultAcademicTermId(terms.value)
|
termId.value = defaultAcademicTermId(terms.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -396,7 +421,12 @@ onBeforeUnmount(() => {
|
|||||||
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
|
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
|
||||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
|
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select v-model="collegeId" clearable placeholder="全部开课学院" style="width: 190px" @change="loadClasses(true)">
|
||||||
|
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
|
||||||
|
</el-select>
|
||||||
<el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" />
|
<el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" />
|
||||||
|
<el-input v-model="teacherKeyword" clearable placeholder="任课教师姓名或工号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" @clear="loadClasses(true)" />
|
||||||
|
<el-checkbox v-model="riskOnly" @change="loadClasses(true)">仅看预警班</el-checkbox>
|
||||||
<el-button type="primary" @click="loadClasses(true)">查询</el-button>
|
<el-button type="primary" @click="loadClasses(true)">查询</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>明序教务</strong>
|
<strong>明序教务</strong>
|
||||||
<small>MINGXU ACADEMIC SYSTEM</small>
|
<small>教务管理与学业服务</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Check,
|
Check,
|
||||||
@@ -11,35 +11,6 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
User,
|
User,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
|
|
||||||
import {
|
|
||||||
AutoLink,
|
|
||||||
BlockQuote,
|
|
||||||
Bold,
|
|
||||||
ClassicEditor,
|
|
||||||
Essentials,
|
|
||||||
Heading,
|
|
||||||
Image,
|
|
||||||
ImageCaption,
|
|
||||||
ImageInsert,
|
|
||||||
ImageInsertViaUrl,
|
|
||||||
ImageResize,
|
|
||||||
ImageStyle,
|
|
||||||
ImageToolbar,
|
|
||||||
Italic,
|
|
||||||
Link,
|
|
||||||
LinkImage,
|
|
||||||
List,
|
|
||||||
Paragraph,
|
|
||||||
Table,
|
|
||||||
TableCaption,
|
|
||||||
TableCellProperties,
|
|
||||||
TableColumnResize,
|
|
||||||
TableProperties,
|
|
||||||
TableToolbar,
|
|
||||||
type EditorConfig,
|
|
||||||
} from 'ckeditor5'
|
|
||||||
import 'ckeditor5/ckeditor5.css'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import RichMessageContent from '../components/RichMessageContent.vue'
|
import RichMessageContent from '../components/RichMessageContent.vue'
|
||||||
@@ -47,6 +18,8 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const NotificationRichTextEditor = defineAsyncComponent(
|
||||||
|
() => import('../components/NotificationRichTextEditor.vue'))
|
||||||
const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
|
const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
|
||||||
const canSend = computed(() =>
|
const canSend = computed(() =>
|
||||||
auth.user?.roles.some(role => senderRoles.includes(role)) ?? false)
|
auth.user?.roles.some(role => senderRoles.includes(role)) ?? false)
|
||||||
@@ -58,6 +31,7 @@ const unreadCount = ref(0)
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const sentTotal = ref(0)
|
const sentTotal = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const markingAllRead = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const composerLoading = ref(false)
|
const composerLoading = ref(false)
|
||||||
const recipientLoading = ref(false)
|
const recipientLoading = ref(false)
|
||||||
@@ -90,92 +64,6 @@ const recipientFilters = reactive({
|
|||||||
keyword: '',
|
keyword: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const editorConfig: EditorConfig = {
|
|
||||||
licenseKey: import.meta.env.VITE_CKEDITOR_LICENSE_KEY || 'GPL',
|
|
||||||
plugins: [
|
|
||||||
Essentials,
|
|
||||||
Paragraph,
|
|
||||||
Heading,
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Link,
|
|
||||||
AutoLink,
|
|
||||||
List,
|
|
||||||
BlockQuote,
|
|
||||||
Image,
|
|
||||||
ImageCaption,
|
|
||||||
ImageInsert,
|
|
||||||
ImageInsertViaUrl,
|
|
||||||
ImageResize,
|
|
||||||
ImageStyle,
|
|
||||||
ImageToolbar,
|
|
||||||
LinkImage,
|
|
||||||
Table,
|
|
||||||
TableCaption,
|
|
||||||
TableCellProperties,
|
|
||||||
TableColumnResize,
|
|
||||||
TableProperties,
|
|
||||||
TableToolbar,
|
|
||||||
],
|
|
||||||
toolbar: {
|
|
||||||
items: [
|
|
||||||
'heading',
|
|
||||||
'|',
|
|
||||||
'bold',
|
|
||||||
'italic',
|
|
||||||
'link',
|
|
||||||
'|',
|
|
||||||
'insertTable',
|
|
||||||
'insertImage',
|
|
||||||
'|',
|
|
||||||
'bulletedList',
|
|
||||||
'numberedList',
|
|
||||||
'blockQuote',
|
|
||||||
'|',
|
|
||||||
'undo',
|
|
||||||
'redo',
|
|
||||||
],
|
|
||||||
shouldNotGroupWhenFull: false,
|
|
||||||
},
|
|
||||||
heading: {
|
|
||||||
options: [
|
|
||||||
{ model: 'paragraph', title: '正文', class: 'ck-heading_paragraph' },
|
|
||||||
{ model: 'heading2', view: 'h2', title: '标题', class: 'ck-heading_heading2' },
|
|
||||||
{ model: 'heading3', view: 'h3', title: '小标题', class: 'ck-heading_heading3' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
addTargetToExternalLinks: true,
|
|
||||||
defaultProtocol: 'https://',
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
insert: {
|
|
||||||
integrations: ['insertImageViaUrl'],
|
|
||||||
},
|
|
||||||
toolbar: [
|
|
||||||
'toggleImageCaption',
|
|
||||||
'imageTextAlternative',
|
|
||||||
'|',
|
|
||||||
'imageStyle:inline',
|
|
||||||
'imageStyle:wrapText',
|
|
||||||
'imageStyle:breakText',
|
|
||||||
'|',
|
|
||||||
'resizeImage',
|
|
||||||
'linkImage',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
contentToolbar: [
|
|
||||||
'tableColumn',
|
|
||||||
'tableRow',
|
|
||||||
'mergeTableCells',
|
|
||||||
'toggleTableCaption',
|
|
||||||
'tableProperties',
|
|
||||||
'tableCellProperties',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories: Record<string, { label: string; className: string }> = {
|
const categories: Record<string, { label: string; className: string }> = {
|
||||||
General: { label: '一般通知', className: 'general' },
|
General: { label: '一般通知', className: 'general' },
|
||||||
Approval: { label: '审核待办', className: 'approval' },
|
Approval: { label: '审核待办', className: 'approval' },
|
||||||
@@ -380,6 +268,8 @@ async function openNotification(notification: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markAllRead() {
|
async function markAllRead() {
|
||||||
|
if (markingAllRead.value) return
|
||||||
|
markingAllRead.value = true
|
||||||
try {
|
try {
|
||||||
await http.post('/notifications/read-all')
|
await http.post('/notifications/read-all')
|
||||||
notifications.value.forEach(notification => {
|
notifications.value.forEach(notification => {
|
||||||
@@ -389,6 +279,8 @@ async function markAllRead() {
|
|||||||
ElMessage.success('全部消息已标为已读')
|
ElMessage.success('全部消息已标为已读')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
markingAllRead.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +356,7 @@ onMounted(() => load())
|
|||||||
<div class="page-stack message-center">
|
<div class="page-stack message-center">
|
||||||
<section class="page-intro message-intro">
|
<section class="page-intro message-intro">
|
||||||
<div>
|
<div>
|
||||||
<span class="section-kicker">MESSAGE CENTER</span>
|
<span class="section-kicker">消息中心</span>
|
||||||
<h2>消息中心</h2>
|
<h2>消息中心</h2>
|
||||||
<p>系统通知、审核待办、课程变动与成绩消息统一汇总,重要信息不再散落在业务页面。</p>
|
<p>系统通知、审核待办、课程变动与成绩消息统一汇总,重要信息不再散落在业务页面。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -481,6 +373,15 @@ onMounted(() => load())
|
|||||||
>
|
>
|
||||||
发消息
|
发消息
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:icon="Check"
|
||||||
|
:loading="markingAllRead"
|
||||||
|
:disabled="!unreadCount"
|
||||||
|
:title="unreadCount ? '将全部未读消息标为已读' : '当前没有未读消息'"
|
||||||
|
@click="markAllRead"
|
||||||
|
>
|
||||||
|
全部已读
|
||||||
|
</el-button>
|
||||||
<el-button :icon="Refresh" @click="activeTab === 'sent' ? loadSent() : load()">
|
<el-button :icon="Refresh" @click="activeTab === 'sent' ? loadSent() : load()">
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -541,9 +442,6 @@ onMounted(() => load())
|
|||||||
@clear="load(true)"
|
@clear="load(true)"
|
||||||
/>
|
/>
|
||||||
<el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox>
|
<el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox>
|
||||||
<el-button v-if="unreadCount" :icon="Check" text @click="markAllRead">
|
|
||||||
全部已读
|
|
||||||
</el-button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="message-list">
|
<section v-loading="loading" class="message-list">
|
||||||
@@ -610,7 +508,7 @@ onMounted(() => load())
|
|||||||
>
|
>
|
||||||
<header class="compose-overview">
|
<header class="compose-overview">
|
||||||
<div>
|
<div>
|
||||||
<span class="panel-kicker">AUTHORIZED DELIVERY</span>
|
<span class="panel-kicker">发送范围</span>
|
||||||
<h3>编写并发送消息</h3>
|
<h3>编写并发送消息</h3>
|
||||||
<p>收件范围始终受当前账号的数据权限约束,发送后不可撤回。</p>
|
<p>收件范围始终受当前账号的数据权限约束,发送后不可撤回。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -861,10 +759,8 @@ onMounted(() => load())
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="消息正文" required class="editor-form-item">
|
<el-form-item label="消息正文" required class="editor-form-item">
|
||||||
<Ckeditor
|
<NotificationRichTextEditor
|
||||||
v-model="messageForm.content"
|
v-model="messageForm.content"
|
||||||
:editor="ClassicEditor"
|
|
||||||
:config="editorConfig"
|
|
||||||
/>
|
/>
|
||||||
<div class="editor-footnote">
|
<div class="editor-footnote">
|
||||||
<span>支持表格、超链接和外链图片;图片请填写 HTTPS 地址,不上传本地文件。</span>
|
<span>支持表格、超链接和外链图片;图片请填写 HTTPS 地址,不上传本地文件。</span>
|
||||||
|
|||||||
@@ -49,8 +49,14 @@ const issueDialog = ref(false)
|
|||||||
const historyDrawer = ref(false)
|
const historyDrawer = ref(false)
|
||||||
const historyLoading = ref(false)
|
const historyLoading = ref(false)
|
||||||
const downloadHistory = ref<any[]>([])
|
const downloadHistory = ref<any[]>([])
|
||||||
|
const historyPage = ref(1)
|
||||||
|
const historyTotal = ref(0)
|
||||||
|
const historyPageSize = 20
|
||||||
const selectedDocument = ref<OfficialDocumentRow>()
|
const selectedDocument = ref<OfficialDocumentRow>()
|
||||||
const filters = reactive<{ type?: DocumentType; status?: DocumentStatus }>({})
|
const filters = reactive<{ type?: DocumentType; status?: DocumentStatus }>({})
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const pageSize = 20
|
||||||
const issueForm = reactive<{ studentId: string; type: DocumentType; purpose: string }>({
|
const issueForm = reactive<{ studentId: string; type: DocumentType; purpose: string }>({
|
||||||
studentId: '',
|
studentId: '',
|
||||||
type: 'Transcript',
|
type: 'Transcript',
|
||||||
@@ -67,11 +73,19 @@ const statusLabels: Record<DocumentStatus, string> = {
|
|||||||
Superseded: '已重签',
|
Superseded: '已重签',
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load(resetPage = false) {
|
||||||
|
if (resetPage) page.value = 1
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/official-documents', { params: filters })
|
const { data } = await http.get('/official-documents', {
|
||||||
documents.value = data
|
params: { ...filters, page: page.value, pageSize },
|
||||||
|
})
|
||||||
|
documents.value = data.items
|
||||||
|
total.value = data.total
|
||||||
|
if (documents.value.length === 0 && page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
await load()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,9 +97,9 @@ async function loadStudents(keyword = '') {
|
|||||||
if (!isManager.value) return
|
if (!isManager.value) return
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/official-documents/students/options', {
|
const { data } = await http.get('/official-documents/students/options', {
|
||||||
params: { keyword: keyword || undefined },
|
params: { keyword: keyword || undefined, page: 1, pageSize: 30 },
|
||||||
})
|
})
|
||||||
students.value = data
|
students.value = data.items
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
@@ -181,10 +195,20 @@ async function reissue(row: OfficialDocumentRow) {
|
|||||||
async function showHistory(row: OfficialDocumentRow) {
|
async function showHistory(row: OfficialDocumentRow) {
|
||||||
selectedDocument.value = row
|
selectedDocument.value = row
|
||||||
historyDrawer.value = true
|
historyDrawer.value = true
|
||||||
|
historyPage.value = 1
|
||||||
|
await loadHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
if (!selectedDocument.value) return
|
||||||
historyLoading.value = true
|
historyLoading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get(`/official-documents/${row.id}/downloads`)
|
const { data } = await http.get(
|
||||||
downloadHistory.value = data
|
`/official-documents/${selectedDocument.value.id}/downloads`,
|
||||||
|
{ params: { page: historyPage.value, pageSize: historyPageSize } },
|
||||||
|
)
|
||||||
|
downloadHistory.value = data.items
|
||||||
|
historyTotal.value = data.total
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -224,14 +248,14 @@ onMounted(load)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="document-toolbar">
|
<section class="document-toolbar">
|
||||||
<el-select v-model="filters.type" clearable placeholder="全部凭证类型" @change="load">
|
<el-select v-model="filters.type" clearable placeholder="全部凭证类型" @change="load(true)">
|
||||||
<el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
|
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load(true)">
|
||||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
|
||||||
<span>共 {{ documents.length }} 份凭证</span>
|
<span>共 {{ total }} 份凭证</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="document-list" v-loading="loading">
|
<section class="document-list" v-loading="loading">
|
||||||
@@ -274,6 +298,15 @@ onMounted(load)
|
|||||||
v-if="!documents.length"
|
v-if="!documents.length"
|
||||||
:description="isManager ? '暂无符合条件的官方凭证' : '暂无电子凭证,可点击上方按钮在线申请'"
|
:description="isManager ? '暂无符合条件的官方凭证' : '暂无电子凭证,可点击上方按钮在线申请'"
|
||||||
/>
|
/>
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > pageSize"
|
||||||
|
v-model:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="document-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="issueDialog" :title="isManager ? '签发官方凭证' : '申请电子凭证'" width="620px">
|
<el-dialog v-model="issueDialog" :title="isManager ? '签发官方凭证' : '申请电子凭证'" width="620px">
|
||||||
@@ -335,6 +368,15 @@ onMounted(load)
|
|||||||
<el-table-column label="来源 IP" prop="ipAddress" min-width="130" />
|
<el-table-column label="来源 IP" prop="ipAddress" min-width="130" />
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-empty v-if="!historyLoading && !downloadHistory.length" description="尚无下载记录" />
|
<el-empty v-if="!historyLoading && !downloadHistory.length" description="尚无下载记录" />
|
||||||
|
<el-pagination
|
||||||
|
v-if="historyTotal > historyPageSize"
|
||||||
|
v-model:current-page="historyPage"
|
||||||
|
:page-size="historyPageSize"
|
||||||
|
:total="historyTotal"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="document-pagination"
|
||||||
|
@current-change="loadHistory"
|
||||||
|
/>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Download, Plus, Promotion, Upload } from '@element-plus/icons-vue'
|
import { Download, Plus, Promotion, Refresh, Search, Upload } from '@element-plus/icons-vue'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
@@ -10,6 +10,13 @@ const auth = useAuthStore()
|
|||||||
const isStudent = computed(() => auth.user?.roles.includes('Student'))
|
const isStudent = computed(() => auth.user?.roles.includes('Student'))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const batches = ref<any[]>([])
|
const batches = ref<any[]>([])
|
||||||
|
const batchKeyword = ref('')
|
||||||
|
const batchStatus = ref<number | undefined>()
|
||||||
|
const batchMetricKind = ref<number | undefined>()
|
||||||
|
const batchDateRange = ref<string[]>([])
|
||||||
|
const batchPage = ref(1)
|
||||||
|
const batchPageSize = 20
|
||||||
|
const batchTotal = ref(0)
|
||||||
const selected = ref<any>(null)
|
const selected = ref<any>(null)
|
||||||
const results = ref<ResultRow[]>([])
|
const results = ref<ResultRow[]>([])
|
||||||
const mine = reactive({ best: [] as any[], history: [] as any[] })
|
const mine = reactive({ best: [] as any[], history: [] as any[] })
|
||||||
@@ -22,11 +29,26 @@ const isScoreMetric = computed(() => selectedMetricKind.value === 3)
|
|||||||
const isLevelMetric = computed(() => selectedMetricKind.value === 2)
|
const isLevelMetric = computed(() => selectedMetricKind.value === 2)
|
||||||
const isPassMetric = computed(() => selectedMetricKind.value === 1)
|
const isPassMetric = computed(() => selectedMetricKind.value === 1)
|
||||||
|
|
||||||
async function load() {
|
async function load(resetPage = false) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data)
|
if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data)
|
||||||
else batches.value = (await http.get('/other-exams/batches')).data
|
else {
|
||||||
|
if (resetPage) batchPage.value = 1
|
||||||
|
const response = (await http.get('/other-exams/batches', {
|
||||||
|
params: {
|
||||||
|
keyword: batchKeyword.value.trim() || undefined,
|
||||||
|
status: batchStatus.value,
|
||||||
|
metricKind: batchMetricKind.value,
|
||||||
|
examDateFrom: batchDateRange.value[0] || undefined,
|
||||||
|
examDateTo: batchDateRange.value[1] || undefined,
|
||||||
|
page: batchPage.value,
|
||||||
|
pageSize: batchPageSize,
|
||||||
|
},
|
||||||
|
})).data
|
||||||
|
batches.value = response.items
|
||||||
|
batchTotal.value = response.total
|
||||||
|
}
|
||||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false }
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
async function openBatch(row: any) {
|
async function openBatch(row: any) {
|
||||||
@@ -93,13 +115,13 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="other-exams-page">
|
<div class="other-exams-page">
|
||||||
<section class="exam-hero"><div><span class="kicker">ASSESSMENT ARCHIVE</span><h1>其他考试成绩</h1><p>{{ isStudent ? '你的证书、等级考试与校外考试,按考试归档,最优结果一目了然。' : '用考试编码归并同一考试,按场次维护成绩,系统自动记录每位学生的参加次数。' }}</p></div><el-button v-if="!isStudent" type="primary" :icon="Plus" @click="dialog = true">新建考试场次</el-button></section>
|
<section class="exam-hero"><div><span class="kicker">考试成绩</span><h1>其他考试成绩</h1><p>{{ isStudent ? '你的证书、等级考试与校外考试,按考试归档,最优结果一目了然。' : '用考试编码归并同一考试,按场次维护成绩,系统自动记录每位学生的参加次数。' }}</p></div><el-button v-if="!isStudent" type="primary" :icon="Plus" @click="dialog = true">新建考试场次</el-button></section>
|
||||||
<template v-if="isStudent">
|
<template v-if="isStudent">
|
||||||
<section class="result-board"><div class="board-title"><div><span class="kicker">BEST OUTCOME</span><h2>我的最优结果</h2></div><span class="board-note">同一考试编码下自动比较</span></div><el-table :data="mine.best" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="最优结果" min-width="150"><template #default="{ row }"><strong class="best-value">{{ displayResult(row) }}</strong></template></el-table-column><el-table-column prop="attemptNumber" label="参加次数" width="100"/></el-table><el-empty v-if="!loading && !mine.best.length" description="暂无已发布的其他考试成绩"/></section>
|
<section class="result-board"><div class="board-title"><div><span class="kicker">成绩汇总</span><h2>我的最优结果</h2></div><span class="board-note">同一考试编码下自动比较</span></div><el-table :data="mine.best" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="最优结果" min-width="150"><template #default="{ row }"><strong class="best-value">{{ displayResult(row) }}</strong></template></el-table-column><el-table-column prop="attemptNumber" label="参加次数" width="100"/></el-table><el-empty v-if="!loading && !mine.best.length" description="暂无已发布的其他考试成绩"/></section>
|
||||||
<section class="result-board history-board"><div class="board-title"><div><span class="kicker">FULL HISTORY</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section>
|
<section class="result-board history-board"><div class="board-title"><div><span class="kicker">历史记录</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="batch-panel"><div class="panel-heading"><div><span class="kicker">EXAM SESSIONS</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} 次` : '草稿' }}</el-tag></template></el-table-column></el-table></section>
|
<section class="batch-panel"><div class="panel-heading"><div><span class="kicker">考试场次</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><div class="batch-filters"><el-input v-model="batchKeyword" clearable placeholder="考试编码、名称或组织方" @keyup.enter="load(true)" @clear="load(true)"/><el-select v-model="batchStatus" clearable placeholder="全部状态" @change="load(true)"><el-option label="草稿" :value="1"/><el-option label="已发布" :value="2"/></el-select><el-select v-model="batchMetricKind" clearable placeholder="全部方式" @change="load(true)"><el-option label="合格制" :value="1"/><el-option label="等级制" :value="2"/><el-option label="分数制" :value="3"/></el-select><el-date-picker v-model="batchDateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" @change="load(true)"/><el-button :icon="Search" @click="load(true)">查询</el-button><el-button :icon="Refresh" @click="() => load()">刷新</el-button></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} 次` : '草稿' }}</el-tag></template></el-table-column></el-table><el-pagination v-if="batchTotal > batchPageSize" small background layout="total, prev, pager, next" :current-page="batchPage" :page-size="batchPageSize" :total="batchTotal" @current-change="(page: number) => { batchPage = page; load() }"/></section>
|
||||||
<section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section>
|
<section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section>
|
||||||
</template>
|
</template>
|
||||||
<el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog>
|
<el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog>
|
||||||
@@ -113,6 +135,7 @@ onMounted(load)
|
|||||||
.kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; }
|
.kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; }
|
||||||
@media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } }
|
@media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } }
|
||||||
.editor-heading > div:first-child { min-width: 0; }
|
.editor-heading > div:first-child { min-width: 0; }
|
||||||
|
.batch-filters { display:flex; flex-wrap:wrap; gap:8px; padding:0 22px 16px; }.batch-filters .el-input { max-width:280px; }.batch-filters .el-select { width:120px; }.batch-panel :deep(.el-pagination) { justify-content:flex-end; padding:14px 22px; }
|
||||||
.editor-actions { flex: 0 0 auto; }
|
.editor-actions { flex: 0 0 auto; }
|
||||||
.upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; }
|
.upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; }
|
||||||
.publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; }
|
.publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; }
|
||||||
|
|||||||
@@ -807,6 +807,16 @@ function changeEntryKind() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function preflightSchedule() {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/schedules/plans/${selected.value.id}/preflight`)
|
||||||
|
const message = data.unscheduledTasks
|
||||||
|
? `尚有 ${data.unscheduledTasks} 个教学班未安排:\n${data.messages.join('\n')}`
|
||||||
|
: '当前草稿已覆盖全部需要排课的教学班。'
|
||||||
|
await ElMessageBox.alert(message, `排课前检查 · 已覆盖 ${data.scheduledTasks}/${data.totalTasks}`, { confirmButtonText: '知道了' })
|
||||||
|
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEntry() {
|
async function saveEntry() {
|
||||||
if (!entryForm.teachingTaskId ||
|
if (!entryForm.teachingTaskId ||
|
||||||
((entryForm.kind === 'Experiment' ||
|
((entryForm.kind === 'Experiment' ||
|
||||||
@@ -937,6 +947,13 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
复制调整
|
复制调整
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="isDraft"
|
||||||
|
:disabled="scheduleJobLoading"
|
||||||
|
@click="preflightSchedule"
|
||||||
|
>
|
||||||
|
排课前检查
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isDraft"
|
v-if="isDraft"
|
||||||
type="warning"
|
type="warning"
|
||||||
|
|||||||
@@ -70,6 +70,27 @@ interface GradeItem {
|
|||||||
publishedAt?: string
|
publishedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DashboardGreeting {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
label: string
|
||||||
|
narrative: string
|
||||||
|
insights: Array<{
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
hint: string
|
||||||
|
tone: 'calm' | 'positive' | 'attention'
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarningItem {
|
||||||
|
id: string
|
||||||
|
type: number
|
||||||
|
status: number
|
||||||
|
detail: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -79,6 +100,9 @@ const notifications = ref<NotificationItem[]>([])
|
|||||||
const unreadCount = ref(0)
|
const unreadCount = ref(0)
|
||||||
const exams = ref<ExamItem[]>([])
|
const exams = ref<ExamItem[]>([])
|
||||||
const grades = ref<GradeItem[]>([])
|
const grades = ref<GradeItem[]>([])
|
||||||
|
const dashboardGreeting = ref<DashboardGreeting | null>(null)
|
||||||
|
const warnings = ref<WarningItem[]>([])
|
||||||
|
const graduation = ref<any>(null)
|
||||||
const now = ref(new Date())
|
const now = ref(new Date())
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
@@ -159,6 +183,11 @@ const recentGrades = computed(() =>
|
|||||||
.slice(0, 4),
|
.slice(0, 4),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const activeWarnings = computed(() => warnings.value.filter((warning) => warning.status === 1))
|
||||||
|
const radarSummary = computed(() => activeWarnings.value.length
|
||||||
|
? `发现 ${activeWarnings.value.length} 项需要你关注的学习风险,建议优先处理下方提示。`
|
||||||
|
: '系统暂未发现需要你处理的学业风险,继续保持当前学习节奏。')
|
||||||
|
|
||||||
function parseDateOnly(value?: string) {
|
function parseDateOnly(value?: string) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||||
@@ -281,6 +310,9 @@ async function loadOverview() {
|
|||||||
http.get('/notifications', { params: { page: 1, pageSize: 5 } }),
|
http.get('/notifications', { params: { page: 1, pageSize: 5 } }),
|
||||||
http.get('/exams/my-schedule'),
|
http.get('/exams/my-schedule'),
|
||||||
http.get('/grades/student/transcript'),
|
http.get('/grades/student/transcript'),
|
||||||
|
http.get<DashboardGreeting>('/dashboard/greeting'),
|
||||||
|
http.get<WarningItem[]>('/warnings/my-warnings'),
|
||||||
|
http.get('/student/academic-planning'),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (results[0].status === 'fulfilled') {
|
if (results[0].status === 'fulfilled') {
|
||||||
@@ -304,6 +336,15 @@ async function loadOverview() {
|
|||||||
} else {
|
} else {
|
||||||
failedSections.value.push('考试成绩')
|
failedSections.value.push('考试成绩')
|
||||||
}
|
}
|
||||||
|
if (results[4].status === 'fulfilled') {
|
||||||
|
dashboardGreeting.value = results[4].value.data
|
||||||
|
}
|
||||||
|
if (results[5].status === 'fulfilled') {
|
||||||
|
warnings.value = results[5].value.data
|
||||||
|
} else {
|
||||||
|
failedSections.value.push('学业风险雷达')
|
||||||
|
}
|
||||||
|
if (results[6].status === 'fulfilled') graduation.value = results[6].value.data
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,9 +355,9 @@ onMounted(loadOverview)
|
|||||||
<div v-loading="loading" class="student-overview">
|
<div v-loading="loading" class="student-overview">
|
||||||
<section class="student-overview-hero">
|
<section class="student-overview-hero">
|
||||||
<div class="student-hero-copy">
|
<div class="student-hero-copy">
|
||||||
<span class="section-kicker">MY ACADEMIC DAY</span>
|
<span class="section-kicker">我的学习安排</span>
|
||||||
<h2>{{ greeting }},{{ auth.user?.displayName ?? '同学' }}</h2>
|
<h2>{{ dashboardGreeting?.title ?? `${greeting},${auth.user?.displayName ?? '同学'}` }}</h2>
|
||||||
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p>
|
<p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="today-status">
|
<div class="today-status">
|
||||||
<span>今日课程</span>
|
<span>今日课程</span>
|
||||||
@@ -325,6 +366,39 @@ onMounted(loadOverview)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="dashboardGreeting?.insights.length" class="student-greeting-insights" :aria-label="dashboardGreeting.label">
|
||||||
|
<span>{{ dashboardGreeting.label }}</span>
|
||||||
|
<article v-for="insight in dashboardGreeting.insights" :key="insight.label" :class="insight.tone">
|
||||||
|
<small>{{ insight.label }}</small>
|
||||||
|
<strong>{{ insight.value }}</strong>
|
||||||
|
<em>{{ insight.hint }}</em>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
|
||||||
|
|
||||||
|
<section class="student-risk-radar" :class="{ attention: activeWarnings.length }">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span class="panel-kicker">学业提醒</span>
|
||||||
|
<h3>学业风险雷达</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="router.push('/warnings')">查看全部 <el-icon><ArrowRight /></el-icon></button>
|
||||||
|
</header>
|
||||||
|
<p>{{ radarSummary }}</p>
|
||||||
|
<div v-if="activeWarnings.length" class="risk-list">
|
||||||
|
<button v-for="warning in activeWarnings.slice(0, 3)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||||
|
<span>{{ categoryLabels.Warning }}</span>
|
||||||
|
<strong>{{ warning.detail }}</strong>
|
||||||
|
<i>去处理 →</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="graduation?.baseline" class="graduation-nav">
|
||||||
|
<div><span class="panel-kicker">毕业进度</span><h3>毕业导航</h3><p>已完成 {{ graduation.baseline.planCompletedCredits ?? 0 }} 学分,距离培养方案要求还差 {{ graduation.baseline.creditGap ?? 0 }} 学分。</p></div>
|
||||||
|
<button type="button" @click="router.push('/academic-planning')">查看毕业航线 <el-icon><ArrowRight /></el-icon></button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="failedSections.length"
|
v-if="failedSections.length"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -539,6 +613,63 @@ onMounted(loadOverview)
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.student-greeting-insights {
|
||||||
|
padding: 15px 21px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 105px repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #dce6eb;
|
||||||
|
background: #f7faf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-greeting-insights > span {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--teal);
|
||||||
|
font: 700 10px/1.5 Consolas, monospace;
|
||||||
|
letter-spacing: .11em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-greeting-insights article {
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
border-left: 2px solid #a9bcc6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-greeting-insights article.positive { border-color: var(--teal); }
|
||||||
|
.student-greeting-insights article.attention { border-color: #c4812a; }
|
||||||
|
.student-greeting-insights small { color: #667488; font-size: 10px; }
|
||||||
|
.student-greeting-insights strong { color: var(--ink); font-size: 19px; }
|
||||||
|
.student-greeting-insights em { overflow: hidden; color: var(--muted); font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.student-greeting-narrative {
|
||||||
|
margin: -5px 0 0;
|
||||||
|
padding: 0 3px;
|
||||||
|
color: #526078;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-risk-radar {
|
||||||
|
padding: 20px 22px;
|
||||||
|
border: 1px solid #dce6eb;
|
||||||
|
background: #fbfdfd;
|
||||||
|
}
|
||||||
|
.graduation-nav{padding:19px 22px;display:flex;justify-content:space-between;align-items:center;gap:18px;border:1px solid #dce6eb;background:#f7faf9}.graduation-nav h3{margin:7px 0;color:var(--ink);font-size:17px}.graduation-nav p{margin:0;color:#59677a;font-size:12px;line-height:1.6}.graduation-nav button{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--indigo);font-size:12px;white-space:nowrap}
|
||||||
|
|
||||||
|
.student-risk-radar.attention { border-left: 3px solid #c4812a; background: #fffcf6; }
|
||||||
|
.student-risk-radar header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||||
|
.student-risk-radar h3 { margin: 6px 0 0; color: var(--ink); font-size: 17px; }
|
||||||
|
.student-risk-radar header button { padding: 4px 0; display: inline-flex; align-items: center; gap: 4px; border: 0; color: #526078; background: transparent; font-size: 12px; white-space: nowrap; }
|
||||||
|
.student-risk-radar header button:hover { color: var(--indigo); }
|
||||||
|
.student-risk-radar > p { margin: 13px 0 0; color: #59677a; font-size: 12px; line-height: 1.7; }
|
||||||
|
.risk-list { margin-top: 13px; display: grid; }
|
||||||
|
.risk-list button { padding: 12px 0; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; text-align: left; border: 0; border-top: 1px solid #eee6d8; background: transparent; }
|
||||||
|
.risk-list span { align-self: center; color: #a16b1c; font: 700 9px/1.4 Consolas, monospace; letter-spacing: .06em; }
|
||||||
|
.risk-list strong { color: #38475a; font-size: 12px; font-weight: 600; line-height: 1.55; }
|
||||||
|
.risk-list i { align-self: center; color: #9a743d; font-size: 11px; font-style: normal; white-space: nowrap; }
|
||||||
|
|
||||||
.today-status {
|
.today-status {
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -871,12 +1002,18 @@ onMounted(loadOverview)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.student-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
|
||||||
.student-overview-grid {
|
.student-overview-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
|
||||||
|
.student-risk-radar { padding: 18px; }
|
||||||
|
.graduation-nav{padding:18px;align-items:flex-start;flex-direction:column}
|
||||||
|
.risk-list button { grid-template-columns: 1fr auto; }
|
||||||
|
.risk-list span { grid-column: 1 / -1; }
|
||||||
.student-overview {
|
.student-overview {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const selected = ref<any | null>(null)
|
|||||||
const reviewApproved = ref(true)
|
const reviewApproved = ref(true)
|
||||||
const applyForm = reactive({ type: '', reason: '' })
|
const applyForm = reactive({ type: '', reason: '' })
|
||||||
const reviewForm = reactive({ comment: '' })
|
const reviewForm = reactive({ comment: '' })
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const actionableTotal = ref(0)
|
||||||
|
const finishedTotal = ref(0)
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
Suspension: '休学',
|
Suspension: '休学',
|
||||||
@@ -48,10 +53,6 @@ const steps = [
|
|||||||
{ label: '学院审核', state: 'CounselorApproved' },
|
{ label: '学院审核', state: 'CounselorApproved' },
|
||||||
{ label: '校级审批', state: 'CollegeApproved' },
|
{ label: '校级审批', state: 'CollegeApproved' },
|
||||||
]
|
]
|
||||||
const currentQueue = computed(() => changes.value.filter(canReview))
|
|
||||||
const finishedCount = computed(() =>
|
|
||||||
changes.value.filter((x) => ['Approved', 'Rejected', 'Cancelled'].includes(x.state)).length)
|
|
||||||
|
|
||||||
function dateText(value?: string) {
|
function dateText(value?: string) {
|
||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
@@ -90,14 +91,24 @@ function stateTagType(state: ChangeState) {
|
|||||||
return 'warning'
|
return 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load(resetPage = false) {
|
||||||
|
if (resetPage) page.value = 1
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const requests = [http.get('/student-status-changes')]
|
const requests = [http.get('/student-status-changes', {
|
||||||
|
params: { page: page.value, pageSize },
|
||||||
|
})]
|
||||||
if (isStudent.value) requests.push(http.get('/student-status-changes/options'))
|
if (isStudent.value) requests.push(http.get('/student-status-changes/options'))
|
||||||
const [changeResponse, optionResponse] = await Promise.all(requests)
|
const [changeResponse, optionResponse] = await Promise.all(requests)
|
||||||
changes.value = changeResponse.data
|
changes.value = changeResponse.data.items
|
||||||
|
total.value = changeResponse.data.total
|
||||||
|
actionableTotal.value = changeResponse.data.actionableTotal
|
||||||
|
finishedTotal.value = changeResponse.data.finishedTotal
|
||||||
if (optionResponse) options.value = optionResponse.data
|
if (optionResponse) options.value = optionResponse.data
|
||||||
|
if (changes.value.length === 0 && page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
await load()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -118,7 +129,7 @@ async function submitApply() {
|
|||||||
await http.post('/student-status-changes', applyForm)
|
await http.post('/student-status-changes', applyForm)
|
||||||
applyDialog.value = false
|
applyDialog.value = false
|
||||||
ElMessage.success('申请已提交,等待辅导员审核。')
|
ElMessage.success('申请已提交,等待辅导员审核。')
|
||||||
await load()
|
await load(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
@@ -177,7 +188,7 @@ onMounted(load)
|
|||||||
:disabled="options?.hasPending || !options?.types?.length"
|
:disabled="options?.hasPending || !options?.types?.length"
|
||||||
@click="openApply"
|
@click="openApply"
|
||||||
>发起申请</el-button>
|
>发起申请</el-button>
|
||||||
<el-button v-else :icon="RefreshRight" @click="load">刷新队列</el-button>
|
<el-button v-else :icon="RefreshRight" @click="() => load()">刷新队列</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="isStudent && options" class="status-identity">
|
<section v-if="isStudent && options" class="status-identity">
|
||||||
@@ -194,9 +205,9 @@ onMounted(load)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="!isStudent" class="status-review-summary">
|
<section v-if="!isStudent" class="status-review-summary">
|
||||||
<div><span>当前待我审核</span><b>{{ currentQueue.length }}</b><small>件</small></div>
|
<div><span>当前待我审核</span><b>{{ actionableTotal }}</b><small>件</small></div>
|
||||||
<div><span>辖区申请总数</span><b>{{ changes.length }}</b><small>件</small></div>
|
<div><span>辖区申请总数</span><b>{{ total }}</b><small>件</small></div>
|
||||||
<div><span>已结束</span><b>{{ finishedCount }}</b><small>件</small></div>
|
<div><span>已结束</span><b>{{ finishedTotal }}</b><small>件</small></div>
|
||||||
<p>系统仅开放当前审核层级的操作,所有越级请求都会由服务端拒绝。</p>
|
<p>系统仅开放当前审核层级的操作,所有越级请求都会由服务端拒绝。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -243,6 +254,15 @@ onMounted(load)
|
|||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!changes.length && !loading" :description="isStudent ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" />
|
<el-empty v-if="!changes.length && !loading" :description="isStudent ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" />
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > pageSize"
|
||||||
|
v-model:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="table-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px">
|
<el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px">
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ const isNativeApp = Capacitor.isNativePlatform()
|
|||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
const fileInput = ref<HTMLInputElement>()
|
const fileInput = ref<HTMLInputElement>()
|
||||||
const termId = ref<string>()
|
const termId = ref<string>()
|
||||||
|
const taskKeyword = ref('')
|
||||||
|
const taskPage = ref(1)
|
||||||
|
const taskPageSize = 12
|
||||||
|
const taskTotal = ref(0)
|
||||||
|
const sheetKeyword = ref('')
|
||||||
|
const sheetStatus = ref<string>()
|
||||||
|
const sheetMethod = ref<string>()
|
||||||
|
const sheetPage = ref(1)
|
||||||
|
const sheetPageSize = 12
|
||||||
|
const sheetTotal = ref(0)
|
||||||
const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
|
const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
|
||||||
const studentKeyword = ref('')
|
const studentKeyword = ref('')
|
||||||
const classFilter = ref('')
|
const classFilter = ref('')
|
||||||
@@ -108,18 +118,27 @@ const filteredStudents = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks(resetPage = false) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
tasks.value = (await http.get('/attendance/my-tasks', {
|
if (resetPage) taskPage.value = 1
|
||||||
params: { academicTermId: termId.value || undefined },
|
const response = (await http.get('/attendance/my-tasks', {
|
||||||
|
params: {
|
||||||
|
academicTermId: termId.value || undefined,
|
||||||
|
keyword: taskKeyword.value.trim() || undefined,
|
||||||
|
page: taskPage.value,
|
||||||
|
pageSize: taskPageSize,
|
||||||
|
},
|
||||||
})).data
|
})).data
|
||||||
|
tasks.value = response.items
|
||||||
|
taskTotal.value = response.total
|
||||||
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
|
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
|
||||||
?? tasks.value[0]
|
?? tasks.value[0]
|
||||||
if (preferred) await selectTask(preferred)
|
if (preferred) await selectTask(preferred)
|
||||||
else {
|
else {
|
||||||
selectedTask.value = null
|
selectedTask.value = null
|
||||||
sheets.value = []
|
sheets.value = []
|
||||||
|
sheetTotal.value = 0
|
||||||
statistics.value = null
|
statistics.value = null
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -137,13 +156,37 @@ async function selectTask(task: any) {
|
|||||||
studentKeyword.value = ''
|
studentKeyword.value = ''
|
||||||
classFilter.value = ''
|
classFilter.value = ''
|
||||||
attentionFilter.value = ''
|
attentionFilter.value = ''
|
||||||
|
sheetPage.value = 1
|
||||||
|
sheetKeyword.value = ''
|
||||||
|
sheetStatus.value = undefined
|
||||||
|
sheetMethod.value = undefined
|
||||||
|
await loadSheets()
|
||||||
|
if (activeMode.value === 'statistics') await loadStatistics()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSheets(resetPage = false) {
|
||||||
|
if (!selectedTask.value) return
|
||||||
try {
|
try {
|
||||||
sheets.value = (await http.get('/attendance/sheets', {
|
if (resetPage) sheetPage.value = 1
|
||||||
params: { teachingTaskId: task.id },
|
const response = (await http.get('/attendance/sheets', {
|
||||||
|
params: {
|
||||||
|
teachingTaskId: selectedTask.value.id,
|
||||||
|
keyword: sheetKeyword.value.trim() || undefined,
|
||||||
|
status: sheetStatus.value,
|
||||||
|
checkInMethod: sheetMethod.value,
|
||||||
|
page: sheetPage.value,
|
||||||
|
pageSize: sheetPageSize,
|
||||||
|
},
|
||||||
})).data
|
})).data
|
||||||
if (activeMode.value === 'statistics') await loadStatistics()
|
sheets.value = response.items
|
||||||
|
sheetTotal.value = response.total
|
||||||
|
if (!sheets.value.some((sheet: any) => sheet.id === selectedSheet.value?.id)) {
|
||||||
|
selectedSheet.value = null
|
||||||
|
sheetDetail.value = null
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sheets.value = []
|
sheets.value = []
|
||||||
|
sheetTotal.value = 0
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,14 +723,22 @@ onUnmounted(() => {
|
|||||||
<h2>教学点名</h2>
|
<h2>教学点名</h2>
|
||||||
<p>完成课堂点名,并持续查看本课程每位学生的出勤表现。</p>
|
<p>完成课堂点名,并持续查看本课程每位学生的出勤表现。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button :icon="Refresh" @click="loadTasks">刷新</el-button>
|
<el-button :icon="Refresh" @click="() => loadTasks()">刷新</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="attendance-toolbar">
|
<section class="attendance-toolbar">
|
||||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
|
<el-input
|
||||||
|
v-model="taskKeyword"
|
||||||
|
clearable
|
||||||
|
placeholder="课程、任务号或教学班"
|
||||||
|
@keyup.enter="loadTasks(true)"
|
||||||
|
@clear="loadTasks(true)"
|
||||||
|
/>
|
||||||
|
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks(true)">
|
||||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<span>共 {{ tasks.length }} 个教学班</span>
|
<el-button :icon="Search" @click="loadTasks(true)">查询</el-button>
|
||||||
|
<span>共 {{ taskTotal }} 个教学班</span>
|
||||||
<el-radio-group v-model="activeMode" class="mode-switch" size="small">
|
<el-radio-group v-model="activeMode" class="mode-switch" size="small">
|
||||||
<el-radio-button value="rollcall">点名记录</el-radio-button>
|
<el-radio-button value="rollcall">点名记录</el-radio-button>
|
||||||
<el-radio-button value="statistics">课程统计</el-radio-button>
|
<el-radio-button value="statistics">课程统计</el-radio-button>
|
||||||
@@ -712,6 +763,16 @@ onUnmounted(() => {
|
|||||||
<small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small>
|
<small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small>
|
||||||
</button>
|
</button>
|
||||||
<el-empty v-if="!tasks.length" description="没有可点名的教学班" />
|
<el-empty v-if="!tasks.length" description="没有可点名的教学班" />
|
||||||
|
<el-pagination
|
||||||
|
v-if="taskTotal > taskPageSize"
|
||||||
|
small
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:current-page="taskPage"
|
||||||
|
:page-size="taskPageSize"
|
||||||
|
:total="taskTotal"
|
||||||
|
@current-change="(page: number) => { taskPage = page; loadTasks() }"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<template v-if="activeMode === 'rollcall'">
|
<template v-if="activeMode === 'rollcall'">
|
||||||
@@ -727,6 +788,25 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button>
|
<el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="attendance-sheet-filters">
|
||||||
|
<el-input
|
||||||
|
v-model="sheetKeyword"
|
||||||
|
clearable
|
||||||
|
placeholder="考勤表名称或备注"
|
||||||
|
@keyup.enter="loadSheets(true)"
|
||||||
|
@clear="loadSheets(true)"
|
||||||
|
/>
|
||||||
|
<el-select v-model="sheetStatus" clearable placeholder="全部状态" @change="loadSheets(true)">
|
||||||
|
<el-option label="草稿" value="Draft" />
|
||||||
|
<el-option label="已提交" value="Submitted" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="sheetMethod" clearable placeholder="全部方式" @change="loadSheets(true)">
|
||||||
|
<el-option label="教师点名" value="Manual" />
|
||||||
|
<el-option label="扫码签到" value="QrCode" />
|
||||||
|
<el-option label="定位签到" value="Location" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Search" @click="loadSheets(true)">筛选</el-button>
|
||||||
|
</div>
|
||||||
<div class="attendance-sheet-list">
|
<div class="attendance-sheet-list">
|
||||||
<button
|
<button
|
||||||
v-for="sheet in sheets"
|
v-for="sheet in sheets"
|
||||||
@@ -766,6 +846,16 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
<el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" />
|
<el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" />
|
||||||
</div>
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
v-if="sheetTotal > sheetPageSize"
|
||||||
|
small
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:current-page="sheetPage"
|
||||||
|
:page-size="sheetPageSize"
|
||||||
|
:total="sheetTotal"
|
||||||
|
@current-change="(page: number) => { sheetPage = page; loadSheets() }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -1192,7 +1282,8 @@ onUnmounted(() => {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #fbfcfd;
|
background: #fbfcfd;
|
||||||
}
|
}
|
||||||
.attendance-toolbar .el-select { width: 260px; }
|
.attendance-toolbar .el-input,
|
||||||
|
.attendance-toolbar .el-select { width: 220px; }
|
||||||
.attendance-toolbar > span { color: var(--muted); font-size: 12px; }
|
.attendance-toolbar > span { color: var(--muted); font-size: 12px; }
|
||||||
.mode-switch { margin-left: auto; }
|
.mode-switch { margin-left: auto; }
|
||||||
.attendance-workspace {
|
.attendance-workspace {
|
||||||
@@ -1256,6 +1347,21 @@ onUnmounted(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.attendance-sheet-list { flex: 1; overflow-y: auto; }
|
.attendance-sheet-list { flex: 1; overflow-y: auto; }
|
||||||
|
.attendance-sheet-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
.attendance-sheet-filters .el-input { flex: 1; min-width: 140px; }
|
||||||
|
.attendance-sheet-filters .el-select { width: 115px; }
|
||||||
|
.attendance-task-list :deep(.el-pagination),
|
||||||
|
.attendance-detail :deep(.el-pagination) {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
.attendance-sheet-list > button {
|
.attendance-sheet-list > button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@@ -1708,7 +1814,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.attendance-toolbar { align-items: stretch; flex-wrap: wrap; }
|
.attendance-toolbar { align-items: stretch; flex-wrap: wrap; }
|
||||||
|
.attendance-toolbar .el-input,
|
||||||
.attendance-toolbar .el-select { width: 100%; }
|
.attendance-toolbar .el-select { width: 100%; }
|
||||||
|
.attendance-sheet-filters { flex-wrap: wrap; }
|
||||||
|
.attendance-sheet-filters .el-input,
|
||||||
|
.attendance-sheet-filters .el-select { width: 100%; flex-basis: 100%; }
|
||||||
.attendance-toolbar > span { align-self: center; }
|
.attendance-toolbar > span { align-self: center; }
|
||||||
.mode-switch { margin-left: auto; }
|
.mode-switch { margin-left: auto; }
|
||||||
.statistics-head { align-items: flex-start; flex-direction: column; }
|
.statistics-head { align-items: flex-start; flex-direction: column; }
|
||||||
|
|||||||
@@ -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 submitDialog = ref(false)
|
||||||
const reviewDialog = ref(false)
|
const reviewDialog = ref(false)
|
||||||
const assignmentDialog = ref(false)
|
const assignmentDialog = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const pageSize = 20
|
||||||
const form = reactive<Record<string, any>>({})
|
const form = reactive<Record<string, any>>({})
|
||||||
const reviewForm = reactive<Record<string, any>>({})
|
const reviewForm = reactive<Record<string, any>>({})
|
||||||
const assignmentForm = reactive<Record<string, any>>({})
|
const assignmentForm = reactive<Record<string, any>>({})
|
||||||
@@ -39,14 +42,22 @@ const statusTypes: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
|
|||||||
Withdrawn: 'info',
|
Withdrawn: 'info',
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load(resetPage = false) {
|
||||||
|
if (resetPage) page.value = 1
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const endpoint = isTeacher.value
|
const endpoint = isTeacher.value
|
||||||
? '/teacher-course-applications/mine'
|
? '/teacher-course-applications/mine'
|
||||||
: '/teacher-course-applications/reviews'
|
: '/teacher-course-applications/reviews'
|
||||||
const { data } = await http.get(endpoint, { params: filters })
|
const { data } = await http.get(endpoint, {
|
||||||
rows.value = data
|
params: { ...filters, page: page.value, pageSize },
|
||||||
|
})
|
||||||
|
rows.value = data.items
|
||||||
|
total.value = data.total
|
||||||
|
if (rows.value.length === 0 && page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
await load()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -195,14 +206,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<section class="data-card">
|
<section class="data-card">
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load">
|
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load(true)">
|
||||||
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
|
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load(true)">
|
||||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
|
||||||
<span>共 {{ rows.length }} 条</span>
|
<span>共 {{ total }} 条</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="rows" class="data-table">
|
<el-table v-loading="loading" :data="rows" class="data-table">
|
||||||
@@ -235,6 +246,15 @@ onMounted(async () => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty :description="isTeacher ? '尚未申报授课科目' : '没有待处理的授课申报'" /></template>
|
<template #empty><el-empty :description="isTeacher ? '尚未申报授课科目' : '没有待处理的授课申报'" /></template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > pageSize"
|
||||||
|
v-model:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="table-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="submitDialog" title="申报授课科目" width="600px">
|
<el-dialog v-model="submitDialog" title="申报授课科目" width="600px">
|
||||||
|
|||||||
+141
-36
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { downloadApiFile } from '../api/excel'
|
import { downloadApiFile, downloadApiPostFile } from '../api/excel'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ const isManager = computed(() =>
|
|||||||
)
|
)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const exportingPdf = ref(false)
|
const exportingPdf = ref(false)
|
||||||
|
const batchExporting = ref(false)
|
||||||
|
const batchResourceIds = ref<string[]>([])
|
||||||
const calendarDialogVisible = ref(false)
|
const calendarDialogVisible = ref(false)
|
||||||
const calendarLoading = ref(false)
|
const calendarLoading = ref(false)
|
||||||
const calendarActionLoading = ref(false)
|
const calendarActionLoading = ref(false)
|
||||||
@@ -61,7 +63,6 @@ const viewMode = ref<'overview' | 'week' | 'day'>('week')
|
|||||||
const selectedWeek = ref(1)
|
const selectedWeek = ref(1)
|
||||||
const selectedDay = ref(1)
|
const selectedDay = ref(1)
|
||||||
const loadedTermId = ref('')
|
const loadedTermId = ref('')
|
||||||
const exportArea = ref<HTMLElement | null>(null)
|
|
||||||
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
|
||||||
const planStatusLabels: Record<string, string> = {
|
const planStatusLabels: Record<string, string> = {
|
||||||
@@ -104,6 +105,11 @@ const selectedResourceId = computed(() => {
|
|||||||
if (resourceType.value === 'Classroom') return classroomId.value
|
if (resourceType.value === 'Classroom') return classroomId.value
|
||||||
return classId.value
|
return classId.value
|
||||||
})
|
})
|
||||||
|
const batchResources = computed(() => {
|
||||||
|
if (resourceType.value === 'Teacher') return filteredTeachers.value
|
||||||
|
if (resourceType.value === 'Classroom') return filteredClassrooms.value
|
||||||
|
return filteredClasses.value
|
||||||
|
})
|
||||||
const slotMap = computed<Map<number, any>>(() =>
|
const slotMap = computed<Map<number, any>>(() =>
|
||||||
new Map<number, any>(
|
new Map<number, any>(
|
||||||
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
|
||||||
@@ -403,7 +409,11 @@ async function loadPublicOptions() {
|
|||||||
termId.value = defaultAcademicTermId(data.terms)
|
termId.value = defaultAcademicTermId(data.terms)
|
||||||
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
|
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
|
||||||
?? ''
|
?? ''
|
||||||
const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable)
|
const studentClassId = auth.user?.roles.includes('Student')
|
||||||
|
? (await http.get('/timetables/my-class')).data.administrativeClassId
|
||||||
|
: undefined
|
||||||
|
const initialClass = data.classes.find((item: any) => item.id === studentClassId)
|
||||||
|
?? data.classes.find((item: any) => item.hasPublishedTimetable)
|
||||||
?? data.classes[0]
|
?? data.classes[0]
|
||||||
classId.value = initialClass?.id ?? ''
|
classId.value = initialClass?.id ?? ''
|
||||||
setClassFilters(initialClass)
|
setClassFilters(initialClass)
|
||||||
@@ -440,6 +450,7 @@ async function loadManagementOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onTermChanged() {
|
async function onTermChanged() {
|
||||||
|
batchResourceIds.value = []
|
||||||
if (isManager.value) await loadManagementOptions()
|
if (isManager.value) await loadManagementOptions()
|
||||||
await loadTimetable()
|
await loadTimetable()
|
||||||
}
|
}
|
||||||
@@ -468,6 +479,7 @@ function onBuildingChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onResourceTypeChanged() {
|
function onResourceTypeChanged() {
|
||||||
|
batchResourceIds.value = []
|
||||||
collegeId.value = ''
|
collegeId.value = ''
|
||||||
majorId.value = ''
|
majorId.value = ''
|
||||||
grade.value = undefined
|
grade.value = undefined
|
||||||
@@ -483,6 +495,67 @@ function onResourceTypeChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceLabel(item: any) {
|
||||||
|
if (resourceType.value === 'Teacher') return `${item.teacherNumber} · ${item.name}`
|
||||||
|
if (resourceType.value === 'Classroom') return `${item.buildingName} · ${item.name}`
|
||||||
|
return `${item.code} · ${item.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFilteredResources() {
|
||||||
|
if (batchResources.value.length > 100) {
|
||||||
|
ElMessage.warning('单次最多导出 100 项,请进一步缩小筛选范围。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchResourceIds.value = batchResources.value.map((item: any) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchExportPayload() {
|
||||||
|
if (!batchResourceIds.value.length) {
|
||||||
|
ElMessage.warning('请先从筛选结果中选择要导出的课表。')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resourceType: resourceType.value,
|
||||||
|
resourceIds: batchResourceIds.value,
|
||||||
|
academicTermId: termId.value,
|
||||||
|
schedulePlanId: planId.value || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBatchExcel() {
|
||||||
|
const payload = batchExportPayload()
|
||||||
|
if (!payload) return
|
||||||
|
batchExporting.value = true
|
||||||
|
try {
|
||||||
|
await downloadApiPostFile(
|
||||||
|
'/timetables/management/export/batch.xlsx',
|
||||||
|
payload,
|
||||||
|
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.xlsx`,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
batchExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBatchPdf() {
|
||||||
|
const payload = batchExportPayload()
|
||||||
|
if (!payload) return
|
||||||
|
batchExporting.value = true
|
||||||
|
try {
|
||||||
|
await downloadApiPostFile(
|
||||||
|
'/timetables/management/export/batch.pdf',
|
||||||
|
payload,
|
||||||
|
`课表批量导出-${terms.value.find((item: any) => item.id === termId.value)?.name ?? ''}.pdf`,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
||||||
|
} finally {
|
||||||
|
batchExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTimetable() {
|
async function loadTimetable() {
|
||||||
if (!termId.value) {
|
if (!termId.value) {
|
||||||
timetable.value = null
|
timetable.value = null
|
||||||
@@ -560,35 +633,23 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportPdf() {
|
async function exportPdf() {
|
||||||
if (!exportArea.value || !timetable.value) return
|
|
||||||
exportingPdf.value = true
|
exportingPdf.value = true
|
||||||
try {
|
try {
|
||||||
await nextTick()
|
if (isMine.value) {
|
||||||
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
await downloadApiFile(`/timetables/mine/export.pdf?academicTermId=${termId.value}`, '我的课表.pdf')
|
||||||
import('html2canvas'),
|
} else if (isTeacherView.value && teacherIdParam.value) {
|
||||||
import('jspdf'),
|
await downloadApiFile(`/timetables/teachers/${teacherIdParam.value}/export.pdf?academicTermId=${termId.value}`, '教师课表.pdf')
|
||||||
])
|
} else if (isManager.value) {
|
||||||
const canvas = await html2canvas(exportArea.value, {
|
const query = new URLSearchParams({
|
||||||
scale: 2,
|
resourceType: resourceType.value,
|
||||||
useCORS: true,
|
resourceId: selectedResourceId.value,
|
||||||
backgroundColor: '#ffffff',
|
academicTermId: termId.value,
|
||||||
})
|
})
|
||||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
if (planId.value) query.set('schedulePlanId', planId.value)
|
||||||
const pageWidth = 297
|
await downloadApiFile(`/timetables/management/export.pdf?${query}`, '课表.pdf')
|
||||||
const pageHeight = 210
|
} else {
|
||||||
const imageHeight = canvas.height * pageWidth / canvas.width
|
await downloadApiFile(`/timetables/classes/${classId.value}/export.pdf?academicTermId=${termId.value}`, '班级课表.pdf')
|
||||||
const image = canvas.toDataURL('image/png')
|
|
||||||
let remaining = imageHeight
|
|
||||||
let position = 0
|
|
||||||
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
|
|
||||||
remaining -= pageHeight
|
|
||||||
while (remaining > 0) {
|
|
||||||
position = remaining - imageHeight
|
|
||||||
pdf.addPage()
|
|
||||||
pdf.addImage(image, 'PNG', 0, position, pageWidth, imageHeight)
|
|
||||||
remaining -= pageHeight
|
|
||||||
}
|
}
|
||||||
pdf.save(`${timetable.value.subject?.name ?? '课表'}-${timetable.value.term.name}.pdf`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
ElMessage.error(`PDF 导出失败:${apiErrorMessage(error)}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -753,7 +814,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<section class="timetable-heading">
|
<section class="timetable-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="section-kicker">{{ isMine && isTeacher ? 'MY TEACHING SCHEDULE' : isMine ? 'MY TIMETABLE' : isTeacherView ? 'TEACHER TIMETABLE' : isManager ? 'TIMETABLE CENTER' : 'CLASS TIMETABLE' }}</span>
|
<span class="section-kicker">{{ isMine && isTeacher ? '我的授课安排' : isMine ? '我的课程安排' : isTeacherView ? '教师课表' : isManager ? '课表管理' : '班级课表' }}</span>
|
||||||
<h2>{{ isMine && isTeacher ? '我的授课课表' : isMine ? '我的课表' : isTeacherView ? '教师课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
|
<h2>{{ isMine && isTeacher ? '我的授课课表' : isMine ? '我的课表' : isTeacherView ? '教师课表' : isManager ? '课表查询中心' : '班级课表查询' }}</h2>
|
||||||
<p v-if="isMine && isTeacher">展示本人本学期所有授课安排,仅采用教务处已发布课表。</p>
|
<p v-if="isMine && isTeacher">展示本人本学期所有授课安排,仅采用教务处已发布课表。</p>
|
||||||
<p v-else-if="isMine">行政班课程与本人已选课程统一展示,仅采用教务处已发布课表。</p>
|
<p v-else-if="isMine">行政班课程与本人已选课程统一展示,仅采用教务处已发布课表。</p>
|
||||||
@@ -849,6 +910,46 @@ onMounted(async () => {
|
|||||||
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
|
{{ resourceType === 'Class' ? filteredClasses.length : resourceType === 'Teacher' ? filteredTeachers.length : filteredClassrooms.length }}
|
||||||
项
|
项
|
||||||
</small>
|
</small>
|
||||||
|
<div v-if="isManager" class="batch-export-panel">
|
||||||
|
<div class="batch-export-heading">
|
||||||
|
<strong>批量导出</strong>
|
||||||
|
<span>按当前筛选结果多选,最多 100 项</span>
|
||||||
|
</div>
|
||||||
|
<el-select
|
||||||
|
v-model="batchResourceIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="选择要批量导出的课表"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in batchResources"
|
||||||
|
:key="item.id"
|
||||||
|
:label="resourceLabel(item)"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div class="batch-export-actions">
|
||||||
|
<el-button size="small" @click="selectFilteredResources">全选筛选结果</el-button>
|
||||||
|
<el-button size="small" :disabled="!batchResourceIds.length" @click="batchResourceIds = []">清空</el-button>
|
||||||
|
<span>已选 {{ batchResourceIds.length }} 项</span>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="batchExporting"
|
||||||
|
:disabled="!batchResourceIds.length"
|
||||||
|
@click="exportBatchExcel"
|
||||||
|
>批量导出 Excel</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:loading="batchExporting"
|
||||||
|
:disabled="!batchResourceIds.length"
|
||||||
|
@click="exportBatchPdf"
|
||||||
|
>批量导出 PDF</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-loading="loading" class="timetable-sheet">
|
<section v-loading="loading" class="timetable-sheet">
|
||||||
@@ -906,7 +1007,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="timetable" ref="exportArea" class="timetable-export-area">
|
<div v-if="timetable" class="timetable-export-area">
|
||||||
<div class="sheet-meta">
|
<div class="sheet-meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ timetable.subject.name }}</strong>
|
<strong>{{ timetable.subject.name }}</strong>
|
||||||
@@ -1097,7 +1198,7 @@ onMounted(async () => {
|
|||||||
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
|
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span>EXAM AGENDA</span>
|
<span>考试安排</span>
|
||||||
<strong>已发布考试安排</strong>
|
<strong>已发布考试安排</strong>
|
||||||
</div>
|
</div>
|
||||||
<small>共 {{ sortedExamEntries.length }} 场 · 按考试日期排序</small>
|
<small>共 {{ sortedExamEntries.length }} 场 · 按考试日期排序</small>
|
||||||
@@ -1133,7 +1234,7 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span>LAB SESSIONS</span>
|
<span>实验安排</span>
|
||||||
<strong>个人实验安排</strong>
|
<strong>个人实验安排</strong>
|
||||||
</div>
|
</div>
|
||||||
<small>共 {{ sortedExperimentEntries.length }} 场 · 预约变化会自动同步</small>
|
<small>共 {{ sortedExperimentEntries.length }} 场 · 预约变化会自动同步</small>
|
||||||
@@ -1170,7 +1271,7 @@ onMounted(async () => {
|
|||||||
<div v-loading="calendarLoading" class="calendar-subscription">
|
<div v-loading="calendarLoading" class="calendar-subscription">
|
||||||
<template v-if="calendarSubscription?.isEnabled">
|
<template v-if="calendarSubscription?.isEnabled">
|
||||||
<div class="calendar-status active">
|
<div class="calendar-status active">
|
||||||
<span>LIVE FEED</span>
|
<span>实时变动</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>订阅已启用</strong>
|
<strong>订阅已启用</strong>
|
||||||
<small>课表、考试和实验安排变更后,日历客户端会在下次同步时自动更新。</small>
|
<small>课表、考试和实验安排变更后,日历客户端会在下次同步时自动更新。</small>
|
||||||
@@ -1214,7 +1315,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template v-else-if="calendarSubscription">
|
<template v-else-if="calendarSubscription">
|
||||||
<div class="calendar-status">
|
<div class="calendar-status">
|
||||||
<span>PRIVATE CALENDAR</span>
|
<span>个人日历</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>将教学安排同步到常用日历</strong>
|
<strong>将教学安排同步到常用日历</strong>
|
||||||
<small>启用后会生成仅属于你的长期订阅地址,可随时重置或停用。</small>
|
<small>启用后会生成仅属于你的长期订阅地址,可随时重置或停用。</small>
|
||||||
@@ -1286,6 +1387,10 @@ onMounted(async () => {
|
|||||||
.calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; }
|
.calendar-coverage ul { margin: 10px 0; padding-left: 20px; color: #526b7d; line-height: 1.8; font-size: 13px; }
|
||||||
.calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
|
.calendar-coverage p { margin: 0; color: #788895; font-size: 12px; line-height: 1.7; }
|
||||||
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
.timetable-export-area { min-width: 0; padding: 2px; background: #fff; }
|
||||||
|
.batch-export-panel { display: grid; gap: 10px; width: min(760px, 100%); margin-top: 8px; padding: 14px; border: 1px solid #d7e3ea; border-radius: 8px; background: #f8fbfc; }
|
||||||
|
.batch-export-heading { display: flex; align-items: baseline; gap: 10px; color: #17324d; }
|
||||||
|
.batch-export-heading span, .batch-export-actions span { color: #718191; font-size: 12px; }
|
||||||
|
.batch-export-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
|
||||||
.sheet-meta div { display: grid; gap: 3px; }
|
.sheet-meta div { display: grid; gap: 3px; }
|
||||||
.sheet-meta strong { color: #17324d; }
|
.sheet-meta strong { color: #17324d; }
|
||||||
|
|||||||
+62
-16
@@ -28,6 +28,13 @@ const editingRoles = ref<string[]>([])
|
|||||||
const editingStaffNumber = ref('')
|
const editingStaffNumber = ref('')
|
||||||
const editingCollegeId = ref<string>()
|
const editingCollegeId = ref<string>()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
const roleFilter = ref<string>()
|
||||||
|
const collegeFilter = ref<string>()
|
||||||
|
const enabledFilter = ref<boolean>()
|
||||||
|
const loginFilter = ref<boolean>()
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const pageSize = 20
|
||||||
const passwordForm = reactive({ newPassword: '', confirmPassword: '' })
|
const passwordForm = reactive({ newPassword: '', confirmPassword: '' })
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
userName: '', displayName: '', password: '', staffNumber: '',
|
userName: '', displayName: '', password: '', staffNumber: '',
|
||||||
@@ -56,25 +63,33 @@ const editingScope = computed(() => {
|
|||||||
)
|
)
|
||||||
return scopeNames[effective]
|
return scopeNames[effective]
|
||||||
})
|
})
|
||||||
const filteredUsers = computed(() => {
|
async function load(resetPage = false) {
|
||||||
const value = keyword.value.trim().toLowerCase()
|
if (resetPage) page.value = 1
|
||||||
if (!value) return users.value
|
|
||||||
return users.value.filter((user) =>
|
|
||||||
[user.userName, user.displayName, user.staffNumber, ...user.roles]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((item) => String(item).toLowerCase().includes(value)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [userRes, roleRes, collegeRes] = await Promise.all([
|
const [userRes, roleRes, collegeRes] = await Promise.all([
|
||||||
http.get('/users'), http.get('/users/roles'), http.get('/base-data/colleges'),
|
http.get('/users', {
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
pageSize,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
roleName: roleFilter.value,
|
||||||
|
collegeId: collegeFilter.value,
|
||||||
|
isEnabled: enabledFilter.value,
|
||||||
|
hasLoggedIn: loginFilter.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
http.get('/users/roles'),
|
||||||
|
http.get('/base-data/colleges'),
|
||||||
])
|
])
|
||||||
users.value = userRes.data
|
users.value = userRes.data.items
|
||||||
|
total.value = userRes.data.total
|
||||||
roles.value = roleRes.data
|
roles.value = roleRes.data
|
||||||
colleges.value = collegeRes.data
|
colleges.value = collegeRes.data
|
||||||
|
if (users.value.length === 0 && page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
await load()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -216,7 +231,29 @@ onMounted(load)
|
|||||||
|
|
||||||
<section class="data-card">
|
<section class="data-card">
|
||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索账号、姓名或工号" />
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
:prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索账号、姓名、工号或角色"
|
||||||
|
@clear="load(true)"
|
||||||
|
@keyup.enter="load(true)"
|
||||||
|
/>
|
||||||
|
<el-select v-model="roleFilter" clearable placeholder="全部角色" @change="load(true)">
|
||||||
|
<el-option v-for="role in roles" :key="role.name" :label="role.name" :value="role.name" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="collegeFilter" clearable placeholder="全部学院" @change="load(true)">
|
||||||
|
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="enabledFilter" clearable placeholder="全部账号状态" @change="load(true)">
|
||||||
|
<el-option label="启用" :value="true" />
|
||||||
|
<el-option label="停用" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="loginFilter" clearable placeholder="全部登录情况" @change="load(true)">
|
||||||
|
<el-option label="已登录过" :value="true" />
|
||||||
|
<el-option label="从未登录" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Search" @click="load(true)">查询</el-button>
|
||||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||||
<el-button :icon="Download" @click="exportRows">导出全部</el-button>
|
<el-button :icon="Download" @click="exportRows">导出全部</el-button>
|
||||||
@@ -227,11 +264,11 @@ onMounted(load)
|
|||||||
accept=".xlsx"
|
accept=".xlsx"
|
||||||
@change="handleImport"
|
@change="handleImport"
|
||||||
/>
|
/>
|
||||||
<span>共 {{ filteredUsers.length }} / {{ users.length }} 个账号</span>
|
<span>共 {{ total }} 个账号</span>
|
||||||
</div>
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="filteredUsers"
|
:data="users"
|
||||||
>
|
>
|
||||||
<el-table-column prop="userName" label="账号" min-width="130" />
|
<el-table-column prop="userName" label="账号" min-width="130" />
|
||||||
<el-table-column prop="displayName" label="姓名" min-width="120" />
|
<el-table-column prop="displayName" label="姓名" min-width="120" />
|
||||||
@@ -263,6 +300,15 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > pageSize"
|
||||||
|
v-model:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
class="table-pagination"
|
||||||
|
@current-change="() => load()"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" title="创建账号" width="540px">
|
<el-dialog v-model="dialogVisible" title="创建账号" width="540px">
|
||||||
|
|||||||
@@ -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