kindergarten/reading-platform-frontend/src/views/school/tasks/TaskListView.vue

535 lines
18 KiB
Vue
Raw Normal View History

<template>
2026-03-03 13:59:02 +08:00
<div class="min-h-100vh p-4 bg-[#fafafa]">
<!-- 页面标题 -->
2026-03-03 13:59:02 +08:00
<div class="flex justify-between items-start mb-6 page-header">
<div class="header-left">
2026-03-03 13:59:02 +08:00
<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>
<!-- 统计卡片 -->
2026-03-03 13:59:02 +08:00
<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">
2026-03-03 13:59:02 +08:00
<span class="block text-[28px] font-600 text-[#333]">{{ stats.totalTasks }}</span>
<span class="text-[13px] text-[#999]">全部任务</span>
</div>
</div>
2026-03-03 13:59:02 +08:00
<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">
2026-03-03 13:59:02 +08:00
<span class="block text-[28px] font-600 text-[#333]">{{ stats.publishedTasks }}</span>
<span class="text-[13px] text-[#999]">进行中</span>
</div>
</div>
2026-03-03 13:59:02 +08:00
<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">
2026-03-03 13:59:02 +08:00
<span class="block text-[28px] font-600 text-[#333]">{{ stats.completionRate }}%</span>
<span class="text-[13px] text-[#999]">完成率</span>
</div>
</div>
</div>
<!-- 筛选区域 -->
2026-03-03 13:59:02 +08:00
<div class="flex gap-3 mb-6 filter-bar">
<a-select
v-model:value="filters.status"
placeholder="任务状态"
2026-03-03 13:59:02 +08:00
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="任务类型"
2026-03-03 13:59:02 +08:00
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="搜索任务标题"
2026-03-03 13:59:02 +08:00
class="w-[200px]"
@search="loadTasks"
allow-clear
/>
</div>
<!-- 任务列表 -->
<a-spin :spinning="loading">
2026-03-03 13:59:02 +08:00
<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>
2026-03-03 13:59:02 +08:00
<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>
2026-03-03 13:59:02 +08:00
<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>
2026-03-03 13:59:02 +08:00
<span class="flex items-center gap-1.5">
<CalendarOutlined />
{{ formatDate(task.startDate) }} - {{ formatDate(task.endDate) }}
</span>
2026-03-03 13:59:02 +08:00
<span class="flex items-center gap-1.5">
<TeamOutlined /> {{ task.targetCount || 0 }} 个目标
</span>
</div>
</div>
2026-03-03 13:59:02 +08:00
<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"
2026-03-03 13:59:02 +08:00
class="w-[150px]"
/>
2026-03-03 13:59:02 +08:00
<span class="text-[13px] text-[#666]">{{ getCompletedCount(task) }}/{{ task.completionCount || 0 }} 人完成</span>
</div>
2026-03-03 13:59:02 +08:00
<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>
<!-- 空状态 -->
2026-03-03 13:59:02 +08:00
<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>
<!-- 分页 -->
2026-03-03 13:59:02 +08:00
<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>
2026-03-03 13:59:02 +08:00
<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="请选择班级"
2026-03-03 13:59:02 +08:00
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"
2026-03-03 13:59:02 +08:00
class="w-full"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 完成情况弹窗 -->
<a-modal
v-model:open="completionModalVisible"
:title="`完成情况 - ${selectedTask?.title || ''}`"
:footer="null"
width="700px"
>
2026-03-03 13:59:02 +08:00
<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'">
2026-03-03 13:59:02 +08:00
<span v-if="record.parentFeedback" class="text-[#666] text-xs">
{{ record.parentFeedback.substring(0, 30) }}{{ record.parentFeedback.length > 30 ? '...' : '' }}
</span>
2026-03-03 13:59:02 +08:00
<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>