feat(mobile-oa-h5): implement oa-start page with form and flow preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-11 13:39:51 +08:00
1 parent 7ea11ffdcd
commit 07eeb58d15
4 files changed
+761 -7

No files matched your search

@@ -0,0 +1,364 @@
<template>
<div class="oa-flow-preview">
<div class="oa-flow-preview__title">审批流程</div>
<van-loading v-if="loading" vertical class="oa-flow-preview__loading">加载中…</van-loading>
<div v-else-if="!resolved" class="oa-flow-preview__pending">
{{ pendingMessage || '必填信息填写后,流程将自动显示' }}
</div>
<div v-else class="oa-flow-preview__chain">
<div v-if="!steps.length" class="oa-flow-preview__empty">暂无流程配置</div>
<div v-for="(s, idx) in steps" :key="s.id || idx" class="oa-flow-step">
<div class="oa-flow-rail">
<div class="oa-flow-dot" :class="{ end: s.type === 'end' }" />
<div v-if="idx < steps.length - 1" class="oa-flow-line" />
</div>
<div class="oa-flow-card">
<div class="oa-flow-card__name-row">
<span class="oa-flow-card__name">{{ s.name || '-' }}</span>
<span v-if="signBadge(s)" class="oa-flow-badge" :class="signBadge(s)">
{{ signBadgeText(s) }}
</span>
</div>
<div v-if="s.meta" class="oa-flow-card__meta">{{ s.meta }}</div>
<div v-if="s.resolveError" class="oa-flow-card__err">{{ s.resolveError }}</div>
<template v-if="isInitiatorSelect(s)">
<div class="oa-flow-card__people">
<span
v-for="p in displayPeople(s)"
:key="p.id"
class="oa-person-chip"
>
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
{{ p.nickName || p.id }}
<button
type="button"
class="oa-person-chip__x"
@click="removeUser(s.id, p.id)"
>
×
</button>
</span>
<span v-if="!displayPeople(s).length" class="oa-flow-card__placeholder">
{{ s.type === 'cc' ? '请选择抄送人' : '请选择审批人' }}
</span>
</div>
<van-button size="small" plain type="primary" class="oa-flow-add" @click="openPicker(s)">
添加
</van-button>
</template>
<div v-else-if="(s.people || []).length" class="oa-flow-card__people">
<span v-for="p in s.people" :key="p.id" class="oa-person-chip">
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
{{ p.nickName || p.id }}
</span>
</div>
</div>
</div>
</div>
<van-popup v-model:show="pickerVisible" position="bottom" round :style="{ height: '70%' }">
<div class="oa-flow-picker">
<div class="oa-flow-picker__hd">
<span @click="pickerVisible = false">取消</span>
<strong>{{ pickerTitle }}</strong>
<span class="oa-flow-picker__ok" @click="confirmPicker">确定</span>
</div>
<van-search
v-model="pickerKeyword"
shape="round"
label-align="left"
input-align="left"
placeholder="搜索姓名或工号"
/>
<div class="oa-flow-picker__list">
<van-checkbox-group v-model="pickerIds">
<van-cell
v-for="u in filteredUsers"
:key="u.id"
clickable
:title="u.nickName || u.id"
:label="u.userNo || undefined"
@click="togglePicker(u.id)"
>
<template #icon>
<van-checkbox class="oa-flow-picker__check" :name="u.id" @click.stop />
</template>
</van-cell>
</van-checkbox-group>
<van-empty v-if="!filteredUsers.length" description="暂无人员" />
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
steps: { type: Array, default: () => [] },
resolved: { type: Boolean, default: true },
pendingMessage: { type: String, default: '' },
loading: { type: Boolean, default: false },
initiatorSelectByNode: { type: Object, default: () => ({}) },
userOptions: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:initiatorSelectByNode'])
const pickerVisible = ref(false)
const pickerNodeId = ref('')
const pickerTitle = ref('选择人员')
const pickerIds = ref([])
const pickerKeyword = ref('')
const filteredUsers = computed(() => {
const q = pickerKeyword.value.trim().toLowerCase()
const list = props.userOptions || []
if (!q) return list
return list.filter(
(u) =>
String(u.nickName || '')
.toLowerCase()
.includes(q) ||
String(u.userNo || '')
.toLowerCase()
.includes(q) ||
String(u.id || '')
.toLowerCase()
.includes(q)
)
})
function isInitiatorSelect(s) {
const types =
Array.isArray(s.assigneeTypes) && s.assigneeTypes.length
? s.assigneeTypes
: s.assigneeType
? [s.assigneeType]
: []
return types.includes('initiator_select')
}
function signBadge(s) {
const m = String(s.signMode || '').toLowerCase()
if (m === 'and' || m === 'all' || m === '会签') return 'and'
if (m === 'or' || m === 'any' || m === '或签') return 'or'
return ''
}
function signBadgeText(s) {
const b = signBadge(s)
if (b === 'and') return '会签'
if (b === 'or') return '或签'
return ''
}
function avatarChar(name) {
const n = String(name || '').trim()
return n ? n.slice(0, 1) : '?'
}
function displayPeople(s) {
const ids = props.initiatorSelectByNode?.[s.id] || []
const map = new Map((props.userOptions || []).map((u) => [u.id, u]))
return ids
.map((id) => map.get(id) || { id, nickName: id })
.filter(Boolean)
}
function setNodeIds(nodeId, ids) {
emit('update:initiatorSelectByNode', {
...(props.initiatorSelectByNode || {}),
[nodeId]: [...ids]
})
}
function removeUser(nodeId, userId) {
const cur = props.initiatorSelectByNode?.[nodeId] || []
setNodeIds(
nodeId,
cur.filter((id) => id !== userId)
)
}
function openPicker(s) {
pickerNodeId.value = s.id
pickerTitle.value = s.type === 'cc' ? '选择抄送人' : `选择${s.name || '审批人'}`
pickerIds.value = [...(props.initiatorSelectByNode?.[s.id] || [])]
pickerKeyword.value = ''
pickerVisible.value = true
}
function togglePicker(id) {
const set = new Set(pickerIds.value)
if (set.has(id)) set.delete(id)
else set.add(id)
pickerIds.value = [...set]
}
function confirmPicker() {
if (pickerNodeId.value) setNodeIds(pickerNodeId.value, pickerIds.value)
pickerVisible.value = false
}
</script>
<style scoped>
.oa-flow-preview {
margin-top: 16px;
padding: 14px 12px 8px;
background: #fff;
border-radius: 12px;
border: 1px solid var(--border, #dcebe4);
}
.oa-flow-preview__title {
margin-bottom: 12px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary, #1f2a26);
}
.oa-flow-preview__loading,
.oa-flow-preview__pending,
.oa-flow-preview__empty {
padding: 12px 4px 16px;
color: var(--text-muted, #6b7b74);
font-size: 13px;
line-height: 1.5;
}
.oa-flow-step {
display: flex;
gap: 10px;
min-height: 56px;
}
.oa-flow-rail {
display: flex;
flex-direction: column;
align-items: center;
width: 16px;
}
.oa-flow-dot {
width: 10px;
height: 10px;
margin-top: 6px;
border-radius: 50%;
background: var(--brand-green, #0a9f6d);
}
.oa-flow-dot.end {
background: #94a3b8;
}
.oa-flow-line {
flex: 1;
width: 2px;
margin: 4px 0;
background: #d1fae5;
}
.oa-flow-card {
flex: 1;
padding: 0 0 16px;
min-width: 0;
}
.oa-flow-card__name-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.oa-flow-card__name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1f2a26);
}
.oa-flow-badge {
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
}
.oa-flow-badge.and {
color: #087f57;
background: #e9f9f2;
}
.oa-flow-badge.or {
color: #b45309;
background: #fff7e8;
}
.oa-flow-card__meta,
.oa-flow-card__placeholder {
margin-top: 4px;
color: var(--text-muted, #6b7b74);
font-size: 12px;
}
.oa-flow-card__err {
margin-top: 4px;
color: #d14343;
font-size: 12px;
}
.oa-flow-card__people {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.oa-person-chip {
display: inline-flex;
gap: 4px;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: #f5f8f7;
font-size: 12px;
color: var(--text-primary, #1f2a26);
}
.oa-person-chip__dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--brand-green, #0a9f6d);
color: #fff;
font-size: 11px;
}
.oa-person-chip__x {
margin-left: 2px;
padding: 0;
border: 0;
background: transparent;
color: #94a3b8;
font-size: 14px;
line-height: 1;
}
.oa-flow-add {
margin-top: 8px;
}
.oa-flow-picker {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.oa-flow-picker__hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #dcebe4);
}
.oa-flow-picker__ok {
color: var(--brand-green, #0a9f6d);
font-weight: 600;
}
.oa-flow-picker__list {
flex: 1;
padding: 12px;
overflow: auto;
}
.oa-flow-picker__list .van-cell {
margin-bottom: 10px;
padding: 12px 14px;
border-radius: 10px;
}
.oa-flow-picker__check {
margin-right: 8px;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
import { oaFormOption } from './form-option.js'
function walkRules(rules, fn) {
const list = Array.isArray(rules) ? rules : []
for (const r of list) {
if (!r || typeof r !== 'object') continue
fn(r)
if (Array.isArray(r.children)) walkRules(r.children, fn)
}
}
/** 与 Web ensureOaUploadRuleProps 同思路的最小版 */
export function ensureOaUploadRuleProps(rules) {
const next = JSON.parse(JSON.stringify(rules || []))
walkRules(next, (r) => {
if (r.type === 'upload' || r.type === 'fcUpload') {
r.props = { ...(r.props || {}) }
if (r.props.limit == null) r.props.limit = 9
}
})
return next
}
export function prepareMobileFormSchema(formSchemaJson) {
const schema = formSchemaJson || {}
const rawRule = Array.isArray(schema.rule)
? schema.rule
: Array.isArray(schema)
? schema
: []
const rule = ensureOaUploadRuleProps(rawRule)
const option = {
...oaFormOption,
...(schema.option || {}),
form: {
...(schema.option?.form || {}),
labelAlign: 'top',
inputAlign: 'left',
colon: false
},
submitBtn: false,
resetBtn: false
}
return { rule, option }
}
+6 -7
View File
@@ -1,16 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import FormSink from './views/FormSink.vue'
import OaStart from './views/OaStart.vue'
export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/oa-form-sink' },
{ path: '/oa-form-sink', component: FormSink },
{
path: '/oa-start',
component: {
template: '<div style="padding:16px">oa-start 占位(阶段二)</div>'
}
}
path: '/',
redirect: (to) => ({ path: '/oa-form-sink', query: to.query, hash: to.hash })
},
{ path: '/oa-form-sink', component: FormSink },
{ path: '/oa-start', component: OaStart }
]
})
+346
View File
@@ -0,0 +1,346 @@
<template>
<div class="oa-start">
<header class="oa-start__hd">{{ appMeta.appName || '发起审批' }}</header>
<div v-if="detailLoading" class="oa-start__loading">
<van-loading vertical>加载模板…</van-loading>
</div>
<div v-else class="oa-start__body">
<form-create-mobile
v-model="formData"
v-model:api="fapi"
:rule="formRule"
:option="formOption"
/>
<OaFlowPreview
v-model:initiator-select-by-node="initiatorSelectByNode"
:steps="flowSteps"
:resolved="flowResolved"
:pending-message="flowPendingMessage"
:loading="previewLoading"
:user-options="userOptions"
/>
</div>
<footer class="oa-start__ft">
<van-button class="oa-start__btn" @click="onCancel">取消</van-button>
<van-button class="oa-start__btn" :loading="savingDraft" @click="saveDraft">
暂存草稿
</van-button>
<van-button
class="oa-start__btn"
type="primary"
:loading="submitting"
@click="submit"
>
提交
</van-button>
</footer>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { showToast } from 'vant'
import {
fetchOaTemplateDetail,
fetchOaFlowPreview,
fetchCreateOaInstanceDraft,
fetchUpdateOaInstanceDraft,
fetchOaInstanceDraftDetail,
fetchSubmitOaInstanceDraft,
fetchStartOaInstance,
fetchUserList
} from '../api/oa.js'
import { prepareMobileFormSchema } from '../oa/schema-prep.js'
import OaFlowPreview from '../oa/components/OaFlowPreview.vue'
const route = useRoute()
const detailLoading = ref(true)
const previewLoading = ref(false)
const savingDraft = ref(false)
const submitting = ref(false)
const appMeta = ref({ id: '', appName: '' })
const editingDraftId = ref('')
const formData = ref({})
const formRule = ref([])
const formOption = ref({})
const fapi = ref(null)
const flowSteps = ref([])
const flowResolved = ref(false)
const flowPendingMessage = ref('必填信息填写后,流程将自动显示')
const initiatorSelectByNode = ref({})
const userOptions = ref([])
let previewTimer = null
let skipPreviewWatch = false
function closeToMini(payload) {
try {
if (typeof wx !== 'undefined' && wx.miniProgram?.navigateBack) {
wx.miniProgram.postMessage({ data: payload || { action: 'oa-start-close' } })
wx.miniProgram.navigateBack()
return
}
} catch (_) {
/* ignore */
}
showToast((payload && payload.message) || '已完成(非小程序环境)')
}
async function loadUsers() {
try {
const pageSize = 100
const raw = []
let page = 1
for (;;) {
const res = await fetchUserList({ page, pageSize, status: '1' })
const list = res?.list || []
raw.push(...list)
if (list.length < pageSize) break
if (typeof res?.total === 'number' && raw.length >= res.total) break
page += 1
if (page > 50) break
}
userOptions.value = raw.map((u) => ({
id: String(u.id || ''),
nickName: u.nickName || '',
userNo: u.userNo || ''
})).filter((u) => u.id)
} catch {
userOptions.value = []
}
}
function stepsNeedInitiatorSelect(steps) {
return (steps || []).some((s) => {
const types =
Array.isArray(s.assigneeTypes) && s.assigneeTypes.length
? s.assigneeTypes
: s.assigneeType
? [s.assigneeType]
: []
return types.includes('initiator_select')
})
}
async function loadFlowPreview() {
if (!appMeta.value?.id) return
previewLoading.value = true
try {
const preview = await fetchOaFlowPreview(appMeta.value.id, {
formData: { ...formData.value },
initiatorSelectByNode: { ...initiatorSelectByNode.value }
})
flowResolved.value = preview?.resolved !== false
flowPendingMessage.value =
preview?.message || '必填信息填写后,流程将自动显示'
flowSteps.value = flowResolved.value ? preview?.steps || [] : []
if (stepsNeedInitiatorSelect(flowSteps.value) && !userOptions.value.length) {
await loadUsers()
}
} catch (e) {
showToast(e?.message || '审批流预览失败')
flowSteps.value = []
flowResolved.value = true
} finally {
previewLoading.value = false
}
}
function scheduleFlowPreview() {
if (!appMeta.value?.id || detailLoading.value) return
if (previewTimer) clearTimeout(previewTimer)
previewTimer = setTimeout(() => {
loadFlowPreview()
}, 300)
}
async function loadByAppId(appId) {
const detail = await fetchOaTemplateDetail(appId)
appMeta.value = {
id: appId,
appName: detail?.appName || detail?.name || '发起审批'
}
const prepared = prepareMobileFormSchema(detail?.formSchemaJson)
formRule.value = prepared.rule
formOption.value = prepared.option
await loadFlowPreview()
}
async function bootstrap() {
detailLoading.value = true
skipPreviewWatch = true
try {
const appId = String(route.query.appId || '').trim()
const draftId = String(route.query.draftId || '').trim()
if (draftId) {
const d = await fetchOaInstanceDraftDetail(draftId)
editingDraftId.value = draftId
const aid = String(d?.appId || '').trim()
if (!aid) throw new Error('草稿缺少 appId')
formData.value = d.formData && typeof d.formData === 'object' ? { ...d.formData } : {}
initiatorSelectByNode.value =
d.initiatorSelectByNode && typeof d.initiatorSelectByNode === 'object'
? { ...d.initiatorSelectByNode }
: {}
await loadByAppId(aid)
} else if (appId) {
formData.value = {}
initiatorSelectByNode.value = {}
editingDraftId.value = ''
await loadByAppId(appId)
} else {
showToast('缺少 appId')
}
} catch (e) {
showToast(e?.message || '加载失败')
} finally {
detailLoading.value = false
skipPreviewWatch = false
}
}
function onCancel() {
closeToMini({ action: 'oa-start-cancel' })
}
async function saveDraft() {
if (!appMeta.value?.id) return
savingDraft.value = true
try {
const payload = {
formData: { ...formData.value },
initiatorSelectByNode: { ...initiatorSelectByNode.value },
title: appMeta.value.appName || ''
}
if (editingDraftId.value) {
await fetchUpdateOaInstanceDraft(editingDraftId.value, payload)
} else {
const created = await fetchCreateOaInstanceDraft({
appId: appMeta.value.id,
...payload
})
if (created?.id) editingDraftId.value = created.id
}
showToast('草稿已保存')
closeToMini({ action: 'oa-start-draft', draftId: editingDraftId.value })
} catch (e) {
showToast(e?.message || '保存草稿失败')
} finally {
savingDraft.value = false
}
}
async function submit() {
if (!appMeta.value?.id) return
for (const s of flowSteps.value) {
if (s.type !== 'approval') continue
const types =
Array.isArray(s.assigneeTypes) && s.assigneeTypes.length
? s.assigneeTypes
: s.assigneeType
? [s.assigneeType]
: []
if (!types.includes('initiator_select')) continue
const ids = initiatorSelectByNode.value[s.id] || []
if (!ids.length) {
showToast(`请为「${s.name || '审批人'}」选择审批人`)
return
}
}
if (fapi.value?.validate) {
try {
await fapi.value.validate()
} catch {
showToast('请完善表单必填项')
return
}
}
submitting.value = true
try {
const payload = {
formData: { ...formData.value },
initiatorSelectByNode: { ...initiatorSelectByNode.value }
}
let data
if (editingDraftId.value) {
data = await fetchSubmitOaInstanceDraft(editingDraftId.value, {
...payload,
title: appMeta.value.appName || ''
})
} else {
data = await fetchStartOaInstance({
appId: appMeta.value.id,
...payload
})
}
showToast('提交成功')
closeToMini({
action: 'oa-start-submitted',
instanceId: data?.id,
message: '提交成功'
})
} catch (e) {
showToast(e?.message || '提交失败')
} finally {
submitting.value = false
}
}
watch(
[formData, initiatorSelectByNode],
() => {
if (skipPreviewWatch) return
scheduleFlowPreview()
},
{ deep: true }
)
onMounted(() => {
bootstrap()
})
onUnmounted(() => {
if (previewTimer) clearTimeout(previewTimer)
})
</script>
<style scoped>
.oa-start {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-page, #f5f8f7);
}
.oa-start__hd {
padding: 14px 16px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, var(--brand-green, #0a9f6d), var(--brand-green-dark, #087f57));
}
.oa-start__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
}
.oa-start__body {
flex: 1;
padding: 12px 12px 96px;
}
.oa-start__ft {
position: fixed;
right: 0;
bottom: 0;
left: 0;
display: flex;
gap: 8px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid var(--border, #dcebe4);
}
.oa-start__btn {
flex: 1;
}
</style>