index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. <template>
  2. <div class="dashboard">
  3. <!-- 统计概览 -->
  4. <el-row :gutter="16">
  5. <el-col v-for="card in visibleStatCards" :key="card.key" :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
  6. <el-card class="stat-card" shadow="hover" @click="$router.push(card.path)">
  7. <div class="stat-item">
  8. <div class="stat-icon" :style="{ background: card.color }">
  9. <el-icon><component :is="card.icon" /></el-icon>
  10. </div>
  11. <div class="stat-info">
  12. <div class="stat-value">{{ card.value }}</div>
  13. <div class="stat-label">{{ card.label }}</div>
  14. </div>
  15. </div>
  16. </el-card>
  17. </el-col>
  18. </el-row>
  19. <el-row :gutter="16" style="margin-top: 16px;">
  20. <!-- 快捷入口 -->
  21. <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
  22. <el-card class="quick-card">
  23. <template #header>
  24. <div class="quick-header">
  25. <span>快捷入口</span>
  26. </div>
  27. </template>
  28. <div class="quick-grid">
  29. <div
  30. v-for="item in visibleQuickActions"
  31. :key="item.key"
  32. class="quick-item"
  33. @click="$router.push(item.path)"
  34. >
  35. <div class="quick-icon" :style="{ background: item.color }">
  36. <el-icon><component :is="item.icon" /></el-icon>
  37. <el-badge v-if="item.badge && stats.todoCount > 0" :value="stats.todoCount" class="todo-badge" />
  38. </div>
  39. <div class="quick-text">{{ item.label }}</div>
  40. </div>
  41. </div>
  42. </el-card>
  43. </el-col>
  44. <!-- 常用流程 -->
  45. <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
  46. <el-card class="quick-card">
  47. <template #header>
  48. <div class="quick-header">
  49. <span>常用流程</span>
  50. <el-link type="primary" :underline="false" @click="$router.push('/approval/execute')">更多</el-link>
  51. </div>
  52. </template>
  53. <div v-loading="loadingDefinitions" class="common-flow-list">
  54. <el-empty v-if="!loadingDefinitions && commonFlows.length === 0" description="暂无可用流程" />
  55. <div
  56. v-for="def in commonFlows"
  57. :key="def.id"
  58. class="common-flow-item"
  59. @click="handleStart(def)"
  60. >
  61. <div class="common-flow-icon">
  62. <el-icon><Promotion /></el-icon>
  63. </div>
  64. <div class="common-flow-info">
  65. <div class="common-flow-name">{{ def.name }}</div>
  66. <div v-if="def.category" class="common-flow-category">{{ def.category }}</div>
  67. </div>
  68. <el-button type="primary" size="small" @click.stop="handleStart(def)">发起</el-button>
  69. </div>
  70. </div>
  71. </el-card>
  72. </el-col>
  73. </el-row>
  74. <!-- 发起流程弹窗 -->
  75. <el-dialog v-model="startDialogVisible" title="快速发起流程" width="640px">
  76. <el-form ref="startFormRef" :model="startForm" :rules="startFormRules" label-width="80px">
  77. <el-form-item v-show="false">
  78. <el-input v-model="startForm.processDefinitionId" />
  79. </el-form-item>
  80. <el-form-item label="流程名称">
  81. <el-input v-model="startForm.definitionName" disabled />
  82. </el-form-item>
  83. <el-form-item label="标题" prop="title">
  84. <el-input v-model="startForm.title" placeholder="请输入流程标题" />
  85. </el-form-item>
  86. <el-form-item label="表单数据">
  87. <FlowFormFields v-if="hasFormFields" ref="flowFormFieldsRef" v-model="dynamicFormData" :definition="selectedDefinition" />
  88. <el-input v-else v-model="startForm.formData" type="textarea" :rows="4" placeholder="请输入表单数据,格式:每行一个字段,如 amount=1000" />
  89. </el-form-item>
  90. <el-form-item label="附件">
  91. <el-upload
  92. v-model:file-list="fileUpload.attachmentList"
  93. action="#"
  94. :http-request="fileUpload.handleUpload"
  95. :before-upload="beforeFileUpload"
  96. :before-remove="() => true"
  97. :limit="5"
  98. multiple
  99. :on-preview="fileUpload.handleFilePreview"
  100. >
  101. <el-button type="primary" size="small">上传附件</el-button>
  102. <template #tip>
  103. <div class="el-upload__tip">支持 Excel、图片等常见格式</div>
  104. </template>
  105. <template #file="{ file }">
  106. <div class="upload-file-item">
  107. <el-icon><Document /></el-icon>
  108. <el-link type="primary" @click="fileUpload.openPreview(file.url || file.response)">
  109. {{ file.name }}
  110. </el-link>
  111. </div>
  112. </template>
  113. </el-upload>
  114. </el-form-item>
  115. </el-form>
  116. <template #footer>
  117. <el-button @click="startDialogVisible = false">取消</el-button>
  118. <el-button type="primary" :loading="starting" @click="submitStart">确认发起</el-button>
  119. </template>
  120. </el-dialog>
  121. <!-- 附件预览 -->
  122. <FilePreview v-model="fileUpload.previewVisible" :url="fileUpload.previewUrl" />
  123. </div>
  124. </template>
  125. <script setup lang="ts">
  126. import { ref, onMounted, reactive, computed } from 'vue'
  127. import { useRouter } from 'vue-router'
  128. import { ElMessage } from 'element-plus'
  129. import type { FormInstance, FormRules } from 'element-plus'
  130. import {
  131. Bell, CircleCheck, Connection, UserFilled, Message,
  132. Document, TrendCharts, Pointer, EditPen, User, Promotion
  133. } from '@element-plus/icons-vue'
  134. import { listTodo, listHandled, listCc } from '@/api/flow/task'
  135. import { listMyInstance, startInstance } from '@/api/flow/instance'
  136. import { listDefinition, listEnabled } from '@/api/flow/definition'
  137. import { listUser } from '@/api/system/user'
  138. import { getOverview } from '@/api/analysis'
  139. import { beforeFileUpload, collectAttachmentUrls } from '@/utils/file'
  140. import { useFileUpload } from '@/composables/useFileUpload'
  141. import type { FlowDefinition } from '@/types/flow'
  142. import { useUserStore } from '@/stores/user-store'
  143. import FlowFormFields from '@/components/FlowFormFields/index.vue'
  144. import FilePreview from '@/components/FilePreview/index.vue'
  145. const router = useRouter()
  146. const userStore = useUserStore()
  147. const employeeType = computed(() => userStore.userInfo?.employeeType || 'common_user')
  148. const isFlowAdmin = computed(() => ['flow_manager', 'super_admin'].includes(employeeType.value))
  149. const isSystemAdmin = computed(() => ['dept_manager', 'super_admin'].includes(employeeType.value))
  150. const stats = ref({
  151. todoCount: 0,
  152. handledCount: 0,
  153. instanceCount: 0,
  154. ccCount: 0,
  155. definitionCount: 0,
  156. userCount: 0,
  157. analysisTotal: 0,
  158. analysisRunning: 0
  159. })
  160. const statCardConfigs = computed(() => [
  161. { key: 'todo', label: '我的待办', value: stats.value.todoCount, color: '#67C23A', icon: Bell, path: '/approval/todo', visible: true },
  162. { key: 'handled', label: '我的已办', value: stats.value.handledCount, color: '#409EFF', icon: CircleCheck, path: '/approval/handled', visible: true },
  163. { key: 'mine', label: '我的流程', value: stats.value.instanceCount, color: '#E6A23C', icon: Connection, path: '/approval/mine', visible: true },
  164. { key: 'cc', label: '我的抄送', value: stats.value.ccCount, color: '#909399', icon: Message, path: '/approval/cc', visible: true },
  165. { key: 'definition', label: '流程管理', value: stats.value.definitionCount, color: '#409EFF', icon: Document, path: '/flow/definition', visible: isFlowAdmin.value },
  166. { key: 'analysis', label: '数据看板', value: stats.value.analysisRunning, color: '#13C2C2', icon: TrendCharts, path: '/analysis/flow', visible: isFlowAdmin.value },
  167. { key: 'user', label: '系统用户', value: stats.value.userCount, color: '#F56C6C', icon: UserFilled, path: '/system/user', visible: isSystemAdmin.value }
  168. ])
  169. const visibleStatCards = computed(() => statCardConfigs.value.filter(c => c.visible))
  170. const quickActionConfigs = computed(() => [
  171. { key: 'todo', label: '处理待办', color: '#67C23A', icon: Bell, path: '/approval/todo', badge: true },
  172. { key: 'execute', label: '流程执行', color: '#909399', icon: Pointer, path: '/approval/execute' },
  173. { key: 'mine', label: '我的流程', color: '#E6A23C', icon: Connection, path: '/approval/mine' },
  174. { key: 'cc', label: '我的抄送', color: '#909399', icon: Message, path: '/approval/cc' },
  175. { key: 'designer', label: '设计流程', color: '#409EFF', icon: EditPen, path: '/flow/designer', visible: isFlowAdmin.value },
  176. { key: 'definition', label: '流程管理', color: '#E6A23C', icon: Document, path: '/flow/definition', visible: isFlowAdmin.value },
  177. { key: 'user', label: '用户管理', color: '#F56C6C', icon: User, path: '/system/user', visible: isSystemAdmin.value },
  178. { key: 'analysis', label: '数据看板', color: '#13C2C2', icon: TrendCharts, path: '/analysis/flow', visible: isFlowAdmin.value }
  179. ])
  180. const visibleQuickActions = computed(() => quickActionConfigs.value.filter(c => c.visible !== false))
  181. const loadingDefinitions = ref(false)
  182. const commonFlows = ref<FlowDefinition[]>([])
  183. const startDialogVisible = ref(false)
  184. const starting = ref(false)
  185. const startFormRef = ref<FormInstance>()
  186. const startForm = reactive({
  187. processDefinitionId: 0,
  188. definitionName: '',
  189. title: '',
  190. formData: ''
  191. })
  192. const dynamicFormData = ref<Record<string, unknown>>({})
  193. const selectedDefinition = ref<FlowDefinition | null>(null)
  194. const flowFormFieldsRef = ref<any>(null)
  195. const hasFormFields = computed(() => {
  196. const schema = selectedDefinition.value?.formSchema
  197. if (!schema) return false
  198. try {
  199. const parsed = JSON.parse(schema)
  200. return Array.isArray(parsed) && parsed.length > 0
  201. } catch {
  202. return false
  203. }
  204. })
  205. const fileUpload = reactive(useFileUpload())
  206. const startFormRules: FormRules = {
  207. title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
  208. }
  209. async function loadStats() {
  210. try {
  211. // 所有用户可见的统计
  212. const [todoRes, handledRes, instRes, ccRes] = await Promise.all([
  213. listTodo({ pageNum: 1, pageSize: 1 }),
  214. listHandled({ pageNum: 1, pageSize: 1 }),
  215. listMyInstance({ pageNum: 1, pageSize: 1 }),
  216. listCc({ pageNum: 1, pageSize: 1 })
  217. ])
  218. stats.value.todoCount = todoRes.total ?? 0
  219. stats.value.handledCount = handledRes.total ?? 0
  220. stats.value.instanceCount = instRes.total ?? 0
  221. stats.value.ccCount = ccRes.total ?? 0
  222. // 流程管理员 / 超级管理员可见
  223. if (isFlowAdmin.value) {
  224. const [defRes, overviewRes] = await Promise.all([
  225. listDefinition({ pageNum: 1, pageSize: 1 }, { silent: true }),
  226. getOverview({}, { silent: true })
  227. ])
  228. stats.value.definitionCount = defRes.total ?? 0
  229. stats.value.analysisTotal = overviewRes?.totalInstances ?? 0
  230. stats.value.analysisRunning = overviewRes?.runningCount ?? 0
  231. }
  232. // 部门经理 / 超级管理员可见
  233. if (isSystemAdmin.value) {
  234. const userRes = await listUser({ pageNum: 1, pageSize: 1 }, { silent: true })
  235. stats.value.userCount = userRes.total ?? 0
  236. }
  237. } catch {
  238. // ignore
  239. }
  240. }
  241. async function loadCommonFlows() {
  242. loadingDefinitions.value = true
  243. try {
  244. const res = await listEnabled()
  245. commonFlows.value = (res || []).slice(0, 6)
  246. } catch {
  247. commonFlows.value = []
  248. } finally {
  249. loadingDefinitions.value = false
  250. }
  251. }
  252. function handleStart(def: FlowDefinition) {
  253. startForm.processDefinitionId = def.id
  254. startForm.definitionName = def.name
  255. startForm.title = ''
  256. startForm.formData = ''
  257. dynamicFormData.value = {}
  258. selectedDefinition.value = def
  259. fileUpload.resetFileUpload()
  260. startDialogVisible.value = true
  261. }
  262. async function submitStart() {
  263. const valid = await startFormRef.value?.validate().catch(() => false)
  264. if (!valid) return
  265. starting.value = true
  266. try {
  267. const dynamicValid = flowFormFieldsRef.value?.validate() ?? true
  268. if (dynamicValid === false) return
  269. let formData: Record<string, unknown> | undefined
  270. if (selectedDefinition.value?.formSchema) {
  271. formData = { ...dynamicFormData.value }
  272. } else if (startForm.formData.trim()) {
  273. formData = {}
  274. startForm.formData.split('\n').forEach(line => {
  275. const idx = line.indexOf('=')
  276. if (idx > 0) {
  277. const key = line.substring(0, idx).trim()
  278. const value = line.substring(idx + 1).trim()
  279. if (key) {
  280. const num = Number(value)
  281. formData![key] = !isNaN(num) && value !== '' ? num : value
  282. }
  283. }
  284. })
  285. }
  286. const attachmentUrls = fileUpload.attachmentList.length > 0
  287. ? collectAttachmentUrls(fileUpload.attachmentList)
  288. : undefined
  289. await startInstance(startForm.processDefinitionId, startForm.title || undefined, formData, attachmentUrls)
  290. ElMessage.success('流程发起成功')
  291. startDialogVisible.value = false
  292. router.push('/approval/mine')
  293. } finally {
  294. starting.value = false
  295. }
  296. }
  297. onMounted(() => {
  298. loadStats()
  299. loadCommonFlows()
  300. })
  301. </script>
  302. <style scoped>
  303. .dashboard {
  304. padding: 4px;
  305. }
  306. .stat-card {
  307. cursor: pointer;
  308. transition: all 0.2s ease;
  309. margin-bottom: 16px;
  310. }
  311. .stat-card:hover {
  312. transform: translateY(-2px);
  313. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  314. }
  315. .stat-item {
  316. display: flex;
  317. align-items: center;
  318. gap: 14px;
  319. }
  320. .stat-icon {
  321. width: 52px;
  322. height: 52px;
  323. border-radius: 10px;
  324. display: flex;
  325. align-items: center;
  326. justify-content: center;
  327. color: #fff;
  328. font-size: 26px;
  329. }
  330. .stat-value {
  331. font-size: 26px;
  332. font-weight: bold;
  333. color: #303133;
  334. line-height: 1.2;
  335. }
  336. .stat-label {
  337. font-size: 13px;
  338. color: #909399;
  339. margin-top: 4px;
  340. }
  341. .quick-card {
  342. min-height: 280px;
  343. }
  344. .quick-header {
  345. display: flex;
  346. justify-content: space-between;
  347. align-items: center;
  348. }
  349. .quick-grid {
  350. display: grid;
  351. grid-template-columns: repeat(2, 1fr);
  352. gap: 12px;
  353. }
  354. .quick-item {
  355. display: flex;
  356. align-items: center;
  357. gap: 12px;
  358. padding: 14px;
  359. border-radius: 8px;
  360. background: #f5f7fa;
  361. cursor: pointer;
  362. transition: all 0.2s ease;
  363. }
  364. .quick-item:hover {
  365. background: #e6f2ff;
  366. transform: translateY(-2px);
  367. }
  368. .quick-icon {
  369. position: relative;
  370. width: 44px;
  371. height: 44px;
  372. border-radius: 8px;
  373. display: flex;
  374. align-items: center;
  375. justify-content: center;
  376. color: #fff;
  377. font-size: 22px;
  378. }
  379. .todo-badge :deep(.el-badge__content) {
  380. position: absolute;
  381. top: -6px;
  382. right: -6px;
  383. }
  384. .quick-text {
  385. font-size: 15px;
  386. font-weight: 500;
  387. color: #303133;
  388. }
  389. .common-flow-list {
  390. display: flex;
  391. flex-direction: column;
  392. gap: 10px;
  393. }
  394. .common-flow-item {
  395. display: flex;
  396. align-items: center;
  397. gap: 12px;
  398. padding: 12px;
  399. border-radius: 8px;
  400. background: #f5f7fa;
  401. cursor: pointer;
  402. transition: all 0.2s ease;
  403. }
  404. .common-flow-item:hover {
  405. background: #e6f2ff;
  406. }
  407. .common-flow-icon {
  408. width: 40px;
  409. height: 40px;
  410. border-radius: 50%;
  411. background: #409eff;
  412. color: #fff;
  413. display: flex;
  414. align-items: center;
  415. justify-content: center;
  416. font-size: 18px;
  417. }
  418. .common-flow-info {
  419. flex: 1;
  420. min-width: 0;
  421. }
  422. .common-flow-name {
  423. font-size: 14px;
  424. font-weight: 500;
  425. color: #303133;
  426. overflow: hidden;
  427. text-overflow: ellipsis;
  428. white-space: nowrap;
  429. }
  430. .common-flow-category {
  431. font-size: 12px;
  432. color: #909399;
  433. margin-top: 2px;
  434. }
  435. .upload-file-item {
  436. display: flex;
  437. align-items: center;
  438. gap: 6px;
  439. padding: 4px 0;
  440. }
  441. </style>