整合前端

This commit is contained in:
砂糖
2026-04-13 17:04:38 +08:00
parent 69609a2cb1
commit 5d4794c9bd
915 changed files with 144259 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>