Files
nanxiisletAdmin/src/views/system/approval/instances.vue
2025-12-28 22:12:08 +08:00

414 lines
12 KiB
Vue

<template>
<div class="approval-instances-page">
<a-page-header title="审批记录" sub-title="查看所有审批实例及其处理状态" />
<!-- 搜索筛选 -->
<a-card class="filter-card">
<a-form layout="inline" class="search-form">
<a-form-item>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索业务标题 / 发起人"
allow-clear
style="width: 220px"
@search="handleSearch"
/>
</a-form-item>
<a-form-item>
<a-select
v-model:value="filterScenario"
placeholder="业务场景"
allow-clear
style="width: 130px"
@change="handleSearch"
>
<a-select-option v-for="(label, key) in ApprovalScenarioMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-select
v-model:value="filterStatus"
placeholder="审批状态"
allow-clear
style="width: 120px"
@change="handleSearch"
>
<a-select-option v-for="(label, key) in ApprovalInstanceStatusMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-range-picker
v-model:value="dateRange"
:placeholder="['开始日期', '结束日期']"
style="width: 240px"
@change="handleSearch"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">
<SearchOutlined /> 查询
</a-button>
</a-form-item>
<a-form-item>
<a-button @click="handleReset">
<ReloadOutlined /> 重置
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 数据列表 -->
<a-card class="main-card" :loading="loading">
<a-table
:columns="columns"
:data-source="instanceList"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'business'">
<div class="business-cell">
<a-tag :color="getScenarioColor(record.scenario)" size="small">
{{ ApprovalScenarioMap[record.scenario as ApprovalScenario] }}
</a-tag>
<span class="business-title">{{ record.businessTitle }}</span>
</div>
</template>
<template v-else-if="column.key === 'initiator'">
<div class="user-cell">
<a-avatar :src="record.initiatorAvatar" :size="28" />
<span>{{ record.initiatorName }}</span>
</div>
</template>
<template v-else-if="column.key === 'currentNode'">
<span v-if="record.currentNodeName" class="current-node">
<ClockCircleOutlined /> {{ record.currentNodeName }}
</span>
<span v-else class="no-node">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="ApprovalInstanceStatusBadgeMap[record.status as ApprovalInstanceStatus]"
:text="ApprovalInstanceStatusMap[record.status as ApprovalInstanceStatus]"
/>
</template>
<template v-else-if="column.key === 'submittedAt'">
{{ formatDateTime(record.submittedAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="showDetail(record as ApprovalInstance)">
<EyeOutlined /> 详情
</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 详情抽屉 -->
<a-drawer
v-model:open="detailVisible"
title="审批详情"
width="640"
destroy-on-close
>
<template v-if="currentInstance">
<!-- 业务信息 -->
<a-descriptions :column="2" bordered size="small" title="业务信息">
<a-descriptions-item label="业务类型">
<a-tag :color="getScenarioColor(currentInstance.scenario)">
{{ ApprovalScenarioMap[currentInstance.scenario] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="审批状态">
<a-badge
:status="ApprovalInstanceStatusBadgeMap[currentInstance.status]"
:text="ApprovalInstanceStatusMap[currentInstance.status]"
/>
</a-descriptions-item>
<a-descriptions-item label="业务标题" :span="2">
{{ currentInstance.businessTitle }}
</a-descriptions-item>
<a-descriptions-item label="使用流程">
{{ currentInstance.templateName }}
</a-descriptions-item>
<a-descriptions-item label="当前节点">
{{ currentInstance.currentNodeName || '-' }}
</a-descriptions-item>
</a-descriptions>
<!-- 发起人 -->
<a-divider>发起人</a-divider>
<div class="initiator-info">
<a-avatar :src="currentInstance.initiatorAvatar" :size="48" />
<div class="info">
<div class="name">{{ currentInstance.initiatorName }}</div>
<div class="time">提交于 {{ formatDateTime(currentInstance.submittedAt) }}</div>
</div>
</div>
<!-- 审批记录 -->
<a-divider>审批记录</a-divider>
<a-timeline v-if="currentInstance.records.length">
<a-timeline-item
v-for="(record, index) in currentInstance.records"
:key="index"
:color="getActionColor(record.action)"
>
<div class="record-item">
<div class="record-header">
<a-avatar :src="record.approverAvatar" :size="32" />
<div class="record-info">
<div class="approver">
{{ record.approverName }}
<a-tag :color="getActionColor(record.action)" size="small">
{{ getActionText(record.action) }}
</a-tag>
</div>
<div class="node-name">{{ record.nodeName }}</div>
</div>
<div class="record-time">{{ formatDateTime(record.operatedAt) }}</div>
</div>
<div v-if="record.comment" class="record-comment">
<MessageOutlined /> {{ record.comment }}
</div>
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="暂无审批记录" :image-style="{ height: '40px' }" />
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
ClockCircleOutlined,
MessageOutlined
} from '@ant-design/icons-vue'
import type { ApprovalInstance, ApprovalScenario, ApprovalInstanceStatus } from '@/types'
import { ApprovalScenarioMap, ApprovalInstanceStatusMap, ApprovalInstanceStatusBadgeMap } from '@/types'
import { mockGetApprovalInstanceList } from '@/mock'
import { formatDateTime } from '@/utils/common'
const loading = ref(false)
const instanceList = ref<ApprovalInstance[]>([])
const searchKeyword = ref('')
const filterScenario = ref<ApprovalScenario | undefined>()
const filterStatus = ref<ApprovalInstanceStatus | undefined>()
const dateRange = ref<[Dayjs, Dayjs] | undefined>()
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `${total}`
})
const detailVisible = ref(false)
const currentInstance = ref<ApprovalInstance | null>(null)
const columns = [
{ title: '业务信息', key: 'business', width: 280 },
{ title: '发起人', key: 'initiator', width: 140 },
{ title: '当前节点', key: 'currentNode', width: 130 },
{ title: '状态', key: 'status', width: 100 },
{ title: '提交时间', key: 'submittedAt', width: 160 },
{ title: '操作', key: 'action', width: 100 }
]
function getScenarioColor(scenario: ApprovalScenario): string {
const colors: Record<ApprovalScenario, string> = {
project_publish: '#1890ff',
withdrawal: '#52c41a',
contract: '#722ed1',
certification: '#faad14',
content: '#13c2c2',
expense_reimbursement: '#eb2f96',
payment_request: '#fa541c',
purchase_request: '#fa8c16',
budget_adjustment: '#2f54eb',
invoice_apply: '#722ed1'
}
return colors[scenario]
}
function getActionColor(action: string): string {
const colors: Record<string, string> = {
approve: 'green',
reject: 'red',
transfer: 'blue',
return: 'orange'
}
return colors[action] || 'gray'
}
function getActionText(action: string): string {
const texts: Record<string, string> = {
approve: '通过',
reject: '拒绝',
transfer: '转交',
return: '退回'
}
return texts[action] || action
}
async function loadData() {
loading.value = true
try {
const res = await mockGetApprovalInstanceList({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: searchKeyword.value || undefined,
scenario: filterScenario.value,
status: filterStatus.value,
startDate: dateRange.value?.[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value?.[1]?.format('YYYY-MM-DD')
})
instanceList.value = res.list
pagination.total = res.total
} catch (error) {
console.error(error)
message.error('加载数据失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
loadData()
}
function handleReset() {
searchKeyword.value = ''
filterScenario.value = undefined
filterStatus.value = undefined
dateRange.value = undefined
pagination.current = 1
loadData()
}
function handleTableChange(pag: { current?: number; pageSize?: number }) {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
loadData()
}
function showDetail(instance: ApprovalInstance) {
currentInstance.value = instance
detailVisible.value = true
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.approval-instances-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
padding-right: 8px;
}
.filter-card, .main-card { border-radius: 12px; }
.filter-card :deep(.ant-card-body) { padding: 16px; }
.search-form { display: flex; flex-wrap: wrap; gap: 12px; }
.search-form :deep(.ant-form-item) { margin-bottom: 0; margin-right: 0; }
.business-cell {
display: flex;
align-items: center;
gap: 8px;
}
.business-title {
font-weight: 500;
color: #1a1a2e;
}
.user-cell {
display: flex;
align-items: center;
gap: 8px;
}
.current-node {
color: #faad14;
font-size: 13px;
}
.no-node {
color: #8c8c8c;
}
/* 详情样式 */
.initiator-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.initiator-info .info { flex: 1; }
.initiator-info .name { font-size: 15px; font-weight: 600; }
.initiator-info .time { font-size: 12px; color: #8c8c8c; margin-top: 2px; }
.record-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.record-header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.record-info { flex: 1; }
.record-info .approver {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.record-info .node-name { font-size: 12px; color: #8c8c8c; margin-top: 2px; }
.record-time { font-size: 12px; color: #8c8c8c; }
.record-comment {
padding: 8px 12px;
background: #f5f5f5;
border-radius: 6px;
font-size: 13px;
color: #666;
margin-left: 44px;
}
</style>