refactor(task-view): implement tree structure for task display with project grouping

- Replaced the table view with a tree structure to enhance task organization by project.
- Introduced project selection checkboxes and task selection within expandable project sections.
- Added computed properties for managing task grouping and selection states.
- Improved loading state handling and empty task display messaging.
dev-QQq
qinzhenpen 5 hours ago
parent 39113aa091
commit 30e5c9ee11

@ -21,34 +21,55 @@
<el-alert v-if="classId && !canOperateSelectedClass" title="只能配置自己创建的教学班,当前班级仅可查看。" type="info" :closable="false" show-icon /> <el-alert v-if="classId && !canOperateSelectedClass" title="只能配置自己创建的教学班,当前班级仅可查看。" type="info" :closable="false" show-icon />
<el-table <!-- 树形结构展示 -->
ref="tableRef" <section v-loading="loading" class="task-tree">
:data="tasks" <div v-for="group in groupedTasks" :key="group.projectName" class="tree-project">
row-key="taskKey" <!-- 模块标题行 -->
v-loading="loading" <div class="project-row">
class="task-table" <div class="project-checkbox" @click.stop>
@selection-change="handleSelectionChange" <input
> type="checkbox"
<el-table-column type="selection" width="58" :reserve-selection="true" :selectable="() => canOperateSelectedClass" /> :checked="isProjectFullySelected(group)"
<el-table-column prop="taskName" label="实训任务" min-width="280" header-align="left" align="left"> :indeterminate="isProjectPartiallySelected(group)"
<template #default="{ row }"> :disabled="!canOperateSelectedClass"
<div class="name-cell"> @change="toggleProjectSelection(group)"
<span class="task-badge">{{ (row.taskName || "?").charAt(0) }}</span> />
<span class="name-text">{{ row.taskName }}</span>
</div> </div>
</template> <div class="project-header" @click="toggleProject(group.projectName)">
</el-table-column> <svg class="collapse-icon" :class="{ collapsed: !isProjectExpanded(group.projectName) }" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<el-table-column prop="projectName" label="所属模块" min-width="180" align="center" header-align="center" /> <polyline points="6 9 12 15 18 9" />
<template #empty> </svg>
<div class="table-empty"> <span class="project-label">{{ group.projectName }}</span>
<span class="project-count">({{ group.tasks.length }})</span>
</div>
</div>
<!-- 任务列表 -->
<div v-show="isProjectExpanded(group.projectName)" class="task-list">
<div v-for="task in group.tasks" :key="task.taskKey" class="task-row" :class="{ disabled: !canOperateSelectedClass }">
<div class="task-checkbox">
<input
type="checkbox"
:checked="isTaskSelected(task.taskKey)"
:disabled="!canOperateSelectedClass"
@change="toggleTaskSelection(task.taskKey)"
/>
</div>
<span class="task-badge">{{ (task.taskName || "?").charAt(0) }}</span>
<span class="task-name">{{ task.taskName }}</span>
<span class="task-hint" v-if="task.taskKey === comprehensiveTaskKey">(默认不发布)</span>
</div>
</div>
</div>
<el-empty v-if="!groupedTasks.length && !loading" class="task-empty" description="暂无实训任务">
<template #image>
<el-icon class="empty-icon"> <el-icon class="empty-icon">
<FolderOpened /> <FolderOpened />
</el-icon> </el-icon>
<p class="empty-title">暂无实训任务</p>
<p class="empty-tip">可通过顶部选择教学班加载实训任务列表</p>
</div>
</template> </template>
</el-table> </el-empty>
</section>
</div> </div>
</template> </template>
@ -67,16 +88,99 @@ const selectedTaskKeys = ref([]);
const configured = ref(false); const configured = ref(false);
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const tableRef = ref(null);
const comprehensiveTaskKey = "comprehensive-case-training"; const comprehensiveTaskKey = "comprehensive-case-training";
const expandedProjects = ref(new Set()); // 追踪展开的项目
let activeLoadGeneration = 0; let activeLoadGeneration = 0;
let selectionSyncGeneration = -1;
const currentUserId = computed(() => userStore.userInfo.userId); const currentUserId = computed(() => userStore.userInfo.userId);
const teachingClassList = computed(() => classList.value.filter((item) => item.classType === "TEACHING" || !item.classType && item.createdBy)); const teachingClassList = computed(() => classList.value.filter((item) => item.classType === "TEACHING" || !item.classType && item.createdBy));
const selectedClass = computed(() => teachingClassList.value.find((item) => item.schoolClassId === classId.value)); const selectedClass = computed(() => teachingClassList.value.find((item) => item.schoolClassId === classId.value));
const canOperateSelectedClass = computed(() => selectedClass.value?.createdBy === currentUserId.value); const canOperateSelectedClass = computed(() => selectedClass.value?.createdBy === currentUserId.value);
// 按项目分组任务
const groupedTasks = computed(() => {
const projectMap = new Map();
tasks.value.forEach((task) => {
const projectName = task.projectName || "其他";
if (!projectMap.has(projectName)) {
projectMap.set(projectName, {
projectName,
tasks: []
});
}
projectMap.get(projectName).tasks.push(task);
});
return Array.from(projectMap.values());
});
// 判断项目是否展开
function isProjectExpanded(projectName) {
return expandedProjects.value.has(projectName);
}
// 切换项目展开/收起
function toggleProject(projectName) {
if (expandedProjects.value.has(projectName)) {
expandedProjects.value.delete(projectName);
} else {
expandedProjects.value.add(projectName);
}
}
// 判断任务是否被选中
function isTaskSelected(taskKey) {
return selectedTaskKeys.value.includes(taskKey);
}
// 判断项目是否完全选中
function isProjectFullySelected(group) {
return group.tasks.every(task => isTaskSelected(task.taskKey));
}
// 判断项目是否部分选中
function isProjectPartiallySelected(group) {
const selectedCount = group.tasks.filter(task => isTaskSelected(task.taskKey)).length;
return selectedCount > 0 && selectedCount < group.tasks.length;
}
// 切换项目选中状态(全选/全不选)
function toggleProjectSelection(group) {
if (!canOperateSelectedClass.value) return;
const isFullySelected = isProjectFullySelected(group);
group.tasks.forEach(task => {
const index = selectedTaskKeys.value.indexOf(task.taskKey);
if (isFullySelected) {
// 如果已全选,则全不选
if (index > -1) {
selectedTaskKeys.value.splice(index, 1);
}
} else {
// 如果未全选,则全选
if (index === -1) {
selectedTaskKeys.value.push(task.taskKey);
}
}
});
}
// 切换任务选中状态
function toggleTaskSelection(taskKey) {
if (!canOperateSelectedClass.value) return;
const index = selectedTaskKeys.value.indexOf(taskKey);
if (index > -1) {
selectedTaskKeys.value.splice(index, 1);
} else {
selectedTaskKeys.value.push(taskKey);
}
}
async function loadClasses() { async function loadClasses() {
const res = await indexApi.getClassListBySchoolId({ schoolId: userStore.userInfo.schoolId }); const res = await indexApi.getClassListBySchoolId({ schoolId: userStore.userInfo.schoolId });
classList.value = res.data || []; classList.value = res.data || [];
@ -87,19 +191,6 @@ function isActiveLoad(requestedClassId, requestGeneration) {
return classId.value === requestedClassId && requestGeneration === activeLoadGeneration; return classId.value === requestedClassId && requestGeneration === activeLoadGeneration;
} }
function syncTableSelection(taskKeys, requestGeneration) {
selectionSyncGeneration = requestGeneration;
tableRef.value?.clearSelection();
proxy.$nextTick(() => {
if (requestGeneration !== activeLoadGeneration) return;
const selected = new Set(taskKeys);
tasks.value.forEach((task) => tableRef.value?.toggleRowSelection(task, selected.has(task.taskKey)));
proxy.$nextTick(() => {
if (requestGeneration === activeLoadGeneration) selectionSyncGeneration = -1;
});
});
}
function sortTasksWithComprehensiveLast(taskList) { function sortTasksWithComprehensiveLast(taskList) {
return [...taskList].sort((left, right) => { return [...taskList].sort((left, right) => {
const leftIsComprehensive = left?.taskKey === comprehensiveTaskKey; const leftIsComprehensive = left?.taskKey === comprehensiveTaskKey;
@ -119,18 +210,17 @@ async function loadTasks(requestGeneration) {
const res = await listClassTrainingTasks(classId.value, { enabledOnly: false }); const res = await listClassTrainingTasks(classId.value, { enabledOnly: false });
if (!isActiveLoad(requestedClassId, requestGeneration)) return false; if (!isActiveLoad(requestedClassId, requestGeneration)) return false;
tasks.value = sortTasksWithComprehensiveLast(res.data || []); tasks.value = sortTasksWithComprehensiveLast(res.data || []);
return true;
}
function applySelection(requestGeneration) { // 初始化展开状态:默认展开所有模块
syncTableSelection(selectedTaskKeys.value, requestGeneration); const projectNames = [...new Set(tasks.value.map(t => t.projectName || "其他"))];
expandedProjects.value = new Set(projectNames);
return true;
} }
async function loadPublication() { async function loadPublication() {
const requestGeneration = ++activeLoadGeneration; const requestGeneration = ++activeLoadGeneration;
selectedTaskKeys.value = []; selectedTaskKeys.value = [];
configured.value = false; configured.value = false;
syncTableSelection([], requestGeneration);
if (!classId.value) { if (!classId.value) {
tasks.value = []; tasks.value = [];
return; return;
@ -148,17 +238,11 @@ async function loadPublication() {
: tasks.value : tasks.value
.filter((task) => task.taskKey !== comprehensiveTaskKey) .filter((task) => task.taskKey !== comprehensiveTaskKey)
.map((task) => task.taskKey); .map((task) => task.taskKey);
applySelection(requestGeneration);
} finally { } finally {
if (requestGeneration === activeLoadGeneration) loading.value = false; if (requestGeneration === activeLoadGeneration) loading.value = false;
} }
} }
function handleSelectionChange(selection) {
if (selectionSyncGeneration === activeLoadGeneration) return;
selectedTaskKeys.value = selection.map((task) => task.taskKey);
}
async function savePublication() { async function savePublication() {
if (!canOperateSelectedClass.value || !classId.value) return; if (!canOperateSelectedClass.value || !classId.value) return;
saving.value = true; saving.value = true;
@ -218,91 +302,162 @@ onMounted(async () => {
} }
} }
/* ===== 实训任务名称首字徽标(对齐班级/学生/院系管理基准)===== */ /* ===== 树形结构 ===== */
.name-cell { .task-tree {
display: inline-flex; background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
padding: 16px;
min-height: 300px;
}
.tree-project {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.project-row {
display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: 8px;
max-width: 100%; padding: 10px 12px;
vertical-align: middle; border-radius: 6px;
transition: background-color 0.2s;
.name-text { &:hover {
min-width: 0; background: #f9fafb;
overflow: hidden; }
white-space: nowrap;
text-overflow: ellipsis;
} }
.task-badge { .project-checkbox {
flex: 0 0 auto; flex-shrink: 0;
width: 28px; display: flex;
height: 28px;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
background: var(--edu-primary-soft); input[type="checkbox"] {
color: var(--edu-primary); width: 16px;
border-radius: var(--r-control); height: 16px;
font-size: var(--fs-body); cursor: pointer;
font-weight: 600; accent-color: #3b82f6;
&:disabled {
cursor: not-allowed;
} }
.name-text { &:indeterminate {
font-weight: 600; accent-color: #60a5fa;
color: var(--text-strong); }
} }
} }
/* ===== 自定义空状态 ===== */ .project-header {
.table-empty { flex: 1;
padding: var(--sp-5) 0;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: var(--sp-1); gap: 8px;
cursor: pointer;
user-select: none;
}
.empty-icon { .collapse-icon {
font-size: 28px; flex-shrink: 0;
color: var(--text-muted); color: #6b7280;
transition: transform 0.2s;
&.collapsed {
transform: rotate(-90deg);
}
} }
.empty-title { .project-label {
margin: var(--sp-1) 0 0; font-size: 15px;
font-size: var(--fs-body);
font-weight: 600; font-weight: 600;
color: var(--text-body); color: #1f2937;
flex: 1;
} }
.empty-tip { .project-count {
margin: 0; font-size: 13px;
font-size: var(--fs-tag); color: #9ca3af;
color: var(--text-muted); font-weight: normal;
} }
.task-list {
margin-left: 24px;
margin-top: 4px;
} }
/* ===== 数据表格(纯白 + 灰线)===== */ .task-row {
.task-table { display: flex;
width: 100%; align-items: center;
background: #ffffff; gap: 8px;
border: 1px solid var(--border-color); padding: 8px 12px;
border-radius: var(--r-card); border-radius: 4px;
overflow: hidden; transition: background-color 0.2s;
:deep(.el-table__header-wrapper th) { &:hover:not(.disabled) {
background: #ffffff; background: #f3f4f6;
color: var(--text-strong); }
font-weight: 600;
font-size: var(--fs-body); &.disabled {
opacity: 0.6;
cursor: not-allowed;
}
} }
:deep(.el-table__body tr:hover > td) { .task-checkbox {
background: #f9fafb !important; flex-shrink: 0;
display: flex;
align-items: center;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #3b82f6;
&:disabled {
cursor: not-allowed;
}
}
} }
:deep(.el-table__cell) { .task-badge {
flex-shrink: 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--edu-primary-soft);
color: var(--edu-primary);
border-radius: var(--r-control);
font-size: var(--fs-body); font-size: var(--fs-body);
color: var(--text-strong); font-weight: 600;
border-color: var(--border-color); }
.task-name {
flex: 1;
font-size: 14px;
color: #374151;
font-weight: 600;
}
.task-hint {
font-size: 12px;
color: #9ca3af;
}
/* ===== 自定义空状态 ===== */
.task-empty {
padding: var(--sp-5) 0;
.empty-icon {
font-size: 28px;
color: var(--text-muted);
} }
} }
} }

Loading…
Cancel
Save