This commit is contained in:
2026-07-23 22:24:21 +08:00 Unverified
parent 25a63c67e6
commit 9931c8e0d3
26 changed files with 12438 additions and 853 deletions
@@ -33,5 +33,6 @@ public interface IAdminArrangementService
string sessionToken, string sessionToken,
string type, string type,
string examId, string examId,
IReadOnlySet<string> selectedIds,
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }
@@ -8,6 +8,7 @@ public interface IAdminExcelService
bool template, bool template,
string examId, string examId,
string batchId, string batchId,
IReadOnlySet<string> selectedIds,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task<AdminEndpointResult> ImportAsync( Task<AdminEndpointResult> ImportAsync(
@@ -19,5 +19,7 @@ public interface IAdminResultService
Task<AdminEndpointResult> SaveFeatureAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> SaveFeatureAsync(string sessionToken, string registrationId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> ReviewAppealAsync(string sessionToken, string resultId, JsonObject body, CancellationToken cancellationToken); Task<AdminEndpointResult> ReviewAppealAsync(string sessionToken, string resultId, JsonObject body, CancellationToken cancellationToken);
Task<AdminEndpointResult> RefreshCacheAsync(string sessionToken, CancellationToken cancellationToken); Task<AdminEndpointResult> RefreshCacheAsync(string sessionToken, CancellationToken cancellationToken);
Task<AdminDocumentResult> ExportAsync(string sessionToken, string examId, CancellationToken cancellationToken); Task<AdminDocumentResult> ExportAsync(
string sessionToken, string examId, IReadOnlySet<string> selectedIds,
CancellationToken cancellationToken);
} }
@@ -628,10 +628,15 @@ internal sealed partial class AdminAdmissionService
IReadOnlyDictionary<string, string> filters) IReadOnlyDictionary<string, string> filters)
{ {
var query = filters.GetValueOrDefault("q", "").Trim(); var query = filters.GetValueOrDefault("q", "").Trim();
var selectedIds = filters.GetValueOrDefault("ids", "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Take(1000)
.ToHashSet(StringComparer.Ordinal);
foreach (var item in rows) foreach (var item in rows)
{ {
if (selectedIds.Count > 0 && !selectedIds.Contains(Text(item["id"]))) continue;
var payload = item["payload"] as JsonObject; var payload = item["payload"] as JsonObject;
var matches = new[] { "examId", "schoolId", "sourceSchoolId", "status", "round" }.All(key => var matches = new[] { "examId", "schoolId", "sourceSchoolId", "status", "fillStatus", "round" }.All(key =>
{ {
var expected = filters.GetValueOrDefault(key, ""); var expected = filters.GetValueOrDefault(key, "");
return expected.Length == 0 || Text(item[key]) == expected || Text(payload?[key]) == expected; return expected.Length == 0 || Text(item[key]) == expected || Text(payload?[key]) == expected;
@@ -114,6 +114,7 @@ internal sealed class AdminArrangementService(
string sessionToken, string sessionToken,
string type, string type,
string examId, string examId,
IReadOnlySet<string> selectedIds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken); var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
@@ -135,7 +136,7 @@ internal sealed class AdminArrangementService(
.Select(item => item.Id) .Select(item => item.Id)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
var registrations = operational.Registrations var registrations = operational.Registrations
.Where(item => item.ExamId == exam.Id && item.Data["admitCard"] is JsonObject card && .Where(item => item.ExamId == exam.Id && (selectedIds.Count == 0 || selectedIds.Contains(item.Id)) && item.Data["admitCard"] is JsonObject card &&
centerIds.Contains(Text(card, "centerId"))) centerIds.Contains(Text(card, "centerId")))
.ToArray(); .ToArray();
var rows = BuildExportRows(operational, centers, directory, registrations); var rows = BuildExportRows(operational, centers, directory, registrations);
@@ -150,7 +151,8 @@ internal sealed class AdminArrangementService(
var scoped = operational.Registrations.Where(item => var scoped = operational.Registrations.Where(item =>
{ {
if (item.ExamId != exam.Id || item.Data["admitCard"] is null) return false; if (item.ExamId != exam.Id || item.Data["admitCard"] is null ||
selectedIds.Count > 0 && !selectedIds.Contains(item.Id)) return false;
var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId); var profile = operational.Profiles.FirstOrDefault(profile => profile.UserId == item.UserId);
return profile is not null && InScope(user, profile); return profile is not null && InScope(user, profile);
}).ToArray(); }).ToArray();
@@ -29,6 +29,7 @@ internal sealed class AdminExcelService(
["account_quotas"] = "报名号班级配额", ["account_quotas"] = "报名号班级配额",
["account_results"] = "报名号下发结果", ["account_results"] = "报名号下发结果",
["candidates"] = "考生资料", ["candidates"] = "考生资料",
["registrations"] = "考试报名台账",
["payments"] = "考试缴费名单", ["payments"] = "考试缴费名单",
["centers"] = "考点考场档案", ["centers"] = "考点考场档案",
["results"] = "成绩台账" ["results"] = "成绩台账"
@@ -40,6 +41,7 @@ internal sealed class AdminExcelService(
bool template, bool template,
string examId, string examId,
string batchId, string batchId,
IReadOnlySet<string> selectedIds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var context = await ResolveAsync(sessionToken, cancellationToken); var context = await ResolveAsync(sessionToken, cancellationToken);
@@ -49,9 +51,9 @@ internal sealed class AdminExcelService(
var user = context.User!; var user = context.User!;
if (!CanRead(user, resource)) return DocumentError(Error(403, "当前账号不能导出该数据")); if (!CanRead(user, resource)) return DocumentError(Error(403, "当前账号不能导出该数据"));
if (resource == "results") if (resource == "results")
return await resultService.ExportAsync(sessionToken, examId, cancellationToken); return await resultService.ExportAsync(sessionToken, examId, selectedIds, cancellationToken);
var rows = template ? Array.Empty<JsonObject>() : var rows = template ? Array.Empty<JsonObject>() :
await ExportRowsAsync(user, resource, batchId, cancellationToken); await ExportRowsAsync(user, resource, batchId, selectedIds, cancellationToken);
if (rows is null) return DocumentError(Error(404, "批次不存在或不在当前学校范围内")); if (rows is null) return DocumentError(Error(404, "批次不存在或不在当前学校范围内"));
var content = SystemWorkbook.Build(resource, rows, template); var content = SystemWorkbook.Build(resource, rows, template);
var name = $"{ResourceNames[resource]}-{(template ? "" : "")}-{DateTime.UtcNow:yyyy-MM-dd}.xlsx"; var name = $"{ResourceNames[resource]}-{(template ? "" : "")}-{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
@@ -69,7 +71,7 @@ internal sealed class AdminExcelService(
if (context.Error is not null) return context.Error; if (context.Error is not null) return context.Error;
if (!ResourceNames.ContainsKey(resource) || !SystemWorkbook.Supports(resource)) if (!ResourceNames.ContainsKey(resource) || !SystemWorkbook.Supports(resource))
return Error(404, "Excel 数据类型不存在"); return Error(404, "Excel 数据类型不存在");
if (resource is "account_results" or "payments") if (resource is "account_results" or "registrations" or "payments")
return Error(400, "该清单只支持导出"); return Error(400, "该清单只支持导出");
IReadOnlyList<JsonObject> rows; IReadOnlyList<JsonObject> rows;
try try
@@ -101,6 +103,7 @@ internal sealed class AdminExcelService(
AuthenticationUser user, AuthenticationUser user,
string resource, string resource,
string batchId, string batchId,
IReadOnlySet<string> selectedIds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var directory = await directoryLoader.LoadAsync(cancellationToken); var directory = await directoryLoader.LoadAsync(cancellationToken);
@@ -108,14 +111,14 @@ internal sealed class AdminExcelService(
? directory.Schools.Select(item => item.Id).ToHashSet(StringComparer.Ordinal) ? directory.Schools.Select(item => item.Id).ToHashSet(StringComparer.Ordinal)
: new HashSet<string>(user.SchoolId is null ? [] : [user.SchoolId], StringComparer.Ordinal); : new HashSet<string>(user.SchoolId is null ? [] : [user.SchoolId], StringComparer.Ordinal);
if (resource == "classes") if (resource == "classes")
return directory.Classes.Where(item => schoolIds.Contains(item.SchoolId)).Select(item => return directory.Classes.Where(item => schoolIds.Contains(item.SchoolId) && Selected(item.Id, selectedIds)).Select(item =>
new JsonObject new JsonObject
{ {
["schoolCode"] = directory.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Code ?? "", ["schoolCode"] = directory.Schools.FirstOrDefault(school => school.Id == item.SchoolId)?.Code ?? "",
["grade"] = item.Grade, ["name"] = item.Name, ["status"] = item.Active ? "启用" : "停用" ["grade"] = item.Grade, ["name"] = item.Name, ["status"] = item.Active ? "启用" : "停用"
}).ToArray(); }).ToArray();
if (resource == "class_admins") if (resource == "class_admins")
return directory.Users.Where(item => item.Role == "admin" && item.AdminLevel == "class" && return directory.Users.Where(item => item.Role == "admin" && item.AdminLevel == "class" && Selected(item.Id, selectedIds) &&
item.SchoolId is not null && schoolIds.Contains(item.SchoolId)) item.SchoolId is not null && schoolIds.Contains(item.SchoolId))
.Select(item => new JsonObject .Select(item => new JsonObject
{ {
@@ -125,14 +128,14 @@ internal sealed class AdminExcelService(
["status"] = item.Active ? "启用" : "停用" ["status"] = item.Active ? "启用" : "停用"
}).ToArray(); }).ToArray();
if (resource == "account_quotas") if (resource == "account_quotas")
return directory.Classes.Where(item => item.Active && schoolIds.Contains(item.SchoolId)) return directory.Classes.Where(item => item.Active && schoolIds.Contains(item.SchoolId) && Selected(item.Id, selectedIds))
.Select(item => new JsonObject { ["className"] = item.Name, ["count"] = 0 }).ToArray(); .Select(item => new JsonObject { ["className"] = item.Name, ["count"] = 0 }).ToArray();
if (resource == "account_results") if (resource == "account_results")
{ {
var batch = directory.Batches.FirstOrDefault(item => var batch = directory.Batches.FirstOrDefault(item =>
item.Id == batchId && schoolIds.Contains(item.SchoolId)); item.Id == batchId && schoolIds.Contains(item.SchoolId));
if (batch is null) return null; if (batch is null) return null;
return directory.Items.Where(item => item.BatchId == batch.Id) return directory.Items.Where(item => item.BatchId == batch.Id && Selected(item.Id, selectedIds))
.OrderBy(item => item.Position).Select(item => new JsonObject .OrderBy(item => item.Position).Select(item => new JsonObject
{ {
["batchId"] = batch.Id, ["batchId"] = batch.Id,
@@ -143,7 +146,7 @@ internal sealed class AdminExcelService(
} }
var operational = await operationalLoader.LoadAsync(cancellationToken); var operational = await operationalLoader.LoadAsync(cancellationToken);
if (resource == "candidates") if (resource == "candidates")
return operational.Profiles.Where(item => InScope(user, item)).Select(profile => return operational.Profiles.Where(item => InScope(user, item) && Selected(item.UserId, selectedIds)).Select(profile =>
new JsonObject new JsonObject
{ {
["candidateNumber"] = operational.Users.FirstOrDefault(item => item.Id == profile.UserId)?.CandidateNumber ?? "", ["candidateNumber"] = operational.Users.FirstOrDefault(item => item.Id == profile.UserId)?.CandidateNumber ?? "",
@@ -159,8 +162,33 @@ internal sealed class AdminExcelService(
["postalCode"] = Text(profile.Data["postalCode"]), ["guardianName"] = Text(profile.Data["guardianName"]), ["postalCode"] = Text(profile.Data["postalCode"]), ["guardianName"] = Text(profile.Data["guardianName"]),
["guardianPhone"] = Text(profile.Data["guardianPhone"]) ["guardianPhone"] = Text(profile.Data["guardianPhone"])
}).ToArray(); }).ToArray();
if (resource == "registrations")
return operational.Registrations.Where(item => Selected(item.Id, selectedIds))
.Select(registration => (Registration: registration,
Profile: operational.Profiles.FirstOrDefault(profile => profile.UserId == registration.UserId)))
.Where(item => item.Profile is not null && InScope(user, item.Profile))
.Select(item =>
{
var profile = item.Profile!;
var exam = operational.Exams.FirstOrDefault(exam => exam.Id == item.Registration.ExamId);
var account = operational.Users.FirstOrDefault(account => account.Id == item.Registration.UserId);
var subjectIds = (item.Registration.Data["subjectIds"] as JsonArray ?? []).Select(Text).ToHashSet(StringComparer.Ordinal);
var subjects = (exam?.Subjects ?? []).Where(subject => subjectIds.Contains(Text(subject["id"]))).ToArray();
return new JsonObject
{
["candidateNumber"] = account?.CandidateNumber ?? Text(item.Registration.Data["registrationNumber"]),
["candidateName"] = Text(profile.Data["name"]) is { Length: > 0 } name ? name : account?.DisplayName ?? "",
["schoolName"] = directory.Schools.FirstOrDefault(school => school.Id == profile.SchoolId)?.Name ?? Text(profile.Data["school"]),
["className"] = directory.Classes.FirstOrDefault(entry => entry.Id == profile.ClassId)?.Name ?? Text(profile.Data["grade"]),
["examCode"] = Text(exam?.Data["code"]), ["examName"] = Text(exam?.Data["name"]),
["subjectNames"] = string.Join('、', subjects.Select(subject => Text(subject["name"]))),
["registrationStatus"] = item.Registration.Status switch { "approved" => "已通过", "rejected" => "已退回", _ => "待审核" },
["paymentStatus"] = item.Registration.PaymentStatus == "paid" ? "已缴费" : "待缴费",
["reviewNote"] = Text(item.Registration.Data["reviewNote"])
};
}).ToArray();
if (resource == "payments") if (resource == "payments")
return operational.Registrations.Where(item => item.Status == "approved") return operational.Registrations.Where(item => item.Status == "approved" && Selected(item.Id, selectedIds))
.Select(registration => (Registration: registration, .Select(registration => (Registration: registration,
Profile: operational.Profiles.FirstOrDefault(profile => profile.UserId == registration.UserId))) Profile: operational.Profiles.FirstOrDefault(profile => profile.UserId == registration.UserId)))
.Where(item => item.Profile is not null && InScope(user, item.Profile)) .Where(item => item.Profile is not null && InScope(user, item.Profile))
@@ -188,7 +216,7 @@ internal sealed class AdminExcelService(
if (resource == "centers") if (resource == "centers")
{ {
var centers = await centerLoader.LoadAsync(cancellationToken); var centers = await centerLoader.LoadAsync(cancellationToken);
return centers.Centers.Where(item => schoolIds.Contains(item.SchoolId)).SelectMany(center => return centers.Centers.Where(item => schoolIds.Contains(item.SchoolId) && Selected(item.Id, selectedIds)).SelectMany(center =>
{ {
var rooms = centers.Rooms.Where(item => item.CenterId == center.Id).ToArray(); var rooms = centers.Rooms.Where(item => item.CenterId == center.Id).ToArray();
if (rooms.Length == 0) rooms = [new AdminCenterRoom("", center.Id, "", "", "", "", 0, "", 1, 0, "", "active", "")]; if (rooms.Length == 0) rooms = [new AdminCenterRoom("", center.Id, "", "", "", "", 0, "", 1, 0, "", "active", "")];
@@ -213,6 +241,9 @@ internal sealed class AdminExcelService(
return []; return [];
} }
private static bool Selected(string id, IReadOnlySet<string> selectedIds) =>
selectedIds.Count == 0 || selectedIds.Contains(id);
private async Task<AdminEndpointResult> ImportClassesAsync( private async Task<AdminEndpointResult> ImportClassesAsync(
AuthenticationUser user, AuthenticationUser user,
IReadOnlyList<JsonObject> rows, IReadOnlyList<JsonObject> rows,
@@ -76,9 +76,9 @@ internal sealed class AdminNoticeService(
if (existing is null) return Error(404, "通知不存在"); if (existing is null) return Error(404, "通知不存在");
var notice = existing with var notice = existing with
{ {
Title = body["title"] is null ? existing.Title : Clean(Text(body["title"]), 260), Title = body["title"] is null ? existing.Title : Clean(Text(body["title"]), 120),
Summary = body["summary"] is null ? existing.Summary : Clean(Text(body["summary"]), 260), Summary = body["summary"] is null ? existing.Summary : Clean(Text(body["summary"]), 260),
Category = body["category"] is null ? existing.Category : Clean(Text(body["category"]), 260), Category = body["category"] is null ? existing.Category : Clean(Text(body["category"]), 30),
Pinned = body["pinned"] is null ? existing.Pinned : JsBoolean(body["pinned"]) Pinned = body["pinned"] is null ? existing.Pinned : JsBoolean(body["pinned"])
}; };
if (body["content"] is not null) if (body["content"] is not null)
@@ -87,6 +87,8 @@ internal sealed class AdminNoticeService(
if (formatter.PlainText(content).Length == 0) return Error(400, "通知正文不能为空"); if (formatter.PlainText(content).Length == 0) return Error(400, "通知正文不能为空");
notice = notice with { Content = content }; notice = notice with { Content = content };
} }
if (notice.Title.Length == 0 || formatter.PlainText(notice.Content).Length == 0)
return Error(400, "通知标题和正文不能为空");
var requestedStatus = Text(body["status"]); var requestedStatus = Text(body["status"]);
if (requestedStatus is "draft" or "published") if (requestedStatus is "draft" or "published")
{ {
@@ -116,7 +118,7 @@ internal sealed class AdminNoticeService(
var snapshot = await repository.LoadManagementAsync(cancellationToken); var snapshot = await repository.LoadManagementAsync(cancellationToken);
var existing = AdminPublicationProjector.FindSourceRecord(snapshot.Records, sourceType, publicationId); var existing = AdminPublicationProjector.FindSourceRecord(snapshot.Records, sourceType, publicationId);
if (existing is null) return Error(404, "系统公示不存在"); if (existing is null) return Error(404, "系统公示不存在");
if (body["visible"] is not JsonValue value || !value.TryGetValue<bool>(out var visible)) if (!TryReadPublicVisibility(body, out var visible))
{ {
return Error(400, "请明确设置是否显示"); return Error(400, "请明确设置是否显示");
} }
@@ -176,6 +178,13 @@ internal sealed class AdminNoticeService(
return true; return true;
} }
internal static bool TryReadPublicVisibility(JsonObject body, out bool visible)
{
visible = false;
var value = body["publicVisible"] ?? body["visible"];
return value is JsonValue jsonValue && jsonValue.TryGetValue(out visible);
}
private static string Text(JsonNode? node) => private static string Text(JsonNode? node) =>
node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? ""; node is JsonValue value && value.TryGetValue<string>(out var text) ? text : node?.ToString() ?? "";
private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; } private static string Clean(string value, int maximum) { var cleaned = value.Trim(); return cleaned[..Math.Min(cleaned.Length, maximum)]; }
@@ -729,6 +729,7 @@ internal sealed class AdminResultService(
public async Task<AdminDocumentResult> ExportAsync( public async Task<AdminDocumentResult> ExportAsync(
string sessionToken, string sessionToken,
string examId, string examId,
IReadOnlySet<string> selectedIds,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken); var context = await ResolveAsync(sessionToken, superOnly: false, cancellationToken);
@@ -756,6 +757,7 @@ internal sealed class AdminResultService(
{ {
var result = allResults.FirstOrDefault(item => var result = allResults.FirstOrDefault(item =>
item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"])); item.RegistrationId == registration.Id && item.SubjectId == Text(subject["id"]));
if (selectedIds.Count > 0 && (result is null || !selectedIds.Contains(result.Id))) continue;
var card = registration.Data["admitCard"] as JsonObject; var card = registration.Data["admitCard"] as JsonObject;
rows.Add(new( rows.Add(new(
account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]), account?.CandidateNumber ?? Text(registration.Data["registrationNumber"]),
@@ -63,6 +63,14 @@ internal static class SystemWorkbook
C("paymentStatus", "缴费状态", 14, ""), C("paidAt", "确认时间", 24, ""), C("paymentStatus", "缴费状态", 14, ""), C("paidAt", "确认时间", 24, ""),
C("paidByName", "确认人", 16, "") C("paidByName", "确认人", 16, "")
]), ]),
["registrations"] = new("考试报名审核台账", "考试报名",
[
C("candidateNumber", "报名号", 26, ""), C("candidateName", "考生姓名", 16, ""),
C("schoolName", "学校", 24, ""), C("className", "班级", 20, ""),
C("examCode", "考试代码", 18, ""), C("examName", "考试名称", 28, ""),
C("subjectNames", "报考科目", 34, ""), C("registrationStatus", "报名状态", 14, ""),
C("paymentStatus", "缴费状态", 14, ""), C("reviewNote", "审核意见", 28, "")
]),
["centers"] = new("考点考场档案", "考点考场", ["centers"] = new("考点考场档案", "考点考场",
[ [
C("schoolCode", "学校代码*", 14, "HZ01"), C("centerCode", "考点代码*", 18, "HZ01-C02"), C("schoolCode", "学校代码*", 14, "HZ01"), C("centerCode", "考点代码*", 18, "HZ01-C02"),
@@ -19,7 +19,7 @@ public static class NativeAdminReadEndpoints
endpoints.MapGet("/api/admin/schools", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) => endpoints.MapGet("/api/admin/schools", (HttpContext context, IAdminReadService service, CancellationToken cancellationToken) =>
Execute(context, service.GetSchoolsAsync(Token(context), cancellationToken))); Execute(context, service.GetSchoolsAsync(Token(context), cancellationToken)));
endpoints.MapGet( endpoints.MapGet(
"/api/admin/excel/{resource:regex(^(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$)}", "/api/admin/excel/{resource:regex(^(classes|class_admins|account_quotas|account_results|candidates|registrations|payments|centers|results)$)}",
(HttpContext context, string resource, IAdminExcelService service, CancellationToken cancellationToken) => (HttpContext context, string resource, IAdminExcelService service, CancellationToken cancellationToken) =>
ExecuteDocument( ExecuteDocument(
context, context,
@@ -29,6 +29,7 @@ public static class NativeAdminReadEndpoints
context.Request.Query.ContainsKey("template"), context.Request.Query.ContainsKey("template"),
context.Request.Query["examId"].ToString(), context.Request.Query["examId"].ToString(),
context.Request.Query["batchId"].ToString(), context.Request.Query["batchId"].ToString(),
SelectedIds(context),
cancellationToken))); cancellationToken)));
endpoints.MapPost( endpoints.MapPost(
"/api/admin/excel/{resource:regex(^(classes|class_admins|account_quotas|candidates|centers|results)$)}", "/api/admin/excel/{resource:regex(^(classes|class_admins|account_quotas|candidates|centers|results)$)}",
@@ -181,6 +182,7 @@ public static class NativeAdminReadEndpoints
Token(context), Token(context),
type, type,
context.Request.Query["examId"].ToString(), context.Request.Query["examId"].ToString(),
SelectedIds(context),
cancellationToken))); cancellationToken)));
endpoints.MapPost("/api/admin/registrations/{registrationId}/admit-card", endpoints.MapPost("/api/admin/registrations/{registrationId}/admit-card",
(HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken cancellationToken) => (HttpContext context, string registrationId, IAdminArrangementService service, CancellationToken cancellationToken) =>
@@ -377,6 +379,13 @@ public static class NativeAdminReadEndpoints
return endpoints; return endpoints;
} }
private static IReadOnlySet<string> SelectedIds(HttpContext context) =>
context.Request.Query["ids"].ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => item.Length <= 128)
.Take(1000)
.ToHashSet(StringComparer.Ordinal);
private static async Task<IResult> Execute(HttpContext context, Task<AdminEndpointResult> operation) private static async Task<IResult> Execute(HttpContext context, Task<AdminEndpointResult> operation)
{ {
var result = await operation; var result = await operation;
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,6 +9,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ckeditor/ckeditor5-vue": "^8.2.0",
"ckeditor5": "^48.3.1",
"vue": "3.5.40", "vue": "3.5.40",
"vue-router": "5.2.0" "vue-router": "5.2.0"
}, },
@@ -0,0 +1,121 @@
<script setup>
import { computed } from "vue";
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
import {
BlockQuote,
Bold,
ClassicEditor,
Essentials,
Heading,
Italic,
Link,
List,
Paragraph,
Strikethrough,
Table,
TableToolbar,
Underline,
} from "ckeditor5";
import zhCnTranslations from "ckeditor5/translations/zh-cn.js";
import "ckeditor5/ckeditor5.css";
const content = defineModel({ type: String, default: "" });
const props = defineProps({
disabled: { type: Boolean, default: false },
});
const editorConfig = {
licenseKey: import.meta.env.VITE_CKEDITOR_LICENSE_KEY || "GPL",
language: "zh-cn",
translations: [zhCnTranslations],
plugins: [
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Underline,
Strikethrough,
Link,
List,
BlockQuote,
Table,
TableToolbar,
],
toolbar: {
items: [
"undo",
"redo",
"|",
"heading",
"|",
"bold",
"italic",
"underline",
"strikethrough",
"|",
"link",
"bulletedList",
"numberedList",
"blockQuote",
"insertTable",
],
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",
},
{
model: "heading4",
view: "h4",
title: "三级标题",
class: "ck-heading_heading4",
},
],
},
link: {
addTargetToExternalLinks: true,
defaultProtocol: "https://",
},
table: {
contentToolbar: ["tableColumn", "tableRow", "mergeTableCells"],
},
placeholder: "在此编写通知正文……",
};
const textLength = computed(
() =>
String(content.value || "")
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim().length,
);
</script>
<template>
<div class="notice-rich-editor" :class="{ 'is-disabled': disabled }">
<Ckeditor
v-model="content"
:editor="ClassicEditor"
:config="editorConfig"
:disabled="disabled"
/>
<footer>
<span>支持标题链接列表引用和表格保存时会再次执行安全过滤</span>
<strong>{{ textLength }} </strong>
</footer>
</div>
</template>
@@ -0,0 +1,48 @@
<script setup>
import { computed, ref } from 'vue';
import LedgerPager from '@/components/common/LedgerPager.vue';
import StatusBadge from '@/components/common/StatusBadge.vue';
import { useLedger } from '@/composables/useLedger';
const props = defineProps({ group: { type: Object, required: true }, busy: { type: Boolean, default: false } });
const emit = defineEmits(['save', 'bulk']);
const selected = ref([]);
const sourceRows = computed(() => props.group.qualificationStatus?.rows || []);
const ledger = useLedger(sourceRows, {
searchText: row => [row.name, row.registrationNumber, row.specialtyLabel].join(' '),
filters: {
status: (row, value) => value === 'unconfirmed' ? !row.confirmed : value === 'eligible' ? row.confirmed && row.eligible : row.confirmed && !row.eligible,
specialty: (row, value) => row.specialtyLabel === value
}
});
const specialties = computed(() => [...new Set(sourceRows.value.map(row => row.specialtyLabel).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN')));
const pageIds = computed(() => ledger.rows.map(row => row.userId));
const allPageSelected = computed(() => pageIds.value.length > 0 && pageIds.value.every(id => selected.value.includes(id)));
function togglePage() {
if (allPageSelected.value) selected.value = selected.value.filter(id => !pageIds.value.includes(id));
else selected.value = [...new Set([...selected.value, ...pageIds.value])];
}
function applyBulk(eligible) {
if (!selected.value.length) return;
emit('bulk', { userIds: [...selected.value], eligible });
selected.value = [];
}
</script>
<template>
<section class="record-panel qualification-ledger-vue">
<header>
<div><h2>{{ group.exam?.name || group.name }}</h2><p>已确认 {{ group.qualificationStatus?.confirmed || 0 }} / {{ group.qualificationStatus?.total || 0 }} 全部确认后系统自动公示</p></div>
<StatusBadge :value="group.qualificationStatus?.complete ? 'approved' : 'pending'" />
</header>
<div class="ledger-toolbar ledger-toolbar--wide">
<label><span>关键词</span><input v-model="ledger.query" placeholder="姓名、报名号、特长类型"></label>
<label><span>确认状态</span><select v-model="ledger.filters.status"><option value="">全部状态</option><option value="unconfirmed">待确认</option><option value="eligible">有资格</option><option value="ineligible">无资格</option></select></label>
<label><span>特长类型</span><select v-model="ledger.filters.specialty"><option value="">全部类型</option><option v-for="item in specialties" :key="item" :value="item">{{ item }}</option></select></label>
<button class="table-action" type="button" @click="ledger.clear">清除筛选</button>
</div>
<div class="ledger-bulk"><label><input type="checkbox" :checked="allPageSelected" @change="togglePage"> 选择当前页</label><strong>已选 {{ selected.length }} </strong><button class="table-action" :disabled="!selected.length || busy" @click="applyBulk(false)">批量无资格</button><button class="table-action table-action--primary" :disabled="!selected.length || busy" @click="applyBulk(true)">批量有资格</button></div>
<div class="table-scroll"><table><thead><tr><th>选择</th><th>考生</th><th>报名号</th><th>特长</th><th>确认状态</th><th>当前资格</th><th>确认</th></tr></thead><tbody><tr v-for="item in ledger.rows" :key="item.userId"><td><input v-model="selected" type="checkbox" :value="item.userId" :aria-label="`选择 ${item.name}`"></td><td>{{ item.name }}</td><td>{{ item.registrationNumber }}</td><td>{{ item.specialtyLabel || '普通生' }}</td><td>{{ item.confirmed ? '已确认' : '待确认' }}</td><td><StatusBadge :value="!item.confirmed ? 'pending' : item.eligible ? 'approved' : 'rejected'" /></td><td><button class="table-action" @click="emit('save', { item, eligible: false })">无资格</button><button class="table-action table-action--primary" @click="emit('save', { item, eligible: true })">有资格</button></td></tr><tr v-if="!ledger.rows.length"><td class="table-empty" colspan="7">没有符合当前条件的考生</td></tr></tbody></table></div>
<LedgerPager v-model:page="ledger.page" v-model:page-size="ledger.pageSize" :total="ledger.total" />
</section>
</template>
@@ -0,0 +1,39 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
resource: { type: String, required: true },
label: { type: String, default: '数据' },
template: { type: Boolean, default: true },
importable: { type: Boolean, default: true },
examId: { type: String, default: '' },
batchId: { type: String, default: '' }
});
const emit = defineEmits(['import']);
const query = computed(() => {
const params = new URLSearchParams();
if (props.examId) params.set('examId', props.examId);
if (props.batchId) params.set('batchId', props.batchId);
return params.toString();
});
function url(template = false) {
const params = new URLSearchParams(query.value);
if (template) params.set('template', '1');
return `/api/admin/excel/${props.resource}${params.size ? `?${params}` : ''}`;
}
function selectFile(event) {
const file = event.target.files?.[0];
if (file) emit('import', { file, input: event.target });
}
</script>
<template>
<div class="excel-action-bar excel-action-bar--descriptive">
<span><strong>{{ label }} Excel</strong><small>{{ importable ? '使用系统模板可获得逐行校验' : '按当前账号数据范围导出' }}</small></span>
<div>
<a v-if="template" :href="url(true)">下载模板</a>
<a :href="url(false)">导出当前数据</a>
<label v-if="importable">导入 Excel<input type="file" accept=".xlsx" hidden @change="selectFile"></label>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
total: { type: Number, default: 0 },
page: { type: Number, default: 1 },
pageSize: { type: Number, default: 20 },
totalPages: { type: Number, default: 0 },
sizes: { type: Array, default: () => [20, 50, 100] }
});
const emit = defineEmits(['update:page', 'update:pageSize']);
const pages = computed(() => props.totalPages || Math.max(1, Math.ceil(props.total / props.pageSize)));
const start = computed(() => props.total ? (props.page - 1) * props.pageSize + 1 : 0);
const end = computed(() => Math.min(props.total, props.page * props.pageSize));
const nearby = computed(() => [...new Set([1, props.page - 1, props.page, props.page + 1, pages.value])].filter(value => value >= 1 && value <= pages.value));
</script>
<template>
<nav class="ledger-pagination" aria-label="列表分页">
<span> {{ start }}{{ end }} {{ total }} </span>
<div>
<button type="button" :disabled="page <= 1" @click="emit('update:page', page - 1)">上一页</button>
<template v-for="(value, index) in nearby" :key="value">
<i v-if="index && value - nearby[index - 1] > 1"></i>
<button type="button" :class="{ active: value === page }" @click="emit('update:page', value)">{{ value }}</button>
</template>
<button type="button" :disabled="page >= pages" @click="emit('update:page', page + 1)">下一页</button>
<label>每页
<select :value="pageSize" @change="emit('update:pageSize', Number($event.target.value))">
<option v-for="size in sizes" :key="size" :value="size">{{ size }}</option>
</select>
</label>
</div>
</nav>
</template>
@@ -0,0 +1,47 @@
import { computed, reactive, ref, watch } from 'vue';
function rowsFrom(source) {
const value = typeof source === 'function' ? source() : source?.value ?? source;
return Array.isArray(value) ? value : [];
}
export function useLedger(source, options = {}) {
const query = ref('');
const filters = reactive(Object.fromEntries(Object.keys(options.filters || {}).map(key => [key, ''])));
const page = ref(1);
const pageSize = ref(options.pageSize || 20);
const sourceRows = computed(() => rowsFrom(source));
const filtered = computed(() => {
const needle = query.value.trim().toLocaleLowerCase('zh-CN');
return sourceRows.value.filter(row => {
if (needle) {
const haystack = options.searchText ? options.searchText(row) : JSON.stringify(row);
if (!String(haystack || '').toLocaleLowerCase('zh-CN').includes(needle)) return false;
}
return Object.entries(options.filters || {}).every(([key, predicate]) => {
const value = filters[key];
return !value || predicate(row, value);
});
});
});
const total = computed(() => filtered.value.length);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const rows = computed(() => {
const start = (page.value - 1) * pageSize.value;
return filtered.value.slice(start, start + pageSize.value);
});
const pageRows = computed(() => rows.value);
const rangeStart = computed(() => total.value ? (page.value - 1) * pageSize.value + 1 : 0);
const rangeEnd = computed(() => Math.min(total.value, page.value * pageSize.value));
watch([query, pageSize, ...Object.keys(filters).map(key => () => filters[key])], () => { page.value = 1; });
watch(totalPages, count => { if (page.value > count) page.value = count; });
function clear() {
query.value = '';
for (const key of Object.keys(filters)) filters[key] = '';
page.value = 1;
}
return reactive({ query, filters, page, pageSize, sourceRows, filtered, total, totalPages, rows, pageRows, rangeStart, rangeEnd, clear });
}
@@ -0,0 +1,10 @@
export function downloadSelectedExcel(resource, ids, parameters = {}) {
const selected = [...new Set((ids || []).filter(Boolean))];
if (!selected.length) return false;
const params = new URLSearchParams(parameters);
params.set('ids', selected.join(','));
const anchor = document.createElement('a');
anchor.href = `/api/admin/excel/${resource}?${params}`;
anchor.click();
return true;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
using System.Text.Json.Nodes;
using Eis.Infrastructure.Administration;
namespace Eis.Infrastructure.Tests.Administration;
public sealed class AdminNoticeServiceTests
{
[Theory]
[InlineData("publicVisible")]
[InlineData("visible")]
public void TryReadPublicVisibility_AcceptsCurrentAndLegacyKeys(string key)
{
var body = new JsonObject { [key] = false };
var parsed = AdminNoticeService.TryReadPublicVisibility(body, out var visible);
Assert.True(parsed);
Assert.False(visible);
}
[Fact]
public void TryReadPublicVisibility_RejectsMissingOrNonBooleanValue()
{
Assert.False(AdminNoticeService.TryReadPublicVisibility(new JsonObject(), out _));
Assert.False(AdminNoticeService.TryReadPublicVisibility(
new JsonObject { ["publicVisible"] = "false" },
out _));
}
}