feat(oa): add mobile OA detail page in read-only mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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>
|
||||
@@ -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 }
|
||||
]
|
||||
})
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user