You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

907 lines
25 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="process-query-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">
<el-icon :size="24"><Search /></el-icon>
流程查询
</h1>
</div>
<!-- 子流程卡片网格选择器 -->
<el-card class="subprocess-selector-section" shadow="never" v-loading="subprocessLoading">
<div class="subprocess-grid" v-if="availableSubprocesses.length > 0">
<div
v-for="subprocess in availableSubprocesses"
:key="subprocess.id"
class="subprocess-card"
:class="{ active: selectedSubprocess?.id === subprocess.id }"
@click="handleSubprocessSelect(subprocess)"
>
<div class="subprocess-icon">
<el-icon :size="32">
<component :is="getIcon(subprocess.icon || subprocess.custom_model?.icon)" />
</el-icon>
</div>
<div class="subprocess-name">{{ subprocess.name || subprocess.custom_model?.name || '未命名流程' }}</div>
<div class="subprocess-description" v-if="subprocess.description">
{{ subprocess.description }}
</div>
</div>
</div>
<el-empty v-else description="暂无可用流程" />
</el-card>
<!-- 查询类型和筛选区域 -->
<el-card class="filter-section" shadow="never">
<!-- 查询类型和年份选择合并到同一行 -->
<div class="filter-bar" v-if="selectedSubprocess">
<div class="filter-group">
<span class="filter-label">关联状态</span>
<el-radio-group v-model="queryType" @change="handleQueryTypeChange">
<el-radio-button label="not-linked">未关联支付</el-radio-button>
<el-radio-button label="linked">已关联支付</el-radio-button>
</el-radio-group>
</div>
<div class="filter-group" v-loading="loadingYears">
<span class="filter-label">发起年份:</span>
<el-radio-group v-model="selectedYear" @change="handleYearChange">
<el-radio-button
v-for="year in availableYears"
:key="year"
:label="year"
>
{{ year }}年
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 如果未选择子流程,只显示关联状态选择 -->
<div class="filter-bar" v-else>
<div class="filter-group">
<span class="filter-label">关联状态:</span>
<el-radio-group v-model="queryType" @change="handleQueryTypeChange">
<el-radio-button label="not-linked">未关联支付</el-radio-button>
<el-radio-button label="linked">已关联支付</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 筛选表单 -->
<el-form :model="filterForm" inline style="margin-top: 16px">
<el-form-item label="流程编号">
<el-input
v-model="filterForm.keyword"
placeholder="请输入流程编号或关键词"
style="width: 200px"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
查询
</el-button>
</el-form-item>
<el-form-item>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格区域 -->
<el-card shadow="never">
<el-table :data="tableData" style="width: 100%" v-loading="loading" border>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="no" label="流程编号" width="180" />
<!-- 动态字段列(放在流程编号后面) -->
<template v-if="Array.isArray(dynamicFields) && dynamicFields.length > 0">
<el-table-column
v-for="field in dynamicFields"
:key="field.id"
:prop="`data.${field.name}`"
:label="field.label"
:min-width="getFieldWidth(field.type)"
>
<template #default="scope">
{{ formatFieldValue(scope.row.data, field) }}
</template>
</el-table-column>
</template>
<el-table-column prop="custom_model" label="流程类型" min-width="150">
<template #default="scope">
<div class="type-cell">
<el-icon :size="16" v-if="scope.row.custom_model?.icon || scope.row.customModel?.icon">
<component :is="getIcon(scope.row.custom_model?.icon || scope.row.customModel?.icon)" />
</el-icon>
<span>{{ scope.row.custom_model?.name || scope.row.customModel?.name || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip>
<template #default="scope">
<div class="title-cell">
<span>{{ scope.row.title || '-' }}</span>
<el-tooltip
:content="scope.row.is_created_by_me ? '我创建的' : '我办理过的'"
placement="top"
>
<el-icon
:size="14"
:color="scope.row.is_created_by_me ? '#409eff' : '#67c23a'"
style="margin-left: 6px; cursor: pointer;"
>
<component :is="scope.row.is_created_by_me ? User : Check" />
</el-icon>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column prop="creator" label="申请人" width="100">
<template #default="scope">
{{ scope.row.creator?.name || scope.row.creator_name || '-' }}
</template>
</el-table-column>
<el-table-column prop="creator_department" label="部门" width="120">
<template #default="scope">
{{ scope.row.creator_department?.name || scope.row.creator_department_name || '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="current_node" label="当前步骤" width="120">
<template #default="scope">
{{ scope.row.current_node?.name || scope.row.currentNode?.name || '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatDateTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="相关支付情况" width="200" fixed="right">
<template #default="scope">
<div v-if="scope.row.related_payments && scope.row.related_payments.length > 0" class="related-payments">
<el-tag
v-for="(payment, idx) in scope.row.related_payments"
:key="payment.id"
type="primary"
size="small"
class="payment-tag"
@click.stop="handleViewPayment(payment)"
>
{{ payment.serial_number }}
</el-tag>
</div>
<span v-else class="no-payment">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button type="primary" link size="small" @click="handleView(scope.row)">
查看
</el-button>
<el-button
v-if="scope.row.status === 0"
type="success"
link
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Search,
Refresh,
Document,
User,
Check
} from '@element-plus/icons-vue'
// 动态导入所有图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { preApprovalProcessConfigAPI, oaFlowAPI } from '@/utils/api'
import { getToken } from '@/utils/auth'
import config from '@/config'
const router = useRouter()
// 加载状态
const loading = ref(false)
const subprocessLoading = ref(false)
const loadingYears = ref(false)
// 可用子流程列表
const availableSubprocesses = ref([])
// 选中的子流程
const selectedSubprocess = ref(null)
// 查询类型not-linked未关联支付或 linked已关联支付
const queryType = ref('not-linked')
// 动态字段列表show_in_list=1的字段
const dynamicFields = ref([])
// 表格数据
const tableData = ref([])
// 年份相关
const availableYears = ref([]) // 可用年份列表
const selectedYear = ref(null) // 选中的年份
// 筛选表单
const filterForm = ref({
keyword: ''
})
// 分页
const pagination = ref({
currentPage: 1,
pageSize: 10,
total: 0
})
// 图标映射
const iconMap = {
Document
}
// 获取图标组件
const getIcon = (iconName) => {
if (!iconName) {
return Document
}
// 首先尝试从静态映射表获取
if (iconMap[iconName]) {
return iconMap[iconName]
}
// 然后尝试从ElementPlusIconsVue动态获取
if (ElementPlusIconsVue[iconName]) {
return ElementPlusIconsVue[iconName]
}
// 如果找不到,返回默认图标
return Document
}
// 获取状态类型
const getStatusType = (status) => {
const statusMap = {
0: 'warning', // 待审批
1: 'success', // 已批准
2: 'danger', // 已拒绝
3: 'info' // 已撤回
}
return statusMap[status] || ''
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
0: '待审批',
1: '已批准',
2: '已拒绝',
3: '已撤回'
}
return statusMap[status] || '未知'
}
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return '-'
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 获取字段宽度
const getFieldWidth = (fieldType) => {
const widthMap = {
'text': 120,
'textarea': 200,
'number': 100,
'money': 120,
'date': 120,
'datetime': 150,
'select': 120,
'file': 150
}
return widthMap[fieldType] || 120
}
// 格式化字段值
const formatFieldValue = (data, field) => {
if (!data || !field || !field.name) return '-'
// 尝试多种方式获取值
let value = data[field.name]
// 如果 data 是对象但没有该字段,尝试其他路径
if (value === undefined && typeof data === 'object') {
// 尝试使用字段的 label 或其他可能的键
value = data[field.label] || data[`${field.name}_text`] || null
}
if (value === null || value === undefined || value === '') return '-'
switch (field.type) {
case 'number':
case 'money':
return typeof value === 'number' ? value.toLocaleString('zh-CN') : value
case 'date':
if (value) {
try {
const date = new Date(value)
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('zh-CN')
}
} catch (e) {
console.warn('日期格式化失败:', value, e)
}
}
return '-'
case 'datetime':
if (value) {
try {
return formatDateTime(value)
} catch (e) {
console.warn('日期时间格式化失败:', value, e)
}
}
return '-'
case 'select':
case 'radio':
// 如果是选项类型,尝试显示选项文本
if (field.options && Array.isArray(field.options)) {
const option = field.options.find(opt => opt.value === value || opt.id === value)
return option ? (option.label || option.name || value) : value
}
return value
case 'file':
case 'files':
// 文件类型,显示文件数量或文件名
if (Array.isArray(value)) {
return `${value.length} 个文件`
}
return value
default:
return String(value)
}
}
// 加载年份列表
const loadAvailableYears = async () => {
if (!selectedSubprocess.value || !selectedSubprocess.value.custom_model_id) {
availableYears.value = []
return
}
loadingYears.value = true
try {
const response = await oaFlowAPI.getFlowYears({
custom_model_id: selectedSubprocess.value.custom_model_id
})
if (response.code === 0 && Array.isArray(response.data)) {
availableYears.value = response.data
// 自动选定当年
const currentYear = new Date().getFullYear()
if (availableYears.value.includes(currentYear)) {
selectedYear.value = currentYear
} else if (availableYears.value.length > 0) {
// 如果没有当年,选择最新的年份
selectedYear.value = availableYears.value[0]
} else {
selectedYear.value = null
}
} else {
availableYears.value = []
// 如果没有年份数据,默认使用当年
selectedYear.value = new Date().getFullYear()
}
} catch (error) {
console.error('加载年份列表失败:', error)
availableYears.value = []
// 出错时默认使用当年
selectedYear.value = new Date().getFullYear()
} finally {
loadingYears.value = false
}
}
// 年份切换
const handleYearChange = () => {
pagination.value.currentPage = 1
filterForm.value.keyword = ''
loadFlowList()
}
// 选择子流程
const handleSubprocessSelect = async (subprocess) => {
if (!subprocess.custom_model_id) {
ElMessage.warning('该流程项未关联OA模型')
return
}
// 立即清空表格数据,避免显示上一个流程的数据
tableData.value = []
pagination.value.total = 0
pagination.value.currentPage = 1
selectedSubprocess.value = subprocess
// 加载模型字段
await loadModelFields(subprocess.custom_model_id)
// 先加载年份列表
await loadAvailableYears()
// 年份加载完成后,再加载流程列表
// 即使没有年份loadFlowList 内部也会处理清空逻辑,但这里已经提前清空了
if (selectedYear.value) {
await loadFlowList()
} else {
// 如果没有年份数据,确保表格是空的(已经在上面清空了)
// 可以显示提示信息
console.log('该流程类型暂无数据')
}
}
// 查询类型改变
const handleQueryTypeChange = () => {
if (selectedSubprocess.value) {
loadFlowList()
}
}
// 加载可用子流程
const loadAvailableSubprocesses = async () => {
subprocessLoading.value = true
try {
const res = await preApprovalProcessConfigAPI.getForStartProcess()
if (res.code === 0) {
// 提取所有流程项(第二层)
const subprocesses = []
// 确保 res.data 是数组
const data = Array.isArray(res.data) ? res.data : []
data.forEach(group => {
// 确保 children 是数组
const children = Array.isArray(group.active_children)
? group.active_children
: Array.isArray(group.activeChildren)
? group.activeChildren
: Array.isArray(group.children)
? group.children
: []
children.forEach(child => {
if (child && child.custom_model_id) {
subprocesses.push({
id: child.id,
name: child.name,
description: child.description,
icon: child.icon || child.custom_model?.icon,
custom_model_id: child.custom_model_id,
custom_model: child.custom_model
})
}
})
})
availableSubprocesses.value = subprocesses
// 默认选中第一个
if (subprocesses.length > 0) {
await handleSubprocessSelect(subprocesses[0])
}
} else {
ElMessage.error(res.msg || res.message || '获取流程配置失败')
availableSubprocesses.value = []
}
} catch (error) {
ElMessage.error('获取流程配置失败:' + error.message)
availableSubprocesses.value = []
} finally {
subprocessLoading.value = false
}
}
// 加载模型字段
const loadModelFields = async (customModelId) => {
try {
const res = await oaFlowAPI.getCustomModelFields(customModelId)
if (res.code === 0 && res.data?.customModel?.fields) {
// 确保 fields 是数组
const fieldsArray = Array.isArray(res.data.customModel.fields)
? res.data.customModel.fields
: []
// 筛选 show_in_list = 1 的字段,并按 myindex 排序
const fields = fieldsArray
.filter(field => field && field.show_in_list === 1)
.sort((a, b) => (a.myindex || 0) - (b.myindex || 0))
dynamicFields.value = fields
} else {
dynamicFields.value = []
}
} catch (error) {
console.error('获取模型字段失败:', error)
dynamicFields.value = []
}
}
// 加载流程列表
const loadFlowList = async () => {
if (!selectedSubprocess.value || !selectedSubprocess.value.custom_model_id) {
return
}
// 如果没有选中年份,不加载数据
if (!selectedYear.value) {
tableData.value = []
pagination.value.total = 0
return
}
loading.value = true
try {
const params = {
custom_model_id: selectedSubprocess.value.custom_model_id,
year: selectedYear.value, // 必填:年份参数
page: pagination.value.currentPage,
page_size: pagination.value.pageSize,
is_simple: 0, // 完整版本包含data字段
payment_link_status: queryType.value, // 关联支付状态not-linked 或 linked
...filterForm.value
}
// 移除空值
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
// 使用 "all" 类型,因为我们现在通过 payment_link_status 参数来过滤
const res = await oaFlowAPI.getFlowList('all', params)
if (res.code === 0) {
// 后端返回结构res.data.data 是分页对象Laravel Paginator
// 分页对象包含:{ data: [...], total: 100, current_page: 1, per_page: 10, ... }
const paginationData = res.data?.data
if (paginationData) {
// 如果 paginationData 有 data 属性,说明是分页对象
if (paginationData.data && Array.isArray(paginationData.data)) {
tableData.value = paginationData.data
pagination.value.total = paginationData.total || 0
}
// 如果 paginationData 本身就是数组,直接使用
else if (Array.isArray(paginationData)) {
tableData.value = paginationData
// 尝试从其他地方获取总数
pagination.value.total = res.data?.total || paginationData.length || 0
}
// 其他情况,尝试作为数组处理
else {
tableData.value = []
pagination.value.total = 0
console.warn('未识别的数据格式,完整响应:', res)
}
} else {
// 如果没有 paginationData尝试直接使用 res.data.data
const data = res.data?.data
if (Array.isArray(data)) {
tableData.value = data
pagination.value.total = res.data?.total || data.length || 0
} else {
tableData.value = []
pagination.value.total = 0
console.warn('数据格式异常,完整响应:', res)
}
}
// 调试信息:检查数据是否正确加载
if (tableData.value.length === 0 && pagination.value.total > 0) {
console.warn('数据为空但总数不为0可能数据格式不匹配')
}
} else {
ElMessage.error(res.msg || res.message || '获取流程列表失败')
tableData.value = []
pagination.value.total = 0
}
} catch (error) {
ElMessage.error('获取流程列表失败:' + error.message)
tableData.value = []
pagination.value.total = 0
} finally {
loading.value = false
}
}
// 查询
const handleSearch = () => {
pagination.value.currentPage = 1
loadFlowList()
}
// 重置
const handleReset = () => {
filterForm.value = {
keyword: ''
}
pagination.value.currentPage = 1
// 年份不重置,保持当前选择
loadFlowList()
}
// 查看支付详情
const handleViewPayment = (payment) => {
// 打开新窗口显示打印预览页
const url = router.resolve({
name: 'PaymentDetailPrint',
params: { id: payment.id }
}).href
window.open(url, '_blank')
}
// 查看
const handleView = (row) => {
// 跳转到OA流程详情页面
const token = getToken()
const baseUrl = '/oa/#/flow/view'
const params = new URLSearchParams({
id: row.id.toString(),
isSinglePage: '1',
module_name: 'oa',
form_canal: 'budget'
})
if (token) {
params.set('auth_token', token)
}
const fullUrl = `${baseUrl}?${params.toString()}`
// 在新窗口打开
window.open(fullUrl, '_blank')
}
// 编辑
const handleEdit = (row) => {
// 跳转到OA流程编辑页面
const token = getToken()
const baseUrl = '/oa/#/flow/deal'
const params = new URLSearchParams({
id: row.id.toString(),
isSinglePage: '1',
module_name: 'oa',
form_canal: 'budget'
})
if (token) {
params.set('auth_token', token)
}
const fullUrl = `${baseUrl}?${params.toString()}`
// 在新窗口打开
window.open(fullUrl, '_blank')
}
// 分页大小改变
const handleSizeChange = (val) => {
pagination.value.pageSize = val
pagination.value.currentPage = 1
loadFlowList()
}
// 当前页改变
const handleCurrentChange = (val) => {
pagination.value.currentPage = val
loadFlowList()
}
// 页面加载
onMounted(() => {
loadAvailableSubprocesses()
})
</script>
<style scoped>
.process-query-container {
padding: 20px;
background: #f5f7fa;
min-height: 100%;
}
.page-header {
background: white;
padding: 25px 30px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
.subprocess-selector-section {
margin-bottom: 20px;
}
.subprocess-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.subprocess-card {
padding: 20px;
border: 2px solid #e4e7ed;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.subprocess-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.subprocess-card.active {
border-color: #409eff;
background: #409eff;
color: white;
}
.subprocess-card.active .subprocess-icon {
color: white;
}
.subprocess-icon {
margin-bottom: 12px;
color: #409eff;
}
.subprocess-card.active .subprocess-icon {
color: white;
}
.subprocess-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.subprocess-description {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
.subprocess-card.active .subprocess-description {
color: rgba(255, 255, 255, 0.8);
}
.filter-section {
margin-bottom: 20px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 16px;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 14px;
color: #606266;
font-weight: 500;
white-space: nowrap;
}
.type-cell {
display: flex;
align-items: center;
gap: 8px;
}
.title-cell {
display: flex;
align-items: center;
gap: 0;
}
.related-payments {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.payment-tag {
cursor: pointer;
transition: all 0.2s;
}
.payment-tag:hover {
opacity: 0.8;
transform: scale(1.05);
}
.no-payment {
color: #909399;
font-style: italic;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.subprocess-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.subprocess-card {
padding: 16px;
}
}
</style>