feat: 完善任务中心全部功能
All checks were successful
continuous-integration/drone/push Build is passing

1. 动态加载选项数据
   - 从API获取团队、成员、课程列表
   - 替换硬编码选项为动态渲染

2. 编辑任务功能
   - 复用创建对话框,添加编辑模式
   - 填充表单数据并调用updateTask API

3. 查看详情弹窗
   - 展示任务基本信息、进度、课程、要求
   - 调用getTaskDetail API获取详情

4. 结束任务功能
   - 确认后调用updateTask API更新状态为completed
   - 刷新列表和统计数据

5. 复制任务功能
   - 复制任务内容到表单(标题添加"副本"后缀)
   - 打开创建对话框

6. 发送提醒功能
   - 后端新增 /tasks/{id}/remind API
   - 前端调用API并显示结果
This commit is contained in:
yuliang_guo
2026-01-31 14:05:55 +08:00
parent 9bd9e58439
commit d2e6abfc80
3 changed files with 410 additions and 63 deletions

View File

@@ -226,3 +226,44 @@ async def delete_task(
return ResponseModel(message="任务已删除") return ResponseModel(message="任务已删除")
@router.post("/{task_id}/remind", response_model=ResponseModel, summary="发送任务提醒")
async def send_task_reminder(
task_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_admin_or_manager)
):
"""向未完成任务的成员发送提醒"""
task = await task_service.get_task_detail(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 获取未完成的成员数量
incomplete_count = sum(1 for a in task.assignments if a.status.value != "completed")
if incomplete_count == 0:
return ResponseModel(message="所有成员已完成任务,无需发送提醒")
# 记录提醒日志
await system_log_service.create_log(
db,
SystemLogCreate(
level="INFO",
type="notification",
message=f"发送任务提醒: {task.title},提醒 {incomplete_count}",
user_id=current_user.id,
user=current_user.username,
ip=request.client.host if request.client else None,
path=f"/api/v1/manager/tasks/{task_id}/remind",
method="POST",
user_agent=request.headers.get("user-agent")
)
)
# TODO: 实际发送通知逻辑(通过通知服务)
# 可以调用 notification_service.send_task_reminder(task, incomplete_assignments)
return ResponseModel(message=f"已向 {incomplete_count} 位未完成成员发送提醒")

View File

@@ -106,3 +106,10 @@ export function deleteTask(id: number): Promise<ResponseModel<void>> {
return http.delete(`/api/v1/manager/tasks/${id}`) return http.delete(`/api/v1/manager/tasks/${id}`)
} }
/**
* 发送任务提醒
*/
export function sendTaskReminder(id: number): Promise<ResponseModel<void>> {
return http.post(`/api/v1/manager/tasks/${id}/remind`)
}

View File

