feat(oa): support withdraw and copy-restart on mobile mine flows

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-12 16:31:04 +08:00
1 parent d5287765ec
commit ecdf73fe23
5 files changed
+244 -17

No files matched your search

+40 -5
View File
@@ -41,9 +41,9 @@
:status-text="statusText(row.instanceStatus || row.status)"
:status-tone="statusTone(row.instanceStatus || row.status)"
:meta-lines="metaLines(row)"
:actions="cardActions"
:actions="rowActions(row)"
@open="openView(row)"
@action="openView(row)"
@action="onCardAction($event, row)"
/>
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
@@ -55,10 +55,12 @@
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'
import { stashOaDetailTitle, stashOaStartTitle } from '../../../utils/oa-h5.js'
const PAGE_SIZE = 20
const COPY_RESTART_STATUSES = ['returned', 'cancelled', 'rejected']
const STATUS_MAP = {
pending: '待处理',
running: '进行中',
@@ -83,8 +85,7 @@
loading: false,
loadingMore: false,
hasMore: true,
_searchTimer: null,
cardActions: [{ key: 'view', label: '查看', primary: true }]
_searchTimer: null
}
},
onShow() {
@@ -141,6 +142,15 @@
}
]
},
rowActions(row) {
const actions = [{ key: 'view', label: '查看', primary: true }]
const status = String((row && (row.instanceStatus || row.status)) || '')
const appId = String((row && row.appId) || '').trim()
if (COPY_RESTART_STATUSES.includes(status) && appId) {
actions.push({ key: 'copyRestart', label: '复制并重新发起' })
}
return actions
},
onSearchInput(e) {
const v =
e && e.detail && e.detail.value != null
@@ -165,6 +175,14 @@
this.appName = ''
this.onSearchConfirm()
},
onCardAction(act, row) {
if (!act || !row) return
if (act.key === 'copyRestart') {
this.openCopyRestart(row)
return
}
this.openView(row)
},
openView(row) {
if (!row) return
// mine 行是实例:Web 用 row.id;兼容 instanceId
@@ -181,6 +199,23 @@
'&mode=view'
})
},
openCopyRestart(row) {
if (!row) return
const appId = String(row.appId || '').trim()
const fromInstanceId = String(row.instanceId || row.id || '').trim()
if (!appId || !fromInstanceId) {
uni.showToast({ title: '无法复制:缺少应用或实例', icon: 'none' })
return
}
stashOaStartTitle(row.appName || row.title)
uni.navigateTo({
url:
'/pages/oa/webview/index?appId=' +
encodeURIComponent(appId) +
'&fromInstanceId=' +
encodeURIComponent(fromInstanceId)
})
},
async loadList(reset) {
if (!getToken()) {
forceRelogin('请先登录')
+32 -2
View File
@@ -39,6 +39,7 @@ export default {
const mode = String((query && query.mode) || '') || 'view'
const appId = String((query && query.appId) || '')
const draftId = String((query && query.draftId) || '')
const fromInstanceId = String((query && query.fromInstanceId) || '')
let src = ''
@@ -48,14 +49,14 @@ export default {
uni.setNavigationBarTitle({ title: cachedName })
}
src = buildOaDetailUrl({ instanceId, taskId, mode })
} else if (draftId || appId) {
} else if (draftId || appId || fromInstanceId) {
const cachedName = takeOaStartTitle()
if (cachedName) {
uni.setNavigationBarTitle({ title: cachedName })
} else if (appId) {
this.resolveTitle(appId)
}
src = buildOaStartUrl({ appId, draftId })
src = buildOaStartUrl({ appId, draftId, fromInstanceId })
} else {
uni.showToast({ title: '参数错误', icon: 'none' })
setTimeout(() => {
@@ -109,6 +110,35 @@ export default {
if (this._closing) return
const action = String((payload && payload.action) || '')
if (action && !CLOSE_ACTIONS.has(action)) return
// 复制重开:离开详情 webview 栈时,改开发起页(同页 redirect)
if (action === 'oa-detail-restart') {
const appId = String((payload && payload.appId) || '').trim()
const fromInstanceId = String(
(payload && (payload.fromInstanceId || payload.instanceId)) || ''
).trim()
if (!appId && !fromInstanceId) return
this._closing = true
const q = []
if (appId) q.push('appId=' + encodeURIComponent(appId))
if (fromInstanceId) {
q.push('fromInstanceId=' + encodeURIComponent(fromInstanceId))
}
uni.redirectTo({
url: '/pages/oa/webview/index?' + q.join('&'),
fail: () => {
this._closing = false
uni.navigateTo({
url: '/pages/oa/webview/index?' + q.join('&'),
fail: () => {
this._closing = false
}
})
}
})
return
}
this._closing = true
const msg = String((payload && payload.message) || '').trim()
if (msg) {
+3 -2
View File
@@ -28,12 +28,13 @@ export function buildFormSinkUrl() {
return withQuery('/oa-form-sink', token ? { token } : {})
}
/** 正式发起页 URL(与水槽同一套拼法,仅多 appId/draftId) */
export function buildOaStartUrl({ appId, draftId } = {}) {
/** 正式发起页 URL(与水槽同一套拼法,仅多 appId/draftId/fromInstanceId) */
export function buildOaStartUrl({ appId, draftId, fromInstanceId } = {}) {
const token = getToken()
const params = {}
if (appId) params.appId = appId
if (draftId) params.draftId = draftId
if (fromInstanceId) params.fromInstanceId = fromInstanceId
if (token) params.token = token
return withQuery('/oa-start', params)
}
+98 -4
View File
@@ -4,7 +4,13 @@
<OaPulseLoading text="加载审批详情…" />
</div>
<template v-else>
<div class="oa-detail__body" :class="{ 'oa-detail__body--approve': canApprove }">
<div
class="oa-detail__body"
:class="{
'oa-detail__body--approve': canApprove,
'oa-detail__body--mine-act': (canWithdraw || canCopyRestart) && !canApprove
}"
>
<header class="oa-detail__hd">
<h1 class="oa-detail__title">{{ detail?.title || '审批详情' }}</h1>
<span
@@ -73,6 +79,24 @@
同意
</van-button>
</template>
<van-button
v-if="canWithdraw"
class="oa-detail__btn oa-detail__btn--ghost"
:loading="acting"
:disabled="acting"
@click="withdraw"
>
撤销
</van-button>
<van-button
v-if="canCopyRestart"
class="oa-detail__btn oa-detail__btn--primary"
type="primary"
:disabled="acting"
@click="goCopyRestart"
>
复制并重新发起
</van-button>
</footer>
</template>
@@ -166,8 +190,8 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog, showToast } from 'vant'
import {
fetchOaInstanceDetail,
fetchCurrentUser,
@@ -175,7 +199,8 @@ import {
fetchRejectOaTask,
fetchReturnOaTask,
fetchAddSignOaTask,
fetchTransferOaTask
fetchTransferOaTask,
fetchWithdrawOaInstance
} from '../api/oa.js'
import { prepareMobileFormSchema } from '../oa/schema-prep.js'
import { statusText, statusTone } from '../oa/status-label.js'
@@ -184,6 +209,7 @@ import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
import OaPersonSelect from '../oa/components/OaPersonSelect.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const acting = ref(false)
const detail = ref(null)
@@ -255,6 +281,22 @@ const myPendingTask = computed(() => {
const canApprove = computed(() => !!myPendingTask.value && mode.value === 'approve')
/** 与 Web instances/detail:发起人 + 进行中 */
const isInitiator = computed(() => {
const uid = currentUserId.value
if (!uid || !detail.value) return false
return String(detail.value.initiatorUserId || '') === uid
})
const canWithdraw = computed(
() => isInitiator.value && String(detail.value?.status || '') === 'running'
)
const canCopyRestart = computed(() => {
if (!isInitiator.value || !detail.value) return false
return ['returned', 'cancelled', 'rejected'].includes(String(detail.value.status || ''))
})
/** 转交:排除自己 + 本节点已有 pending approval 的办理人 */
const transferExcludeIds = computed(() => {
const me = currentUserId.value
@@ -321,6 +363,55 @@ function parseFormData(d) {
return {}
}
async function withdraw() {
try {
await showConfirmDialog({
title: '提示',
message: '确定撤销该审批吗?'
})
} catch {
return
}
const id = instanceId.value
if (!id) return
acting.value = true
try {
await fetchWithdrawOaInstance(id)
showToast('已撤销')
closeToMini({ action: 'oa-detail-withdraw', message: '已撤销' })
} catch (e) {
showToast(e?.message || '撤销失败')
} finally {
acting.value = false
}
}
function goCopyRestart() {
const appId = String(detail.value?.appId || '').trim()
const id = instanceId.value
if (!appId || !id) {
showToast('无法复制:缺少应用或实例信息')
return
}
// 优先同 webview 内跳转发起页(带 fromInstanceId 预填)
const q = { appId, fromInstanceId: id }
const token = String(route.query.token || '').trim()
if (token) q.token = token
try {
router.replace({ path: '/oa-start', query: q })
return
} catch (_) {
/* fallback below */
}
// 离开 webview 栈时:宿主按 oa-detail-restart 再开发起页
closeToMini({
action: 'oa-detail-restart',
appId,
fromInstanceId: id,
message: '复制并重新发起'
})
}
function openComment(kind) {
if (!myPendingTask.value) return
commentKind.value = kind
@@ -505,6 +596,9 @@ onMounted(() => {
.oa-detail__body--approve {
padding-bottom: 128px;
}
.oa-detail__body--mine-act {
padding-bottom: 120px;
}
.oa-detail__hd {
display: flex;
flex-wrap: wrap;
+71 -4
View File
@@ -67,7 +67,7 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import {
fetchOaTemplateDetail,
@@ -77,6 +77,7 @@ import {
fetchOaInstanceDraftDetail,
fetchSubmitOaInstanceDraft,
fetchStartOaInstance,
fetchOaInstanceDetail,
fetchUserList,
fetchCurrentUser,
fetchCnHolidayRange
@@ -109,6 +110,7 @@ import OaFlowPreview from '../oa/components/OaFlowPreview.vue'
import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
const route = useRoute()
const router = useRouter()
const detailLoading = ref(true)
const previewLoading = ref(false)
const savingDraft = ref(false)
@@ -612,6 +614,38 @@ async function loadByAppId(appId, { seedData = null, applyDefaults = true } = {}
await loadFlowPreview()
}
function takeRestartPrefillsFromSession() {
try {
const raw = sessionStorage.getItem('oa_restart_prefills')
if (!raw) return null
sessionStorage.removeItem('oa_restart_prefills')
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
return parsed
} catch (_) {
try {
sessionStorage.removeItem('oa_restart_prefills')
} catch (__) {
/* ignore */
}
return null
}
}
function parseInstanceFormData(d) {
if (d?.formData && typeof d.formData === 'object') return { ...d.formData }
if (d?.formDataJson && typeof d.formDataJson === 'object') return { ...d.formDataJson }
return {}
}
/** 加载成功后去掉 fromInstanceId,避免刷新/提交误当成编辑旧单 */
function clearFromInstanceIdQuery() {
const q = { ...route.query }
if (q.fromInstanceId == null || q.fromInstanceId === '') return
delete q.fromInstanceId
router.replace({ path: '/oa-start', query: q }).catch(() => {})
}
async function bootstrap() {
detailLoading.value = true
skipPreviewWatch = true
@@ -619,6 +653,9 @@ async function bootstrap() {
await loadCurrentUser()
const appId = String(route.query.appId || '').trim()
const draftId = String(route.query.draftId || '').trim()
const fromInstanceId = String(route.query.fromInstanceId || '').trim()
const sessionPrefills = takeRestartPrefillsFromSession()
if (draftId) {
const d = await fetchOaInstanceDraftDetail(draftId)
editingDraftId.value = draftId
@@ -632,10 +669,40 @@ async function bootstrap() {
d.formData && typeof d.formData === 'object' ? { ...d.formData } : {}
// 草稿:保留已填值,仅补空的 owner 等默认
await loadByAppId(aid, { seedData: seed, applyDefaults: true })
} else if (appId) {
initiatorSelectByNode.value = {}
} else if (appId || fromInstanceId || sessionPrefills) {
// 复制重开 / 普通发起:绝不带 editingDraftId,提交走新建实例
editingDraftId.value = ''
await loadByAppId(appId, { seedData: {}, applyDefaults: true })
let seed = {}
let aid = appId
let initiatorSel = {}
if (sessionPrefills) {
if (sessionPrefills.formData && typeof sessionPrefills.formData === 'object') {
seed = { ...sessionPrefills.formData }
}
if (
sessionPrefills.initiatorSelectByNode &&
typeof sessionPrefills.initiatorSelectByNode === 'object'
) {
initiatorSel = { ...sessionPrefills.initiatorSelectByNode }
}
}
if (fromInstanceId) {
try {
const old = await fetchOaInstanceDetail(fromInstanceId)
if (!aid) aid = String(old?.appId || '').trim()
const copied = parseInstanceFormData(old)
if (Object.keys(copied).length) seed = copied
} catch (_) {
/* 预填失败仍可手工填 */
}
}
if (!aid) throw new Error('缺少 appId')
initiatorSelectByNode.value = initiatorSel
await loadByAppId(aid, { seedData: seed, applyDefaults: true })
clearFromInstanceIdQuery()
} else {
showToast('缺少 appId')
}