kindergarten/reading-platform-frontend/src/views/school/tasks/TaskListView.vue
2026-03-03 13:59:02 +08:00

535 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="min-h-100vh p-4 bg-[#fafafa]">
<!-- 页面标题 -->
<div class="flex justify-between items-start mb-6 page-header">
<div class="header-left">
<h2 class="m-0 text-2xl font-600 text-[#333]">阅读任务</h2>
<p class="mt-1 mb-0 text-[#999] text-sm">管理全校阅读任务跟踪学生完成情况</p>
</div>
<div class="header-right">
<a-button type="primary" @click="showCreateModal">
<PlusOutlined /> 发布任务
</a-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="flex gap-5 mb-6 stats-cards">
<div class="flex-1 flex items-center gap-4 p-5 bg-white rounded-xl shadow-[0_2px_8px_rgba(0,0,0,0.04)] stat-card">
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl bg-[#e6f7ff] text-[#1890ff]">
<FileTextOutlined />
</div>
<div class="stat-info">
<span class="block text-[28px] font-600 text-[#333]">{{ stats.totalTasks }}</span>
<span class="text-[13px] text-[#999]">全部任务</span>
</div>
</div>
<div class="flex-1 flex items-center gap-4 p-5 bg-white rounded-xl shadow-[0_2px_8px_rgba(0,0,0,0.04)] stat-card">
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl bg-[#fff7e6] text-[#fa8c16]">
<SyncOutlined />
</div>
<div class="stat-info">
<span class="block text-[28px] font-600 text-[#333]">{{ stats.publishedTasks }}</span>
<span class="text-[13px] text-[#999]">进行中</span>
</div>
</div>
<div class="flex-1 flex items-center gap-4 p-5 bg-white rounded-xl shadow-[0_2px_8px_rgba(0,0,0,0.04)] stat-card">
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-2xl bg-[#f6ffed] text-[#52c41a]">
<CheckCircleOutlined />
</div>
<div class="stat-info">
<span class="block text-[28px] font-600 text-[#333]">{{ stats.completionRate }}%</span>
<span class="text-[13px] text-[#999]">完成率</span>
</div>
</div>
</div>
<!-- 筛选区域 -->
<div class="flex gap-3 mb-6 filter-bar">
<a-select
v-model:value="filters.status"
placeholder="任务状态"
class="w-[120px]"
allowClear
@change="loadTasks"
>
<a-select-option value="PUBLISHED">进行中</a-select-option>
<a-select-option value="DRAFT">草稿</a-select-option>
<a-select-option value="ARCHIVED">已归档</a-select-option>
</a-select>
<a-select
v-model:value="filters.taskType"
placeholder="任务类型"
class="w-[120px]"
allowClear
@change="loadTasks"
>
<a-select-option value="READING">阅读</a-select-option>
<a-select-option value="ACTIVITY">活动</a-select-option>
<a-select-option value="HOMEWORK">作业</a-select-option>
</a-select>
<a-input-search
v-model:value="filters.keyword"
placeholder="搜索任务标题"
class="w-[200px]"
@search="loadTasks"
allow-clear
/>
</div>
<!-- 任务列表 -->
<a-spin :spinning="loading">
<div class="flex flex-col gap-4 task-list" v-if="tasks.length > 0">
<div v-for="task in tasks" :key="task.id" class="bg-white border border-[#f0f0f0] rounded-xl overflow-hidden task-card">
<div class="flex justify-between items-center py-4 px-5 bg-[#fafafa] border-b border-[#f0f0f0] card-header">
<div class="flex items-center gap-3 task-title">
<a-tag :color="getTypeColor(task.taskType)">{{ getTypeText(task.taskType) }}</a-tag>
<h3 class="m-0 text-base font-600 text-[#333]">{{ task.title }}</h3>
</div>
<a-tag :color="getStatusColor(task.status)">{{ getStatusText(task.status) }}</a-tag>
</div>
<div class="py-4 px-5 card-body">
<p v-if="task.description" class="text-[#666] m-0 mb-3 leading-[1.6]">{{ task.description }}</p>
<div class="flex gap-6 text-[#999] text-[13px] task-meta">
<span v-if="task.course" class="flex items-center gap-1.5">
<BookOutlined /> {{ task.course.name }}
</span>
<span class="flex items-center gap-1.5">
<CalendarOutlined />
{{ formatDate(task.startDate) }} - {{ formatDate(task.endDate) }}
</span>
<span class="flex items-center gap-1.5">
<TeamOutlined /> {{ task.targetCount || 0 }} 个目标
</span>
</div>
</div>
<div class="flex justify-between items-center py-3 px-5 bg-[#fafafa] border-t border-[#f0f0f0] card-footer">
<div class="flex items-center gap-3 progress-info">
<a-progress
:percent="getCompletionRate(task)"
:stroke-color="{ '0%': '#52c41a', '100%': '#73d13d' }"
size="small"
class="w-[150px]"
/>
<span class="text-[13px] text-[#666]">{{ getCompletedCount(task) }}/{{ task.completionCount || 0 }} 人完成</span>
</div>
<div class="flex gap-2 card-actions">
<a-button type="link" size="small" @click="viewCompletionDetail(task)">
<EyeOutlined /> 完成情况
</a-button>
<a-button type="link" size="small" @click="showEditModal(task)">
<EditOutlined /> 编辑
</a-button>
<a-popconfirm title="确定删除此任务?" @confirm="handleDelete(task.id)">
<a-button type="link" size="small" danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div class="text-center py-[60px] px-5 text-[#999] empty-state" v-else>
<InboxOutlined class="text-[64px] text-[#d9d9d9] mb-4" />
<p class="my-1">暂无阅读任务</p>
<a-button type="primary" @click="showCreateModal">发布第一个任务</a-button>
</div>
</a-spin>
<!-- 分页 -->
<div class="flex justify-center mt-6 pagination-section" v-if="total > pageSize">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
@change="onPageChange"
show-quick-jumper
:show-total="(total: number) => `共 ${total} 条`"
/>
</div>
<!-- 创建/编辑任务弹窗 -->
<a-modal
v-model:open="createModalVisible"
:title="isEdit ? '编辑任务' : '发布任务'"
@ok="handleSubmit"
:confirm-loading="creating"
width="600px"
>
<a-form :model="createForm" layout="vertical">
<a-form-item label="任务标题" required>
<a-input v-model:value="createForm.title" placeholder="请输入任务标题" />
</a-form-item>
<a-form-item label="任务描述">
<a-textarea v-model:value="createForm.description" placeholder="请输入任务描述" :rows="3" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="任务类型" required>
<a-select v-model:value="createForm.taskType" class="w-full">
<a-select-option value="READING">阅读</a-select-option>
<a-select-option value="ACTIVITY">活动</a-select-option>
<a-select-option value="HOMEWORK">作业</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="目标类型" required>
<a-radio-group v-model:value="createForm.targetType">
<a-radio value="CLASS">班级</a-radio>
<a-radio value="STUDENT">学生</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="选择目标" required v-if="createForm.targetType === 'CLASS'">
<a-select
v-model:value="createForm.targetIds"
mode="multiple"
placeholder="请选择班级"
class="w-full"
>
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }} ({{ cls.grade }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="任务时间" required>
<a-range-picker
v-model:value="createForm.dateRange"
class="w-full"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 完成情况弹窗 -->
<a-modal
v-model:open="completionModalVisible"
:title="`完成情况 - ${selectedTask?.title || ''}`"
:footer="null"
width="700px"
>
<div class="flex gap-2 mb-4 completion-stats">
<a-tag color="blue">{{ completionStats.pending }} 待完成</a-tag>
<a-tag color="orange">{{ completionStats.inProgress }} 进行中</a-tag>
<a-tag color="green">{{ completionStats.completed }} 已完成</a-tag>
</div>
<a-table
:dataSource="completions"
:columns="completionColumns"
:pagination="false"
:loading="loadingCompletions"
rowKey="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getCompletionStatusColor(record.status)">
{{ getCompletionStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'feedback'">
<span v-if="record.parentFeedback" class="text-[#666] text-xs">
{{ record.parentFeedback.substring(0, 30) }}{{ record.parentFeedback.length > 30 ? '...' : '' }}
</span>
<span v-else class="text-[#bfbfbf] text-xs">暂无家长反馈</span>
</template>
</template>
</a-table>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import {
PlusOutlined,
FileTextOutlined,
SyncOutlined,
CheckCircleOutlined,
BookOutlined,
CalendarOutlined,
TeamOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
InboxOutlined,
} from '@ant-design/icons-vue';
import {
getSchoolTasks,
createSchoolTask,
updateSchoolTask,
deleteSchoolTask,
getSchoolTaskCompletions,
getSchoolClasses,
type SchoolTask,
type TaskCompletion,
} from '@/api/school';
import dayjs, { Dayjs } from 'dayjs';
const loading = ref(false);
const creating = ref(false);
const createModalVisible = ref(false);
const completionModalVisible = ref(false);
const isEdit = ref(false);
const editTaskId = ref<number | null>(null);
const tasks = ref<SchoolTask[]>([]);
const classes = ref<any[]>([]);
const completions = ref<TaskCompletion[]>([]);
const selectedTask = ref<SchoolTask | null>(null);
const loadingCompletions = ref(false);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const stats = reactive({
totalTasks: 0,
publishedTasks: 0,
completionRate: 0,
});
const completionStats = reactive({
pending: 0,
inProgress: 0,
completed: 0,
});
const filters = reactive({
status: undefined as string | undefined,
taskType: undefined as string | undefined,
keyword: undefined as string | undefined,
});
const createForm = reactive({
title: '',
description: '',
taskType: 'READING' as 'READING' | 'ACTIVITY' | 'HOMEWORK',
targetType: 'CLASS' as 'CLASS' | 'STUDENT',
targetIds: [] as number[],
dateRange: null as [Dayjs, Dayjs] | null,
});
const completionColumns = [
{ title: '学生姓名', dataIndex: ['student', 'name'], key: 'name' },
{ title: '班级', dataIndex: ['student', 'class', 'name'], key: 'class' },
{ title: '状态', key: 'status' },
{ title: '家长反馈', key: 'feedback' },
];
const typeColors: Record<string, string> = {
READING: 'blue',
ACTIVITY: 'green',
HOMEWORK: 'orange',
};
const typeTexts: Record<string, string> = {
READING: '阅读',
ACTIVITY: '活动',
HOMEWORK: '作业',
};
const statusColors: Record<string, string> = {
PUBLISHED: 'green',
DRAFT: 'default',
ARCHIVED: 'default',
};
const statusTexts: Record<string, string> = {
PUBLISHED: '进行中',
DRAFT: '草稿',
ARCHIVED: '已归档',
};
const getTypeColor = (type: string) => typeColors[type] || 'default';
const getTypeText = (type: string) => typeTexts[type] || type;
const getStatusColor = (status: string) => statusColors[status] || 'default';
const getStatusText = (status: string) => statusTexts[status] || status;
const formatDate = (date: string) => {
if (!date) return '-';
return dayjs(date).format('YYYY-MM-DD');
};
const getCompletionRate = (task: SchoolTask) => {
if (!task.completionCount) return 0;
const completed = completions.value.filter(c => c.status === 'COMPLETED').length;
return Math.round((completed / task.completionCount) * 100);
};
const getCompletedCount = (task: SchoolTask) => {
return completions.value.filter(c => c.status === 'COMPLETED').length;
};
const getCompletionStatusColor = (status: string) => {
const colors: Record<string, string> = {
PENDING: 'blue',
IN_PROGRESS: 'orange',
COMPLETED: 'green',
};
return colors[status] || 'default';
};
const getCompletionStatusText = (status: string) => {
const texts: Record<string, string> = {
PENDING: '待完成',
IN_PROGRESS: '进行中',
COMPLETED: '已完成',
};
return texts[status] || status;
};
const loadTasks = async () => {
loading.value = true;
try {
const result = await getSchoolTasks({
page: currentPage.value,
pageSize: pageSize.value,
status: filters.status,
taskType: filters.taskType,
keyword: filters.keyword,
});
tasks.value = result.items;
total.value = result.total;
// 更新统计
stats.totalTasks = result.total;
stats.publishedTasks = tasks.value.filter(t => t.status === 'PUBLISHED').length;
} catch (error: any) {
message.error(error.response?.data?.message || '获取任务列表失败');
} finally {
loading.value = false;
}
};
const loadClasses = async () => {
try {
const result = await getSchoolClasses();
classes.value = result;
} catch (error) {
console.error('获取班级列表失败', error);
}
};
const showCreateModal = () => {
isEdit.value = false;
editTaskId.value = null;
createForm.title = '';
createForm.description = '';
createForm.taskType = 'READING';
createForm.targetType = 'CLASS';
createForm.targetIds = [];
createForm.dateRange = null;
createModalVisible.value = true;
loadClasses();
};
const showEditModal = (task: SchoolTask) => {
isEdit.value = true;
editTaskId.value = task.id;
createForm.title = task.title;
createForm.description = task.description || '';
createForm.taskType = task.taskType;
createForm.targetType = task.targetType;
createForm.targetIds = [];
createForm.dateRange = [
dayjs(task.startDate),
dayjs(task.endDate),
];
createModalVisible.value = true;
loadClasses();
};
const handleSubmit = async () => {
if (!createForm.title.trim()) {
message.warning('请输入任务标题');
return;
}
if (!createForm.dateRange) {
message.warning('请选择任务时间');
return;
}
if (createForm.targetType === 'CLASS' && createForm.targetIds.length === 0) {
message.warning('请选择目标班级');
return;
}
creating.value = true;
try {
const data = {
title: createForm.title,
description: createForm.description || undefined,
taskType: createForm.taskType,
targetType: createForm.targetType,
targetIds: createForm.targetIds,
startDate: createForm.dateRange[0].format('YYYY-MM-DD'),
endDate: createForm.dateRange[1].format('YYYY-MM-DD'),
};
if (isEdit.value && editTaskId.value) {
await updateSchoolTask(editTaskId.value, data);
message.success('任务更新成功');
} else {
await createSchoolTask(data);
message.success('任务发布成功');
}
createModalVisible.value = false;
loadTasks();
} catch (error: any) {
message.error(error.response?.data?.message || '操作失败');
} finally {
creating.value = false;
}
};
const handleDelete = async (id: number) => {
try {
await deleteSchoolTask(id);
message.success('删除成功');
loadTasks();
} catch (error: any) {
message.error(error.response?.data?.message || '删除失败');
}
};
const viewCompletionDetail = async (task: SchoolTask) => {
selectedTask.value = task;
completionModalVisible.value = true;
loadingCompletions.value = true;
try {
const result = await getSchoolTaskCompletions(task.id, { pageSize: 100 });
completions.value = result.items;
// 计算统计
completionStats.pending = result.items.filter(c => c.status === 'PENDING').length;
completionStats.inProgress = result.items.filter(c => c.status === 'IN_PROGRESS').length;
completionStats.completed = result.items.filter(c => c.status === 'COMPLETED').length;
// 计算完成率
const total = result.items.length;
stats.completionRate = total > 0 ? Math.round((completionStats.completed / total) * 100) : 0;
} catch (error: any) {
message.error(error.response?.data?.message || '获取完成情况失败');
} finally {
loadingCompletions.value = false;
}
};
const onPageChange = (page: number) => {
currentPage.value = page;
loadTasks();
};
onMounted(() => {
loadTasks();
});
</script>