feat(oa): 完成审批历史页面全链路开发,含前后端接口、菜单配置

This commit is contained in:
2026-04-14 20:36:01 +08:00
parent 5d4794c9bd
commit f4dbe29d8e
11 changed files with 336 additions and 59 deletions

View File

@@ -174,4 +174,12 @@ public class WfTaskController {
}
}
}
/**
* 审批历史列表
*/
@SaCheckPermission("workflow:task:historyList")
@GetMapping("/historyList")
public R historyList() {
return R.ok(flowTaskService.selectHistoryTaskList());
}
}

View File

@@ -68,6 +68,7 @@
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
</dependency>

View File

@@ -5,7 +5,7 @@ import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.oa.domain.OaExpress;
import com.ruoyi.oa.domain.vo.OaExpressVo;
import sun.misc.BASE64Encoder;
import java.util.Base64;
import com.ruoyi.common.utils.DateUtils;
import org.apache.commons.lang3.StringUtils;

View File

@@ -4,6 +4,7 @@ import com.ruoyi.workflow.domain.FlowRecord;
import com.ruoyi.workflow.domain.bo.WfTaskBo;
import org.flowable.bpmn.model.FlowElement;
import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.task.api.history.HistoricTaskInstance;
import java.io.InputStream;
import java.util.List;
@@ -124,4 +125,9 @@ public interface IWfTaskService {
* @return Map包含isStartNode和isEndNode信息
*/
Map<String, Boolean> checkTaskNodeType(String taskId);
/**
* 查询当前用户的审批历史(排除待审批状态)
*/
List<HistoricTaskInstance> selectHistoryTaskList();
}

View File

@@ -63,6 +63,7 @@ public class WfTaskServiceImpl extends FlowServiceFactory implements IWfTaskServ
private final IWfCopyService copyService;
/**
* 完成任务
*
@@ -788,4 +789,21 @@ public class WfTaskServiceImpl extends FlowServiceFactory implements IWfTaskServ
return result;
}
/**
* 查询当前用户的审批历史(排除待办任务)
*/
@Override
public List<HistoricTaskInstance> selectHistoryTaskList() {
// 获取当前登录用户ID
String userId = TaskUtils.getUserId();
// Flowable 原生查询:当前用户 + 已完成排除待办pending
return historyService.createHistoricTaskInstanceQuery()
.taskAssignee(userId) // 审批人是当前用户
.finished() // 已完成(排除待办)
.orderByHistoricTaskInstanceEndTime()
.desc() // 按完成时间倒序,最新的在最前面
.list();
}
}

View File

@@ -1,12 +1,12 @@
import router from './router'
import store from './store'
import {Message} from 'element-ui'
import { Message } from 'element-ui'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import {getToken} from '@/utils/auth'
import {isRelogin} from '@/utils/request'
import { getToken } from '@/utils/auth'
import { isRelogin } from '@/utils/request'
NProgress.configure({showSpinner: false})
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register']
@@ -14,25 +14,32 @@ router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
/* has token*/
if (to.path === '/login') {
next({path: '/'})
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) {
isRelogin.show = true
// 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(res => {
isRelogin.show = false
store.dispatch('GenerateRoutes').then(accessRoutes => {
// 根据roles权限生成可访问的路由表
router.addRoutes(accessRoutes) // 动态添加可访问路由表
next({...to, replace: true}) // hack方法 确保addRoutes已完成
const officeMenu = accessRoutes.find(m => m.path === '/oa')
if (officeMenu) {
const staticOa = router.options.routes.find(r => r.path === '/oa')
const historyMenu = staticOa?.children?.find(c => c.name === 'ApprovalHistory')
if (historyMenu) {
officeMenu.children.push(historyMenu)
}
}
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
})
}).catch(err => {
store.dispatch('LogOut').then(() => {
Message.error(err)
next({path: '/'})
next({ path: '/' })
})
})
} else {
@@ -40,12 +47,10 @@ router.beforeEach((to, from, next) => {
}
}
} else {
// 没有token
if (whiteList.indexOf(to.path) !== -1) {
// 在免登录白名单,直接进入
next()
} else {
next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
next(`/login?redirect=${to.fullPath}`)
NProgress.done()
}
}
@@ -53,4 +58,4 @@ router.beforeEach((to, from, next) => {
router.afterEach(() => {
NProgress.done()
})
})

