教学任务页面优化
This commit is contained in:
@@ -240,26 +240,8 @@ public sealed class TeachingTasksController(
|
|||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (task is null) return NotFound();
|
if (task is null) return NotFound();
|
||||||
if (!CanManage(task.Course!)) return Forbid();
|
if (!CanManage(task.Course!)) return Forbid();
|
||||||
if (task.Status != TeachingTaskStatus.Draft)
|
var validation = await ValidatePublishingTasksAsync([task], cancellationToken);
|
||||||
return ConflictProblem("只有草稿教学任务可以发布。");
|
if (validation is not null) return ConflictProblem(validation);
|
||||||
if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1)
|
|
||||||
return ConflictProblem("发布前必须指定且只能指定一名主讲教师。");
|
|
||||||
if (task.Classes.Count == 0)
|
|
||||||
return ConflictProblem("发布前至少需要关联一个行政班。");
|
|
||||||
var teacherIds = task.Teachers.Select(x => x.TeacherId).ToArray();
|
|
||||||
if (await db.TeacherCourseApplications.CountAsync(
|
|
||||||
x =>
|
|
||||||
x.AcademicTermId == task.AcademicTermId &&
|
|
||||||
x.CourseId == task.CourseId &&
|
|
||||||
teacherIds.Contains(x.TeacherId) &&
|
|
||||||
x.Status == TeacherCourseApplicationStatus.Approved,
|
|
||||||
cancellationToken) != teacherIds.Length)
|
|
||||||
return ConflictProblem("授课教师尚未完成该课程申报,或学院审核尚未通过。");
|
|
||||||
var studentCount = task.Classes.Sum(x =>
|
|
||||||
x.AdministrativeClass!.Students.Count(student =>
|
|
||||||
student.Status == StudentStatus.Active));
|
|
||||||
if (studentCount > task.Capacity)
|
|
||||||
return ConflictProblem($"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。");
|
|
||||||
|
|
||||||
task.Status = TeachingTaskStatus.Published;
|
task.Status = TeachingTaskStatus.Published;
|
||||||
task.PublishedAt = DateTime.UtcNow;
|
task.PublishedAt = DateTime.UtcNow;
|
||||||
@@ -280,6 +262,69 @@ public sealed class TeachingTasksController(
|
|||||||
return await SaveAsync(id, false, cancellationToken);
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("batch")]
|
||||||
|
public async Task<ActionResult> Batch(
|
||||||
|
TeachingTaskBatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ids = request.Ids.Distinct().ToArray();
|
||||||
|
if (ids.Length == 0) return ValidationProblem("请至少选择一个教学任务。");
|
||||||
|
if (ids.Length > 100) return ValidationProblem("每次最多处理 100 个教学任务。");
|
||||||
|
|
||||||
|
var tasks = await ScopedTasks()
|
||||||
|
.Include(x => x.Course)
|
||||||
|
.Include(x => x.Teachers)
|
||||||
|
.Include(x => x.Classes)
|
||||||
|
.ThenInclude(x => x.AdministrativeClass)
|
||||||
|
.ThenInclude(x => x!.Students)
|
||||||
|
.Where(x => ids.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.TaskNumber)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (tasks.Count != ids.Length)
|
||||||
|
return ConflictProblem("部分教学任务不存在,或已超出当前账号的数据范围。");
|
||||||
|
if (tasks.Any(x => !CanManage(x.Course!))) return Forbid();
|
||||||
|
|
||||||
|
switch (request.Operation)
|
||||||
|
{
|
||||||
|
case TeachingTaskBatchOperation.Publish:
|
||||||
|
{
|
||||||
|
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
|
||||||
|
if (validation is not null) return ConflictProblem(validation);
|
||||||
|
var publishedAt = DateTime.UtcNow;
|
||||||
|
foreach (var task in tasks)
|
||||||
|
{
|
||||||
|
task.Status = TeachingTaskStatus.Published;
|
||||||
|
task.PublishedAt = publishedAt;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TeachingTaskBatchOperation.Delete:
|
||||||
|
if (tasks.Any(x => x.Status != TeachingTaskStatus.Draft))
|
||||||
|
return ConflictProblem(
|
||||||
|
$"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Draft).Name}”不是草稿,整批未删除。");
|
||||||
|
db.TeachingTasks.RemoveRange(tasks);
|
||||||
|
break;
|
||||||
|
case TeachingTaskBatchOperation.Archive:
|
||||||
|
if (tasks.Any(x => x.Status != TeachingTaskStatus.Published))
|
||||||
|
return ConflictProblem(
|
||||||
|
$"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Published).Name}”不是已发布状态,整批未归档。");
|
||||||
|
foreach (var task in tasks) task.Status = TeachingTaskStatus.Closed;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return ValidationProblem("不支持的批量操作。");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new { AffectedCount = tasks.Count });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return ConflictProblem("批量操作未执行:教学任务仍被其他业务引用,或关联数据已失效。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("generate-public-course")]
|
[HttpPost("generate-public-course")]
|
||||||
[Authorize(Roles = SchoolManagementRoles)]
|
[Authorize(Roles = SchoolManagementRoles)]
|
||||||
public async Task<ActionResult> GeneratePublicCourseTasks(
|
public async Task<ActionResult> GeneratePublicCourseTasks(
|
||||||
@@ -511,6 +556,82 @@ public sealed class TeachingTasksController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ValidatePublishingTasksAsync(
|
||||||
|
IReadOnlyCollection<TeachingTask> tasks,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var invalidStatus = tasks.FirstOrDefault(x => x.Status != TeachingTaskStatus.Draft);
|
||||||
|
if (invalidStatus is not null)
|
||||||
|
return PublishingProblem(
|
||||||
|
tasks,
|
||||||
|
invalidStatus,
|
||||||
|
"只有草稿教学任务可以发布。");
|
||||||
|
|
||||||
|
var invalidPrimaryTeacher = tasks.FirstOrDefault(x =>
|
||||||
|
x.Teachers.Count == 0 || x.Teachers.Count(item => item.IsPrimary) != 1);
|
||||||
|
if (invalidPrimaryTeacher is not null)
|
||||||
|
return PublishingProblem(
|
||||||
|
tasks,
|
||||||
|
invalidPrimaryTeacher,
|
||||||
|
"发布前必须指定且只能指定一名主讲教师。");
|
||||||
|
|
||||||
|
var missingClass = tasks.FirstOrDefault(x => x.Classes.Count == 0);
|
||||||
|
if (missingClass is not null)
|
||||||
|
return PublishingProblem(
|
||||||
|
tasks,
|
||||||
|
missingClass,
|
||||||
|
"发布前至少需要关联一个行政班。");
|
||||||
|
|
||||||
|
var termIds = tasks.Select(x => x.AcademicTermId).Distinct().ToArray();
|
||||||
|
var courseIds = tasks.Select(x => x.CourseId).Distinct().ToArray();
|
||||||
|
var teacherIds = tasks
|
||||||
|
.SelectMany(x => x.Teachers)
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var approvedApplications = await db.TeacherCourseApplications
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
termIds.Contains(x.AcademicTermId) &&
|
||||||
|
courseIds.Contains(x.CourseId) &&
|
||||||
|
teacherIds.Contains(x.TeacherId) &&
|
||||||
|
x.Status == TeacherCourseApplicationStatus.Approved)
|
||||||
|
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var approvedAssignments = approvedApplications
|
||||||
|
.Select(x => (x.AcademicTermId, x.CourseId, x.TeacherId))
|
||||||
|
.ToHashSet();
|
||||||
|
var unapprovedTeacher = tasks.FirstOrDefault(task =>
|
||||||
|
task.Teachers.Any(teacher =>
|
||||||
|
!approvedAssignments.Contains(
|
||||||
|
(task.AcademicTermId, task.CourseId, teacher.TeacherId))));
|
||||||
|
if (unapprovedTeacher is not null)
|
||||||
|
return PublishingProblem(
|
||||||
|
tasks,
|
||||||
|
unapprovedTeacher,
|
||||||
|
"授课教师尚未完成该课程申报,或学院审核尚未通过。");
|
||||||
|
|
||||||
|
foreach (var task in tasks)
|
||||||
|
{
|
||||||
|
var studentCount = task.Classes.Sum(x =>
|
||||||
|
x.AdministrativeClass!.Students.Count(student =>
|
||||||
|
student.Status == StudentStatus.Active));
|
||||||
|
if (studentCount > task.Capacity)
|
||||||
|
return PublishingProblem(
|
||||||
|
tasks,
|
||||||
|
task,
|
||||||
|
$"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PublishingProblem(
|
||||||
|
IReadOnlyCollection<TeachingTask> tasks,
|
||||||
|
TeachingTask task,
|
||||||
|
string detail) =>
|
||||||
|
tasks.Count == 1 ? detail : $"教学任务“{task.Name}”:{detail}整批未发布。";
|
||||||
|
|
||||||
private Guid? ScopedCollegeId()
|
private Guid? ScopedCollegeId()
|
||||||
=> currentUserDataScope.Current.RestrictedCollegeId;
|
=> currentUserDataScope.Current.RestrictedCollegeId;
|
||||||
|
|
||||||
@@ -588,3 +709,14 @@ public sealed record PublicCourseTaskGenerationRequest(
|
|||||||
[Range(1, 30)] int EndWeek,
|
[Range(1, 30)] int EndWeek,
|
||||||
[Range(1, 40)] int WeeklyHours,
|
[Range(1, 40)] int WeeklyHours,
|
||||||
IReadOnlyCollection<Guid> ClassIds);
|
IReadOnlyCollection<Guid> ClassIds);
|
||||||
|
|
||||||
|
public sealed record TeachingTaskBatchRequest(
|
||||||
|
TeachingTaskBatchOperation Operation,
|
||||||
|
[MinLength(1), MaxLength(100)] IReadOnlyCollection<Guid> Ids);
|
||||||
|
|
||||||
|
public enum TeachingTaskBatchOperation
|
||||||
|
{
|
||||||
|
Publish = 1,
|
||||||
|
Delete = 2,
|
||||||
|
Archive = 3
|
||||||
|
}
|
||||||
|
|||||||
@@ -300,6 +300,12 @@ button { cursor: pointer; }
|
|||||||
.task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; }
|
.task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; }
|
||||||
.task-summary b { margin-left: auto; color: #58d1c0; font: 700 30px/1 Consolas, monospace; }
|
.task-summary b { margin-left: auto; color: #58d1c0; font: 700 30px/1 Consolas, monospace; }
|
||||||
.task-summary p { margin: 0; padding-left: 28px; border-left: 1px solid rgba(255,255,255,.16); color: #d4daeb; font-size: 11px; letter-spacing: .03em; }
|
.task-summary p { margin: 0; padding-left: 28px; border-left: 1px solid rgba(255,255,255,.16); color: #d4daeb; font-size: 11px; letter-spacing: .03em; }
|
||||||
|
.batch-action-bar { min-height: 64px; padding: 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid #b9dcd7; background: #f1f9f7; }
|
||||||
|
.batch-action-bar > div:first-child { display: grid; gap: 4px; }
|
||||||
|
.batch-action-bar b { color: #244a49; font-size: 12px; }
|
||||||
|
.batch-action-bar span { color: #66807f; font-size: 10px; }
|
||||||
|
.batch-action-bar > div:last-child { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
|
||||||
|
.batch-action-bar .el-button + .el-button { margin-left: 0; }
|
||||||
.muted-action { color: #9aa1ae; font-size: 11px; }
|
.muted-action { color: #9aa1ae; font-size: 11px; }
|
||||||
.schedule-toolbar { min-height: 86px; padding: 12px 16px; display: flex; align-items: stretch; gap: 14px; border: 1px solid var(--line); background: white; }
|
.schedule-toolbar { min-height: 86px; padding: 12px 16px; display: flex; align-items: stretch; gap: 14px; border: 1px solid var(--line); background: white; }
|
||||||
.schedule-toolbar > .el-select { width: 245px; align-self: center; }
|
.schedule-toolbar > .el-select { width: 245px; align-self: center; }
|
||||||
@@ -362,6 +368,18 @@ button { cursor: pointer; }
|
|||||||
.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
|
.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
|
||||||
.generation-preview span, .generation-preview small { font-size: 10px; }
|
.generation-preview span, .generation-preview small { font-size: 10px; }
|
||||||
.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
|
.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
|
||||||
|
.option-filter-panel { margin: -3px 0 18px; padding: 11px 13px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; border: 1px solid #dce4eb; border-left: 3px solid var(--teal); background: #f8fafb; }
|
||||||
|
.option-filter-title { color: #3f5367; font-size: 10px; font-weight: 650; white-space: nowrap; }
|
||||||
|
.option-filter-panel > small { color: var(--muted); font-size: 9px; white-space: nowrap; }
|
||||||
|
.option-filter-grid { min-width: 0; display: grid; gap: 8px; }
|
||||||
|
.option-filter-grid.course { grid-template-columns: minmax(150px, 1fr) minmax(145px, .8fr) minmax(130px, .65fr); }
|
||||||
|
.option-filter-grid.course.compact { grid-template-columns: minmax(180px, 1fr) minmax(180px, .8fr); }
|
||||||
|
.option-filter-grid.classes { grid-template-columns: minmax(190px, 1.25fr) repeat(3, minmax(115px, .75fr)); }
|
||||||
|
.class-picker { width: 100%; margin-bottom: 8px; padding: 11px 12px 8px; border: 1px solid #dce4eb; background: #f8fafb; }
|
||||||
|
.class-picker-actions { min-height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.class-picker-actions > span { color: var(--muted); font-size: 9px; }
|
||||||
|
.class-picker-actions > div { display: flex; align-items: center; }
|
||||||
|
.class-picker-actions .el-button + .el-button { margin-left: 4px; }
|
||||||
|
|
||||||
.selection-page { min-width: 0; }
|
.selection-page { min-width: 0; }
|
||||||
.selection-round-strip {
|
.selection-round-strip {
|
||||||
@@ -982,6 +1000,13 @@ button { cursor: pointer; }
|
|||||||
.task-summary { align-items: flex-start; flex-direction: column; gap: 12px; }
|
.task-summary { align-items: flex-start; flex-direction: column; gap: 12px; }
|
||||||
.task-summary > div { width: 100%; }
|
.task-summary > div { width: 100%; }
|
||||||
.task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; }
|
.task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; }
|
||||||
|
.batch-action-bar { align-items: flex-start; flex-direction: column; }
|
||||||
|
.batch-action-bar > div:last-child { justify-content: flex-start; }
|
||||||
|
.option-filter-panel { grid-template-columns: 1fr; }
|
||||||
|
.option-filter-grid.course,
|
||||||
|
.option-filter-grid.course.compact,
|
||||||
|
.option-filter-grid.classes { grid-template-columns: 1fr 1fr; }
|
||||||
|
.class-picker-actions { align-items: flex-start; flex-direction: column; gap: 2px; }
|
||||||
.schedule-toolbar { display: block; }
|
.schedule-toolbar { display: block; }
|
||||||
.schedule-toolbar > .el-select { width: 100%; }
|
.schedule-toolbar > .el-select { width: 100%; }
|
||||||
.schedule-version-strip { margin-top: 10px; }
|
.schedule-version-strip { margin-top: 10px; }
|
||||||
@@ -1025,6 +1050,11 @@ button { cursor: pointer; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
.option-filter-grid.course,
|
||||||
|
.option-filter-grid.course.compact,
|
||||||
|
.option-filter-grid.classes { grid-template-columns: 1fr; }
|
||||||
|
.batch-action-bar > div:last-child { width: 100%; }
|
||||||
|
.batch-action-bar > div:last-child .el-button { flex: 1 1 auto; }
|
||||||
.selection-round-strip > button { flex-basis: 240px; }
|
.selection-round-strip > button { flex-basis: 240px; }
|
||||||
.selection-window { display: block; }
|
.selection-window { display: block; }
|
||||||
.window-seal { min-height: 54px; padding: 10px; display: flex; border-right: none; border-bottom: 1px solid rgba(255,255,255,.13); }
|
.window-seal { min-height: 54px; padding: 10px; display: flex; border-right: none; border-bottom: 1px solid rgba(255,255,255,.13); }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const dialogVisible = ref(false)
|
|||||||
const generationDialog = ref(false)
|
const generationDialog = ref(false)
|
||||||
const generating = ref(false)
|
const generating = ref(false)
|
||||||
const editingId = ref('')
|
const editingId = ref('')
|
||||||
|
const selectedRows = ref<any[]>([])
|
||||||
const terms = ref<any[]>([])
|
const terms = ref<any[]>([])
|
||||||
const courses = ref<any[]>([])
|
const courses = ref<any[]>([])
|
||||||
const teachers = ref<any[]>([])
|
const teachers = ref<any[]>([])
|
||||||
@@ -30,6 +31,27 @@ const query = reactive({
|
|||||||
})
|
})
|
||||||
const form = reactive<Record<string, any>>({})
|
const form = reactive<Record<string, any>>({})
|
||||||
const generationForm = reactive<Record<string, any>>({})
|
const generationForm = reactive<Record<string, any>>({})
|
||||||
|
const manualCourseFilters = reactive({
|
||||||
|
keyword: '',
|
||||||
|
collegeId: undefined as string | undefined,
|
||||||
|
nature: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
const manualClassFilters = reactive({
|
||||||
|
keyword: '',
|
||||||
|
collegeId: undefined as string | undefined,
|
||||||
|
majorId: undefined as string | undefined,
|
||||||
|
grade: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
const generationCourseFilters = reactive({
|
||||||
|
keyword: '',
|
||||||
|
collegeId: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
const generationClassFilters = reactive({
|
||||||
|
keyword: '',
|
||||||
|
collegeId: undefined as string | undefined,
|
||||||
|
majorId: undefined as string | undefined,
|
||||||
|
grade: undefined as number | undefined,
|
||||||
|
})
|
||||||
const eligibleTeachers = ref<any[]>([])
|
const eligibleTeachers = ref<any[]>([])
|
||||||
const manualEligibleTeachers = ref<any[]>([])
|
const manualEligibleTeachers = ref<any[]>([])
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
@@ -37,12 +59,20 @@ const statusLabels: Record<string, string> = {
|
|||||||
Published: '已发布',
|
Published: '已发布',
|
||||||
Closed: '已结课',
|
Closed: '已结课',
|
||||||
}
|
}
|
||||||
|
const courseNatureLabels: Record<string, string> = {
|
||||||
|
GeneralRequired: '公共必修',
|
||||||
|
GeneralElective: '公共选修',
|
||||||
|
MajorRequired: '专业必修',
|
||||||
|
MajorElective: '专业选修',
|
||||||
|
Practice: '实践教学',
|
||||||
|
}
|
||||||
const availableClasses = computed(() => {
|
const availableClasses = computed(() => {
|
||||||
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return classes.value
|
const enabled = classes.value.filter((item) => item.isEnabled)
|
||||||
|
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return enabled
|
||||||
const ids = new Set(
|
const ids = new Set(
|
||||||
majors.value.filter((item) => item.collegeId === auth.user!.collegeId).map((item) => item.id),
|
majors.value.filter((item) => item.collegeId === auth.user!.collegeId).map((item) => item.id),
|
||||||
)
|
)
|
||||||
return classes.value.filter((item) => ids.has(item.majorId))
|
return enabled.filter((item) => ids.has(item.majorId))
|
||||||
})
|
})
|
||||||
const selectedCourse = computed(() =>
|
const selectedCourse = computed(() =>
|
||||||
courses.value.find((item) => item.id === form.courseId),
|
courses.value.find((item) => item.id === form.courseId),
|
||||||
@@ -72,6 +102,46 @@ const manageableCourses = computed(() => {
|
|||||||
['MajorRequired', 'MajorElective', 'Practice'].includes(item.nature)
|
['MajorRequired', 'MajorElective', 'Practice'].includes(item.nature)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
const courseColleges = computed(() => {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
courses.value.forEach((item) => result.set(item.collegeId, item.collegeName))
|
||||||
|
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, 'zh-CN'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const classColleges = computed(() => {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
majors.value.forEach((item) => result.set(item.collegeId, item.collegeName))
|
||||||
|
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, 'zh-CN'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const majorById = computed(() =>
|
||||||
|
new Map(majors.value.map((item) => [item.id, item])),
|
||||||
|
)
|
||||||
|
const classGrades = computed(() =>
|
||||||
|
[...new Set(availableClasses.value.map((item) => item.grade))].sort((a, b) => b - a),
|
||||||
|
)
|
||||||
|
const manualClassMajors = computed(() => filteredMajors(manualClassFilters.collegeId))
|
||||||
|
const generationClassMajors = computed(() => filteredMajors(generationClassFilters.collegeId))
|
||||||
|
const filteredManageableCourses = computed(() =>
|
||||||
|
manageableCourses.value.filter((item) => courseMatches(item, manualCourseFilters)),
|
||||||
|
)
|
||||||
|
const filteredPublicCourses = computed(() =>
|
||||||
|
publicCourses.value.filter((item) => courseMatches(item, generationCourseFilters)),
|
||||||
|
)
|
||||||
|
const filteredManualClasses = computed(() =>
|
||||||
|
availableClasses.value.filter((item) => classMatches(item, manualClassFilters)),
|
||||||
|
)
|
||||||
|
const filteredGenerationClasses = computed(() =>
|
||||||
|
availableClasses.value.filter((item) => classMatches(item, generationClassFilters)),
|
||||||
|
)
|
||||||
|
const selectedDraftRows = computed(() =>
|
||||||
|
selectedRows.value.filter((item) => item.status === 'Draft'),
|
||||||
|
)
|
||||||
|
const selectedPublishedRows = computed(() =>
|
||||||
|
selectedRows.value.filter((item) => item.status === 'Published'),
|
||||||
|
)
|
||||||
const responsibilityText = computed(() => {
|
const responsibilityText = computed(() => {
|
||||||
if (isSuperAdmin.value) return '超级管理员可处理全部教学任务,并保留跨层级纠错权限。'
|
if (isSuperAdmin.value) return '超级管理员可处理全部教学任务,并保留跨层级纠错权限。'
|
||||||
if (isAcademicAdmin.value && isCollegeAdmin.value) {
|
if (isAcademicAdmin.value && isCollegeAdmin.value) {
|
||||||
@@ -91,6 +161,70 @@ function canManageRow(row: any) {
|
|||||||
isCollegeAdmin.value && !isPublic
|
isCollegeAdmin.value && !isPublic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function courseMatches(item: any, filters: Record<string, any>) {
|
||||||
|
const keyword = filters.keyword.trim().toLocaleLowerCase()
|
||||||
|
return (!keyword ||
|
||||||
|
item.code.toLocaleLowerCase().includes(keyword) ||
|
||||||
|
item.name.toLocaleLowerCase().includes(keyword)) &&
|
||||||
|
(!filters.collegeId || item.collegeId === filters.collegeId) &&
|
||||||
|
(!filters.nature || item.nature === filters.nature)
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredMajors(collegeId?: string) {
|
||||||
|
return majors.value
|
||||||
|
.filter((item) =>
|
||||||
|
item.isEnabled &&
|
||||||
|
(!collegeId || item.collegeId === collegeId) &&
|
||||||
|
availableClasses.value.some((administrativeClass) =>
|
||||||
|
administrativeClass.majorId === item.id),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.code.localeCompare(b.code))
|
||||||
|
}
|
||||||
|
|
||||||
|
function classMatches(item: any, filters: Record<string, any>) {
|
||||||
|
const keyword = filters.keyword.trim().toLocaleLowerCase()
|
||||||
|
const major = majorById.value.get(item.majorId)
|
||||||
|
return (!keyword ||
|
||||||
|
item.code.toLocaleLowerCase().includes(keyword) ||
|
||||||
|
item.name.toLocaleLowerCase().includes(keyword) ||
|
||||||
|
item.majorName.toLocaleLowerCase().includes(keyword)) &&
|
||||||
|
(!filters.collegeId || major?.collegeId === filters.collegeId) &&
|
||||||
|
(!filters.majorId || item.majorId === filters.majorId) &&
|
||||||
|
(!filters.grade || item.grade === filters.grade)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOptionFilters(filters: Record<string, any>) {
|
||||||
|
Object.keys(filters).forEach((key) => {
|
||||||
|
filters[key] = key === 'keyword' ? '' : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClassCollegeChanged(filters: Record<string, any>) {
|
||||||
|
if (filters.majorId &&
|
||||||
|
majorById.value.get(filters.majorId)?.collegeId !== filters.collegeId) {
|
||||||
|
filters.majorId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFilteredClasses(target: Record<string, any>, filtered: any[]) {
|
||||||
|
const selected = new Set<string>(target.classIds ?? [])
|
||||||
|
filtered.forEach((item) => selected.add(item.id))
|
||||||
|
target.classIds = [...selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilteredClasses(target: Record<string, any>, filtered: any[]) {
|
||||||
|
const filteredIds = new Set(filtered.map((item) => item.id))
|
||||||
|
target.classIds = (target.classIds ?? []).filter((id: string) => !filteredIds.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowSelectable(row: any) {
|
||||||
|
return canManageRow(row) && ['Draft', 'Published'].includes(row.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskSelectionChanged(selection: any[]) {
|
||||||
|
selectedRows.value = selection
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -102,6 +236,7 @@ async function load() {
|
|||||||
})
|
})
|
||||||
rows.value = data.items
|
rows.value = data.items
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
selectedRows.value = []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -141,6 +276,8 @@ function resetForm(detail?: any) {
|
|||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingId.value = ''
|
editingId.value = ''
|
||||||
resetForm()
|
resetForm()
|
||||||
|
resetOptionFilters(manualCourseFilters)
|
||||||
|
resetOptionFilters(manualClassFilters)
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +286,8 @@ async function openEdit(row: any) {
|
|||||||
const detail = (await http.get(`/teaching-tasks/${row.id}`)).data
|
const detail = (await http.get(`/teaching-tasks/${row.id}`)).data
|
||||||
editingId.value = row.id
|
editingId.value = row.id
|
||||||
resetForm(detail)
|
resetForm(detail)
|
||||||
|
resetOptionFilters(manualCourseFilters)
|
||||||
|
resetOptionFilters(manualClassFilters)
|
||||||
await loadManualEligibleTeachers()
|
await loadManualEligibleTeachers()
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -238,6 +377,52 @@ async function closeTask(row: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchAction(
|
||||||
|
operation: 'Publish' | 'Delete' | 'Archive',
|
||||||
|
targets: any[],
|
||||||
|
) {
|
||||||
|
if (!targets.length) return
|
||||||
|
const descriptions = {
|
||||||
|
Publish: {
|
||||||
|
title: '批量发布教学任务',
|
||||||
|
message: `将发布选中的 ${targets.length} 个草稿。发布前会逐项校验教师资格、行政班和容量,确定继续吗?`,
|
||||||
|
confirmButtonText: '批量发布',
|
||||||
|
success: '教学任务已批量发布',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
Delete: {
|
||||||
|
title: '批量删除教学任务',
|
||||||
|
message: `将永久删除选中的 ${targets.length} 个草稿,此操作不可撤销。确定继续吗?`,
|
||||||
|
confirmButtonText: '批量删除',
|
||||||
|
success: '教学任务草稿已批量删除',
|
||||||
|
type: 'error',
|
||||||
|
},
|
||||||
|
Archive: {
|
||||||
|
title: '批量归档教学任务',
|
||||||
|
message: `将选中的 ${targets.length} 个已发布任务结课并归档,后续将停止选课和排课调整。确定继续吗?`,
|
||||||
|
confirmButtonText: '批量归档',
|
||||||
|
success: '教学任务已批量归档',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
const description = descriptions[operation]
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(description.message, description.title, {
|
||||||
|
type: description.type,
|
||||||
|
confirmButtonText: description.confirmButtonText,
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
const { data } = await http.post('/teaching-tasks/batch', {
|
||||||
|
operation,
|
||||||
|
ids: targets.map((item) => item.id),
|
||||||
|
})
|
||||||
|
ElMessage.success(`${description.success},共 ${data.affectedCount} 个`)
|
||||||
|
await load()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openGeneration() {
|
function openGeneration() {
|
||||||
const currentTerm = terms.value.find((item) => item.isCurrent)
|
const currentTerm = terms.value.find((item) => item.isCurrent)
|
||||||
Object.assign(generationForm, {
|
Object.assign(generationForm, {
|
||||||
@@ -250,6 +435,8 @@ function openGeneration() {
|
|||||||
classIds: [],
|
classIds: [],
|
||||||
})
|
})
|
||||||
eligibleTeachers.value = []
|
eligibleTeachers.value = []
|
||||||
|
resetOptionFilters(generationCourseFilters)
|
||||||
|
resetOptionFilters(generationClassFilters)
|
||||||
generationDialog.value = true
|
generationDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +534,46 @@ onMounted(async () => {
|
|||||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="rows" class="data-table task-table">
|
<div v-if="selectedRows.length" class="batch-action-bar">
|
||||||
|
<div>
|
||||||
|
<b>已选择 {{ selectedRows.length }} 个教学任务</b>
|
||||||
|
<span>可操作:{{ selectedDraftRows.length }} 个草稿,{{ selectedPublishedRows.length }} 个已发布</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
:disabled="!selectedDraftRows.length"
|
||||||
|
@click="batchAction('Publish', selectedDraftRows)"
|
||||||
|
>
|
||||||
|
发布草稿({{ selectedDraftRows.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:disabled="!selectedDraftRows.length"
|
||||||
|
@click="batchAction('Delete', selectedDraftRows)"
|
||||||
|
>
|
||||||
|
删除草稿({{ selectedDraftRows.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
:disabled="!selectedPublishedRows.length"
|
||||||
|
@click="batchAction('Archive', selectedPublishedRows)"
|
||||||
|
>
|
||||||
|
归档已发布({{ selectedPublishedRows.length }})
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="rows"
|
||||||
|
class="data-table task-table"
|
||||||
|
@selection-change="onTaskSelectionChanged"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" :selectable="rowSelectable" />
|
||||||
<el-table-column label="任务编号" width="155"><template #default="{ row }"><span class="registry-number">{{ row.taskNumber }}</span></template></el-table-column>
|
<el-table-column label="任务编号" width="155"><template #default="{ row }"><span class="registry-number">{{ row.taskNumber }}</span></template></el-table-column>
|
||||||
<el-table-column label="教学班 / 课程" min-width="230">
|
<el-table-column label="教学班 / 课程" min-width="230">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -371,7 +597,7 @@ onMounted(async () => {
|
|||||||
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
|
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
|
||||||
<el-button v-if="canManageRow(row) && row.status === 'Published'" link type="warning" @click="closeTask(row)">结课</el-button>
|
<el-button v-if="canManageRow(row) && row.status === 'Published'" link type="warning" @click="closeTask(row)">结课</el-button>
|
||||||
<span v-if="!canManageRow(row) && row.status !== 'Closed'" class="muted-action">只读</span>
|
<span v-if="!canManageRow(row) && row.status !== 'Closed'" class="muted-action">只读</span>
|
||||||
<span v-if="row.status === 'Closed'" class="muted-action">已归档</span>
|
<span v-if="row.status === 'Closed'" class="muted-action">已结课归档</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="没有符合条件的教学任务" /></template>
|
<template #empty><el-empty description="没有符合条件的教学任务" /></template>
|
||||||
@@ -390,7 +616,24 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||||
<el-form-item label="课程" required><el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()"><el-option v-for="item in manageableCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /></el-select></el-form-item>
|
<el-form-item label="课程" required>
|
||||||
|
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
|
||||||
|
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="option-filter-panel">
|
||||||
|
<span class="option-filter-title">筛选课程</span>
|
||||||
|
<div class="option-filter-grid course">
|
||||||
|
<el-input v-model="manualCourseFilters.keyword" clearable placeholder="课程代码或名称" />
|
||||||
|
<el-select v-model="manualCourseFilters.collegeId" clearable placeholder="全部开课单位">
|
||||||
|
<el-option v-for="item in courseColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="manualCourseFilters.nature" clearable placeholder="全部课程性质">
|
||||||
|
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<small>当前显示 {{ filteredManageableCourses.length }} 门可维护课程</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid three">
|
<div class="form-grid three">
|
||||||
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
|
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
|
||||||
@@ -410,8 +653,29 @@ onMounted(async () => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="关联行政班">
|
<el-form-item label="关联行政班">
|
||||||
<el-select v-model="form.classIds" multiple filterable>
|
<div class="class-picker">
|
||||||
<el-option v-for="item in availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
<div class="option-filter-grid classes">
|
||||||
|
<el-input v-model="manualClassFilters.keyword" clearable placeholder="班级代码、名称或专业" />
|
||||||
|
<el-select v-model="manualClassFilters.collegeId" clearable placeholder="全部学院" @change="onClassCollegeChanged(manualClassFilters)">
|
||||||
|
<el-option v-for="item in classColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="manualClassFilters.majorId" clearable placeholder="全部专业">
|
||||||
|
<el-option v-for="item in manualClassMajors" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="manualClassFilters.grade" clearable placeholder="全部年级">
|
||||||
|
<el-option v-for="grade in classGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="class-picker-actions">
|
||||||
|
<span>筛选到 {{ filteredManualClasses.length }} 个班,已选择 {{ form.classIds?.length ?? 0 }} 个</span>
|
||||||
|
<div>
|
||||||
|
<el-button link type="primary" @click="selectFilteredClasses(form, filteredManualClasses)">选择筛选结果</el-button>
|
||||||
|
<el-button link @click="clearFilteredClasses(form, filteredManualClasses)">取消筛选结果</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-select v-model="form.classIds" multiple filterable collapse-tags collapse-tags-tooltip>
|
||||||
|
<el-option v-for="item in filteredManualClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
|
||||||
@@ -432,10 +696,20 @@ onMounted(async () => {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="公共课程" required>
|
<el-form-item label="公共课程" required>
|
||||||
<el-select v-model="generationForm.courseId" filterable @change="loadEligibleTeachers">
|
<el-select v-model="generationForm.courseId" filterable @change="loadEligibleTeachers">
|
||||||
<el-option v-for="item in publicCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
<el-option v-for="item in filteredPublicCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="option-filter-panel">
|
||||||
|
<span class="option-filter-title">筛选公共课程</span>
|
||||||
|
<div class="option-filter-grid course compact">
|
||||||
|
<el-input v-model="generationCourseFilters.keyword" clearable placeholder="课程代码或名称" />
|
||||||
|
<el-select v-model="generationCourseFilters.collegeId" clearable placeholder="全部开课单位">
|
||||||
|
<el-option v-for="item in courseColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<small>当前显示 {{ filteredPublicCourses.length }} 门公共课程</small>
|
||||||
|
</div>
|
||||||
<el-alert
|
<el-alert
|
||||||
:title="eligibleTeachers.length ? `已有 ${eligibleTeachers.length} 名教师审核通过` : '请选择课程查看可分配教师'"
|
:title="eligibleTeachers.length ? `已有 ${eligibleTeachers.length} 名教师审核通过` : '请选择课程查看可分配教师'"
|
||||||
:type="eligibleTeachers.length ? 'success' : 'warning'"
|
:type="eligibleTeachers.length ? 'success' : 'warning'"
|
||||||
@@ -447,8 +721,29 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<el-form-item label="参与合班的行政班" required style="margin-top: 16px">
|
<el-form-item label="参与合班的行政班" required style="margin-top: 16px">
|
||||||
|
<div class="class-picker">
|
||||||
|
<div class="option-filter-grid classes">
|
||||||
|
<el-input v-model="generationClassFilters.keyword" clearable placeholder="班级代码、名称或专业" />
|
||||||
|
<el-select v-model="generationClassFilters.collegeId" clearable placeholder="全部学院" @change="onClassCollegeChanged(generationClassFilters)">
|
||||||
|
<el-option v-for="item in classColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="generationClassFilters.majorId" clearable placeholder="全部专业">
|
||||||
|
<el-option v-for="item in generationClassMajors" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="generationClassFilters.grade" clearable placeholder="全部年级">
|
||||||
|
<el-option v-for="grade in classGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="class-picker-actions">
|
||||||
|
<span>筛选到 {{ filteredGenerationClasses.length }} 个班,已选择 {{ generationForm.classIds?.length ?? 0 }} 个</span>
|
||||||
|
<div>
|
||||||
|
<el-button link type="primary" @click="selectFilteredClasses(generationForm, filteredGenerationClasses)">选择筛选结果</el-button>
|
||||||
|
<el-button link @click="clearFilteredClasses(generationForm, filteredGenerationClasses)">取消筛选结果</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<el-select v-model="generationForm.classIds" multiple filterable collapse-tags>
|
<el-select v-model="generationForm.classIds" multiple filterable collapse-tags>
|
||||||
<el-option v-for="item in availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
<el-option v-for="item in filteredGenerationClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid three">
|
<div class="form-grid three">
|
||||||
|
|||||||
Reference in New Issue
Block a user