This commit is contained in:
hero920103 committed 2026-10-17 11:59:54 +08:00
commit 699a117678
28 files changed
+4428 -970

No files matched your search

+185
View File
@@ -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'
})
}
+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
}
/**
+127
View File
@@ -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>
+14 -2
View File
@@ -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 })
+21
View File
@@ -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": {
+593
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
{
"path": "pages/approval/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "审批列表"
}
}
+355
View File
@@ -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>
+309
View File
@@ -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>
+156 -90
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,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;
+142
View File
@@ -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
}
+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) {
+16 -2
View File
@@ -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 : ''
}