feat(oa): render instance detail progress from API projection

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-15 14:38:39 +08:00
1 parent 98beade090
commit 265d790ea6
2 files changed
+747

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
import request from '@/utils/http'
const BASE = '/web/admin/oa-approval'
// ---------------------------------------------------------------------------
// 工作流角色
// ---------------------------------------------------------------------------
export function fetchOaRoleList(params?: { keyword?: string; status?: string }) {
return request.get<{ list: any[] }>({
url: `${BASE}/roles`,
params
})
}
export function fetchEnsureOaRolePresets() {
return request.post<{ createdCount: number }>({
url: `${BASE}/roles/ensure-presets`,
showSuccessMessage: true
})
}
export function fetchCreateOaRole(data: {
roleName: string
roleCode: string
status?: string
orderNum?: number
remark?: string
}) {
return request.post({
url: `${BASE}/roles`,
data,
showSuccessMessage: true
})
}
export function fetchUpdateOaRole(
id: string,
data: Partial<{
roleName: string
status: string
orderNum: number
remark: string
}>
) {
return request.put({
url: `${BASE}/roles/${id}`,
data,
showSuccessMessage: true
})
}
export function fetchDeleteOaRole(id: string) {
return request.del({
url: `${BASE}/roles/${id}`,
showSuccessMessage: true
})
}
export function fetchOaRoleUsers(roleId: string) {
return request.get<{ list: any[] }>({
url: `${BASE}/roles/${roleId}/users`
})
}
export function fetchReplaceOaRoleUsers(roleId: string, userIds: string[]) {
return request.put<{ list: any[] }>({
url: `${BASE}/roles/${roleId}/users`,
data: { userIds },
showSuccessMessage: true
})
}
// ---------------------------------------------------------------------------
// 分组 / 应用 / 草稿 / 发布
// ---------------------------------------------------------------------------
export function fetchOaGroupList() {
return request.get<{ list: any[] }>({ url: `${BASE}/groups` })
}
export function fetchCreateOaGroup(data: {
groupName: string
orderNum?: number
remark?: string
}) {
return request.post({
url: `${BASE}/groups`,
data,
showSuccessMessage: true
})
}
export function fetchUpdateOaGroup(
id: string,
data: Partial<{ groupName: string; orderNum: number; remark: string }>
) {
return request.put({
url: `${BASE}/groups/${id}`,
data,
showSuccessMessage: true
})
}
export function fetchDeleteOaGroup(id: string) {
return request.del({
url: `${BASE}/groups/${id}`,
showSuccessMessage: true
})
}
export function fetchOaAppList(params?: {
groupId?: string
keyword?: string
status?: string
}) {
return request.get<{ list: any[] }>({
url: `${BASE}/apps`,
params
})
}
export function fetchCreateOaApp(data: Record<string, any>) {
return request.post({
url: `${BASE}/apps`,
data,
showSuccessMessage: true
})
}
export function fetchUpdateOaApp(id: string, data: Record<string, any>) {
return request.put({
url: `${BASE}/apps/${id}`,
data,
showSuccessMessage: true
})
}
export function fetchDeleteOaApp(id: string) {
return request.del({
url: `${BASE}/apps/${id}`,
showSuccessMessage: true
})
}
export function fetchOaAppDraft(appId: string) {
return request.get<any>({ url: `${BASE}/apps/${appId}/draft` })
}
export function fetchSaveOaAppDraft(
appId: string,
data: {
basic?: Record<string, any>
formSchemaJson?: any
flowTreeJson?: any
advancedSettingsJson?: any
}
) {
return request.put({
url: `${BASE}/apps/${appId}/draft`,
data,
showSuccessMessage: true
})
}
export function fetchPublishOaApp(appId: string) {
return request.post({
url: `${BASE}/apps/${appId}/publish`,
showSuccessMessage: true
})
}
// ---------------------------------------------------------------------------
// 运行时
// ---------------------------------------------------------------------------
export function fetchOaTemplates() {
return request.get<{ list: any[] }>({ url: `${BASE}/templates` })
}
export function fetchOaTemplateDetail(appId: string) {
return request.get<any>({ url: `${BASE}/templates/${appId}` })
}
/** 发起前审批流预览(按当前登录人解析审批人) */
export function fetchOaFlowPreview(appId: string, initiatorSelectUserIds?: string[]) {
return request.get<{ steps: OaFlowPreviewStep[] }>({
url: `${BASE}/templates/${appId}/flow-preview`,
params: initiatorSelectUserIds?.length
? { initiatorSelectUserIds: initiatorSelectUserIds.join(',') }
: undefined
})
}
export type OaFlowPreviewStep = {
id: string
type: 'start' | 'approval' | 'cc' | 'branch' | 'end' | string
name: string
signMode?: string
assigneeType?: string
meta?: string
people?: Array<{ id: string; nickName: string }>
resolveError?: string
}
export function fetchStartOaInstance(data: {
appId: string
formData?: Record<string, any>
title?: string
initiatorDeptId?: string
initiatorSelectUserIds?: string[]
relatedInstanceIds?: string[]
}) {
return request.post({
url: `${BASE}/instances`,
data,
showSuccessMessage: true
})
}
/** 详情审批进度投影项(后端 build_progress_items) */
export interface OaProgressItem {
kind: 'submit' | 'task' | 'pending_node' | 'terminal' | string
roundNo?: number
title?: string
status?: string
assigneeUserId?: string
assigneeUserName?: string
timestamp?: string
commentText?: string
nodeKey?: string
taskType?: string
resolveSource?: string
taskId?: string
}
export function fetchOaInstanceDetail(id: string) {
return request.get<{ progress?: OaProgressItem[]; tasks?: any[] } & Record<string, any>>({
url: `${BASE}/instances/${id}`
})
}
export function fetchOaTasks(kind: 'pending' | 'done' | 'mine' | 'cc') {
return request.get<{ list: any[] }>({ url: `${BASE}/tasks/${kind}` })
}
export function fetchApproveOaTask(taskId: string, comment?: string) {
return request.post({
url: `${BASE}/tasks/${taskId}/approve`,
data: { comment },
showSuccessMessage: true
})
}
export function fetchRejectOaTask(taskId: string, comment?: string) {
return request.post({
url: `${BASE}/tasks/${taskId}/reject`,
data: { comment },
showSuccessMessage: true
})
}
export function fetchAddSignOaTask(
taskId: string,
data: { userIds: string[]; comment?: string }
) {
return request.post({
url: `${BASE}/tasks/${taskId}/add-sign`,
data,
showSuccessMessage: true
})
}
export function fetchWithdrawOaInstance(instanceId: string) {
return request.post({
url: `${BASE}/instances/${instanceId}/withdraw`,
showSuccessMessage: true
})
}
@@ -0,0 +1,468 @@
<!-- 审批详情 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top oa-detail__top">
<div class="oa-detail__title-wrap">
<el-button class="btn-ghost" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h2 class="title-bar-heading">{{ detail?.title || '审批详情' }}</h2>
<el-tag v-if="detail?.status" size="small" :type="statusType(detail.status)">
{{ statusText(detail.status) }}
</el-tag>
</div>
<div class="oa-detail__actions">
<el-button
v-if="canApprove"
class="btn-main"
type="primary"
:loading="acting"
@click="act('approve')"
>
<el-icon><Check /></el-icon>
同意
</el-button>
<el-button v-if="canApprove" class="btn-ghost" :loading="acting" @click="act('reject')">
<el-icon><Close /></el-icon>
拒绝
</el-button>
<el-button
v-if="canApprove && detail?.allowAddSign !== false"
class="btn-ghost"
:loading="acting"
@click="openAddSign"
>
<el-icon><Plus /></el-icon>
加签
</el-button>
<el-button
v-if="detail?.status === 'running' && isInitiator"
class="btn-ghost"
:loading="acting"
@click="withdraw"
>
<el-icon><RefreshLeft /></el-icon>
撤销
</el-button>
</div>
</div>
<div v-loading="loading" class="oa-detail__body">
<section class="oa-detail__section">
<h3>表单内容</h3>
<div class="oa-detail__form-wrap">
<form-create
v-if="viewRule.length"
v-model="viewFormData"
:rule="viewRule"
:option="viewOption"
/>
<pre v-else class="oa-detail__json">{{ prettyForm }}</pre>
</div>
</section>
<section class="oa-detail__section">
<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"
>
<div class="oa-tl">
<div class="oa-tl__title">
{{ item.title }}
<el-tag size="small" :type="statusType(item.status)">
{{ statusText(item.status) }}
</el-tag>
<el-tag v-if="item.taskType === 'cc'" size="small" type="info">抄送</el-tag>
</div>
<div
v-if="item.assignee && item.kind !== 'pending_node' && item.kind !== 'terminal'"
class="oa-tl__meta"
>
处理人:{{ item.assignee }}
</div>
<div v-if="item.resolveSource" class="oa-tl__meta">
解析来源:{{ resolveSourceText(item.resolveSource) }}
</div>
<div v-if="item.commentText" class="oa-tl__comment">意见:{{ item.commentText }}</div>
</div>
</el-timeline-item>
</el-timeline>
<p v-else class="oa-detail__empty">暂无任务记录</p>
</div>
</section>
</div>
<el-dialog
v-model="addSignVisible"
title="加签"
width="520px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<el-form-item label="加签人">
<el-select
v-model="addSignUserIds"
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-item label="说明(可选)">
<el-input v-model="addSignComment" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="addSignVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="acting" @click="submitAddSign">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Check, Close, RefreshLeft, Plus } from '@element-plus/icons-vue'
import { fetchUserList } from '@/api/admin'
import {
fetchAddSignOaTask,
fetchApproveOaTask,
fetchOaInstanceDetail,
fetchRejectOaTask,
fetchWithdrawOaInstance
} from '@/api/oaApproval'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'OaApprovalInstanceDetail' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const acting = ref(false)
const detail = ref<any>(null)
const viewFormData = ref<Record<string, any>>({})
const viewRule = ref<any[]>([])
const viewOption = ref({
form: { disabled: true, labelPosition: 'top' },
submitBtn: false,
resetBtn: false
})
const addSignVisible = ref(false)
const addSignUserIds = ref<string[]>([])
const addSignComment = ref('')
const userLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
const prettyForm = computed(() => JSON.stringify(detail.value?.formDataJson || {}, null, 2))
/** 进度以详情 API progress 投影为准(勿回落无序 tasks) */
const timelineItems = computed(() => {
const list = detail.value?.progress
if (!Array.isArray(list) || !list.length) return []
return list.map((p: any, idx: number) => ({
key: `${p.kind}-${p.taskId || p.nodeKey || idx}-${p.roundNo || 1}`,
title: p.title || '-',
status: p.status || 'info',
timestamp: p.timestamp || '',
assignee: p.assigneeUserName || p.assigneeUserId || '',
resolveSource: p.resolveSource || '',
commentText: p.commentText || '',
taskType: p.taskType || '',
kind: p.kind || '',
roundNo: p.roundNo || 1
}))
})
const currentUserId = computed(() => {
const info = userStore.info || userStore.getUserInfo
return String(info?.id || info?.userId || '').trim()
})
const isInitiator = computed(
() => !!detail.value && detail.value.initiatorUserId === currentUserId.value
)
const myPendingTask = computed(() => {
const uid = currentUserId.value
const list = detail.value?.tasks || []
return list.find(
(t: any) => t.taskType === 'approval' && t.status === 'pending' && t.assigneeUserId === uid
)
})
const canApprove = computed(() => !!myPendingTask.value)
function statusType(s: string) {
if (s === 'pending' || s === 'running') 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'
return 'info'
}
function statusText(s: string) {
const map: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
submitted: '已提交',
not_reached: '未到达'
}
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 === 'not_reached') return 'info'
return 'primary'
}
function resolveSourceText(src: string) {
const map: Record<string, string> = {
users: '指定成员',
oa_roles: '工作流角色',
dept_leader: '部门负责人',
direct_supervisor: '直属主管',
supervisor_fallback_dept_leader: '部门负责人(主管回退)',
initiator_select: '发起人自选'
}
return map[src] || src
}
function applySchema(schema: any, data: any) {
viewFormData.value = { ...(data || {}) }
const rule = Array.isArray(schema?.rule) ? schema.rule : Array.isArray(schema) ? schema : []
viewRule.value = rule
viewOption.value = {
...(schema?.option || {}),
form: { ...(schema?.option?.form || {}), disabled: true, labelPosition: 'top' },
submitBtn: false,
resetBtn: false
}
}
async function load() {
loading.value = true
try {
detail.value = await fetchOaInstanceDetail(String(route.params.id || ''))
applySchema(detail.value?.formSchemaJson, detail.value?.formDataJson)
} finally {
loading.value = false
}
}
async function act(kind: 'approve' | 'reject') {
const task = myPendingTask.value
if (!task) return
let comment = ''
try {
const res = await ElMessageBox.prompt(
'请输入审批意见(可选)',
kind === 'approve' ? '同意' : '拒绝',
{
inputPlaceholder: '意见',
confirmButtonText: '确定',
cancelButtonText: '取消'
}
)
comment = res.value || ''
} catch {
return
}
acting.value = true
try {
if (kind === 'approve') await fetchApproveOaTask(task.id, comment || undefined)
else await fetchRejectOaTask(task.id, comment || undefined)
await load()
} finally {
acting.value = false
}
}
async function withdraw() {
await ElMessageBox.confirm('确定撤销该审批吗?', '提示', { type: 'warning' })
acting.value = true
try {
await fetchWithdrawOaInstance(String(route.params.id || ''))
await load()
} finally {
acting.value = 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 openAddSign() {
addSignUserIds.value = []
addSignComment.value = ''
addSignVisible.value = true
if (!userOptions.value.length) await loadUsers()
}
async function submitAddSign() {
const task = myPendingTask.value
if (!task) return
if (!addSignUserIds.value.length) {
ElMessage.warning('请选择加签人')
return
}
acting.value = true
try {
await fetchAddSignOaTask(task.id, {
userIds: addSignUserIds.value,
comment: addSignComment.value || undefined
})
addSignVisible.value = false
await load()
} finally {
acting.value = false
}
}
function goBack() {
router.back()
}
onMounted(load)
</script>
<style scoped lang="scss">
.oa-detail__top {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.oa-detail__title-wrap {
display: flex;
gap: 12px;
align-items: center;
h2 {
margin: 0;
}
}
.oa-detail__actions {
display: flex;
gap: 8px;
}
.oa-detail__body {
padding: 0 20px 20px;
}
.oa-detail__section {
max-width: 960px;
margin: 0 auto;
h3 {
margin: 16px 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #0f172a);
}
}
.oa-detail__form-wrap {
box-sizing: border-box;
padding: 4px 0 8px;
}
.oa-detail__json {
margin: 0;
padding: 0;
overflow: auto;
font-size: 12px;
background: transparent;
border: none;
}
.oa-detail__empty {
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
}
.oa-tl__title {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-weight: 600;
}
.oa-tl__meta {
margin-top: 4px;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
}
.oa-tl__comment {
margin-top: 6px;
font-size: 13px;
}
</style>