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

5 months ago
<template>
<div class="process-query-container">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">
<el-icon :size="24"><Search /></el-icon>
流程查询
</h1>
</div>
4 months ago
<!-- 子流程卡片网格选择器 -->
<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>
<!-- 查询类型和筛选区域 -->
5 months ago
<el-card class="filter-section" shadow="never">
4 months ago
<!-- 查询类型和年份选择合并到同一行 -->
<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>
4 months ago
</div>
<!-- 筛选表单 -->
<el-form :model="filterForm" inline style="margin-top: 16px">
5 months ago
<el-form-item label="流程编号">
<el-input
4 months ago
v-model="filterForm.keyword"
placeholder="请输入流程编号或关键词"
5 months ago
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">
4 months ago
<el-table :data="tableData" style="width: 100%" v-loading="loading" border>
5 months ago
<el-table-column type="index" label="序号" width="60" />
4 months ago
<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">
5 months ago
<template #default="scope">
<div class="type-cell">
4 months ago
<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)" />
5 months ago
</el-icon>
4 months ago
<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>
5 months ago
</div>
</template>
</el-table-column>
4 months ago
<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">
5 months ago
<template #default="scope">
4 months ago
{{ scope.row.creator_department?.name || scope.row.creator_department_name || '-' }}
5 months ago
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
4 months ago
{{ getStatusText(scope.row.status) }}
5 months ago
</el-tag>
</template>
</el-table-column>
4 months ago
<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>
5 months ago
<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
4 months ago
v-if="scope.row.status === 0"
5 months ago
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>
4 months ago
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
5 months ago
import { ElMessage } from 'element-plus'
import {
Search,
Refresh,
Document,
4 months ago
User,
Check
5 months ago
} from '@element-plus/icons-vue'
4 months ago
// 动态导入所有图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { preApprovalProcessConfigAPI, oaFlowAPI } from '@/utils/api'
import { getToken } from '@/utils/auth'
import config from '@/config'
5 months ago
4 months ago
const router = useRouter()
// 加载状态
5 months ago
const loading = ref(false)
4 months ago
const subprocessLoading = ref(false)
4 months ago
const loadingYears = ref(false)
4 months ago
// 可用子流程列表
const availableSubprocesses = ref([])
// 选中的子流程
const selectedSubprocess = ref(null)
// 查询类型not-linked未关联支付或 linked已关联支付
const queryType = ref('not-linked')
// 动态字段列表show_in_list=1的字段
const dynamicFields = ref([])
// 表格数据
5 months ago
const tableData = ref([])
4 months ago
4 months ago
// 年份相关
const availableYears = ref([]) // 可用年份列表
const selectedYear = ref(null) // 选中的年份
4 months ago
// 筛选表单
5 months ago
const filterForm = ref({
4 months ago
keyword: ''
5 months ago
})
4 months ago
// 分页
5 months ago
const pagination = ref({
currentPage: 1,
pageSize: 10,
total: 0
})
4 months ago
// 图标映射
5 months ago
const iconMap = {
4 months ago
Document
5 months ago
}
4 months ago
// 获取图标组件
5 months ago
const getIcon = (iconName) => {
4 months ago
if (!iconName) {
return Document
}
// 首先尝试从静态映射表获取
if (iconMap[iconName]) {
return iconMap[iconName]
5 months ago
}
4 months ago
// 然后尝试从ElementPlusIconsVue动态获取
if (ElementPlusIconsVue[iconName]) {
return ElementPlusIconsVue[iconName]
}
// 如果找不到,返回默认图标
return Document
5 months ago
}
4 months ago
// 获取状态类型
5 months ago
const getStatusType = (status) => {
const statusMap = {
4 months ago
0: 'warning', // 待审批
1: 'success', // 已批准
2: 'danger', // 已拒绝
3: 'info' // 已撤回
5 months ago
}
return statusMap[status] || ''
}
4 months ago
// 获取状态文本
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)
}
}
4 months ago
// 加载年份列表
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()
}
4 months ago
// 选择子流程
const handleSubprocessSelect = async (subprocess) => {
if (!subprocess.custom_model_id) {
ElMessage.warning('该流程项未关联OA模型')
return
}
// 立即清空表格数据,避免显示上一个流程的数据
tableData.value = []
pagination.value.total = 0
pagination.value.currentPage = 1
4 months ago
selectedSubprocess.value = subprocess
// 加载模型字段
await loadModelFields(subprocess.custom_model_id)
4 months ago
// 先加载年份列表
await loadAvailableYears()
// 年份加载完成后,再加载流程列表
// 即使没有年份loadFlowList 内部也会处理清空逻辑,但这里已经提前清空了
4 months ago
if (selectedYear.value) {
await loadFlowList()
} else {
// 如果没有年份数据,确保表格是空的(已经在上面清空了)
// 可以显示提示信息
console.log('该流程类型暂无数据')
4 months ago
}
4 months ago
}
// 查询类型改变
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
}
5 months ago
}
4 months ago
// 加载模型字段
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
}
4 months ago
// 如果没有选中年份,不加载数据
if (!selectedYear.value) {
tableData.value = []
pagination.value.total = 0
return
}
4 months ago
loading.value = true
try {
const params = {
custom_model_id: selectedSubprocess.value.custom_model_id,
4 months ago
year: selectedYear.value, // 必填:年份参数
4 months ago
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
}
}
// 查询
5 months ago
const handleSearch = () => {
4 months ago
pagination.value.currentPage = 1
loadFlowList()
5 months ago
}
4 months ago
// 重置
5 months ago
const handleReset = () => {
filterForm.value = {
4 months ago
keyword: ''
5 months ago
}
4 months ago
pagination.value.currentPage = 1
4 months ago
// 年份不重置,保持当前选择
4 months ago
loadFlowList()
}
// 查看支付详情
const handleViewPayment = (payment) => {
// 打开新窗口显示打印预览页
const url = router.resolve({
name: 'PaymentDetailPrint',
params: { id: payment.id }
}).href
window.open(url, '_blank')
5 months ago
}
4 months ago
// 查看
5 months ago
const handleView = (row) => {
4 months ago
// 跳转到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')
5 months ago
}
4 months ago
// 编辑
5 months ago
const handleEdit = (row) => {
4 months ago
// 跳转到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')
5 months ago
}
4 months ago
// 分页大小改变
5 months ago
const handleSizeChange = (val) => {
pagination.value.pageSize = val
4 months ago
pagination.value.currentPage = 1
loadFlowList()
5 months ago
}
4 months ago
// 当前页改变
5 months ago
const handleCurrentChange = (val) => {
pagination.value.currentPage = val
4 months ago
loadFlowList()
5 months ago
}
4 months ago
// 页面加载
5 months ago
onMounted(() => {
4 months ago
loadAvailableSubprocesses()
5 months ago
})
</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;
}
4 months ago
.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);
}
5 months ago
.filter-section {
margin-bottom: 20px;
}
4 months ago
.filter-bar {
display: flex;
align-items: center;
gap: 24px;
4 months ago
margin-bottom: 16px;
4 months ago
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;
4 months ago
}
5 months ago
.type-cell {
display: flex;
align-items: center;
gap: 8px;
}
4 months ago
.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;
}
5 months ago
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
4 months ago
@media (max-width: 768px) {
.subprocess-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.subprocess-card {
padding: 16px;
}
}
5 months ago
</style>