feat(oa): add return, resubmit, and multi-round approval progress

Align with DingTalk semantics: reject ends the instance, return keeps it for initiator edits, and revoke-and-resubmit creates a linked new instance. Progress timeline projects rounds via oa_instance_event.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-15 14:53:52 +08:00
1 parent 265d790ea6
commit 26800865cd
13 files changed
+4736 -72

No files matched your search

+20
View File
@@ -210,6 +210,7 @@ export function fetchStartOaInstance(data: {
initiatorDeptId?: string
initiatorSelectUserIds?: string[]
relatedInstanceIds?: string[]
sourceInstanceId?: string
}) {
return request.post({
url: `${BASE}/instances`,
@@ -260,6 +261,25 @@ export function fetchRejectOaTask(taskId: string, comment?: string) {
})
}
export function fetchReturnOaTask(taskId: string, comment: string) {
return request.post({
url: `${BASE}/tasks/${taskId}/return`,
data: { comment },
showSuccessMessage: true
})
}
export function fetchResubmitOaInstance(
id: string,
data: { formData?: Record<string, any>; initiatorSelectUserIds?: string[] }
) {
return request.post({
url: `${BASE}/instances/${id}/resubmit`,
data,
showSuccessMessage: true
})
}
export function fetchAddSignOaTask(
taskId: string,
data: { userIds: string[]; comment?: string }
@@ -0,0 +1,129 @@
<!-- OA 办理中心列表(待处理 / 已处理 / 我发起 / 抄送) -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">{{ title }}</h2>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="rows" v-loading="loading" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
v-if="kind === 'mine'"
prop="title"
label="标题"
min-width="200"
align="left"
header-align="left"
/>
<el-table-column
v-if="kind !== 'mine'"
prop="nodeName"
label="节点"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusType(row.status)">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="时间" min-width="170" align="center" />
<el-table-column label="操作" min-width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button
v-if="kind === 'mine' && row.status === 'cancelled' && row.appId"
class="btn-op-item"
@click="openResubmitFromCancelled(row)"
>
<el-icon><RefreshRight /></el-icon>
修改并重新提交
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { RefreshRight, View } from '@element-plus/icons-vue'
import { fetchOaTasks } from '@/api/oaApproval'
const props = defineProps<{
title: string
kind: 'pending' | 'done' | 'mine' | 'cc'
}>()
const router = useRouter()
const loading = ref(false)
const rows = ref<any[]>([])
function statusType(s: string) {
if (s === 'pending' || s === 'running' || s === 'returned') return 'warning'
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
if (s === 'rejected' || s === 'cancelled') return 'danger'
return 'info'
}
function statusText(s: string) {
if (!s) return '-'
const map: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
returned: '待修改'
}
return map[s] || s
}
function openDetail(row: any) {
const id = props.kind === 'mine' ? row.id : row.instanceId
if (!id) return
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
function openResubmitFromCancelled(row: any) {
if (!row?.appId || !row?.id) return
router.push({
path: '/system-manage/oa-approval/start',
query: { appId: row.appId, sourceInstanceId: row.id }
})
}
async function load() {
loading.value = true
try {
const res = await fetchOaTasks(props.kind)
rows.value = res?.list || []
} catch {
rows.value = []
} finally {
loading.value = false
}
}
onMounted(load)
// 菜单 keepAlive:切回本页时重新拉取,避免审批后列表仍是旧数据
onActivated(load)
</script>
@@ -0,0 +1,430 @@
<!-- OA 四步设计器壳(D10 完善基础/高级;E11/E12 填表单与流程) -->
<template>
<div class="page-container oa-designer">
<section class="panel oa-designer__panel">
<div class="oa-designer__toolbar">
<el-button class="btn-ghost" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="oa-designer__actions">
<el-button class="btn-ghost" :loading="saving" @click="saveDraft">
<el-icon><Document /></el-icon>
保存草稿
</el-button>
<el-button class="btn-main" type="primary" :loading="publishing" @click="publish">
<el-icon><Upload /></el-icon>
发布
</el-button>
</div>
</div>
<el-tabs v-model="activeStep" class="oa-designer__tabs" @tab-change="onStepChange">
<el-tab-pane label="1 基础设置" name="basic" />
<el-tab-pane label="2 表单设计" name="form" />
<el-tab-pane label="3 流程设计" name="flow" />
<el-tab-pane label="4 高级设置" name="advanced" />
</el-tabs>
<div v-loading="loading" class="oa-designer__body">
<div v-show="activeStep === 'basic'" class="oa-designer__step">
<el-form label-position="top" class="oa-designer__form">
<div class="form-grid-2">
<el-form-item label="应用图标" class="form-col-span-2">
<div class="oa-designer__icon-row">
<span class="oa-designer__icon-preview">
<img :src="resolveOaAppIconUrl(basic.iconUrl)" alt="" />
</span>
<el-upload
:show-file-list="false"
accept="image/*"
:http-request="uploadBasicIcon"
:disabled="iconUploading"
>
<el-button class="btn-ghost" :loading="iconUploading">
<el-icon><Upload /></el-icon>
上传
</el-button>
</el-upload>
<el-button class="btn-ghost" @click="basic.iconUrl = pickRandomOaAppIconUrl()">
换默认
</el-button>
</div>
</el-form-item>
<el-form-item label="应用名称">
<el-input v-model="basic.appName" maxlength="50" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="basic.status">
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item label="表单说明" class="form-col-span-2">
<el-input v-model="basic.description" type="textarea" :rows="3" maxlength="100" />
</el-form-item>
</div>
</el-form>
</div>
<div v-show="activeStep === 'form'" class="oa-designer__form-step">
<OaFormDesignerEmbed
v-if="formDesignerMounted"
ref="formEmbedRef"
v-model="formSchema"
/>
</div>
<div v-show="activeStep === 'flow'" class="oa-designer__step oa-designer__flow-step">
<FlowDesigner v-model="flowTreeJson" />
</div>
<div v-show="activeStep === 'advanced'" class="oa-designer__step">
<el-form label-position="top" class="oa-designer__form oa-designer__form--narrow">
<el-form-item label="主管为空时回退部门负责人">
<el-switch v-model="advanced.supervisorFallbackToDeptLeader" />
</el-form-item>
<el-form-item label="允许撤销进行中的审批">
<el-switch v-model="advanced.allowWithdrawRunning" />
</el-form-item>
<el-form-item label="审批意见必填">
<el-switch v-model="advanced.commentRequired" />
</el-form-item>
<el-form-item label="允许加签">
<el-switch v-model="advanced.allowAddSign" />
</el-form-item>
<el-form-item label="允许审批人打回(退回发起人修改)">
<el-switch v-model="advanced.allowReturn" />
</el-form-item>
<el-form-item label="审批人去重">
<el-switch v-model="advanced.approverDedup" />
</el-form-item>
</el-form>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import { ArrowLeft, Document, Upload } from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { fetchOaAppDraft, fetchPublishOaApp, fetchSaveOaAppDraft } from '@/api/oaApproval'
import FlowDesigner from '@/components/oa-approval/flow-tree/FlowDesigner.vue'
import { createDefaultFlow, ensureFlowRoot } from '@/components/oa-approval/flow-tree/types'
import OaFormDesignerEmbed from '@/components/oa-approval/OaFormDesignerEmbed.vue'
import { pickRandomOaAppIconUrl, resolveOaAppIconUrl } from '@/utils/oa-app-icons'
defineOptions({ name: 'OaApprovalDesigner' })
const route = useRoute()
const router = useRouter()
const appId = String(route.params.id || '')
const loading = ref(false)
const saving = ref(false)
const publishing = ref(false)
const activeStep = ref('basic')
const draft = ref<any>(null)
/** iframe 表单设计器(与主应用 Tailwind 隔离) */
const formEmbedRef = ref<{ fetchSchema: () => Promise<{ rule: unknown; option: unknown }> } | null>(
null
)
const formDesignerMounted = ref(false)
const formSchema = ref<{ rule: unknown; option: unknown }>({ rule: [], option: {} })
const basic = reactive({
appName: '',
description: '',
status: '1',
groupId: '',
iconUrl: ''
})
const iconUploading = ref(false)
const advanced = reactive({
supervisorFallbackToDeptLeader: true,
allowWithdrawRunning: true,
commentRequired: false,
allowAddSign: true,
allowReturn: true,
approverDedup: false
})
const flowTreeJson = ref(createDefaultFlow())
async function collectFormSchema() {
if (formEmbedRef.value?.fetchSchema) {
try {
const schema = await formEmbedRef.value.fetchSchema()
formSchema.value = schema
return schema
} catch {
/* 超时则回退本地缓存 */
}
}
return {
rule: formSchema.value?.rule ?? draft.value?.formSchemaJson?.rule ?? [],
option: formSchema.value?.option ?? draft.value?.formSchemaJson?.option ?? {}
}
}
async function uploadBasicIcon(options: UploadRequestOptions) {
const file = options.file as File
if (!file?.type?.startsWith('image/')) {
ElMessage.warning('请上传图片文件')
return
}
iconUploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
const url = res?.fileUrl || res?.data?.fileUrl
if (!url) {
ElMessage.error('上传失败,未返回地址')
return
}
basic.iconUrl = url
options.onSuccess?.(res)
} catch (e) {
options.onError?.(e as any)
} finally {
iconUploading.value = false
}
}
async function loadDraft() {
if (!appId) return
loading.value = true
try {
const data = await fetchOaAppDraft(appId)
draft.value = data
const b = data?.basic || {}
basic.appName = b.appName || ''
basic.description = b.description || ''
basic.status = b.status || '1'
basic.groupId = b.groupId || ''
basic.iconUrl = b.iconUrl || ''
const a = data?.advancedSettingsJson || {}
advanced.supervisorFallbackToDeptLeader = a.supervisorFallbackToDeptLeader !== false
advanced.allowWithdrawRunning = a.allowWithdrawRunning !== false
advanced.commentRequired = !!a.commentRequired
advanced.allowAddSign = a.allowAddSign !== false
advanced.allowReturn = a.allowReturn !== false
advanced.approverDedup = !!a.approverDedup
flowTreeJson.value = ensureFlowRoot(data?.flowTreeJson)
const fs = data?.formSchemaJson
// 存纯 JSON,便于 iframe postMessage 克隆与 FormCreate setRule
try {
const plain = JSON.parse(JSON.stringify(fs ?? {}))
formSchema.value = {
rule: Array.isArray(plain?.rule) ? plain.rule : Array.isArray(plain) ? plain : [],
option: plain?.option && typeof plain.option === 'object' ? plain.option : {}
}
} catch {
formSchema.value = { rule: [], option: {} }
}
} finally {
loading.value = false
}
}
async function saveDraft() {
saving.value = true
try {
const formSchemaJson = await collectFormSchema()
const data = await fetchSaveOaAppDraft(appId, {
basic: {
appName: basic.appName.trim(),
description: basic.description,
status: basic.status,
groupId: basic.groupId || undefined,
iconUrl: basic.iconUrl || undefined
},
formSchemaJson,
flowTreeJson: flowTreeJson.value,
advancedSettingsJson: { ...advanced }
})
draft.value = data
} finally {
saving.value = false
}
}
async function publish() {
await saveDraft()
await ElMessageBox.confirm('确定发布当前草稿吗?', '发布确认', { type: 'warning' })
publishing.value = true
try {
await fetchPublishOaApp(appId)
// 勿再 loadDraft:GET /draft 会无草稿时从已发布版克隆出新草稿,导致列表仍显示「草稿」
goBack()
} catch (e: any) {
const errors = e?.data?.errors || e?.response?.data?.data?.errors
if (Array.isArray(errors) && errors.length) {
await ElMessageBox.alert(errors.map((x: string) => `· ${x}`).join('\n'), '发布校验未通过', {
type: 'error'
})
}
} finally {
publishing.value = false
}
}
function goBack() {
router.push({ path: '/system-manage/oa-approval/apps' })
}
async function onStepChange(name: string | number) {
if (formDesignerMounted.value) {
try {
const schema = await collectFormSchema()
if (draft.value) draft.value.formSchemaJson = schema
} catch {
/* ignore */
}
}
if (name === 'form') {
formDesignerMounted.value = true
}
}
onMounted(async () => {
await loadDraft()
if (activeStep.value === 'form') {
formDesignerMounted.value = true
}
})
</script>
<style scoped lang="scss">
.oa-designer__panel {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
.oa-designer__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.oa-designer__actions {
display: flex;
gap: 8px;
}
/* Tab 导航居中 */
.oa-designer__tabs {
:deep(.el-tabs__header) {
margin: 0 0 16px;
}
:deep(.el-tabs__nav-wrap) {
display: flex;
justify-content: center;
}
:deep(.el-tabs__nav-scroll) {
overflow: visible;
}
:deep(.el-tabs__nav) {
float: none;
display: inline-flex;
justify-content: center;
margin: 0 auto;
transform: none !important;
}
:deep(.el-tabs__item) {
padding: 0 22px;
font-size: 15px;
font-weight: 600;
}
}
.oa-designer__body {
width: 100%;
min-height: 360px;
padding: 8px 0 16px;
}
.oa-designer {
width: 100%;
}
:deep(.oa-designer__tabs.el-tabs),
:deep(.el-tabs__content) {
width: 100%;
}
/* 各步内容水平居中 */
.oa-designer__step {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.oa-designer__form {
width: 100%;
max-width: 720px;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.oa-designer__form--narrow {
max-width: 480px;
}
.oa-designer__icon-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.oa-designer__icon-preview {
display: block;
width: 48px;
height: 48px;
overflow: hidden;
border-radius: 10px;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.oa-designer__form-step {
width: 100%;
}
.oa-designer__flow-step {
align-items: stretch;
width: 100%;
min-height: 0;
:deep(.oa-flow-designer) {
width: 100%;
max-width: none;
margin: 0;
}
}
</style>
@@ -28,6 +28,15 @@
<el-icon><Close /></el-icon>
拒绝
</el-button>
<el-button
v-if="canApprove && detail?.allowReturn !== false"
class="btn-ghost"
:loading="acting"
@click="doReturn"
>
<el-icon><RefreshLeft /></el-icon>
打回
</el-button>
<el-button
v-if="canApprove && detail?.allowAddSign !== false"
class="btn-ghost"
@@ -37,6 +46,16 @@
<el-icon><Plus /></el-icon>
加签
</el-button>
<el-button
v-if="detail?.status === 'returned' && isInitiator"
class="btn-main"
type="primary"
:loading="acting"
@click="openResubmit"
>
<el-icon><Check /></el-icon>
修改并再次提交
</el-button>
<el-button
v-if="detail?.status === 'running' && isInitiator"
class="btn-ghost"
@@ -50,6 +69,16 @@
</div>
<div v-loading="loading" class="oa-detail__body">
<p v-if="detail?.sourceInstanceId" class="oa-detail__link-tip">
本单由已撤销审批重提,
<el-button link type="primary" @click="goInstance(detail.sourceInstanceId)">查看原单</el-button>
</p>
<p v-if="detail?.resubmittedInstanceId" class="oa-detail__link-tip">
已有重提单,
<el-button link type="primary" @click="goInstance(detail.resubmittedInstanceId)">
查看重提单
</el-button>
</p>
<section class="oa-detail__section">
<h3>表单内容</h3>
<div class="oa-detail__form-wrap">
@@ -67,13 +96,18 @@
<h3>审批进度</h3>
<div class="oa-detail__timeline-wrap">
<el-timeline v-if="timelineItems.length">
<el-timeline-item
v-for="item in timelineItems"
:key="item.key"
:type="timelineType(item.status)"
:timestamp="item.timestamp"
placement="top"
>
<template v-for="(item, idx) in timelineItems" :key="item.key">
<div
v-if="idx === 0 || item.roundNo !== timelineItems[idx - 1].roundNo"
class="oa-tl__round"
>
第 {{ item.roundNo }} 轮
</div>
<el-timeline-item
:type="timelineType(item.status)"
:timestamp="item.timestamp"
placement="top"
>
<div class="oa-tl">
<div class="oa-tl__title">
{{ item.title }}
@@ -81,6 +115,7 @@
{{ statusText(item.status) }}
</el-tag>
<el-tag v-if="item.taskType === 'cc'" size="small" type="info">抄送</el-tag>
<el-tag v-if="item.kind === 'return'" size="small" type="warning">打回</el-tag>
</div>
<div
v-if="item.assignee && item.kind !== 'pending_node' && item.kind !== 'terminal'"
@@ -93,7 +128,8 @@
</div>
<div v-if="item.commentText" class="oa-tl__comment">意见:{{ item.commentText }}</div>
</div>
</el-timeline-item>
</el-timeline-item>
</template>
</el-timeline>
<p v-else class="oa-detail__empty">暂无任务记录</p>
</div>
@@ -161,6 +197,8 @@
fetchApproveOaTask,
fetchOaInstanceDetail,
fetchRejectOaTask,
fetchResubmitOaInstance,
fetchReturnOaTask,
fetchWithdrawOaInstance
} from '@/api/oaApproval'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
@@ -227,7 +265,7 @@
const canApprove = computed(() => !!myPendingTask.value)
function statusType(s: string) {
if (s === 'pending' || s === 'running') return 'warning'
if (s === 'pending' || s === 'running' || s === 'returned') return 'warning'
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'success'
if (s === 'rejected' || s === 'cancelled') return 'danger'
if (s === 'not_reached') return 'info'
@@ -244,15 +282,18 @@
cancelled: '已撤销',
done: '已结束',
submitted: '已提交',
not_reached: '未到达'
not_reached: '未到达',
returned: '待修改'
}
// 任务被打回后取消:展示「已取消」
if (s === 'cancelled') return '已撤销'
return map[s] || s
}
function timelineType(s: string) {
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'success'
if (s === 'rejected' || s === 'cancelled') return 'danger'
if (s === 'pending') return 'warning'
if (s === 'pending' || s === 'returned') return 'warning'
if (s === 'not_reached') return 'info'
return 'primary'
}
@@ -330,6 +371,52 @@
}
}
async function doReturn() {
const task = myPendingTask.value
if (!task) return
let comment = ''
try {
const res = await ElMessageBox.prompt('请填写打回意见(必填)', '打回', {
inputPlaceholder: '请发起人修改后再次提交',
confirmButtonText: '确定打回',
cancelButtonText: '取消',
inputValidator: (v) => !!(v && String(v).trim()) || '打回意见必填'
})
comment = String(res.value || '').trim()
} catch {
return
}
acting.value = true
try {
await fetchReturnOaTask(task.id, comment)
await load()
} finally {
acting.value = false
}
}
async function openResubmit() {
await ElMessageBox.confirm(
'将使用当前表单内容再次提交并重新走审批。若需改字段,请先在下方确认表单后确定。',
'再次提交',
{ type: 'warning' }
)
acting.value = true
try {
await fetchResubmitOaInstance(String(route.params.id || ''), {
formData: { ...(viewFormData.value || detail.value?.formDataJson || {}) }
})
await load()
} finally {
acting.value = false
}
}
function goInstance(id: string) {
if (!id) return
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
async function loadUsers() {
userLoading.value = true
try {
@@ -447,6 +534,20 @@
font-size: 13px;
}
.oa-detail__link-tip {
max-width: 960px;
margin: 0 auto 8px;
color: var(--mint-text-muted, #64748b);
font-size: 13px;
}
.oa-tl__round {
margin: 8px 0 4px 4px;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
font-weight: 600;
}
.oa-tl__title {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,646 @@
<!-- 发起审批 · 列表样式对齐审批管理 -->
<template>
<div class="page-container">
<section class="panel oa-apps">
<div class="oa-apps__search-wrap">
<el-input
v-model="searchKeyword"
class="oa-apps__search"
clearable
placeholder="请输入审批单名称"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="oa-apps__tabs" role="tablist">
<button
type="button"
class="oa-apps__tab"
:class="{ active: activeGroupId === '' }"
role="tab"
@click="activeGroupId = ''"
>
全部
</button>
<button
v-for="g in groupOptions"
:key="g.id"
type="button"
class="oa-apps__tab"
:class="{ active: activeGroupId === g.id }"
role="tab"
@click="activeGroupId = g.id"
>
{{ g.groupName }}
</button>
</div>
<div class="oa-apps__toolbar">
<div class="oa-apps__toolbar-hint">选择一个审批单即可发起</div>
</div>
<div v-loading="loading" class="oa-apps__body">
<div v-if="!displayGroups.length && !loading" class="oa-apps__empty">
暂无可发起的审批模板,请先在「审批管理」中发布应用。
</div>
<section
v-for="sec in displayGroups"
:key="sec.id || '_ungrouped'"
class="oa-apps__section"
>
<div class="oa-apps__section-head">
<h3>{{ sec.name }}</h3>
<span class="oa-apps__dot" aria-hidden="true" />
<span class="oa-apps__section-meta">{{ sec.apps.length }} 个表单</span>
</div>
<div class="oa-apps__card-row">
<div
v-for="row in sec.apps"
:key="row.id"
class="oa-app-card"
role="button"
tabindex="0"
@click="onPick(row)"
@keydown.enter="onPick(row)"
>
<div class="oa-app-card__main">
<span class="oa-app-card__icon">
<img :src="resolveOaAppIconUrl(row.iconUrl)" alt="" />
</span>
<span class="oa-app-card__body">
<span class="oa-app-card__name">{{ row.appName }}</span>
<span class="oa-app-card__desc">{{ row.description || '点击发起审批' }}</span>
</span>
</div>
<div class="oa-app-card__ops">
<el-button class="oa-app-card__btn primary" @click.stop="onPick(row)">
<el-icon><Promotion /></el-icon>
发起
</el-button>
</div>
</div>
</div>
</section>
</div>
<el-dialog
v-model="dlgVisible"
:title="picked?.appName || '发起审批'"
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<div v-loading="detailLoading">
<el-form v-if="needInitiatorSelect" label-position="top" class="oa-start__meta">
<el-form-item label="自选审批人" required>
<el-select
v-model="initiatorSelectUserIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择审批人"
style="width: 100%"
:loading="userLoading"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="`${u.nickName}(${u.userNo})`"
:value="u.id"
/>
</el-select>
</el-form-item>
</el-form>
<form-create
v-if="formRule.length"
v-model="formData"
v-model:api="fapi"
:rule="formRule"
:option="formOption"
/>
<p v-else-if="!detailLoading" class="oa-start__tip">该模板暂无表单字段,仍可直接提交。</p>
<OaFlowPreview
v-if="!detailLoading"
:steps="flowSteps"
:loading="previewLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dlgVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="submit">
<el-icon><Check /></el-icon>
提交
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Check, Close, Promotion, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import {
fetchOaFlowPreview,
fetchOaGroupList,
fetchOaInstanceDetail,
fetchOaTemplateDetail,
fetchOaTemplates,
fetchStartOaInstance,
type OaFlowPreviewStep
} from '@/api/oaApproval'
import OaFlowPreview from '@/components/oa-approval/OaFlowPreview.vue'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { resolveOaAppIconUrl } from '@/utils/oa-app-icons'
defineOptions({ name: 'OaApprovalStart' })
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const templates = ref<any[]>([])
const groupOptions = ref<Array<{ id: string; groupName: string }>>([])
const searchKeyword = ref('')
const activeGroupId = ref('')
const dlgVisible = ref(false)
const detailLoading = ref(false)
const picked = ref<any>(null)
const formData = ref<Record<string, any>>({})
const formRule = ref<any[]>([])
const formOption = ref<Record<string, any>>({ submitBtn: false, resetBtn: false })
const fapi = ref<any>(null)
const submitting = ref(false)
const sourceInstanceId = ref('')
const needInitiatorSelect = ref(false)
const initiatorSelectUserIds = ref<string[]>([])
const userLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
const flowSteps = ref<OaFlowPreviewStep[]>([])
const previewLoading = ref(false)
const displayGroups = computed(() => {
const kw = (searchKeyword.value || '').trim().toLowerCase()
let list = [...(templates.value || [])]
if (activeGroupId.value) {
list = list.filter((a) => a.groupId === activeGroupId.value)
}
if (kw) {
list = list.filter((a) => String(a.appName || '').toLowerCase().includes(kw))
}
const bucket = new Map<string, { id: string; name: string; apps: any[] }>()
for (const g of groupOptions.value) {
bucket.set(g.id, { id: g.id, name: g.groupName, apps: [] })
}
const ungrouped = { id: '_ungrouped', name: '未分组', apps: [] as any[] }
for (const app of list) {
if (app.groupId && bucket.has(app.groupId)) {
bucket.get(app.groupId)!.apps.push(app)
} else {
ungrouped.apps.push(app)
}
}
if (activeGroupId.value) {
const one = bucket.get(activeGroupId.value)
return one && one.apps.length ? [one] : []
}
const sections: { id: string; name: string; apps: any[] }[] = []
for (const g of groupOptions.value) {
const sec = bucket.get(g.id)!
if (sec.apps.length) sections.push(sec)
}
if (ungrouped.apps.length) sections.push(ungrouped)
return sections
})
function flowNeedsInitiatorSelect(node: any): boolean {
if (!node || typeof node !== 'object') return false
const cfg = node.approverConfig || {}
if ((cfg.assigneeType || '') === 'initiator_select') return true
if (node.child && flowNeedsInitiatorSelect(node.child)) return true
for (const g of node.conditionGroups || []) {
if (g?.child && flowNeedsInitiatorSelect(g.child)) return true
}
return false
}
async function loadUsers() {
userLoading.value = true
try {
const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
const raw: any[] = []
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
}
userOptions.value = raw.map((u) => ({
id: u.id,
nickName: u.nickName || '',
userNo: u.userNo || ''
}))
} finally {
userLoading.value = false
}
}
async function loadFlowPreview(appId: string) {
previewLoading.value = true
try {
const preview = await fetchOaFlowPreview(
appId,
needInitiatorSelect.value ? [...initiatorSelectUserIds.value] : undefined
)
flowSteps.value = preview?.steps || []
} catch {
flowSteps.value = []
} finally {
previewLoading.value = false
}
}
async function onPick(t: any) {
picked.value = t
formData.value = {}
formRule.value = []
needInitiatorSelect.value = false
initiatorSelectUserIds.value = []
flowSteps.value = []
dlgVisible.value = true
detailLoading.value = true
previewLoading.value = true
try {
const detail = await fetchOaTemplateDetail(t.id)
const schema = detail?.formSchemaJson || {}
formRule.value = Array.isArray(schema.rule) ? schema.rule : Array.isArray(schema) ? schema : []
formOption.value = {
...(schema.option || {}),
form: {
...(schema.option?.form || {}),
labelPosition: 'top'
},
submitBtn: false,
resetBtn: false
}
const flow = detail?.flowTreeJson || {}
const root = flow.root || flow
needInitiatorSelect.value = flowNeedsInitiatorSelect(root)
if (needInitiatorSelect.value && !userOptions.value.length) {
await loadUsers()
}
await loadFlowPreview(t.id)
} catch {
formRule.value = []
flowSteps.value = []
previewLoading.value = false
} finally {
detailLoading.value = false
}
}
watch(
initiatorSelectUserIds,
() => {
if (dlgVisible.value && picked.value?.id && needInitiatorSelect.value) {
loadFlowPreview(picked.value.id)
}
},
{ deep: true }
)
async function submit() {
if (!picked.value?.id) return
if (needInitiatorSelect.value && !initiatorSelectUserIds.value.length) {
ElMessage.warning('请选择自选审批人')
return
}
if (fapi.value?.validate) {
try {
await fapi.value.validate()
} catch {
ElMessage.warning('请完善表单必填项')
return
}
}
submitting.value = true
try {
const data: any = await fetchStartOaInstance({
appId: picked.value.id,
formData: { ...formData.value },
initiatorSelectUserIds: needInitiatorSelect.value
? [...initiatorSelectUserIds.value]
: undefined,
sourceInstanceId: sourceInstanceId.value || undefined
})
dlgVisible.value = false
sourceInstanceId.value = ''
if (data?.id) {
router.push({ path: `/system-manage/oa-approval/instances/${data.id}` })
}
} finally {
submitting.value = false
}
}
onMounted(async () => {
loading.value = true
try {
const [tpl, groupsRes] = await Promise.all([fetchOaTemplates(), fetchOaGroupList()])
templates.value = tpl?.list || []
groupOptions.value = groupsRes?.list || []
const qAppId = String(route.query.appId || '').trim()
const qSource = String(route.query.sourceInstanceId || '').trim()
if (qAppId) {
const row = templates.value.find((t: any) => t.id === qAppId)
if (row) {
sourceInstanceId.value = qSource
await onPick(row)
if (qSource) {
try {
const old = await fetchOaInstanceDetail(qSource)
formData.value = { ...(old?.formDataJson || {}) }
} catch {
/* 预填失败仍可手工填 */
}
}
}
}
} finally {
loading.value = false
}
})
</script>
<style scoped lang="scss">
.oa-apps {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
.oa-apps__search-wrap {
display: flex;
justify-content: center;
margin: 8px 0 20px;
}
.oa-apps__search {
width: min(520px, 100%);
:deep(.el-input__wrapper) {
height: 42px;
padding-left: 16px;
background: #fff;
border-radius: 999px;
box-shadow:
0 0 0 1px var(--mint-border, #cbd5e1) inset,
0 1px 2px rgb(15 23 42 / 6%);
}
:deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px var(--primary, #10b981) inset,
0 0 0 3px var(--primary-bg, #ecfdf5);
}
}
.oa-apps__tabs {
display: flex;
flex-wrap: wrap;
gap: 28px;
margin-bottom: 4px;
}
.oa-apps__tab {
position: relative;
padding: 0 0 12px;
color: var(--mint-text, #334155);
font-size: 16px;
font-weight: 600;
cursor: pointer;
background: none;
border: 0;
&:hover {
color: var(--primary-dark, #059669);
}
&.active {
color: var(--mint-text-strong, #0f172a);
&::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
content: '';
background: var(--mint-text-strong, #0f172a);
border-radius: 2px;
}
}
}
.oa-apps__toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin: 8px 0 20px;
}
.oa-apps__toolbar-hint {
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
}
.oa-apps__body {
min-height: 160px;
}
.oa-apps__empty {
padding: 48px 16px;
color: var(--mint-text-muted, #94a3b8);
text-align: center;
}
.oa-apps__section {
margin-bottom: 28px;
}
.oa-apps__section-head {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 14px;
h3 {
margin: 0;
color: var(--mint-text, #334155);
font-size: 14px;
font-weight: 600;
}
}
.oa-apps__dot {
width: 6px;
height: 6px;
background: #f87171;
border-radius: 50%;
}
.oa-apps__section-meta {
margin-left: auto;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
.oa-apps__card-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: stretch;
}
.oa-app-card {
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
min-height: 100px;
padding: 14px 16px 10px;
cursor: pointer;
background: #fff;
border: 1px solid var(--mint-border, #cbd5e1);
border-radius: 14px;
box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
transition:
border-color 0.15s,
box-shadow 0.15s,
transform 0.15s;
&:hover {
border-color: #86efac;
box-shadow: 0 4px 14px rgb(16 185 129 / 16%);
transform: translateY(-1px);
}
}
.oa-app-card__main {
display: flex;
gap: 14px;
align-items: center;
min-width: 0;
}
.oa-app-card__icon {
display: grid;
flex-shrink: 0;
place-items: center;
width: 40px;
height: 40px;
overflow: hidden;
border-radius: 10px;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.oa-app-card__body {
flex: 1;
min-width: 0;
}
.oa-app-card__name {
display: block;
overflow: hidden;
color: var(--mint-text-strong, #0f172a);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-app-card__desc {
display: block;
margin-top: 4px;
overflow: hidden;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-app-card__ops {
display: flex;
gap: 6px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.oa-app-card__btn {
height: 24px !important;
padding: 0 8px !important;
font-size: 11px !important;
border-radius: 5px !important;
.el-icon {
margin-right: 2px;
font-size: 12px;
}
&.primary {
color: #fff !important;
background: var(--primary, #10b981) !important;
border-color: var(--primary, #10b981) !important;
}
}
.oa-start__tip {
margin: 8px 0 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
}
.oa-start__meta {
margin-bottom: 8px;
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
@media (max-width: 640px) {
.oa-app-card {
width: 100%;
}
}
</style>