This commit is contained in:
2025-12-30 13:47:53 +08:00
parent f1637501b2
commit a623c5673f
137 changed files with 11031 additions and 4043 deletions

View File

@@ -0,0 +1,149 @@
<template>
<div class="app-container flow-cc-container">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span class="card-title">抄送我的</span>
</div>
</template>
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane>
<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" />
<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="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="scope.row.readFlag === 0"
type="primary"
@click.stop="handleRead(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 { parseTime } from '@/utils'
export default {
name: 'HrmFlowCc',
data() {
return {
loading: false,
activeTab: 'unread',
ccList: [],
total: 0,
badgeUnread: 0,
queryParams: {
pageNum: 1,
pageSize: 10,
readFlag: 0
}
}
},
created() {
this.refreshCounts()
this.getList()
},
methods: {
parseTime,
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
listCc(this.queryParams)
.then((res) => {
this.ccList = res.rows || []
this.total = res.total || 0
if (this.activeTab === 'unread') {
this.badgeUnread = res.total || 0
}
})
.finally(() => {
this.loading = false
})
},
// 单独刷新未读数量(用于 badge
refreshCounts() {
listCc({ pageNum: 1, pageSize: 1, readFlag: 0 }).then((res) => {
this.badgeUnread = res.total || 0
})
},
handleRead(row) {
readCc(row.ccId).then(() => {
this.$modal.msgSuccess('已标记已读')
// 刷新当前列表 & 未读数量
this.refreshCounts()
this.getList()
})
}
}
}
</script>
<style scoped>
.flow-cc-container {
padding: 20px 20px 0 20px;
}
.card-header {
display: flex;
align-items: center;
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>

View File

@@ -1,68 +1,271 @@
<template>
<div class="hrm-page">
<el-card class="metal-panel" shadow="hover">
<div slot="header" class="panel-header">
<span>流程节点配置</span>
<div class="actions-inline">
<el-select v-model="query.tplId" size="mini" placeholder="选择模板" filterable clearable style="width: 200px" @change="loadList">
<el-option v-for="tpl in templateOptions" :key="tpl.tplId" :label="`${tpl.tplName || tpl.tplCode} (${tpl.bizType || ''})`" :value="tpl.tplId" />
</el-select>
<el-input v-model="query.nodeType" size="mini" placeholder="节点类型" clearable style="width: 140px" @keyup.enter.native="loadList" />
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openDialog()">新增</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadList">刷新</el-button>
<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>
<el-table :data="list" v-loading="loading" height="520" stripe>
<el-table-column label="序号" prop="orderNo" width="80" />
<el-table-column label="节点类型" prop="nodeType" min-width="140" />
<el-table-column label="审批规则" prop="approverRule" min-width="160" />
<el-table-column label="规则值" prop="approverValue" min-width="200" show-overflow-tooltip />
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="openDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="delRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog :visible.sync="dialogVisible" title="流程节点" width="520px" append-to-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" placeholder="选择模板" filterable style="width: 100%">
<el-option v-for="tpl in templateOptions" :key="tpl.tplId" :label="`${tpl.tplName || tpl.tplCode} (${tpl.bizType || ''})`" :value="tpl.tplId" />
<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>
</el-form-item>
<el-form-item label="顺序" prop="orderNo">
<el-input-number v-model="form.orderNo" :min="1" :step="1" controls-position="right" />
</el-form-item>
<el-form-item label="节点类型" prop="nodeType">
<el-input v-model="form.nodeType" placeholder="如 approve/cc/end" />
</el-form-item>
<el-form-item label="审批规则" prop="approverRule">
<el-select v-model="form.approverRule" placeholder="选择规则" style="width: 100%">
<el-option v-for="opt in approverRuleOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="规则值" prop="approverValue">
<el-input v-model="form.approverValue" placeholder="根据规则填写用户ID/角色编码/岗位ID/字段名等" />
<div class="muted">fixed_user: 用户ID逗号分隔role: 角色编码position: 岗位IDleader/initiator 无需填form_field: 表单字段名</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</div>
</el-dialog>
<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 { listFlowNode, addFlowNode, updateFlowNode, delFlowNode, listFlowTemplate } from '@/api/hrm'
import {
listFlowNode,
addFlowNode,
updateFlowNode,
delFlowNode,
listFlowTemplate,
listEmployee
} from '@/api/hrm'
import { listPost } from '@/api/system/post'
export default {
name: 'HrmFlowNode',
@@ -70,41 +273,133 @@ export default {
return {
list: [],
loading: false,
query: { tplId: undefined, nodeType: '' },
query: { tplId: undefined },
templateOptions: [],
dialogVisible: false,
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: 'leader' },
{ label: '发起人', value: 'initiator' },
{ label: '表单字段指定', value: 'form_field' }
{ 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: '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.loadList()
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: '用印', payroll: '薪酬' }
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 || []
})
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, ...this.query })
listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.query.tplId })
.then(res => {
this.list = res.rows || []
})
@@ -112,29 +407,102 @@ export default {
this.loading = false
})
},
openDialog(row) {
openDrawer(row) {
this.drawerTitle = row ? '编辑节点' : '新增节点'
const nextOrder = (this.sortedList[this.sortedList.length - 1]?.orderNo || 0) + 1
this.form = row
? { ...row }
: {
tplId: this.query.tplId || (this.templateOptions[0] && this.templateOptions[0].tplId),
orderNo: (this.list[this.list.length - 1]?.orderNo || 0) + 1,
nodeType: '',
approverRule: '',
tplId: this.query.tplId,
orderNo: nextOrder,
nodeType: 'approve',
approverRule: 'leader',
approverValue: '',
remark: ''
}
this.dialogVisible = true
// 清理选择器状态
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
api(this.form)
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.dialogVisible = false
this.drawerVisible = false
this.loadList()
})
.finally(() => {
@@ -142,6 +510,7 @@ export default {
})
})
},
delRow(row) {
this.$confirm('确认删除该节点吗?', '提示', { type: 'warning' }).then(() => {
delFlowNode(row.nodeId).then(() => {
@@ -149,6 +518,42 @@ export default {
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
})
}
}
}
@@ -159,21 +564,175 @@ export default {
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: 600;
color: #303133;
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: #909399;
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>

View File

@@ -1,180 +1,314 @@
<template>
<div class="hrm-page">
<section class="panel-grid">
<el-card class="metal-panel" shadow="hover">
<div class="flow-task-layout">
<!-- 任务列表 -->
<el-card class="metal-panel left" shadow="hover">
<div slot="header" class="panel-header">
<div class="header-left">
<div class="header-title">
<span>流程任务</span>
<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="mini"
style="width: 140px"
@change="fetchList"
>
<el-option label="待办" value="pending" />
<el-option label="完成" value="done" />
<el-option label="拒绝" value="rejected" />
<el-option label="撤回" value="withdrawn" />
</el-select>
<span class="sub">面向办理人待办优先可快速审批</span>
</div>
<div class="actions-inline">
<el-button size="mini" icon="el-icon-refresh" @click="fetchList">刷新</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" height="680" stripe @row-click="openDetail">
<el-table-column label="任务ID" prop="taskId" width="100" />
<el-table-column label="实例" prop="instId" width="100" />
<el-table-column label="业务类型" prop="bizType" min-width="100" />
<el-table-column label="节点" prop="nodeId" min-width="90" />
<el-table-column label="办理人" prop="assigneeUserId" min-width="120" />
<el-table-column label="状态" prop="status" min-width="100">
<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="120">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status }}</el-tag>
<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="到期" prop="expireTime" min-width="150">
<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="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="150">
<template slot-scope="scope">{{ formatDate(scope.row.expireTime) }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click.stop="openDetail(scope.row)">详情</el-button>
<el-button
v-if="scope.row.status === 'pending'"
size="mini"
type="text"
@click.stop="openAction(scope.row, 'approve')"
>通过</el-button>
<el-button
v-if="scope.row.status === 'pending'"
size="mini"
type="text"
@click.stop="openAction(scope.row, 'reject')"
>驳回</el-button>
<el-button
size="mini"
type="text"
@click.stop="openAction(scope.row, 'withdraw')"
>撤回</el-button>
<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>
</section>
<el-drawer
title="任务详情"
:visible.sync="detailVisible"
size="60%"
append-to-body
>
<div v-if="detailTask" class="detail-wrap">
<el-descriptions :column="3" size="small" border class="mb12">
<el-descriptions-item label="任务ID">{{ detailTask.taskId }}</el-descriptions-item>
<el-descriptions-item label="实例">{{ detailTask.instId }}</el-descriptions-item>
<el-descriptions-item label="业务类型">{{ detailTask.bizType }}</el-descriptions-item>
<el-descriptions-item label="节点">{{ detailTask.nodeId }}</el-descriptions-item>
<el-descriptions-item label="办理人">{{ detailTask.assigneeUserId }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(detailTask.status)">{{ detailTask.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="到期">{{ formatDate(detailTask.expireTime) }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detailTask.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 详情区 -->
<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>
<el-tabs value="form">
<el-tab-pane label="表单数据" name="form">
<el-table :data="formData" v-loading="formLoading" size="mini">
<el-table-column label="字段" prop="fieldName" min-width="140" />
<el-table-column label="值" prop="fieldValue" min-width="220" show-overflow-tooltip />
<el-table-column label="展示标签" prop="fieldLabel" min-width="160" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<el-tab-pane label="流转历史" name="history">
<el-table :data="actionList" v-loading="actionLoading" size="mini">
<el-table-column label="动作" prop="action" min-width="120" />
<el-table-column label="办理人" prop="actionUserId" min-width="120" />
<el-table-column label="时间" prop="createTime" min-width="160">
<template slot-scope="scope">{{ formatDate(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
</el-table>
</el-tab-pane>
</el-tabs>
</div>
<div v-else class="placeholder">请选择任务查看详情</div>
</el-drawer>
<div v-if="!detailTask" class="placeholder">
<div class="p-title">请在左侧选择一条任务</div>
<div class="p-sub">将展示业务信息表单字段流转历史并可发起审批动作</div>
</div>
<el-dialog
:title="actionDialogTitle"
:visible.sync="actionDialogVisible"
width="520px"
append-to-body
>
<el-form :model="actionForm" label-width="100px" size="small">
<el-form-item label="意见">
<el-input v-model="actionForm.remark" type="textarea" :rows="3" />
</el-form-item>
<template v-if="detailTask && detailTask.bizType === 'seal' && actionType === 'approve'">
<el-divider>盖章选填仅用印业务</el-divider>
<el-form-item label="待盖章文件">
<el-input v-model="actionForm.stampBo.targetFileUrl" placeholder="OSS URL" />
</el-form-item>
<el-form-item label="章图片">
<el-input v-model="actionForm.stampBo.stampImageUrl" placeholder="OSS URL" />
</el-form-item>
<el-form-item label="页码">
<el-input-number v-model="actionForm.stampBo.pageNo" :min="1" />
</el-form-item>
<el-form-item label="坐标 (x,y)">
<div class="coord-row">
<el-input-number v-model="actionForm.stampBo.xPx" :min="0" />
<el-input-number v-model="actionForm.stampBo.yPx" :min="0" />
<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>
</el-form-item>
<el-form-item label="尺寸 (可选)">
<div class="coord-row">
<el-input-number v-model="actionForm.stampBo.widthPx" :min="1" />
<el-input-number v-model="actionForm.stampBo.heightPx" :min="1" />
<div class="ds-right">
<div class="ds-item">
<div class="k">办理人</div>
<div class="v">{{ formatUser(detailTask.assigneeUserId) }}</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.actionUserId) }}</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>
</template>
</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-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 {
listFlowTask,
listTodoFlowTask,
approveFlowTask,
rejectFlowTask,
withdrawFlowTask,
listFlowAction,
listFlowFormData
} from '@/api/hrm'
import { listUser } from '@/api/system/user'
import { listFlowTask, listTodoFlowTask, getTodoTaskByBiz, approveFlowTask, rejectFlowTask, transferFlowTask, ccFlowTask, listFlowAction, listFlowFormData } from '@/api/hrm/flow'
import { listByIds } from '@/api/system/oss'
import UserSingleSelect from '@/components/userSelect/single.vue'
import UserMultiSelect from '@/components/userSelect/multi.vue'
import PdfStamper from '@/components/PdfStamper/index.vue'
// 四大申请详情页(嵌入模式)
import LeaveDetail from '@/views/hrm/requests/leaveDetail.vue'
import TravelDetail from '@/views/hrm/requests/travelDetail.vue'
import SealDetail from '@/views/hrm/requests/sealDetail.vue'
import ReimburseDetail from '@/views/hrm/requests/reimburseDetail.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,
detailVisible: false,
detailTask: null,
actionList: [],
actionLoading: false,
@@ -187,35 +321,39 @@ export default {
actionForm: {
remark: '',
stampBo: {
targetFileUrl: '',
stampImageUrl: '',
pageNo: 1,
xPx: 0,
yPx: 0,
widthPx: undefined,
heightPx: undefined
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: {
statusType(status) {
if (!status) return 'info'
const map = { pending: 'warning', done: '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())}`
},
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) { if (!userId) return '-'; const user = this.allUsers.find(u => u.userId === userId); return user ? `${user.nickName || user.userName}` : `ID:${userId}` },
fetchList() {
this.loading = true
const userId = this.$store?.state?.user?.userId
const userId = this.$store?.state?.user?.id
const params = { ...this.query }
let req
if (this.mode === 'todo' && userId) {
@@ -225,144 +363,243 @@ export default {
} else {
req = listFlowTask(params)
}
req
.then(res => {
this.list = res.rows || res || []
})
.finally(() => {
this.loading = false
})
req.then(res => {
this.list = res.data || res.rows || []
if (!this.detailTask && this.list.length) this.openDetail(this.list[0])
}).finally(() => { this.loading = false })
},
openDetail(row) {
async openDetail(row) {
if (!row) return
// 先用列表行渲染一份,避免右侧空白
this.detailTask = row
this.detailVisible = true
this.loadActions(row)
this.loadFormData(row)
},
loadActions(row) {
if (!row) return
if (row.instId && !/^\d+$/.test(row.instId)) {
this.$message.warning('实例ID需为数字已跳过加载动作')
return
// 再按新接口拉取“按业务维度的当前待办详情”(用于字段对齐/按钮状态)
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可能不是待办/无权限
}
this.actionLoading = true
listFlowAction({ instId: row.instId, pageNum: 1, pageSize: 100 })
.then(res => {
this.actionList = res.rows || res || []
})
.finally(() => {
this.actionLoading = false
})
},
loadFormData(row) {
if (!row) return
if (row.instId && !/^\d+$/.test(row.instId)) {
this.$message.warning('实例ID需为数字已跳过加载表单数据')
return
}
this.formLoading = true
listFlowFormData({ instId: row.instId, pageNum: 1, pageSize: 100 })
.then(res => {
this.formData = res.rows || res || []
})
.finally(() => {
this.formLoading = false
})
},
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
const titleMap = { approve: '审批通过', reject: '驳回', withdraw: '撤回' }
this.actionDialogTitle = titleMap[type] || '操作'
this.actionForm = {
remark: '',
stampBo: {
targetFileUrl: '',
stampImageUrl: '',
pageNo: 1,
xPx: 0,
yPx: 0,
widthPx: undefined,
heightPx: undefined
}
// 用印审批通过”走专用弹窗
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 payload = { remark: this.actionForm.remark }
if (this.actionType === 'approve' && this.detailTask.bizType === 'seal') {
// 仅当用户填了必要字段时传 stampBo
const sb = this.actionForm.stampBo
if (sb.targetFileUrl && sb.stampImageUrl && sb.pageNo && sb.xPx != null && sb.yPx != null) {
payload.stampBo = { ...sb }
}
}
const apiMap = {
approve: approveFlowTask,
reject: rejectFlowTask,
withdraw: withdrawFlowTask
}
apiMap[this.actionType](this.detailTask.taskId, payload)
.then(() => {
this.$message.success('操作成功')
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)
})
.finally(() => {
this.actionSubmitting = false
.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;
}
.panel-grid {
display: grid;
grid-template-columns: 1fr;
}
.metal-panel {
border: 1px solid #d7d9df;
border-radius: 10px;
background: #fff;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: #303133;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.actions-inline {
display: flex;
gap: 8px;
align-items: center;
}
.detail-wrap {
padding-right: 4px;
}
.coord-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.placeholder {
color: #a0a3ad;
padding: 12px;
}
.mb12 {
margin-bottom: 12px;
}
.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>

View File

@@ -1,73 +1,155 @@
<template>
<div class="hrm-page">
<el-card class="metal-panel" shadow="hover">
<div slot="header" class="panel-header">
<span>流程模板</span>
<div class="actions-inline">
<el-input v-model="query.tplName" size="mini" placeholder="模板名称" clearable style="width: 160px" @keyup.enter.native="loadList" />
<el-select v-model="query.bizType" size="mini" placeholder="业务类型" clearable style="width: 140px" @change="loadList">
<el-option label="请假" value="leave" />
<el-option label="加班" value="overtime" />
<el-option label="出差" value="travel" />
<el-option label="用印" value="seal" />
<el-option label="薪酬" value="payroll" />
</el-select>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openDialog()">新增</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadList">刷新</el-button>
<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>
<el-table :data="list" v-loading="loading" height="520" stripe>
<el-table-column label="名称" prop="tplName" min-width="160" />
<el-table-column label="编码" prop="tplCode" min-width="140" />
<el-table-column label="业务类型" prop="bizType" min-width="120" />
<el-table-column label="版本" prop="version" width="90" />
<el-table-column label="启用" prop="enabled" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="mini">{{ scope.row.enabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="openDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="delRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog :visible.sync="dialogVisible" title="流程模板" width="480px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" size="small">
<el-form-item label="模板名称" prop="tplName">
<el-input v-model="form.tplName" />
</el-form-item>
<el-form-item label="模板编码" prop="tplCode">
<el-input v-model="form.tplCode" />
</el-form-item>
<el-form-item label="业务类型" prop="bizType">
<el-select v-model="form.bizType" placeholder="选择业务" style="width: 100%">
<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="overtime" />
<el-option label="出差" value="travel" />
<el-option label="用印" value="seal" />
<el-option label="薪酬" value="payroll" />
<el-option label="日常报销" value="reimburse" />
</el-select>
</el-form-item>
<el-form-item label="版本" prop="version">
<el-input-number v-model="form.version" :min="1" :step="1" controls-position="right" />
</el-form-item>
<el-form-item label="启用" prop="enabled">
<el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</div>
</el-dialog>
<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-select>
</el-form-item>
<el-form-item label="版本" prop="version">
<el-input-number v-model="form.version" :min="1" :step="1" controls-position="right" 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>
@@ -81,7 +163,10 @@ export default {
list: [],
loading: false,
query: { tplName: '', bizType: undefined },
dialogVisible: false,
selectedTpl: null,
drawerVisible: false,
drawerTitle: '流程模板',
submitting: false,
form: {},
rules: {
@@ -96,21 +181,34 @@ export default {
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
})
},
openDialog(row) {
selectTpl(tpl) {
this.selectedTpl = tpl
},
openEditDrawer(row) {
this.drawerTitle = row ? '编辑流程模板' : '新建流程模板'
this.form = row
? { ...row }
: { tplName: '', tplCode: '', bizType: '', version: 1, enabled: 1, remark: '' }
this.dialogVisible = true
this.drawerVisible = true
this.$nextTick(() => this.$refs.formRef && this.$refs.formRef.clearValidate())
},
submit() {
@@ -121,7 +219,7 @@ export default {
api(this.form)
.then(() => {
this.$message.success('已保存')
this.dialogVisible = false
this.drawerVisible = false
this.loadList()
})
.finally(() => {
@@ -133,9 +231,16 @@ export default {
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: '我知道了' })
}
}
}
@@ -146,16 +251,167 @@ export default {
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: 600;
color: #303133;
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>