feat(oa): add pending list and approve reject return add-sign transfer

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-12 16:25:23 +08:00
1 parent 29560b743c
commit d5287765ec
4 files changed
+833 -18

No files matched your search

+3 -1
View File
@@ -79,7 +79,9 @@
"style": {
"navigationBarTitleText": "待办中心",
"navigationBarBackgroundColor": "#ecfdf5",
"navigationBarTextStyle": "black"
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
+418 -6
View File
@@ -1,12 +1,424 @@
<template>
<view class="page-shell" style="padding:24rpx">
<text>{{ title }}</text>
<view class="page">
<view class="toolbar">
<view class="toolbar-row">
<view class="search-box" :class="{ 'is-focus': appNameFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="appName"
confirm-type="search"
placeholder="搜索流程名称"
placeholder-class="search-ph"
@input="onAppNameInput"
@confirm="onSearchConfirm"
@focus="appNameFocused = true"
@blur="appNameFocused = false"
/>
<view v-if="appName" class="search-clear" @click="clearAppName">
<text class="search-clear__x">×</text>
</view>
</view>
</view>
<view class="toolbar-row toolbar-row--sub">
<view class="search-box" :class="{ 'is-focus': initiatorFocused }">
<input
class="search-input"
:value="initiatorName"
confirm-type="search"
placeholder="搜索申请人"
placeholder-class="search-ph"
@input="onInitiatorInput"
@confirm="onSearchConfirm"
@focus="initiatorFocused = true"
@blur="initiatorFocused = false"
/>
<view v-if="initiatorName" class="search-clear" @click="clearInitiator">
<text class="search-clear__x">×</text>
</view>
</view>
</view>
</view>
<view v-if="loading && !list.length" class="state state-loading">
<view class="pulse-dots" aria-hidden="true">
<view class="pulse-dot" />
<view class="pulse-dot" />
<view class="pulse-dot" />
</view>
<text class="state-loading__text">加载待办…</text>
</view>
<view v-else-if="!list.length" class="oa-task-list__empty">暂无待办</view>
<view v-else class="oa-task-list">
<OaTaskCard
v-for="row in list"
:key="row.id"
:title="cardTitle(row)"
:status-text="statusText(row.instanceStatus)"
:status-tone="statusTone(row.instanceStatus)"
:meta-lines="metaLines(row)"
:actions="cardActions"
@open="openApprove(row)"
@action="openApprove(row)"
/>
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
</view>
</view>
</template>
<script>
export default {
data() {
return { title: '待办中心' }
import OaTaskCard from '../../../components/OaTaskCard.vue'
import { fetchOaTasks } from '../../../utils/oa-api.js'
import { forceRelogin, getToken } from '../../../utils/auth.js'
import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
const PAGE_SIZE = 20
const STATUS_MAP = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
returned: '已打回',
transferred: '已转交'
}
export default {
components: { OaTaskCard },
data() {
return {
appName: '',
initiatorName: '',
appNameFocused: false,
initiatorFocused: false,
list: [],
page: 1,
total: 0,
loading: false,
loadingMore: false,
hasMore: true,
_searchTimer: null,
cardActions: [{ key: 'approve', label: '办理', primary: true }]
}
},
onShow() {
if (!getToken()) {
forceRelogin('请先登录')
return
}
this.loadList(true)
},
onPullDownRefresh() {
this.loadList(true)
.catch(() => {})
.finally(() => {
uni.stopPullDownRefresh()
})
},
onReachBottom() {
if (!this.hasMore || this.loading || this.loadingMore) return
this.loadList(false)
},
onUnload() {
if (this._searchTimer) {
clearTimeout(this._searchTimer)
this._searchTimer = null
}
},
methods: {
cardTitle(row) {
return (row && (row.appName || row.title)) || '-'
},
statusText(s) {
if (!s) return '-'
return STATUS_MAP[s] || s
},
statusTone(s) {
if (s === 'pending' || s === 'running') return 'warn'
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
return 'info'
},
metaLines(row) {
return [
{
label: '申请人',
value: (row && row.initiatorUserName) || '-'
},
{
label: '申请时间',
value: (row && row.applyTime) || '-'
},
{
label: '当前节点',
value: (row && row.currentNodeName) || '-'
}
]
},
scheduleSearch() {
if (this._searchTimer) clearTimeout(this._searchTimer)
this._searchTimer = setTimeout(() => {
this.loadList(true)
}, 400)
},
onAppNameInput(e) {
this.appName = this._inputVal(e)
this.scheduleSearch()
},
onInitiatorInput(e) {
this.initiatorName = this._inputVal(e)
this.scheduleSearch()
},
_inputVal(e) {
if (e && e.detail && e.detail.value != null) return String(e.detail.value)
if (e && e.target) return String(e.target.value || '')
return ''
},
onSearchConfirm() {
if (this._searchTimer) {
clearTimeout(this._searchTimer)
this._searchTimer = null
}
this.loadList(true)
},
clearAppName() {
this.appName = ''
this.onSearchConfirm()
},
clearInitiator() {
this.initiatorName = ''
this.onSearchConfirm()
},
openApprove(row) {
if (!row) return
const instanceId = String(row.instanceId || '').trim()
if (!instanceId) {
uni.showToast({ title: '缺少实例 ID', icon: 'none' })
return
}
// row.id = 待办审批任务 id(对齐办理动作 taskId;Web pending 仅跳实例页,移动端需带 taskId)
const taskId = String(row.id || '').trim()
stashOaDetailTitle(row.appName || row.title)
let url =
'/pages/oa/webview/index?instanceId=' +
encodeURIComponent(instanceId) +
'&mode=approve'
if (taskId) {
url += '&taskId=' + encodeURIComponent(taskId)
}
uni.navigateTo({ url })
},
async loadList(reset) {
if (!getToken()) {
forceRelogin('请先登录')
return
}
const isReset = !!reset
if (isReset) {
this.page = 1
this.hasMore = true
this.loading = true
} else {
this.loadingMore = true
}
try {
const res = await fetchOaTasks('pending', {
appName: (this.appName || '').trim() || undefined,
initiatorName: (this.initiatorName || '').trim() || undefined,
page: this.page,
pageSize: PAGE_SIZE
})
const rows = (res && res.list) || []
const total = (res && res.total) != null ? Number(res.total) : 0
this.total = total
if (isReset) this.list = rows
else this.list = this.list.concat(rows)
const loaded = this.list.length
this.hasMore = loaded < total && rows.length > 0
if (rows.length > 0) this.page += 1
} catch (e) {
if (isReset) this.list = []
uni.showToast({
title: (e && e.message) || '加载失败',
icon: 'none'
})
} finally {
this.loading = false
this.loadingMore = false
}
}
}
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
padding-bottom: 48rpx;
background: #f5f6f8;
box-sizing: border-box;
}
.toolbar {
position: sticky;
top: 0;
z-index: 10;
background: #fff;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 24rpx 0;
box-sizing: border-box;
}
.toolbar-row--sub {
padding-top: 12rpx;
}
.search-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16rpx;
height: 72rpx;
padding: 0 24rpx;
border-radius: 20rpx;
border: 1rpx solid #e8eaed;
background: #fafafa;
box-sizing: border-box;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.search-box.is-focus {
border-color: #cbd5e1;
background: #fff;
box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
}
.search-ico {
position: relative;
width: 26rpx;
height: 26rpx;
border: 3rpx solid #94a3b8;
border-radius: 50%;
box-sizing: border-box;
flex-shrink: 0;
}
.search-ico::after {
content: '';
position: absolute;
width: 12rpx;
height: 3rpx;
right: -8rpx;
bottom: -5rpx;
background: #94a3b8;
border-radius: 3rpx;
transform: rotate(45deg);
}
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #1f2937;
min-width: 0;
}
.search-ph {
color: #b0b7c3;
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: #e5e7eb;
flex-shrink: 0;
}
.search-clear__x {
font-size: 28rpx;
line-height: 1;
color: #6b7280;
margin-top: -2rpx;
}
.state {
padding: 80rpx 24rpx;
text-align: center;
color: #999;
font-size: 28rpx;
}
.state-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28rpx;
min-height: 50vh;
padding: 96rpx 32rpx;
box-sizing: border-box;
}
.pulse-dots {
display: flex;
align-items: center;
gap: 16rpx;
}
.pulse-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: var(--brand-green, #22c55e);
animation: oa-pulse-bounce 1.2s ease-in-out infinite;
}
.pulse-dot:nth-child(2) {
animation-delay: 0.15s;
opacity: 0.75;
}
.pulse-dot:nth-child(3) {
animation-delay: 0.3s;
opacity: 0.55;
}
.state-loading__text {
font-size: 26rpx;
font-weight: 500;
color: var(--text-muted, #5f7a6d);
line-height: 1.4;
}
@keyframes oa-pulse-bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-12rpx);
}
}
</style>
@@ -104,6 +104,8 @@ const props = defineProps({
disabled: { type: Boolean, default: false },
multiple: { type: Boolean, default: true },
max: { type: [Number, String], default: null },
/** 不可选用户 id(如转交时排除自己及本节点已有待办审批人) */
excludeIds: { type: Array, default: () => [] },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
@@ -117,14 +119,26 @@ const nameMap = ref(new Map())
const userOptions = ref([])
const multiple = computed(() => !!props.multiple)
const excludeSet = computed(() => {
const set = new Set()
for (const x of props.excludeIds || []) {
const id = String(x || '').trim()
if (id) set.add(id)
}
return set
})
const normalized = computed(() =>
normalizeIdNameModelValue(props.modelValue, multiple.value)
)
const displayText = computed(() => formatIdNameDisplay(normalized.value))
const filtered = computed(() => {
const excluded = excludeSet.value
const base = excluded.size
? userOptions.value.filter((u) => !excluded.has(u.id))
: userOptions.value
const q = keyword.value.trim().toLowerCase()
if (!q) return userOptions.value
return userOptions.value.filter(
if (!q) return base
return base.filter(
(u) =>
u.label.toLowerCase().includes(q) ||
String(u.sub || '')
+396 -9
View File
@@ -4,7 +4,7 @@
<OaPulseLoading text="加载审批详情…" />
</div>
<template v-else>
<div class="oa-detail__body">
<div class="oa-detail__body" :class="{ 'oa-detail__body--approve': canApprove }">
<header class="oa-detail__hd">
<h1 class="oa-detail__title">{{ detail?.title || '审批详情' }}</h1>
<span
@@ -28,8 +28,139 @@
<van-button class="oa-detail__btn oa-detail__btn--close" @click="onClose">
关闭
</van-button>
<template v-if="canApprove">
<van-button
class="oa-detail__btn oa-detail__btn--ghost"
:loading="acting"
:disabled="acting"
@click="openComment('reject')"
>
拒绝
</van-button>
<van-button
v-if="detail?.allowReturn !== false"
class="oa-detail__btn oa-detail__btn--ghost"
:loading="acting"
:disabled="acting"
@click="openComment('return')"
>
打回
</van-button>
<van-button
v-if="detail?.allowAddSign !== false"
class="oa-detail__btn oa-detail__btn--ghost"
:loading="acting"
:disabled="acting"
@click="openAddSign"
>
加签
</van-button>
<van-button
class="oa-detail__btn oa-detail__btn--ghost"
:loading="acting"
:disabled="acting"
@click="openTransfer"
>
转交
</van-button>
<van-button
class="oa-detail__btn oa-detail__btn--primary"
type="primary"
:loading="acting"
:disabled="acting"
@click="openComment('approve')"
>
同意
</van-button>
</template>
</footer>
</template>
<!-- 同意 / 拒绝 / 打回意见 -->
<van-dialog
v-model:show="commentVisible"
:title="commentTitle"
show-cancel-button
confirm-button-text="确定"
cancel-button-text="取消"
:before-close="onCommentBeforeClose"
>
<div class="oa-detail__dialog-body">
<van-field
v-model="commentText"
type="textarea"
rows="3"
autosize
maxlength="500"
show-word-limit
:placeholder="commentPlaceholder"
/>
</div>
</van-dialog>
<!-- 加签 -->
<van-popup
v-model:show="addSignVisible"
position="bottom"
round
:style="{ height: '72%' }"
>
<div class="oa-act-sheet">
<div class="oa-act-sheet__hd">
<span @click="addSignVisible = false">取消</span>
<strong>加签</strong>
<span class="oa-act-sheet__ok" @click="submitAddSign">确定</span>
</div>
<div class="oa-act-sheet__body">
<p class="oa-act-sheet__label">加签人</p>
<OaPersonSelect v-model="addSignUsers" :multiple="true" />
<p class="oa-act-sheet__label">说明(可选)</p>
<van-field
v-model="addSignComment"
type="textarea"
rows="2"
autosize
maxlength="500"
show-word-limit
placeholder="加签说明"
/>
</div>
</div>
</van-popup>
<!-- 转交 -->
<van-popup
v-model:show="transferVisible"
position="bottom"
round
:style="{ height: '72%' }"
>
<div class="oa-act-sheet">
<div class="oa-act-sheet__hd">
<span @click="transferVisible = false">取消</span>
<strong>转交</strong>
<span class="oa-act-sheet__ok" @click="submitTransfer">确定</span>
</div>
<div class="oa-act-sheet__body">
<p class="oa-act-sheet__label">转交给</p>
<OaPersonSelect
v-model="transferUser"
:multiple="false"
:exclude-ids="transferExcludeIds"
/>
<p class="oa-act-sheet__label">说明(可选)</p>
<van-field
v-model="transferComment"
type="textarea"
rows="2"
autosize
maxlength="500"
show-word-limit
placeholder="转交说明"
/>
</div>
</div>
</van-popup>
</div>
</template>
@@ -37,21 +168,48 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { showToast } from 'vant'
import { fetchOaInstanceDetail } from '../api/oa.js'
import {
fetchOaInstanceDetail,
fetchCurrentUser,
fetchApproveOaTask,
fetchRejectOaTask,
fetchReturnOaTask,
fetchAddSignOaTask,
fetchTransferOaTask
} from '../api/oa.js'
import { prepareMobileFormSchema } from '../oa/schema-prep.js'
import { statusText, statusTone } from '../oa/status-label.js'
import OaProgressTimeline from '../oa/components/OaProgressTimeline.vue'
import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
import OaPersonSelect from '../oa/components/OaPersonSelect.vue'
const route = useRoute()
const loading = ref(true)
const acting = ref(false)
const detail = ref(null)
const formData = ref({})
const formRule = ref([])
const formOption = ref({})
const currentUserId = ref('')
const commentVisible = ref(false)
const commentKind = ref('approve') // approve | reject | return
const commentText = ref('')
const addSignVisible = ref(false)
const addSignUsers = ref([])
const addSignComment = ref('')
const transferVisible = ref(false)
const transferUser = ref(null)
const transferComment = ref('')
/** instanceId 必填;mode 默认 view;taskId 本任务忽略办理动作 */
const instanceId = computed(() => String(route.query.instanceId || '').trim())
const queryTaskId = computed(() => String(route.query.taskId || '').trim())
const mode = computed(() => {
const m = String(route.query.mode || 'view').trim()
return m || 'view'
})
const timelineItems = computed(() => {
const list = detail.value?.progress
@@ -74,6 +232,50 @@ const timelineItems = computed(() => {
}))
})
function isPendingApprovalForMe(t) {
const uid = currentUserId.value
if (!uid || !t) return false
return (
t.taskType === 'approval' &&
t.status === 'pending' &&
String(t.assigneeUserId || '') === uid
)
}
/** 优先 query.taskId(须为本用户待办审批任务),否则取本用户第一条 pending approval */
const myPendingTask = computed(() => {
const list = detail.value?.tasks || []
const qid = queryTaskId.value
if (qid) {
const matched = list.find((t) => String(t.id || '') === qid && isPendingApprovalForMe(t))
if (matched) return matched
}
return list.find((t) => isPendingApprovalForMe(t))
})
const canApprove = computed(() => !!myPendingTask.value && mode.value === 'approve')
/** 转交:排除自己 + 本节点已有 pending approval 的办理人 */
const transferExcludeIds = computed(() => {
const me = currentUserId.value
const pendingIds = (detail.value?.tasks || [])
.filter((t) => t.status === 'pending' && t.taskType === 'approval')
.map((t) => String(t.assigneeUserId || '').trim())
.filter(Boolean)
return [...new Set([me, ...pendingIds].filter(Boolean))]
})
const commentTitle = computed(() => {
if (commentKind.value === 'reject') return '拒绝'
if (commentKind.value === 'return') return '打回'
return '同意'
})
const commentPlaceholder = computed(() => {
if (commentKind.value === 'return') return '请填写打回意见(必填)'
return '审批意见(可选)'
})
/** 通知宿主关闭(对齐 OaStart closeToMini) */
function closeToMini(payload) {
const data = payload || { action: 'oa-detail-close' }
@@ -119,6 +321,125 @@ function parseFormData(d) {
return {}
}
function openComment(kind) {
if (!myPendingTask.value) return
commentKind.value = kind
commentText.value = ''
commentVisible.value = true
}
function onCommentBeforeClose(action) {
if (action !== 'confirm') return true
if (commentKind.value === 'return') {
const c = String(commentText.value || '').trim()
if (!c) {
showToast('打回意见必填')
return false
}
}
void submitComment()
return false
}
async function submitComment() {
const task = myPendingTask.value
if (!task) {
commentVisible.value = false
return
}
const comment = String(commentText.value || '').trim()
acting.value = true
try {
const kind = commentKind.value
if (kind === 'approve') {
await fetchApproveOaTask(task.id, comment || undefined)
commentVisible.value = false
showToast('已同意')
closeToMini({ action: 'oa-detail-done', message: '已同意' })
} else if (kind === 'reject') {
await fetchRejectOaTask(task.id, comment || undefined)
commentVisible.value = false
showToast('已拒绝')
closeToMini({ action: 'oa-detail-done', message: '已拒绝' })
} else if (kind === 'return') {
await fetchReturnOaTask(task.id, comment)
commentVisible.value = false
showToast('已打回')
closeToMini({ action: 'oa-detail-done', message: '已打回' })
}
} catch (e) {
showToast(e?.message || '操作失败')
} finally {
acting.value = false
}
}
function openAddSign() {
if (!myPendingTask.value) return
addSignUsers.value = []
addSignComment.value = ''
addSignVisible.value = true
}
async function submitAddSign() {
const task = myPendingTask.value
if (!task) return
const userIds = (Array.isArray(addSignUsers.value) ? addSignUsers.value : [])
.map((u) => String(u?.id || '').trim())
.filter(Boolean)
if (!userIds.length) {
showToast('请选择加签人')
return
}
acting.value = true
try {
const c = String(addSignComment.value || '').trim()
await fetchAddSignOaTask(task.id, {
userIds,
comment: c || undefined
})
addSignVisible.value = false
showToast('已加签')
closeToMini({ action: 'oa-detail-done', message: '已加签' })
} catch (e) {
showToast(e?.message || '加签失败')
} finally {
acting.value = false
}
}
function openTransfer() {
if (!myPendingTask.value) return
transferUser.value = null
transferComment.value = ''
transferVisible.value = true
}
async function submitTransfer() {
const task = myPendingTask.value
if (!task) return
const targetUserId = String(transferUser.value?.id || '').trim()
if (!targetUserId) {
showToast('请选择转交人')
return
}
acting.value = true
try {
const c = String(transferComment.value || '').trim()
await fetchTransferOaTask(task.id, {
targetUserId,
comment: c || undefined
})
transferVisible.value = false
showToast('已转交')
closeToMini({ action: 'oa-detail-done', message: '已转交' })
} catch (e) {
showToast(e?.message || '转交失败')
} finally {
acting.value = false
}
}
async function bootstrap() {
loading.value = true
try {
@@ -127,9 +448,12 @@ async function bootstrap() {
showToast('缺少 instanceId')
return
}
// mode 默认 view;taskId 本任务忽略(办理动作留给后续)
const d = await fetchOaInstanceDetail(id)
const [me, d] = await Promise.all([
fetchCurrentUser().catch(() => null),
fetchOaInstanceDetail(id)
])
currentUserId.value = String(me?.id || me?.userId || '').trim()
detail.value = d
try {
document.title = d?.title || d?.appName || '审批详情'
@@ -178,6 +502,9 @@ onMounted(() => {
flex: 1;
padding: 12px 12px 96px;
}
.oa-detail__body--approve {
padding-bottom: 128px;
}
.oa-detail__hd {
display: flex;
flex-wrap: wrap;
@@ -230,20 +557,26 @@ onMounted(() => {
left: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
gap: 8px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eef2f6;
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
}
.oa-detail__btn {
margin: 0;
height: 44px;
height: 40px;
min-width: 0;
flex: 1 1 auto;
border-radius: 12px !important;
font-size: 15px;
font-size: 14px;
padding: 0 10px !important;
}
.oa-detail__btn--close {
flex: 1;
flex: 0 0 auto;
min-width: 64px;
border: none !important;
background: #f1f5f9 !important;
color: #334155 !important;
@@ -252,4 +585,58 @@ onMounted(() => {
.oa-detail__btn--close:active {
opacity: 0.85;
}
.oa-detail__btn--ghost {
border: 1px solid #e2e8f0 !important;
background: #fff !important;
color: #334155 !important;
font-weight: 600;
}
.oa-detail__btn--primary {
flex: 1 1 88px;
border: none !important;
background: var(--brand-green, #22c55e) !important;
color: #fff !important;
font-weight: 700;
}
.oa-detail__dialog-body {
padding: 8px 16px 4px;
}
.oa-act-sheet {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.oa-act-sheet__hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
font-size: 15px;
color: #64748b;
}
.oa-act-sheet__hd strong {
color: #0f172a;
font-size: 16px;
}
.oa-act-sheet__ok {
color: var(--brand-green, #22c55e);
font-weight: 700;
}
.oa-act-sheet__body {
flex: 1;
overflow: auto;
padding: 12px 16px 24px;
-webkit-overflow-scrolling: touch;
}
.oa-act-sheet__label {
margin: 12px 0 8px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.oa-act-sheet__label:first-child {
margin-top: 0;
}
</style>