优化报销功能

This commit is contained in:
hero920103 committed 2026-10-16 23:46:49 +08:00
1 parent 395c7f717e
commit 4306cbd74b
19 files changed
+2059 -534

No files matched your search

+27
View File
@@ -57,3 +57,30 @@ export function getApprovalFlowPick(params) {
export function getAvailableFlows(params) {
return request({ url: '/backend/approval/flow/available', method: 'get', params });
}
// ==================== 审批中心(运行时实例) ====================
// 我发起的审批实例列表
export function getMyApprovalInstances(params) {
return request({ url: '/backend/approval/instance/mine', method: 'get', params });
}
// 我作为审批人的任务列表(handled=1 返回我已处理)
export function getApprovalTodo(params) {
return request({ url: '/backend/approval/instance/todo', method: 'get', params });
}
// 审批实例详情(含流程时间线)
export function getApprovalInstanceDetail(id) {
return request({ url: `/backend/approval/instance/detail/${id}`, method: 'get' });
}
// 审批人通过 / 驳回
export function approveApprovalInstance(data) {
return request({ url: '/backend/approval/instance/approve', 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>
@@ -0,0 +1,228 @@
<template>
<div class="approval-page">
<div class="page-header">
<div>
<h2>审批列表</h2>
<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">
<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="total, sizes, prev, pager, next, jumper"
@update:current-page="pagination.page = $event"
@update:page-size="pagination.pageSize = $event"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</div>
<DetailDrawer v-model="detailVisible" :instance-id="detailId" @changed="loadList" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { Refresh, Search } from "@element-plus/icons-vue";
import DetailDrawer from "./components/DetailDrawer.vue";
import { getApprovalTodo } from "@/api/approval";
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 list = ref([]);
const loading = ref(false);
const detailVisible = ref(false);
const detailId = ref("");
const emptyText = computed(() =>
filters.handled === 1 ? "暂无已处理的审批" : "暂无待我审批的单据",
);
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const loadList = async () => {
loading.value = true;
try {
const data = responseData(
await getApprovalTodo({
handled: filters.handled,
biz_type: filters.biz_type || "",
page: pagination.page,
pageSize: pagination.pageSize,
}),
);
// 前端二次过滤:类型下拉切换后即时生效
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 || "加载审批列表失败");
} finally {
loading.value = false;
}
};
const handleScopeChange = () => {
pagination.page = 1;
loadList();
};
const openDetail = (id) => {
detailId.value = id;
detailVisible.value = true;
};
function tagType(type) {
if (type === "error") return "danger";
return type || "info";
}
onMounted(loadList);
</script>
<style lang="less" scoped>
.approval-page {
background: #f5f7fa;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0 0 8px;
color: #303133;
font-size: 22px;
}
p {
margin: 0;
color: #909399;
font-size: 13px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.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: 16px;
}
</style>
@@ -0,0 +1,236 @@
<template>
<div class="approval-page">
<div class="page-header">
<div>
<h2>我的发起</h2>
<p>查看自己发起的审批单据及当前进度</p>
</div>
<div class="header-actions">
<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="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 :type="tagType(row.status_tag)">{{ row.status_label || "-" }}</el-tag>
</template>
</el-table-column>
<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="170">
<template #default="{ row }">{{ row.submit_time || row.create_time || "-" }}</template>
</el-table-column>
<el-table-column prop="finish_time" label="完成时间" width="170">
<template #default="{ row }">{{ row.finish_time || "-" }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="140">
<template #default="{ row }">
<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>
<template #empty><el-empty description="暂无发起的审批" /></template>
</el-table>
</div>
<div class="pagination">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@update:current-page="pagination.page = $event"
@update:page-size="pagination.pageSize = $event"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</div>
<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 { 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 detailVisible = ref(false);
const detailId = ref("");
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const loadList = async () => {
loading.value = true;
try {
const data = responseData(
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 || "加载审批列表失败");
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.keyword = "";
filters.status = undefined;
pagination.page = 1;
loadList();
};
const openDetail = (id) => {
detailId.value = id;
detailVisible.value = true;
};
const handleWithdraw = async (row) => {
try {
await ElMessageBox.confirm(`确认撤回「${row.biz_title || "该单据"}」的审批吗?`, "撤回确认", {
type: "warning",
});
await withdrawApprovalInstance(row.id);
ElMessage.success("已撤回");
loadList();
} catch (error) {
if (error !== "cancel" && error !== "close")
ElMessage.error(error?.message || "撤回失败");
}
};
function tagType(type) {
if (type === "error") return "danger";
return type || "info";
}
onMounted(loadList);
</script>
<style lang="less" scoped>
.approval-page {
background: #f5f7fa;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0 0 8px;
color: #303133;
font-size: 22px;
}
p {
margin: 0;
color: #909399;
font-size: 13px;
}
}
.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;
}
}
.status-select {
width: 150px;
}
.table-scroll {
width: 100%;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="approval-settings-page">
<div class="page-header">
<div>
<h2>审批流设置</h2>
<p>为本租户各功能模块编排审批流程:选择分类 → 新建流程 → 拖拽节点并保存</p>
</div>
</div>
<!-- 复用「系统管理 → 审批流配置」的编排界面,避免同一能力两份实现 -->
<ApprovalFlowConfig />
<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 { ElMessage } from "element-plus";
import ApprovalFlowConfig from "@/views/system/approval/index.vue";
import { getApprovalFlowList } from "@/api/approval";
const flows = ref([]);
const loading = ref(false);
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const loadFlows = async () => {
loading.value = true;
try {
const data = responseData(await getApprovalFlowList());
flows.value = Array.isArray(data) ? data : data.list || [];
} catch (error) {
ElMessage.error(error?.message || "加载审批流失败");
} finally {
loading.value = false;
}
};
onMounted(loadFlows);
</script>
<style lang="less" scoped>
.approval-settings-page {
background: #f5f7fa;
padding: 20px;
}
.page-header {
margin-bottom: 16px;
h2 {
margin: 0 0 8px;
color: #303133;
font-size: 22px;
}
p {
margin: 0;
color: #909399;
font-size: 13px;
}
}
.reference-card {
margin-top: 16px;
background: #fff;
border-radius: 6px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.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;
}
}
.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("确认删除这条报销记录吗?", "删除确认", {
+176 -43
View File
@@ -41,19 +41,19 @@ import (
// 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"` // 表单数据
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"` // 审批意见
TaskID uint64 `json:"task_id"` // 待审批任务ID(必填)
Pass bool `json:"pass"` // true 通过 false 驳回
Comment string `json:"comment"` // 审批意见
}
// ============================ 工具 ============================
@@ -267,8 +267,9 @@ func oaApprovalTaskTag(status int8) string {
}
}
// oaApprovalTaskView 任务视图(移动端时间线用)
func oaApprovalTaskView(t models.ApprovalTask, isCurrent bool) map[string]interface{} {
// 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")
@@ -277,23 +278,158 @@ func oaApprovalTaskView(t models.ApprovalTask, isCurrent bool) map[string]interf
if t.Deadline != nil {
deadline = t.Deadline.Format("2006-01-02 15:04:05")
}
return map[string]interface{}{
"id": t.ID,
"node_key": t.NodeKey,
"node_name": t.NodeName,
"approver_id": t.ApproverID,
"approver_name": t.ApproverName,
"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"),
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
}
// ============================ 路由方法 ============================
@@ -468,7 +604,7 @@ func (c *AppOaController) ApprovalTodo() {
if inst.Status != models.ApprovalInstanceRunning {
continue
}
view := oaApprovalTaskView(t, true)
view := oaApprovalTaskView(t, true, oaApprovalNodeTypes(inst)[t.NodeKey])
view["instance"] = oaApprovalInstanceView(inst)
list = append(list, view)
}
@@ -529,16 +665,13 @@ func (c *AppOaController) ApprovalDetail() {
}
tasks := oaApprovalTasksByInstance(inst.ID)
timeline := make([]map[string]interface{}, 0, len(tasks))
for _, t := range tasks {
timeline = append(timeline, oaApprovalTaskView(t, t.NodeKey == inst.CurrentNode))
}
timeline := oaApprovalFlowTimeline(inst, tasks)
c.oaOk(map[string]interface{}{
"item": oaApprovalInstanceView(inst),
"timeline": timeline,
"can_approve": isApprover,
"can_withdraw": isSubmitter && inst.Status == models.ApprovalInstanceRunning,
"item": oaApprovalInstanceView(inst),
"timeline": timeline,
"can_approve": isApprover,
"can_withdraw": isSubmitter && inst.Status == models.ApprovalInstanceRunning,
"current_task_id": currentTaskID,
})
}
@@ -602,8 +735,8 @@ func (c *AppOaController) ApprovalSubmit() {
Filter("id", instID).One(&inst)
c.oaOk(map[string]interface{}{
"id": instID,
"item": oaApprovalInstanceView(inst),
"id": instID,
"item": oaApprovalInstanceView(inst),
})
}
@@ -662,11 +795,11 @@ func (c *AppOaController) ApprovalApprove() {
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),
"finished": res.Finished,
"instance_stat": res.InstanceStat,
"current_node": res.CurrentNode,
"message": res.Message,
"item": oaApprovalInstanceView(inst),
})
}
+2 -1
View File
@@ -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 {
+340
View File
@@ -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
}
+19 -15
View File
@@ -1001,16 +1001,14 @@ 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 {
@@ -1040,27 +1038,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
+65
View File
@@ -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;
+7
View File
@@ -33,6 +33,13 @@ func RegisterApprovalRoutes() {
beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete")
beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick")
beego.Router("/backend/approval/flow/available", &controllers.BackendApprovalController{}, "get:AvailableFlows")
// 审批中心运行时(我发起 / 待我审批 / 详情 / 通过驳回 / 撤回)
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 注册租户端「产品购买」路由。
+3 -45
View File
@@ -84,31 +84,6 @@ export function getApprovalMultiModeLabel(value) {
return labelOf(APPROVAL_MULTI_MODE_OPTIONS, value, '或签')
}
// ============================ 单据类型 / 流程 ============================
/**
* 获取本租户可用的单据类型(biz_type)下拉项。
* 用于「发起审批」选择单据类型。
*/
export async function fetchApprovalCategories() {
const data = await request({ url: '/api/oa/approval/categories' })
const list = (data && data.list) || []
return list
}
/**
* 按 biz_type 获取本租户可发起的流程列表(含 flow_name + 节点名)。
*/
export async function fetchApprovalFlows(params = {}) {
const data = await request({
url: '/api/oa/approval/flows',
data: {
biz_type: params.bizType || ''
}
})
return { list: (data && data.list) || [], total: (data && data.total) || 0 }
}
// ============================ 我的发起 ============================
/**
@@ -178,26 +153,9 @@ export async function fetchApprovalDetail(id) {
}
}
// ============================ 发起 / 审批 / 撤回 ============================
/**
* 提交审批(发起)。
* payload: { biz_type, flow_code?, flow_id?, biz_title, biz_id?, form_data? }
*/
export async function submitApproval(payload) {
return request({
url: '/api/oa/approval/submit',
method: 'POST',
data: {
biz_type: payload.bizType || '',
flow_code: payload.flowCode || '',
flow_id: payload.flowId || 0,
biz_title: payload.bizTitle || '',
biz_id: payload.bizId || 0,
form_data: payload.formData || {}
}
})
}
// ============================ 审批 / 撤回 ============================
// 说明:审批不允许在此处「自由发起」,统一由具体业务模块提交
// (如报销单在「报销管理」提交后自动进入审批流),本模块只负责查询与处理。
/**
* 审批人通过 / 驳回任务。
+5 -4
View File
@@ -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
}
/**
+80 -140
View File
@@ -47,43 +47,33 @@
</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.id || idx"
class="timeline-item"
:class="{ current: task.is_current }"
: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="timeline-dot">
<view class="dot-inner" :class="dotClass(task)"></view>
<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="timeline-content" v-if="idx === 0 || task.node_key !== timeline[idx - 1].node_key">
<view class="timeline-head">
<text class="node-name">{{ task.node_name || task.node_key }}</text>
<u-tag
:text="task.status_label || ''"
:type="task.status_tag || 'info'"
size="mini"
/>
</view>
<view v-if="task.is_current" class="timeline-row">
<text class="node-meta current">当前节点 · 等待 {{ task.approver_name || '审批人' }} 处理</text>
</view>
<view v-else-if="task.status === 1" class="timeline-row">
<text class="node-meta">{{ task.approver_name || '-' }} · 通过于 {{ task.finish_time }}</text>
<text v-if="task.comment" class="node-comment">{{ task.comment }}</text>
</view>
<view v-else-if="task.status === 2" class="timeline-row">
<text class="node-meta">{{ task.approver_name || '-' }} · 驳回于 {{ task.finish_time }}</text>
<text v-if="task.comment" class="node-comment">{{ task.comment }}</text>
</view>
<view v-else class="timeline-row">
<text class="node-meta">{{ task.approver_name || '-' }} · {{ task.finish_time || task.create_time }}</text>
<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>
@@ -203,11 +193,50 @@ async function loadDetail(id) {
}
}
function dotClass(task) {
if (task.is_current) return 'dot-running'
if (task.status === 1) return 'dot-pass'
if (task.status === 2) return 'dot-reject'
return 'dot-default'
/** 头像文字:取姓名字首字 */
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) {
@@ -338,10 +367,25 @@ async function doWithdraw() {
.card-title {
display: block;
position: relative;
padding-left: 18rpx;
font-size: 28rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 18rpx;
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 {
@@ -375,112 +419,8 @@ async function doWithdraw() {
padding: 16rpx 0 8rpx;
}
.timeline-list {
display: flex;
flex-direction: column;
padding-left: 12rpx;
}
.timeline-item {
display: flex;
gap: 16rpx;
position: relative;
padding-bottom: 18rpx;
&:not(:last-child)::after {
content: '';
position: absolute;
left: 18rpx;
top: 30rpx;
bottom: 0;
width: 2rpx;
background: $color-divider;
}
&.current {
.node-name { color: $color-primary; }
}
}
.timeline-dot {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dot-inner {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: $color-text-muted;
&.dot-default { background: $color-text-muted; }
&.dot-pass { background: #16a34a; }
&.dot-reject { background: #f04438; }
&.dot-running {
background: $color-primary;
box-shadow: 0 0 0 8rpx rgba(37, 99, 235, 0.16);
}
}
.timeline-content {
flex: 1;
min-width: 0;
padding-top: 4rpx;
}
.timeline-row {
display: flex;
flex-direction: column;
gap: 4rpx;
margin-bottom: 6rpx;
&:last-child { margin-bottom: 0; }
}
/* 节点头部:节点名 + 状态 tag 横排,tag 不要撑满 100% */
.timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 6rpx;
}
.node-name {
flex: 1;
min-width: 0;
font-size: 28rpx;
color: $color-text;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-meta {
font-size: 24rpx;
color: $color-text-muted;
word-break: break-all;
&.current {
color: $color-primary;
font-weight: 500;
}
}
.node-comment {
font-size: 24rpx;
color: $color-text-secondary;
background: $color-bg-page;
padding: 10rpx 14rpx;
border-radius: $radius-sm;
margin-top: 6rpx;
line-height: 1.5;
}
/* 审批流时间线(公共 mixin,与报销详情的审批记录同一套样式) */
@include flow-timeline;
.bottom-space {
height: 40rpx;
+40 -154
View File
@@ -15,19 +15,23 @@
</view>
</view>
<!-- 状态下拉面板 -->
<view
v-for="tab in statusTabs"
:key="String(tab.value)"
class="dropdown-option"
:class="{ active: currentStatus === tab.value }"
hover-class="dropdown-option-active"
@tap="onStatusChange(tab.value)"
>
<text class="dropdown-option-text">{{ tab.label }}</text>
<FaIcon v-if="currentStatus === tab.value" name="check" color="#2563eb" :size="14" />
<!-- 状态筛选 -->
<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>
</view>
</scroll-view>
<!-- 说明:审批单据由各业务模块提交后进入此处,这里不提供新增 -->
<text class="page-tip">审批由各业务模块提交后在此查看与处理</text>
<!-- 列表 -->
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
@@ -79,7 +83,7 @@
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import {
fetchApprovalList,
@@ -104,68 +108,18 @@ const finished = ref(false)
const keyword = ref('')
const currentStatus = ref('')
/** 当前展开的下拉:'status' | '' */
const openDropdown = ref('')
/** filter-bar 的底部坐标(用于下拉面板 fixed 定位) */
const filterBarBottom = ref(0)
const bizTypes = ref([])
/** 当前状态下拉框展示文案 */
const currentStatusLabel = computed(() => {
const hit = statusTabs.find(s => s.value === currentStatus.value)
return hit ? hit.label : '全部'
})
/** 下拉面板顶部偏移(紧贴 filter-bar 底部 + 8px 间隙) */
const panelTopStyle = computed(() => ({
top: (filterBarBottom.value + 8) + 'px'
}))
function toggleDropdown(name) {
openDropdown.value = openDropdown.value === name ? '' : name
if (openDropdown.value) {
// 打开后下一帧再取位置(filter-bar 还未渲染完成则取到 0)
nextTick(() => measureFilterBar())
}
}
function closeDropdown() {
openDropdown.value = ''
}
/** 动态获取 filter-bar 在视口中的底部坐标 */
function measureFilterBar() {
uni.createSelectorQuery()
.select('.filter-bar')
.boundingClientRect(rect => {
if (rect && typeof rect.bottom === 'number') {
filterBarBottom.value = Math.round(rect.bottom)
}
})
.exec()
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
refresh()
// 取 filter-bar 位置供下拉面板定位用
nextTick(() => measureFilterBar())
})
onShow(() => {
if (isLoggedIn()) refresh()
})
async function loadBizTypes() {
// 类型筛选已移除,保留此函数以避免潜在依赖
bizTypes.value = [{ value: '', label: '全部' }]
}
async function refresh() {
page.value = 1
finished.value = false
@@ -213,7 +167,6 @@ function clearKeyword() {
function onStatusChange(value) {
currentStatus.value = value
closeDropdown()
refresh()
}
@@ -268,107 +221,40 @@ function openDetail(id) {
}
}
/* ====== 筛选条(顶部下拉触发器) ====== */
.filter-bar {
/* ====== 状态筛选 tab ====== */
.tab-scroll {
flex-shrink: 0;
display: flex;
gap: 16rpx;
padding: 12rpx $page-padding-x;
background: $color-card;
}
.filter-trigger {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8rpx;
height: 72rpx;
padding: 0 20rpx;
border-radius: $radius-md;
background: $color-bg-page;
border: 1rpx solid transparent;
transition: background 0.2s, border-color 0.2s;
&.open {
background: $color-primary-bg;
border-color: $color-primary;
}
}
.filter-trigger-active {
background: #e2e8f0;
}
.filter-label {
font-size: 26rpx;
color: $color-text-muted;
flex-shrink: 0;
}
.filter-value {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: $color-text;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-trigger.open .filter-value {
color: $color-primary;
}
/* ====== 下拉面板 ====== */
.dropdown-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: transparent;
z-index: 50;
}
.dropdown-panel {
position: absolute;
left: $page-padding-x;
right: $page-padding-x;
background: $color-card;
border-radius: $radius-lg;
box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.16);
z-index: 60;
max-height: 480rpx;
overflow-y: auto;
padding: 8rpx 0;
padding: 0 $page-padding-x 12rpx;
}
.dropdown-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
padding: 20rpx 28rpx;
font-size: 28rpx;
color: $color-text;
transition: background 0.15s;
.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 {
color: $color-primary;
background: $color-primary;
color: #ffffff;
font-weight: 500;
background: $color-primary-bg;
}
}
.dropdown-option-active {
background: $color-bg-page;
}
.dropdown-option-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.page-tip {
display: block;
padding: 12rpx $page-padding-x 0;
font-size: 22rpx;
color: $color-text-muted;
}
/* ====== 列表 ====== */
+152 -80
View File
@@ -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,16 +124,24 @@
<u-button type="primary" icon="arrow-up" text="提交审批" @click="onSubmit" />
</view>
</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>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow, onUniAppEvent } from '@dcloudio/uni-app'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import {
getExpense,
deleteExpense,
submitExpense,
confirmExpensePaid,
fetchExpenseRecords,
getExpenseStatusLabel,
getExpenseStatusType,
@@ -135,12 +158,95 @@ const records = ref([])
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
@@ -152,12 +258,18 @@ onShow(() => {
if (expenseId.value) loadDetail()
})
// 监听审批通过后的刷新事件
onUniAppEvent('expense-updated', (data) => {
// 监听审批通过 / 确认到账后的刷新事件。
// 新版 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 分隔 */
@@ -236,6 +348,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: '删除报销单',
@@ -443,71 +578,8 @@ function onDelete() {
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;
+130
View File
@@ -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) {