@@ -134,12 +134,13 @@
<el-empty v-if="taskList.length === 0" description="暂无任务" /> <el-empty v-if="taskList.length === 0" description="暂无任务" />
</div> </div>
<!-- 创建任务弹窗 --> <!-- 创建/编辑任务弹窗 -->
<el-dialog <el-dialog
v-model="createDialogVisible" v-model="createDialogVisible"
title="创建学习任务" :title="isEditMode ? '编辑学习任务' : '创建学习任务'"
width="680px" width="680px"
:close-on-click-modal="false" :close-on-click-modal="false"
@close="resetForm"
> >
<el-form ref="formRef" :model="taskForm" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="taskForm" :rules="rules" label-width="100px">
<el-form-item label="任务名称" prop="title"> <el-form-item label="任务名称" prop="title">
@@ -165,7 +166,7 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="分配对象" prop="assignType"> <el-form-item v-if="!isEditMode" label="分配对象" prop="assignType">
<el-radio-group v-model="taskForm.assignType" @change="handleAssignTypeChange"> <el-radio-group v-model="taskForm.assignType" @change="handleAssignTypeChange">
<el-radio label="all">全体成员</el-radio> <el-radio label="all">全体成员</el-radio>
<el-radio label="team">指定团队</el-radio> <el-radio label="team">指定团队</el-radio>
@@ -173,34 +174,36 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item v-if="taskForm.assignType === 'team'" label="选择团队" prop="teams"> <el-form-item v-if="!isEditMode && taskForm.assignType === 'team'" label="选择团队" prop="teams">
<el-select v-model="taskForm.teams" multiple placeholder="请选择团队" style="width: 100%"> <el-select v-model="taskForm.teams" multiple placeholder="请选择团队" style="width: 100%" filterable>
<el-option label="销售一组" value="team1" /> <el-option
<el-option label="销售二组" value="team2" /> v-for="team in teamOptions"
<el-option label="销售三组" value="team3" /> :key="team.id"
:label="team.name"
:value="team.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="taskForm.assignType === 'member'" label="选择成员" prop="members"> <el-form-item v-if="!isEditMode && taskForm.assignType === 'member'" label="选择成员" prop="members">
<el-select v-model="taskForm.members" multiple placeholder="请选择成员" style="width: 100%"> <el-select v-model="taskForm.members" multiple placeholder="请选择成员" style="width: 100%" filterable>
<el-option label="张三" value="user1" /> <el-option
<el-option label="李四" value="user2" /> v-for="member in memberOptions"
<el-option label="王五" value="user3" /> :key="member.id"
<el-option label="赵六" value="user4" /> :label="member.full_name || member.username"
:value="member.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="选择课程" prop="courses"> <el-form-item v-if="!isEditMode" label="选择课程" prop="courses">
<el-select v-model="taskForm.courses" multiple placeholder="请选择课程" style="width: 100%"> <el-select v-model="taskForm.courses" multiple placeholder="请选择课程" style="width: 100%" filterable>
<el-option-group label="销售技巧"> <el-option
<el-option label="客户沟通技巧" value="course1" /> v-for="course in courseOptions"
<el-option label="需求挖掘方法" value="course2" /> :key="course.id"
<el-option label="异议处理技巧" value="course3" /> :label="course.name"
</el-option-group> :value="course.id"
<el-option-group label="产品知识"> />
<el-option label="产品基础知识" value="course4" />
<el-option label="竞品分析" value="course5" />
</el-option-group>
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -228,11 +231,91 @@
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button> <el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreateTask" :loading="createLoading"> <el-button type="primary" @click="handleCreateTask" :loading="createLoading">
确定 {{ isEditMode ? '保存' : '确定' }}
</el-button> </el-button>
</span> </span>
</template> </template>
</el-dialog> </el-dialog>
<!-- 任务详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="任务详情"
width="600px"
>
<div v-loading="detailLoading" class="task-detail-content">
<template v-if="currentTaskDetail">
<div class="detail-section">
<h4>基本信息</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="任务名称" :span="2">
{{ currentTaskDetail.title }}
</el-descriptions-item>
<el-descriptions-item label="任务描述" :span="2">
{{ currentTaskDetail.description || '暂无描述' }}
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="getTaskTagType(currentTaskDetail.priority)" size="small">
{{ currentTaskDetail.priority }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentTaskDetail.status === 'completed' ? 'success' : 'warning'" size="small">
{{ currentTaskDetail.status === 'completed' ? '已完成' : currentTaskDetail.status === 'ongoing' ? '进行中' : currentTaskDetail.status }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDeadline(currentTaskDetail.deadline) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDeadline(currentTaskDetail.created_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<h4>完成进度</h4>
<div class="progress-info">
<el-progress
:percentage="currentTaskDetail.progress"
:color="getProgressColor(currentTaskDetail.progress)"
:stroke-width="20"
/>
<p class="progress-text">
{{ currentTaskDetail.completed_count }}/{{ currentTaskDetail.assigned_count }} 人完成
</p>
</div>
</div>
<div class="detail-section" v-if="currentTaskDetail.courses && currentTaskDetail.courses.length > 0">
<h4>包含课程 ({{ currentTaskDetail.courses.length }})</h4>
<div class="course-tags">
<el-tag v-for="course in currentTaskDetail.courses" :key="course" class="course-tag">
{{ course }}
</el-tag>
</div>
</div>
<div class="detail-section" v-if="currentTaskDetail.requirements">
<h4>任务要求</h4>
<ul class="requirements-list">
<li v-if="currentTaskDetail.requirements.mustComplete">
<el-icon><CircleCheck /></el-icon> 必须完成所有课程
</li>
<li v-if="currentTaskDetail.requirements.mustPass">
<el-icon><CircleCheck /></el-icon> 考试必须及格
</li>
<li v-if="currentTaskDetail.requirements.mustPractice">
<el-icon><CircleCheck /></el-icon> 必须完成AI陪练
</li>
</ul>
</div>
</template>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -240,16 +323,30 @@
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { getTasks, getTaskStats, createTask as createTaskApi, deleteTask, type Task } from '@/api/task' import { getTasks, getTaskStats, createTask as createTaskApi, deleteTask, updateTask, getTaskDetail, sendTaskReminder, type Task } from '@/api/task'
import { getUserList, getTeamList } from '@/api/user/index'
import { getCourseList } from '@/api/score'
// 当前标签页 // 当前标签页
const activeTab = ref('ongoing') const activeTab = ref('ongoing')
// 创建任务弹窗 // 创建/编辑任务弹窗
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const createLoading = ref(false) const createLoading = ref(false)
const loading = ref(false) const loading = ref(false)
const isEditMode = ref(false)
const editingTaskId = ref<number | null>(null)
// 详情弹窗
const detailDialogVisible = ref(false)
const detailLoading = ref(false)
const currentTaskDetail = ref<Task | null>(null)
// 选项数据
const teamOptions = ref<Array<{ id: number; name: string }>>([])
const memberOptions = ref<Array<{ id: number; username: string; full_name?: string }>>([])
const courseOptions = ref<Array<{ id: number; name: string; category?: string }>>([])
// 任务统计数据 // 任务统计数据
const taskStats = ref([ const taskStats = ref([
@@ -292,9 +389,9 @@ const taskForm = reactive({
description: '', description: '',
priority: '中', priority: '中',
assignType: 'all', assignType: 'all',
teams: [], teams: [] as number[],
members: [], members: [] as number[],
courses: [], courses: [] as number[],
deadline: '', deadline: '',
requirements: ['mustComplete'] requirements: ['mustComplete']
}) })
@@ -382,10 +479,62 @@ const formatDeadline = (deadline?: string) => {
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
/**
* 加载选项数据(团队、成员、课程)
*/
const loadOptions = async () => {
try {
// 并行加载
const [teamsRes, usersRes, coursesRes] = await Promise.all([
getTeamList({ page: 1, page_size: 100 }).catch(() => null),
getUserList({ page: 1, page_size: 500 }).catch(() => null),
getCourseList().catch(() => null)
])
// 处理团队数据
if (teamsRes?.code === 200 && teamsRes.data) {
const teamsData = teamsRes.data.items || teamsRes.data
teamOptions.value = Array.isArray(teamsData) ? teamsData : []
}
// 处理成员数据
if (usersRes?.code === 200 && usersRes.data) {
const usersData = usersRes.data.items || usersRes.data
memberOptions.value = Array.isArray(usersData) ? usersData : []
}
// 处理课程数据
if (coursesRes?.code === 200 && coursesRes.data) {
const coursesData = coursesRes.data.items || coursesRes.data
courseOptions.value = Array.isArray(coursesData) ? coursesData : []
}
} catch (error) {
console.error('加载选项数据失败:', error)
}
}
/**
* 重置表单
*/
const resetForm = () => {
taskForm.title = ''
taskForm.description = ''
taskForm.priority = '中'
taskForm.assignType = 'all'
taskForm.teams = []
taskForm.members = []
taskForm.courses = []
taskForm.deadline = ''
taskForm.requirements = ['mustComplete']
isEditMode.value = false
editingTaskId.value = null
}
/** /**
* 创建任务 * 创建任务
*/ */
const createTask = () => { const createTask = () => {
resetForm()
createDialogVisible.value = true createDialogVisible.value = true
} }
@@ -398,7 +547,7 @@ const handleAssignTypeChange = () => {
} }
/** /**
* 提交创建任务 * 提交创建/编辑任务
*/ */
const handleCreateTask = async () => { const handleCreateTask = async () => {
if (!formRef.value) return if (!formRef.value) return
@@ -418,24 +567,38 @@ const handleCreateTask = async () => {
user_ids: taskForm.assignType === 'all' ? [] : taskForm.members, user_ids: taskForm.assignType === 'all' ? [] : taskForm.members,
requirements: { requirements: {
mustComplete: taskForm.requirements.includes('mustComplete'), mustComplete: taskForm.requirements.includes('mustComplete'),
allowRetake: taskForm.requirements.includes('allowRetake') mustPass: taskForm.requirements.includes('mustPass'),
mustPractice: taskForm.requirements.includes('mustPractice')
} }
} }
const res = await createTaskApi(taskData) let res
if (isEditMode.value && editingTaskId.value) {
// 编辑模式
res = await updateTask(editingTaskId.value, {
title: taskData.title,
description: taskData.description,
priority: taskData.priority,
deadline: taskData.deadline
})
} else {
// 创建模式
res = await createTaskApi(taskData)
}
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('任务创建成功') ElMessage.success(isEditMode.value ? '任务更新成功' : '任务创建成功')
createDialogVisible.value = false createDialogVisible.value = false
formRef.value?.resetFields() resetForm()
// 刷新数据 // 刷新数据
await loadTaskStats() await loadTaskStats()
await loadTasks() await loadTasks()
} else { } else {
ElMessage.error(res.message || '创建任务失败') ElMessage.error(res.message || (isEditMode.value ? '更新任务失败' : '创建任务失败'))
} }
} catch (error: any) { } catch (error: any) {
console.error('创建任务失败:', error) console.error(isEditMode.value ? '更新任务失败:' : '创建任务失败:', error)
ElMessage.error(error.message || '创建任务失败') ElMessage.error(error.message || (isEditMode.value ? '更新任务失败' : '创建任务失败'))
} finally { } finally {
createLoading.value = false createLoading.value = false
} }
@@ -446,15 +609,31 @@ const handleCreateTask = async () => {
/** /**
* 查看详情 * 查看详情
*/ */
const viewDetail = (task: any) => { const viewDetail = async (task: Task) => {
ElMessage.info(`查看任务详情:${task.title}`) detailLoading.value = true
detailDialogVisible.value = true
try {
const res = await getTaskDetail(task.id)
if (res.code === 200 && res.data) {
currentTaskDetail.value = res.data
} else {
currentTaskDetail.value = task
}
} catch (error) {
console.error('获取任务详情失败:', error)
currentTaskDetail.value = task
} finally {
detailLoading.value = false
}
} }
/** /**
* 发送提醒 * 发送提醒
*/ */
const sendReminder = (_task: any) => { const sendReminder = async (task: Task) => {
ElMessageBox.confirm( try {
await ElMessageBox.confirm(
`确定要向未完成的成员发送任务提醒吗?`, `确定要向未完成的成员发送任务提醒吗?`,
'发送提醒', '发送提醒',
{ {
@@ -462,32 +641,81 @@ const sendReminder = (_task: any) => {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'info' type: 'info'
} }
).then(() => { )
ElMessage.success('提醒发送成功')
}).catch(() => {}) const res = await sendTaskReminder(task.id)
if (res.code === 200) {
ElMessage.success(res.message || '提醒发送成功')
} else {
ElMessage.error(res.message || '发送提醒失败')
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('发送提醒失败:', error)
ElMessage.error(error.message || '发送提醒失败')
}
}
} }
/** /**
* 编辑任务 * 编辑任务
*/ */
const editTask = async (task: Task) => { const editTask = async (task: Task) => {
// 这里可以打开编辑对话框填充task数据 isEditMode.value = true
// 简化实现:直接提示 editingTaskId.value = task.id
ElMessage.info(`编辑任务功能开发中:${task.title}`)
// TODO: 实现完整的编辑功能 // 填充表单数据
taskForm.title = task.title
taskForm.description = task.description || ''
taskForm.priority = task.priority === 'high' ? '高' : task.priority === 'low' ? '低' : '中'
taskForm.deadline = task.deadline || ''
taskForm.assignType = 'all'
taskForm.teams = []
taskForm.members = []
taskForm.courses = []
// 解析 requirements
if (task.requirements) {
taskForm.requirements = []
if (task.requirements.mustComplete) taskForm.requirements.push('mustComplete')
if (task.requirements.mustPass) taskForm.requirements.push('mustPass')
if (task.requirements.mustPractice) taskForm.requirements.push('mustPractice')
}
createDialogVisible.value = true
} }
/** /**
* 复制任务 * 复制任务
*/ */
const copyTask = (task: any) => { const copyTask = async (task: Task) => {
ElMessage.success(`已复制任务:${task.title}`) resetForm()
// 填充表单数据(标题添加"副本"后缀)
taskForm.title = `${task.title} (副本)`
taskForm.description = task.description || ''
taskForm.priority = task.priority === 'high' ? '高' : task.priority === 'low' ? '低' : '中'
taskForm.deadline = '' // 截止时间需要重新设置
// 解析 requirements
if (task.requirements) {
taskForm.requirements = []
if (task.requirements.mustComplete) taskForm.requirements.push('mustComplete')
if (task.requirements.mustPass) taskForm.requirements.push('mustPass')
if (task.requirements.mustPractice) taskForm.requirements.push('mustPractice')
}
isEditMode.value = false
editingTaskId.value = null
createDialogVisible.value = true
ElMessage.info('已复制任务内容,请修改后保存')
} }
/** /**
* 结束任务 * 结束任务
*/ */
const endTask = async (_task: Task) => { const endTask = async (task: Task) => {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'确定要结束这个任务吗?结束后将不能再修改。', '确定要结束这个任务吗?结束后将不能再修改。',
@@ -498,8 +726,22 @@ const endTask = async (_task: Task) => {
type: 'warning' type: 'warning'
} }
) )
const res = await updateTask(task.id, { status: 'completed' })
if (res.code === 200) {
ElMessage.success('任务已结束') ElMessage.success('任务已结束')
} catch {} // 刷新数据
await loadTaskStats()
await loadTasks()
} else {
ElMessage.error(res.message || '结束任务失败')
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('结束任务失败:', error)
ElMessage.error(error.message || '结束任务失败')
}
}
} }
/** /**
@@ -558,8 +800,11 @@ const getProgressColor = (percentage: number) => {
// 组件挂载时加载数据 // 组件挂载时加载数据
onMounted(async () => { onMounted(async () => {
await loadTaskStats() await Promise.all([
await loadTasks() loadTaskStats(),
loadTasks(),
loadOptions()
])
}) })
</script> </script>
@@ -744,6 +989,60 @@ onMounted(async () => {
} }
} }
// 任务详情弹窗样式
.task-detail-content {
.detail-section {
margin-bottom: 24px;
h4 {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.progress-info {
.progress-text {
margin-top: 8px;
font-size: 14px;
color: #666;
text-align: center;
}
}
.course-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
.course-tag {
margin: 0;
}
}
.requirements-list {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 14px;
color: #666;
.el-icon {
color: #67c23a;
}
}
}
}
}
// 响应式 // 响应式
@media (max-width: 768px) { @media (max-width: 768px) {
.assignment-center-container { .assignment-center-container {