整合前端
This commit is contained in:
152
ruoyi-ui/src/views/hrm/flow/cc.vue
Normal file
152
ruoyi-ui/src/views/hrm/flow/cc.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="app-container flow-cc-container">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">抄送我的</span>
|
||||
<el-button style="margin-left: 10px" size="mini" @click="handleRefresh">刷新</el-button>
|
||||
<el-checkbox v-model="ccFlag" label="已读" size="mini" @change="getList">只看已通过的</el-checkbox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||
<el-tab-pane label="未读" name="unread">
|
||||
<template #label>
|
||||
<span>
|
||||
未读
|
||||
<el-badge :value="badgeUnread" class="badge" v-if="badgeUnread > 0" />
|
||||
</span>
|
||||
</template>
|
||||
<!-- 内容跟随 tabs 切换统一渲染,逻辑写下面 -->
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="已读" name="read" />
|
||||
</el-tabs>
|
||||
|
||||
<el-table v-loading="loading" :data="ccList" border style="width: 100%" :row-class-name="tableRowClassName">
|
||||
<el-table-column label="业务" prop="bizTypeName" width="150">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="getTypeTagType(scope.row.bizType)">{{ getTypeText(scope.row.bizType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容" prop="bizTitle" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column label="节点" prop="nodeName" width="180" />
|
||||
<el-table-column label="抄送人" prop="createBy">
|
||||
</el-table-column>
|
||||
<el-table-column label="抄送时间" prop="createTime" width="180">
|
||||
<template #default="scope">
|
||||
{{ parseTime(scope.row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-link v-if="activeTab === 'unread'" type="primary" @click.stop="handleRead(scope.row)">
|
||||
标记已读
|
||||
</el-link>
|
||||
<el-link type="primary" @click.stop="goDetail(scope.row)">
|
||||
详情
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
||||
@pagination="getList" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listCc, readCc } from '@/api/hrm/cc';
|
||||
import applyTypeMinix from '@/views/hrm/minix/applyTypeMinix.js';
|
||||
|
||||
export default {
|
||||
name: 'HrmFlowCc',
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
activeTab: 'unread',
|
||||
ccList: [],
|
||||
total: 0,
|
||||
ccFlag: true,
|
||||
badgeUnread: 0,
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
readFlag: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [applyTypeMinix],
|
||||
created () {
|
||||
this.getList()
|
||||
},
|
||||
computed: {
|
||||
currentUserId () {
|
||||
return this.$store.getters.id;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
tableRowClassName ({ row }) {
|
||||
return row.readFlag === 0 ? 'row-unread' : ''
|
||||
},
|
||||
handleTabClick () {
|
||||
this.queryParams.pageNum = 1
|
||||
this.queryParams.readFlag = this.activeTab === 'read' ? 1 : 0
|
||||
this.getList()
|
||||
},
|
||||
getList () {
|
||||
this.loading = true
|
||||
this.queryParams.ccFlag = this.ccFlag ? 1 : 0
|
||||
listCc({ ...this.queryParams, ccUserId: this.currentUserId })
|
||||
.then((res) => {
|
||||
this.ccList = res.rows || []
|
||||
this.total = res.total || 0
|
||||
if (this.activeTab === 'unread') {
|
||||
this.badgeUnread = res.total || 0
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
handleRead (row) {
|
||||
readCc(row.ccId).then(() => {
|
||||
this.$modal.msgSuccess('已标记已读')
|
||||
this.getList()
|
||||
})
|
||||
},
|
||||
handleRefresh () {
|
||||
this.getList()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-cc-container {
|
||||
padding: 20px 20px 0 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* 未读行高亮(柔和背景,不用渐变) */
|
||||
.row-unread td {
|
||||
background-color: #fdf6ec !important;
|
||||
/* Element UI warning-light */
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
723
ruoyi-ui/src/views/hrm/flow/node.vue
Normal file
723
ruoyi-ui/src/views/hrm/flow/node.vue
Normal file
@@ -0,0 +1,723 @@
|
||||
<template>
|
||||
<div class="hrm-page">
|
||||
<div class="flow-layout">
|
||||
<!-- 左侧:选择模板 + 模板摘要 -->
|
||||
<el-card class="metal-panel left" shadow="hover">
|
||||
<div slot="header" class="panel-header">
|
||||
<div class="header-title">
|
||||
<span>节点配置</span>
|
||||
<span class="sub">先选择模板,再配置节点顺序与审批人</span>
|
||||
</div>
|
||||
<div class="actions-inline">
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="loadTemplates">刷新模板</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selector">
|
||||
<el-select v-model="query.tplId" filterable clearable size="small" placeholder="选择流程模板" style="width: 100%"
|
||||
@change="handleTplChange">
|
||||
<el-option v-for="tpl in templateOptions" :key="tpl.tplId" :label="formatTplLabel(tpl)"
|
||||
:value="tpl.tplId" />
|
||||
</el-select>
|
||||
<div class="hint-text">建议:同一业务只启用一个默认模板,避免用户选择困难</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTpl" class="tpl-summary">
|
||||
<div class="tpl-name">{{ currentTpl.tplName }}</div>
|
||||
<div class="tpl-sub">
|
||||
<el-tag size="mini" type="info">{{ bizTypeText(currentTpl.bizType) }}</el-tag>
|
||||
<el-tag size="mini" :type="currentTpl.enabled ? 'success' : 'info'">{{ currentTpl.enabled ? '启用' : '停用'
|
||||
}}</el-tag>
|
||||
<span class="muted">v{{ currentTpl.version || 1 }}</span>
|
||||
</div>
|
||||
<div class="muted mt6">编码:{{ currentTpl.tplCode || '-' }}</div>
|
||||
<div class="muted mt6" v-if="currentTpl.remark">备注:{{ currentTpl.remark }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="placeholder">
|
||||
<div class="p-title">请选择一个模板</div>
|
||||
<div class="p-sub">选择后,右侧会显示该模板的节点步骤,并可新增/调整顺序</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 右侧:节点步骤(卡片化) -->
|
||||
<el-card class="metal-panel right" shadow="hover">
|
||||
<div slot="header" class="panel-header">
|
||||
<span>节点步骤</span>
|
||||
<div class="actions-inline">
|
||||
<el-button size="mini" type="primary" icon="el-icon-plus" :disabled="!query.tplId"
|
||||
@click="openDrawer()">新增节点</el-button>
|
||||
<el-button size="mini" icon="el-icon-refresh" :disabled="!query.tplId" @click="loadList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!query.tplId" class="empty">
|
||||
请选择模板后再配置节点。
|
||||
</div>
|
||||
|
||||
<div v-else class="steps" v-loading="loading">
|
||||
<el-steps :active="-1" align-center finish-status="success">
|
||||
<el-step v-for="n in sortedList" :key="n.nodeId" :title="nodeTypeText(n.nodeType)"
|
||||
:description="nodeDesc(n)" />
|
||||
</el-steps>
|
||||
|
||||
<div class="node-cards">
|
||||
<div v-for="n in sortedList" :key="n.nodeId" class="node-card">
|
||||
<div class="node-head">
|
||||
<div class="lh">
|
||||
<div class="node-title">#{{ n.orderNo }} · {{ nodeTypeText(n.nodeType) }}</div>
|
||||
<div class="node-sub muted">{{ approverRuleText(n.approverRule) }}{{ approverValueReadable(n) ? ` ·
|
||||
${approverValueReadable(n)}` : '' }}</div>
|
||||
</div>
|
||||
<div class="rh">
|
||||
<el-button size="mini" type="text" @click="moveUp(n)" :disabled="!canMoveUp(n)">上移</el-button>
|
||||
<el-button size="mini" type="text" @click="moveDown(n)" :disabled="!canMoveDown(n)">下移</el-button>
|
||||
<el-button size="mini" type="text" @click="openDrawer(n)">编辑</el-button>
|
||||
<el-button size="mini" type="text" class="danger" @click="delRow(n)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-body">
|
||||
<div class="muted" v-if="n.remark">备注:{{ n.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sortedList.length === 0" class="empty">
|
||||
当前模板还没有节点,请新增一个“审批节点”。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 节点编辑抽屉:大面板 + 提示 -->
|
||||
<el-drawer :title="drawerTitle" :visible.sync="drawerVisible" size="640px" append-to-body>
|
||||
<div class="drawer-body">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" size="small">
|
||||
<el-form-item label="所属模板" prop="tplId">
|
||||
<el-select v-model="form.tplId" filterable placeholder="选择模板" style="width: 100%">
|
||||
<el-option v-for="tpl in templateOptions" :key="tpl.tplId" :label="formatTplLabel(tpl)"
|
||||
:value="tpl.tplId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="顺序" prop="orderNo">
|
||||
<el-input-number v-model="form.orderNo" :min="1" :step="1" controls-position="right"
|
||||
style="width: 100%" />
|
||||
<div class="hint-text">顺序越小越先执行</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="节点类型" prop="nodeType">
|
||||
<el-select v-model="form.nodeType" placeholder="选择类型" style="width: 100%">
|
||||
<el-option v-for="opt in nodeTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div class="hint-text">常用为“审批节点”</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="block-title">审批人设置(去ID化增强)</div>
|
||||
<el-form-item label="审批规则" prop="approverRule">
|
||||
<el-select v-model="form.approverRule" placeholder="选择规则" style="width: 100%" @change="handleRuleChange">
|
||||
<el-option v-for="opt in approverRuleOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div class="hint-text">尽量使用"部门负责人/发起人/角色"来减少维护成本</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="规则值" prop="approverValue">
|
||||
<!-- 固定用户:从员工列表选择(多选),避免手填ID -->
|
||||
<template v-if="form.approverRule === 'fixed_user'">
|
||||
<el-select v-model="selectedFixedUsers" multiple filterable clearable collapse-tags
|
||||
placeholder="选择审批人(可多选)" style="width: 100%" @change="syncFixedUserValue">
|
||||
<el-option v-for="emp in employees" :key="emp.empId" :label="formatEmpLabel(emp)"
|
||||
:value="String(emp.empId)" />
|
||||
</el-select>
|
||||
<div class="hint-text">不建议手填ID;这里可直接选择员工(系统以 empId 提交)。</div>
|
||||
</template>
|
||||
|
||||
<!-- 角色:可多选下拉(最小可行:输入/创建),减少纯文本 -->
|
||||
<template v-else-if="form.approverRule === 'role'">
|
||||
<el-select v-model="selectedRoles" multiple filterable allow-create default-first-option clearable
|
||||
collapse-tags placeholder="选择/输入角色编码(可多选)" style="width: 100%" @change="syncRoleValue">
|
||||
<el-option v-for="r in roleOptions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
<div class="hint-text">示例:HR_ADMIN, FIN。后续可对接系统角色接口做真正的“角色选择器”。</div>
|
||||
</template>
|
||||
|
||||
<!-- 岗位:从岗位列表选择(多选),避免手填ID -->
|
||||
<template v-else-if="form.approverRule === 'position'">
|
||||
<el-select v-model="selectedPositions" multiple filterable clearable collapse-tags
|
||||
placeholder="选择岗位(可多选)" style="width: 100%" @change="syncPositionValue">
|
||||
<el-option v-for="pos in positions" :key="pos.postId" :label="formatPositionLabel(pos)"
|
||||
:value="String(pos.postId)" />
|
||||
</el-select>
|
||||
<div class="hint-text">建议直接选择岗位(系统以 postId 提交),避免手填ID。</div>
|
||||
</template>
|
||||
|
||||
<!-- 表单字段/其他:保留输入,但提示清晰 -->
|
||||
<template v-else-if="needsValue(form.approverRule)">
|
||||
<el-input v-model="form.approverValue" :placeholder="valuePlaceholder(form.approverRule)" />
|
||||
<div class="hint-text">{{ valueHint(form.approverRule) }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 部门负责人:说明如何确定 -->
|
||||
<template v-else-if="form.approverRule === 'dept_leader'">
|
||||
<div class="rule-explanation">
|
||||
<div class="explanation-title">如何确定部门负责人?</div>
|
||||
<div class="explanation-content">
|
||||
<p>系统将按以下规则自动查找:</p>
|
||||
<ol>
|
||||
<li>获取发起人的主部门(hrm_employee.dept_id)</li>
|
||||
<li>查找该部门的负责人(sys_dept.leader)</li>
|
||||
<li>如果部门没有负责人,则查找上级部门的负责人,直到找到为止</li>
|
||||
<li>如果始终找不到,则使用发起人本人</li>
|
||||
</ol>
|
||||
<p class="hint-text">提示:确保部门信息中已正确设置负责人(leader字段)</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 发起人本人:简单说明 -->
|
||||
<template v-else-if="form.approverRule === 'initiator'">
|
||||
<div class="rule-explanation">
|
||||
<div class="explanation-content">
|
||||
<p>系统将自动使用流程发起人作为审批人。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="muted">该规则无需填写(系统自动计算)</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="可选:说明该节点职责、注意事项" />
|
||||
</el-form-item>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
addFlowNode,
|
||||
delFlowNode,
|
||||
listEmployee,
|
||||
listFlowNode,
|
||||
listFlowTemplate,
|
||||
updateFlowNode
|
||||
} from '@/api/hrm';
|
||||
import { listPost } from '@/api/system/post';
|
||||
|
||||
export default {
|
||||
name: 'HrmFlowNode',
|
||||
data () {
|
||||
return {
|
||||
list: [],
|
||||
loading: false,
|
||||
query: { tplId: undefined },
|
||||
templateOptions: [],
|
||||
currentTpl: null,
|
||||
|
||||
employees: [],
|
||||
positions: [],
|
||||
selectedFixedUsers: [],
|
||||
selectedRoles: [],
|
||||
selectedPositions: [],
|
||||
roleOptions: ['HR_ADMIN', 'FIN', 'DEPT_LEADER'],
|
||||
|
||||
drawerVisible: false,
|
||||
drawerTitle: '节点配置',
|
||||
submitting: false,
|
||||
form: {},
|
||||
|
||||
nodeTypeOptions: [
|
||||
{ label: '审批节点', value: 'approve' },
|
||||
{ label: '结束节点', value: 'end' }
|
||||
],
|
||||
approverRuleOptions: [
|
||||
{ label: '固定人员(选择)', value: 'fixed_user' },
|
||||
// { label: '角色(选择/输入编码)', value: 'role' },
|
||||
// { label: '岗位(选择)', value: 'position' },
|
||||
// { label: '部门负责人(自动)', value: 'dept_leader' },
|
||||
// { label: '发起人本人(自动)', value: 'initiator' },
|
||||
// { label: '表单字段指定(字段名)', value: 'form_field' }
|
||||
],
|
||||
rules: {
|
||||
tplId: [{ required: true, message: '请选择模板', trigger: 'change' }],
|
||||
orderNo: [{ required: true, message: '请输入顺序', trigger: 'blur' }],
|
||||
nodeType: [{ required: true, message: '请选择节点类型', trigger: 'change' }],
|
||||
approverRule: [{ required: true, message: '请选择审批规则', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedList () {
|
||||
return [...this.list].sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.loadTemplates()
|
||||
this.loadEmployees()
|
||||
this.loadPositions()
|
||||
},
|
||||
methods: {
|
||||
loadEmployees () {
|
||||
listEmployee({ pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
})
|
||||
},
|
||||
loadPositions () {
|
||||
listPost({ pageNum: 1, pageSize: 500 }).then(res => {
|
||||
this.positions = res.rows || res.data || []
|
||||
})
|
||||
},
|
||||
formatPositionLabel (pos) {
|
||||
const name = pos.postName || pos.postCode || ''
|
||||
const code = pos.postCode ? `(${pos.postCode})` : ''
|
||||
return `${name}${code}`.trim() || `ID:${pos.postId}`
|
||||
},
|
||||
formatEmpLabel (emp) {
|
||||
const name = emp.empName || emp.nickName || emp.userName || ''
|
||||
const no = emp.empNo ? ` · ${emp.empNo}` : ''
|
||||
const dept = emp.deptName ? ` · ${emp.deptName}` : ''
|
||||
return `${name || '员工'}${no}${dept}`.trim()
|
||||
},
|
||||
bizTypeText (val) {
|
||||
const map = { leave: '请假', travel: '出差', seal: '用印', reimburse: '报销', appropriation: '拨款' }
|
||||
return map[val] || val || '-'
|
||||
},
|
||||
formatTplLabel (tpl) {
|
||||
const name = tpl.tplName || tpl.tplCode || '模板'
|
||||
const biz = this.bizTypeText(tpl.bizType)
|
||||
return `${name}(${biz} · v${tpl.version || 1})`
|
||||
},
|
||||
nodeTypeText (val) {
|
||||
const map = { approve: '审批', cc: '抄送', end: '结束' }
|
||||
// 兼容旧数据,但不再显示为"抄送节点"
|
||||
if (val === 'cc') return '审批(已废弃抄送节点)'
|
||||
return map[val] || val || '-'
|
||||
},
|
||||
approverRuleText (val) {
|
||||
const map = {
|
||||
fixed_user: '固定人员',
|
||||
role: '角色',
|
||||
position: '岗位',
|
||||
dept_leader: '部门负责人',
|
||||
leader: '部门负责人', // 兼容旧数据
|
||||
initiator: '发起人',
|
||||
form_field: '表单字段'
|
||||
}
|
||||
return map[val] || val || '-'
|
||||
},
|
||||
nodeDesc (n) {
|
||||
const t = this.approverRuleText(n.approverRule)
|
||||
const v = this.approverValueReadable(n)
|
||||
return v ? `${t} · ${v}` : t
|
||||
},
|
||||
approverValueReadable (n) {
|
||||
if (!n || !n.approverRule) return ''
|
||||
try {
|
||||
const arr = typeof n.approverValue === 'string' ? JSON.parse(n.approverValue || '[]') : n.approverValue
|
||||
if (Array.isArray(arr) && arr.length) return arr.join(',')
|
||||
} catch (e) {
|
||||
if (n.approverValue) return String(n.approverValue)
|
||||
}
|
||||
return ''
|
||||
},
|
||||
|
||||
loadTemplates () {
|
||||
listFlowTemplate({ pageNum: 1, pageSize: 200 }).then(res => {
|
||||
this.templateOptions = res.rows || []
|
||||
})
|
||||
},
|
||||
handleTplChange () {
|
||||
this.currentTpl = this.templateOptions.find(t => t.tplId === this.query.tplId) || null
|
||||
this.loadList()
|
||||
},
|
||||
loadList () {
|
||||
if (!this.query.tplId) {
|
||||
this.list = []
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.query.tplId })
|
||||
.then(res => {
|
||||
this.list = res.rows || []
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
|
||||
openDrawer (row) {
|
||||
this.drawerTitle = row ? '编辑节点' : '新增节点'
|
||||
const nextOrder = (this.sortedList[this.sortedList.length - 1]?.orderNo || 0) + 1
|
||||
this.form = row
|
||||
? { ...row }
|
||||
: {
|
||||
tplId: this.query.tplId,
|
||||
orderNo: nextOrder,
|
||||
nodeType: 'approve',
|
||||
approverRule: 'leader',
|
||||
approverValue: '',
|
||||
remark: ''
|
||||
}
|
||||
|
||||
// 清理选择器状态
|
||||
this.selectedFixedUsers = []
|
||||
this.selectedRoles = []
|
||||
this.selectedPositions = []
|
||||
|
||||
// 编辑态把 approverValue 解析回可编辑文本/选择
|
||||
if (row && row.approverValue) {
|
||||
try {
|
||||
const arr = JSON.parse(row.approverValue)
|
||||
if (Array.isArray(arr)) {
|
||||
if (row.approverRule === 'fixed_user') this.selectedFixedUsers = arr.map(v => String(v))
|
||||
if (row.approverRule === 'role') this.selectedRoles = arr
|
||||
if (row.approverRule === 'position') this.selectedPositions = arr.map(v => String(v))
|
||||
this.form.approverValue = arr.join(',')
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
this.drawerVisible = true
|
||||
this.$nextTick(() => this.$refs.formRef && this.$refs.formRef.clearValidate())
|
||||
},
|
||||
|
||||
needsValue (rule) {
|
||||
return ['fixed_user', 'role', 'position', 'form_field'].includes(rule)
|
||||
},
|
||||
valuePlaceholder (rule) {
|
||||
const map = {
|
||||
form_field: '输入表单字段名(如 managerId)'
|
||||
}
|
||||
return map[rule] || '填写规则值'
|
||||
},
|
||||
valueHint (rule) {
|
||||
const map = {
|
||||
form_field: '示例:managerId(表单字段名,字段值将作为审批人ID)。'
|
||||
}
|
||||
return map[rule] || ''
|
||||
},
|
||||
handleRuleChange () {
|
||||
// 切换规则时清理值与选择
|
||||
this.form.approverValue = ''
|
||||
this.selectedFixedUsers = []
|
||||
this.selectedRoles = []
|
||||
this.selectedPositions = []
|
||||
},
|
||||
|
||||
syncFixedUserValue () {
|
||||
this.form.approverValue = this.selectedFixedUsers.join(',')
|
||||
},
|
||||
syncRoleValue () {
|
||||
this.form.approverValue = this.selectedRoles.join(',')
|
||||
},
|
||||
syncPositionValue () {
|
||||
this.form.approverValue = this.selectedPositions.join(',')
|
||||
},
|
||||
|
||||
submit () {
|
||||
this.$refs.formRef.validate(valid => {
|
||||
if (!valid) return
|
||||
this.submitting = true
|
||||
const api = this.form.nodeId ? updateFlowNode : addFlowNode
|
||||
const payload = { ...this.form }
|
||||
|
||||
// approver_value 列是 JSON(数组),统一处理
|
||||
if (['fixed_user', 'role', 'position'].includes(payload.approverRule)) {
|
||||
const arr = (payload.approverValue || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
payload.approverValue = JSON.stringify(arr)
|
||||
} else if (payload.approverRule === 'form_field') {
|
||||
payload.approverValue = JSON.stringify([(payload.approverValue || '').trim()].filter(Boolean))
|
||||
} else {
|
||||
payload.approverValue = JSON.stringify([])
|
||||
}
|
||||
|
||||
api(payload)
|
||||
.then(() => {
|
||||
this.$message.success('已保存')
|
||||
this.drawerVisible = false
|
||||
this.loadList()
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
delRow (row) {
|
||||
this.$confirm('确认删除该节点吗?', '提示', { type: 'warning' }).then(() => {
|
||||
delFlowNode(row.nodeId).then(() => {
|
||||
this.$message.success('已删除')
|
||||
this.loadList()
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
canMoveUp (n) {
|
||||
const idx = this.sortedList.findIndex(i => i.nodeId === n.nodeId)
|
||||
return idx > 0
|
||||
},
|
||||
canMoveDown (n) {
|
||||
const idx = this.sortedList.findIndex(i => i.nodeId === n.nodeId)
|
||||
return idx >= 0 && idx < this.sortedList.length - 1
|
||||
},
|
||||
moveUp (n) {
|
||||
const idx = this.sortedList.findIndex(i => i.nodeId === n.nodeId)
|
||||
if (idx <= 0) return
|
||||
const prev = this.sortedList[idx - 1]
|
||||
this.swapOrder(n, prev)
|
||||
},
|
||||
moveDown (n) {
|
||||
const idx = this.sortedList.findIndex(i => i.nodeId === n.nodeId)
|
||||
if (idx < 0 || idx >= this.sortedList.length - 1) return
|
||||
const next = this.sortedList[idx + 1]
|
||||
this.swapOrder(n, next)
|
||||
},
|
||||
swapOrder (a, b) {
|
||||
const aOrder = a.orderNo
|
||||
const bOrder = b.orderNo
|
||||
const updateA = { ...a, orderNo: bOrder }
|
||||
const updateB = { ...b, orderNo: aOrder }
|
||||
this.loading = true
|
||||
Promise.all([updateFlowNode(updateA), updateFlowNode(updateB)])
|
||||
.then(() => {
|
||||
this.$message.success('顺序已调整')
|
||||
this.loadList()
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hrm-page {
|
||||
padding: 16px 20px 32px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.flow-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metal-panel {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-title .sub {
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selector {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tpl-summary {
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
|
||||
}
|
||||
|
||||
.tpl-name {
|
||||
font-weight: 900;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.tpl-sub {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #8a8f99;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mt6 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 18px 14px;
|
||||
border: 1px dashed #e6e8ed;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.p-title {
|
||||
font-weight: 900;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.p-sub {
|
||||
margin-top: 6px;
|
||||
color: #8a8f99;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.node-cards {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-weight: 900;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.node-sub {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.node-body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px;
|
||||
color: #8a8f99;
|
||||
border: 1px dashed #e6e8ed;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 10px 0 8px;
|
||||
padding-left: 10px;
|
||||
font-weight: 800;
|
||||
color: #2f3440;
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rule-explanation {
|
||||
padding: 12px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.explanation-title {
|
||||
font-weight: 600;
|
||||
color: #2b2f36;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.explanation-content {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.explanation-content p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.explanation-content ol {
|
||||
margin: 8px 0 8px 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.explanation-content li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.flow-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
736
ruoyi-ui/src/views/hrm/flow/task.vue
Normal file
736
ruoyi-ui/src/views/hrm/flow/task.vue
Normal file
@@ -0,0 +1,736 @@
|
||||
<template>
|
||||
<div class="hrm-page">
|
||||
<div class="flow-task-layout">
|
||||
<!-- 左:任务列表 -->
|
||||
<el-card class="metal-panel left" shadow="hover">
|
||||
<div slot="header" class="panel-header">
|
||||
<div class="header-title">
|
||||
<span>流程任务</span>
|
||||
<span class="sub">面向办理人:待办优先,可快速审批</span>
|
||||
</div>
|
||||
<div class="actions-inline">
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="fetchList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-radio-group v-model="mode" size="small" @change="fetchList">
|
||||
<el-radio-button label="todo">我的待办</el-radio-button>
|
||||
<el-radio-button label="mine">我发起的</el-radio-button>
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select v-model="query.status" placeholder="状态" clearable size="small" style="width: 150px"
|
||||
@change="fetchList">
|
||||
<el-option label="待办" value="pending" />
|
||||
<el-option label="通过" value="done" />
|
||||
<el-option label="驳回" value="rejected" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" height="680" stripe highlight-current-row @row-click="openDetail">
|
||||
<el-table-column label="状态" min-width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusText(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" type="info">{{ bizTypeText(scope.row.bizType) }}</el-tag>
|
||||
<span class="muted" v-if="scope.row.bizId"> #{{ scope.row.bizId }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column label="节点" min-width="110">
|
||||
<template slot-scope="scope">
|
||||
<span class="pill">{{ scope.row.nodeId }}</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
|
||||
<!-- <el-table-column label="到期" min-width="150">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.expireTime) }}</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column label="备注" prop="remark" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="160" fixed="right" v-if="mode === 'todo'">
|
||||
<template slot-scope="scope">
|
||||
<el-dropdown v-if="scope.row.status === 'pending'" @command="cmd => openAction(scope.row, cmd)">
|
||||
<span class="el-dropdown-link">
|
||||
快捷办理<i class="el-icon-arrow-down el-icon--right"></i>
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item command="approve">通过</el-dropdown-item>
|
||||
<el-dropdown-item command="reject">驳回</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 右:详情区 -->
|
||||
<el-card class="metal-panel right" shadow="hover">
|
||||
<div slot="header" class="panel-header">
|
||||
<span>任务详情</span>
|
||||
<div class="actions-inline">
|
||||
<el-button size="mini" icon="el-icon-document-copy" :disabled="!detailTask"
|
||||
@click="copyTaskInfo">复制关键信息</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!detailTask" class="placeholder">
|
||||
<div class="p-title">请在左侧选择一条任务</div>
|
||||
<div class="p-sub">将展示业务信息、表单字段、流转历史,并可发起审批动作</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-wrap">
|
||||
<div class="detail-summary">
|
||||
<div class="ds-left">
|
||||
<div class="ds-title">{{ bizTypeText(detailTask.bizType) }} · 任务 #{{ detailTask.taskId }}</div>
|
||||
<div class="ds-sub">
|
||||
<el-tag size="mini" :type="statusType(detailTask.status)">{{ statusText(detailTask.status) }}</el-tag>
|
||||
<span class="muted">实例 {{ detailTask.instId }} · 节点 {{ detailTask.nodeId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-right">
|
||||
<div class="ds-item">
|
||||
<div class="k">办理人</div>
|
||||
<div class="v">{{ formatUser(detailTask.assigneeUserId, 'userId') }}</div>
|
||||
</div>
|
||||
<div class="ds-item">
|
||||
<div class="k">到期</div>
|
||||
<div class="v">{{ formatDate(detailTask.expireTime) || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-tabs value="form">
|
||||
<el-tab-pane label="表单数据" name="form">
|
||||
<!-- 业务详情:内嵌四大申请 Detail 页面(嵌入模式) -->
|
||||
<LeaveDetail v-if="detailTask && detailTask.bizType === 'leave'" :biz-id="detailTask.bizId"
|
||||
:embedded="true" />
|
||||
<TravelDetail v-else-if="detailTask && detailTask.bizType === 'travel'" :biz-id="detailTask.bizId"
|
||||
:embedded="true" />
|
||||
<SealDetail v-else-if="detailTask && detailTask.bizType === 'seal'" :biz-id="detailTask.bizId"
|
||||
:embedded="true" />
|
||||
<ReimburseDetail v-else-if="detailTask && detailTask.bizType === 'reimburse'" :biz-id="detailTask.bizId"
|
||||
:embedded="true" />
|
||||
|
||||
<!-- 兜底:如果不是四大申请,仍展示字段列表 -->
|
||||
<div v-else>
|
||||
<el-table :data="formData" v-loading="formLoading" size="mini" height="260">
|
||||
<el-table-column label="字段" prop="fieldName" min-width="140" />
|
||||
<el-table-column label="展示" prop="fieldLabel" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="值" prop="fieldValue" min-width="220" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div class="hint-text">提示:展示字段优先给用户看;值字段用于技术排查</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="流转历史" name="history">
|
||||
<el-timeline v-loading="actionLoading" v-if="actionList.length">
|
||||
<el-timeline-item v-for="(a, idx) in actionList" :key="idx" :timestamp="formatDate(a.createTime)"
|
||||
:type="actionTagType(a.action)">
|
||||
<div class="timeline-row">
|
||||
<div class="t-main">
|
||||
<span class="t-action">{{ actionText(a.action) }}</span>
|
||||
<span class="t-user">· 办理人:{{ formatUser(a.createBy, 'createBy') }}</span>
|
||||
</div>
|
||||
<div class="t-remark" v-if="a.remark">{{ a.remark }}</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else class="empty">暂无流转记录</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="block-title">办理动作</div>
|
||||
<div class="hint-text">建议填写简短意见;用印业务支持在"通过"时附带盖章参数。</div>
|
||||
<div class="btn-row">
|
||||
<el-button type="success" :disabled="detailTask.status !== 'pending'" :loading="actionSubmitting"
|
||||
@click="openAction(detailTask, 'approve')">通过</el-button>
|
||||
<el-button type="danger" :disabled="detailTask.status !== 'pending'" :loading="actionSubmitting"
|
||||
@click="openAction(detailTask, 'reject')">驳回</el-button>
|
||||
<el-button type="info" :disabled="!detailTask" @click="openCcDialog">抄送</el-button>
|
||||
<el-button type="info" :disabled="!detailTask || detailTask.status !== 'pending'"
|
||||
@click="openForwardDialog">转发</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 通用动作弹窗(驳回等) -->
|
||||
<el-dialog :title="actionDialogTitle" :visible.sync="actionDialogVisible" width="720px" append-to-body>
|
||||
<el-form :model="actionForm" label-width="110px" size="small">
|
||||
<el-form-item label="意见">
|
||||
<el-input v-model="actionForm.remark" type="textarea" :rows="3" placeholder="可选:请输入办理意见" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="actionDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionSubmitting" @click="submitAction">提交</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 用印盖章弹窗 -->
|
||||
<el-dialog title="审批通过并盖章" :visible.sync="stampDialogVisible" width="1200px" append-to-body>
|
||||
<div class="stamp-dialog-body">
|
||||
<div class="stamp-form">
|
||||
<el-form :model="actionForm" label-width="120px" size="small">
|
||||
<el-form-item label="审批意见">
|
||||
<el-input v-model="actionForm.remark" type="textarea" :rows="2" placeholder="可选:请输入办理意见" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择印章" required>
|
||||
<el-select v-model="actionForm.stampBo.stampImageUrl" placeholder="选择印章" filterable style="width: 100%">
|
||||
<el-option v-for="dict in dict.type.hrm_stamp_image" :key="dict.value" :label="dict.label"
|
||||
:value="dict.value">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<img v-if="dict.value" :src="dict.value"
|
||||
style="width: 40px; height: 40px; object-fit: contain; border: 1px solid #e6e8ed;"
|
||||
onerror="this.style.display='none'" />
|
||||
<span>{{ dict.label }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div class="hint-text">从字典 hrm_stamp_image 加载印章列表(label为章名,value为URL)</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="印章图片URL"
|
||||
v-if="!actionForm.stampBo.stampImageUrl || !dict.type.hrm_stamp_image || dict.type.hrm_stamp_image.length === 0">
|
||||
<el-input v-model="actionForm.stampBo.stampImageUrl" placeholder="手动输入印章图片的完整OSS URL" />
|
||||
<div class="hint-text">如果下拉列表中没有印章,可手动输入印章图片的完整URL</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="stamp-preview">
|
||||
<div v-if="stampTargetPdfUrl" class="pdf-stamper-wrapper">
|
||||
<PdfStamper :pdf-url="stampTargetPdfUrl" :initial-page="actionForm.stampBo.pageNo || 1"
|
||||
@change="onStampChange" />
|
||||
</div>
|
||||
<div v-else class="empty">PDF文件加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="stampDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionSubmitting"
|
||||
:disabled="!actionForm.stampBo.stampImageUrl || !actionForm.stampBo.ready"
|
||||
@click="submitStampAndApprove">确认盖章并通过</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 抄送弹窗 -->
|
||||
<el-dialog title="抄送" :visible.sync="ccDialogVisible" width="720px" append-to-body>
|
||||
<el-form :model="ccForm" label-width="90px" size="small">
|
||||
<el-form-item label="抄送人" required>
|
||||
<el-button size="mini" type="primary" plain icon="el-icon-user"
|
||||
@click="openUserMultiSelect">选择抄送人</el-button>
|
||||
<div class="selected-users" v-if="ccForm.selectedUsers && ccForm.selectedUsers.length">
|
||||
<el-tag v-for="u in ccForm.selectedUsers" :key="u.userId" size="mini" closable @close="removeCcUser(u)">
|
||||
{{ u.nickName || u.userName || ('ID:' + u.userId) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="muted" style="margin-top:6px;">未选择抄送人</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="ccForm.remark" type="textarea" :rows="3" placeholder="可选:抄送说明" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="ccDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionSubmitting" @click="submitCc">确定抄送</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 抄送/转发用户选择组件 -->
|
||||
<UserSingleSelect ref="userSingleSelect" @onSelected="onForwardUserSelected" />
|
||||
<UserMultiSelect ref="userMultiSelect" @onSelected="onCcUsersSelected" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { approveFlowTask, ccFlowTask, getTodoTaskByBiz, listFlowAction, listFlowFormData, listFlowTask, listTodoFlowTask, rejectFlowTask, transferFlowTask } from '@/api/hrm/flow'
|
||||
import { listByIds } from '@/api/system/oss'
|
||||
import { listUser } from '@/api/system/user'
|
||||
import PdfStamper from '@/components/PdfStamper/index.vue'
|
||||
import UserMultiSelect from '@/components/UserSelect/multi.vue'
|
||||
import UserSingleSelect from '@/components/UserSelect/single.vue'
|
||||
|
||||
// 四大申请详情页(嵌入模式)
|
||||
import LeaveDetail from '@/views/hrm/requests/leaveDetail.vue'
|
||||
import ReimburseDetail from '@/views/hrm/requests/reimburseDetail.vue'
|
||||
import SealDetail from '@/views/hrm/requests/sealDetail.vue'
|
||||
import TravelDetail from '@/views/hrm/requests/travelDetail.vue'
|
||||
|
||||
export default {
|
||||
name: 'HrmFlowTask',
|
||||
dicts: ['hrm_stamp_image'],
|
||||
components: { UserSingleSelect, UserMultiSelect, PdfStamper, LeaveDetail, TravelDetail, SealDetail, ReimburseDetail },
|
||||
data () {
|
||||
return {
|
||||
mode: 'todo',
|
||||
query: { status: undefined, pageNum: 1, pageSize: 50 },
|
||||
list: [],
|
||||
loading: false,
|
||||
detailTask: null,
|
||||
actionList: [],
|
||||
actionLoading: false,
|
||||
formData: [],
|
||||
formLoading: false,
|
||||
actionDialogVisible: false,
|
||||
actionDialogTitle: '',
|
||||
actionType: '',
|
||||
actionSubmitting: false,
|
||||
actionForm: {
|
||||
remark: '',
|
||||
stampBo: {
|
||||
stampImageUrl: '',
|
||||
pageNo: 1,
|
||||
xPx: 0,
|
||||
yPx: 0,
|
||||
widthPx: null,
|
||||
heightPx: null
|
||||
}
|
||||
},
|
||||
stampDialogVisible: false,
|
||||
stampTargetPdfUrl: '',
|
||||
ccDialogVisible: false,
|
||||
forwardDialogVisible: false,
|
||||
ccForm: { selectedUsers: [], remark: '' },
|
||||
forwardForm: { selectedUser: null, remark: '' },
|
||||
allUsers: []
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.loadAllUsers()
|
||||
this.fetchList()
|
||||
},
|
||||
methods: {
|
||||
bizTypeText (val) { const map = { leave: '请假', travel: '出差', seal: '用印', payroll: '薪酬', reimburse: '报销' }; return map[val] || val || '-' },
|
||||
statusText (status) { const map = { pending: '待办', done: '已通过', approved: '已通过', rejected: '已驳回', withdrawn: '已撤回' }; return map[status] || status || '-' },
|
||||
statusType (status) { const map = { pending: 'warning', done: 'success', approved: 'success', rejected: 'danger', withdrawn: 'info' }; return map[status] || 'info' },
|
||||
formatDate (val) { if (!val) return ''; const d = new Date(val); const p = n => (n < 10 ? `0${n}` : n); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` },
|
||||
actionText (action) { const map = { submit: '提交', approve: '通过', reject: '驳回', withdraw: '撤回', cancel: '撤销', stamp: '盖章', transfer: '转发' }; return map[action] || action || '-' },
|
||||
actionTagType (action) { const map = { submit: 'primary', approve: 'success', reject: 'danger', withdraw: 'info', cancel: 'info', stamp: 'primary', transfer: 'warning' }; return map[action] || 'info' },
|
||||
async loadAllUsers () { try { const res = await listUser({ pageNum: 1, pageSize: 1000 }); this.allUsers = res.rows || [] } catch (e) { this.allUsers = [] } },
|
||||
formatUser (userId, fieldName) { if (!userId) return '-'; const user = this.allUsers.find(u => u[fieldName] === userId); return user ? `${user.nickName || user.userName}` : `ID:${userId}` },
|
||||
fetchList () {
|
||||
this.loading = true
|
||||
const userId = this.$store?.state?.user?.id
|
||||
const userName = this.$store?.state?.user?.name
|
||||
const params = { ...this.query }
|
||||
let req
|
||||
if (this.mode === 'todo' && userId) {
|
||||
req = listTodoFlowTask(userId)
|
||||
} else if (this.mode === 'mine' && userId && userName) {
|
||||
req = listFlowTask({ ...params, createBy: userName })
|
||||
} else {
|
||||
req = listFlowTask(params)
|
||||
}
|
||||
req.then(res => {
|
||||
this.list = res.data || res.rows || []
|
||||
if (!this.detailTask && this.list.length) this.openDetail(this.list[0])
|
||||
}).finally(() => { this.loading = false })
|
||||
},
|
||||
async openDetail (row) {
|
||||
if (!row) return
|
||||
|
||||
// 先用列表行渲染一份,避免右侧空白
|
||||
this.detailTask = row
|
||||
this.loadActions(row)
|
||||
this.loadFormData(row)
|
||||
|
||||
// 再按新接口拉取“按业务维度的当前待办详情”(用于字段对齐/按钮状态)
|
||||
const bizType = row.bizType
|
||||
const bizId = row.bizId
|
||||
if (!bizType || !bizId) return
|
||||
|
||||
try {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
const res = await getTodoTaskByBiz(bizType, bizId, userId)
|
||||
const task = res && res.data
|
||||
// 若当前不是待办,后端可能返回 null,这里不强制报错
|
||||
if (task) {
|
||||
this.detailTask = task
|
||||
this.loadActions(task)
|
||||
this.loadFormData(task)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore:可能不是待办/无权限
|
||||
}
|
||||
},
|
||||
loadActions (row) { if (!row || !row.instId) return; this.actionLoading = true; listFlowAction({ instId: row.instId, pageNum: 1, pageSize: 200 }).then(res => { this.actionList = res.rows || [] }).finally(() => { this.actionLoading = false }) },
|
||||
loadFormData (row) { if (!row || !row.instId) return; this.formLoading = true; listFlowFormData({ instId: row.instId, pageNum: 1, pageSize: 200 }).then(res => { this.formData = res.rows || [] }).finally(() => { this.formLoading = false }) },
|
||||
openAction (row, type) {
|
||||
this.detailTask = row
|
||||
this.actionType = type
|
||||
// 用印审批“通过”走专用弹窗
|
||||
if (type === 'approve' && row.bizType === 'seal') {
|
||||
this.openStampDialog(row)
|
||||
return
|
||||
}
|
||||
const titleMap = { approve: '审批通过', reject: '驳回' }
|
||||
this.actionDialogTitle = `${titleMap[type] || '操作'} · 任务 #${row.taskId}`
|
||||
this.actionForm = { remark: '', stampBo: {} }
|
||||
this.actionDialogVisible = true
|
||||
},
|
||||
submitAction () {
|
||||
if (!this.detailTask || !this.actionType) return
|
||||
this.actionSubmitting = true
|
||||
const apiMap = { approve: approveFlowTask, reject: rejectFlowTask }
|
||||
apiMap[this.actionType](this.detailTask.taskId, { remark: this.actionForm.remark })
|
||||
.then(() => {
|
||||
this.$message.success('操作成功')
|
||||
this.actionDialogVisible = false
|
||||
this.fetchList()
|
||||
this.loadActions(this.detailTask)
|
||||
})
|
||||
.finally(() => { this.actionSubmitting = false })
|
||||
},
|
||||
async openStampDialog (task) {
|
||||
if (!task.bizData || !task.bizData.applyFileIds) {
|
||||
return this.$message.error('用印申请未关联待盖章PDF文件')
|
||||
}
|
||||
|
||||
// 从 applyFileIds (ossId) 解析为可访问的 URL
|
||||
const fileIds = task.bizData.applyFileIds
|
||||
const ids = String(fileIds).split(',').map(id => id.trim()).filter(Boolean)
|
||||
if (ids.length === 0) {
|
||||
return this.$message.error('未找到待盖章PDF文件')
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await listByIds(ids)
|
||||
const files = res.data || []
|
||||
// 取第一个PDF文件
|
||||
const pdfFile = files.find(f => f.fileName && f.fileName.toLowerCase().endsWith('.pdf')) || files[0]
|
||||
if (!pdfFile || !pdfFile.url) {
|
||||
return this.$message.error('PDF文件URL不存在')
|
||||
}
|
||||
|
||||
this.stampTargetPdfUrl = pdfFile.url
|
||||
|
||||
// 从备注中解析pageNo(格式:[盖章页码:第X页])
|
||||
let pageNo = 1
|
||||
if (task.bizData.pageNo) {
|
||||
pageNo = task.bizData.pageNo
|
||||
} else if (task.bizData.remark) {
|
||||
const match = task.bizData.remark.match(/\[盖章页码:第(\d+)页\]/)
|
||||
if (match) {
|
||||
pageNo = parseInt(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有申请时的用印类型,尝试匹配对应的印章
|
||||
let defaultStampUrl = ''
|
||||
const sealType = task.bizData.sealType
|
||||
if (sealType && this.dict.type.hrm_stamp_image && this.dict.type.hrm_stamp_image.length > 0) {
|
||||
const matched = this.dict.type.hrm_stamp_image.find(d => d.label === sealType || d.label.includes(sealType))
|
||||
if (matched) {
|
||||
defaultStampUrl = matched.value
|
||||
}
|
||||
}
|
||||
|
||||
this.actionForm = {
|
||||
remark: '',
|
||||
stampBo: {
|
||||
stampImageUrl: defaultStampUrl,
|
||||
pageNo: pageNo,
|
||||
xPx: 0,
|
||||
yPx: 0,
|
||||
widthPx: null,
|
||||
heightPx: null,
|
||||
viewportWidth: null,
|
||||
viewportHeight: null,
|
||||
ready: false
|
||||
}
|
||||
}
|
||||
this.stampDialogVisible = true
|
||||
} catch (error) {
|
||||
this.$message.error('加载PDF文件失败:' + (error.message || '未知错误'))
|
||||
}
|
||||
},
|
||||
onStampChange (params) {
|
||||
if (params) {
|
||||
this.actionForm.stampBo.pageNo = params.pageNo
|
||||
this.actionForm.stampBo.xPx = params.xPx
|
||||
this.actionForm.stampBo.yPx = params.yPx
|
||||
this.actionForm.stampBo.widthPx = params.widthPx
|
||||
this.actionForm.stampBo.heightPx = params.heightPx
|
||||
this.actionForm.stampBo.viewportWidth = params.viewportWidth
|
||||
this.actionForm.stampBo.viewportHeight = params.viewportHeight
|
||||
this.actionForm.stampBo.ready = params.ready || false
|
||||
}
|
||||
},
|
||||
submitStampAndApprove () {
|
||||
if (!this.detailTask) return
|
||||
if (!this.actionForm.stampBo.stampImageUrl) {
|
||||
return this.$message.warning('请选择或输入印章图片URL')
|
||||
}
|
||||
if (!this.actionForm.stampBo.ready) {
|
||||
return this.$message.warning('请在PDF上点击选择盖章位置')
|
||||
}
|
||||
if (this.actionForm.stampBo.xPx === null || this.actionForm.stampBo.yPx === null) {
|
||||
return this.$message.warning('请选择盖章位置坐标')
|
||||
}
|
||||
|
||||
this.actionSubmitting = true
|
||||
const stampBo = {
|
||||
targetFileUrl: this.stampTargetPdfUrl,
|
||||
stampImageUrl: this.actionForm.stampBo.stampImageUrl,
|
||||
pageNo: this.actionForm.stampBo.pageNo,
|
||||
xPx: this.actionForm.stampBo.xPx,
|
||||
yPx: this.actionForm.stampBo.yPx,
|
||||
viewportWidth: this.actionForm.stampBo.viewportWidth,
|
||||
viewportHeight: this.actionForm.stampBo.viewportHeight
|
||||
}
|
||||
if (this.actionForm.stampBo.widthPx) {
|
||||
stampBo.widthPx = this.actionForm.stampBo.widthPx
|
||||
}
|
||||
if (this.actionForm.stampBo.heightPx) {
|
||||
stampBo.heightPx = this.actionForm.stampBo.heightPx
|
||||
}
|
||||
|
||||
approveFlowTask(this.detailTask.taskId, {
|
||||
remark: this.actionForm.remark,
|
||||
stampBo: stampBo
|
||||
})
|
||||
.then(() => {
|
||||
this.$message.success('盖章并通过成功')
|
||||
this.stampDialogVisible = false
|
||||
this.fetchList()
|
||||
this.loadActions(this.detailTask)
|
||||
})
|
||||
.catch(error => {
|
||||
this.$message.error(error.message || '盖章并通过失败')
|
||||
})
|
||||
.finally(() => { this.actionSubmitting = false })
|
||||
},
|
||||
copyTaskInfo () { if (!this.detailTask) return; const t = this.detailTask; const text = `任务ID:${t.taskId}\n实例:${t.instId}\n业务:${t.bizType}${t.bizId ? `#${t.bizId}` : ''}\n节点:${t.nodeId}\n状态:${t.status}`; navigator.clipboard.writeText(text).then(() => this.$message.success('已复制')) },
|
||||
openCcDialog () { if (!this.detailTask) return; this.ccForm = { selectedUsers: [], remark: '' }; this.ccDialogVisible = true },
|
||||
openUserMultiSelect () { this.$refs.userMultiSelect.open() },
|
||||
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
|
||||
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
|
||||
openForwardDialog () { if (!this.detailTask || this.detailTask.status !== 'pending') return; this.forwardForm = { selectedUser: null, remark: '' }; this.forwardDialogVisible = true },
|
||||
openUserSingleSelect () { this.$refs.userSingleSelect.open() },
|
||||
onForwardUserSelected (user) { this.forwardForm.selectedUser = user || null },
|
||||
submitCc () {
|
||||
const userIds = this.ccForm.selectedUsers.map(u => u.userId)
|
||||
if (!this.detailTask || userIds.length === 0) return this.$message.warning('请选择至少一个抄送人')
|
||||
const fromUserId = this.$store?.state?.user?.id
|
||||
ccFlowTask({ instId: this.detailTask.instId, bizType: this.detailTask.bizType, bizId: this.detailTask.bizId, nodeId: this.detailTask.nodeId, nodeName: `节点#${this.detailTask.nodeId}`, fromUserId, ccUserIds: userIds, readFlag: 0, remark: this.ccForm.remark || '手动抄送' })
|
||||
.then(() => { this.$message.success('抄送成功'); this.ccDialogVisible = false })
|
||||
.catch(() => { this.$message.error('抄送失败') })
|
||||
},
|
||||
submitForward () {
|
||||
if (!this.detailTask || !this.forwardForm.selectedUser) return this.$message.warning('请选择转发对象')
|
||||
const newAssigneeUserId = this.forwardForm.selectedUser.userId
|
||||
transferFlowTask(this.detailTask.taskId, { newAssigneeUserId, remark: this.forwardForm.remark })
|
||||
.then(() => { this.$message.success('转发成功'); this.forwardDialogVisible = false; this.fetchList() })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hrm-page {
|
||||
padding: 16px 20px 32px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.flow-task-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 520px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metal-panel {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-title .sub {
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #8a8f99;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #f4f5f7;
|
||||
border-radius: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 18px 14px;
|
||||
border: 1px dashed #e6e8ed;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.p-title {
|
||||
font-weight: 900;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.p-sub {
|
||||
margin-top: 6px;
|
||||
color: #8a8f99;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-wrap {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
|
||||
}
|
||||
|
||||
.ds-title {
|
||||
font-weight: 900;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.ds-sub {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ds-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ds-item .k {
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.ds-item .v {
|
||||
margin-top: 2px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 12px 0 8px;
|
||||
padding-left: 10px;
|
||||
font-weight: 800;
|
||||
color: #2f3440;
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.timeline-row .t-main {
|
||||
font-weight: 600;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.timeline-row .t-remark {
|
||||
margin-top: 4px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.selected-users {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stamp-dialog-body {
|
||||
display: grid;
|
||||
grid-template-columns: 380px 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stamp-form {
|
||||
padding: 12px;
|
||||
background: #fafbfc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stamp-preview {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pdf-stamper-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stamp-dialog-body .hint-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.flow-task-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
431
ruoyi-ui/src/views/hrm/flow/template.vue
Normal file
431
ruoyi-ui/src/views/hrm/flow/template.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<div class="hrm-page">
|
||||
<div class="flow-layout">
|
||||
<!-- 左侧:模板列表 -->
|
||||
<el-card class="metal-panel left" shadow="hover">
|
||||
<div slot="header" class="panel-header">
|
||||
<div class="header-title">
|
||||
<span>流程模板</span>
|
||||
<span class="sub">先选业务 → 再选模板</span>
|
||||
</div>
|
||||
<div class="actions-inline">
|
||||
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openEditDrawer()">新建模板</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="query.bizType" size="small" placeholder="业务类型" clearable style="width: 160px"
|
||||
@change="loadList">
|
||||
<el-option label="请假" value="leave" />
|
||||
<el-option label="出差" value="travel" />
|
||||
<el-option label="用印" value="seal" />
|
||||
<el-option label="日常报销" value="reimburse" />
|
||||
<el-option label="拨款" value="appropriation" />
|
||||
</el-select>
|
||||
<el-input v-model="query.tplName" size="small" placeholder="搜索模板名称/编码" clearable
|
||||
@keyup.enter.native="loadList" />
|
||||
<el-button size="small" icon="el-icon-search" @click="loadList">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="template-list" v-loading="loading">
|
||||
<div v-for="tpl in list" :key="tpl.tplId" class="tpl-item"
|
||||
:class="{ active: selectedTpl && selectedTpl.tplId === tpl.tplId }" @click="selectTpl(tpl)">
|
||||
<div class="tpl-main">
|
||||
<div class="tpl-name">{{ tpl.tplName || '-' }}</div>
|
||||
<div class="tpl-meta">
|
||||
<el-tag size="mini" type="info">{{ bizTypeText(tpl.bizType) }}</el-tag>
|
||||
<el-tag size="mini" :type="tpl.enabled ? 'success' : 'info'">{{ tpl.enabled ? '启用' : '停用' }}</el-tag>
|
||||
<span class="muted">v{{ tpl.version || 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tpl-sub">
|
||||
<span class="muted">编码:{{ tpl.tplCode || '-' }}</span>
|
||||
</div>
|
||||
<div class="tpl-actions">
|
||||
<el-button size="mini" type="text" @click.stop="openEditDrawer(tpl)">编辑</el-button>
|
||||
<el-button size="mini" type="text" @click.stop="goNodeConfig(tpl)">节点配置</el-button>
|
||||
<el-button size="mini" type="text" class="danger" @click.stop="delRow(tpl)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && list.length === 0" class="empty">
|
||||
暂无模板,请先新建。
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 右侧:模板详情预览(面向用户可读) -->
|
||||
<el-card class="metal-panel right" shadow="hover">
|
||||
<div slot="header" class="panel-header">
|
||||
<span>模板预览</span>
|
||||
<div class="actions-inline">
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="loadList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedTpl" class="placeholder">
|
||||
<div class="p-title">请选择左侧一个模板</div>
|
||||
<div class="p-sub">选择后可查看关键信息,并进入节点配置完善流程</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="preview">
|
||||
<div class="preview-title">{{ selectedTpl.tplName }}</div>
|
||||
<div class="preview-sub">
|
||||
<el-tag size="mini" type="info">{{ bizTypeText(selectedTpl.bizType) }}</el-tag>
|
||||
<el-tag size="mini" :type="selectedTpl.enabled ? 'success' : 'info'">{{ selectedTpl.enabled ? '启用' : '停用'
|
||||
}}</el-tag>
|
||||
<span class="muted">版本 v{{ selectedTpl.version || 1 }}</span>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small" class="mt12">
|
||||
<el-descriptions-item label="模板编码">{{ selectedTpl.tplCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务类型">{{ bizTypeText(selectedTpl.bizType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ selectedTpl.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="next-step">
|
||||
<div class="ns-title">下一步</div>
|
||||
<div class="ns-sub">配置审批节点顺序与审批人规则,用户提交申请时才会自动按流程流转。</div>
|
||||
<el-button type="primary" plain icon="el-icon-s-operation"
|
||||
@click="goNodeConfig(selectedTpl)">进入节点配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑抽屉:大面板,避免小弹窗 -->
|
||||
<el-drawer :title="drawerTitle" :visible.sync="drawerVisible" size="520px" append-to-body>
|
||||
<div class="drawer-body">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" size="small">
|
||||
<el-form-item label="模板名称" prop="tplName">
|
||||
<el-input v-model="form.tplName" placeholder="例如:请假审批(部门负责人→HR)" />
|
||||
<div class="hint-text">建议包含业务与审批路径,便于用户选择</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板编码" prop="tplCode">
|
||||
<el-input v-model="form.tplCode" placeholder="例如:LEAVE_V1" />
|
||||
<div class="hint-text">用于系统识别,建议英文大写+下划线</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务类型" prop="bizType">
|
||||
<el-select v-model="form.bizType" placeholder="选择业务" style="width: 100%">
|
||||
<el-option label="请假" value="leave" />
|
||||
<el-option label="出差" value="travel" />
|
||||
<el-option label="用印" value="seal" />
|
||||
<el-option label="日常报销" value="reimburse" />
|
||||
<el-option label="拨款" value="appropriation" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本" prop="version">
|
||||
<el-input-number v-model="form.version" :min="1" :step="1" control="false" style="width: 100%" />
|
||||
<div class="hint-text">同一业务可存在多版本,建议先从 v1 开始</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用" prop="enabled">
|
||||
<el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
|
||||
<div class="hint-text">停用后用户将无法按此模板发起流程</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="可选:说明适用范围、条件等" />
|
||||
</el-form-item>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addFlowTemplate, delFlowTemplate, listFlowTemplate, updateFlowTemplate } from '@/api/hrm';
|
||||
|
||||
export default {
|
||||
name: 'HrmFlowTemplate',
|
||||
data () {
|
||||
return {
|
||||
list: [],
|
||||
loading: false,
|
||||
query: { tplName: '', bizType: undefined },
|
||||
selectedTpl: null,
|
||||
|
||||
drawerVisible: false,
|
||||
drawerTitle: '流程模板',
|
||||
submitting: false,
|
||||
form: {},
|
||||
rules: {
|
||||
tplName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
tplCode: [{ required: true, message: '请输入模板编码', trigger: 'blur' }],
|
||||
bizType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
|
||||
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.loadList()
|
||||
},
|
||||
methods: {
|
||||
bizTypeText (val) {
|
||||
const map = { leave: '请假', travel: '出差', seal: '用印', reimburse: '日常报销' }
|
||||
return map[val] || val || '-'
|
||||
},
|
||||
loadList () {
|
||||
this.loading = true
|
||||
listFlowTemplate({ pageNum: 1, pageSize: 200, ...this.query })
|
||||
.then(res => {
|
||||
this.list = res.rows || []
|
||||
// 保持选中
|
||||
if (this.selectedTpl) {
|
||||
const hit = this.list.find(i => i.tplId === this.selectedTpl.tplId)
|
||||
this.selectedTpl = hit || null
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
selectTpl (tpl) {
|
||||
this.selectedTpl = tpl
|
||||
},
|
||||
openEditDrawer (row) {
|
||||
this.drawerTitle = row ? '编辑流程模板' : '新建流程模板'
|
||||
this.form = row
|
||||
? { ...row }
|
||||
: { tplName: '', tplCode: '', bizType: '', version: 1, enabled: 1, remark: '' }
|
||||
this.drawerVisible = true
|
||||
this.$nextTick(() => this.$refs.formRef && this.$refs.formRef.clearValidate())
|
||||
},
|
||||
submit () {
|
||||
this.$refs.formRef.validate(valid => {
|
||||
if (!valid) return
|
||||
this.submitting = true
|
||||
const api = this.form.tplId ? updateFlowTemplate : addFlowTemplate
|
||||
api(this.form)
|
||||
.then(() => {
|
||||
this.$message.success('已保存')
|
||||
this.drawerVisible = false
|
||||
this.loadList()
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
})
|
||||
},
|
||||
delRow (row) {
|
||||
this.$confirm('确认删除该模板吗?', '提示', { type: 'warning' }).then(() => {
|
||||
delFlowTemplate(row.tplId).then(() => {
|
||||
this.$message.success('已删除')
|
||||
if (this.selectedTpl && this.selectedTpl.tplId === row.tplId) this.selectedTpl = null
|
||||
this.loadList()
|
||||
})
|
||||
})
|
||||
},
|
||||
goNodeConfig (tpl) {
|
||||
// 按你的要求:不新增路由,也不在这里强制跳转。
|
||||
// 这里用事件/提示引导用户去“流程节点配置”页面,并自动复制 tplId。
|
||||
const text = `已选模板:${tpl.tplName}\n模板ID:${tpl.tplId}\n\n请前往【HRM-流程节点配置】页面,选择该模板继续配置节点。`
|
||||
this.$alert(text, '去配置节点', { confirmButtonText: '我知道了' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hrm-page {
|
||||
padding: 16px 20px 32px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.flow-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 420px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metal-panel {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-title .sub {
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr auto;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.template-list {
|
||||
max-height: 620px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tpl-item {
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
transition: all .15s ease;
|
||||
}
|
||||
|
||||
.tpl-item:hover {
|
||||
border-color: #cfd3dc;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
.tpl-item.active {
|
||||
border-color: #9aa3b2;
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.tpl-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tpl-name {
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.tpl-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tpl-sub {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tpl-actions {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tpl-actions .danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #8a8f99;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 28px 18px;
|
||||
border: 1px dashed #e6e8ed;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.p-title {
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.p-sub {
|
||||
margin-top: 6px;
|
||||
color: #8a8f99;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.preview-sub {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mt12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.next-step {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
|
||||
}
|
||||
|
||||
.ns-title {
|
||||
font-weight: 900;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.ns-sub {
|
||||
margin-top: 4px;
|
||||
color: #8a8f99;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.flow-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user