View File

@@ -7,28 +7,6 @@ Vue.use(Router);
/* Layout */
import Layout from "@/layout";
/**
* Note: 路由配置项
*
* hidden: true // 当设置 true 的时候该路由不会再侧边栏出现 如401login等页面或者如一些编辑页面/edit/1
* alwaysShow: true // 当你一个路由下面的 children 声明的路由大于1个时自动会变成嵌套的模式--如组件页面
* // 只有一个时,会将那个子路由当做根路由显示在侧边栏--如引导页面
* // 若你想不管路由下面的 children 声明的个数都显示你的根路由
* // 你可以设置 alwaysShow: true这样它就会忽略之前定义的规则一直显示根路由
* redirect: noRedirect // 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击
* name:'router-name' // 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
* query: '{"id": 1, "name": "ry"}' // 访问路由的默认传递参数
* roles: ['admin', 'common'] // 访问路由的角色权限
* permissions: ['a:a:a', 'b:b:b'] // 访问路由的菜单权限
* meta : {
noCache: true // 如果设置为true则不会被 <keep-alive> 缓存(默认 false)
title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字
icon: 'svg-name' // 设置该路由的图标对应路径src/assets/icons/svg
breadcrumb: false // 如果设置为false则不会在breadcrumb面包屑中显示
activeMenu: '/system/user' // 当路由设置了该属性,则会高亮相对应的侧边栏。
}
*/
// 公共路由
export const constantRoutes = [
{
@@ -73,12 +51,10 @@ export const constantRoutes = [
name: "Index",
meta: { title: "工作台", icon: "dashboard", affix: true },
beforeEnter: (to, from, next) => {
// 从本地存储获取角色信息
currentRole().then((res) => {
console.log(res)
const role = res.data[0].roleKey;
if (role === "temp") {
next("/temp"); // 重定向到临时页面
next("/temp");
} else {
next();
}
@@ -91,16 +67,15 @@ export const constantRoutes = [
path: "/temp",
component: () => import("@/views/temp"),
name: "Temp",
hidden: true, // 隐藏路由
hidden: true,
meta: { title: "临时页面", icon: "dashboard" },
beforeEnter: (to, from, next) => {
// 从本地存储获取角色信息
currentRole().then((res) => {
const role = res.data[0].roleKey;
if (role === "temp") {
next();
} else {
next("/index"); // 重定向到工作台
next("/index");
}
});
},
@@ -144,14 +119,12 @@ export const constantRoutes = [
name: "updateOnboarding",
meta: { title: "更新入职数据", activeMenu: "/people/onboarding" },
},
{
path: "addOffboarding",
component: () => import("@/views/oa/offboarding/add"),
name: "addOffboarding",
meta: { title: "新增离职申请", activeMenu: "/people/offboarding" },
},
{
path: "updateOffboarding/:offboardingId(\\d+)",
component: () => import("@/views/oa/offboarding/update"),
@@ -174,7 +147,6 @@ export const constantRoutes = [
},
],
},
{
path: "/money",
component: Layout,
@@ -188,7 +160,6 @@ export const constantRoutes = [
},
],
},
{
path: "/claim",
component: Layout,
@@ -210,7 +181,6 @@ export const constantRoutes = [
},
];
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
{
path: "/system/user-auth",
@@ -240,7 +210,6 @@ export const dynamicRoutes = [
},
],
},
{
path: "/system/dict-data",
component: Layout,
@@ -255,7 +224,6 @@ export const dynamicRoutes = [
},
],
},
{
path: "/oa/warehouse-data",
component: Layout,
@@ -348,23 +316,20 @@ export const dynamicRoutes = [
},
];
// 防止连续点击多次路由报错
let routerPush = Router.prototype.push;
let routerReplace = Router.prototype.replace;
// push
Router.prototype.push = function push (location) {
Router.prototype.push = function push(location) {
return routerPush.call(this, location).catch((err) => err);
};
// replace
Router.prototype.replace = function push (location) {
Router.prototype.replace = function replace(location) {
return routerReplace.call(this, location).catch((err) => err);
};
const router = new Router({
base: process.env.VUE_APP_CONTEXT_PATH,
mode: "history", // 去掉url中的#
mode: "history",
scrollBehavior: () => ({ y: 0 }),
routes: constantRoutes,
});
export default router;
export default router;

View File

@@ -34,6 +34,36 @@ const permission = {
return new Promise(resolve => {
// 向后端请求路由数据
getRouters().then(res => {
// ================= 新增拦截代码:将页面注入到办公中心开始 =================
// 寻找后端传来的“办公中心”节点(根据路径或标题匹配)
const oaMenu = res.data.find(item => item.path === '/oa' || (item.meta && item.meta.title === '办公中心'));
if (oaMenu) {
if (!oaMenu.children) oaMenu.children = [];
// 防重判断,避免代码热更新时重复添加导致菜单重复
const hasHistory = oaMenu.children.some(child => child.path === 'flowHistory');
if (!hasHistory) {
oaMenu.children.push({
name: 'FlowHistory',
path: 'flowHistory', // 浏览器地址后缀,点击后地址变为 /oa/flowHistory
hidden: false, // 确保在左侧菜单显示
// 【特别注意】:这里对应的是你存放 vue 文件的真实相对路径
// 根据你最初的代码,我推测在 hrm/flow 文件夹下。
// 如果你的文件名叫 taskHistory.vue请把下面的 flowHistory 改成 taskHistory
component: 'hrm/flow/flowHistory',
meta: {
title: '审批历史',
icon: 'date-range', // 菜单图标,支持 element 图标
noCache: false
}
});
}
}
// ================= 新增拦截代码:将页面注入到办公中心结束 =================
const sdata = JSON.parse(JSON.stringify(res.data))
const rdata = JSON.parse(JSON.stringify(res.data))
const sidebarRoutes = filterAsyncRouter(sdata)
@@ -130,4 +160,4 @@ export const loadView = (view) => {
}
}
export default permission
export default permission

View File

@@ -0,0 +1,205 @@
<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>
<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="备注" prop="remark" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right">
<template slot-scope="scope">
<el-button link type="primary" @click="openDetail(scope.row)">查看详情</el-button>
</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">
<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>
</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>
</el-card>
</div>
</div>
</template>
<script>
import { getTodoTaskByBiz, listFlowAction, listFlowFormData, listHistoryFlowTask } from '@/views/hrm/js/History'
import { listByIds } from '@/api/system/oss'
import { listUser } from '@/api/system/user'
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: 'HrmFlowHistory',
dicts: ['hrm_stamp_image'],
components: { LeaveDetail, TravelDetail, SealDetail, ReimburseDetail },
data() {
return {
query: { status: undefined, pageNum: 1, pageSize: 50 },
list: [],
loading: false,
detailTask: null,
actionList: [],
actionLoading: false,
formData: [],
formLoading: false,
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
listHistoryFlowTask().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)
},
loadActions(row) { if (!row || !row.instId) return; this.actionLoading = true; listFlowAction({ instId: row.instId }).then(res => { this.actionList = res.rows || [] }).finally(() => { this.actionLoading = false }) },
loadFormData(row) { if (!row || !row.instId) return; this.formLoading = true; listFlowFormData({ instId: row.instId }).then(res => { this.formData = res.rows || [] }).finally(() => { this.formLoading = 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('已复制')) }
}
}
</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; }
.muted { color: #8a8f99; font-size: 12px; }
.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: #fff; }
.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; }
.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; }
</style>

View File

@@ -17,6 +17,9 @@
<router-link to="/hrm/payroll">
<el-button type="success" plain icon="el-icon-coin">薪酬与指标</el-button>
</router-link>
<router-link to="/oa/taskHistory">
<el-button type="default" plain icon="el-icon-tickets">审批历史</el-button>
</router-link>
</div>
</div>
<div class="hero-stat">

View File

@@ -0,0 +1,36 @@
import request from '@/utils/request'
// 获取审批历史(已完成)
export function listHistoryFlowTask(query) {
return request({
url: '/workflow/task/historyList',
method: 'get',
params: query
})
}
// 任务详情
export function getTodoTaskByBiz(taskId) {
return request({
url: '/workflow/task/getByTaskId/' + taskId,
method: 'get'
})
}
// 审批记录
export function listFlowAction(query) {
return request({
url: '/workflow/instance/action/list',
method: 'get',
params: query
})
}
// 表单数据
export function listFlowFormData(query) {
return request({
url: '/workflow/instance/form/data',
method: 'get',
params: query
})
}