Explorar o código

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

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 2 meses
pai
achega
d5287765ec

+ 3 - 1
mobile-h5/pages.json

@@ -79,7 +79,9 @@
       "style": {
         "navigationBarTitleText": "待办中心",
         "navigationBarBackgroundColor": "#ecfdf5",
-        "navigationBarTextStyle": "black"
+        "navigationBarTextStyle": "black",
+        "enablePullDownRefresh": true,
+        "backgroundTextStyle": "dark"
       }
     },
     {

+ 418 - 6
mobile-h5/pages/oa/pending/index.vue

@@ -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>

+ 16 - 2
mobile-oa-h5/src/oa/components/OaPersonSelect.vue

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

@@ -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>