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.

803 lines
23 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="related-flows-component">
<el-card shadow="never" class="related-flows-card">
<div slot="header" class="card-header">
<div class="header-left">
<span class="header-title">
<i class="el-icon-link"></i>
关联流程
</span>
<span class="header-tip">可选择自己发起或经办过的任意流程进行关联</span>
</div>
<el-button
v-if="!readonly && collapsible"
type="text"
:icon="collapsed ? 'el-icon-arrow-down' : 'el-icon-arrow-up'"
@click.stop.prevent="handleToggleCollapse"
class="collapse-btn"
>
{{ collapsed ? '展开' : '收起' }}
</el-button>
</div>
<!-- 搜索区域 -->
<div class="search-area" v-show="!collapsed && !readonly" @click.stop>
<el-row :gutter="10">
<el-col :span="6">
<el-select
v-model="searchForm.custom_model_id"
placeholder="流程类型"
clearable
filterable
style="width: 100%"
@change="handleModelChange"
@click.native.stop
@visible-change="handleSelectVisible"
popper-class="related-flow-select-popper"
>
<el-option
v-for="model in customModels"
:key="model.id"
:label="model.name"
:value="model.id"
></el-option>
</el-select>
</el-col>
<el-col :span="10">
<el-input
v-model="searchForm.keyword"
placeholder="输入标题或编号搜索"
clearable
@keyup.enter.native="handleSearch"
>
<el-button slot="append" icon="el-icon-search" @click="handleSearch"></el-button>
</el-input>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="handleSearch" :loading="loading"></el-button>
</el-col>
</el-row>
</div>
<!-- 已选流程列表 -->
<div class="selected-flows" v-show="!collapsed && selectedFlows.length > 0">
<div class="selected-title">已关联的流程({{ selectedFlows.length }}</div>
<div class="selected-list">
<div v-for="flow in selectedFlows" :key="flow.id" class="selected-flow-block">
<el-tag
:closable="!readonly"
@close="handleRemove(flow.id)"
@click="handleViewFlow(flow)"
class="flow-tag"
type="info"
effect="plain"
>
<span class="flow-title">{{ flow.title }}</span>
<span class="flow-meta">{{ flow.no }}</span>
</el-tag>
<div v-if="flow.meeting_minutes && flow.meeting_minutes.length" class="flow-meeting-minutes">
<span class="mm-label">会议纪要:</span>
<span v-for="(mm, idx) in (flow.meeting_minutes || [])" :key="mm.id" class="mm-link-wrap">
<el-link type="primary" size="mini" @click.stop="$emit('open-meeting-minute', mm.id)">{{ mm.title }}</el-link>
<span v-if="idx < (flow.meeting_minutes.length - 1)" class="mm-sep">、</span>
</span>
</div>
</div>
</div>
</div>
<!-- 可选流程列表 -->
<div class="available-flows" v-show="!collapsed && !readonly">
<div class="available-title">可选流程</div>
<div v-loading="loading" class="flow-list">
<el-empty v-if="!loading && availableFlows.length === 0" description="暂无可用流程"></el-empty>
<div
v-for="flow in availableFlows"
:key="flow.id"
class="flow-item"
:class="{ 'is-selected': isSelected(flow.id) }"
@click="handleToggleFlow(flow)"
>
<div class="flow-content">
<div class="flow-header">
<span class="flow-title-text">{{ flow.title }}</span>
<el-tag size="mini" :type="getStatusType(flow.status)">
{{ flow.status_text }}
</el-tag>
</div>
<div class="flow-info">
<span class="flow-no">编号:{{ flow.no }}</span>
<span class="flow-type">类型:{{ flow.custom_model_name }}</span>
<span class="flow-creator">发起人:{{ flow.creator_name }}</span>
<span class="flow-date">{{ formatDate(flow.created_at) }}</span>
</div>
<div v-if="flow.meeting_minutes && flow.meeting_minutes.length" class="flow-meeting-minutes">
<span class="mm-label">会议纪要:</span>
<span v-for="(mm, idx) in (flow.meeting_minutes || [])" :key="mm.id" class="mm-link-wrap">
<el-link type="primary" size="mini" @click.stop="$emit('open-meeting-minute', mm.id)">{{ mm.title }}</el-link>
<span v-if="idx < (flow.meeting_minutes.length - 1)" class="mm-sep">、</span>
</span>
</div>
</div>
<div class="flow-actions">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click.stop="handleViewFlow(flow)"
>
查看
</el-button>
<el-button
v-if="!isSelected(flow.id)"
size="mini"
type="primary"
@click.stop="handleToggleFlow(flow)"
>
关联
</el-button>
<el-button
v-else
size="mini"
type="danger"
@click.stop="handleToggleFlow(flow)"
>
取消
</el-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination" v-if="pagination.total > 0">
<el-pagination
@current-change="handlePageChange"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
layout="total, prev, pager, next"
small
></el-pagination>
</div>
</div>
<!-- 只读模式显示关联流程 -->
<div class="readonly-flows" v-show="readonly">
<div v-if="relatedFlows.length > 0" class="related-section">
<div class="section-title">关联的流程({{ relatedFlows.length }}</div>
<div class="related-list">
<div v-for="flow in relatedFlows" :key="flow.id" class="related-item">
<div class="related-item-flow" @click="handleViewFlow(flow)">
<span class="flow-title-text">{{ flow.title }}</span>
<span class="flow-meta">{{ flow.no }} - {{ flow.custom_model_name }}</span>
</div>
<div v-if="flow.meeting_minutes && flow.meeting_minutes.length" class="related-item-meeting-minutes">
<span class="mm-label">会议纪要:</span>
<span
v-for="mm in (flow.meeting_minutes || [])"
:key="mm.id"
class="mm-block"
@click="$emit('open-meeting-minute', mm.id)"
>{{ mm.title }}</span>
</div>
</div>
</div>
</div>
<div v-if="relatedByFlows.length > 0" class="related-section">
<div class="section-title">被关联的流程({{ relatedByFlows.length }}</div>
<div class="related-list">
<div v-for="flow in relatedByFlows" :key="flow.id" class="related-item">
<div class="related-item-flow" @click="handleViewFlow(flow)">
<span class="flow-title-text">{{ flow.title }}</span>
<span class="flow-meta">{{ flow.no }} - {{ flow.custom_model_name }}</span>
</div>
<div v-if="flow.meeting_minutes && flow.meeting_minutes.length" class="related-item-meeting-minutes">
<span class="mm-label">会议纪要:</span>
<span
v-for="mm in (flow.meeting_minutes || [])"
:key="mm.id"
class="mm-block"
@click="$emit('open-meeting-minute', mm.id)"
>{{ mm.title }}</span>
</div>
</div>
</div>
</div>
<div v-if="readonly && relatedFlows.length === 0 && relatedByFlows.length === 0" class="empty-tip">
<span class="empty-text">暂无关联流程</span>
</div>
</div>
</el-card>
<!-- 流程详情弹窗 -->
<el-dialog
:title="currentFlow ? currentFlow.title : '流程详情'"
:visible.sync="showFlowDetail"
width="90%"
:close-on-click-modal="false"
class="flow-detail-dialog"
>
<iframe
v-if="flowDetailUrl"
:src="flowDetailUrl"
style="width: 100%; height: 70vh; border: none;"
frameborder="0"
></iframe>
</el-dialog>
</div>
</template>
<script>
import { getRelationOptions, getRelationList, flowList, flow as flowIndex } from '@/api/flow'
import { getToken } from '@/utils/auth'
import moment from 'moment'
export default {
name: 'RelatedFlows',
props: {
value: {
type: [String, Array],
default: () => []
},
readonly: {
type: Boolean,
default: false
},
flowId: {
type: [Number, String],
default: null
},
collapsible: {
type: Boolean,
default: true
}
},
data() {
return {
collapsed: this.readonly ? false : true, // readonly模式默认展开编辑模式默认收起
loading: false,
searchForm: {
keyword: '',
custom_model_id: ''
},
availableFlows: [],
selectedFlows: [],
relatedFlows: [],
relatedByFlows: [],
customModels: [],
pagination: {
page: 1,
page_size: 20,
total: 0
},
showFlowDetail: false,
currentFlow: null,
flowDetailUrl: ''
}
},
watch: {
value: {
immediate: true,
handler(newVal) {
if (newVal) {
const ids = Array.isArray(newVal) ? newVal : newVal.toString().split(',').filter(Boolean).map(Number)
if (ids.length > 0 && this.selectedFlows.length === 0) {
// 如果有值但selectedFlows为空需要加载这些流程的详情
this.loadSelectedFlows(ids)
}
} else {
this.selectedFlows = []
}
}
},
readonly: {
immediate: true,
handler(newVal) {
if (newVal) {
// readonly模式下默认展开且不可收起
this.collapsed = false
if (this.flowId) {
this.loadRelationList()
}
}
}
},
flowId: {
immediate: true,
handler(newVal) {
if (newVal && this.readonly) {
this.loadRelationList()
}
}
},
collapsed(newVal) {
// 展开时自动搜索
if (!newVal && !this.readonly && this.availableFlows.length === 0) {
this.$nextTick(() => {
this.handleSearch()
})
}
}
},
mounted() {
this.loadCustomModels()
},
methods: {
handleToggleCollapse(e) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
this.collapsed = !this.collapsed
},
handleModelChange() {
// 选择流程类型后不收起,只触发搜索
// 这里不需要做任何操作,因为 change 事件已经更新了 searchForm.custom_model_id
},
handleSelectVisible(visible) {
// 下拉菜单显示/隐藏时,确保不会触发收起
// 如果下拉菜单打开时组件是收起的,自动展开
if (visible && this.collapsed && !this.readonly) {
this.collapsed = false
}
},
async loadCustomModels() {
try {
// 方法1: 尝试从 flowList API 获取
const res = await flowList('all', { page: 1, page_size: 1, is_simple: 1 })
// customModels 在 res 的顶层,不在 res.data 中
if (res.customModels && res.customModels.length > 0) {
this.customModels = res.customModels
return
} else if (res.data && res.data.customModels) {
this.customModels = res.data.customModels
return
}
} catch (err) {
console.warn('从 flowList 加载流程类型失败,尝试备用方法', err)
}
// 方法2: 从 flow index API 获取(备用方法)
try {
const flowRes = await flowIndex()
// request.js 拦截器已经返回了 res.data
if (flowRes && flowRes.cates) {
// 从分类中提取所有模型
const models = []
flowRes.cates.forEach(cate => {
if (cate.customerModels && Array.isArray(cate.customerModels)) {
models.push(...cate.customerModels)
}
})
this.customModels = models.map(m => ({ id: m.id, name: m.name }))
}
} catch (e) {
console.error('备用加载流程类型失败', e)
this.$message.warning('加载流程类型失败,请刷新页面重试')
}
},
async handleSearch() {
this.loading = true
try {
const params = {
page: this.pagination.page,
page_size: this.pagination.page_size,
...this.searchForm
}
const res = await getRelationOptions(params)
// request.js 拦截器已经返回了 res.data所以这里 res 就是 { data: [...], total: ..., page: ... }
if (res) {
this.availableFlows = res.data || []
this.pagination.total = res.total || 0
this.pagination.page = res.page || 1
this.pagination.page_size = res.page_size || 20
}
} catch (err) {
this.$message.error('搜索失败:' + (err.message || '未知错误'))
} finally {
this.loading = false
}
},
async loadSelectedFlows(ids) {
if (!ids || ids.length === 0) {
this.selectedFlows = []
return
}
try {
const res = await flowList('all', {
ids: ids.join(','),
page: 1,
page_size: 9999,
is_simple: 1
})
// request.js 拦截器已经返回了 res.data
const flows = res.data || []
if (flows.length > 0) {
this.selectedFlows = flows.map(flow => ({
id: flow.id,
title: flow.title,
no: flow.no,
custom_model_id: flow.custom_model_id,
custom_model_name: flow.customModel ? flow.customModel.name : (flow.custom_model_name || ''),
status: flow.status,
status_text: this.getStatusText(flow.status),
created_by: flow.created_by,
creator_name: flow.creator ? flow.creator.name : (flow.creator_name || ''),
created_at: flow.created_at,
meeting_minutes: flow.meeting_minutes || []
}))
}
} catch (err) {
console.error('加载已选流程失败', err)
}
},
async loadRelationList() {
if (!this.flowId) return
try {
const res = await getRelationList(this.flowId)
// request.js 拦截器已经返回了 res.data
if (res) {
this.relatedFlows = res.related_flows || []
this.relatedByFlows = res.related_by_flows || []
}
} catch (err) {
console.error('加载关联列表失败', err)
}
},
handleToggleFlow(flow) {
if (this.isSelected(flow.id)) {
this.handleRemove(flow.id)
} else {
this.handleAdd(flow)
}
},
handleAdd(flow) {
if (this.isSelected(flow.id)) return
this.selectedFlows.push(flow)
this.updateValue()
},
handleRemove(flowId) {
this.selectedFlows = this.selectedFlows.filter(f => f.id !== flowId)
this.updateValue()
},
isSelected(flowId) {
return this.selectedFlows.some(f => f.id === flowId)
},
updateValue() {
const ids = this.selectedFlows.map(f => f.id)
this.$emit('input', ids.join(','))
this.$emit('change', ids)
},
handlePageChange(page) {
this.pagination.page = page
this.handleSearch()
},
handleViewFlow(flow) {
this.currentFlow = flow
const baseUrl = process.env.VUE_APP_BASE_API || ''
this.flowDetailUrl = `${baseUrl}/oa/#/flow/detail?module_id=${flow.custom_model_id}&flow_id=${flow.id}&isSinglePage=1&auth_token=${encodeURIComponent(getToken())}`
this.showFlowDetail = true
},
getStatusType(status) {
const statusMap = {
0: 'info',
1: 'success',
'-1': 'danger'
}
return statusMap[status] || 'info'
},
getStatusText(status) {
const statusMap = {
0: '办理中',
1: '已办结',
'-1': '已取消'
}
return statusMap[status] || '未知'
},
formatDate(date) {
if (!date) return ''
return moment(date).format('YYYY-MM-DD HH:mm')
}
}
}
</script>
<style lang="scss" scoped>
.related-flows-component {
margin-bottom: 20px;
.related-flows-card {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-title {
font-size: 16px;
font-weight: 500;
color: #303133;
i {
margin-right: 5px;
color: #409eff;
}
}
.header-tip {
font-size: 12px;
color: #909399;
}
.collapse-btn {
padding: 0;
font-size: 14px;
}
}
}
.search-area {
margin-bottom: 20px;
}
.selected-flows {
margin-bottom: 20px;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
.selected-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
color: #303133;
}
.selected-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
.selected-flow-block {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.flow-tag {
cursor: pointer;
padding: 5px 10px;
font-size: 13px;
.flow-title {
color: #409eff;
}
.flow-meta {
color: #909399;
font-size: 12px;
}
&:hover {
background-color: #ecf5ff;
}
}
.flow-meeting-minutes {
font-size: 12px;
color: #606266;
padding-left: 4px;
.mm-label {
color: #909399;
margin-right: 2px;
}
.mm-sep {
margin: 0 2px;
color: #909399;
}
}
}
}
.flow-meeting-minutes {
font-size: 12px;
color: #606266;
margin-top: 6px;
.mm-label {
color: #909399;
margin-right: 4px;
}
.mm-sep {
margin: 0 2px;
color: #909399;
}
}
.available-flows {
.available-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 15px;
color: #303133;
}
.flow-list {
min-height: 200px;
max-height: 400px;
overflow-y: auto;
.flow-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
margin-bottom: 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #409eff;
background-color: #ecf5ff;
}
&.is-selected {
border-color: #409eff;
background-color: #ecf5ff;
}
.flow-content {
flex: 1;
.flow-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.flow-title-text {
font-size: 14px;
font-weight: 500;
color: #303133;
}
}
.flow-info {
display: flex;
gap: 15px;
font-size: 12px;
color: #909399;
span {
white-space: nowrap;
}
}
}
.flow-actions {
margin-left: 15px;
}
}
}
.pagination {
margin-top: 15px;
text-align: right;
}
}
.readonly-flows {
.related-section {
margin-bottom: 20px;
.section-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
color: #303133;
}
.related-list {
.related-item {
padding: 10px 12px;
margin-bottom: 8px;
background: #f5f7fa;
border-radius: 4px;
transition: all 0.3s;
.related-item-flow {
display: block;
padding: 6px 10px;
margin: -4px -6px 8px -6px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #ecf5ff;
}
.flow-title-text {
color: #409eff;
font-weight: 500;
}
.flow-meta {
color: #909399;
font-size: 12px;
margin-left: 8px;
}
}
.related-item-meeting-minutes {
margin-top: 4px;
.mm-label {
display: inline-block;
color: #909399;
font-size: 12px;
margin-right: 8px;
vertical-align: middle;
}
.mm-block {
display: inline-block;
padding: 4px 10px;
margin: 2px 6px 2px 0;
color: #409eff;
font-size: 13px;
background: #ecf5ff;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #d9ecff;
color: #66b1ff;
}
}
}
}
}
.empty-tip {
padding: 8px 0;
text-align: center;
.empty-text {
color: #909399;
font-size: 13px;
}
}
}
}
}
.flow-detail-dialog {
::v-deep .el-dialog__body {
padding: 20px;
}
}
</style>
<style lang="scss">
//
.related-flow-select-popper {
.el-select-dropdown__item {
&:hover {
background-color: #f5f7fa;
}
}
}
</style>