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

4 months ago
<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">
2 months ago
<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>
4 months ago
</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>
2 months ago
<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>
4 months ago
</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">
2 months ago
<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>
4 months ago
</div>
</div>
</div>
<div v-if="relatedByFlows.length > 0" class="related-section">
<div class="section-title">被关联的流程{{ relatedByFlows.length }}</div>
<div class="related-list">
2 months ago
<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>
4 months ago
</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 || ''),
2 months ago
created_at: flow.created_at,
meeting_minutes: flow.meeting_minutes || []
4 months ago
}))
}
} 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;
2 months ago
.selected-flow-block {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
4 months ago
.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;
}
}
2 months ago
.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;
4 months ago
}
}
.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 {
2 months ago
padding: 10px 12px;
4 months ago
margin-bottom: 8px;
background: #f5f7fa;
border-radius: 4px;
transition: all 0.3s;
2 months ago
.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;
}
4 months ago
2 months ago
.flow-title-text {
color: #409eff;
font-weight: 500;
}
.flow-meta {
color: #909399;
font-size: 12px;
margin-left: 8px;
}
4 months ago
}
2 months ago
.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;
}
}
4 months ago
}
}
}
.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>