Просмотр исходного кода

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

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
ecdf73fe23

+ 40 - 5
mobile-h5/pages/oa/mine/index.vue

@@ -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
mobile-h5/pages/oa/webview/index.vue

@@ -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
mobile-h5/utils/oa-h5.js

@@ -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
mobile-oa-h5/src/views/OaDetail.vue

@@ -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
mobile-oa-h5/src/views/OaStart.vue

@@ -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')
     }