优化审批功能

This commit is contained in:
hero920103 committed 2026-10-16 22:40:02 +08:00
1 parent 184d5b0403
commit 395c7f717e
15 files changed
+2997 -16

No files matched your search

+653
View File
@@ -0,0 +1,653 @@
<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.id || idx"
class="timeline-item"
:class="{ current: task.is_current }"
>
<view class="timeline-dot">
<view class="dot-inner" :class="dotClass(task)"></view>
</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>
</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 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 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;
font-size: 28rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 18rpx;
}
.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;
}
.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;
}
.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": "审批列表"
}
}
+469
View File
@@ -0,0 +1,469 @@
<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
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" />
</view>
</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="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, computed, onMounted, nextTick } 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('')
/** 当前展开的下拉:'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
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
closeDropdown()
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;
}
}
/* ====== 筛选条(顶部下拉触发器) ====== */
.filter-bar {
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;
}
.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;
&.active {
color: $color-primary;
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;
}
/* ====== 列表 ====== */
.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>