| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791 |
- <template>
- <div class="oa-detail">
- <div v-if="loading" class="oa-detail__loading">
- <OaPulseLoading text="加载审批详情…" />
- </div>
- <template v-else>
- <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
- v-if="detail?.status"
- class="oa-detail__status"
- :class="statusTone(detail.status)"
- >
- {{ statusText(detail.status) }}
- </span>
- </header>
- <form-create-mobile
- v-if="formRule.length"
- v-model="formData"
- :rule="formRule"
- :option="formOption"
- />
- <p v-else class="oa-detail__empty">暂无表单内容</p>
- <OaProgressTimeline :items="timelineItems" />
- </div>
- <footer class="oa-detail__ft">
- <van-button class="oa-detail__btn oa-detail__btn--close" @click="onClose">
- 关闭
- </van-button>
- <van-button
- class="oa-detail__btn oa-detail__btn--ghost oa-detail__btn--print"
- :loading="printing"
- :disabled="acting || printing"
- @click="onPrint"
- >
- 打印
- </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>
- <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>
- <!-- 同意 / 拒绝 / 打回意见 -->
- <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>
- <script setup>
- import { computed, onMounted, ref } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import { showConfirmDialog, showToast } from 'vant'
- import {
- fetchOaInstanceDetail,
- fetchCurrentUser,
- fetchApproveOaTask,
- fetchRejectOaTask,
- fetchReturnOaTask,
- fetchAddSignOaTask,
- fetchTransferOaTask,
- fetchWithdrawOaInstance
- } from '../api/oa.js'
- import { prepareMobileFormSchema } from '../oa/schema-prep.js'
- import { statusText, statusTone } from '../oa/status-label.js'
- import { exportOaApprovalPdf } from '../oa/print/exportOaApprovalPdf.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 router = useRouter()
- const loading = ref(true)
- const acting = ref(false)
- const printing = ref(false)
- const detail = ref(null)
- const formData = ref({})
- const formRule = ref([])
- const formOption = ref({})
- const currentUserId = ref('')
- const currentUserName = ref('')
- const autoPrinted = ref(false)
- 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('')
- const instanceId = computed(() => String(route.query.instanceId || '').trim())
- const queryTaskId = computed(() => String(route.query.taskId || '').trim())
- const wantAutoPrint = computed(() => {
- const v = String(route.query.autoPrint || '').trim()
- return v === '1' || v === 'true'
- })
- const mode = computed(() => {
- const m = String(route.query.mode || 'view').trim()
- return m || 'view'
- })
- const timelineItems = computed(() => {
- const list = detail.value?.progress
- if (!Array.isArray(list) || !list.length) return []
- return list
- .filter((p) => {
- const kind = String(p?.kind || '')
- const status = String(p?.status || '')
- const title = String(p?.title || '').trim()
- if (kind === 'terminal' && (status === 'returned' || title === '已打回')) return false
- return true
- })
- .map((p, idx) => ({
- key: `${p.kind}-${p.nodeKey || p.taskId || idx}-${p.roundNo || 1}`,
- title: p.title || '-',
- status: p.status || '',
- timestamp: p.timestamp || '',
- assignee: p.assigneeUserName || p.assigneeUserId || '',
- commentText: p.commentText || ''
- }))
- })
- 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')
- /** 与 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
- 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' }
- try {
- if (typeof wx !== 'undefined' && wx.miniProgram?.navigateBack) {
- wx.miniProgram.postMessage({ data })
- wx.miniProgram.navigateBack()
- return
- }
- } catch (_) {
- /* ignore */
- }
- try {
- const uniWv = typeof uni !== 'undefined' ? uni.webView : null
- if (uniWv?.postMessage) {
- uniWv.postMessage({ data })
- }
- if (uniWv?.navigateBack) {
- uniWv.navigateBack()
- return
- }
- } catch (_) {
- /* ignore */
- }
- try {
- if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
- window.parent.postMessage({ source: 'zkxw-oa-h5', ...data }, '*')
- return
- }
- } catch (_) {
- /* ignore */
- }
- showToast((data && data.message) || '已关闭(非小程序环境)')
- }
- function onClose() {
- closeToMini({ action: 'oa-detail-close' })
- }
- async function onPrint() {
- if (!detail.value || printing.value) return
- printing.value = true
- try {
- const printerName =
- currentUserName.value ||
- String(detail.value?.initiatorUserName || '').trim() ||
- '-'
- const result = await exportOaApprovalPdf(detail.value, { printerName })
- if (!result?.ok) {
- showToast(
- result?.message ||
- '当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
- )
- }
- } catch (e) {
- showToast(
- e?.message || '当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
- )
- } finally {
- printing.value = false
- }
- }
- function parseFormData(d) {
- if (d?.formData && typeof d.formData === 'object') return { ...d.formData }
- if (d?.formDataJson && typeof d.formDataJson === 'object') return { ...d.formDataJson }
- 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
- 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 {
- const id = instanceId.value
- if (!id) {
- showToast('缺少 instanceId')
- return
- }
- const [me, d] = await Promise.all([
- fetchCurrentUser().catch(() => null),
- fetchOaInstanceDetail(id)
- ])
- currentUserId.value = String(me?.id || me?.userId || '').trim()
- currentUserName.value = String(
- me?.nickName || me?.userName || me?.name || ''
- ).trim()
- detail.value = d
- try {
- document.title = d?.title || d?.appName || '审批详情'
- } catch (_) {
- /* ignore */
- }
- const prepared = prepareMobileFormSchema(d?.formSchemaJson)
- formRule.value = prepared.rule
- formOption.value = {
- ...prepared.option,
- form: {
- ...(prepared.option?.form || {}),
- disabled: true
- },
- submitBtn: false,
- resetBtn: false
- }
- formData.value = parseFormData(d)
- } catch (e) {
- showToast(e?.message || '加载失败')
- } finally {
- loading.value = false
- }
- if (wantAutoPrint.value && detail.value && !autoPrinted.value) {
- autoPrinted.value = true
- // 等首屏渲染后再调打印,避免遮挡 loading
- setTimeout(() => {
- void onPrint()
- }, 320)
- }
- }
- onMounted(() => {
- bootstrap()
- })
- </script>
- <style scoped>
- .oa-detail {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- background: var(--bg-page, #ffffff);
- }
- .oa-detail__loading {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 48px 16px;
- }
- .oa-detail__body {
- flex: 1;
- padding: 12px 12px 96px;
- }
- .oa-detail__body--approve {
- padding-bottom: 128px;
- }
- .oa-detail__body--mine-act {
- padding-bottom: 120px;
- }
- .oa-detail__hd {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
- margin-bottom: 12px;
- padding: 4px 2px;
- }
- .oa-detail__title {
- margin: 0;
- font-size: 17px;
- font-weight: 700;
- line-height: 1.35;
- color: var(--text-primary, #0f291e);
- }
- .oa-detail__status {
- flex-shrink: 0;
- padding: 2px 8px;
- border-radius: 6px;
- font-size: 12px;
- font-weight: 600;
- }
- .oa-detail__status.ok {
- color: var(--success, #10b981);
- background: var(--brand-green-soft, #ecfdf5);
- }
- .oa-detail__status.warn {
- color: var(--warning, #d97706);
- background: var(--barley-yellow-soft, #fffbeb);
- }
- .oa-detail__status.danger {
- color: var(--danger, #ef4444);
- background: #fef2f2;
- }
- .oa-detail__status.mute {
- color: var(--text-muted, #5f7a6d);
- background: #f1f5f9;
- }
- .oa-detail__empty {
- margin: 12px 0;
- padding: 16px 8px;
- color: var(--text-muted, #5f7a6d);
- font-size: 13px;
- text-align: center;
- }
- .oa-detail__ft {
- position: fixed;
- right: 0;
- bottom: 0;
- left: 0;
- z-index: 20;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- 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: 40px;
- min-width: 0;
- flex: 1 1 auto;
- border-radius: 12px !important;
- font-size: 14px;
- padding: 0 10px !important;
- }
- .oa-detail__btn--close {
- flex: 0 0 auto;
- min-width: 64px;
- border: none !important;
- background: #f1f5f9 !important;
- color: #334155 !important;
- font-weight: 600;
- }
- .oa-detail__btn--close:active {
- opacity: 0.85;
- }
- .oa-detail__btn--print {
- flex: 0 0 auto;
- min-width: 64px;
- }
- .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>
|