Merge branch 'master' of https://git.yunzer.cn/yunzerwebsite/yunzerwebsiteallinone
This commit is contained in:
commit
699a117678
28 files changed
+4428
-970
No files matched your search
+16
-16
@@ -58,29 +58,29 @@ export function getAvailableFlows(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 });
|
||||
// 我发起的审批实例列表
|
||||
export function getMyApprovalInstances(params) {
|
||||
return request({ url: '/backend/approval/instance/mine', method: 'get', params });
|
||||
}
|
||||
|
||||
// 通用审批操作:{ task_id, pass, comment }
|
||||
export function actCenterTask(data) {
|
||||
return request({ url: '/backend/approval/center/task/act', method: 'post', data });
|
||||
// 我作为审批人的任务列表(handled=1 返回我已处理)
|
||||
export function getApprovalTodo(params) {
|
||||
return request({ url: '/backend/approval/instance/todo', method: 'get', params });
|
||||
}
|
||||
|
||||
// 我发起的审批实例列表(status 可选:1审批中 2通过 3驳回 4撤回)
|
||||
export function getCenterInstanceList(params) {
|
||||
return request({ url: '/backend/approval/center/instance/list', method: 'get', params });
|
||||
// 审批实例详情(含流程时间线)
|
||||
export function getApprovalInstanceDetail(id) {
|
||||
return request({ url: `/backend/approval/instance/detail/${id}`, method: 'get' });
|
||||
}
|
||||
|
||||
// 审批实例详情(含节点任务时间线)
|
||||
export function getCenterInstanceDetail(id) {
|
||||
return request({ url: `/backend/approval/center/instance/detail/${id}`, method: 'get' });
|
||||
// 审批人通过 / 驳回
|
||||
export function approveApprovalInstance(data) {
|
||||
return request({ url: '/backend/approval/instance/approve', method: 'post', data });
|
||||
}
|
||||
|
||||
// 审批权限设置:只更新流程的适用部门/是否默认/启用状态(body: {id, apply_dept_ids, is_default, status})
|
||||
export function saveApprovalFlowPerm(data) {
|
||||
return request({ url: '/backend/approval/flow/perm', method: 'post', data });
|
||||
// 发起人撤回
|
||||
export function withdrawApprovalInstance(id) {
|
||||
return request({ url: `/backend/approval/instance/withdraw/${id}`, method: 'post' });
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="审批详情" size="560px" destroy-on-close>
|
||||
<div v-loading="loading">
|
||||
<template v-if="item">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="单据标题">{{
|
||||
item.biz_title || "-"
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="单据类型">{{
|
||||
item.biz_type || "-"
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="审批流程">{{
|
||||
flowName || "-"
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="发起人">{{
|
||||
item.submit_user_name || "-"
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">{{
|
||||
item.submit_time || item.create_time || "-"
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成时间">{{
|
||||
item.finish_time || "-"
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="tagType(item.status_tag)">{{
|
||||
item.status_label || "-"
|
||||
}}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">审批流程</div>
|
||||
<div class="flow">
|
||||
<div
|
||||
v-for="(node, idx) in timeline"
|
||||
:key="`${node.node_key}-${idx}`"
|
||||
class="flow-item"
|
||||
>
|
||||
<div class="flow-rail">
|
||||
<div class="avatar" :class="{ end: node.node_type === 3 }">
|
||||
{{ avatarText(node) }}
|
||||
</div>
|
||||
<span class="badge" :class="badgeClass(node)">{{
|
||||
badgeIcon(node)
|
||||
}}</span>
|
||||
<span v-if="idx !== timeline.length - 1" class="line" />
|
||||
</div>
|
||||
<div class="flow-main">
|
||||
<div class="flow-head">
|
||||
<div class="flow-title">
|
||||
<div class="flow-name">{{ nodeTitle(node) }}</div>
|
||||
<div class="flow-sub">{{ nodeSub(node) }}</div>
|
||||
</div>
|
||||
<el-tag size="small" :type="tagType(node.status_tag)">{{
|
||||
node.status_label
|
||||
}}</el-tag>
|
||||
</div>
|
||||
<div v-if="nodeTime(node)" class="flow-time">
|
||||
{{ nodeTime(node) }}
|
||||
</div>
|
||||
<div v-if="node.comment" class="flow-comment">
|
||||
{{ node.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!timeline.length" description="暂无流程节点" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button v-if="canWithdraw" @click="handleWithdraw">撤回审批</el-button>
|
||||
<template v-if="canApprove">
|
||||
<el-button type="danger" :loading="submitting" @click="handleApprove(false)"
|
||||
>驳回</el-button
|
||||
>
|
||||
<el-button type="primary" :loading="submitting" @click="handleApprove(true)"
|
||||
>通过</el-button
|
||||
>
|
||||
</template>
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
approveApprovalInstance,
|
||||
getApprovalInstanceDetail,
|
||||
withdrawApprovalInstance,
|
||||
} from "@/api/approval";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
instanceId: { type: [Number, String], default: "" },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "changed"]);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const item = ref(null);
|
||||
const timeline = ref([]);
|
||||
const flowName = ref("");
|
||||
const canApprove = ref(false);
|
||||
const canWithdraw = ref(false);
|
||||
const currentTaskId = ref(0);
|
||||
|
||||
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.instanceId],
|
||||
([open, id]) => {
|
||||
if (open && id) loadDetail(id);
|
||||
},
|
||||
);
|
||||
|
||||
async function loadDetail(id) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = responseData(await getApprovalInstanceDetail(id));
|
||||
item.value = data.item || null;
|
||||
timeline.value = data.timeline || [];
|
||||
flowName.value = data.flow_name || "";
|
||||
canApprove.value = !!data.can_approve;
|
||||
canWithdraw.value = !!data.can_withdraw;
|
||||
currentTaskId.value = data.current_task_id || 0;
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "加载审批详情失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(pass) {
|
||||
let comment = "";
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
pass ? "审批意见(可留空)" : "请填写驳回原因",
|
||||
pass ? "审批通过" : "审批驳回",
|
||||
{ inputType: "textarea", inputPlaceholder: "请输入", confirmButtonText: "确定", cancelButtonText: "取消" },
|
||||
);
|
||||
comment = value || "";
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await approveApprovalInstance({
|
||||
task_id: currentTaskId.value,
|
||||
pass,
|
||||
comment,
|
||||
});
|
||||
ElMessage.success(pass ? "已通过" : "已驳回");
|
||||
emit("changed");
|
||||
await loadDetail(props.instanceId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "操作失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWithdraw() {
|
||||
try {
|
||||
await ElMessageBox.confirm("撤回后本次审批立即结束,确认撤回吗?", "撤回确认", {
|
||||
type: "warning",
|
||||
});
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await withdrawApprovalInstance(props.instanceId);
|
||||
ElMessage.success("已撤回");
|
||||
emit("changed");
|
||||
await loadDetail(props.instanceId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "撤回失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 时间线展示辅助(与移动端保持一致的语义) ===== */
|
||||
function avatarText(node) {
|
||||
const name = String(node.approver_name || "").trim();
|
||||
return name ? name.slice(0, 1) : "系";
|
||||
}
|
||||
|
||||
function badgeClass(node) {
|
||||
if (node.is_current) return "badge-running";
|
||||
if (node.status === 1) return "badge-pass";
|
||||
if (node.status === 2) return "badge-reject";
|
||||
return "badge-skip";
|
||||
}
|
||||
|
||||
function badgeIcon(node) {
|
||||
if (node.is_current) return "·";
|
||||
if (node.status === 1) return "✓";
|
||||
if (node.status === 2) return "✕";
|
||||
return "—";
|
||||
}
|
||||
|
||||
function nodeTitle(node) {
|
||||
if (node.node_type === 1) return `${node.approver_name || "发起人"}(申请人)`;
|
||||
if (node.node_type === 3) return "流程结束";
|
||||
return node.approver_name || node.node_name || "审批节点";
|
||||
}
|
||||
|
||||
function nodeSub(node) {
|
||||
if (node.node_type === 1) return "提交申请";
|
||||
if (node.node_type === 3) return node.status_label || "";
|
||||
return node.approver_sub || node.node_name || "审批";
|
||||
}
|
||||
|
||||
function nodeTime(node) {
|
||||
const value = node.finish_time || node.create_time || "";
|
||||
return value ? String(value).slice(0, 16) : "";
|
||||
}
|
||||
|
||||
/** uview 的 tag 类型映射为 element-plus 的 tag 类型 */
|
||||
function tagType(type) {
|
||||
if (type === "error") return "danger";
|
||||
return type || "info";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.section-title {
|
||||
position: relative;
|
||||
margin: 18px 0 12px;
|
||||
padding-left: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-rail {
|
||||
width: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #60a5fa, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
|
||||
&.end {
|
||||
background: linear-gradient(135deg, #cbd5e1, #94a3b8);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
left: 26px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
z-index: 3;
|
||||
|
||||
&.badge-pass {
|
||||
background: #16a34a;
|
||||
}
|
||||
&.badge-running {
|
||||
background: #f59e0b;
|
||||
}
|
||||
&.badge-reject {
|
||||
background: #ef4444;
|
||||
}
|
||||
&.badge-skip {
|
||||
background: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
margin-top: 6px;
|
||||
border-left: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
.flow-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.flow-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flow-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.flow-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.flow-time {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.flow-comment {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,207 +1,147 @@
|
||||
<template>
|
||||
<div class="approval-center-page">
|
||||
<div class="approval-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>审批列表</h2>
|
||||
<p>查看我参与的全部审批记录</p>
|
||||
<p>处理待我审批的单据,并查看已处理记录</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-radio-group v-model="filters.handled" @change="handleScopeChange">
|
||||
<el-radio-button :value="0">待我审批</el-radio-button>
|
||||
<el-radio-button :value="1">我已处理</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-card">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item label="单据类型">
|
||||
<el-select
|
||||
v-model="filters.biz_type"
|
||||
clearable
|
||||
placeholder="全部类型"
|
||||
style="width: 160px"
|
||||
@change="loadList"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in bizTypes"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="loadList">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</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="table-scroll">
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column label="单据标题" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.instance?.biz_title || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单据类型" width="120">
|
||||
<template #default="{ row }">{{ row.instance?.biz_type || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发起人" width="120">
|
||||
<template #default="{ row }">{{ row.instance?.submit_user_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="node_name" label="审批节点" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.node_name || row.node_key || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单据状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="tagType(row.instance?.status_tag)">{{
|
||||
row.instance?.status_label || "-"
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="tagType(row.status_tag)">{{ row.status_label || "-" }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="到达时间" width="170">
|
||||
<template #default="{ row }">{{ row.create_time || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row.instance_id)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 0"
|
||||
link
|
||||
type="primary"
|
||||
@click="openDetail(row.instance_id)"
|
||||
>去处理</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :description="emptyText" /></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'"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@update:current-page="pagination.page = $event"
|
||||
@update:page-size="pagination.pageSize = $event"
|
||||
@current-change="loadCurrent"
|
||||
@size-change="loadCurrent"
|
||||
@current-change="loadList"
|
||||
@size-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressTimeline v-model="progressVisible" :instance-id="progressInstanceId" />
|
||||
<DetailDrawer v-model="detailVisible" :instance-id="detailId" @changed="loadList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import ProgressTimeline from "./components/ProgressTimeline.vue";
|
||||
import { getCenterInstanceList, getCenterTaskList } from "@/api/approval";
|
||||
import { Refresh, Search } from "@element-plus/icons-vue";
|
||||
import DetailDrawer from "./components/DetailDrawer.vue";
|
||||
import { getApprovalTodo } from "@/api/approval";
|
||||
|
||||
const activeTab = ref("done");
|
||||
const taskList = ref([]);
|
||||
const instanceList = ref([]);
|
||||
const instanceStatus = ref(undefined);
|
||||
const loading = ref(false);
|
||||
const bizTypes = [
|
||||
{ value: "reimburse", label: "报销单" },
|
||||
{ value: "oa", label: "OA 通用审批" },
|
||||
];
|
||||
|
||||
const filters = reactive({ handled: 0, biz_type: "" });
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
|
||||
const progressVisible = ref(false);
|
||||
const progressInstanceId = ref(0);
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const detailId = ref("");
|
||||
|
||||
const isMobile = ref(false);
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
};
|
||||
const emptyText = computed(() =>
|
||||
filters.handled === 1 ? "暂无已处理的审批" : "暂无待我审批的单据",
|
||||
);
|
||||
|
||||
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) => {
|
||||
const loadList = async () => {
|
||||
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,
|
||||
await getApprovalTodo({
|
||||
handled: filters.handled,
|
||||
biz_type: filters.biz_type || "",
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
);
|
||||
instanceList.value = data.list || [];
|
||||
// 前端二次过滤:类型下拉切换后即时生效
|
||||
const rows = data.list || [];
|
||||
list.value = filters.biz_type
|
||||
? rows.filter((row) => row.instance?.biz_type === filters.biz_type)
|
||||
: rows;
|
||||
pagination.total = Number(data.total || 0);
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "加载审批列表失败");
|
||||
@@ -210,103 +150,79 @@ const loadInstances = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrent = () => {
|
||||
if (activeTab.value === "submitted") loadInstances();
|
||||
else loadTasks(activeTab.value);
|
||||
};
|
||||
|
||||
const handleTabChange = () => {
|
||||
const handleScopeChange = () => {
|
||||
pagination.page = 1;
|
||||
pagination.total = 0;
|
||||
taskList.value = [];
|
||||
instanceList.value = [];
|
||||
loadCurrent();
|
||||
loadList();
|
||||
};
|
||||
|
||||
const openProgress = (row) => {
|
||||
progressInstanceId.value = row.instance_id || row.id;
|
||||
progressVisible.value = true;
|
||||
const openDetail = (id) => {
|
||||
detailId.value = id;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
loadCurrent();
|
||||
});
|
||||
function tagType(type) {
|
||||
if (type === "error") return "danger";
|
||||
return type || "info";
|
||||
}
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.approval-center-page {
|
||||
padding: 24px;
|
||||
.approval-page {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 8px;
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
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 {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 160px;
|
||||
.filter-card,
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +1,86 @@
|
||||
<template>
|
||||
<div class="approval-center-page">
|
||||
<div class="approval-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>待我审批</h2>
|
||||
<p>处理与我相关的 OA 审批事项</p>
|
||||
<h2>我的发起</h2>
|
||||
<p>查看自己发起的审批单据及当前进度</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>
|
||||
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-card">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="搜索单据标题"
|
||||
@keyup.enter="loadList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
class="status-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="loadList">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</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">
|
||||
<el-table-column prop="biz_title" label="单据标题" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.biz_title || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="biz_type" label="单据类型" width="120">
|
||||
<template #default="{ row }">{{ row.biz_type || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="flow_name" label="审批流程" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.flow_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ bizTypeLabel(row.biz_type) }}</el-tag>
|
||||
<el-tag :type="tagType(row.status_tag)">{{ row.status_label || "-" }}</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 prop="current_node" label="当前节点" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.current_node || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submit_time" label="发起时间" width="150">
|
||||
<template #default="{ row }">{{ row.submit_time || "-" }}</template>
|
||||
<el-table-column prop="submit_time" label="提交时间" width="170">
|
||||
<template #default="{ row }">{{ row.submit_time || row.create_time || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="node_name" label="审批节点" width="140">
|
||||
<template #default="{ row }">{{ row.node_name || "-" }}</template>
|
||||
<el-table-column prop="finish_time" label="完成时间" width="170">
|
||||
<template #default="{ row }">{{ row.finish_time || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deadline" label="处理时限" width="150">
|
||||
<el-table-column label="操作" fixed="right" width="140">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ overdue: isOverdue(row.deadline) }">{{
|
||||
row.deadline || "-"
|
||||
}}</span>
|
||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleWithdraw(row)"
|
||||
>撤回</el-button
|
||||
>
|
||||
</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>
|
||||
<template #empty><el-empty description="暂无发起的审批" /></template>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
@@ -57,7 +88,7 @@
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@update:current-page="pagination.page = $event"
|
||||
@update:page-size="pagination.pageSize = $event"
|
||||
@current-change="loadList"
|
||||
@@ -66,165 +97,140 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressTimeline v-model="progressVisible" :instance-id="progressInstanceId" />
|
||||
<DetailDrawer v-model="detailVisible" :instance-id="detailId" @changed="loadList" />
|
||||
</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";
|
||||
import { Refresh, Search } from "@element-plus/icons-vue";
|
||||
import DetailDrawer from "./components/DetailDrawer.vue";
|
||||
import { getMyApprovalInstances, withdrawApprovalInstance } from "@/api/approval";
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 1, label: "审批中" },
|
||||
{ value: 2, label: "已通过" },
|
||||
{ value: 3, label: "已驳回" },
|
||||
{ value: 4, label: "已撤回" },
|
||||
{ value: 5, label: "已取消" },
|
||||
];
|
||||
|
||||
const filters = reactive({ keyword: "", status: undefined });
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
|
||||
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 detailVisible = ref(false);
|
||||
const detailId = ref("");
|
||||
|
||||
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 }),
|
||||
await getMyApprovalInstances({
|
||||
...filters,
|
||||
status: filters.status ?? "",
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
);
|
||||
list.value = data.list || [];
|
||||
pagination.total = Number(data.total || 0);
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "加载待办列表失败");
|
||||
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 || "操作失败");
|
||||
}
|
||||
};
|
||||
const resetFilters = () => {
|
||||
filters.keyword = "";
|
||||
filters.status = undefined;
|
||||
pagination.page = 1;
|
||||
loadList();
|
||||
};
|
||||
|
||||
if (pass) {
|
||||
ElMessageBox.confirm(`确认通过「${row.biz_title}」的「${row.node_name}」节点吗?`, "通过审批", {
|
||||
type: "success",
|
||||
confirmButtonText: "通过",
|
||||
cancelButtonText: "取消",
|
||||
}).then(() => doAct(""));
|
||||
} else {
|
||||
ElMessageBox.prompt("请输入驳回原因", "驳回", {
|
||||
const openDetail = (id) => {
|
||||
detailId.value = id;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const handleWithdraw = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认撤回「${row.biz_title || "该单据"}」的审批吗?`, "撤回确认", {
|
||||
type: "warning",
|
||||
inputPlaceholder: "驳回原因(选填)",
|
||||
confirmButtonText: "驳回",
|
||||
cancelButtonText: "取消",
|
||||
}).then(({ value }) => doAct((value || "").trim()));
|
||||
});
|
||||
await withdrawApprovalInstance(row.id);
|
||||
ElMessage.success("已撤回");
|
||||
loadList();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close")
|
||||
ElMessage.error(error?.message || "撤回失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openProgress = (row) => {
|
||||
progressInstanceId.value = row.instance_id;
|
||||
progressVisible.value = true;
|
||||
};
|
||||
function tagType(type) {
|
||||
if (type === "error") return "danger";
|
||||
return type || "info";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
loadList();
|
||||
});
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.approval-center-page {
|
||||
padding: 24px;
|
||||
.approval-page {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 8px;
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
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;
|
||||
}
|
||||
|
||||
.filter-card,
|
||||
.table-card {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,297 +1,117 @@
|
||||
<template>
|
||||
<div class="approval-center-page">
|
||||
<div class="approval-settings-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>
|
||||
<h2>审批流设置</h2>
|
||||
<p>为本租户各功能模块编排审批流程:选择分类 → 新建流程 → 拖拽节点并保存</p>
|
||||
</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>
|
||||
<!-- 复用「系统管理 → 审批流配置」的编排界面,避免同一能力两份实现 -->
|
||||
<ApprovalFlowConfig />
|
||||
|
||||
<!-- 表单设计 -->
|
||||
<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 class="reference-card">
|
||||
<div class="section-title">流程编号引用</div>
|
||||
<p class="hint">
|
||||
保存后系统会为每个流程自动生成唯一的「审批流编号」,业务程序按编号调用:
|
||||
</p>
|
||||
<el-table v-loading="loading" :data="flows" size="small" stripe>
|
||||
<el-table-column prop="module_code" label="功能模块" width="120" />
|
||||
<el-table-column prop="flow_name" label="流程名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="flow_code" label="审批流编号" width="160" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">{{
|
||||
row.status === 1 ? "启用" : "停用"
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无审批流,请先在上方新建" /></template>
|
||||
</el-table>
|
||||
</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";
|
||||
import { ElMessage } from "element-plus";
|
||||
import ApprovalFlowConfig from "@/views/system/approval/index.vue";
|
||||
import { getApprovalFlowList } 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 loading = ref(false);
|
||||
|
||||
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;
|
||||
loading.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 : [],
|
||||
}));
|
||||
flows.value = Array.isArray(data) ? data : data.list || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "加载流程列表失败");
|
||||
ElMessage.error(error?.message || "加载审批流失败");
|
||||
} finally {
|
||||
flowsLoading.value = false;
|
||||
loading.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();
|
||||
});
|
||||
onMounted(loadFlows);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.approval-center-page {
|
||||
padding: 24px;
|
||||
.approval-settings-page {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.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);
|
||||
margin: 0 0 8px;
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 8px 16px 16px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
.reference-card {
|
||||
margin-top: 16px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dept-select {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
.section-title {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -242,33 +242,16 @@
|
||||
@saved="handleItemsSaved"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-model="manualConfirmVisible"
|
||||
title="手动确认"
|
||||
width="420px"
|
||||
class="manual-confirm-dialog"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<p class="manual-tip">选择要变更的单据状态,保存后立即生效。</p>
|
||||
<el-radio-group v-model="manualStatus" class="manual-status-group">
|
||||
<el-radio
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<template #footer>
|
||||
<el-button @click="manualConfirmVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="manualSaving" @click="saveManualStatus">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<template #footer>
|
||||
<div class="detail-toolbar">
|
||||
<el-button @click="openManualConfirm">手动确认</el-button>
|
||||
<el-button
|
||||
v-if="isApproved"
|
||||
type="success"
|
||||
:loading="manualSaving"
|
||||
@click="confirmPaid"
|
||||
>
|
||||
确认到账
|
||||
</el-button>
|
||||
<div class="toolbar-right">
|
||||
<el-button
|
||||
v-if="isDraft"
|
||||
@@ -327,6 +310,8 @@ const visible = computed({
|
||||
set: value => emit('update:modelValue', value)
|
||||
})
|
||||
const isDraft = computed(() => props.detail?.status === 0)
|
||||
// 审批通过(等待报销人确认款项到账)
|
||||
const isApproved = computed(() => props.detail?.status === 2)
|
||||
|
||||
const detailApplicantName = computed(() => {
|
||||
const name = props.detail?.user_name
|
||||
@@ -341,9 +326,7 @@ const detailApplicantName = computed(() => {
|
||||
return '-'
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const manualConfirmVisible = ref(false)
|
||||
const manualSaving = ref(false)
|
||||
const manualStatus = ref(0)
|
||||
const itemVisible = ref(false)
|
||||
const batchUploading = ref(false)
|
||||
const invoiceFileInputRef = ref(null)
|
||||
@@ -712,23 +695,25 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveManualStatus = status => (status >= 0 && status <= 5 ? status : 0)
|
||||
|
||||
const openManualConfirm = () => {
|
||||
manualStatus.value = resolveManualStatus(props.detail?.status ?? 0)
|
||||
manualConfirmVisible.value = true
|
||||
}
|
||||
|
||||
const saveManualStatus = async () => {
|
||||
// 审批通过后由报销人本人确认款项到账,单据置为「已打款」
|
||||
const confirmPaid = async () => {
|
||||
if (!props.detail?.id) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认已收到 ¥${Number(props.detail.total_amount || 0).toFixed(2)} 的报销款吗?确认后单据状态变为「已打款」。`,
|
||||
'确认到账',
|
||||
{ type: 'warning', confirmButtonText: '确认已到账', cancelButtonText: '暂未到账' }
|
||||
)
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
manualSaving.value = true
|
||||
try {
|
||||
await updateReimbursementStatus(props.detail.id, { status: manualStatus.value })
|
||||
ElMessage.success('状态已更新')
|
||||
manualConfirmVisible.value = false
|
||||
await updateReimbursementStatus(props.detail.id, { status: 5 })
|
||||
ElMessage.success('已确认到账')
|
||||
emit('changed', props.detail.id)
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '更新状态失败')
|
||||
ElMessage.error(error?.message || '确认到账失败')
|
||||
} finally {
|
||||
manualSaving.value = false
|
||||
}
|
||||
@@ -1066,19 +1051,6 @@ const money = value => Number(value || 0).toFixed(2)
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.manual-tip {
|
||||
margin: 0 0 16px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manual-status-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.amount-compare-card {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
|
||||
@@ -92,6 +92,13 @@
|
||||
<el-button link type="primary" @click="openDetail(row.id)"
|
||||
>详情</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2"
|
||||
link
|
||||
type="success"
|
||||
@click="handleConfirmPaid(row)"
|
||||
>确认到账</el-button
|
||||
>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@@ -181,6 +188,7 @@ import {
|
||||
getReimbursementRecords,
|
||||
submitReimbursement,
|
||||
updateReimbursement,
|
||||
updateReimbursementStatus,
|
||||
} from "@/api/reimburse";
|
||||
|
||||
const statusOptions = [
|
||||
@@ -304,6 +312,23 @@ const handleSubmit = async (row) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 审批通过后由报销人本人确认款项到账,确认后状态变为「已打款」
|
||||
const handleConfirmPaid = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认已收到 ¥${money(row.total_amount)} 的报销款吗?确认后单据状态变为「已打款」。`,
|
||||
"确认到账",
|
||||
{ type: "warning", confirmButtonText: "确认已到账", cancelButtonText: "暂未到账" }
|
||||
);
|
||||
await updateReimbursementStatus(row.id, { status: 5 });
|
||||
ElMessage.success("已确认到账");
|
||||
loadList();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close")
|
||||
ElMessage.error(error?.message || "确认到账失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确认删除这条报销记录吗?", "删除确认", {
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
approvalEngine "server/services/approval"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// 移动端审批接口,挂在 AppOaController 上。
|
||||
//
|
||||
// 设计说明:
|
||||
// - 数据按 JWT 的 tid + user 双重隔离:发起列表仅看本人发起的审批,
|
||||
// 待办列表仅看本人作为审批人的待办;
|
||||
// - 复用 services/approval 引擎的 Submit / Approve,保证与 Web 端行为一致;
|
||||
// - 单据类型(biz_type)取审批流 yz_backend_approval_flow.biz_type;
|
||||
// 移动端不开放自由单据类型,仅允许本租户已配置的分类。
|
||||
//
|
||||
// 路由前缀:/api/oa/approval/*
|
||||
//
|
||||
// 接口清单:
|
||||
// GET /api/oa/approval/list 我发起的审批实例
|
||||
// GET /api/oa/approval/todo 我的待办(作为审批人)
|
||||
// GET /api/oa/approval/detail/:id 审批详情(含任务时间线)
|
||||
// POST /api/oa/approval/submit 提交审批
|
||||
// POST /api/oa/approval/approve 通过/驳回审批任务
|
||||
// POST /api/oa/approval/withdraw/:id 发起人撤回审批
|
||||
// GET /api/oa/approval/flows 可发起的流程清单(按分类)
|
||||
// GET /api/oa/approval/categories 单据类型(biz_type)下拉
|
||||
|
||||
// ============================ 入参 ============================
|
||||
|
||||
// oaApprovalSubmitPayload 提交审批入参
|
||||
type oaApprovalSubmitPayload struct {
|
||||
BizType string `json:"biz_type"` // 单据类型(必填)
|
||||
FlowCode string `json:"flow_code"` // 指定流程(可选;留空取该 biz_type 默认流)
|
||||
FlowID uint64 `json:"flow_id"` // 指定流程ID(可选)
|
||||
BizTitle string `json:"biz_title"` // 单据标题(必填)
|
||||
BizID uint64 `json:"biz_id"` // 关联业务单据ID(可选)
|
||||
FormData map[string]interface{} `json:"form_data"` // 表单数据
|
||||
}
|
||||
|
||||
// oaApprovalApprovePayload 审批入参
|
||||
type oaApprovalApprovePayload struct {
|
||||
TaskID uint64 `json:"task_id"` // 待审批任务ID(必填)
|
||||
Pass bool `json:"pass"` // true 通过 false 驳回
|
||||
Comment string `json:"comment"` // 审批意见
|
||||
}
|
||||
|
||||
// ============================ 工具 ============================
|
||||
|
||||
// oaApprovalIDParam 解析 :id 路由参数
|
||||
func oaApprovalIDParam(c *AppOaController) uint64 {
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// oaApprovalClaims 解析并校验 JWT
|
||||
func oaApprovalClaims(c *AppOaController) (*jwtutil.Claims, bool) {
|
||||
claims, err := c.oaClaims()
|
||||
if err != nil {
|
||||
c.oaErr(401, 401, "未登录或无权限")
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// oaApprovalEmployee 通过 JWT 找到当前登录用户对应的员工记录(用于取部门 ID)
|
||||
func oaApprovalEmployee(c *AppOaController, claims *jwtutil.Claims) *models.BackendEmployee {
|
||||
emp, ok := c.oaMyEmployee(claims)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return emp
|
||||
}
|
||||
|
||||
// oaApprovalSubmitUID 通过 JWT 拿到租户侧的 UID(SystemTenantUser.id),
|
||||
// 审批引擎 SubmitInput.SubmitUID 用的是租户侧用户 ID 而非平台 AdminUser.ID。
|
||||
func oaApprovalSubmitUID(claims *jwtutil.Claims) uint64 {
|
||||
return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
}
|
||||
|
||||
// oaApprovalReadJSON 读取并解析 JSON 请求体,失败时输出错误响应
|
||||
func oaApprovalReadJSON(c *AppOaController, dst interface{}) bool {
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.oaErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return true
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
c.oaErr(400, 400, "参数错误: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// oaApprovalUserName 取得发起人姓名(优先员工表,否则使用 JWT 用户名)
|
||||
func oaApprovalUserName(c *AppOaController, claims *jwtutil.Claims) string {
|
||||
if emp := oaApprovalEmployee(c, claims); emp != nil && emp.Name != "" {
|
||||
return emp.Name
|
||||
}
|
||||
return strings.TrimSpace(claims.Username)
|
||||
}
|
||||
|
||||
// ============================ 实例组装 ============================
|
||||
|
||||
// oaApprovalStatusText / oaApprovalStatusTag 状态展示文案/Tag 类型(uview-plus)
|
||||
func oaApprovalStatusText(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalInstanceDraft:
|
||||
return "草稿"
|
||||
case models.ApprovalInstanceRunning:
|
||||
return "审批中"
|
||||
case models.ApprovalInstanceApproved:
|
||||
return "已通过"
|
||||
case models.ApprovalInstanceRejected:
|
||||
return "已驳回"
|
||||
case models.ApprovalInstanceWithdrawn:
|
||||
return "已撤回"
|
||||
case models.ApprovalInstanceCanceled:
|
||||
return "已取消"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalStatusTagType 给前端 uview-plus u-tag 用的类型映射
|
||||
func oaApprovalStatusTagType(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalInstanceRunning:
|
||||
return "warning"
|
||||
case models.ApprovalInstanceApproved:
|
||||
return "success"
|
||||
case models.ApprovalInstanceRejected:
|
||||
return "error"
|
||||
case models.ApprovalInstanceWithdrawn, models.ApprovalInstanceCanceled:
|
||||
return "info"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalInstanceView 把 ApprovalInstance 组装为移动端列表/详情通用的视图结构
|
||||
func oaApprovalInstanceView(inst models.ApprovalInstance) map[string]interface{} {
|
||||
submitTime := ""
|
||||
if inst.SubmitTime != nil {
|
||||
submitTime = inst.SubmitTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
finishTime := ""
|
||||
if inst.FinishTime != nil {
|
||||
finishTime = inst.FinishTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
createTime := ""
|
||||
if !inst.CreateTime.IsZero() {
|
||||
createTime = inst.CreateTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": inst.ID,
|
||||
"biz_type": inst.BizType,
|
||||
"flow_id": inst.FlowID,
|
||||
"biz_id": inst.BizID,
|
||||
"biz_title": inst.BizTitle,
|
||||
"submit_user_id": inst.SubmitUserID,
|
||||
"submit_user_name": inst.SubmitUserName,
|
||||
"submit_dept_id": inst.SubmitDeptID,
|
||||
"current_node": inst.CurrentNode,
|
||||
"status": inst.Status,
|
||||
"status_label": oaApprovalStatusText(inst.Status),
|
||||
"status_tag": oaApprovalStatusTagType(inst.Status),
|
||||
"submit_time": submitTime,
|
||||
"finish_time": finishTime,
|
||||
"create_time": createTime,
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalBizTypes 返回本租户已配置(启用 + 未删除)的 biz_type 列表
|
||||
func oaApprovalBizTypes(tid uint64) []map[string]interface{} {
|
||||
var flows []models.TenantApprovalFlow
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1).
|
||||
OrderBy("module_code", "id").All(&flows)
|
||||
|
||||
seen := map[string]bool{}
|
||||
list := make([]map[string]interface{}, 0, len(flows))
|
||||
for _, f := range flows {
|
||||
biz := strings.TrimSpace(f.BizType)
|
||||
if biz == "" {
|
||||
biz = strings.TrimSpace(f.ModuleCode)
|
||||
}
|
||||
if biz == "" || seen[biz] {
|
||||
continue
|
||||
}
|
||||
seen[biz] = true
|
||||
list = append(list, map[string]interface{}{
|
||||
"value": biz,
|
||||
"label": biz,
|
||||
"module_code": f.ModuleCode,
|
||||
})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i]["value"].(string) < list[j]["value"].(string)
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// ============================ 任务时间线 ============================
|
||||
|
||||
// oaApprovalTasksByInstance 取出某实例下的全部任务(按 sort / id 升序),
|
||||
// 同时按 (instance_id, node_key) 找当前节点的当前审批人。
|
||||
func oaApprovalTasksByInstance(instanceID uint64) []models.ApprovalTask {
|
||||
var tasks []models.ApprovalTask
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", instanceID).Filter("is_deleted", 0).
|
||||
OrderBy("sort", "id").All(&tasks)
|
||||
return tasks
|
||||
}
|
||||
|
||||
// oaApprovalTaskText / oaApprovalTaskTag 任务级状态文案/Tag 类型
|
||||
func oaApprovalTaskText(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalTaskPending:
|
||||
return "待审批"
|
||||
case models.ApprovalTaskApproved:
|
||||
return "已通过"
|
||||
case models.ApprovalTaskRejected:
|
||||
return "已驳回"
|
||||
case models.ApprovalTaskReturned:
|
||||
return "已退回"
|
||||
case models.ApprovalTaskSkipped:
|
||||
return "已跳过"
|
||||
case models.ApprovalTaskTimeout:
|
||||
return "已超时"
|
||||
case models.ApprovalTaskNotified:
|
||||
return "已通知"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
func oaApprovalTaskTag(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalTaskPending:
|
||||
return "warning"
|
||||
case models.ApprovalTaskApproved:
|
||||
return "success"
|
||||
case models.ApprovalTaskRejected:
|
||||
return "error"
|
||||
case models.ApprovalTaskReturned, models.ApprovalTaskSkipped, models.ApprovalTaskTimeout:
|
||||
return "info"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalTaskView 任务视图(移动端流程时间线用)
|
||||
// nodeType 来自流程定义(开始/审批/通知/结束),取不到时按审批节点处理。
|
||||
func oaApprovalTaskView(t models.ApprovalTask, isCurrent bool, nodeType int8) map[string]interface{} {
|
||||
finishTime := ""
|
||||
if t.FinishTime != nil {
|
||||
finishTime = t.FinishTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
deadline := ""
|
||||
if t.Deadline != nil {
|
||||
deadline = t.Deadline.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if nodeType == 0 {
|
||||
nodeType = models.ApprovalNodeApproval
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": t.ID,
|
||||
"node_key": t.NodeKey,
|
||||
"node_name": t.NodeName,
|
||||
"node_type": nodeType,
|
||||
"approver_id": t.ApproverID,
|
||||
"approver_name": t.ApproverName,
|
||||
"approver_sub": oaApprovalUserOrgName(t.Tid, t.ApproverID),
|
||||
"multi_mode": t.MultiMode,
|
||||
"status": t.Status,
|
||||
"status_label": oaApprovalTaskText(t.Status),
|
||||
"status_tag": oaApprovalTaskTag(t.Status),
|
||||
"action": t.Action,
|
||||
"comment": t.Comment,
|
||||
"is_current": isCurrent,
|
||||
"deadline": deadline,
|
||||
"finish_time": finishTime,
|
||||
"create_time": t.CreateTime.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalUserOrgName 审批人所属部门名,用作流程节点副标题(如「财务部」),取不到返回空串。
|
||||
func oaApprovalUserOrgName(tid, uid uint64) string {
|
||||
if tid == 0 || uid == 0 {
|
||||
return ""
|
||||
}
|
||||
var tu models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).Filter("uid", uid).
|
||||
Filter("delete_time__isnull", true).One(&tu); err != nil {
|
||||
return ""
|
||||
}
|
||||
if tu.OrgID == 0 {
|
||||
return ""
|
||||
}
|
||||
var org models.BackendOrganization
|
||||
if err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||
Filter("id", tu.OrgID).One(&org); err != nil {
|
||||
return ""
|
||||
}
|
||||
return org.OrgName
|
||||
}
|
||||
|
||||
// 时间线虚拟节点的 node_key:用于标识流程的开始 / 结束,前端据此区分渲染。
|
||||
const (
|
||||
oaApprovalStartKey = "__start__"
|
||||
oaApprovalEndKey = "__end__"
|
||||
)
|
||||
|
||||
// oaApprovalFlowTimeline 组装移动端「审批流程」时间线:
|
||||
// 首尾固定为提交申请(开始)与流程结束(结束),中间是流程产生的节点任务,
|
||||
// 让发起人一眼看出流程当前走到哪、是否已经走完。
|
||||
func oaApprovalFlowTimeline(inst models.ApprovalInstance, tasks []models.ApprovalTask) []map[string]interface{} {
|
||||
nodeTypes := oaApprovalNodeTypes(inst)
|
||||
timeline := make([]map[string]interface{}, 0, len(tasks)+2)
|
||||
timeline = append(timeline, oaApprovalStartNode(inst))
|
||||
for _, t := range tasks {
|
||||
timeline = append(timeline, oaApprovalTaskView(t, t.NodeKey == inst.CurrentNode, nodeTypes[t.NodeKey]))
|
||||
}
|
||||
timeline = append(timeline, oaApprovalEndNode(inst))
|
||||
return timeline
|
||||
}
|
||||
|
||||
// oaApprovalNodeTypes 读流程定义,取各节点类型(开始/审批/通知/结束)。
|
||||
// 流程被删除或改版时返回空表,调用方按审批节点兜底。
|
||||
func oaApprovalNodeTypes(inst models.ApprovalInstance) map[string]int8 {
|
||||
out := make(map[string]int8)
|
||||
flow, err := approvalEngine.LoadFlow(inst.Scope, inst.Tid, inst.BizType, inst.FlowID)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for i := range flow.Nodes {
|
||||
out[flow.Nodes[i].NodeKey] = flow.Nodes[i].NodeType
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// oaApprovalStartNode 虚拟开始节点:即发起人提交动作。
|
||||
func oaApprovalStartNode(inst models.ApprovalInstance) map[string]interface{} {
|
||||
timeStr := ""
|
||||
if inst.SubmitTime != nil {
|
||||
timeStr = inst.SubmitTime.Format("2006-01-02 15:04:05")
|
||||
} else if !inst.CreateTime.IsZero() {
|
||||
timeStr = inst.CreateTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": 0,
|
||||
"node_key": oaApprovalStartKey,
|
||||
"node_name": "提交申请",
|
||||
"node_type": models.ApprovalNodeStart,
|
||||
"approver_id": inst.SubmitUserID,
|
||||
"approver_name": inst.SubmitUserName,
|
||||
"approver_sub": oaApprovalUserOrgName(inst.Tid, inst.SubmitUserID),
|
||||
"multi_mode": 1,
|
||||
"status": models.ApprovalTaskApproved,
|
||||
"status_label": "已提交",
|
||||
"status_tag": "success",
|
||||
"action": 0,
|
||||
"comment": "",
|
||||
"is_current": false,
|
||||
"deadline": "",
|
||||
"finish_time": timeStr,
|
||||
"create_time": timeStr,
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalEndNode 虚拟结束节点:实例运行中为「待完成」,结束后展示最终结果与完成时间。
|
||||
func oaApprovalEndNode(inst models.ApprovalInstance) map[string]interface{} {
|
||||
finishTime := ""
|
||||
if inst.FinishTime != nil {
|
||||
finishTime = inst.FinishTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
end := map[string]interface{}{
|
||||
"id": 0,
|
||||
"node_key": oaApprovalEndKey,
|
||||
"node_name": "流程结束",
|
||||
"node_type": models.ApprovalNodeEnd,
|
||||
"approver_id": 0,
|
||||
"approver_name": "",
|
||||
"multi_mode": 1,
|
||||
"status": models.ApprovalTaskPending,
|
||||
"status_label": "待完成",
|
||||
"status_tag": "info",
|
||||
"action": 0,
|
||||
"comment": "",
|
||||
"is_current": false,
|
||||
"deadline": "",
|
||||
"finish_time": finishTime,
|
||||
"create_time": finishTime,
|
||||
}
|
||||
switch inst.Status {
|
||||
case models.ApprovalInstanceApproved:
|
||||
end["status"] = models.ApprovalTaskApproved
|
||||
end["status_label"] = "已通过"
|
||||
end["status_tag"] = "success"
|
||||
case models.ApprovalInstanceRejected:
|
||||
end["status"] = models.ApprovalTaskRejected
|
||||
end["status_label"] = "已驳回"
|
||||
end["status_tag"] = "error"
|
||||
case models.ApprovalInstanceWithdrawn:
|
||||
end["status"] = models.ApprovalTaskSkipped
|
||||
end["status_label"] = "已撤回"
|
||||
end["status_tag"] = "info"
|
||||
case models.ApprovalInstanceCanceled:
|
||||
end["status"] = models.ApprovalTaskSkipped
|
||||
end["status_label"] = "已取消"
|
||||
end["status_tag"] = "info"
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// ============================ 路由方法 ============================
|
||||
|
||||
// ApprovalCategories GET /api/oa/approval/categories
|
||||
// 返回本租户可用的 biz_type 下拉项,移动端「发起审批」选择单据类型使用。
|
||||
func (c *AppOaController) ApprovalCategories() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{
|
||||
"list": oaApprovalBizTypes(uint64(claims.TenantId)),
|
||||
"total": len(oaApprovalBizTypes(uint64(claims.TenantId))),
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalFlows GET /api/oa/approval/flows
|
||||
// 按 biz_type 返回本租户可发起的流程(含流程名 + 节点名列表),
|
||||
// 用于「选择流程」下拉。biz_type 为空时返回全部。
|
||||
func (c *AppOaController) ApprovalFlows() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
biz := strings.TrimSpace(c.GetString("biz_type"))
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1)
|
||||
if biz != "" {
|
||||
qs = qs.Filter("biz_type", biz)
|
||||
}
|
||||
var flows []models.TenantApprovalFlow
|
||||
_, _ = qs.OrderBy("is_default", "id").All(&flows)
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(flows))
|
||||
for _, f := range flows {
|
||||
var nodes []models.TenantApprovalNode
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).
|
||||
Filter("flow_id", f.ID).Filter("is_deleted", 0).
|
||||
OrderBy("sort", "id").All(&nodes)
|
||||
|
||||
nodeNames := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if n.NodeType == models.ApprovalNodeApproval {
|
||||
nodeNames = append(nodeNames, n.NodeName)
|
||||
}
|
||||
}
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": f.ID,
|
||||
"flow_code": f.FlowCode,
|
||||
"flow_name": f.FlowName,
|
||||
"biz_type": f.BizType,
|
||||
"module_code": f.ModuleCode,
|
||||
"is_default": f.IsDefault,
|
||||
"nodes": nodeNames,
|
||||
})
|
||||
}
|
||||
c.oaOk(map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
// ApprovalList GET /api/oa/approval/list
|
||||
// 我发起的审批实例(移动端「我的发起」列表)。
|
||||
// 支持 keyword / status / biz_type 过滤。
|
||||
func (c *AppOaController) ApprovalList() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
statusStr := strings.TrimSpace(c.GetString("status"))
|
||||
bizType := strings.TrimSpace(c.GetString("biz_type"))
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", tid).
|
||||
Filter("submit_user_id", uid).
|
||||
Filter("is_deleted", 0)
|
||||
if bizType != "" {
|
||||
qs = qs.Filter("biz_type", bizType)
|
||||
}
|
||||
if statusStr != "" {
|
||||
if s, err := strconv.Atoi(statusStr); err == nil {
|
||||
qs = qs.Filter("status", int8(s))
|
||||
}
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var rows []models.ApprovalInstance
|
||||
_, err := qs.OrderBy("-id").
|
||||
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.oaErr(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, inst := range rows {
|
||||
item := oaApprovalInstanceView(inst)
|
||||
if keyword != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.BizTitle), strings.ToLower(keyword)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalTodo GET /api/oa/approval/todo
|
||||
// 我的待办(作为审批人)。返回任务列表 + 关联实例基本信息,
|
||||
// 移动端「待我审批」直接展示。
|
||||
func (c *AppOaController) ApprovalTodo() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", tid).
|
||||
Filter("approver_id", uid).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Filter("is_deleted", 0)
|
||||
total, _ := qs.Count()
|
||||
|
||||
var tasks []models.ApprovalTask
|
||||
_, err := qs.OrderBy("-id").
|
||||
Limit(pageSize).Offset((page - 1) * pageSize).All(&tasks)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.oaErr(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
// 关联查询实例,校验实例仍在审批中(防止实例已结束但任务未及时同步)
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", t.InstanceID).Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
continue
|
||||
}
|
||||
if inst.Status != models.ApprovalInstanceRunning {
|
||||
continue
|
||||
}
|
||||
view := oaApprovalTaskView(t, true, oaApprovalNodeTypes(inst)[t.NodeKey])
|
||||
view["instance"] = oaApprovalInstanceView(inst)
|
||||
list = append(list, view)
|
||||
}
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalDetail GET /api/oa/approval/detail/:id
|
||||
// 审批详情:实例基本信息 + 任务时间线 + 当前节点可执行动作。
|
||||
func (c *AppOaController) ApprovalDetail() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
id := oaApprovalIDParam(c)
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
c.oaErr(404, 404, "审批不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限:仅发起人或当前节点的审批人可查看
|
||||
// 两者独立判断:同一个用户既可能是发起人也可能是审批人(如自审批场景)
|
||||
isSubmitter := inst.SubmitUserID == uid
|
||||
isApprover := false
|
||||
var currentTaskID uint64
|
||||
{
|
||||
var t models.ApprovalTask
|
||||
err := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", inst.ID).
|
||||
Filter("approver_id", uid).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Filter("is_deleted", 0).One(&t)
|
||||
if err == nil {
|
||||
isApprover = true
|
||||
currentTaskID = t.ID
|
||||
}
|
||||
}
|
||||
if !isSubmitter && !isApprover {
|
||||
c.oaErr(403, 403, "无权查看该审批")
|
||||
return
|
||||
}
|
||||
|
||||
tasks := oaApprovalTasksByInstance(inst.ID)
|
||||
timeline := oaApprovalFlowTimeline(inst, tasks)
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"item": oaApprovalInstanceView(inst),
|
||||
"timeline": timeline,
|
||||
"can_approve": isApprover,
|
||||
"can_withdraw": isSubmitter && inst.Status == models.ApprovalInstanceRunning,
|
||||
"current_task_id": currentTaskID,
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalSubmit POST /api/oa/approval/submit
|
||||
// 提交审批(发起)。引擎内部完成:创建实例 → 找首个审批节点 → 生成任务。
|
||||
func (c *AppOaController) ApprovalSubmit() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var p oaApprovalSubmitPayload
|
||||
if !oaApprovalReadJSON(c, &p) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.BizType) == "" {
|
||||
c.oaErr(400, 400, "请选择单据类型")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.BizTitle) == "" {
|
||||
c.oaErr(400, 400, "请填写单据标题")
|
||||
return
|
||||
}
|
||||
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
// 发起人所在部门(用于审批引擎按部门主管匹配审批人)
|
||||
var deptID uint64
|
||||
var tu models.SystemTenantUser
|
||||
if models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).One(&tu) == nil {
|
||||
deptID = tu.OrgID
|
||||
}
|
||||
|
||||
in := approvalEngine.SubmitInput{
|
||||
Scope: models.ApprovalScopeTenant,
|
||||
Tid: tid,
|
||||
BizType: strings.TrimSpace(p.BizType),
|
||||
FlowCode: strings.TrimSpace(p.FlowCode),
|
||||
FlowID: p.FlowID,
|
||||
BizID: p.BizID,
|
||||
BizTitle: strings.TrimSpace(p.BizTitle),
|
||||
SubmitUID: uid,
|
||||
SubmitName: oaApprovalUserName(c, claims),
|
||||
DeptID: deptID,
|
||||
FormData: p.FormData,
|
||||
}
|
||||
|
||||
instID, err := approvalEngine.Submit(in)
|
||||
if err != nil {
|
||||
// ErrNoFlow / ErrNoApprover 等业务错误统一以 400 返回
|
||||
c.oaErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
_ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst)
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"id": instID,
|
||||
"item": oaApprovalInstanceView(inst),
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalApprove POST /api/oa/approval/approve
|
||||
// 审批人通过/驳回一条任务。
|
||||
func (c *AppOaController) ApprovalApprove() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
var p oaApprovalApprovePayload
|
||||
if !oaApprovalReadJSON(c, &p) {
|
||||
return
|
||||
}
|
||||
if p.TaskID == 0 {
|
||||
// 发起人视角在「我的发起」详情里也会看到「通过」按钮(操作入口对齐 Web 端),
|
||||
// 但发起人不是当前审批人,没有可处理的 task:给一个明确的提示而不是含糊的「缺少任务ID」。
|
||||
c.oaErr(403, 403, "您不是当前审批人,无法通过/驳回该审批")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验任务归属本租户、当前审批人
|
||||
var task models.ApprovalTask
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("id", p.TaskID).
|
||||
Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&task); err != nil {
|
||||
c.oaErr(404, 404, "任务不存在")
|
||||
return
|
||||
}
|
||||
if task.ApproverID != uid {
|
||||
c.oaErr(403, 403, "您无权处理该任务")
|
||||
return
|
||||
}
|
||||
if task.Status != models.ApprovalTaskPending {
|
||||
c.oaErr(400, 400, "该任务已处理")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := approvalEngine.Approve(p.TaskID, approvalEngine.ApproveInput{
|
||||
ApproverID: uid,
|
||||
Pass: p.Pass,
|
||||
Comment: strings.TrimSpace(p.Comment),
|
||||
})
|
||||
if err != nil {
|
||||
c.oaErr(500, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
_ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", res.InstanceID).One(&inst)
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"finished": res.Finished,
|
||||
"instance_stat": res.InstanceStat,
|
||||
"current_node": res.CurrentNode,
|
||||
"message": res.Message,
|
||||
"item": oaApprovalInstanceView(inst),
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalWithdraw POST /api/oa/approval/withdraw/:id
|
||||
// 发起人撤回(仅审批中允许)。
|
||||
func (c *AppOaController) ApprovalWithdraw() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
id := oaApprovalIDParam(c)
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
c.oaErr(404, 404, "审批不存在")
|
||||
return
|
||||
}
|
||||
if inst.SubmitUserID != uid {
|
||||
c.oaErr(403, 403, "仅发起人可撤回")
|
||||
return
|
||||
}
|
||||
if inst.Status != models.ApprovalInstanceRunning {
|
||||
c.oaErr(400, 400, "当前状态不允许撤回")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Update(orm.Params{
|
||||
"status": models.ApprovalInstanceWithdrawn,
|
||||
"finish_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
// 同实例剩余待办置为已跳过
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", id).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now})
|
||||
|
||||
// 回写业务单据状态(如报销单置为已撤回),保持业务列表与审批中心一致
|
||||
approvalEngine.SyncBizResult(id, models.ApprovalInstanceWithdrawn)
|
||||
|
||||
var updated models.ApprovalInstance
|
||||
_ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).One(&updated)
|
||||
c.oaOk(map[string]interface{}{"item": oaApprovalInstanceView(updated)})
|
||||
}
|
||||
@@ -256,7 +256,8 @@ func (c *AppOaController) ExpenseSubmit() {
|
||||
c.oaOk(nil)
|
||||
}
|
||||
|
||||
// ExpenseStatus POST /api/oa/expense/status/:id 手动确认更改单据状态(body: {status})。
|
||||
// ExpenseStatus POST /api/oa/expense/status/:id 报销人确认款项到账(body: {status:5})。
|
||||
// 仅限「已通过」的单据确认收款,其它状态由审批引擎推进。
|
||||
func (c *AppOaController) ExpenseStatus() {
|
||||
claims, err := c.oaClaims()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
approvalEngine "server/services/approval"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendApprovalRuntimeController Web 后台「审批中心」运行时接口。
|
||||
//
|
||||
// 与移动端 /api/oa/approval/* 共用同一套审批引擎与视图函数,差别只在鉴权:
|
||||
// 移动端走 oaClaims(backend/app/platform 三类 token),后台只认 backend token。
|
||||
//
|
||||
// 接口清单:
|
||||
//
|
||||
// GET /backend/approval/instance/mine 我发起的审批
|
||||
// GET /backend/approval/instance/todo 待我审批(handled=1 返回我已处理)
|
||||
// GET /backend/approval/instance/detail/:id 审批详情(含流程时间线)
|
||||
// POST /backend/approval/instance/approve 通过 / 驳回
|
||||
// POST /backend/approval/instance/withdraw/:id 发起人撤回
|
||||
type BackendApprovalRuntimeController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendApprovalRuntimeController) fail(httpStatus, bizCode int, msg string) {
|
||||
jsonErr(&c.Controller, httpStatus, bizCode, msg)
|
||||
}
|
||||
|
||||
func (c *BackendApprovalRuntimeController) ok(data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendApprovalRuntimeController) idParam() uint64 {
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// approvalRuntimeUID 当前登录用户在租户侧的 UID(审批人以 yz_system_tenant_user.id 记录)
|
||||
func approvalRuntimeUID(claims *jwtutil.Claims) uint64 {
|
||||
return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
}
|
||||
|
||||
// ============================ 我发起的 ============================
|
||||
|
||||
// InstanceMine GET /backend/approval/instance/mine?page=&pageSize=&keyword=&status=
|
||||
// 我发起的审批实例列表。
|
||||
func (c *BackendApprovalRuntimeController) InstanceMine() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
c.fail(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := approvalRuntimeUID(claims)
|
||||
|
||||
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", tid).
|
||||
Filter("submit_user_id", uid).
|
||||
Filter("is_deleted", 0)
|
||||
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
|
||||
qs = qs.Filter("biz_title__icontains", keyword)
|
||||
}
|
||||
if status, err := c.GetInt("status"); err == nil {
|
||||
qs = qs.Filter("status", status)
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var rows []models.ApprovalInstance
|
||||
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.fail(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
view := oaApprovalInstanceView(row)
|
||||
view["flow_name"] = approvalRuntimeFlowName(tid, row.FlowID)
|
||||
list = append(list, view)
|
||||
}
|
||||
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
|
||||
}
|
||||
|
||||
// ============================ 待我审批 ============================
|
||||
|
||||
// InstanceTodo GET /backend/approval/instance/todo?handled=0|1
|
||||
// 我作为审批人的任务列表,默认取待办;handled=1 时返回我已处理过的任务。
|
||||
func (c *BackendApprovalRuntimeController) InstanceTodo() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
c.fail(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := approvalRuntimeUID(claims)
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 10)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
handled, _ := c.GetInt("handled", 0)
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", tid).
|
||||
Filter("approver_id", uid).
|
||||
Filter("is_deleted", 0)
|
||||
if handled == 1 {
|
||||
qs = qs.Filter("status__in", models.ApprovalTaskApproved, models.ApprovalTaskRejected,
|
||||
models.ApprovalTaskSkipped, models.ApprovalTaskTimeout)
|
||||
} else {
|
||||
qs = qs.Filter("status", models.ApprovalTaskPending)
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var tasks []models.ApprovalTask
|
||||
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&tasks)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.fail(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
var inst models.ApprovalInstance
|
||||
if models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", t.InstanceID).Filter("is_deleted", 0).One(&inst) != nil {
|
||||
continue
|
||||
}
|
||||
// 待办列表只保留仍在审批中的实例,已结束的实例不再展示为待办
|
||||
if handled == 0 && inst.Status != models.ApprovalInstanceRunning {
|
||||
continue
|
||||
}
|
||||
taskView := oaApprovalTaskView(t, true, oaApprovalNodeTypes(inst)[t.NodeKey])
|
||||
taskView["instance"] = oaApprovalInstanceView(inst)
|
||||
list = append(list, taskView)
|
||||
}
|
||||
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
|
||||
}
|
||||
|
||||
// ============================ 详情 ============================
|
||||
|
||||
// InstanceDetail GET /backend/approval/instance/detail/:id
|
||||
// 返回实例信息 + 首尾齐备的流程时间线(复用移动端同一套视图)。
|
||||
func (c *BackendApprovalRuntimeController) InstanceDetail() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
c.fail(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := approvalRuntimeUID(claims)
|
||||
id := c.idParam()
|
||||
if id == 0 {
|
||||
c.fail(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
c.fail(404, 404, "审批不存在")
|
||||
return
|
||||
}
|
||||
|
||||
tasks := oaApprovalTasksByInstance(inst.ID)
|
||||
timeline := oaApprovalFlowTimeline(inst, tasks)
|
||||
|
||||
// 当前用户可操作的任务(存在即说明本人是当前审批人)
|
||||
var currentTaskID uint64
|
||||
canApprove := false
|
||||
for _, t := range tasks {
|
||||
if t.ApproverID == uid && t.Status == models.ApprovalTaskPending {
|
||||
canApprove = true
|
||||
currentTaskID = t.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.ok(map[string]interface{}{
|
||||
"item": oaApprovalInstanceView(inst),
|
||||
"timeline": timeline,
|
||||
"flow_name": approvalRuntimeFlowName(tid, inst.FlowID),
|
||||
"can_approve": canApprove,
|
||||
"can_withdraw": inst.SubmitUserID == uid && inst.Status == models.ApprovalInstanceRunning,
|
||||
"current_task_id": currentTaskID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================ 审批动作 ============================
|
||||
|
||||
type approvalRuntimeApprovePayload struct {
|
||||
TaskID uint64 `json:"task_id"`
|
||||
Pass bool `json:"pass"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// Approve POST /backend/approval/instance/approve {task_id, pass, comment}
|
||||
func (c *BackendApprovalRuntimeController) Approve() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
c.fail(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := approvalRuntimeUID(claims)
|
||||
|
||||
var p approvalRuntimeApprovePayload
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if len(raw) > 0 && (err != nil || json.Unmarshal(raw, &p) != nil) {
|
||||
c.fail(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.TaskID == 0 {
|
||||
c.fail(400, 400, "缺少审批任务ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验任务归属本租户且状态为待办
|
||||
var task models.ApprovalTask
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("id", p.TaskID).Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&task); err != nil {
|
||||
c.fail(404, 404, "任务不存在")
|
||||
return
|
||||
}
|
||||
if task.ApproverID != uid {
|
||||
c.fail(403, 403, "您不是该任务的审批人")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := approvalEngine.Approve(task.ID, approvalEngine.ApproveInput{
|
||||
ApproverID: uid,
|
||||
Pass: p.Pass,
|
||||
Comment: strings.TrimSpace(p.Comment),
|
||||
})
|
||||
if err != nil {
|
||||
c.fail(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"instance_id": res.InstanceID,
|
||||
"instance_stat": res.InstanceStat,
|
||||
"finished": res.Finished,
|
||||
"message": res.Message,
|
||||
})
|
||||
}
|
||||
|
||||
// Withdraw POST /backend/approval/instance/withdraw/:id 发起人撤回(仅审批中)
|
||||
func (c *BackendApprovalRuntimeController) Withdraw() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
c.fail(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := approvalRuntimeUID(claims)
|
||||
id := c.idParam()
|
||||
if id == 0 {
|
||||
c.fail(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
c.fail(404, 404, "审批不存在")
|
||||
return
|
||||
}
|
||||
if inst.SubmitUserID != uid {
|
||||
c.fail(403, 403, "仅发起人可撤回")
|
||||
return
|
||||
}
|
||||
if inst.Status != models.ApprovalInstanceRunning {
|
||||
c.fail(400, 400, "当前状态不允许撤回")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Update(orm.Params{
|
||||
"status": models.ApprovalInstanceWithdrawn,
|
||||
"finish_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", id).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now})
|
||||
// 回写业务单据状态(如报销单置为已撤回)
|
||||
approvalEngine.SyncBizResult(id, models.ApprovalInstanceWithdrawn)
|
||||
|
||||
c.ok(nil)
|
||||
}
|
||||
|
||||
// approvalRuntimeFlowName 取流程名称(供列表/详情展示),流程被删除时返回空串。
|
||||
func approvalRuntimeFlowName(tid, flowID uint64) string {
|
||||
if flowID == 0 {
|
||||
return ""
|
||||
}
|
||||
var f models.TenantApprovalFlow
|
||||
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("id", flowID).Filter("tid", tid).One(&f); err != nil {
|
||||
return ""
|
||||
}
|
||||
return f.FlowName
|
||||
}
|
||||
+155
-178
@@ -14,7 +14,7 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
"server/services/approval"
|
||||
approvalEngine "server/services/approval"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
@@ -410,6 +410,8 @@ func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword strin
|
||||
}
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// 审批已结束但业务状态未及时回写时,读取即自愈
|
||||
row.Status = syncReimburseStatusFromInstance(&row)
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"tid": row.Tid,
|
||||
@@ -463,9 +465,58 @@ func reimburseDetailData(claims *jwtutil.Claims, id uint64, scheme, host string)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, 500, "查询明细失败: " + err.Error()
|
||||
}
|
||||
// 审批已结束但业务状态未及时回写时,读取即自愈
|
||||
row.Status = syncReimburseStatusFromInstance(row)
|
||||
return reimbursementDTO(*row, items, scheme, host), 200, ""
|
||||
}
|
||||
|
||||
// syncReimburseStatusFromInstance 报销单状态自愈:单据处于「审批中」时,
|
||||
// 按审批引擎中对应实例(biz_type=reimburse)的最终态回写业务状态。
|
||||
// 用于兜底引擎回写异常/历史数据,列表与详情读取时调用,无变化时原样返回。
|
||||
func syncReimburseStatusFromInstance(row *models.BackendReimbursement) int8 {
|
||||
if row == nil {
|
||||
return 0
|
||||
}
|
||||
if row.Status != 1 {
|
||||
return row.Status
|
||||
}
|
||||
inst, ok := findReimburseInstance(row.Tid, row.ID)
|
||||
if !ok || inst.Status == models.ApprovalInstanceRunning {
|
||||
return row.Status
|
||||
}
|
||||
approvalEngine.SyncBizResult(inst.ID, inst.Status)
|
||||
switch inst.Status {
|
||||
case models.ApprovalInstanceApproved:
|
||||
return 2
|
||||
case models.ApprovalInstanceRejected:
|
||||
return 3
|
||||
case models.ApprovalInstanceWithdrawn:
|
||||
return 4
|
||||
}
|
||||
return row.Status
|
||||
}
|
||||
|
||||
// findReimburseInstance 查报销单对应的审批实例。
|
||||
// 兼容两种 biz_type 写法(reimburse / 早期的 oa),并按 tid、biz_tid 依次匹配,
|
||||
// 这样租户侧与平台侧产生的实例都能找到。
|
||||
func findReimburseInstance(tid, bizID uint64) (*models.ApprovalInstance, bool) {
|
||||
if tid == 0 || bizID == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, key := range []string{"tid", "biz_tid"} {
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter(key, tid).
|
||||
Filter("biz_type__in", models.ApprovalBizReimburse, approvalEngine.BizTypeLegacyOA).
|
||||
Filter("biz_id", bizID).
|
||||
Filter("is_deleted", 0).
|
||||
OrderBy("-id").One(&inst); err == nil {
|
||||
return &inst, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Create POST /backend/reimbursements
|
||||
func (c *BackendReimburseController) Create() {
|
||||
claims, ok := c.claims()
|
||||
@@ -948,11 +999,12 @@ func (c *BackendReimburseController) MyApproveTask() {
|
||||
// (FlowSave 未显式指定单据类型时 biz_type=module_code,即 oa)。
|
||||
var reimburseFallbackBizTypes = []string{models.ApprovalBizReimburse, "oa"}
|
||||
|
||||
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
|
||||
// 提交后状态置为审批中(1)并写入 action=1 的审批记录。
|
||||
// 流程匹配与实例/任务创建走运行期引擎(services/approval):
|
||||
// 实例落 yz_approval_instance,首节点待办落 yz_approval_task,
|
||||
// 审批人在「我的待办」里处理(reimburseActCore)。
|
||||
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流。
|
||||
//
|
||||
// 提交进入统一审批引擎(biz_type=reimburse、biz_id=报销单ID),引擎负责节点流转;
|
||||
// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
|
||||
// 「审批中心」与「报销管理」看到的状态一致。
|
||||
// 提交后报销单置为审批中(1),并写入 action=1 的审批记录供业务详情时间线展示。
|
||||
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
||||
row, err := findOwnedReimbursement(claims, id)
|
||||
if err != nil {
|
||||
@@ -961,203 +1013,122 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
||||
if row.Status != 0 {
|
||||
return 400, "当前状态不可提交"
|
||||
}
|
||||
|
||||
var deptID uint64
|
||||
if row.DepartmentID != nil {
|
||||
deptID = *row.DepartmentID
|
||||
}
|
||||
|
||||
// 依次尝试回退类型匹配流程;有部门限制的流程未命中时由引擎继续尝试通用流
|
||||
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 {
|
||||
tid := uint64(claims.TenantId)
|
||||
flowID := findReimburseFlowID(tid)
|
||||
if flowID == 0 {
|
||||
return 400, "尚未配置审批流程"
|
||||
}
|
||||
|
||||
// 实例可能无审批节点直接通过(如流程只有通知),按实例实际状态落单据状态
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst); err != nil {
|
||||
return 500, "查询审批实例失败"
|
||||
instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
|
||||
Scope: models.ApprovalScopeTenant,
|
||||
Tid: tid,
|
||||
FlowID: flowID,
|
||||
BizType: models.ApprovalBizReimburse,
|
||||
BizID: id,
|
||||
BizTitle: reimburseBizTitle(row),
|
||||
SubmitUID: uint64(claims.UserID),
|
||||
SubmitName: claims.Username,
|
||||
DeptID: reimburseSubmitDeptID(tid, uint64(claims.UserID)),
|
||||
FormData: map[string]interface{}{
|
||||
"id": id,
|
||||
"total_amount": row.TotalAmount,
|
||||
"apply_date": row.ApplyDate.Format("2006-01-02"),
|
||||
"description": reimburseDescription(row),
|
||||
"department_id": row.DepartmentID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 400, err.Error()
|
||||
}
|
||||
newStatus := int8(1) // 审批中
|
||||
if inst.Status == models.ApprovalInstanceApproved {
|
||||
newStatus = 2
|
||||
|
||||
// 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(2)
|
||||
status := int8(1)
|
||||
var inst models.ApprovalInstance
|
||||
if e := models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).One(&inst); e == nil {
|
||||
switch inst.Status {
|
||||
case models.ApprovalInstanceApproved:
|
||||
status = 2
|
||||
case models.ApprovalInstanceRejected:
|
||||
status = 3
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
|
||||
"status": newStatus, "current_approver_id": 0, "update_time": now,
|
||||
}); err != nil {
|
||||
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
|
||||
"status": status, "current_step": 0, "current_approver_id": 0, "update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
return 500, "提交失败"
|
||||
}
|
||||
if _, err := models.Orm.Insert(&models.BackendApprovalRecord{
|
||||
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
||||
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1,
|
||||
}); err != nil {
|
||||
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
|
||||
Tid: tid, UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
||||
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: models.ApprovalActionSubmit,
|
||||
Step: 0, PrevStep: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return 500, "提交审批记录失败"
|
||||
}
|
||||
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
|
||||
}
|
||||
// findReimburseFlowID 查找报销单可用的启用审批流:
|
||||
// 优先精确匹配 biz_type=reimburse;若租户未区分单据类型(只按模块 oa 配置),回退到模块流程。
|
||||
func findReimburseFlowID(tid uint64) uint64 {
|
||||
var flow models.TenantApprovalFlow
|
||||
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("biz_type", models.ApprovalBizReimburse).
|
||||
Filter("status", 1).Filter("is_deleted", 0).
|
||||
OrderBy("-is_default", "-id").One(&flow); err == nil {
|
||||
return flow.ID
|
||||
}
|
||||
return nil, orm.ErrNoRows
|
||||
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("module_code", "oa").
|
||||
Filter("status", 1).Filter("is_deleted", 0).
|
||||
Filter("biz_type__in", "", "oa").
|
||||
OrderBy("-is_default", "-id").One(&flow); err == nil {
|
||||
return flow.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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 // 无运行中实例:不是「错误」,只是没有待办
|
||||
// reimburseSubmitDeptID 取发起人所属组织ID,供审批引擎按「部门主管」规则解析审批人。
|
||||
func reimburseSubmitDeptID(tid, uid uint64) uint64 {
|
||||
var tu models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).One(&tu); err != nil {
|
||||
return 0
|
||||
}
|
||||
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
|
||||
return tu.OrgID
|
||||
}
|
||||
|
||||
// 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, "该报销单不在审批中", ""
|
||||
// reimburseBizTitle 审批标题:优先报销说明,其次带单据号。
|
||||
func reimburseBizTitle(row *models.BackendReimbursement) string {
|
||||
if desc := reimburseDescription(row); desc != "" {
|
||||
return desc
|
||||
}
|
||||
|
||||
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
|
||||
return fmt.Sprintf("报销单 #%d", row.ID)
|
||||
}
|
||||
|
||||
// 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()})
|
||||
func reimburseDescription(row *models.BackendReimbursement) string {
|
||||
if row == nil || row.Description == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*row.Description)
|
||||
}
|
||||
|
||||
type reimbursementStatusPayload struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
|
||||
var manualConfirmStatuses = map[int8]bool{
|
||||
0: true, // 草稿
|
||||
1: true, // 审批中
|
||||
2: true, // 已通过
|
||||
3: true, // 已驳回
|
||||
4: true, // 已撤回
|
||||
5: true, // 已打款
|
||||
}
|
||||
// 报销单状态(0草稿 1审批中 2已通过 3已驳回 4已撤回 5已打款)
|
||||
const (
|
||||
reimbursementStatusApproved int8 = 2 // 已通过
|
||||
reimbursementStatusPaid int8 = 5 // 已打款
|
||||
)
|
||||
|
||||
// UpdateStatus POST /backend/reimbursements/:id/status — 手动确认更改单据状态
|
||||
// UpdateStatus POST /backend/reimbursements/:id/status — 报销人确认款项到账(置为「已打款」)。
|
||||
// 其它状态均由审批引擎/流程动作推进,不再支持手工变更。
|
||||
func (c *BackendReimburseController) UpdateStatus() {
|
||||
claims, ok := c.claims()
|
||||
if !ok {
|
||||
@@ -1187,27 +1158,33 @@ func (c *BackendReimburseController) UpdateStatus() {
|
||||
c.reply(200, "success", map[string]interface{}{"status": newStatus})
|
||||
}
|
||||
|
||||
// reimburseUpdateStatusCore 手动确认更改单据状态共享逻辑。
|
||||
// reimburseUpdateStatusCore 报销人确认款项到账共享逻辑。
|
||||
//
|
||||
// 规则:审批通过后,报销金额是否到账由报销人本人确认;确认后单据置为「已打款」。
|
||||
// 草稿/审批中/已通过/已驳回/已撤回等状态一律由流程动作与审批引擎推进,不再支持手工修改。
|
||||
// 返回 (生效状态, HTTP 状态码, 错误信息)。
|
||||
func reimburseUpdateStatusCore(claims *jwtutil.Claims, id uint64, status int8) (int8, int, string) {
|
||||
if !manualConfirmStatuses[status] {
|
||||
return 0, 400, "不支持的状态值"
|
||||
if status != reimbursementStatusPaid {
|
||||
return 0, 400, "仅支持确认收款,其它状态由审批流程自动推进"
|
||||
}
|
||||
row, err := findOwnedReimbursement(claims, id)
|
||||
if err != nil {
|
||||
return 0, 404, "报销单不存在"
|
||||
}
|
||||
if row.Status == status {
|
||||
if row.Status == reimbursementStatusPaid {
|
||||
return row.Status, 200, ""
|
||||
}
|
||||
if row.Status != reimbursementStatusApproved {
|
||||
return 0, 400, "仅审批通过的报销单可以确认到账"
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
|
||||
"status": status, "update_time": now,
|
||||
"status": reimbursementStatusPaid, "update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 500, "更新状态失败"
|
||||
}
|
||||
return status, 200, ""
|
||||
return reimbursementStatusPaid, 200, ""
|
||||
}
|
||||
|
||||
// Records GET /backend/reimbursements/:id/records
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- ============================================================
|
||||
-- OA 审批中心 —— 菜单配置(yz_system_menu)
|
||||
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_approvalcenter_menus.sql
|
||||
--
|
||||
-- 对应页面:
|
||||
-- /apps/oa/approvalcenter/mine 我的发起(我提交的单据)
|
||||
-- /apps/oa/approvalcenter/list 审批列表(待我审批 / 我已处理)
|
||||
-- /apps/oa/approvalcenter/settings 审批流设置(复用系统管理的编排能力)
|
||||
-- 注意:目录本身依赖 OA 应用根菜单 '/apps/oa',缺失时脚本不会写入任何数据。
|
||||
-- ============================================================
|
||||
|
||||
SET @oa_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 审批中心(目录) ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT @oa_id, '审批中心', '/apps/oa/approvalcenter', NULL,
|
||||
'Stamp', 99, 1, 1, '[2]', 1, 'system', '发起、处理与配置 OA 审批流程'
|
||||
FROM DUAL
|
||||
WHERE @oa_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/approvalcenter' AND delete_time IS NULL) x);
|
||||
|
||||
SET @center_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa/approvalcenter' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 我的发起 ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT @center_id, '我的发起', '/apps/oa/approvalcenter/mine', 'apps/oa/approvalcenter/mine.vue',
|
||||
'Promotion', 1, 1, 1, '[2]', 2, 'system', '我发起的审批单据与当前进度,支持撤回'
|
||||
FROM DUAL
|
||||
WHERE @center_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/approvalcenter/mine' AND delete_time IS NULL) x);
|
||||
|
||||
-- ---------- 审批列表 ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT @center_id, '审批列表', '/apps/oa/approvalcenter/list', 'apps/oa/approvalcenter/list.vue',
|
||||
'Tickets', 2, 1, 1, '[2]', 2, 'system', '待我审批 / 我已处理的任务,可直接通过或驳回'
|
||||
FROM DUAL
|
||||
WHERE @center_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/approvalcenter/list' AND delete_time IS NULL) x);
|
||||
|
||||
-- ---------- 审批流设置 ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
|
||||
SELECT @center_id, '审批流设置', '/apps/oa/approvalcenter/settings', 'apps/oa/approvalcenter/settings.vue',
|
||||
'Setting', 3, 1, 1, '[2]', 2, 'system', '按功能模块编排审批流程(复用系统管理 → 审批流配置)'
|
||||
FROM DUAL
|
||||
WHERE @center_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/approvalcenter/settings' AND delete_time IS NULL) x);
|
||||
|
||||
-- ---------- 结果校验 ----------
|
||||
SELECT id, pid, title, path, component_path, type, sort
|
||||
FROM yz_system_menu
|
||||
WHERE delete_time IS NULL AND path LIKE '/apps/oa/approvalcenter%'
|
||||
ORDER BY pid, sort;
|
||||
@@ -129,6 +129,16 @@ func Register() {
|
||||
beego.Router("/api/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree")
|
||||
beego.Router("/api/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail")
|
||||
|
||||
// 自定义审批流(移动端:我发起的 / 待我审批 / 详情 / 提交 / 通过驳回 / 撤回)
|
||||
beego.Router("/api/oa/approval/categories", &controllers.AppOaController{}, "get:ApprovalCategories")
|
||||
beego.Router("/api/oa/approval/flows", &controllers.AppOaController{}, "get:ApprovalFlows")
|
||||
beego.Router("/api/oa/approval/list", &controllers.AppOaController{}, "get:ApprovalList")
|
||||
beego.Router("/api/oa/approval/todo", &controllers.AppOaController{}, "get:ApprovalTodo")
|
||||
beego.Router("/api/oa/approval/submit", &controllers.AppOaController{}, "post:ApprovalSubmit")
|
||||
beego.Router("/api/oa/approval/approve", &controllers.AppOaController{}, "post:ApprovalApprove")
|
||||
beego.Router("/api/oa/approval/withdraw/:id", &controllers.AppOaController{}, "post:ApprovalWithdraw")
|
||||
beego.Router("/api/oa/approval/detail/:id", &controllers.AppOaController{}, "get:ApprovalDetail")
|
||||
|
||||
// 活动日志
|
||||
beego.Router("/api/activity/list", &controllers.AppActivityController{}, "get:GetList")
|
||||
|
||||
|
||||
@@ -34,11 +34,12 @@ func RegisterApprovalRoutes() {
|
||||
beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick")
|
||||
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")
|
||||
// 审批中心运行时(我发起 / 待我审批 / 详情 / 通过驳回 / 撤回)
|
||||
beego.Router("/backend/approval/instance/mine", &controllers.BackendApprovalRuntimeController{}, "get:InstanceMine")
|
||||
beego.Router("/backend/approval/instance/todo", &controllers.BackendApprovalRuntimeController{}, "get:InstanceTodo")
|
||||
beego.Router("/backend/approval/instance/detail/:id", &controllers.BackendApprovalRuntimeController{}, "get:InstanceDetail")
|
||||
beego.Router("/backend/approval/instance/approve", &controllers.BackendApprovalRuntimeController{}, "post:Approve")
|
||||
beego.Router("/backend/approval/instance/withdraw/:id", &controllers.BackendApprovalRuntimeController{}, "post:Withdraw")
|
||||
}
|
||||
|
||||
// RegisterProductRoutes 注册租户端「产品购买」路由。
|
||||
|
||||
@@ -985,6 +985,127 @@ func finishInstance(instID uint64, status int8) {
|
||||
"finish_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
SyncBizResult(instID, status)
|
||||
}
|
||||
|
||||
// 报销单状态(与 yz_backend_reimbursements.status / 前端字典 oa_reimburse_status 一致)
|
||||
const (
|
||||
reimburseStatusApproved int8 = 2 // 已通过
|
||||
reimburseStatusRejected int8 = 3 // 已驳回
|
||||
reimburseStatusWithdrawn int8 = 4 // 已撤回
|
||||
)
|
||||
|
||||
// BizTypeLegacyOA 早期报销单提交进审批引擎时写入的 biz_type(按模块 code 记录)。
|
||||
// 与 ApprovalBizReimburse 一并识别,保证按老写法产生的在途/历史单据也能正常回写。
|
||||
const BizTypeLegacyOA = "oa"
|
||||
|
||||
// IsReimburseBizType 判断审批实例是否属于 OA 报销单
|
||||
func IsReimburseBizType(bizType string) bool {
|
||||
switch strings.TrimSpace(bizType) {
|
||||
case models.ApprovalBizReimburse, BizTypeLegacyOA:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SyncBizResult 把审批实例的最终结果回写到对应业务单据,保证「审批中心」与业务列表状态一致。
|
||||
//
|
||||
// 目前覆盖 OA 报销单(biz_type=reimburse / 老写法 oa):按实例状态同步
|
||||
// yz_backend_reimbursements.status,并补一条审批记录供业务详情页时间线展示。
|
||||
// 撤回入口(不走 finishInstance)也会显式调用本函数,重复调用按最终态幂等。
|
||||
func SyncBizResult(instID uint64, status int8) {
|
||||
if instID == 0 {
|
||||
return
|
||||
}
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst); err != nil {
|
||||
return
|
||||
}
|
||||
// 无业务单据ID的实例(纯手工审批)不回写任何业务表
|
||||
if inst.BizID == 0 || !IsReimburseBizType(inst.BizType) {
|
||||
return
|
||||
}
|
||||
syncReimburseResult(&inst, status)
|
||||
}
|
||||
|
||||
// bizTenantID 业务单据所属租户:平台侧实例的 tid 恒为 0,来源租户记在 biz_tid。
|
||||
func bizTenantID(inst *models.ApprovalInstance) uint64 {
|
||||
if inst.Scope == models.ApprovalScopePlatform && inst.BizTid != 0 {
|
||||
return inst.BizTid
|
||||
}
|
||||
return inst.Tid
|
||||
}
|
||||
|
||||
// syncReimburseResult 回写报销单状态:审批通过/驳回/撤回分别对应业务状态 2/3/4。
|
||||
func syncReimburseResult(inst *models.ApprovalInstance, status int8) {
|
||||
var bizStatus, action int8
|
||||
switch status {
|
||||
case models.ApprovalInstanceApproved:
|
||||
bizStatus, action = reimburseStatusApproved, models.ApprovalActionApprove
|
||||
case models.ApprovalInstanceRejected:
|
||||
bizStatus, action = reimburseStatusRejected, models.ApprovalActionReject
|
||||
case models.ApprovalInstanceWithdrawn:
|
||||
bizStatus, action = reimburseStatusWithdrawn, models.ApprovalActionWithdraw
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
tid := bizTenantID(inst)
|
||||
if tid == 0 {
|
||||
return
|
||||
}
|
||||
var row models.BackendReimbursement
|
||||
if err := models.Orm.QueryTable(new(models.BackendReimbursement)).
|
||||
Filter("id", inst.BizID).Filter("tid", tid).
|
||||
Filter("is_deleted", 0).One(&row); err != nil {
|
||||
return
|
||||
}
|
||||
// 已是目标状态说明同步过,避免重复插入审批记录
|
||||
if row.Status == bizStatus {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).
|
||||
Filter("id", inst.BizID).Filter("tid", tid).
|
||||
Update(orm.Params{"status": bizStatus, "update_time": now}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 业务详情页的时间线读 yz_backend_approval_records,这里补记一条审批结果
|
||||
actorID, actorName, comment := lastApprovalActor(inst)
|
||||
_, _ = models.Orm.Insert(&models.BackendApprovalRecord{
|
||||
Tid: tid, UID: approvalUint64Ptr(actorID), ReimbursementID: inst.BizID,
|
||||
ApproverID: actorID, ApproverName: actorName, Action: action,
|
||||
Comment: approvalStringPtr(comment),
|
||||
})
|
||||
}
|
||||
|
||||
// lastApprovalActor 取实例上最近一次通过/驳回的审批人(撤回等场景回退到发起人)。
|
||||
func lastApprovalActor(inst *models.ApprovalInstance) (uint64, string, string) {
|
||||
var task models.ApprovalTask
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", inst.ID).
|
||||
Filter("status__in", models.ApprovalTaskApproved, models.ApprovalTaskRejected).
|
||||
OrderBy("-finish_time", "-id").One(&task); err == nil {
|
||||
return task.ApproverID, task.ApproverName, task.Comment
|
||||
}
|
||||
return inst.SubmitUserID, inst.SubmitUserName, ""
|
||||
}
|
||||
|
||||
func approvalUint64Ptr(v uint64) *uint64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func approvalStringPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// ============================ 运行时:审批 ============================
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 自定义审批流(移动端)接口封装。
|
||||
* 对应后端 go/controllers/app_approval.go 的 /api/oa/approval/* 接口。
|
||||
*
|
||||
* 数据模型:yz_approval_instance(实例)+ yz_approval_task(任务)。
|
||||
* 与 Web 端 /backend/approval/* 共用同一套数据与 services/approval 引擎,
|
||||
* 仅鉴权放宽为 app 令牌(oaClaims)。
|
||||
*/
|
||||
import { request } from '@/api/request.js'
|
||||
|
||||
// ============================ 常量 ============================
|
||||
|
||||
/** 实例状态:0-草稿 1-审批中 2-已通过 3-已驳回 4-已撤回 5-已取消 */
|
||||
export const APPROVAL_INSTANCE_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '草稿' },
|
||||
{ value: 1, label: '审批中' },
|
||||
{ value: 2, label: '已通过' },
|
||||
{ value: 3, label: '已驳回' },
|
||||
{ value: 4, label: '已撤回' },
|
||||
{ value: 5, label: '已取消' }
|
||||
]
|
||||
|
||||
/** 任务状态:0-待审批 1-已通过 2-已驳回 3-已退回 4-已跳过 5-已超时 6-已通知 */
|
||||
export const APPROVAL_TASK_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '待审批' },
|
||||
{ value: 1, label: '已通过' },
|
||||
{ value: 2, label: '已驳回' },
|
||||
{ value: 3, label: '已退回' },
|
||||
{ value: 4, label: '已跳过' },
|
||||
{ value: 5, label: '已超时' },
|
||||
{ value: 6, label: '已通知' }
|
||||
]
|
||||
|
||||
/** 多签方式:1-或签(任一通过)2-会签(全部通过) */
|
||||
export const APPROVAL_MULTI_MODE_OPTIONS = [
|
||||
{ value: 1, label: '或签' },
|
||||
{ value: 2, label: '会签' }
|
||||
]
|
||||
|
||||
/** uview-plus u-tag 类型映射 */
|
||||
const APPROVAL_STATUS_TAG = {
|
||||
0: 'info', // 草稿
|
||||
1: 'warning', // 审批中
|
||||
2: 'success', // 已通过
|
||||
3: 'error', // 已驳回
|
||||
4: 'info', // 已撤回
|
||||
5: 'info' // 已取消
|
||||
}
|
||||
|
||||
const APPROVAL_TASK_TAG = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'error',
|
||||
3: 'info',
|
||||
4: 'info',
|
||||
5: 'info',
|
||||
6: 'info'
|
||||
}
|
||||
|
||||
// ============================ 工具函数 ============================
|
||||
|
||||
function labelOf(options, value, fallback = '') {
|
||||
const hit = options.find(item => item.value === Number(value))
|
||||
return hit ? hit.label : fallback
|
||||
}
|
||||
|
||||
export function getApprovalStatusLabel(value) {
|
||||
return labelOf(APPROVAL_INSTANCE_STATUS_OPTIONS, value, '未知')
|
||||
}
|
||||
|
||||
export function getApprovalStatusTag(value) {
|
||||
return APPROVAL_STATUS_TAG[Number(value)] || 'info'
|
||||
}
|
||||
|
||||
export function getApprovalTaskStatusLabel(value) {
|
||||
return labelOf(APPROVAL_TASK_STATUS_OPTIONS, value, '未知')
|
||||
}
|
||||
|
||||
export function getApprovalTaskStatusTag(value) {
|
||||
return APPROVAL_TASK_TAG[Number(value)] || 'info'
|
||||
}
|
||||
|
||||
export function getApprovalMultiModeLabel(value) {
|
||||
return labelOf(APPROVAL_MULTI_MODE_OPTIONS, value, '或签')
|
||||
}
|
||||
|
||||
// ============================ 我的发起 ============================
|
||||
|
||||
/**
|
||||
* 获取我发起的审批实例列表(移动端「我的发起」页面)。
|
||||
* 入参:page / pageSize / keyword / status / biz_type
|
||||
* 返回:{ list, total, page, pageSize }
|
||||
*/
|
||||
export async function fetchApprovalList(params = {}) {
|
||||
const data = await request({
|
||||
url: '/api/oa/approval/list',
|
||||
data: {
|
||||
page: params.page || 1,
|
||||
pageSize: params.pageSize || 20,
|
||||
keyword: params.keyword || '',
|
||||
status: params.status === '' || params.status === undefined ? '' : Number(params.status),
|
||||
biz_type: params.bizType || ''
|
||||
}
|
||||
})
|
||||
return {
|
||||
list: (data && data.list) || [],
|
||||
total: (data && data.total) || 0,
|
||||
page: (data && data.page) || 1,
|
||||
pageSize: (data && data.pageSize) || 20
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 待我审批 ============================
|
||||
|
||||
/**
|
||||
* 获取我作为审批人的待办列表(移动端「待我审批」)。
|
||||
* 每条 task 包含当前实例的基本信息(task.instance 字段)。
|
||||
*/
|
||||
export async function fetchApprovalTodoList(params = {}) {
|
||||
const data = await request({
|
||||
url: '/api/oa/approval/todo',
|
||||
data: {
|
||||
page: params.page || 1,
|
||||
pageSize: params.pageSize || 20
|
||||
}
|
||||
})
|
||||
return {
|
||||
list: (data && data.list) || [],
|
||||
total: (data && data.total) || 0,
|
||||
page: (data && data.page) || 1,
|
||||
pageSize: (data && data.pageSize) || 20
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 详情 ============================
|
||||
|
||||
/**
|
||||
* 获取审批详情(含实例 + 任务时间线 + 权限标志)。
|
||||
* 返回结构:{ item, timeline, can_approve, can_withdraw, current_task_id }
|
||||
*/
|
||||
export async function fetchApprovalDetail(id) {
|
||||
if (!id) {
|
||||
throw new Error('缺少审批ID')
|
||||
}
|
||||
const data = await request({ url: `/api/oa/approval/detail/${id}` })
|
||||
if (!data) return null
|
||||
return {
|
||||
item: data.item || {},
|
||||
timeline: data.timeline || [],
|
||||
canApprove: !!data.can_approve,
|
||||
canWithdraw: !!data.can_withdraw,
|
||||
currentTaskID: data.current_task_id || 0
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 审批 / 撤回 ============================
|
||||
// 说明:审批不允许在此处「自由发起」,统一由具体业务模块提交
|
||||
// (如报销单在「报销管理」提交后自动进入审批流),本模块只负责查询与处理。
|
||||
|
||||
/**
|
||||
* 审批人通过 / 驳回任务。
|
||||
* payload: { task_id, pass, comment }
|
||||
*/
|
||||
export async function approveTask(payload) {
|
||||
return request({
|
||||
url: '/api/oa/approval/approve',
|
||||
method: 'POST',
|
||||
data: {
|
||||
task_id: Number(payload.taskId) || 0,
|
||||
pass: !!payload.pass,
|
||||
comment: payload.comment || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起人撤回审批(仅审批中可撤回)。
|
||||
*/
|
||||
export async function withdrawApproval(id) {
|
||||
if (!id) throw new Error('缺少审批ID')
|
||||
return request({
|
||||
url: `/api/oa/approval/withdraw/${id}`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
@@ -163,15 +163,16 @@ export async function submitExpense(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动确认更改单据状态(与 Web 端「手动确认」能力一致)
|
||||
* 报销人确认款项到账(仅「已通过」的单据可用)
|
||||
* 确认成功后单据状态变为「已打款」;其它状态由审批引擎推进,端上不可手动修改。
|
||||
*/
|
||||
export async function updateExpenseStatus(id, status) {
|
||||
export async function confirmExpensePaid(id) {
|
||||
const res = await request({
|
||||
url: `/api/oa/expense/status/${id}`,
|
||||
method: 'POST',
|
||||
data: { status }
|
||||
data: { status: EXPENSE_STATUS.PAID }
|
||||
})
|
||||
return res?.status
|
||||
return res?.status ?? EXPENSE_STATUS.PAID
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="apv-head" :style="headPad">
|
||||
<!-- 左侧:返回按钮 + 可选标题前缀 -->
|
||||
<view class="apv-head-left">
|
||||
<view class="apv-back" hover-class="apv-back-active" @tap="onBack">
|
||||
<FaIcon name="chevron-left" color="#1f2b48" :size="20" />
|
||||
</view>
|
||||
<text v-if="backText" class="apv-back-text">{{ backText }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 中间:标题 -->
|
||||
<view class="apv-head-center">
|
||||
<text class="apv-title">{{ title }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 右侧:可选操作槽(默认占位,避免标题被挤偏) -->
|
||||
<view class="apv-head-right">
|
||||
<slot name="right" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 标题 */
|
||||
title: { type: String, required: true },
|
||||
/** 返回按钮旁的文字(不传则只显示箭头图标)。常见「返回」「关闭」。 */
|
||||
backText: { type: String, default: '' },
|
||||
/**
|
||||
* 自定义返回行为。函数返回 false 时阻止默认 navigateBack。
|
||||
* 例:detail 页面有未保存修改时拦截。
|
||||
*/
|
||||
beforeBack: { type: Function, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back'])
|
||||
|
||||
function onBack() {
|
||||
if (typeof props.beforeBack === 'function' && props.beforeBack() === false) return
|
||||
emit('back')
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 })
|
||||
} else {
|
||||
// 栈内只有自己(直链进入),回到子系统首页
|
||||
uni.reLaunch({ url: '/pages/nav/nav' })
|
||||
}
|
||||
}
|
||||
|
||||
// 状态栏高度避让:胶囊可用则按胶囊底缘 + 8px,否则回退到 safe-area-inset-top
|
||||
const headPad = computed(() => mpHeadPad(8))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.apv-head {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
padding: 0 $page-padding-x;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: 0; /* mpHeadPad 已注入 paddingTop;这里不再叠加 */
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
.apv-head-left,
|
||||
.apv-head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
min-width: 120rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.apv-head-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.apv-head-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
|
||||
.apv-back {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $radius-full;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.apv-back-active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
.apv-back-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.apv-title {
|
||||
max-width: 100%;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -63,7 +63,7 @@ import { mpHeadPad } from '@/utils/mpHead.js'
|
||||
const headPad = mpHeadPad()
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
import { openMenu } from '@/utils/nav.js'
|
||||
import { openMenu, WEB_RULES } from '@/utils/nav.js'
|
||||
import { useSubsystem } from '@/utils/subsystem.js'
|
||||
import AppTabbar from '@/components/AppTabbar.vue'
|
||||
|
||||
@@ -88,13 +88,25 @@ const fallbackIcon = computed(() => {
|
||||
return map[String(sub.module || '').toLowerCase()] || 'file-lines'
|
||||
})
|
||||
|
||||
/** 判断菜单项是否命中 WEB_RULES(小程序端不再展示,由 Web 端处理) */
|
||||
function isWebEntry(menu) {
|
||||
if (!menu) return false
|
||||
const title = String(menu.title || '')
|
||||
const path = String(menu.path || '')
|
||||
return WEB_RULES.some(kw => title.includes(kw) || path.includes(kw))
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「一级分类目录」分组:
|
||||
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录(个人办公 / 人事管理 …),
|
||||
* 组内为该目录下的全部可见功能。无分类归属的快捷功能不在功能页展示,由首页「常用功能」承载。
|
||||
*/
|
||||
const groups = computed(() => {
|
||||
const menus = allMenus.value.slice().sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
// 先把命中 WEB_RULES 的菜单项过滤掉(这些是 Web 端入口,小程序功能页不展示)
|
||||
const menus = allMenus.value
|
||||
.filter(m => !isWebEntry(m))
|
||||
.slice()
|
||||
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
const byId = {}
|
||||
menus.forEach((m) => { byId[m.id] = m })
|
||||
|
||||
|
||||
@@ -366,6 +366,27 @@
|
||||
"navigationBarTitleText": "报销详情",
|
||||
"navigationBarBackgroundColor": "#F8FAFC"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/approval/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "审批列表",
|
||||
"navigationBarBackgroundColor": "#F8FAFC"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/approval/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "审批详情",
|
||||
"navigationBarBackgroundColor": "#F8FAFC"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/approval/verify",
|
||||
"style": {
|
||||
"navigationBarTitleText": "待我审批",
|
||||
"navigationBarBackgroundColor": "#F8FAFC"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 系统导航栏已带返回按钮 + 标题,无需自定义 header -->
|
||||
|
||||
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||
<!-- 顶部摘要 -->
|
||||
<view class="summary">
|
||||
<view class="summary-main">
|
||||
<text class="summary-title">{{ item.biz_title || '审批详情' }}</text>
|
||||
<text class="summary-sub">{{ item.biz_type || '' }}</text>
|
||||
</view>
|
||||
<view class="summary-status">
|
||||
<u-tag
|
||||
:text="item.status_label || ''"
|
||||
:type="item.status_tag || 'info'"
|
||||
size="medium"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<view class="card">
|
||||
<text class="card-title">基本信息</text>
|
||||
<view class="info-row">
|
||||
<text class="info-label">单据编号</text>
|
||||
<text class="info-value">#{{ item.id || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">单据类型</text>
|
||||
<text class="info-value">{{ item.biz_type || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">发起人</text>
|
||||
<text class="info-value">{{ item.submit_user_name || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">提交时间</text>
|
||||
<text class="info-value">{{ item.submit_time || item.create_time || '-' }}</text>
|
||||
</view>
|
||||
<view v-if="item.finish_time" class="info-row">
|
||||
<text class="info-label">完成时间</text>
|
||||
<text class="info-value">{{ item.finish_time }}</text>
|
||||
</view>
|
||||
<view v-if="item.current_node" class="info-row">
|
||||
<text class="info-label">当前节点</text>
|
||||
<text class="info-value">{{ item.current_node }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 审批流程 -->
|
||||
<view class="card">
|
||||
<text class="card-title">审批流程</text>
|
||||
<view v-if="!timeline.length" class="timeline-empty">暂无流程节点</view>
|
||||
<view v-else class="timeline-list">
|
||||
<view
|
||||
v-for="(task, idx) in timeline"
|
||||
:key="`${task.node_key || 'node'}-${idx}`"
|
||||
class="tl-item"
|
||||
:class="{ current: task.is_current, edge: task.node_type === 1 || task.node_type === 3 }"
|
||||
>
|
||||
<view class="tl-rail">
|
||||
<view class="tl-avatar-wrap">
|
||||
<view class="tl-avatar" :class="avatarClass(task)">{{ avatarText(task) }}</view>
|
||||
<view class="tl-badge" :class="badgeClass(task)">{{ badgeIcon(task) }}</view>
|
||||
</view>
|
||||
<view v-if="idx !== timeline.length - 1" class="tl-line" />
|
||||
</view>
|
||||
<view class="tl-main">
|
||||
<view class="tl-head">
|
||||
<view class="tl-title">
|
||||
<text class="tl-name">{{ nodeTitle(task) }}</text>
|
||||
<text v-if="nodeSub(task)" class="tl-sub">{{ nodeSub(task) }}</text>
|
||||
</view>
|
||||
<text class="tl-time">{{ nodeTime(task) }}</text>
|
||||
</view>
|
||||
<view v-if="task.comment" class="tl-comment">{{ task.comment }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏:只有有操作权限时才显示 -->
|
||||
<view v-if="showActionBar" class="action-bar">
|
||||
<!-- 当前审批人:通过 + 驳回 -->
|
||||
<view v-if="canApprove" class="btn-row">
|
||||
<view class="btn-reject" hover-class="btn-active" @tap="openApprove(false)">驳回</view>
|
||||
<view class="btn-pass" hover-class="btn-active" @tap="openApprove(true)">通过</view>
|
||||
</view>
|
||||
<!-- 发起人:仅撤回(不能审批自己的单子) -->
|
||||
<view v-else-if="canWithdraw" class="btn-row">
|
||||
<view class="btn-withdraw" hover-class="btn-active" @tap="doWithdraw">撤回审批</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 审批意见弹窗 -->
|
||||
<view v-if="showApprove" class="modal-overlay" @tap="hideApprove">
|
||||
<view class="modal-container" @tap.stop>
|
||||
<view class="modal-head">
|
||||
<text class="modal-title">{{ approveForm.pass ? '通过审批' : '驳回审批' }}</text>
|
||||
<text class="modal-close" @tap="hideApprove">✕</text>
|
||||
</view>
|
||||
<view class="approve-summary">
|
||||
<text class="summary-label">单据</text>
|
||||
<text class="summary-value">{{ item.biz_title || '-' }}</text>
|
||||
</view>
|
||||
<view class="approve-summary">
|
||||
<text class="summary-label">节点</text>
|
||||
<text class="summary-value">{{ currentNodeLabel || '-' }}</text>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="field-label">审批意见</text>
|
||||
<textarea
|
||||
v-model="approveForm.comment"
|
||||
class="field-textarea"
|
||||
placeholder="请输入审批意见(选填)"
|
||||
placeholder-class="field-placeholder"
|
||||
rows="4"
|
||||
/>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view
|
||||
class="btn-primary"
|
||||
:class="[approveForm.pass ? 'btn-pass' : 'btn-reject', { disabled: approving }]"
|
||||
@tap="doApprove"
|
||||
>
|
||||
{{ approving ? '提交中...' : (approveForm.pass ? '确认通过' : '确认驳回') }}
|
||||
</view>
|
||||
<view class="btn-cancel" hover-class="btn-active" @tap="hideApprove">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchApprovalDetail,
|
||||
approveTask,
|
||||
withdrawApproval
|
||||
} from '@/api/approval.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const item = ref({})
|
||||
const timeline = ref([])
|
||||
const canApprove = ref(false)
|
||||
const canWithdraw = ref(false)
|
||||
const currentTaskID = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const showApprove = ref(false)
|
||||
const approving = ref(false)
|
||||
const approveForm = ref({ pass: true, comment: '' })
|
||||
|
||||
const showActionBar = computed(() => canApprove.value || canWithdraw.value)
|
||||
|
||||
// 当前节点名(审批弹窗展示用)
|
||||
const currentNodeLabel = computed(() => {
|
||||
const cur = timeline.value.find(t => t.is_current)
|
||||
return cur ? (cur.node_name || cur.node_key) : ''
|
||||
})
|
||||
|
||||
onLoad((options) => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
const id = options && options.id
|
||||
if (!id) {
|
||||
uni.showToast({ title: '缺少审批ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loadDetail(id)
|
||||
})
|
||||
|
||||
async function loadDetail(id) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchApprovalDetail(id)
|
||||
item.value = data.item || {}
|
||||
timeline.value = data.timeline || []
|
||||
canApprove.value = !!data.canApprove
|
||||
canWithdraw.value = !!data.canWithdraw
|
||||
currentTaskID.value = data.currentTaskID || 0
|
||||
} catch (e) {
|
||||
// request.js 已 toast
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 头像文字:取姓名字首字 */
|
||||
function avatarText(task) {
|
||||
const name = String(task.approver_name || '').trim()
|
||||
return name ? name.slice(0, 1) : '系'
|
||||
}
|
||||
|
||||
/** 头像底色:审批人蓝色,结束节点灰色 */
|
||||
function avatarClass(task) {
|
||||
return task.node_type === 3 ? 'avatar-end' : ''
|
||||
}
|
||||
|
||||
/** 状态角标配色:进行中橙 / 已通过绿 / 已驳回红 / 其它灰 */
|
||||
function badgeClass(task) {
|
||||
if (task.is_current) return 'badge-running'
|
||||
if (task.status === 1) return 'badge-pass'
|
||||
if (task.status === 2) return 'badge-reject'
|
||||
return 'badge-skip'
|
||||
}
|
||||
|
||||
function badgeIcon(task) {
|
||||
if (task.is_current) return '·'
|
||||
if (task.status === 1) return '✓'
|
||||
if (task.status === 2) return '✕'
|
||||
return '—'
|
||||
}
|
||||
|
||||
/** 主标题:申请人带「(申请人)」标识,末尾固定「流程结束」 */
|
||||
function nodeTitle(task) {
|
||||
if (task.node_type === 1) return `${task.approver_name || '发起人'}(申请人)`
|
||||
if (task.node_type === 3) return '流程结束'
|
||||
return task.approver_name || task.node_name || '审批节点'
|
||||
}
|
||||
|
||||
/** 副标题:部门/角色,缺失时回退节点名 */
|
||||
function nodeSub(task) {
|
||||
if (task.node_type === 1) return '提交申请'
|
||||
if (task.node_type === 3) return task.status_label || ''
|
||||
return task.approver_sub || task.node_name || '审批'
|
||||
}
|
||||
|
||||
/** 时间只展示到分钟,保持与设计稿一致的紧凑排版 */
|
||||
function nodeTime(task) {
|
||||
const value = task.finish_time || task.create_time || ''
|
||||
return value ? String(value).slice(0, 16) : ''
|
||||
}
|
||||
|
||||
function openApprove(pass) {
|
||||
approveForm.value = { pass, comment: '' }
|
||||
showApprove.value = true
|
||||
}
|
||||
|
||||
function hideApprove() {
|
||||
showApprove.value = false
|
||||
}
|
||||
|
||||
async function doApprove() {
|
||||
if (approving.value) return
|
||||
// currentTaskID 为 0 表示当前用户不是审批人(典型:发起人视角),
|
||||
// 仍然把请求交给后端判定:后端会返回 403「您无权处理该任务」并由 request.js 弹 toast。
|
||||
approving.value = true
|
||||
try {
|
||||
await approveTask({
|
||||
taskId: currentTaskID.value || 0,
|
||||
pass: approveForm.value.pass,
|
||||
comment: approveForm.value.comment.trim()
|
||||
})
|
||||
uni.showToast({
|
||||
title: approveForm.value.pass ? '已通过' : '已驳回',
|
||||
icon: 'success'
|
||||
})
|
||||
hideApprove()
|
||||
|
||||
// 如果是报销单审批,跳转回报销详情页并触发刷新事件
|
||||
// (biz_type 兼容新写法 reimburse 与早期按模块记录的 oa)
|
||||
if (['reimburse', 'oa'].includes(item.value.biz_type) && item.value.biz_id) {
|
||||
uni.$emit('expense-updated', { id: item.value.biz_id })
|
||||
uni.redirectTo({
|
||||
url: `/pages/tools/expense/detail?id=${item.value.biz_id}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loadDetail(item.value.id)
|
||||
} catch (e) {
|
||||
// request.js 已 toast
|
||||
} finally {
|
||||
approving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doWithdraw() {
|
||||
uni.showModal({
|
||||
title: '确认撤回',
|
||||
content: '撤回后该审批将立即结束,确认要撤回吗?',
|
||||
confirmColor: '#f04438',
|
||||
cancelText: '取消',
|
||||
confirmText: '撤回',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await withdrawApproval(item.value.id)
|
||||
uni.showToast({ title: '已撤回', icon: 'success' })
|
||||
loadDetail(item.value.id)
|
||||
} catch (e) {
|
||||
// request.js 已 toast
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20rpx $page-padding-x;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ====== 摘要区 ====== */
|
||||
.summary {
|
||||
@include card;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 28rpx 28rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.summary-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.summary-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.summary-status {
|
||||
flex-shrink: 0;
|
||||
padding-top: 4rpx;
|
||||
}
|
||||
|
||||
/* ====== 卡片 ====== */
|
||||
.card {
|
||||
@include card;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 28rpx 28rpx 16rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 18rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
/* 左侧蓝色竖条标识(与设计稿一致) */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8rpx;
|
||||
height: 26rpx;
|
||||
border-radius: 4rpx;
|
||||
background: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
padding: 12rpx 0;
|
||||
font-size: 26rpx;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.info-label {
|
||||
width: 140rpx;
|
||||
flex-shrink: 0;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ====== 时间线 ====== */
|
||||
.timeline-empty {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
padding: 16rpx 0 8rpx;
|
||||
}
|
||||
|
||||
/* 审批流时间线(公共 mixin,与报销详情的审批记录同一套样式) */
|
||||
@include flow-timeline;
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
/* ====== 底部操作栏 ====== */
|
||||
.action-bar {
|
||||
flex-shrink: 0;
|
||||
background: $color-card;
|
||||
padding: 20rpx $page-padding-x;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.btn-pass,
|
||||
.btn-reject,
|
||||
.btn-withdraw {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.btn-pass {
|
||||
background: $color-primary;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: #f04438;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-withdraw {
|
||||
background: $color-bg-page;
|
||||
color: $color-text-secondary;
|
||||
border: 1rpx solid $color-border;
|
||||
}
|
||||
|
||||
.btn-active { opacity: 0.85; }
|
||||
|
||||
/* ====== 审批弹窗 ====== */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 640rpx;
|
||||
max-height: 80vh;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 24rpx 16rpx;
|
||||
border-bottom: 1rpx solid $color-border;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.approve-summary {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
font-size: 26rpx;
|
||||
border-bottom: 1rpx dashed $color-border;
|
||||
|
||||
&:last-of-type { border-bottom: 1rpx solid $color-border; }
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: $color-text-muted;
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
color: $color-text;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
margin-bottom: 10rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
min-height: 160rpx;
|
||||
border: 1rpx solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
padding: 16rpx 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
background: $color-bg-page;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20rpx 24rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
border-top: 1rpx solid $color-border;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 16rpx 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: #ffffff;
|
||||
border-radius: $radius-md;
|
||||
|
||||
&.btn-pass { background: $color-primary; }
|
||||
&.btn-reject { background: #f04438; }
|
||||
&.disabled { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 16rpx 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: $color-text-secondary;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"path": "pages/approval/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "审批列表"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部工具栏(系统导航栏已带返回按钮 + 标题,这里只放搜索/筛选) -->
|
||||
<view class="toolbar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
placeholder="搜索单据标题"
|
||||
confirm-type="search"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<scroll-view class="tab-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="tab-list">
|
||||
<view
|
||||
v-for="tab in statusTabs"
|
||||
:key="String(tab.value)"
|
||||
class="tab-item"
|
||||
:class="{ active: currentStatus === tab.value }"
|
||||
@tap="onStatusChange(tab.value)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 说明:审批单据由各业务模块提交后进入此处,这里不提供新增 -->
|
||||
<text class="page-tip">审批由各业务模块提交后在此查看与处理</text>
|
||||
|
||||
<!-- 列表 -->
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="list" text="暂无审批记录" />
|
||||
</view>
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
class="approval-card"
|
||||
>
|
||||
<view class="card-top">
|
||||
<text class="card-title">{{ row.biz_title || '(无标题)' }}</text>
|
||||
<u-tag
|
||||
:text="row.status_label || ''"
|
||||
:type="row.status_tag || 'info'"
|
||||
size="mini"
|
||||
/>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">{{ row.biz_type || '-' }}</text>
|
||||
<text class="meta-text">发起人:{{ row.submit_user_name || '-' }}</text>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">提交:{{ row.submit_time || row.create_time || '-' }}</text>
|
||||
<text v-if="row.finish_time" class="meta-text">完成:{{ row.finish_time }}</text>
|
||||
</view>
|
||||
<view v-if="row.current_node" class="card-meta">
|
||||
<text class="meta-text">当前节点:{{ row.current_node }}</text>
|
||||
</view>
|
||||
<view class="card-actions">
|
||||
<view class="op-btn op-view" hover-class="btn-active" @tap.stop="openDetail(row.id)">查看详情</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
<view v-else class="more-tip" @tap="loadMore">
|
||||
<text>加载更多</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchApprovalList,
|
||||
APPROVAL_INSTANCE_STATUS_OPTIONS
|
||||
} from '@/api/approval.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/** 状态 tab(首个为「全部」,不展示「草稿」) */
|
||||
const statusTabs = [
|
||||
{ value: '', label: '全部' },
|
||||
...APPROVAL_INSTANCE_STATUS_OPTIONS.filter(o => o.value !== 0)
|
||||
]
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
const currentStatus = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (isLoggedIn()) refresh()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (finished.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchApprovalList({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value.trim(),
|
||||
status: currentStatus.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onStatusChange(value) {
|
||||
currentStatus.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
if (!id) {
|
||||
uni.showToast({ title: '单据数据异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/approval/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ====== 顶部搜索 ====== */
|
||||
.toolbar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== 状态筛选 tab ====== */
|
||||
.tab-scroll {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
background: $color-card;
|
||||
padding: 0 $page-padding-x 12rpx;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 0 4rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 10rpx 28rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-bg-page;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: $color-primary;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.page-tip {
|
||||
display: block;
|
||||
padding: 12rpx $page-padding-x 0;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
@include card;
|
||||
margin: 16rpx $page-padding-x 0;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.card-active { opacity: 0.85; }
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.op-btn {
|
||||
padding: 14rpx 36rpx;
|
||||
border-radius: $radius-md;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.op-view { background: $color-primary; }
|
||||
|
||||
.btn-active { opacity: 0.85; }
|
||||
|
||||
.more-tip {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 140rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部:搜索(系统导航栏已带返回按钮 + 标题) -->
|
||||
<view class="toolbar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
placeholder="搜索单据标题"
|
||||
confirm-type="search"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stat-bar">
|
||||
<text class="stat-text">共 {{ total }} 条待办</text>
|
||||
</view>
|
||||
|
||||
<!-- 待办列表 -->
|
||||
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="list" text="暂无待我审批的任务" />
|
||||
</view>
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="task in list"
|
||||
:key="task.id"
|
||||
class="todo-card"
|
||||
hover-class="card-active"
|
||||
@tap="openDetail(task.instance)"
|
||||
>
|
||||
<view class="card-top">
|
||||
<text class="card-title">{{ task.instance && task.instance.biz_title ? task.instance.biz_title : '(无标题)' }}</text>
|
||||
<u-tag
|
||||
:text="task.instance && task.instance.status_label || ''"
|
||||
:type="task.instance && task.instance.status_tag || 'info'"
|
||||
size="mini"
|
||||
/>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">节点:{{ task.node_name || '-' }}</text>
|
||||
<text class="meta-text">发起人:{{ task.instance && task.instance.submit_user_name || '-' }}</text>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-text">单据:{{ task.instance && task.instance.biz_type || '-' }}</text>
|
||||
<text class="meta-text">提交:{{ task.instance && (task.instance.submit_time || task.instance.create_time) || '-' }}</text>
|
||||
</view>
|
||||
<view class="card-meta" v-if="task.deadline">
|
||||
<text class="meta-text">截止:{{ task.deadline }}</text>
|
||||
</view>
|
||||
<!-- card-actions 不接管 tap,只把"查看详情"按钮的 tap 单独拦在按钮层,
|
||||
这样 card 主体其它区域的 tap 仍能正常冒泡到 .todo-card 触发跳转 -->
|
||||
<view class="card-actions">
|
||||
<view class="op-btn op-view" hover-class="btn-active" @tap.stop="openDetail(task.instance)">查看详情</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
<view v-else class="more-tip" @tap="loadMore">
|
||||
<text>加载更多</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchApprovalTodoList
|
||||
} from '@/api/approval.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
|
||||
onLoad(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (isLoggedIn() && list.value.length === 0) refresh()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (finished.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchApprovalTodoList({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value.trim()
|
||||
})
|
||||
const rows = (data.list || []).filter(t => {
|
||||
if (!keyword.value) return true
|
||||
const title = t.instance && t.instance.biz_title ? String(t.instance.biz_title) : ''
|
||||
return title.toLowerCase().includes(keyword.value.toLowerCase())
|
||||
})
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { refresh() }
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openDetail(instance) {
|
||||
if (!instance || !instance.id) {
|
||||
uni.showToast({ title: '单据数据异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/approval/detail?id=${instance.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ====== 顶部搜索 ====== */
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 20rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== 计数条 ====== */
|
||||
.stat-bar {
|
||||
flex-shrink: 0;
|
||||
padding: 16rpx $page-padding-x;
|
||||
background: $color-card;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.stat-text {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
@include card;
|
||||
margin: 16rpx $page-padding-x 0;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.card-active { opacity: 0.85; }
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.op-btn {
|
||||
padding: 14rpx 36rpx;
|
||||
border-radius: $radius-md;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.op-view {
|
||||
background: $color-primary;
|
||||
}
|
||||
.btn-active { opacity: 0.85; }
|
||||
|
||||
.more-tip {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
padding: 12rpx 0 4rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 140rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -74,30 +74,45 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 审批记录 -->
|
||||
<!-- 审批记录(与审批中心一致的审批流样式) -->
|
||||
<view class="block-card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">审批记录</text>
|
||||
<text class="block-total">{{ records.length }} 条</text>
|
||||
</view>
|
||||
<view v-if="!records.length" class="items-empty">
|
||||
<view v-if="!flowNodes.length" class="items-empty">
|
||||
<text>暂无审批记录</text>
|
||||
</view>
|
||||
<view v-else class="timeline">
|
||||
<view v-for="record in records" :key="record.id" class="timeline-item">
|
||||
<view class="timeline-dot" />
|
||||
<view class="timeline-content">
|
||||
<text class="timeline-title">
|
||||
{{ getExpenseActionLabel(record.action) }} · {{ record.approver_name || '-' }}
|
||||
</text>
|
||||
<text v-if="record.comment" class="timeline-comment">{{ record.comment }}</text>
|
||||
<text class="timeline-time">{{ formatTime(record.create_time) }}</text>
|
||||
<view v-else class="timeline-list">
|
||||
<view
|
||||
v-for="(node, idx) in flowNodes"
|
||||
:key="node.key"
|
||||
class="tl-item"
|
||||
:class="{ edge: node.isEdge, current: node.isCurrent }"
|
||||
>
|
||||
<view class="tl-rail">
|
||||
<view class="tl-avatar-wrap">
|
||||
<view class="tl-avatar" :class="{ 'avatar-end': node.isEdge }">{{ node.avatar }}</view>
|
||||
<view class="tl-badge" :class="node.badgeClass">{{ node.badgeIcon }}</view>
|
||||
</view>
|
||||
<view v-if="idx !== flowNodes.length - 1" class="tl-line" />
|
||||
</view>
|
||||
<view class="tl-main">
|
||||
<view class="tl-head">
|
||||
<view class="tl-title">
|
||||
<text class="tl-name">{{ node.title }}</text>
|
||||
<text v-if="node.sub" class="tl-sub">{{ node.sub }}</text>
|
||||
</view>
|
||||
<text class="tl-time">{{ node.time }}</text>
|
||||
</view>
|
||||
<view v-if="node.comment" class="tl-comment">{{ node.comment }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮(统一放详情页):草稿为创建人操作,审批中为待办审批人操作 -->
|
||||
<!-- 操作按钮:草稿可删除/编辑/提交;审批通过后由报销人确认到账 -->
|
||||
<view v-if="detail && isDraft" class="footer">
|
||||
<view class="footer-btn">
|
||||
<u-button type="error" plain icon="trash" text="删除" @click="onDelete" />
|
||||
@@ -109,18 +124,11 @@
|
||||
<u-button type="primary" icon="arrow-up" text="提交审批" @click="onSubmit" />
|
||||
</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 v-else-if="detail && isApproved" class="footer">
|
||||
<view class="footer-btn">
|
||||
<u-button type="success" icon="checkmark-circle" text="确认到账" @click="onConfirmPaid" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -128,11 +136,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import {
|
||||
getExpense,
|
||||
deleteExpense,
|
||||
submitExpense,
|
||||
confirmExpensePaid,
|
||||
fetchExpenseRecords,
|
||||
fetchMyApproveTask,
|
||||
approveExpense,
|
||||
@@ -153,12 +162,95 @@ const myTask = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const isDraft = computed(() => Number(detail.value?.status) === EXPENSE_STATUS.DRAFT)
|
||||
// 审批通过:等待报销人本人确认款项到账
|
||||
const isApproved = computed(() => Number(detail.value?.status) === EXPENSE_STATUS.APPROVED)
|
||||
|
||||
const descText = computed(() => {
|
||||
const desc = (detail.value?.description || '').trim()
|
||||
return desc || ''
|
||||
})
|
||||
|
||||
/** 审批记录 -> 审批流节点:每条记录一个节点,末尾补「流程结束」节点展示单据当前状态 */
|
||||
const flowNodes = computed(() => {
|
||||
const nodes = (records.value || []).map((record, index) => {
|
||||
const action = Number(record.action)
|
||||
const step = Number(record.step)
|
||||
return {
|
||||
key: `record-${record.id || index}`,
|
||||
avatar: avatarOf(record.approver_name),
|
||||
badgeClass: badgeClassOfAction(action),
|
||||
badgeIcon: badgeIconOfAction(action),
|
||||
title:
|
||||
action === 1
|
||||
? `${record.approver_name || '发起人'}(申请人)`
|
||||
: record.approver_name || '审批人',
|
||||
sub:
|
||||
step > 0
|
||||
? `${getExpenseActionLabel(action)} · 第 ${step} 步`
|
||||
: getExpenseActionLabel(action),
|
||||
time: shortTime(record.create_time),
|
||||
comment: record.comment ? String(record.comment) : '',
|
||||
isEdge: action === 1,
|
||||
isCurrent: false,
|
||||
}
|
||||
})
|
||||
if (!detail.value) return nodes
|
||||
nodes.push(buildEndNode())
|
||||
return nodes
|
||||
})
|
||||
|
||||
/** 末尾「流程结束」节点:显示单据当前状态,让申请人一眼看出流程是否走完 */
|
||||
function buildEndNode() {
|
||||
const status = Number(detail.value?.status)
|
||||
const map = {
|
||||
0: { label: '草稿', cls: 'badge-skip', icon: '—' },
|
||||
1: { label: '审批中', cls: 'badge-running', icon: '·' },
|
||||
2: { label: '已通过', cls: 'badge-pass', icon: '✓' },
|
||||
3: { label: '已驳回', cls: 'badge-reject', icon: '✕' },
|
||||
4: { label: '已撤回', cls: 'badge-skip', icon: '—' },
|
||||
5: { label: '已打款', cls: 'badge-pass', icon: '✓' },
|
||||
}
|
||||
const hit = map[status] || { label: '未知', cls: 'badge-skip', icon: '—' }
|
||||
return {
|
||||
key: 'flow-end',
|
||||
avatar: '结',
|
||||
badgeClass: hit.cls,
|
||||
badgeIcon: hit.icon,
|
||||
title: '流程结束',
|
||||
sub: hit.label,
|
||||
time: shortTime(detail.value?.update_time),
|
||||
comment: '',
|
||||
isEdge: true,
|
||||
isCurrent: status === 1,
|
||||
}
|
||||
}
|
||||
|
||||
function avatarOf(name) {
|
||||
const text = String(name || '').trim()
|
||||
return text ? text.slice(0, 1) : '系'
|
||||
}
|
||||
|
||||
/** 动作角标:1提交/2通过 -> 绿✓;3驳回 -> 红✕;4转审 -> 橙;5撤回 -> 灰 */
|
||||
function badgeClassOfAction(action) {
|
||||
if (action === 3) return 'badge-reject'
|
||||
if (action === 1 || action === 2) return 'badge-pass'
|
||||
if (action === 4) return 'badge-running'
|
||||
return 'badge-skip'
|
||||
}
|
||||
|
||||
function badgeIconOfAction(action) {
|
||||
if (action === 3) return '✕'
|
||||
if (action === 1 || action === 2) return '✓'
|
||||
if (action === 4) return '·'
|
||||
return '—'
|
||||
}
|
||||
|
||||
/** 时间只展示到分钟 */
|
||||
function shortTime(value) {
|
||||
if (!value) return ''
|
||||
return String(value).slice(0, 16)
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
if (query?.id) {
|
||||
expenseId.value = query.id
|
||||
@@ -170,6 +262,20 @@ onShow(() => {
|
||||
if (expenseId.value) loadDetail()
|
||||
})
|
||||
|
||||
// 监听审批通过 / 确认到账后的刷新事件。
|
||||
// 新版 uni-app 已不再导出 onUniAppEvent,改用全局事件总线并在页面卸载时解绑。
|
||||
function handleExpenseUpdated(data) {
|
||||
if (data?.id === expenseId.value) {
|
||||
// 延迟一点刷新,确保后端数据已同步
|
||||
setTimeout(() => loadDetail(), 300)
|
||||
}
|
||||
}
|
||||
uni.$on('expense-updated', handleExpenseUpdated)
|
||||
|
||||
onUnload(() => {
|
||||
uni.$off('expense-updated', handleExpenseUpdated)
|
||||
})
|
||||
|
||||
/** 后端时间字段可能为 "YYYY-MM-DD HH:mm:ss" 格式,iOS 需转 T 分隔 */
|
||||
function formatTime(value) {
|
||||
if (!value) return ''
|
||||
@@ -254,6 +360,29 @@ function onSubmit() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 报销人确认款项已到账:确认后单据状态变为「已打款」 */
|
||||
async function onConfirmPaid() {
|
||||
const confirmed = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '确认到账',
|
||||
content: `确认已收到 ¥${moneyText(detail.value?.total_amount)} 的报销款吗?确认后单据状态变为「已打款」。`,
|
||||
confirmText: '确认已到账',
|
||||
cancelText: '暂未到账',
|
||||
success: (res) => resolve(!!res.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await confirmExpensePaid(expenseId.value)
|
||||
uni.showToast({ title: '已确认到账', icon: 'success' })
|
||||
await loadDetail()
|
||||
uni.$emit('expense-updated', { id: expenseId.value })
|
||||
} catch (e) {
|
||||
// request 封装已弹出服务端错误提示
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除报销单',
|
||||
@@ -498,71 +627,8 @@ function onReject() {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-top: 4rpx;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding-bottom: 28rpx;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 4rpx;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9rpx;
|
||||
top: 24rpx;
|
||||
bottom: 0;
|
||||
width: 2rpx;
|
||||
background: $color-divider;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline-comment {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
/* 审批记录沿用审批中心的审批流样式 */
|
||||
@include flow-timeline;
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// approval.js — 审批列表 API 服务
|
||||
// 提供审批列表的增删改查接口,供 `pages/approval/index.vue` 使用。
|
||||
|
||||
import { request } from '@/api/request.js'
|
||||
|
||||
/**
|
||||
* 获取审批列表
|
||||
* @param {object} params - 分页、筛选参数
|
||||
* @returns {Promise<object>} { list, total, page, pageSize }
|
||||
*/
|
||||
export async function fetchApprovalList(params) {
|
||||
const res = await request({
|
||||
url: '/approval/list',
|
||||
method: 'GET',
|
||||
...params,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批类型下拉选项(用于筛选)
|
||||
* @returns {Promise<object>} 审批类型列表
|
||||
*/
|
||||
export async function fetchApprovalTypes() {
|
||||
const res = await request({
|
||||
url: '/approval/types',
|
||||
method: 'GET',
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批项详情
|
||||
* @param {number} id - 审批项 ID
|
||||
* @returns {Promise<object>} 审批项详情
|
||||
*/
|
||||
export async function fetchApprovalDetail(id) {
|
||||
const res = await request({
|
||||
url: `/approval/detail/${id}`,
|
||||
method: 'GET',
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建审批项
|
||||
* @param {object} data - 审批项数据
|
||||
* @returns {Promise<object>} 创建结果
|
||||
*/
|
||||
export async function createApproval(data) {
|
||||
const res = await request({
|
||||
url: '/approval/create',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新审批项
|
||||
* @param {number} id - 审批项 ID
|
||||
* @param {object} data - 更新的数据
|
||||
* @returns {Promise<object>} 更新结果
|
||||
*/
|
||||
export async function updateApproval(id, data) {
|
||||
const res = await request({
|
||||
url: `/approval/update/${id}`,
|
||||
method: 'PUT',
|
||||
data,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除审批项
|
||||
* @param {number} id - 审批项 ID
|
||||
* @returns {Promise<object>} 删除结果
|
||||
*/
|
||||
export async function deleteApproval(id) {
|
||||
const res = await request({
|
||||
url: `/approval/delete/${id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除审批项
|
||||
* @param {number[]} ids - 审批项 ID 数组
|
||||
* @returns {Promise<object>} 批量删除结果
|
||||
*/
|
||||
export async function batchDeleteApprovals(ids) {
|
||||
const res = await request({
|
||||
url: '/approval/batch-delete',
|
||||
method: 'POST',
|
||||
data: { ids },
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 同意/通过审批项
|
||||
* @param {number} id - 审批项 ID
|
||||
* @param {string} reason - 通过原因
|
||||
* @returns {Promise<object>} 通过结果
|
||||
*/
|
||||
export async function approveApproval(id, reason) {
|
||||
const res = await request({
|
||||
url: `/approval/approve/${id}`,
|
||||
method: 'POST',
|
||||
data: { reason },
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回审批项
|
||||
* @param {number} id - 审批项 ID
|
||||
* @param {string} reason - 驳回原因
|
||||
* @returns {Promise<object>} 驳回结果
|
||||
*/
|
||||
export async function rejectApproval(id, reason) {
|
||||
const res = await request({
|
||||
url: `/approval/reject/${id}`,
|
||||
method: 'POST',
|
||||
data: { reason },
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批进度(待办 / 进行中 / 已完成)
|
||||
* @param {number} id - 审批项 ID
|
||||
* @returns {Promise<object>} 进度信息
|
||||
*/
|
||||
export async function fetchApprovalProgress(id) {
|
||||
const res = await request({
|
||||
url: `/approval/progress/${id}`,
|
||||
method: 'GET',
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -85,6 +85,136 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 审批流时间线 ============
|
||||
// 头像(姓名首字)+ 右下状态角标 + 姓名/说明 + 时间 + 意见气泡,节点之间虚线连接。
|
||||
// 用法:页面 scoped 样式里 `@include flow-timeline;`,根节点用 .timeline-list,
|
||||
// 每个节点用 .tl-item(.tl-rail / .tl-avatar-wrap / .tl-avatar / .tl-badge / .tl-line / .tl-main ...)。
|
||||
@mixin flow-timeline {
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.tl-item {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
|
||||
&.current .tl-name { color: $color-primary; }
|
||||
&.edge .tl-name { font-weight: 500; }
|
||||
}
|
||||
|
||||
.tl-rail {
|
||||
width: 76rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tl-avatar-wrap {
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tl-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(135deg, #60a5fa, #2563eb);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.avatar-end {
|
||||
background: linear-gradient(135deg, #cbd5e1, #94a3b8);
|
||||
}
|
||||
}
|
||||
|
||||
.tl-badge {
|
||||
position: absolute;
|
||||
right: -10rpx;
|
||||
bottom: -10rpx;
|
||||
box-sizing: border-box;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 6rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 4rpx solid #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
|
||||
&.badge-pass { background: #16a34a; }
|
||||
&.badge-running { background: #f59e0b; }
|
||||
&.badge-reject { background: #ef4444; }
|
||||
&.badge-skip { background: #94a3b8; }
|
||||
}
|
||||
|
||||
.tl-line {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
margin-top: 8rpx;
|
||||
border-left: 2rpx dashed #cbd5e1;
|
||||
}
|
||||
|
||||
.tl-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 36rpx;
|
||||
}
|
||||
|
||||
.tl-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.tl-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tl-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.tl-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.tl-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.tl-comment {
|
||||
margin-top: 16rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
// 居中弹窗(非贴底):宽屏下把固定定位的遮罩面板限制为列宽并水平+垂直居中
|
||||
@mixin dialog-responsive($w: 560px) {
|
||||
@media (min-width: 560px) {
|
||||
|
||||
+16
-2
@@ -15,6 +15,10 @@ const NATIVE_RULES = [
|
||||
{ pattern: /记事本|笔记|便签|notebook/i, route: '/pages/tools/notepad/index' },
|
||||
// 「日程提醒」与「工作日程」是两个独立功能:提醒在前,命中"提醒"后即不再落到日历页
|
||||
{ pattern: /提醒|reminder/i, route: '/pages/tools/schedule/reminder' },
|
||||
// 自定义审批流(移动端):放在工作日程之前,避免「待办审批」「审批中心」被工作日程吞掉
|
||||
// 「我发起的审批 / 我的发起」也是同一页(业务口径就是申请人视角的实例列表)
|
||||
{ pattern: /审批列表|我的审批|我发起的|审批管理|审批申请|approvalList|approval\/list/i, route: '/pages/approval/index' },
|
||||
{ pattern: /待我审批|我审批的|审批待办|审批中心|approvalTodo|approval\/todo/i, route: '/pages/approval/verify' },
|
||||
{ pattern: /工作日程|日程|日历|待办|calendar|schedule/i, route: '/pages/tools/schedule/index' },
|
||||
// 报销管理(注意放在日程规则之后即可,与日程关键词无冲突)
|
||||
{ pattern: /报销|reimburse|expense/i, route: '/pages/tools/expense/index' },
|
||||
@@ -34,7 +38,13 @@ const WEBVIEW_PAGE = '/pages/apps/webview'
|
||||
* 命中 → webview 打开 Web 端;未命中 → 「功能开发中」占位页。
|
||||
* 新功能上线时把对应关键词加进来即可。
|
||||
*/
|
||||
const WEB_RULES = []
|
||||
export const WEB_RULES = [
|
||||
// 审批流配置(Web 端 /backend/approval):端上没有原生实现,直接跳 Web 配置页
|
||||
'审批设置',
|
||||
'审批流配置',
|
||||
'审批流程',
|
||||
'审批配置'
|
||||
]
|
||||
|
||||
/** 开发中原生占位页 */
|
||||
const DEVELOPING_PAGE = '/pages/common/developing'
|
||||
@@ -48,8 +58,12 @@ function openDeveloping(name) {
|
||||
|
||||
/** 菜单是否命中移动端原生页面 */
|
||||
export function resolveNativeRoute(menu = {}) {
|
||||
const haystack = (menu.title || '') + ' ' + (menu.path || '')
|
||||
const title = String(menu.title || '')
|
||||
const path = String(menu.path || '')
|
||||
const haystack = title + ' ' + path
|
||||
const hit = NATIVE_RULES.find((r) => r.pattern.test(haystack))
|
||||
// 调试日志:方便排查菜单跳转是否符合预期(HBuilderX / 微信开发者工具 控制台可见)
|
||||
console.log('[nav] resolveNativeRoute', { title, path, hit: hit ? hit.route : '' })
|
||||
return hit ? hit.route : ''
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user