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 1 hour ago
parent 39113aa091
commit 30e5c9ee11

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