This commit is contained in:
hero920103 committed 2026-10-17 11:52:02 +08:00
1 parent 184d5b0403
commit a758966fae
18 files changed
+2002 -19

No files matched your search

+27
View File
@@ -57,3 +57,30 @@ export function getApprovalFlowPick(params) {
export function getAvailableFlows(params) { export function getAvailableFlows(params) {
return request({ url: '/backend/approval/flow/available', method: 'get', params }); return request({ url: '/backend/approval/flow/available', method: 'get', params });
} }
// ==================== 审批中心 ====================
// 我的任务列表:type=pending 待我审批 / done 我已审批 / cc 抄送我的
export function getCenterTaskList(params) {
return request({ url: '/backend/approval/center/task/list', method: 'get', params });
}
// 通用审批操作:{ task_id, pass, comment }
export function actCenterTask(data) {
return request({ url: '/backend/approval/center/task/act', method: 'post', data });
}
// 我发起的审批实例列表(status 可选:1审批中 2通过 3驳回 4撤回)
export function getCenterInstanceList(params) {
return request({ url: '/backend/approval/center/instance/list', method: 'get', params });
}
// 审批实例详情(含节点任务时间线)
export function getCenterInstanceDetail(id) {
return request({ url: `/backend/approval/center/instance/detail/${id}`, method: 'get' });
}
// 审批权限设置:只更新流程的适用部门/是否默认/启用状态(body: {id, apply_dept_ids, is_default, status})
export function saveApprovalFlowPerm(data) {
return request({ url: '/backend/approval/flow/perm', method: 'post', data });
}
+19
View File
@@ -22,6 +22,25 @@ const staticMainChildren = [
component: () => import("@/views/system/approval/index.vue"), component: () => import("@/views/system/approval/index.vue"),
meta: { requiresAuth: true, title: "审批流", modulePath: "/systemManager" } meta: { requiresAuth: true, title: "审批流", modulePath: "/systemManager" }
}, },
// OA 审批中心:待我审批 / 审批列表 / 审批设置(静态注册保证 URL 直达)
{
path: "/apps/oa/approvalcenter/mine",
name: "OaApprovalCenterMine",
component: () => import("@/views/apps/oa/approvalcenter/mine.vue"),
meta: { requiresAuth: true, title: "待我审批", modulePath: "/apps/oa" }
},
{
path: "/apps/oa/approvalcenter/list",
name: "OaApprovalCenterList",
component: () => import("@/views/apps/oa/approvalcenter/list.vue"),
meta: { requiresAuth: true, title: "审批列表", modulePath: "/apps/oa" }
},
{
path: "/apps/oa/approvalcenter/settings",
name: "OaApprovalCenterSettings",
component: () => import("@/views/apps/oa/approvalcenter/settings.vue"),
meta: { requiresAuth: true, title: "审批设置", modulePath: "/apps/oa" }
},
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。 // CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
// 统一结构:index 为列表页,type 为分类页。 // 统一结构:index 为列表页,type 为分类页。
{ {
+3 -2
View File
@@ -7,7 +7,7 @@
* 环境变量(见 .env.development / .env.production): * 环境变量(见 .env.development / .env.production):
* VITE_AUTH_BASE=https://api.yunzer.cn/auth 认证中心地址,缺省回退到 ${VITE_API_BASE_URL}/auth * VITE_AUTH_BASE=https://api.yunzer.cn/auth 认证中心地址,缺省回退到 ${VITE_API_BASE_URL}/auth
* VITE_AUTH_CLIENT_ID=yz-backend * VITE_AUTH_CLIENT_ID=yz-backend
* VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/#/auth/callback * VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/auth-callback.html
* VITE_AUTH_POST_LOGOUT_URI=https://back.yunzer.cn/#/ * VITE_AUTH_POST_LOGOUT_URI=https://back.yunzer.cn/#/
* *
* 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js), * 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js),
@@ -21,8 +21,9 @@ const AUTH_BASE = (import.meta.env.VITE_AUTH_BASE || (API_BASE ? `${API_BASE}/au
"" ""
); );
const CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || "yz-backend"; const CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || "yz-backend";
// 缺省回落到静态中转页(不带 #);该地址必须已在认证中心白名单登记
const REDIRECT_URI = const REDIRECT_URI =
import.meta.env.VITE_AUTH_REDIRECT_URI || `${window.location.origin}/#/auth/callback`; import.meta.env.VITE_AUTH_REDIRECT_URI || `${window.location.origin}/auth-callback.html`;
const POST_LOGOUT_URI = const POST_LOGOUT_URI =
import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/`; import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/`;
@@ -0,0 +1,159 @@
<template>
<el-dialog
v-model="visible"
title="审批进度"
width="560px"
:close-on-click-modal="false"
append-to-body
>
<div v-loading="loading" class="progress-wrap">
<template v-if="detail">
<div class="progress-head">
<div class="progress-title">
<span class="title-text">{{ detail.biz_title }}</span>
<el-tag :type="instanceStatusType(detail.status)" size="small">
{{ instanceStatusLabel(detail.status) }}
</el-tag>
</div>
<div class="progress-meta">
<span v-if="detail.flow_name">流程:{{ detail.flow_name }}</span>
<span>发起人:{{ detail.submit_user_name || "-" }}</span>
<span>发起时间:{{ detail.submit_time || "-" }}</span>
<span v-if="detail.finish_time">完成时间:{{ detail.finish_time }}</span>
</div>
</div>
<el-timeline class="progress-timeline">
<el-timeline-item
v-for="node in detail.timeline"
:key="node.task_id"
:type="timelineDotType(node.status)"
:timestamp="node.finish_time || ''"
>
<div class="timeline-node">
<div class="node-head">
<span class="node-name">{{ node.node_name }}</span>
<el-tag :type="taskStatusType(node.status)" size="small" effect="plain">
{{ taskStatusLabel(node.status) }}
</el-tag>
</div>
<div class="node-approver">处理人:{{ node.approver || "-" }}</div>
<div v-if="node.comment" class="node-comment">意见:{{ node.comment }}</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!detail.timeline?.length" description="暂无审批记录" :image-size="80" />
</template>
</div>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { getCenterInstanceDetail } from "@/api/approval";
const props = defineProps({
modelValue: { type: Boolean, default: false },
instanceId: { type: [Number, String], default: 0 },
});
const emit = defineEmits(["update:modelValue"]);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
const loading = ref(false);
const detail = ref(null);
// 实例状态:1审批中 2通过 3驳回 4撤回 5取消
const instanceStatusLabel = (status) =>
({ 1: "审批中", 2: "已通过", 3: "已驳回", 4: "已撤回", 5: "已取消" }[status] || "未知");
const instanceStatusType = (status) =>
({ 1: "warning", 2: "success", 3: "danger", 4: "info", 5: "info" }[status] || "info");
// 任务状态:1待审批 2已通过 3已驳回 6已通知(抄送)
const taskStatusLabel = (status) =>
({ 1: "待审批", 2: "已通过", 3: "已驳回", 6: "已通知" }[status] || "未知");
const taskStatusType = (status) =>
({ 1: "warning", 2: "success", 3: "danger", 6: "info" }[status] || "info");
const timelineDotType = (status) => taskStatusType(status);
watch(
() => [props.modelValue, props.instanceId],
([open, id]) => {
if (open && id) loadDetail(id);
},
{ immediate: true },
);
const loadDetail = async (id) => {
loading.value = true;
detail.value = null;
try {
const res = await getCenterInstanceDetail(id);
detail.value = res?.data?.data ?? res?.data ?? null;
} catch (error) {
ElMessage.error(error?.message || "加载审批进度失败");
} finally {
loading.value = false;
}
};
</script>
<style lang="less" scoped>
.progress-wrap {
min-height: 160px;
}
.progress-head {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.progress-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.title-text {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.progress-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.progress-timeline {
padding-left: 4px;
}
.timeline-node {
.node-head {
display: flex;
align-items: center;
gap: 8px;
}
.node-name {
font-weight: 600;
color: var(--el-text-color-primary);
}
.node-approver,
.node-comment {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
@@ -0,0 +1,312 @@
<template>
<div class="approval-center-page">
<div class="page-header">
<div>
<h2>审批列表</h2>
<p>查看我参与的全部审批记录</p>
</div>
</div>
<div class="table-card">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<!-- 我已审批 -->
<el-tab-pane label="我已审批" name="done">
<div class="table-scroll">
<el-table v-loading="loading" :data="taskList" stripe>
<el-table-column prop="biz_title" label="审批事项" min-width="220" show-overflow-tooltip />
<el-table-column prop="biz_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ bizTypeLabel(row.biz_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="submit_user_name" label="发起人" width="120">
<template #default="{ row }">{{ row.submit_user_name || "-" }}</template>
</el-table-column>
<el-table-column prop="node_name" label="处理节点" width="140">
<template #default="{ row }">{{ row.node_name || "-" }}</template>
</el-table-column>
<el-table-column prop="status" label="我的处理" width="110">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">
{{ taskStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="comment" label="审批意见" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.comment || "-" }}</template>
</el-table-column>
<el-table-column prop="finish_time" label="处理时间" width="150">
<template #default="{ row }">{{ row.finish_time || "-" }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="90">
<template #default="{ row }">
<el-button link type="primary" @click="openProgress(row)">进度</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无已审批记录" /></template>
</el-table>
</div>
</el-tab-pane>
<!-- 我发起的 -->
<el-tab-pane label="我发起的" name="submitted">
<div class="tab-filter">
<el-select v-model="instanceStatus" clearable placeholder="全部状态" class="status-select" @change="loadInstances">
<el-option label="审批中" :value="1" />
<el-option label="已通过" :value="2" />
<el-option label="已驳回" :value="3" />
<el-option label="已撤回" :value="4" />
</el-select>
</div>
<div class="table-scroll">
<el-table v-loading="loading" :data="instanceList" stripe>
<el-table-column prop="biz_title" label="审批事项" min-width="220" show-overflow-tooltip />
<el-table-column prop="flow_name" label="所属流程" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.flow_name || "-" }}</template>
</el-table-column>
<el-table-column prop="biz_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ bizTypeLabel(row.biz_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="instanceStatusType(row.status)" size="small">
{{ instanceStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="submit_time" label="发起时间" width="150">
<template #default="{ row }">{{ row.submit_time || "-" }}</template>
</el-table-column>
<el-table-column prop="finish_time" label="完成时间" width="150">
<template #default="{ row }">{{ row.finish_time || "-" }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="90">
<template #default="{ row }">
<el-button link type="primary" @click="openProgress(row)">进度</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无发起的审批" /></template>
</el-table>
</div>
</el-tab-pane>
<!-- 抄送我的 -->
<el-tab-pane label="抄送我的" name="cc">
<div class="table-scroll">
<el-table v-loading="loading" :data="taskList" stripe>
<el-table-column prop="biz_title" label="审批事项" min-width="220" show-overflow-tooltip />
<el-table-column prop="biz_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ bizTypeLabel(row.biz_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="submit_user_name" label="发起人" width="120">
<template #default="{ row }">{{ row.submit_user_name || "-" }}</template>
</el-table-column>
<el-table-column prop="node_name" label="通知节点" width="140">
<template #default="{ row }">{{ row.node_name || "-" }}</template>
</el-table-column>
<el-table-column prop="finish_time" label="通知时间" width="150">
<template #default="{ row }">{{ row.finish_time || "-" }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="90">
<template #default="{ row }">
<el-button link type="primary" @click="openProgress(row)">进度</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无抄送记录" /></template>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<div class="pagination">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
@update:current-page="pagination.page = $event"
@update:page-size="pagination.pageSize = $event"
@current-change="loadCurrent"
@size-change="loadCurrent"
/>
</div>
</div>
<ProgressTimeline v-model="progressVisible" :instance-id="progressInstanceId" />
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import ProgressTimeline from "./components/ProgressTimeline.vue";
import { getCenterInstanceList, getCenterTaskList } from "@/api/approval";
const activeTab = ref("done");
const taskList = ref([]);
const instanceList = ref([]);
const instanceStatus = ref(undefined);
const loading = ref(false);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const progressVisible = ref(false);
const progressInstanceId = ref(0);
const isMobile = ref(false);
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768;
};
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const bizTypeLabel = (bizType) =>
({ reimburse: "报销", oa: "OA 审批" }[bizType] || bizType || "-");
// 任务状态:1待审批 2已通过 3已驳回 6已通知
const taskStatusLabel = (status) =>
({ 1: "待审批", 2: "已通过", 3: "已驳回", 6: "已通知" }[status] || "未知");
const taskStatusType = (status) =>
({ 1: "warning", 2: "success", 3: "danger", 6: "info" }[status] || "info");
// 实例状态:1审批中 2通过 3驳回 4撤回
const instanceStatusLabel = (status) =>
({ 1: "审批中", 2: "已通过", 3: "已驳回", 4: "已撤回" }[status] || "未知");
const instanceStatusType = (status) =>
({ 1: "warning", 2: "success", 3: "danger", 4: "info" }[status] || "info");
const loadTasks = async (type) => {
loading.value = true;
try {
const data = responseData(
await getCenterTaskList({ type, ...pagination }),
);
taskList.value = data.list || [];
pagination.total = Number(data.total || 0);
} catch (error) {
ElMessage.error(error?.message || "加载审批列表失败");
} finally {
loading.value = false;
}
};
const loadInstances = async () => {
loading.value = true;
try {
const data = responseData(
await getCenterInstanceList({
status: instanceStatus.value ?? undefined,
...pagination,
}),
);
instanceList.value = data.list || [];
pagination.total = Number(data.total || 0);
} catch (error) {
ElMessage.error(error?.message || "加载审批列表失败");
} finally {
loading.value = false;
}
};
const loadCurrent = () => {
if (activeTab.value === "submitted") loadInstances();
else loadTasks(activeTab.value);
};
const handleTabChange = () => {
pagination.page = 1;
pagination.total = 0;
taskList.value = [];
instanceList.value = [];
loadCurrent();
};
const openProgress = (row) => {
progressInstanceId.value = row.instance_id || row.id;
progressVisible.value = true;
};
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
loadCurrent();
});
</script>
<style lang="less" scoped>
.approval-center-page {
padding: 24px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
h2 {
margin: 0 0 4px;
font-size: 20px;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.table-card {
padding: 8px 16px 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.tab-filter {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
.status-select {
width: 160px;
}
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 768px) {
.approval-center-page {
padding: 12px;
}
.page-header {
flex-direction: column;
gap: 12px;
}
.table-card {
padding: 4px 12px 12px;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
:deep(.el-table) {
min-width: 720px;
}
}
.pagination {
justify-content: center;
margin-top: 12px;
}
}
</style>
@@ -0,0 +1,230 @@
<template>
<div class="approval-center-page">
<div class="page-header">
<div>
<h2>待我审批</h2>
<p>处理与我相关的 OA 审批事项</p>
</div>
<div class="header-actions">
<el-badge :value="pagination.total" :hidden="!pagination.total" type="warning">
<el-tag type="warning" effect="plain">待办 {{ pagination.total }} 条</el-tag>
</el-badge>
</div>
</div>
<div class="table-card">
<div class="table-scroll">
<el-table v-loading="loading" :data="list" stripe>
<el-table-column
prop="biz_title"
label="审批事项"
min-width="220"
show-overflow-tooltip
/>
<el-table-column prop="biz_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ bizTypeLabel(row.biz_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="submit_user_name" label="发起人" width="120">
<template #default="{ row }">{{ row.submit_user_name || "-" }}</template>
</el-table-column>
<el-table-column prop="submit_time" label="发起时间" width="150">
<template #default="{ row }">{{ row.submit_time || "-" }}</template>
</el-table-column>
<el-table-column prop="node_name" label="审批节点" width="140">
<template #default="{ row }">{{ row.node_name || "-" }}</template>
</el-table-column>
<el-table-column prop="deadline" label="处理时限" width="150">
<template #default="{ row }">
<span :class="{ overdue: isOverdue(row.deadline) }">{{
row.deadline || "-"
}}</span>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="180">
<template #default="{ row }">
<el-button link type="success" @click="handleAct(row, true)">通过</el-button>
<el-button link type="danger" @click="handleAct(row, false)">驳回</el-button>
<el-button link type="primary" @click="openProgress(row)">进度</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无待审批事项" /></template>
</el-table>
</div>
<div class="pagination">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
@update:current-page="pagination.page = $event"
@update:page-size="pagination.pageSize = $event"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</div>
<ProgressTimeline v-model="progressVisible" :instance-id="progressInstanceId" />
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import ProgressTimeline from "./components/ProgressTimeline.vue";
import { actCenterTask, getCenterTaskList } from "@/api/approval";
const list = ref([]);
const loading = ref(false);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const progressVisible = ref(false);
const progressInstanceId = ref(0);
const isMobile = ref(false);
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768;
};
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
// 业务类型中文名(与流程配置的分类/单据类型对应)
const bizTypeLabel = (bizType) =>
({ reimburse: "报销", oa: "OA 审批" }[bizType] || bizType || "-");
const isOverdue = (deadline) => {
if (!deadline) return false;
return new Date(deadline.replace(/-/g, "/")).getTime() < Date.now();
};
const loadList = async () => {
loading.value = true;
try {
const data = responseData(
await getCenterTaskList({ type: "pending", ...pagination }),
);
list.value = data.list || [];
pagination.total = Number(data.total || 0);
} catch (error) {
ElMessage.error(error?.message || "加载待办列表失败");
} finally {
loading.value = false;
}
};
// 通过/驳回:驳回时填写审批意见
const handleAct = (row, pass) => {
const doAct = async (comment) => {
try {
const res = responseData(await actCenterTask({ task_id: row.task_id, pass, comment }));
ElMessage.success(res?.message || (pass ? "已通过" : "已驳回"));
loadList();
} catch (error) {
ElMessage.error(error?.message || "操作失败");
}
};
if (pass) {
ElMessageBox.confirm(`确认通过「${row.biz_title}」的「${row.node_name}」节点吗?`, "通过审批", {
type: "success",
confirmButtonText: "通过",
cancelButtonText: "取消",
}).then(() => doAct(""));
} else {
ElMessageBox.prompt("请输入驳回原因", "驳回", {
type: "warning",
inputPlaceholder: "驳回原因(选填)",
confirmButtonText: "驳回",
cancelButtonText: "取消",
}).then(({ value }) => doAct((value || "").trim()));
}
};
const openProgress = (row) => {
progressInstanceId.value = row.instance_id;
progressVisible.value = true;
};
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
loadList();
});
</script>
<style lang="less" scoped>
.approval-center-page {
padding: 24px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
h2 {
margin: 0 0 4px;
font-size: 20px;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.header-actions {
display: flex;
align-items: center;
}
.overdue {
color: var(--el-color-danger);
font-weight: 600;
}
.table-card {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 768px) {
.approval-center-page {
padding: 12px;
}
.page-header {
flex-direction: column;
gap: 12px;
}
.table-card {
padding: 12px;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
:deep(.el-table) {
min-width: 760px;
}
}
.pagination {
justify-content: center;
margin-top: 12px;
}
}
</style>
@@ -0,0 +1,297 @@
<template>
<div class="approval-center-page">
<div class="page-header">
<div>
<h2>审批设置</h2>
<p>配置 OA 各业务的审批流程模板、表单与权限</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Plus" @click="goDesigner">新建流程</el-button>
</div>
</div>
<div class="table-card">
<el-tabs v-model="activeTab">
<!-- 流程模板 -->
<el-tab-pane label="流程模板" name="flows">
<el-table v-loading="flowsLoading" :data="flows" stripe>
<el-table-column prop="flow_code" label="流程编号" width="120" />
<el-table-column prop="flow_name" label="流程名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="module_code" label="所属模块" width="120">
<template #default="{ row }">{{ moduleName(row.module_code) }}</template>
</el-table-column>
<el-table-column prop="biz_type" label="适用类型" width="110">
<template #default="{ row }">{{ bizTypeLabel(row.biz_type) }}</template>
</el-table-column>
<el-table-column prop="node_count" label="节点数" width="80" align="center" />
<el-table-column prop="is_default" label="默认流" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_default === 1" type="success" size="small">默认</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-switch
:model-value="row.status === 1"
@change="(val) => toggleFlowStatus(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="140">
<template #default="{ row }">
<el-button link type="primary" @click="goDesigner">编辑</el-button>
<el-button link type="danger" @click="handleDeleteFlow(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无审批流程,点击右上角新建" /></template>
</el-table>
</el-tab-pane>
<!-- 表单设计 -->
<el-tab-pane label="表单设计" name="forms">
<el-empty description="当前报销等业务使用内置固定表单,自定义表单设计器规划中">
<template #image>
<el-icon :size="48" color="var(--el-color-info)"><Document /></el-icon>
</template>
</el-empty>
</el-tab-pane>
<!-- 审批权限 -->
<el-tab-pane label="审批权限" name="perms">
<el-table v-loading="flowsLoading" :data="flows" stripe>
<el-table-column prop="flow_name" label="流程名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="biz_type" label="适用类型" width="110">
<template #default="{ row }">{{ bizTypeLabel(row.biz_type) }}</template>
</el-table-column>
<el-table-column label="适用部门" min-width="240">
<template #default="{ row }">
<el-select
v-model="row._dept_ids"
multiple
collapse-tags
clearable
placeholder="全部部门(全公司通用)"
class="dept-select"
>
<el-option
v-for="org in orgs"
:key="org.id"
:label="org.name"
:value="org.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="默认流" width="90" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_default === 1"
@change="(val) => savePerm(row, { is_default: val ? 1 : 0 })"
/>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="100">
<template #default="{ row }">
<el-button
link
type="primary"
:loading="savingPermId === row.id"
@click="savePerm(row, { apply_dept_ids: row._dept_ids })"
>保存部门</el-button
>
</template>
</el-table-column>
<template #empty><el-empty description="暂无审批流程" /></template>
</el-table>
<div class="perm-tip">
适用部门为空表示全公司通用;同一业务类型下仅一条默认流程,发起时自动命中。
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Document } from "@element-plus/icons-vue";
import {
deleteApprovalFlow,
getApprovalFlowList,
getApprovalOptions,
saveApprovalFlowPerm,
} from "@/api/approval";
const router = useRouter();
const activeTab = ref("flows");
const flows = ref([]);
const orgs = ref([]);
const modules = ref([]);
const flowsLoading = ref(false);
const savingPermId = ref(0);
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const moduleName = (code) =>
modules.value.find((m) => m.code === code)?.name || code || "-";
const bizTypeLabel = (bizType) =>
({ reimburse: "报销", oa: "OA 通用" }[bizType] || bizType || "-");
const loadOptions = async () => {
try {
const data = responseData(await getApprovalOptions());
orgs.value = data?.orgs || [];
modules.value = data?.modules || [];
} catch (error) {
ElMessage.error(error?.message || "加载配置数据失败");
}
};
const loadFlows = async () => {
flowsLoading.value = true;
try {
const data = responseData(await getApprovalFlowList());
const rows = Array.isArray(data) ? data : data?.list || [];
// _dept_ids 为本地编辑态(后端 apply_dept_ids 即 ID 数组)
flows.value = rows.map((row) => ({
...row,
_dept_ids: Array.isArray(row.apply_dept_ids) ? row.apply_dept_ids : [],
}));
} catch (error) {
ElMessage.error(error?.message || "加载流程列表失败");
} finally {
flowsLoading.value = false;
}
};
const goDesigner = () => {
router.push("/system/approval");
};
// 启停流程(复用权限接口,仅更新状态字段)
const toggleFlowStatus = async (row, enabled) => {
try {
await saveApprovalFlowPerm({
id: row.id,
apply_dept_ids: row._dept_ids,
is_default: row.is_default,
status: enabled ? 1 : 0,
});
row.status = enabled ? 1 : 0;
ElMessage.success(enabled ? "已启用" : "已停用");
} catch (error) {
ElMessage.error(error?.message || "操作失败");
}
};
const savePerm = async (row, patch) => {
savingPermId.value = row.id;
try {
await saveApprovalFlowPerm({
id: row.id,
apply_dept_ids: patch.apply_dept_ids ?? row._dept_ids,
is_default: patch.is_default ?? row.is_default,
status: row.status,
});
if (patch.is_default !== undefined) row.is_default = patch.is_default;
ElMessage.success("保存成功");
loadFlows();
} catch (error) {
ElMessage.error(error?.message || "保存失败");
} finally {
savingPermId.value = 0;
}
};
const handleDeleteFlow = (row) => {
ElMessageBox.confirm(
`确定删除流程「${row.flow_name}」吗?在途审批不受影响,但该流程将无法再被发起。`,
"删除流程",
{ type: "warning", confirmButtonText: "删除", cancelButtonText: "取消" },
)
.then(async () => {
try {
await deleteApprovalFlow(row.id);
ElMessage.success("已删除");
loadFlows();
} catch (error) {
ElMessage.error(error?.message || "删除失败");
}
})
.catch(() => {});
};
onMounted(() => {
loadOptions();
loadFlows();
});
</script>
<style lang="less" scoped>
.approval-center-page {
padding: 24px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
h2 {
margin: 0 0 4px;
font-size: 20px;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.table-card {
padding: 8px 16px 16px;
background: var(--el-bg-color);
border-radius: 8px;
}
.dept-select {
width: 100%;
max-width: 320px;
}
.perm-tip {
margin-top: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
@media (max-width: 768px) {
.approval-center-page {
padding: 12px;
}
.page-header {
flex-direction: column;
gap: 12px;
}
.table-card {
padding: 4px 12px 12px;
}
.table-scroll,
:deep(.el-table) {
overflow-x: auto;
}
:deep(.el-table) {
min-width: 680px;
}
}
</style>
+91
View File
@@ -307,6 +307,97 @@ func (c *AppOaController) ExpenseRecords() {
c.oaOk(rows) c.oaOk(rows)
} }
// expenseActPayload 审批操作请求体。
type expenseActPayload struct {
Comment string `json:"comment"`
}
// expenseReadActPayload 读取审批意见(可空)。
func (c *AppOaController) expenseReadActPayload() string {
var payload expenseActPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
return strings.TrimSpace(payload.Comment)
}
// ExpenseApprove POST /api/oa/expense/approve/:id 通过当前审批节点(body: {comment})。
// 审批人视角:无需是单据创建人,只要是当前节点待办审批人即可操作。
func (c *AppOaController) ExpenseApprove() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id := c.oaScheduleIDParam()
if id == 0 {
c.oaErr(400, 400, "无效ID")
return
}
code, msg, hint := reimburseActCore(claims, id, true, c.expenseReadActPayload())
if msg != "" {
c.oaErr(code, code, msg)
return
}
c.oaOk(map[string]interface{}{"message": hint})
}
// ExpenseReject POST /api/oa/expense/reject/:id 驳回报销单(body: {comment})。
func (c *AppOaController) ExpenseReject() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id := c.oaScheduleIDParam()
if id == 0 {
c.oaErr(400, 400, "无效ID")
return
}
code, msg, hint := reimburseActCore(claims, id, false, c.expenseReadActPayload())
if msg != "" {
c.oaErr(code, code, msg)
return
}
c.oaOk(map[string]interface{}{"message": hint})
}
// ExpenseMyApproveTask GET /api/oa/expense/my-approve-task/:id
// 查当前用户在该单据上的待审批任务:返回 {task_id,node_name},无待办返回 data=null。
// 小程序详情页据此决定是否展示「通过/驳回」按钮。
func (c *AppOaController) ExpenseMyApproveTask() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id := c.oaScheduleIDParam()
if id == 0 {
c.oaErr(400, 400, "无效ID")
return
}
task, err := reimburseMyTaskCore(claims, id)
if err != nil {
c.oaErr(500, 500, "查询审批任务失败")
return
}
if task == nil {
c.oaOk(nil)
return
}
c.oaOk(map[string]interface{}{
"task_id": task.ID,
"node_name": task.NodeName,
})
}
// ExpenseTypes GET /api/oa/expense/types 费用类型下拉(全局通用 + 本租户专有,仅启用)。 // ExpenseTypes GET /api/oa/expense/types 费用类型下拉(全局通用 + 本租户专有,仅启用)。
func (c *AppOaController) ExpenseTypes() { func (c *AppOaController) ExpenseTypes() {
claims, err := c.oaClaims() claims, err := c.oaClaims()
+67
View File
@@ -380,6 +380,73 @@ func (c *BackendApprovalController) FlowDelete() {
_ = c.ServeJSON() _ = c.ServeJSON()
} }
// FlowPerm POST /backend/approval/flow/perm
// 审批中心「审批权限」设置:只更新流程权限相关字段(适用部门 / 是否默认 / 启用状态),
// 不动节点编排。设为默认流时自动取消同分类(biz_type)下其它流程的默认标记。
func (c *BackendApprovalController) FlowPerm() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
var payload struct {
ID uint64 `json:"id"`
ApplyDeptIDs []uint64 `json:"apply_dept_ids"`
IsDefault int8 `json:"is_default"`
Status int8 `json:"status"`
}
if err := c.readTenantBody(&payload); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if payload.ID == 0 {
jsonErr(&c.Controller, 400, 400, "无效ID")
return
}
var f models.TenantApprovalFlow
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", payload.ID).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
now := time.Now()
params := orm.Params{
"apply_dept_ids": joinUintCSV(payload.ApplyDeptIDs),
"is_default": payload.IsDefault,
"status": payload.Status,
"update_time": now,
}
if _, err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", payload.ID).Filter("tid", tid).Update(params); err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败")
return
}
// 设为默认流:取消同分类其它流程的默认标记,保证每个分类只有一条默认流
if payload.IsDefault == 1 {
models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("biz_type", f.BizType).
Filter("id__ne", payload.ID).Filter("is_deleted", 0).
Update(orm.Params{"is_default": 0, "update_time": now})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"}
_ = c.ServeJSON()
}
// joinUintCSV 把 ID 列表拼成逗号分隔串(与 parseIDSlice 互逆)。
func joinUintCSV(ids []uint64) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
parts = append(parts, strconv.FormatUint(id, 10))
}
}
return strings.Join(parts, ",")
}
// Options GET /backend/approval/options // Options GET /backend/approval/options
// 编排界面数据源:当前租户的功能模块 + 审批人候选(用户 / 角色 / 部门 / 职位)。 // 编排界面数据源:当前租户的功能模块 + 审批人候选(用户 / 角色 / 部门 / 职位)。
func (c *BackendApprovalController) Options() { func (c *BackendApprovalController) Options() {
+382
View File
@@ -0,0 +1,382 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"time"
"server/models"
"server/pkg/jwtutil"
approvalEngine "server/services/approval"
beego "github.com/beego/beego/v2/server/web"
)
// ApprovalCenterController 审批中心(OA 审批工作台):
// - 待我审批 / 我已审批 / 抄送我的:按当前用户查询运行期任务(yz_approval_task);
// - 我发起的:按提交人查询审批实例(yz_approval_instance);
// - 通用审批操作:复用运行期引擎 services/approval 的 Approve(或签/会签/多级推进均在引擎内),
// 业务侧回调(如报销单状态同步、审批记录时间线)按 biz_type 分发。
//
// 目前走引擎的 OA 业务为报销(biz_type=reimburse,回退 oa),后续新业务接入后无需改本控制器。
type ApprovalCenterController struct {
beego.Controller
}
// centerTaskType 任务列表视角:pending-待我审批 done-我已审批 cc-抄送我的
const centerTaskTypeAll = "pending|done|cc"
func (c *ApprovalCenterController) centerJSON(httpStatus, bizCode int, msg string, data interface{}) {
c.Ctx.Output.SetStatus(httpStatus)
resp := map[string]interface{}{"code": bizCode, "msg": msg}
if data != nil {
resp["data"] = data
}
c.Data["json"] = resp
_ = c.ServeJSON()
}
// centerClaims 解析 backend 令牌。
func (c *ApprovalCenterController) centerClaims() (*jwtutil.Claims, error) {
return requireBackend(&c.Controller)
}
// centerTaskView 任务行(含实例概要信息),列表与操作返回共用。
type centerTaskView struct {
TaskID uint64 `json:"task_id"`
NodeName string `json:"node_name"`
NodeSort int `json:"node_sort"`
MultiMode int8 `json:"multi_mode"`
Status int8 `json:"status"`
Comment string `json:"comment"`
FinishTime string `json:"finish_time"`
Deadline string `json:"deadline"`
InstanceID uint64 `json:"instance_id"`
InstanceStatus int8 `json:"instance_status"`
BizType string `json:"biz_type"`
BizID uint64 `json:"biz_id"`
BizTitle string `json:"biz_title"`
FlowName string `json:"flow_name"`
SubmitUserID uint64 `json:"submit_user_id"`
SubmitUserName string `json:"submit_user_name"`
SubmitTime string `json:"submit_time"`
CurrentNode string `json:"current_node"`
}
// TaskList GET /backend/approval/center/task/list?type=pending|done|cc&page=&pageSize=
// 我的任务列表。pending=待我审批(status=待办);done=我已审批(通过/驳回);
// cc=抄送我的(通知节点生成的已通知任务)。
func (c *ApprovalCenterController) TaskList() {
claims, err := c.centerClaims()
if err != nil {
c.centerJSON(401, 401, err.Error(), nil)
return
}
taskType := c.GetString("type")
switch taskType {
case "pending", "done", "cc":
default:
c.centerJSON(400, 400, "type 参数须为 "+centerTaskTypeAll, nil)
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
qs := models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("scope", models.ApprovalScopeTenant).
Filter("tid", claims.TenantId).
Filter("approver_id", claims.UserID).
Filter("is_deleted", 0)
switch taskType {
case "pending":
qs = qs.Filter("status", models.ApprovalTaskPending)
case "done":
qs = qs.Filter("status__in", []int8{models.ApprovalTaskApproved, models.ApprovalTaskRejected})
case "cc":
qs = qs.Filter("status", models.ApprovalTaskNotified)
}
total, _ := qs.Count()
var tasks []models.ApprovalTask
if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&tasks); err != nil {
c.centerJSON(500, 500, "查询任务失败", nil)
return
}
list := make([]centerTaskView, 0, len(tasks))
if len(tasks) > 0 {
instMap := c.loadInstanceMap(tasks)
for _, t := range tasks {
view := centerTaskView{
TaskID: t.ID,
NodeName: t.NodeName,
NodeSort: t.Sort,
MultiMode: t.MultiMode,
Status: t.Status,
Comment: t.Comment,
}
if t.FinishTime != nil {
view.FinishTime = t.FinishTime.Format("2006-01-02 15:04")
}
if t.Deadline != nil {
view.Deadline = t.Deadline.Format("2006-01-02 15:04")
}
if inst, ok := instMap[t.InstanceID]; ok {
view.InstanceID = inst.ID
view.InstanceStatus = inst.Status
view.BizType = inst.BizType
view.BizID = inst.BizID
view.BizTitle = inst.BizTitle
view.SubmitUserID = inst.SubmitUserID
view.SubmitUserName = inst.SubmitUserName
if inst.SubmitTime != nil {
view.SubmitTime = inst.SubmitTime.Format("2006-01-02 15:04")
}
view.CurrentNode = inst.CurrentNode
}
// 兜底:任务行自带的业务信息(实例被删时仍可展示)
if view.BizTitle == "" {
view.BizType = t.BizType
view.BizID = t.BizID
}
list = append(list, view)
}
}
c.centerJSON(200, 200, "ok", map[string]interface{}{"list": list, "total": total})
}
// loadInstanceMap 批量加载任务关联的实例。
func (c *ApprovalCenterController) loadInstanceMap(tasks []models.ApprovalTask) map[uint64]models.ApprovalInstance {
ids := make([]uint64, 0, len(tasks))
seen := make(map[uint64]bool)
for _, t := range tasks {
if t.InstanceID > 0 && !seen[t.InstanceID] {
seen[t.InstanceID] = true
ids = append(ids, t.InstanceID)
}
}
instMap := make(map[uint64]models.ApprovalInstance, len(ids))
if len(ids) == 0 {
return instMap
}
var insts []models.ApprovalInstance
_, _ = models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id__in", ids).All(&insts)
for _, inst := range insts {
instMap[inst.ID] = inst
}
return instMap
}
// TaskAct POST /backend/approval/center/task/act
// 通用审批操作(body: {task_id, pass, comment})。业务回调按 biz_type 分发:
// 报销类(reimburse/oa)同步单据状态并写审批记录;其它业务仅推进引擎。
func (c *ApprovalCenterController) TaskAct() {
claims, err := c.centerClaims()
if err != nil {
c.centerJSON(401, 401, err.Error(), nil)
return
}
var payload struct {
TaskID uint64 `json:"task_id"`
Pass bool `json:"pass"`
Comment string `json:"comment"`
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil || json.Unmarshal(raw, &payload) != nil || payload.TaskID == 0 {
c.centerJSON(400, 400, "参数错误", nil)
return
}
var task models.ApprovalTask
if err := models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("id", payload.TaskID).
Filter("tid", claims.TenantId).
Filter("approver_id", claims.UserID).
Filter("is_deleted", 0).One(&task); err != nil {
c.centerJSON(404, 404, "审批任务不存在", nil)
return
}
result, err := approvalEngine.Approve(task.ID, approvalEngine.ApproveInput{
ApproverID: uint64(claims.UserID),
Pass: payload.Pass,
Comment: payload.Comment,
})
if err != nil {
c.centerJSON(400, 400, err.Error(), nil)
return
}
// OA 报销业务回调:同步单据状态 + 时间线记录
if isReimburseBizType(task.BizType) {
applyReimburseApprovalResult(uint64(claims.TenantId), task.BizID,
uint64(claims.UserID), claims.Username, task.Sort,
payload.Pass, payload.Comment, result.Finished, result.InstanceStat)
}
msg := result.Message
if msg == "" {
msg = "操作成功"
}
c.centerJSON(200, 200, msg, map[string]interface{}{
"finished": result.Finished,
})
}
// InstanceList GET /backend/approval/center/instance/list?status=&page=&pageSize=
// 我发起的审批实例列表(status 可选:1审批中 2通过 3驳回 4撤回)。
func (c *ApprovalCenterController) InstanceList() {
claims, err := c.centerClaims()
if err != nil {
c.centerJSON(401, 401, err.Error(), nil)
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
qs := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("scope", models.ApprovalScopeTenant).
Filter("tid", claims.TenantId).
Filter("submit_user_id", claims.UserID).
Filter("is_deleted", 0)
if status, err := c.GetInt8("status"); err == nil && status > 0 {
qs = qs.Filter("status", status)
}
total, _ := qs.Count()
var insts []models.ApprovalInstance
if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&insts); err != nil {
c.centerJSON(500, 500, "查询失败", nil)
return
}
flowNames := c.loadFlowNames(insts)
list := make([]map[string]interface{}, 0, len(insts))
for _, inst := range insts {
row := map[string]interface{}{
"id": inst.ID,
"biz_type": inst.BizType,
"biz_id": inst.BizID,
"biz_title": inst.BizTitle,
"status": inst.Status,
"current_node": inst.CurrentNode,
"submit_time": formatCenterTime(inst.SubmitTime),
"finish_time": formatCenterTime(inst.FinishTime),
"submit_dept_name": "",
}
if name, ok := flowNames[inst.FlowID]; ok {
row["flow_name"] = name
}
list = append(list, row)
}
c.centerJSON(200, 200, "ok", map[string]interface{}{"list": list, "total": total})
}
// loadFlowNames 批量加载实例关联的流程名称。
func (c *ApprovalCenterController) loadFlowNames(insts []models.ApprovalInstance) map[uint64]string {
ids := make([]uint64, 0, len(insts))
seen := make(map[uint64]bool)
for _, inst := range insts {
if inst.FlowID > 0 && !seen[inst.FlowID] {
seen[inst.FlowID] = true
ids = append(ids, inst.FlowID)
}
}
names := make(map[uint64]string, len(ids))
if len(ids) == 0 {
return names
}
var flows []models.TenantApprovalFlow
_, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).Filter("id__in", ids).All(&flows)
for _, f := range flows {
names[f.ID] = f.FlowName
}
return names
}
// InstanceDetail GET /backend/approval/center/instance/detail/:id
// 实例详情:实例信息 + 节点任务时间线(含通知/抄送任务),按 sort 升序。
func (c *ApprovalCenterController) InstanceDetail() {
claims, err := c.centerClaims()
if err != nil {
c.centerJSON(401, 401, err.Error(), nil)
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.centerJSON(400, 400, "无效ID", nil)
return
}
var inst models.ApprovalInstance
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("id", id).
Filter("tid", claims.TenantId).
Filter("is_deleted", 0).One(&inst); err != nil {
c.centerJSON(404, 404, "审批实例不存在", nil)
return
}
var tasks []models.ApprovalTask
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).
Filter("is_deleted", 0).
OrderBy("sort", "id").All(&tasks)
timeline := make([]map[string]interface{}, 0, len(tasks))
for _, t := range tasks {
timeline = append(timeline, map[string]interface{}{
"task_id": t.ID,
"node_key": t.NodeKey,
"node_name": t.NodeName,
"node_sort": t.Sort,
"approver_id": t.ApproverID,
"approver": t.ApproverName,
"status": t.Status,
"comment": t.Comment,
"finish_time": formatCenterTime(t.FinishTime),
})
}
flowName := ""
if name, ok := c.loadFlowNames([]models.ApprovalInstance{inst})[inst.FlowID]; ok {
flowName = name
}
c.centerJSON(200, 200, "ok", map[string]interface{}{
"id": inst.ID,
"biz_type": inst.BizType,
"biz_id": inst.BizID,
"biz_title": inst.BizTitle,
"status": inst.Status,
"flow_name": flowName,
"submit_user_name": inst.SubmitUserName,
"submit_time": formatCenterTime(inst.SubmitTime),
"finish_time": formatCenterTime(inst.FinishTime),
"current_node": inst.CurrentNode,
"timeline": timeline,
})
}
// formatCenterTime 时间指针格式化(空返回空串)。
func formatCenterTime(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02 15:04")
}
+296 -14
View File
@@ -2,6 +2,7 @@ package controllers
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"path" "path"
@@ -13,6 +14,7 @@ import (
"server/models" "server/models"
"server/pkg/jwtutil" "server/pkg/jwtutil"
"server/services" "server/services"
"server/services/approval"
"github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
@@ -355,8 +357,39 @@ func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword strin
if pageSize < 1 || pageSize > 100 { if pageSize < 1 || pageSize > 100 {
pageSize = 10 pageSize = 10
} }
qs := models.Orm.QueryTable(new(models.BackendReimbursement)). qs := models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("tid", claims.TenantId).Filter("user_id", claims.UserID).Filter("is_deleted", 0) Filter("tid", claims.TenantId).Filter("user_id", claims.UserID).Filter("is_deleted", 0)
// 待我审批(status=-1 约定值):审批人视角,切换为「我名下运行中实例的待办单据」
// 查询(yz_approval_task.approver_id=当前用户),不再按创建人 user_id 过滤
mineOnly := hasStatus && status == -1
if mineOnly {
hasStatus = false
taskQs := models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("scope", models.ApprovalScopeTenant).
Filter("tid", claims.TenantId).
Filter("approver_id", claims.UserID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
Filter("biz_type__in", reimburseFallbackBizTypes)
var tasks []models.ApprovalTask
if _, err := taskQs.All(&tasks, "biz_id"); err != nil {
return nil, 500, "查询审批待办失败"
}
ids := make([]uint64, 0, len(tasks))
for _, t := range tasks {
if t.BizID > 0 {
ids = append(ids, t.BizID)
}
}
if len(ids) == 0 {
return map[string]interface{}{"list": []map[string]interface{}{}, "total": 0}, 200, ""
}
qs = models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("tid", claims.TenantId).Filter("id__in", ids).Filter("is_deleted", 0)
}
if keyword != "" { if keyword != "" {
qs = qs.Filter("description__icontains", keyword) qs = qs.Filter("description__icontains", keyword)
} }
@@ -827,8 +860,99 @@ func (c *BackendReimburseController) Submit() {
c.reply(200, "success", nil) c.reply(200, "success", nil)
} }
// reimburseActPayload 审批操作请求体。
type reimburseActPayload struct {
Comment string `json:"comment"`
}
// readReimburseActPayload 读取审批意见(可空)。
func (c *BackendReimburseController) readReimburseActPayload() string {
var payload reimburseActPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
return strings.TrimSpace(payload.Comment)
}
// Approve POST /backend/reimbursements/approve/:id 通过当前审批节点(body: {comment})。
func (c *BackendReimburseController) Approve() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
code, msg, hint := reimburseActCore(claims, id, true, c.readReimburseActPayload())
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, hint, nil)
}
// Reject POST /backend/reimbursements/reject/:id 驳回报销单(body: {comment})。
func (c *BackendReimburseController) Reject() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
code, msg, hint := reimburseActCore(claims, id, false, c.readReimburseActPayload())
if msg != "" {
c.reply(code, msg, nil)
return
}
c.reply(200, hint, nil)
}
// MyApproveTask GET /backend/reimbursements/my-approve-task/:id
// 查当前用户在该单据上的待审批任务:返回 {task_id, node_name},无待办返回 data=null。
func (c *BackendReimburseController) MyApproveTask() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
task, err := reimburseMyTaskCore(claims, id)
if err != nil {
c.reply(500, "查询审批任务失败", nil)
return
}
if task == nil {
c.reply(200, "success", nil)
return
}
c.reply(200, "success", map[string]interface{}{
"task_id": task.ID,
"node_name": task.NodeName,
})
}
// reimburseFallbackBizTypes 报销匹配审批流的单据类型回退顺序:
// 先按报销专有流(biz_type=reimburse),未配置时回退到 OA 分类默认流
// (FlowSave 未显式指定单据类型时 biz_type=module_code,即 oa)。
var reimburseFallbackBizTypes = []string{models.ApprovalBizReimburse, "oa"}
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流, // reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
// 提交后状态置为审批中(1),并写入 action=1 的审批记录。 // 提交后状态置为审批中(1)并写入 action=1 的审批记录。
// 流程匹配与实例/任务创建走运行期引擎(services/approval):
// 实例落 yz_approval_instance,首节点待办落 yz_approval_task,
// 审批人在「我的待办」里处理(reimburseActCore)。
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) { func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
row, err := findOwnedReimbursement(claims, id) row, err := findOwnedReimbursement(claims, id)
if err != nil { if err != nil {
@@ -837,31 +961,189 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
if row.Status != 0 { if row.Status != 0 {
return 400, "当前状态不可提交" return 400, "当前状态不可提交"
} }
var flow models.BackendApprovalFlow
err = models.Orm.QueryTable(new(models.BackendApprovalFlow)). var deptID uint64
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0). if row.DepartmentID != nil {
OrderBy("step_order").One(&flow) deptID = *row.DepartmentID
if err != nil { }
// 依次尝试回退类型匹配流程;有部门限制的流程未命中时由引擎继续尝试通用流
var instID uint64
var submitErr error
for _, bizType := range reimburseFallbackBizTypes {
instID, submitErr = approval.Submit(approval.SubmitInput{
Scope: models.ApprovalScopeTenant,
Tid: uint64(claims.TenantId),
BizTid: uint64(claims.TenantId),
BizType: bizType,
BizID: id,
BizTitle: claims.Username + "的报销单",
SubmitUID: uint64(claims.UserID),
SubmitName: claims.Username,
DeptID: deptID,
})
if submitErr == nil {
break
}
var noFlow *approval.ErrNoFlow
if !errors.As(submitErr, &noFlow) {
return 400, submitErr.Error()
}
}
if submitErr != nil {
return 400, "尚未配置审批流程" return 400, "尚未配置审批流程"
} }
// 实例可能无审批节点直接通过(如流程只有通知),按实例实际状态落单据状态
var inst models.ApprovalInstance
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("id", instID).One(&inst); err != nil {
return 500, "查询审批实例失败"
}
newStatus := int8(1) // 审批中
if inst.Status == models.ApprovalInstanceApproved {
newStatus = 2
}
now := time.Now() now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{ if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": 1, "current_step": flow.StepOrder, "current_approver_id": 0, "update_time": now, "status": newStatus, "current_approver_id": 0, "update_time": now,
}) }); err != nil {
if err != nil {
return 500, "提交失败" return 500, "提交失败"
} }
_, err = models.Orm.Insert(&models.BackendApprovalRecord{ if _, err := models.Orm.Insert(&models.BackendApprovalRecord{
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id, Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1, ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1,
Step: flow.StepOrder, PrevStep: 0, }); err != nil {
})
if err != nil {
return 500, "提交审批记录失败" return 500, "提交审批记录失败"
} }
return 200, "" return 200, ""
} }
// findRunningReimburseInstance 按报销单 ID 查运行中的审批实例
// (实例的 biz_type 取发起时命中的类型,按回退顺序逐一查找)。
func findRunningReimburseInstance(tid, id uint64) (*models.ApprovalInstance, error) {
for _, bizType := range reimburseFallbackBizTypes {
var inst models.ApprovalInstance
err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("tid", tid).Filter("biz_type", bizType).Filter("biz_id", id).
Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).
OrderBy("-id").One(&inst)
if err == nil {
return &inst, nil
}
if err != orm.ErrNoRows {
return nil, err
}
}
return nil, orm.ErrNoRows
}
// reimburseMyTaskCore 查当前用户在指定报销单上的待审批任务(无则返回 nil)。
func reimburseMyTaskCore(claims *jwtutil.Claims, id uint64) (*models.ApprovalTask, error) {
inst, err := findRunningReimburseInstance(uint64(claims.TenantId), id)
if err != nil {
return nil, nil // 无运行中实例:不是「错误」,只是没有待办
}
var task models.ApprovalTask
err = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).
Filter("approver_id", claims.UserID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
One(&task)
if err == orm.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &task, nil
}
// reimburseActCore 审批操作共享逻辑:当前用户必须是该单当前节点的待办审批人,
// 通过/驳回经运行期引擎推进(或签/会签/多级节点均由引擎处理),
// 实例结束时同步报销单状态,并写审批记录保持时间线兼容。
// 返回 (HTTP 状态码, 错误信息, 成功提示),错误信息为空串表示成功。
func reimburseActCore(claims *jwtutil.Claims, id uint64, pass bool, comment string) (int, string, string) {
inst, err := findRunningReimburseInstance(uint64(claims.TenantId), id)
if err != nil {
return 404, "该报销单不在审批中", ""
}
var task models.ApprovalTask
err = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).
Filter("approver_id", claims.UserID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
One(&task)
if err == orm.ErrNoRows {
return 400, "您没有该单据的待审批任务", ""
}
if err != nil {
return 500, "查询审批任务失败", ""
}
result, err := approval.Approve(task.ID, approval.ApproveInput{
ApproverID: uint64(claims.UserID),
Pass: pass,
Comment: comment,
})
if err != nil {
return 400, err.Error(), ""
}
// 业务回调:写时间线记录 + 实例结束时同步单据状态
applyReimburseApprovalResult(uint64(claims.TenantId), id,
uint64(claims.UserID), claims.Username, task.Sort,
pass, comment, result.Finished, result.InstanceStat)
hint := result.Message
if hint == "" {
hint = "操作成功"
}
return 200, "", hint
}
// isReimburseBizType 业务类型是否归属报销(提交与实例落库的 biz_type 均可能命中)。
func isReimburseBizType(bizType string) bool {
for _, bt := range reimburseFallbackBizTypes {
if bt == bizType {
return true
}
}
return false
}
// applyReimburseApprovalResult 报销业务回调(报销页与审批中心两处审批入口共用):
// - 始终写一条审批记录(action: 2通过 3驳回),与既有时间线保持兼容;
// - 实例结束时同步报销单状态:通过→2,驳回→3。
func applyReimburseApprovalResult(tid, bizID, approverID uint64, approverName string,
taskSort int, pass bool, comment string, finished bool, instStat int8) {
action := int8(models.ApprovalActionApprove)
if !pass {
action = models.ApprovalActionReject
}
if _, err := models.Orm.Insert(&models.BackendApprovalRecord{
Tid: tid, UID: uint64Ptr(approverID), ReimbursementID: bizID,
ApproverID: approverID, ApproverName: approverName, Action: action,
Comment: &comment, Step: int8(taskSort),
}); err != nil {
return
}
if finished {
newStatus := int8(3)
if instStat == models.ApprovalInstanceApproved {
newStatus = 2
}
_, _ = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", bizID).
Update(orm.Params{"status": newStatus, "update_time": time.Now()})
}
}
type reimbursementStatusPayload struct { type reimbursementStatusPayload struct {
Status int8 `json:"status"` Status int8 `json:"status"`
} }
+3
View File
@@ -118,6 +118,9 @@ func Register() {
beego.Router("/api/oa/expense/item/update/:id/:item_id", &controllers.AppOaController{}, "post:ExpenseUpdateItem") beego.Router("/api/oa/expense/item/update/:id/:item_id", &controllers.AppOaController{}, "post:ExpenseUpdateItem")
beego.Router("/api/oa/expense/item/delete/:id/:item_id", &controllers.AppOaController{}, "delete:ExpenseDeleteItem") beego.Router("/api/oa/expense/item/delete/:id/:item_id", &controllers.AppOaController{}, "delete:ExpenseDeleteItem")
beego.Router("/api/oa/expense/submit/:id", &controllers.AppOaController{}, "post:ExpenseSubmit") beego.Router("/api/oa/expense/submit/:id", &controllers.AppOaController{}, "post:ExpenseSubmit")
beego.Router("/api/oa/expense/approve/:id", &controllers.AppOaController{}, "post:ExpenseApprove")
beego.Router("/api/oa/expense/reject/:id", &controllers.AppOaController{}, "post:ExpenseReject")
beego.Router("/api/oa/expense/my-approve-task/:id", &controllers.AppOaController{}, "get:ExpenseMyApproveTask")
beego.Router("/api/oa/expense/status/:id", &controllers.AppOaController{}, "post:ExpenseStatus") beego.Router("/api/oa/expense/status/:id", &controllers.AppOaController{}, "post:ExpenseStatus")
beego.Router("/api/oa/expense/records/:id", &controllers.AppOaController{}, "get:ExpenseRecords") beego.Router("/api/oa/expense/records/:id", &controllers.AppOaController{}, "get:ExpenseRecords")
+9
View File
@@ -33,6 +33,12 @@ func RegisterApprovalRoutes() {
beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete") beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete")
beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick") beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick")
beego.Router("/backend/approval/flow/available", &controllers.BackendApprovalController{}, "get:AvailableFlows") beego.Router("/backend/approval/flow/available", &controllers.BackendApprovalController{}, "get:AvailableFlows")
// 审批中心:待我审批/我已审批/抄送我的/我发起的 + 通用审批操作 + 实例进度
beego.Router("/backend/approval/center/task/list", &controllers.ApprovalCenterController{}, "get:TaskList")
beego.Router("/backend/approval/center/task/act", &controllers.ApprovalCenterController{}, "post:TaskAct")
beego.Router("/backend/approval/center/instance/list", &controllers.ApprovalCenterController{}, "get:InstanceList")
beego.Router("/backend/approval/center/instance/detail/:id", &controllers.ApprovalCenterController{}, "get:InstanceDetail")
} }
// RegisterProductRoutes 注册租户端「产品购买」路由。 // RegisterProductRoutes 注册租户端「产品购买」路由。
@@ -296,6 +302,9 @@ func RegisterAuthRoutes() {
beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType") beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType")
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType") beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType")
beego.Router("/backend/reimbursements/flows", &controllers.BackendReimburseController{}, "get:Flows") beego.Router("/backend/reimbursements/flows", &controllers.BackendReimburseController{}, "get:Flows")
beego.Router("/backend/reimbursements/approve/:id", &controllers.BackendReimburseController{}, "post:Approve")
beego.Router("/backend/reimbursements/reject/:id", &controllers.BackendReimburseController{}, "post:Reject")
beego.Router("/backend/reimbursements/my-approve-task/:id", &controllers.BackendReimburseController{}, "get:MyApproveTask")
beego.Router("/backend/reimbursements/:id/items", &controllers.BackendReimburseController{}, "post:AddItem") beego.Router("/backend/reimbursements/:id/items", &controllers.BackendReimburseController{}, "post:AddItem")
beego.Router("/backend/reimbursements/:id/items/:item_id", &controllers.BackendReimburseController{}, "post:UpdateItem;delete:DeleteItem") beego.Router("/backend/reimbursements/:id/items/:item_id", &controllers.BackendReimburseController{}, "post:UpdateItem;delete:DeleteItem")
beego.Router("/backend/reimbursements/:id", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete") beego.Router("/backend/reimbursements/:id", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete")
+15
View File
@@ -253,6 +253,21 @@ export function resolveImageUrl(url) {
return `${getBaseURL().replace(/\/$/, '')}${value.startsWith('/') ? '' : '/'}${value}` return `${getBaseURL().replace(/\/$/, '')}${value.startsWith('/') ? '' : '/'}${value}`
} }
/** 查我的待审批任务(审批人视角):返回 {task_id,node_name},无待办返回 null */
export function fetchMyApproveTask(id) {
return request({ url: `/api/oa/expense/my-approve-task/${id}` })
}
/** 通过当前审批节点(body: {comment}),返回引擎提示(会签等待/流转/完成) */
export function approveExpense(id, comment = '') {
return request({ url: `/api/oa/expense/approve/${id}`, method: 'POST', data: { comment } })
}
/** 驳回报销单(body: {comment}) */
export function rejectExpense(id, comment = '') {
return request({ url: `/api/oa/expense/reject/${id}`, method: 'POST', data: { comment } })
}
/** /**
* 发票引用是否为 PDF 文件:PDF 不能用 image 组件渲染(会被 ORB 拦截不显示), * 发票引用是否为 PDF 文件:PDF 不能用 image 组件渲染(会被 ORB 拦截不显示),
* 需要走 openDocument 打开。 * 需要走 openDocument 打开。
+6 -1
View File
@@ -81,6 +81,11 @@
}, },
"vueVersion" : "3", "vueVersion" : "3",
"h5" : { "h5" : {
"title" : "云泽系统" "title" : "云泽系统",
"router" : {
"mode" : "hash",
"base" : "/h5/"
},
"publicPath" : "/h5/"
} }
} }
+83 -1
View File
@@ -97,7 +97,7 @@
</view> </view>
</template> </template>
<!-- 操作按钮(统一放详情页,仅草稿可操作) --> <!-- 操作按钮(统一放详情页):草稿为创建人操作,审批中为待办审批人操作 -->
<view v-if="detail && isDraft" class="footer"> <view v-if="detail && isDraft" class="footer">
<view class="footer-btn"> <view class="footer-btn">
<u-button type="error" plain icon="trash" text="删除" @click="onDelete" /> <u-button type="error" plain icon="trash" text="删除" @click="onDelete" />
@@ -109,6 +109,20 @@
<u-button type="primary" icon="arrow-up" text="提交审批" @click="onSubmit" /> <u-button type="primary" icon="arrow-up" text="提交审批" @click="onSubmit" />
</view> </view>
</view> </view>
<view v-else-if="detail && myTask" class="footer footer-approve">
<view class="footer-tip">
<FaIcon name="circle-check" color="#10b981" :size="14" />
<text>我是当前节点「{{ myTask.node_name }}」审批人</text>
</view>
<view class="footer-row">
<view class="footer-btn">
<u-button type="error" plain icon="close-circle" text="驳回" @click="onReject" />
</view>
<view class="footer-btn">
<u-button type="primary" icon="checkmark" text="通过" @click="onApprove" />
</view>
</view>
</view>
</view> </view>
</template> </template>
@@ -120,6 +134,9 @@ import {
deleteExpense, deleteExpense,
submitExpense, submitExpense,
fetchExpenseRecords, fetchExpenseRecords,
fetchMyApproveTask,
approveExpense,
rejectExpense,
getExpenseStatusLabel, getExpenseStatusLabel,
getExpenseStatusType, getExpenseStatusType,
getExpenseActionLabel, getExpenseActionLabel,
@@ -132,6 +149,7 @@ const expenseId = ref('')
const detail = ref(null) const detail = ref(null)
const items = ref([]) const items = ref([])
const records = ref([]) const records = ref([])
const myTask = ref(null)
const loading = ref(false) const loading = ref(false)
const isDraft = computed(() => Number(detail.value?.status) === EXPENSE_STATUS.DRAFT) const isDraft = computed(() => Number(detail.value?.status) === EXPENSE_STATUS.DRAFT)
@@ -170,6 +188,14 @@ async function loadDetail() {
detail.value = data detail.value = data
items.value = data.items || [] items.value = data.items || []
records.value = await fetchExpenseRecords(expenseId.value) records.value = await fetchExpenseRecords(expenseId.value)
// 审批人视角:审批中的单据查询自己是否有待办任务(非审批人静默返回 null)
try {
myTask.value = Number(data.status) === EXPENSE_STATUS.APPROVING
? await fetchMyApproveTask(expenseId.value)
: null
} catch (e) {
myTask.value = null
}
} catch (e) { } catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' }) uni.showToast({ title: '加载失败', icon: 'none' })
} finally { } finally {
@@ -244,6 +270,43 @@ function onDelete() {
} }
}) })
} }
/** 通过当前审批节点:成功后按引擎提示刷新(会签等待/流转/完成) */
function onApprove() {
uni.showModal({
title: '通过审批',
content: `确认通过「${myTask.value?.node_name || '审批'}」节点吗?`,
success: async (res) => {
if (!res.confirm) return
try {
const result = await approveExpense(expenseId.value, '')
uni.showToast({ title: result?.message || '已通过', icon: 'none' })
loadDetail()
} catch (e) {
// request 封装已弹出服务端错误提示
}
}
})
}
/** 驳回报销单:填写审批意见(可空) */
function onReject() {
uni.showModal({
title: '驳回',
editable: true,
placeholderText: '请输入驳回原因(选填)',
success: async (res) => {
if (!res.confirm) return
try {
await rejectExpense(expenseId.value, (res.content || '').trim())
uni.showToast({ title: '已驳回', icon: 'none' })
loadDetail()
} catch (e) {
// request 封装已弹出服务端错误提示
}
}
})
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -519,6 +582,25 @@ function onDelete() {
flex: 1; flex: 1;
} }
.footer-approve {
flex-direction: column;
gap: 12rpx;
}
.footer-row {
display: flex;
gap: 16rpx;
}
.footer-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.state-box { .state-box {
display: flex; display: flex;
justify-content: center; justify-content: center;
+2 -1
View File
@@ -82,7 +82,8 @@ import {
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const tabs = [{ value: '', label: '全部' }, ...EXPENSE_STATUS_OPTIONS] // -1 为「待我审批」约定值(后端按当前用户运行中审批待办过滤)
const tabs = [{ value: -1, label: '待我审批' }, { value: '', label: '全部' }, ...EXPENSE_STATUS_OPTIONS]
const list = ref([]) const list = ref([])
const total = ref(0) const total = ref(0)
+1
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
const rootDir = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({ export default defineConfig({
base: '/h5/',
plugins: [uni()], plugins: [uni()],
resolve: { resolve: {
alias: { alias: {