feat: 实现子任务组审核功能

- 修改子任务完成逻辑,解锁后的子任务组设置为pending_group_approval状态
- 添加子任务组审核API(approveTaskGroup, rejectTaskGroup)
- 在待审核Tab中集成子任务组显示,统一管理主任务和子任务组审核
- 子任务组卡片显示子任务组标识
- 审核时支持选择发布到大厅或指定设计师
- 修复通知图标样式,与系统设置图标对齐统一
This commit is contained in:
Your Name 2026-01-02 20:02:45 +08:00
parent 2a7f9f7cd7
commit 6314d2e034
3 changed files with 387 additions and 112 deletions

View File

@ -535,3 +535,38 @@ export const rejectByReceivingLeader = (taskId: number, reason: string) => {
return request.post<any>(`/tasks/${taskId}/reject-receiving`, { reason })
}
// ==================== 子任务组审核相关 ====================
/**
* 子任务组信息
*/
export interface TaskGroup {
groupId: number
mainTaskId: number
mainTaskTitle: string
mainTaskNo: string
items: TaskItem[]
totalScore: number
}
/**
* 获取待审核的子任务组列表
*/
export const getPendingApprovalTaskGroups = () => {
return request.get<TaskGroup[]>('/tasks/items/pending-approval-groups')
}
/**
* 审核通过子任务组
*/
export const approveTaskGroup = (groupId: number, data: { assignedReceiverId?: number; comment?: string }) => {
return request.post<any>(`/tasks/items/groups/${groupId}/approve`, data)
}
/**
* 驳回子任务组
*/
export const rejectTaskGroup = (groupId: number, reason: string) => {
return request.post<any>(`/tasks/items/groups/${groupId}/reject`, { reason })
}

View File

