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:
1 parent
265d790ea6
commit
26800865cd
13 files changed
+4736
-72
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user