优化服务器端分页

This commit is contained in:
2026-08-11 11:52:43 +08:00 Unverified
parent 83103eebb8
commit 5d7322507b
6 changed files with 225 additions and 50 deletions
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -176,8 +177,13 @@ public sealed class CourseAdjustmentsController(
public async Task<ActionResult> GetMine(
Guid? academicTermId,
CourseAdjustmentStatus? status,
CancellationToken cancellationToken)
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var userId = currentUserDataScope.Current.UserId;
var source = db.CourseAdjustments.AsNoTracking()
.Where(x => x.ApplicantUserId == userId);
@@ -187,10 +193,15 @@ public sealed class CourseAdjustmentsController(
if (status.HasValue)
source = source.Where(x => x.Status == status);
return Ok(await source
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(AdjustmentProjection())
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
// ═══════════════ Pending reviews ═══════════════
@@ -199,8 +210,13 @@ public sealed class CourseAdjustmentsController(
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> GetPendingReviews(
Guid? academicTermId,
CancellationToken cancellationToken)
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var scope = currentUserDataScope.Current;
var source = db.CourseAdjustments.AsNoTracking()
.Where(x => x.Status == CourseAdjustmentStatus.Submitted);
@@ -211,10 +227,15 @@ public sealed class CourseAdjustmentsController(
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
return Ok(await source
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.SubmittedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(AdjustmentProjection())
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
// ═══════════════ Detail ═══════════════
@@ -17,17 +17,53 @@ public sealed class StudentStatusChangesController(
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet]
public async Task<ActionResult> Get(CancellationToken token)
public async Task<ActionResult<StudentStatusChangePage>> Get(
int page = 1,
int pageSize = 20,
CancellationToken token = default)
{
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = ScopedChanges().AsNoTracking();
return Ok(await source.OrderByDescending(x => x.SubmittedAt).Select(x => new
{
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
CollegeName = x.Student.AdministrativeClass.Major!.College!.Name,
x.Type, x.OriginalStatus, x.TargetStatus, x.Reason, x.State,
x.ReviewComment, x.SubmittedAt, x.ReviewedAt, x.ApprovedAt
}).ToListAsync(token));
var userScope = currentUserDataScope.Current;
var total = await source.CountAsync(token);
var actionableTotal = await source.CountAsync(change =>
(change.State == StudentStatusChangeState.Submitted &&
userScope.IsInRole(SystemRoles.Counselor)) ||
(change.State == StudentStatusChangeState.CounselorApproved &&
userScope.IsInRole(SystemRoles.CollegeAdmin)) ||
(change.State == StudentStatusChangeState.CollegeApproved &&
(userScope.IsInRole(SystemRoles.AcademicAdmin) ||
userScope.IsInRole(SystemRoles.SuperAdmin))), token);
var finishedTotal = await source.CountAsync(change =>
change.State == StudentStatusChangeState.Approved ||
change.State == StudentStatusChangeState.Rejected ||
change.State == StudentStatusChangeState.Cancelled, token);
var items = await source
.OrderByDescending(x => x.SubmittedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new StudentStatusChangeListItem(
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
x.Student.AdministrativeClass!.Name,
x.Student.AdministrativeClass.Major!.College!.Name,
x.Type,
x.OriginalStatus,
x.TargetStatus,
x.Reason,
x.State,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.ApprovedAt))
.ToListAsync(token);
return Ok(new StudentStatusChangePage(
items, total, page, pageSize, actionableTotal, finishedTotal));
}
[HttpGet("options")]
@@ -199,6 +235,31 @@ public sealed class StudentStatusChangesController(
});
}
public sealed record StudentStatusChangePage(
IReadOnlyCollection<StudentStatusChangeListItem> Items,
int Total,
int Page,
int PageSize,
int ActionableTotal,
int FinishedTotal);
public sealed record StudentStatusChangeListItem(
Guid Id,
Guid StudentId,
string StudentNumber,
string Name,
string ClassName,
string CollegeName,
StudentStatusChangeType Type,
StudentStatus OriginalStatus,
StudentStatus TargetStatus,
string Reason,
StudentStatusChangeState State,
string? ReviewComment,
DateTime SubmittedAt,
DateTime? ReviewedAt,
DateTime? ApprovedAt);
public sealed record StudentStatusChangeRequest(
StudentStatusChangeType Type,
[Required, MinLength(10), MaxLength(1000)] string Reason);
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -46,17 +47,26 @@ public sealed class TeacherCourseApplicationsController(
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMine(
Guid? academicTermId,
CancellationToken cancellationToken)
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var teacher = await CurrentTeacherAsync(cancellationToken);
if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。");
var source = db.TeacherCourseApplications.AsNoTracking()
.Where(x => x.TeacherId == teacher.Id);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
@@ -72,7 +82,8 @@ public sealed class TeacherCourseApplicationsController(
x.SubmittedAt,
x.ReviewedAt
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost("mine")]
@@ -144,15 +155,24 @@ public sealed class TeacherCourseApplicationsController(
public async Task<ActionResult> GetReviews(
Guid? academicTermId,
TeacherCourseApplicationStatus? status,
CancellationToken cancellationToken)
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
if (page < 1 || pageSize is < 1 or > 100)
return ValidationProblem("页码必须大于 0,且每页条数应在 1 至 100 之间。");
var source = ScopedApplications().AsNoTracking();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (status.HasValue) source = source.Where(x => x.Status == status);
return Ok(await source
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.Status)
.ThenByDescending(x => x.SubmittedAt)
.ThenBy(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
@@ -172,7 +192,8 @@ public sealed class TeacherCourseApplicationsController(
x.SubmittedAt,
x.ReviewedAt
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpGet("assignment-options")]
+42 -10
View File
@@ -27,6 +27,11 @@ const sessionTask = ref<any>(null)
const sessionLoading = ref(false)
const termId = ref<string>()
const tab = ref(isManager.value ? 'reviews' : 'mine')
const minePage = ref(1)
const mineTotal = ref(0)
const reviewPage = ref(1)
const reviewTotal = ref(0)
const pageSize = 20
const form = reactive({
teachingTaskId: undefined as string | undefined,
@@ -79,21 +84,30 @@ function sessionLabel(session: any) {
return `${session.week} 周 · ${session.date}${weekdayLabel(session.dayOfWeek)} · 第 ${session.startPeriod}${end}${room}`
}
async function load() {
async function load(resetPages = false) {
if (resetPages) {
minePage.value = 1
reviewPage.value = 1
}
loading.value = true
reviewLoading.value = isManager.value
try {
if (isTeacher.value || (!isManager.value)) {
adjustments.value = (await http.get('/course-adjustments/mine', {
params: { academicTermId: termId.value },
})).data
const response = await http.get('/course-adjustments/mine', {
params: { academicTermId: termId.value, page: minePage.value, pageSize },
})
adjustments.value = response.data.items
mineTotal.value = response.data.total
}
if (isManager.value) {
pendingReviews.value = (await http.get('/course-adjustments/pending-reviews', {
params: { academicTermId: termId.value },
})).data
const response = await http.get('/course-adjustments/pending-reviews', {
params: { academicTermId: termId.value, page: reviewPage.value, pageSize },
})
pendingReviews.value = response.data.items
reviewTotal.value = response.data.total
}
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
finally { loading.value = false; reviewLoading.value = false }
}
function openCreate() {
@@ -255,12 +269,12 @@ function showSub(type: string) { return type === 'Substitute' }
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
</div>
</section>
<section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load(); loadTaskOptions()">
<el-select v-model="termId" clearable placeholder="全部学期" @change="() => { load(true); loadTaskOptions() }">
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select>
<el-segmented v-model="tab" :options="[
@@ -307,6 +321,15 @@ function showSub(type: string) { return type === 'Substitute' }
</footer>
</article>
<el-empty v-if="!adjustments.length" description="暂无调停课申请" />
<el-pagination
v-if="mineTotal > pageSize"
v-model:current-page="minePage"
:page-size="pageSize"
:total="mineTotal"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section>
<!-- Pending reviews -->
@@ -345,6 +368,15 @@ function showSub(type: string) { return type === 'Substitute' }
</footer>
</article>
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
<el-pagination
v-if="reviewTotal > pageSize"
v-model:current-page="reviewPage"
:page-size="pageSize"
:total="reviewTotal"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section>
<!-- Create dialog -->
+32 -12
View File
@@ -23,6 +23,11 @@ const selected = ref<any | null>(null)
const reviewApproved = ref(true)
const applyForm = reactive({ type: '', reason: '' })
const reviewForm = reactive({ comment: '' })
const page = ref(1)
const total = ref(0)
const actionableTotal = ref(0)
const finishedTotal = ref(0)
const pageSize = 20
const typeLabels: Record<string, string> = {
Suspension: '休学',
@@ -48,10 +53,6 @@ const steps = [
{ label: '学院审核', state: 'CounselorApproved' },
{ label: '校级审批', state: 'CollegeApproved' },
]
const currentQueue = computed(() => changes.value.filter(canReview))
const finishedCount = computed(() =>
changes.value.filter((x) => ['Approved', 'Rejected', 'Cancelled'].includes(x.state)).length)
function dateText(value?: string) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
@@ -90,14 +91,24 @@ function stateTagType(state: ChangeState) {
return 'warning'
}
async function load() {
async function load(resetPage = false) {
if (resetPage) page.value = 1
loading.value = true
try {
const requests = [http.get('/student-status-changes')]
const requests = [http.get('/student-status-changes', {
params: { page: page.value, pageSize },
})]
if (isStudent.value) requests.push(http.get('/student-status-changes/options'))
const [changeResponse, optionResponse] = await Promise.all(requests)
changes.value = changeResponse.data
changes.value = changeResponse.data.items
total.value = changeResponse.data.total
actionableTotal.value = changeResponse.data.actionableTotal
finishedTotal.value = changeResponse.data.finishedTotal
if (optionResponse) options.value = optionResponse.data
if (changes.value.length === 0 && page.value > 1) {
page.value--
await load()
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -118,7 +129,7 @@ async function submitApply() {
await http.post('/student-status-changes', applyForm)
applyDialog.value = false
ElMessage.success('申请已提交,等待辅导员审核。')
await load()
await load(true)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
@@ -177,7 +188,7 @@ onMounted(load)
:disabled="options?.hasPending || !options?.types?.length"
@click="openApply"
>发起申请</el-button>
<el-button v-else :icon="RefreshRight" @click="load">刷新队列</el-button>
<el-button v-else :icon="RefreshRight" @click="() => load()">刷新队列</el-button>
</section>
<section v-if="isStudent && options" class="status-identity">
@@ -194,9 +205,9 @@ onMounted(load)
</section>
<section v-if="!isStudent" class="status-review-summary">
<div><span>当前待我审核</span><b>{{ currentQueue.length }}</b><small></small></div>
<div><span>辖区申请总数</span><b>{{ changes.length }}</b><small></small></div>
<div><span>已结束</span><b>{{ finishedCount }}</b><small></small></div>
<div><span>当前待我审核</span><b>{{ actionableTotal }}</b><small></small></div>
<div><span>辖区申请总数</span><b>{{ total }}</b><small></small></div>
<div><span>已结束</span><b>{{ finishedTotal }}</b><small></small></div>
<p>系统仅开放当前审核层级的操作所有越级请求都会由服务端拒绝</p>
</section>
@@ -243,6 +254,15 @@ onMounted(load)
</footer>
</article>
<el-empty v-if="!changes.length && !loading" :description="isStudent ? '尚未提交学籍异动申请' : '当前辖区暂无学籍异动申请'" />
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section>
<el-dialog v-model="applyDialog" title="发起学籍异动申请" width="620px">
+27 -7
View File
@@ -19,6 +19,9 @@ const teachers = ref<any[]>([])
const submitDialog = ref(false)
const reviewDialog = ref(false)
const assignmentDialog = ref(false)
const page = ref(1)
const total = ref(0)
const pageSize = 20
const form = reactive<Record<string, any>>({})
const reviewForm = reactive<Record<string, any>>({})
const assignmentForm = reactive<Record<string, any>>({})
@@ -39,14 +42,22 @@ const statusTypes: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
Withdrawn: 'info',
}
async function load() {
async function load(resetPage = false) {
if (resetPage) page.value = 1
loading.value = true
try {
const endpoint = isTeacher.value
? '/teacher-course-applications/mine'
: '/teacher-course-applications/reviews'
const { data } = await http.get(endpoint, { params: filters })
rows.value = data
const { data } = await http.get(endpoint, {
params: { ...filters, page: page.value, pageSize },
})
rows.value = data.items
total.value = data.total
if (rows.value.length === 0 && page.value > 1) {
page.value--
await load()
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -195,14 +206,14 @@ onMounted(async () => {
<section class="data-card">
<div class="filter-bar">
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load">
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load(true)">
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
</el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load(true)">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button>
<span> {{ rows.length }} </span>
<el-button :icon="Refresh" @click="() => load()">刷新</el-button>
<span> {{ total }} </span>
</div>
<el-table v-loading="loading" :data="rows" class="data-table">
@@ -235,6 +246,15 @@ onMounted(async () => {
</el-table-column>
<template #empty><el-empty :description="isTeacher ? '尚未申报授课科目' : '没有待处理的授课申报'" /></template>
</el-table>
<el-pagination
v-if="total > pageSize"
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="table-pagination"
@current-change="() => load()"
/>
</section>
<el-dialog v-model="submitDialog" title="申报授课科目" width="600px">