feat(oa): add mobile OA detail page in read-only mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-12 16:14:59 +08:00
1 parent 47d7a1421a
commit d827f55bf0
3 files changed
+456 -1

No files matched your search

@@ -0,0 +1,177 @@
<template>
<div class="oa-progress">
<div class="oa-progress__title">审批进度</div>
<div v-if="!items.length" class="oa-progress__empty">暂无任务记录</div>
<div v-else class="oa-progress__chain">
<div v-for="(item, idx) in items" :key="item.key || idx" class="oa-progress-step">
<div class="oa-progress-rail">
<div class="oa-progress-dot" :class="dotClass(item)" />
<div v-if="idx < items.length - 1" class="oa-progress-line" />
</div>
<div class="oa-progress-card">
<div class="oa-progress-card__row">
<span class="oa-progress-card__name">{{ item.title || '-' }}</span>
<span
v-if="item.status"
class="oa-progress-badge"
:class="statusTone(item.status)"
>
{{ statusText(item.status) }}
</span>
</div>
<div v-if="item.assignee" class="oa-progress-card__meta">
{{ item.assignee }}
</div>
<div v-if="item.timestamp" class="oa-progress-card__meta">
{{ item.timestamp }}
</div>
<div v-if="item.commentText" class="oa-progress-card__comment">
意见:{{ item.commentText }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
items: { type: Array, default: () => [] }
})
function statusText(s) {
const map = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
submitted: '已提交',
not_reached: '未到达',
returned: '已打回',
transferred: '已转交'
}
return map[s] || s || ''
}
function statusTone(s) {
if (s === 'pending' || s === 'running') return 'warn'
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'ok'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
return 'mute'
}
function dotClass(item) {
return statusTone(item?.status)
}
</script>
<style scoped>
.oa-progress {
margin-top: 16px;
padding: 14px 12px 8px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border, #d8f3e5);
}
.oa-progress__title {
margin-bottom: 12px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary, #0f291e);
}
.oa-progress__empty {
padding: 12px 4px 16px;
color: var(--text-muted, #5f7a6d);
font-size: 13px;
line-height: 1.5;
}
.oa-progress-step {
display: flex;
gap: 10px;
min-height: 56px;
}
.oa-progress-rail {
display: flex;
flex-direction: column;
align-items: center;
width: 16px;
}
.oa-progress-dot {
width: 10px;
height: 10px;
margin-top: 6px;
border-radius: 50%;
background: var(--brand-green, #22c55e);
}
.oa-progress-dot.warn {
background: var(--warning, #d97706);
}
.oa-progress-dot.danger {
background: var(--danger, #ef4444);
}
.oa-progress-dot.mute {
background: #94a3b8;
}
.oa-progress-line {
flex: 1;
width: 2px;
margin: 4px 0;
background: #d1fae5;
}
.oa-progress-card {
flex: 1;
padding: 0 0 16px;
min-width: 0;
}
.oa-progress-card__row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.oa-progress-card__name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #0f291e);
}
.oa-progress-badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
}
.oa-progress-badge.ok {
color: var(--success, #10b981);
background: var(--brand-green-soft, #ecfdf5);
}
.oa-progress-badge.warn {
color: var(--warning, #d97706);
background: var(--barley-yellow-soft, #fffbeb);
}
.oa-progress-badge.danger {
color: var(--danger, #ef4444);
background: #fef2f2;
}
.oa-progress-badge.mute {
color: var(--text-muted, #5f7a6d);
background: #f1f5f9;
}
.oa-progress-card__meta {
margin-top: 4px;
color: var(--text-muted, #5f7a6d);
font-size: 12px;
line-height: 1.4;
}
.oa-progress-card__comment {
margin-top: 6px;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
color: var(--text-primary, #0f291e);
font-size: 12px;
line-height: 1.5;
}
</style>
+3 -1
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import FormSink from './views/FormSink.vue'
import OaStart from './views/OaStart.vue'
import OaDetail from './views/OaDetail.vue'
export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -10,6 +11,7 @@ export default createRouter({
redirect: (to) => ({ path: '/oa-form-sink', query: to.query, hash: to.hash })
},
{ path: '/oa-form-sink', component: FormSink },
{ path: '/oa-start', component: OaStart }
{ path: '/oa-start', component: OaStart },
{ path: '/oa-detail', component: OaDetail }
]
})
+276
View File
@@ -0,0 +1,276 @@
<template>
<div class="oa-detail">
<div v-if="loading" class="oa-detail__loading">
<OaPulseLoading text="加载审批详情…" />
</div>
<template v-else>
<div class="oa-detail__body">
<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>
</footer>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { showToast } from 'vant'
import { fetchOaInstanceDetail } from '../api/oa.js'
import { prepareMobileFormSchema } from '../oa/schema-prep.js'
import OaProgressTimeline from '../oa/components/OaProgressTimeline.vue'
import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
const route = useRoute()
const loading = ref(true)
const detail = ref(null)
const formData = ref({})
const formRule = ref([])
const formOption = ref({})
/** instanceId 必填;mode 默认 view;taskId 本任务忽略办理动作 */
const instanceId = computed(() => String(route.query.instanceId || '').trim())
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 statusText(s) {
const map = {
running: '进行中',
pending: '进行中',
completed: '已完成',
approved: '已完成',
done: '已完成',
rejected: '已拒绝',
returned: '已打回',
cancelled: '已撤销'
}
return map[s] || s || ''
}
function statusTone(s) {
if (s === 'pending' || s === 'running') return 'warn'
if (s === 'approved' || s === 'completed' || s === 'done') return 'ok'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
return 'mute'
}
/** 通知宿主关闭(对齐 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' })
}
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 bootstrap() {
loading.value = true
try {
const id = instanceId.value
if (!id) {
showToast('缺少 instanceId')
return
}
// mode 默认 view;taskId 本任务忽略(办理动作留给后续)
const d = await fetchOaInstanceDetail(id)
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
}
}
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__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;
align-items: center;
padding: 12px 14px calc(12px + 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;
border-radius: 12px !important;
font-size: 15px;
}
.oa-detail__btn--close {
flex: 1;
border: none !important;
background: #f1f5f9 !important;
color: #334155 !important;
font-weight: 600;
}
.oa-detail__btn--close:active {
opacity: 0.85;
}
</style>