@ -10,8 +10,8 @@
>
<path
d="M12 2.25a4.5 4.5 0 0 0-4.5 4.5V9c0 .414-.168.81-.467 1.098L5.21 11.82a1.125 1.125 0 0 0 .79 1.93h12a1.125 1.125 0 0 0 .79-1.93l-1.823-1.722A1.5 1.5 0 0 1 16.5 9V6.75a4.5 4.5 0 0 0-4.5-4.5Zm0 18.375a3.375 3.375 0 0 0 3.182-2.25H8.818A3.375 3.375 0 0 0 12 20.625Z"
fill="#ffffff"
stroke="#ffffff"
fill="currentColor"
stroke="currentColor"
stroke-width="0"
/>
</svg>
@ -263,20 +263,26 @@ onUnmounted(() => {
<style lang="scss" scoped>
.notification-bell {
width: 40px;
height: 40px;
border-radius: $radius-full;
display: flex;
align-items: center;
justify-content: center;
color: $text-secondary;
cursor: pointer;
padding: 8px 12px;
border-radius: $radius-md;
transition: all 0.2s ease-out;
position: relative;
transition: all $transition-base $ease-out;
&:hover {
background: rgba(0, 0, 0, 0.05);
background: $bg-card;
color: $text-primary;
}
.bell-icon {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
display: inline-block;
color: #fff;
fill: currentColor;
stroke: currentColor;
stroke-width: 0;

View File

@ -1560,47 +1560,55 @@
<!-- 任务列表 - 使用统一的任务卡片样式 -->
<div class="task-grid">
<div
v-for="task in pendingApprovalTasks"
:key="task.id"
v-for="item in pendingApprovalTasks"
:key="`${item.type}-${item.id}`"
class="task-card chart-card approval-card"
@click="handleTaskClick(task)"
@click="item.type === 'task' ? handleTaskClick(item.data) : null"
>
<!-- 任务标题 -->
<div class="task-header">
<h3 class="task-title">{{ task.title }}</h3>
<h3 class="task-title">{{ item.title }}</h3>
<div class="task-badges">
<span class="badge badge-approval">
<Icon name="file-text" size="sm" color="#31f08b" style="margin-right: 4px; vertical-align: middle;" />
{{ getApprovalStatusText(task.approvalStatus) }}
<span v-if="item.type === 'taskGroup'" class="badge badge-subtask">
<Icon name="layers" size="sm" color="#31f08b" style="margin-right: 4px; vertical-align: middle;" />
子任务组
</span>
<span v-if="task.isUrgent" class="badge badge-urgent">
<span v-else class="badge badge-approval">
<Icon name="file-text" size="sm" color="#31f08b" style="margin-right: 4px; vertical-align: middle;" />
{{ getApprovalStatusText(item.data.approvalStatus) }}
</span>
<span v-if="item.isUrgent" class="badge badge-urgent">
<Icon name="fire" size="sm" color="#31f08b" style="margin-right: 4px; vertical-align: middle;" />
紧急
</span>
</div>
</div>
<!-- 流程阶段 -->
<div v-if="getProcessStage(task)" class="process-stage">
<!-- 流程阶段(仅主任务) -->
<div v-if="item.type === 'task' && getProcessStage(item.data)" class="process-stage">
<span class="stage-icon">
<Icon name="clock" size="sm" color="#31f08b" />
</span>
<span class="stage-text">{{ getProcessStage(task) }}</span>
<span class="stage-text">{{ getProcessStage(item.data) }}</span>
</div>
<!-- 任务信息 -->
<div class="task-info">
<div class="info-item">
<div v-if="item.type === 'task'" class="info-item">
<span class="label">任务类型</span>
<span class="value">{{ getTaskTypeName(task.taskTypeId) }}</span>
<span class="value">{{ getTaskTypeName(item.data.taskTypeId) }}</span>
</div>
<div v-if="item.type === 'taskGroup'" class="info-item">
<span class="label">子任务数量</span>
<span class="value">{{ item.data.items?.length || 0 }} 个</span>
</div>
<div class="info-item">
<span class="label">预估积分</span>
<span class="value text-brand">{{ task.score }} 分</span>
<span class="value text-brand">{{ item.score }} 分</span>
</div>
<div class="info-item">
<div v-if="item.type === 'task'" class="info-item">
<span class="label">数量</span>
<span class="value">{{ task.quantity || '--' }} 个</span>
<span class="value">{{ item.data.quantity || '--' }} 个</span>
</div>
</div>
@ -1610,94 +1618,97 @@
<span class="icon">
<Icon name="hash" size="sm" color="#31f08b" />
</span>
<span>任务编号: {{ task.taskNo }}</span>
<span>任务编号: {{ item.taskNo }}</span>
</div>
<div class="detail-item">
<div v-if="item.publisherName" class="detail-item">
<span class="icon">
<Icon name="user" size="sm" color="#31f08b" />
</span>
<span>发起人: {{ task.publisherName || '未知' }}</span>
<span>发起人: {{ item.publisherName || '未知' }}</span>
</div>
<div v-if="task.publisherDeptName" class="detail-item">
<div v-if="item.publisherDeptName" class="detail-item">
<span class="icon">
<Icon name="users" size="sm" color="#31f08b" />
</span>
<span>发起部门: {{ task.publisherDeptName }}</span>
</div>
<div v-if="task.brandName" class="detail-item">
<span class="icon">
<Icon name="tag" size="sm" color="#31f08b" />
</span>
<span>品牌: {{ task.brandName }}</span>
</div>
<div v-if="task.categoryName" class="detail-item">
<span class="icon">
<Icon name="folder" size="sm" color="#31f08b" />
</span>
<span>类目: {{ task.categoryName }}</span>
</div>
<div v-if="task.platformName" class="detail-item">
<span class="icon">
<Icon name="monitor" size="sm" color="#31f08b" />
</span>
<span>平台: {{ task.platformName }}</span>
</div>
<div v-if="task.spuInfo" class="detail-item">
<span class="icon">
<Icon name="package" size="sm" color="#31f08b" />
</span>
<span>SPU: {{ task.spuInfo }}</span>
</div>
<div v-if="task.remark" class="detail-item">
<span class="icon">
<Icon name="message-square" size="sm" color="#31f08b" />
</span>
<span>备注: {{ task.remark }}</span>
</div>
<div v-if="task.attachmentUrl" class="detail-item">
<span class="icon">
<Icon name="paperclip" size="sm" color="#31f08b" />
</span>
<span>附件:
<a
v-for="(url, index) in (task.attachmentUrl || '').split(',').filter(u => u.trim())"
:key="index"
:href="url.trim()"
target="_blank"
class="attachment-link"
@click.stop
>
附件{{ index + 1 }}
</a>
</span>
</div>
<div class="detail-item">
<span class="icon">
<Icon name="clock" size="sm" color="#31f08b" />
</span>
<span>截止: {{ formatDeadline(task.deadline) }}</span>
</div>
<div class="detail-item">
<span class="icon">
<Icon name="calendar" size="sm" color="#31f08b" />
</span>
<span>发起时间: {{ formatTime(task.createTime) }}</span>
<span>发起部门: {{ item.publisherDeptName }}</span>
</div>
<template v-if="item.type === 'task'">
<div v-if="item.data.brandName" class="detail-item">
<span class="icon">
<Icon name="tag" size="sm" color="#31f08b" />
</span>
<span>品牌: {{ item.data.brandName }}</span>
</div>
<div v-if="item.data.categoryName" class="detail-item">
<span class="icon">
<Icon name="folder" size="sm" color="#31f08b" />
</span>
<span>类目: {{ item.data.categoryName }}</span>
</div>
<div v-if="item.data.platformName" class="detail-item">
<span class="icon">
<Icon name="monitor" size="sm" color="#31f08b" />
</span>
<span>平台: {{ item.data.platformName }}</span>
</div>
<div v-if="item.data.spuInfo" class="detail-item">
<span class="icon">
<Icon name="package" size="sm" color="#31f08b" />
</span>
<span>SPU: {{ item.data.spuInfo }}</span>
</div>
<div v-if="item.data.remark" class="detail-item">
<span class="icon">
<Icon name="message-square" size="sm" color="#31f08b" />
</span>
<span>备注: {{ item.data.remark }}</span>
</div>
<div v-if="item.data.attachmentUrl" class="detail-item">
<span class="icon">
<Icon name="paperclip" size="sm" color="#31f08b" />
</span>
<span>附件:
<a
v-for="(url, index) in (item.data.attachmentUrl || '').split(',').filter(u => u.trim())"
:key="index"
:href="url.trim()"
target="_blank"
class="attachment-link"
@click.stop
>
附件{{ index + 1 }}
</a>
</span>
</div>
<div v-if="item.deadline" class="detail-item">
<span class="icon">
<Icon name="clock" size="sm" color="#31f08b" />
</span>
<span>截止: {{ formatDeadline(item.deadline) }}</span>
</div>
<div v-if="item.createTime" class="detail-item">
<span class="icon">
<Icon name="calendar" size="sm" color="#31f08b" />
</span>
<span>发起时间: {{ formatTime(item.createTime) }}</span>
</div>
</template>
</div>
<!-- 审核操作按钮 -->
<div class="task-actions" @click.stop>
<button
v-if="item.type === 'task'"
class="btn btn-secondary"
style="flex: 1"
@click.stop="handleTaskClick(task)"
@click.stop="handleTaskClick(item.data)"
>
查看详情
</button>
<button
class="btn btn-primary"
style="flex: 1"
@click="handleApproveTwoLevelTask(task)"
@click="item.type === 'task' ? handleApproveTwoLevelTask(item.data) : handleApproveTaskGroup(item)"
:disabled="approvalLoading"
>
<Icon name="check" size="sm" style="margin-right: 4px;" />
@ -1706,7 +1717,7 @@
<button
class="btn btn-danger"
style="flex: 1"
@click="handleRejectTwoLevelTask(task)"
@click="item.type === 'task' ? handleRejectTwoLevelTask(item.data) : handleRejectTaskGroup(item)"
:disabled="approvalLoading"
>
<Icon name="x" size="sm" style="margin-right: 4px;" />
@ -1946,8 +1957,22 @@ const reviewLoading = ref(false)
const reviewTaskList = ref<Task[]>([])
const reviewTotal = ref(0)
// 待我审核任务状态
const pendingApprovalTasks = ref<Task[]>([])
// 待我审核任务状态(包含主任务和子任务组)
interface ApprovalItem {
id: number
type: 'task' | 'taskGroup' // 区分类型
data: any // 主任务或子任务组数据
title: string
taskNo: string
score: number
deadline?: string | null
createTime?: string | null
isUrgent?: boolean | number
publisherName?: string | null
publisherDeptName?: string | null
}
const pendingApprovalTasks = ref<ApprovalItem[]>([])
const pendingApprovalTotal = ref(0)
const approvalFilters = reactive({
keyword: undefined as string | undefined
@ -2309,27 +2334,73 @@ const handleTabChange = (key: string) => {
// ==================== 审核相关方法 ====================
// 加载待审核任务
// 加载待审核任务(包含主任务和子任务组)
const loadPendingApprovalTasks = async () => {
try {
loading.value = true
const response = await taskApi.getPendingApprovalTasks()
if (response.code === 200) {
pendingApprovalTasks.value = response.data || []
// 如果有搜索关键词,进行本地过滤
if (approvalFilters.keyword && approvalFilters.keyword.trim()) {
const keyword = approvalFilters.keyword.trim().toLowerCase()
pendingApprovalTasks.value = pendingApprovalTasks.value.filter(task =>
task.title?.toLowerCase().includes(keyword) ||
task.taskNo?.toLowerCase().includes(keyword)
)
}
pendingApprovalTotal.value = pendingApprovalTasks.value.length
} else {
message.error(response.msg || '加载待审核任务失败')
// 并行加载主任务和子任务组
const [tasksResponse, groupsResponse] = await Promise.all([
taskApi.getPendingApprovalTasks(),
taskApi.getPendingApprovalTaskGroups()
])
const approvalItems: ApprovalItem[] = []
// 转换主任务为ApprovalItem
if (tasksResponse && tasksResponse.data) {
const tasks = tasksResponse.data as Task[]
tasks.forEach(task => {
if (!task.id || !task.taskNo) return // 跳过无效任务
approvalItems.push({
id: task.id,
type: 'task',
data: task,
title: task.title,
taskNo: task.taskNo,
score: task.score || 0,
deadline: task.deadline || null,
createTime: task.createTime || null,
isUrgent: !!task.isUrgent,
publisherName: task.publisherName || null,
publisherDeptName: task.publisherDeptName || null
})
})
}
// 转换子任务组为ApprovalItem
if (groupsResponse && groupsResponse.data) {
const groups = groupsResponse.data as taskApi.TaskGroup[]
groups.forEach(group => {
approvalItems.push({
id: group.groupId,
type: 'taskGroup',
data: group,
title: `${group.mainTaskTitle} - 第${group.groupId}组`,
taskNo: group.mainTaskNo,
score: group.totalScore || 0,
deadline: null, // 子任务组没有独立的截止时间
createTime: null,
isUrgent: false,
publisherName: null,
publisherDeptName: null
})
})
}
// 如果有搜索关键词,进行本地过滤
if (approvalFilters.keyword && approvalFilters.keyword.trim()) {
const keyword = approvalFilters.keyword.trim().toLowerCase()
pendingApprovalTasks.value = approvalItems.filter(item =>
item.title?.toLowerCase().includes(keyword) ||
item.taskNo?.toLowerCase().includes(keyword)
)
} else {
pendingApprovalTasks.value = approvalItems
}
pendingApprovalTotal.value = pendingApprovalTasks.value.length
} catch (error: any) {
console.error('加载待审核任务失败:', error)
message.error(error.msg || error.message || '加载待审核任务失败')
@ -2501,7 +2572,7 @@ const showAssignDesignerModal = async (task: Task) => {
const approvalData: TaskApprovalDTO = {
comment: assignType === 'manual' ?
`指定设计师接单` : '审核通过,发布到任务大厅',
assignedReceiverId: assignType === 'manual' ? selectedDesignerId : undefined
assignedReceiverId: assignType === 'manual' && selectedDesignerId ? selectedDesignerId : undefined
}
await taskApi.approveReceivingLevel(task.id!, approvalData)
@ -2585,6 +2656,169 @@ const handleRejectTwoLevelTask = async (task: Task) => {
})
}
// ==================== 子任务组审核相关方法 ====================
// 审核通过子任务组
const handleApproveTaskGroup = async (item: ApprovalItem) => {
try {
// 获取设计师列表及工作量
const designersWithWorkload = await userApi.getDesignersWithWorkload()
let assignType = ref<'auto' | 'manual'>('auto')
let selectedDesignerId = ref<number | undefined>(undefined)
Modal.confirm({
title: '审核子任务组',
width: 600,
content: h('div', { style: 'padding: 10px 0;' }, [
h('p', { style: 'margin-bottom: 16px; color: #666; font-size: 13px;' },
`即将审核通过子任务组"${item.title}",共${item.data.items?.length || 0}个子任务,总积分${item.score}分。`
),
h('p', { style: 'margin-bottom: 12px; font-weight: 500;' }, '请选择接单方式:'),
h(RadioGroup, {
value: assignType.value,
onChange: (e: any) => {
assignType.value = e.target.value
if (e.target.value === 'auto') {
selectedDesignerId.value = undefined
}
}
}, () => [
h(Radio, { value: 'auto', style: { display: 'block', marginBottom: '12px' } }, () => '发布到任务大厅(设计师抢单)'),
h(Radio, { value: 'manual', style: { display: 'block', marginBottom: '12px' } }, () => '指定设计师接单')
]),
h('div', {
style: {
display: assignType.value === 'manual' ? 'block' : 'none',
marginTop: '16px',
marginLeft: '24px'
}
}, [
h('p', { style: 'margin-bottom: 8px; font-size: 13px; color: #666;' }, '选择设计师:'),
h(RadioGroup, {
value: selectedDesignerId.value,
onChange: (e: any) => {
selectedDesignerId.value = e.target.value
},
style: { width: '100%' }
}, () => designersWithWorkload.map(designer => {
const workloadColor = designer.inProgressCount === 0 ? '#52c41a'
: designer.inProgressCount <= 3 ? '#faad14'
: '#f5222d'
return h(Radio, {
value: designer.id,
style: {
display: 'block',
marginBottom: '8px',
padding: '8px',
border: '1px solid #d9d9d9',
borderRadius: '4px'
}
}, () => h('div', { style: 'display: flex; justify-content: space-between; align-items: center;' }, [
h('span', {}, `${designer.realName} (${designer.deptName || '未知部门'})`),
h('span', {
style: {
fontSize: '12px',
color: workloadColor,
fontWeight: 'bold'
}
}, `进行中: ${designer.inProgressCount}个任务`)
]))
}))
])
]),
okText: '确认审核通过',
cancelText: '取消',
onOk: async () => {
if (assignType.value === 'manual' && !selectedDesignerId.value) {
message.warning('请选择设计师')
return Promise.reject()
}
try {
approvalLoading.value = true
await taskApi.approveTaskGroup(item.id, {
assignedReceiverId: assignType.value === 'manual' ? selectedDesignerId.value : undefined
})
if (assignType.value === 'manual') {
const designer = designersWithWorkload.find(d => d.id === selectedDesignerId.value)
message.success(`已审核通过并指定给设计师 ${designer?.realName}`)
} else {
message.success('已审核通过,子任务组已发布到任务大厅')
}
// 刷新列表和数量
loadPendingApprovalTasks()
loadAllTabCounts()
loadDashboardStats()
} catch (error: any) {
console.error('审核失败:', error)
message.error(error.msg || error.message || '审核失败')
return Promise.reject(error)
} finally {
approvalLoading.value = false
}
}
})
} catch (error: any) {
console.error('获取设计师列表失败:', error)
message.error('获取设计师列表失败')
}
}
// 驳回子任务组
const handleRejectTaskGroup = async (item: ApprovalItem) => {
let rejectReason = ''
Modal.confirm({
title: '驳回子任务组',
content: h('div', { style: 'padding: 10px 0;' }, [
h('p', { style: 'margin-bottom: 12px; color: #666;' },
`即将驳回子任务组"${item.title}",共${item.data.items?.length || 0}个子任务。`
),
h('p', { style: 'margin-bottom: 10px; color: #666;' }, '请输入驳回原因:'),
h(Textarea, {
rows: 4,
placeholder: '请详细说明驳回原因',
maxlength: 500,
showCount: true,
onChange: (e: any) => {
rejectReason = e.target.value
}
})
]),
okText: '确认驳回',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
if (!rejectReason || !rejectReason.trim()) {
message.warning('请输入驳回原因')
return Promise.reject()
}
try {
approvalLoading.value = true
await taskApi.rejectTaskGroup(item.id, rejectReason.trim())
message.success('已驳回子任务组')
// 刷新列表和数量
loadPendingApprovalTasks()
loadAllTabCounts()
loadDashboardStats()
} catch (error: any) {
console.error('驳回失败:', error)
message.error(error.msg || error.message || '驳回失败')
} finally {
approvalLoading.value = false
}
}
})
}
// 已完成任务分页变化
const handleCompletedPageChange = (page: number) => {
completedPagination.page = page