优化报销功能
This commit is contained in:
1 parent
395c7f717e
commit
4306cbd74b
19 files changed
+2059
-534
No files matched your search
+3
-45
@@ -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 || {}
|
||||
}
|
||||
})
|
||||
}
|
||||
// ============================ 审批 / 撤回 ============================
|
||||
// 说明:审批不允许在此处「自由发起」,统一由具体业务模块提交
|
||||
// (如报销单在「报销管理」提交后自动进入审批流),本模块只负责查询与处理。
|
||||
|
||||
/**
|
||||
* 审批人通过 / 驳回任务。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user