Explorar el Código

feat(wm-bpm+frontend): #33 BPMN.js设计器+流程模板管理完整实现

bot_dev2 hace 5 días
padre
commit
351b388e92

+ 84
- 0
frontend/src/api/bpmApi.ts Ver fichero

@@ -0,0 +1,84 @@
1
+import request from './request'
2
+
3
+const BASE = '/api/bpm/template'
4
+
5
+/** 流程模板类型 */
6
+export interface ProcessTemplate {
7
+  id?: number
8
+  name: string
9
+  description?: string
10
+  bpmnXml?: string
11
+  category?: string
12
+  tags?: string
13
+  useCount?: number
14
+  status?: number // 0-草稿 1-已发布 2-已停用
15
+  creatorId?: number
16
+  creatorName?: string
17
+  tenantId?: string
18
+  createdAt?: string
19
+  updatedAt?: string
20
+}
21
+
22
+/** 模板查询参数 */
23
+export interface TemplateQuery {
24
+  keyword?: string
25
+  category?: string
26
+  status?: number
27
+  tag?: string
28
+  page?: number
29
+  size?: number
30
+}
31
+
32
+/** 分页响应 */
33
+export interface PageResult<T> {
34
+  records: T[]
35
+  total: number
36
+  size: number
37
+  current: number
38
+  pages: number
39
+}
40
+
41
+// 创建模板
42
+export function createTemplate(data: ProcessTemplate) {
43
+  return request.post(BASE, data)
44
+}
45
+
46
+// 获取模板详情
47
+export function getTemplate(id: number) {
48
+  return request.get(`${BASE}/${id}`)
49
+}
50
+
51
+// 更新模板
52
+export function updateTemplate(id: number, data: Partial<ProcessTemplate>) {
53
+  return request.put(`${BASE}/${id}`, data)
54
+}
55
+
56
+// 删除模板
57
+export function deleteTemplate(id: number) {
58
+  return request.delete(`${BASE}/${id}`)
59
+}
60
+
61
+// 分页查询模板
62
+export function listTemplates(params: TemplateQuery) {
63
+  return request.get(BASE, { params })
64
+}
65
+
66
+// 发布模板
67
+export function publishTemplate(id: number) {
68
+  return request.put(`${BASE}/${id}/publish`)
69
+}
70
+
71
+// 停用模板
72
+export function disableTemplate(id: number) {
73
+  return request.put(`${BASE}/${id}/disable`)
74
+}
75
+
76
+// 复制模板
77
+export function copyTemplate(id: number) {
78
+  return request.post(`${BASE}/${id}/copy`)
79
+}
80
+
81
+// 热门模板
82
+export function hotTemplates(limit = 10) {
83
+  return request.get(`${BASE}/hot`, { params: { limit } })
84
+}

+ 2
- 0
frontend/src/router/index.ts Ver fichero

@@ -16,6 +16,8 @@ const routes = [
16 16
       { path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
17 17
       { path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
18 18
       { path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
19
+      { path: 'bpm/templates', name: 'bpmTemplates', component: () => import('@/views/bpm/TemplateListView.vue') },
20
+      { path: 'bpm/designer', name: 'bpmDesigner', component: () => import('@/views/bpm/BpmnDesigner.vue') },
19 21
     ]
20 22
   },
21 23
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 561
- 0
frontend/src/views/bpm/BpmnDesigner.vue Ver fichero

@@ -0,0 +1,561 @@
1
+<template>
2
+  <div class="bpmn-designer">
3
+    <!-- 工具栏 -->
4
+    <div class="toolbar">
5
+      <el-button-group>
6
+        <el-button type="primary" :icon="Document" @click="handleNew">新建</el-button>
7
+        <el-button :icon="FolderOpened" @click="handleImport">导入</el-button>
8
+        <el-button :icon="Download" @click="handleExport">导出</el-button>
9
+        <el-button :icon="Check" @click="handleSave" type="success">保存</el-button>
10
+      </el-button-group>
11
+      <el-button-group>
12
+        <el-button :icon="RefreshLeft" @click="handleUndo" title="撤销">撤销</el-button>
13
+        <el-button :icon="RefreshRight" @click="handleRedo" title="重做">重做</el-button>
14
+      </el-button-group>
15
+      <el-button-group>
16
+        <el-button :icon="ZoomIn" @click="handleZoomIn">放大</el-button>
17
+        <el-button :icon="ZoomOut" @click="handleZoomOut">缩小</el-button>
18
+        <el-button :icon="FullScreen" @click="handleResetZoom">重置</el-button>
19
+      </el-button-group>
20
+      <span class="zoom-label">{{ Math.round(currentZoom * 100) }}%</span>
21
+    </div>
22
+
23
+    <div class="designer-main">
24
+      <!-- BPMN 画布 -->
25
+      <div class="canvas-container" ref="canvasRef">
26
+        <div class="canvas" ref="bpmnCanvas"></div>
27
+      </div>
28
+
29
+      <!-- 属性面板 -->
30
+      <div class="properties-panel" v-if="selectedElement">
31
+        <el-card shadow="never">
32
+          <template #header>
33
+            <div class="panel-header">
34
+              <span>属性面板</span>
35
+              <el-tag size="small" type="info">{{ elementTypeLabel }}</el-tag>
36
+            </div>
37
+          </template>
38
+          <el-form label-width="80px" size="small">
39
+            <el-form-item label="ID">
40
+              <el-input v-model="selectedElement.id" disabled />
41
+            </el-form-item>
42
+            <el-form-item label="名称">
43
+              <el-input v-model="elementProps.name" @blur="applyNameChange" placeholder="输入名称" />
44
+            </el-form-item>
45
+            <el-form-item label="审批人" v-if="isUserTask">
46
+              <el-input v-model="elementProps.assignee" @blur="applyAssigneeChange" placeholder="审批人ID或表达式" />
47
+            </el-form-item>
48
+            <el-form-item label="候选组" v-if="isUserTask">
49
+              <el-input v-model="elementProps.candidateGroups" @blur="applyCandidateGroupsChange" placeholder="候选组" />
50
+            </el-form-item>
51
+            <el-form-item label="条件表达式" v-if="isSequenceFlow">
52
+              <el-input v-model="elementProps.conditionExpression" @blur="applyConditionChange" type="textarea" :rows="2" />
53
+            </el-form-item>
54
+            <el-form-item label="文档说明">
55
+              <el-input v-model="elementProps.documentation" @blur="applyDocumentationChange" type="textarea" :rows="2" />
56
+            </el-form-item>
57
+          </el-form>
58
+        </el-card>
59
+      </div>
60
+    </div>
61
+
62
+    <!-- 保存对话框 -->
63
+    <el-dialog v-model="saveDialogVisible" title="保存模板" width="500px">
64
+      <el-form :model="saveForm" label-width="100px">
65
+        <el-form-item label="模板名称" required>
66
+          <el-input v-model="saveForm.name" placeholder="输入模板名称" />
67
+        </el-form-item>
68
+        <el-form-item label="分类">
69
+          <el-select v-model="saveForm.category" placeholder="选择分类" style="width:100%">
70
+            <el-option label="审批流程" value="approval" />
71
+            <el-option label="工作流程" value="workflow" />
72
+            <el-option label="通知流程" value="notification" />
73
+            <el-option label="巡检流程" value="inspection" />
74
+          </el-select>
75
+        </el-form-item>
76
+        <el-form-item label="描述">
77
+          <el-input v-model="saveForm.description" type="textarea" :rows="3" />
78
+        </el-form-item>
79
+        <el-form-item label="标签">
80
+          <el-input v-model="saveForm.tags" placeholder="逗号分隔,如: 审批,通用" />
81
+        </el-form-item>
82
+      </el-form>
83
+      <template #footer>
84
+        <el-button @click="saveDialogVisible = false">取消</el-button>
85
+        <el-button type="primary" @click="confirmSave" :loading="saving">保存</el-button>
86
+      </template>
87
+    </el-dialog>
88
+
89
+    <!-- 导入对话框 -->
90
+    <el-dialog v-model="importDialogVisible" title="导入 BPMN XML" width="600px">
91
+      <el-input v-model="importXml" type="textarea" :rows="15" placeholder="粘贴 BPMN XML 内容" />
92
+      <template #footer>
93
+        <el-button @click="importDialogVisible = false">取消</el-button>
94
+        <el-button type="primary" @click="confirmImport">导入</el-button>
95
+      </template>
96
+    </el-dialog>
97
+  </div>
98
+</template>
99
+
100
+<script setup lang="ts">
101
+import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
102
+import { useRoute, useRouter } from 'vue-router'
103
+import { ElMessage } from 'element-plus'
104
+import {
105
+  Document, FolderOpened, Download, Check,
106
+  RefreshLeft, RefreshRight, ZoomIn, ZoomOut, FullScreen
107
+} from '@element-plus/icons-vue'
108
+import { createTemplate, getTemplate, updateTemplate, type ProcessTemplate } from '@/api/bpmApi'
109
+
110
+const route = useRoute()
111
+const router = useRouter()
112
+
113
+// Refs
114
+const canvasRef = ref<HTMLElement>()
115
+const bpmnCanvas = ref<HTMLElement>()
116
+
117
+// State
118
+const currentZoom = ref(1)
119
+const selectedElement = ref<any>(null)
120
+const saveDialogVisible = ref(false)
121
+const importDialogVisible = ref(false)
122
+const importXml = ref('')
123
+const saving = ref(false)
124
+let viewer: any = null
125
+let modeler: any = null
126
+let commandStack: any = null
127
+
128
+const elementProps = reactive({
129
+  name: '',
130
+  assignee: '',
131
+  candidateGroups: '',
132
+  conditionExpression: '',
133
+  documentation: ''
134
+})
135
+
136
+const saveForm = reactive({
137
+  id: undefined as number | undefined,
138
+  name: '',
139
+  category: 'approval',
140
+  description: '',
141
+  tags: ''
142
+})
143
+
144
+// Computed
145
+const isUserTask = computed(() => selectedElement.value?.type === 'bpmn:UserTask')
146
+const isSequenceFlow = computed(() => selectedElement.value?.type === 'bpmn:SequenceFlow')
147
+
148
+const elementTypeLabel = computed(() => {
149
+  if (!selectedElement.value) return ''
150
+  const typeMap: Record<string, string> = {
151
+    'bpmn:StartEvent': '开始事件',
152
+    'bpmn:EndEvent': '结束事件',
153
+    'bpmn:UserTask': '用户任务',
154
+    'bpmn:ServiceTask': '服务任务',
155
+    'bpmn:ExclusiveGateway': '排他网关',
156
+    'bpmn:ParallelGateway': '并行网关',
157
+    'bpmn:InclusiveGateway': '包容网关',
158
+    'bpmn:SequenceFlow': '连接线',
159
+    'bpmn:Task': '任务',
160
+    'bpmn:SubProcess': '子流程',
161
+    'bpmn:IntermediateThrowEvent': '中间事件',
162
+    'bpmn:IntermediateCatchEvent': '中间捕获事件'
163
+  }
164
+  return typeMap[selectedElement.value.type] || selectedElement.value.type
165
+})
166
+
167
+// 默认 BPMN 模板
168
+const DEFAULT_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
169
+<definitions xmlns="http://www.omg.org/spec/BPMN20"
170
+             xmlns:bpmndi="http://www.omg.org/spec/BPMN20/di"
171
+             xmlns:dc="http://www.omg.org/spec/DD/20100524/DI"
172
+             xmlns:di="http://www.omg.org/spec/DD/20100524/DC"
173
+             id="Definitions_1"
174
+             targetNamespace="http://water.com/bpm">
175
+  <process id="Process_1" name="新建流程" isExecutable="true">
176
+    <startEvent id="StartEvent_1" name="开始"/>
177
+  </process>
178
+  <bpmndi:BPMNDiagram id="BPMNDiagram_1">
179
+    <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1">
180
+      <bpmndi:BPMNShape id="StartEvent_1_di" bpmnElement="StartEvent_1">
181
+        <dc:Bounds x="180" y="160" width="36" height="36"/>
182
+        <bpmndi:BPMNLabel>
183
+          <dc:Bounds x="187" y="203" width="22" height="14"/>
184
+        </bpmndi:BPMNLabel>
185
+      </bpmndi:BPMNShape>
186
+    </bpmndi:BPMNPlane>
187
+  </bpmndi:BPMNDiagram>
188
+</definitions>`
189
+
190
+onMounted(async () => {
191
+  await initModeler()
192
+  // 如果有模板 ID,加载模板
193
+  const templateId = route.query.id
194
+  if (templateId) {
195
+    await loadTemplate(Number(templateId))
196
+  }
197
+})
198
+
199
+onBeforeUnmount(() => {
200
+  if (modeler) {
201
+    modeler.destroy()
202
+  }
203
+})
204
+
205
+/** 初始化 BPMN.js Modeler(CDN 加载) */
206
+async function initModeler() {
207
+  // 动态加载 bpmn-js CDN
208
+  await loadBpmnJs()
209
+
210
+  if (!bpmnCanvas.value) return
211
+
212
+  const BpmnModeler = (window as any).BpmnJS
213
+  if (!BpmnModeler) {
214
+    // CDN 加载失败时使用 fallback 简单画布
215
+    ElMessage.warning('BPMN.js CDN 加载失败,使用简化模式')
216
+    initFallbackCanvas()
217
+    return
218
+  }
219
+
220
+  modeler = new BpmnModeler({
221
+    container: bpmnCanvas.value,
222
+    keyboard: { bindTo: document },
223
+    additionalModules: [],
224
+    moddleExtensions: {}
225
+  })
226
+
227
+  commandStack = modeler.get('commandStack')
228
+
229
+  // 监听选中事件
230
+  const selection = modeler.get('selection')
231
+  const eventBus = modeler.get('eventBus')
232
+
233
+  eventBus.on('selection.changed', (e: any) => {
234
+    const selected = e.newSelection
235
+    if (selected.length === 1) {
236
+      selectedElement.value = selected[0]
237
+      loadElementProps(selected[0])
238
+    } else {
239
+      selectedElement.value = null
240
+    }
241
+  })
242
+
243
+  await modeler.importXML(DEFAULT_BPMN)
244
+  const canvas = modeler.get('canvas')
245
+  canvas.zoom('fit-viewport')
246
+}
247
+
248
+/** 加载 bpmn-js CDN */
249
+function loadBpmnJs(): Promise<void> {
250
+  return new Promise((resolve) => {
251
+    if ((window as any).BpmnJS) {
252
+      resolve()
253
+      return
254
+    }
255
+    // 加载 bpmn-js 独立版本
256
+    const script = document.createElement('script')
257
+    script.src = 'https://unpkg.com/bpmn-js@17.11.1/dist/bpmn-modeler.production.min.js'
258
+    script.onload = () => resolve()
259
+    script.onerror = () => resolve() // 允许 fallback
260
+    document.head.appendChild(script)
261
+
262
+    // 加载 bpmn-js CSS
263
+    const link = document.createElement('link')
264
+    link.rel = 'stylesheet'
265
+    link.href = 'https://unpkg.com/bpmn-js@17.11.1/dist/assets/diagram-js.css'
266
+    document.head.appendChild(link)
267
+
268
+    const link2 = document.createElement('link')
269
+    link2.rel = 'stylesheet'
270
+    link2.href = 'https://unpkg.com/bpmn-js@17.11.1/dist/assets/bpmn-js.css'
271
+    document.head.appendChild(link2)
272
+
273
+    const link3 = document.createElement('link')
274
+    link3.rel = 'stylesheet'
275
+    link3.href = 'https://unpkg.com/bpmn-js@17.11.1/dist/assets/bpmn-font/css/bpmn-embedded.css'
276
+    document.head.appendChild(link3)
277
+  })
278
+}
279
+
280
+/** Fallback: 简化 SVG 画布(CDN 不可用时) */
281
+function initFallbackCanvas() {
282
+  if (!bpmnCanvas.value) return
283
+  bpmnCanvas.value.innerHTML = `
284
+    <div style="padding:40px;text-align:center;color:#999;">
285
+      <h3>BPMN 设计器(简化模式)</h3>
286
+      <p>CDN 不可用,请确保网络连接正常。</p>
287
+      <p>您仍可通过导入/导出 XML 来编辑流程模板。</p>
288
+      <textarea id="fallback-xml" style="width:100%;height:300px;margin-top:20px;font-family:monospace;" placeholder="在此粘贴或编辑 BPMN XML"></textarea>
289
+    </div>
290
+  `
291
+}
292
+
293
+/** 加载元素属性 */
294
+function loadElementProps(element: any) {
295
+  const bo = element.businessObject
296
+  elementProps.name = bo.name || ''
297
+  elementProps.assignee = bo.assignee || ''
298
+  elementProps.candidateGroups = bo.candidateGroups || ''
299
+  elementProps.conditionExpression = bo.conditionExpression?.body || ''
300
+  elementProps.documentation = bo.documentation?.[0]?.text || ''
301
+}
302
+
303
+/** 应用名称变更 */
304
+function applyNameChange() {
305
+  if (!selectedElement.value || !modeler) return
306
+  const modeling = modeler.get('modeling')
307
+  modeling.updateProperties(selectedElement.value, { name: elementProps.name })
308
+}
309
+
310
+/** 应用审批人变更 */
311
+function applyAssigneeChange() {
312
+  if (!selectedElement.value || !modeler) return
313
+  const modeling = modeler.get('modeling')
314
+  modeling.updateProperties(selectedElement.value, { assignee: elementProps.assignee })
315
+}
316
+
317
+/** 应用候选组变更 */
318
+function applyCandidateGroupsChange() {
319
+  if (!selectedElement.value || !modeler) return
320
+  const modeling = modeler.get('modeling')
321
+  modeling.updateProperties(selectedElement.value, { candidateGroups: elementProps.candidateGroups })
322
+}
323
+
324
+/** 应用条件表达式变更 */
325
+function applyConditionChange() {
326
+  if (!selectedElement.value || !modeler) return
327
+  const moddle = modeler.get('moddle')
328
+  const modeling = modeler.get('modeling')
329
+  const condition = moddle.create('bpmn:FormalExpression', { body: elementProps.conditionExpression })
330
+  modeling.updateProperties(selectedElement.value, { conditionExpression: condition })
331
+}
332
+
333
+/** 应用文档说明变更 */
334
+function applyDocumentationChange() {
335
+  if (!selectedElement.value || !modeler) return
336
+  const moddle = modeler.get('moddle')
337
+  const modeling = modeler.get('modeling')
338
+  const doc = moddle.create('bpmn:Documentation', { text: elementProps.documentation })
339
+  modeling.updateProperties(selectedElement.value, { documentation: [doc] })
340
+}
341
+
342
+// 工具栏操作
343
+function handleNew() {
344
+  if (modeler) {
345
+    modeler.importXML(DEFAULT_BPMN)
346
+    saveForm.id = undefined
347
+    saveForm.name = ''
348
+    ElMessage.success('已创建新流程')
349
+  }
350
+}
351
+
352
+async function handleImport() {
353
+  importXml.value = ''
354
+  importDialogVisible.value = true
355
+}
356
+
357
+async function confirmImport() {
358
+  if (!importXml.value.trim()) {
359
+    ElMessage.warning('请输入 BPMN XML 内容')
360
+    return
361
+  }
362
+  try {
363
+    if (modeler) {
364
+      await modeler.importXML(importXml.value)
365
+      const canvas = modeler.get('canvas')
366
+      canvas.zoom('fit-viewport')
367
+    } else {
368
+      const textarea = document.getElementById('fallback-xml') as HTMLTextAreaElement
369
+      if (textarea) textarea.value = importXml.value
370
+    }
371
+    importDialogVisible.value = false
372
+    ElMessage.success('导入成功')
373
+  } catch (e: any) {
374
+    ElMessage.error('导入失败: ' + (e.message || 'XML 格式错误'))
375
+  }
376
+}
377
+
378
+async function handleExport() {
379
+  try {
380
+    if (modeler) {
381
+      const { xml } = await modeler.saveXML({ format: true })
382
+      downloadXml(xml)
383
+    } else {
384
+      const textarea = document.getElementById('fallback-xml') as HTMLTextAreaElement
385
+      if (textarea && textarea.value) {
386
+        downloadXml(textarea.value)
387
+      }
388
+    }
389
+    ElMessage.success('导出成功')
390
+  } catch (e: any) {
391
+    ElMessage.error('导出失败')
392
+  }
393
+}
394
+
395
+function downloadXml(xml: string) {
396
+  const blob = new Blob([xml], { type: 'application/xml' })
397
+  const url = URL.createObjectURL(blob)
398
+  const a = document.createElement('a')
399
+  a.href = url
400
+  a.download = `${saveForm.name || 'process'}.bpmn`
401
+  a.click()
402
+  URL.revokeObjectURL(url)
403
+}
404
+
405
+function handleSave() {
406
+  saveDialogVisible.value = true
407
+}
408
+
409
+async function confirmSave() {
410
+  if (!saveForm.name.trim()) {
411
+    ElMessage.warning('请输入模板名称')
412
+    return
413
+  }
414
+  saving.value = true
415
+  try {
416
+    let xml = ''
417
+    if (modeler) {
418
+      const result = await modeler.saveXML({ format: true })
419
+      xml = result.xml
420
+    } else {
421
+      const textarea = document.getElementById('fallback-xml') as HTMLTextAreaElement
422
+      xml = textarea?.value || DEFAULT_BPMN
423
+    }
424
+
425
+    const data: ProcessTemplate = {
426
+      name: saveForm.name,
427
+      description: saveForm.description,
428
+      bpmnXml: xml,
429
+      category: saveForm.category,
430
+      tags: saveForm.tags
431
+    }
432
+
433
+    if (saveForm.id) {
434
+      await updateTemplate(saveForm.id, data)
435
+      ElMessage.success('模板更新成功')
436
+    } else {
437
+      await createTemplate(data)
438
+      ElMessage.success('模板创建成功')
439
+    }
440
+    saveDialogVisible.value = false
441
+  } catch (e: any) {
442
+    ElMessage.error('保存失败: ' + (e.message || ''))
443
+  } finally {
444
+    saving.value = false
445
+  }
446
+}
447
+
448
+function handleUndo() {
449
+  if (commandStack) {
450
+    try { commandStack.undo() } catch {}
451
+  }
452
+}
453
+
454
+function handleRedo() {
455
+  if (commandStack) {
456
+    try { commandStack.redo() } catch {}
457
+  }
458
+}
459
+
460
+function handleZoomIn() {
461
+  if (!modeler) return
462
+  currentZoom.value = Math.min(currentZoom.value + 0.1, 3)
463
+  modeler.get('canvas').zoom(currentZoom.value)
464
+}
465
+
466
+function handleZoomOut() {
467
+  if (!modeler) return
468
+  currentZoom.value = Math.max(currentZoom.value - 0.1, 0.2)
469
+  modeler.get('canvas').zoom(currentZoom.value)
470
+}
471
+
472
+function handleResetZoom() {
473
+  if (!modeler) return
474
+  currentZoom.value = 1
475
+  modeler.get('canvas').zoom('fit-viewport')
476
+}
477
+
478
+/** 加载已有模板 */
479
+async function loadTemplate(id: number) {
480
+  try {
481
+    const res: any = await getTemplate(id)
482
+    const template = res.data
483
+    if (template) {
484
+      saveForm.id = template.id
485
+      saveForm.name = template.name
486
+      saveForm.category = template.category || 'approval'
487
+      saveForm.description = template.description || ''
488
+      saveForm.tags = template.tags || ''
489
+      if (template.bpmnXml && modeler) {
490
+        await modeler.importXML(template.bpmnXml)
491
+        const canvas = modeler.get('canvas')
492
+        canvas.zoom('fit-viewport')
493
+      }
494
+    }
495
+  } catch (e: any) {
496
+    ElMessage.error('加载模板失败')
497
+  }
498
+}
499
+</script>
500
+
501
+<style scoped lang="scss">
502
+.bpmn-designer {
503
+  height: 100%;
504
+  display: flex;
505
+  flex-direction: column;
506
+  background: #f5f5f5;
507
+}
508
+
509
+.toolbar {
510
+  padding: 10px 16px;
511
+  background: #fff;
512
+  border-bottom: 1px solid #e4e7ed;
513
+  display: flex;
514
+  align-items: center;
515
+  gap: 12px;
516
+  flex-shrink: 0;
517
+
518
+  .zoom-label {
519
+    font-size: 13px;
520
+    color: #606266;
521
+    min-width: 50px;
522
+    text-align: center;
523
+  }
524
+}
525
+
526
+.designer-main {
527
+  flex: 1;
528
+  display: flex;
529
+  overflow: hidden;
530
+}
531
+
532
+.canvas-container {
533
+  flex: 1;
534
+  position: relative;
535
+  overflow: hidden;
536
+
537
+  .canvas {
538
+    width: 100%;
539
+    height: 100%;
540
+  }
541
+}
542
+
543
+.properties-panel {
544
+  width: 320px;
545
+  border-left: 1px solid #e4e7ed;
546
+  background: #fff;
547
+  overflow-y: auto;
548
+  flex-shrink: 0;
549
+
550
+  .panel-header {
551
+    display: flex;
552
+    align-items: center;
553
+    justify-content: space-between;
554
+    font-weight: 600;
555
+  }
556
+
557
+  :deep(.el-card__body) {
558
+    padding: 12px;
559
+  }
560
+}
561
+</style>

+ 339
- 0
frontend/src/views/bpm/TemplateListView.vue Ver fichero

@@ -0,0 +1,339 @@
1
+<template>
2
+  <div class="template-list">
3
+    <!-- 顶部操作栏 -->
4
+    <div class="page-header">
5
+      <div class="header-left">
6
+        <h2>流程模板管理</h2>
7
+        <el-tag type="success" size="small">BPMN.js</el-tag>
8
+      </div>
9
+      <el-button type="primary" :icon="Plus" @click="handleCreate">新建模板</el-button>
10
+    </div>
11
+
12
+    <!-- 搜索/筛选栏 -->
13
+    <el-card shadow="never" class="filter-card">
14
+      <el-form :inline="true" :model="queryForm">
15
+        <el-form-item label="关键字">
16
+          <el-input v-model="queryForm.keyword" placeholder="搜索模板名称/描述" clearable @keyup.enter="loadData" :prefix-icon="Search" />
17
+        </el-form-item>
18
+        <el-form-item label="分类">
19
+          <el-select v-model="queryForm.category" placeholder="全部分类" clearable style="width:160px">
20
+            <el-option label="审批流程" value="approval" />
21
+            <el-option label="工作流程" value="workflow" />
22
+            <el-option label="通知流程" value="notification" />
23
+            <el-option label="巡检流程" value="inspection" />
24
+          </el-select>
25
+        </el-form-item>
26
+        <el-form-item label="状态">
27
+          <el-select v-model="queryForm.status" placeholder="全部状态" clearable style="width:130px">
28
+            <el-option label="草稿" :value="0" />
29
+            <el-option label="已发布" :value="1" />
30
+            <el-option label="已停用" :value="2" />
31
+          </el-select>
32
+        </el-form-item>
33
+        <el-form-item>
34
+          <el-button type="primary" @click="loadData">查询</el-button>
35
+          <el-button @click="resetQuery">重置</el-button>
36
+        </el-form-item>
37
+      </el-form>
38
+    </el-card>
39
+
40
+    <!-- 热门模板 -->
41
+    <el-card shadow="never" class="hot-section" v-if="hotList.length > 0">
42
+      <template #header>
43
+        <span>🔥 热门模板</span>
44
+      </template>
45
+      <div class="hot-grid">
46
+        <div v-for="item in hotList" :key="item.id" class="hot-card" @click="handleEdit(item)">
47
+          <div class="hot-name">{{ item.name }}</div>
48
+          <div class="hot-meta">
49
+            <el-tag size="small">{{ getCategoryLabel(item.category) }}</el-tag>
50
+            <span class="use-count">使用 {{ item.useCount }} 次</span>
51
+          </div>
52
+        </div>
53
+      </div>
54
+    </el-card>
55
+
56
+    <!-- 模板列表 -->
57
+    <el-card shadow="never" class="table-card">
58
+      <el-table :data="tableData" v-loading="loading" stripe style="width:100%" @row-click="handleEdit">
59
+        <el-table-column prop="id" label="ID" width="70" />
60
+        <el-table-column prop="name" label="模板名称" min-width="180">
61
+          <template #default="{ row }">
62
+            <span class="template-name">{{ row.name }}</span>
63
+          </template>
64
+        </el-table-column>
65
+        <el-table-column prop="category" label="分类" width="110">
66
+          <template #default="{ row }">
67
+            <el-tag size="small">{{ getCategoryLabel(row.category) }}</el-tag>
68
+          </template>
69
+        </el-table-column>
70
+        <el-table-column prop="status" label="状态" width="90">
71
+          <template #default="{ row }">
72
+            <el-tag :type="getStatusType(row.status)" size="small">{{ getStatusLabel(row.status) }}</el-tag>
73
+          </template>
74
+        </el-table-column>
75
+        <el-table-column prop="useCount" label="使用次数" width="100" align="center" />
76
+        <el-table-column prop="tags" label="标签" width="160">
77
+          <template #default="{ row }">
78
+            <el-tag v-for="tag in (row.tags || '').split(',').filter(Boolean)" :key="tag" size="small" type="info" class="tag-item">{{ tag }}</el-tag>
79
+          </template>
80
+        </el-table-column>
81
+        <el-table-column prop="creatorName" label="创建人" width="110" />
82
+        <el-table-column prop="createdAt" label="创建时间" width="170" />
83
+        <el-table-column label="操作" width="280" fixed="right">
84
+          <template #default="{ row }">
85
+            <el-button size="small" type="primary" @click.stop="handleOpenDesigner(row)">设计</el-button>
86
+            <el-button size="small" @click.stop="handleCopy(row)">复制</el-button>
87
+            <el-button size="small" v-if="row.status !== 1" type="success" @click.stop="handlePublish(row)">发布</el-button>
88
+            <el-button size="small" v-if="row.status === 1" type="warning" @click.stop="handleDisable(row)">停用</el-button>
89
+            <el-popconfirm title="确定删除该模板?" @confirm="handleDelete(row)">
90
+              <template #reference>
91
+                <el-button size="small" type="danger" @click.stop>删除</el-button>
92
+              </template>
93
+            </el-popconfirm>
94
+          </template>
95
+        </el-table-column>
96
+      </el-table>
97
+      <div class="pagination-wrapper">
98
+        <el-pagination
99
+          v-model:current-page="queryForm.page"
100
+          v-model:page-size="queryForm.size"
101
+          :page-sizes="[10, 20, 50]"
102
+          :total="total"
103
+          layout="total, sizes, prev, pager, next, jumper"
104
+          @size-change="loadData"
105
+          @current-change="loadData"
106
+        />
107
+      </div>
108
+    </el-card>
109
+  </div>
110
+</template>
111
+
112
+<script setup lang="ts">
113
+import { ref, reactive, onMounted } from 'vue'
114
+import { useRouter } from 'vue-router'
115
+import { ElMessage } from 'element-plus'
116
+import { Plus, Search } from '@element-plus/icons-vue'
117
+import {
118
+  listTemplates, deleteTemplate, publishTemplate,
119
+  disableTemplate, copyTemplate, hotTemplates,
120
+  type ProcessTemplate, type TemplateQuery
121
+} from '@/api/bpmApi'
122
+
123
+const router = useRouter()
124
+
125
+const loading = ref(false)
126
+const tableData = ref<ProcessTemplate[]>([])
127
+const hotList = ref<ProcessTemplate[]>([])
128
+const total = ref(0)
129
+
130
+const queryForm = reactive<TemplateQuery>({
131
+  keyword: '',
132
+  category: '',
133
+  status: undefined,
134
+  page: 1,
135
+  size: 10
136
+})
137
+
138
+onMounted(() => {
139
+  loadData()
140
+  loadHotTemplates()
141
+})
142
+
143
+async function loadData() {
144
+  loading.value = true
145
+  try {
146
+    const res: any = await listTemplates(queryForm)
147
+    const page = res.data
148
+    tableData.value = page.records || []
149
+    total.value = page.total || 0
150
+  } catch (e) {
151
+    ElMessage.error('加载模板列表失败')
152
+  } finally {
153
+    loading.value = false
154
+  }
155
+}
156
+
157
+async function loadHotTemplates() {
158
+  try {
159
+    const res: any = await hotTemplates(6)
160
+    hotList.value = res.data || []
161
+  } catch {}
162
+}
163
+
164
+function resetQuery() {
165
+  queryForm.keyword = ''
166
+  queryForm.category = ''
167
+  queryForm.status = undefined
168
+  queryForm.page = 1
169
+  loadData()
170
+}
171
+
172
+function handleCreate() {
173
+  router.push({ name: 'bpmDesigner' })
174
+}
175
+
176
+function handleEdit(row: ProcessTemplate) {
177
+  router.push({ name: 'bpmDesigner', query: { id: String(row.id) } })
178
+}
179
+
180
+function handleOpenDesigner(row: ProcessTemplate) {
181
+  router.push({ name: 'bpmDesigner', query: { id: String(row.id) } })
182
+}
183
+
184
+async function handleCopy(row: ProcessTemplate) {
185
+  try {
186
+    await copyTemplate(row.id!)
187
+    ElMessage.success('复制成功')
188
+    loadData()
189
+  } catch {
190
+    ElMessage.error('复制失败')
191
+  }
192
+}
193
+
194
+async function handlePublish(row: ProcessTemplate) {
195
+  try {
196
+    await publishTemplate(row.id!)
197
+    ElMessage.success('发布成功')
198
+    loadData()
199
+  } catch {
200
+    ElMessage.error('发布失败')
201
+  }
202
+}
203
+
204
+async function handleDisable(row: ProcessTemplate) {
205
+  try {
206
+    await disableTemplate(row.id!)
207
+    ElMessage.success('已停用')
208
+    loadData()
209
+  } catch {
210
+    ElMessage.error('停用失败')
211
+  }
212
+}
213
+
214
+async function handleDelete(row: ProcessTemplate) {
215
+  try {
216
+    await deleteTemplate(row.id!)
217
+    ElMessage.success('删除成功')
218
+    loadData()
219
+  } catch {
220
+    ElMessage.error('删除失败')
221
+  }
222
+}
223
+
224
+function getCategoryLabel(cat?: string) {
225
+  const map: Record<string, string> = {
226
+    approval: '审批',
227
+    workflow: '工作流',
228
+    notification: '通知',
229
+    inspection: '巡检'
230
+  }
231
+  return map[cat || ''] || cat || '-'
232
+}
233
+
234
+function getStatusLabel(status?: number) {
235
+  const map: Record<number, string> = { 0: '草稿', 1: '已发布', 2: '已停用' }
236
+  return map[status ?? 0] || '-'
237
+}
238
+
239
+function getStatusType(status?: number): '' | 'success' | 'warning' | 'danger' | 'info' {
240
+  const map: Record<number, '' | 'success' | 'warning' | 'danger' | 'info'> = { 0: 'info', 1: 'success', 2: 'warning' }
241
+  return map[status ?? 0] || 'info'
242
+}
243
+</script>
244
+
245
+<style scoped lang="scss">
246
+.template-list {
247
+  padding: 20px;
248
+}
249
+
250
+.page-header {
251
+  display: flex;
252
+  align-items: center;
253
+  justify-content: space-between;
254
+  margin-bottom: 16px;
255
+
256
+  .header-left {
257
+    display: flex;
258
+    align-items: center;
259
+    gap: 10px;
260
+
261
+    h2 {
262
+      margin: 0;
263
+      font-size: 20px;
264
+      color: #303133;
265
+    }
266
+  }
267
+}
268
+
269
+.filter-card {
270
+  margin-bottom: 16px;
271
+
272
+  :deep(.el-card__body) {
273
+    padding-bottom: 0;
274
+  }
275
+}
276
+
277
+.hot-section {
278
+  margin-bottom: 16px;
279
+
280
+  .hot-grid {
281
+    display: grid;
282
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
283
+    gap: 12px;
284
+  }
285
+
286
+  .hot-card {
287
+    padding: 12px;
288
+    border: 1px solid #e4e7ed;
289
+    border-radius: 6px;
290
+    cursor: pointer;
291
+    transition: all 0.2s;
292
+
293
+    &:hover {
294
+      border-color: #409eff;
295
+      box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
296
+    }
297
+
298
+    .hot-name {
299
+      font-weight: 600;
300
+      margin-bottom: 6px;
301
+      font-size: 14px;
302
+    }
303
+
304
+    .hot-meta {
305
+      display: flex;
306
+      align-items: center;
307
+      gap: 8px;
308
+
309
+      .use-count {
310
+        font-size: 12px;
311
+        color: #909399;
312
+      }
313
+    }
314
+  }
315
+}
316
+
317
+.table-card {
318
+  .template-name {
319
+    font-weight: 500;
320
+    color: #303133;
321
+    cursor: pointer;
322
+
323
+    &:hover {
324
+      color: #409eff;
325
+    }
326
+  }
327
+
328
+  .tag-item {
329
+    margin-right: 4px;
330
+    margin-bottom: 2px;
331
+  }
332
+
333
+  .pagination-wrapper {
334
+    margin-top: 16px;
335
+    display: flex;
336
+    justify-content: flex-end;
337
+  }
338
+}
339
+</style>

+ 84
- 0
wm-bpm/src/main/java/com/water/bpm/controller/ProcessTemplateController.java Ver fichero

@@ -0,0 +1,84 @@
1
+package com.water.bpm.controller;
2
+
3
+import com.baomidou.mybatisplus.core.metadata.IPage;
4
+import com.water.bpm.entity.ProcessTemplate;
5
+import com.water.bpm.entity.dto.ProcessTemplateQuery;
6
+import com.water.bpm.service.ProcessTemplateService;
7
+import com.water.common.core.result.R;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+
15
+/**
16
+ * 流程模板管理 Controller
17
+ */
18
+@Tag(name = "流程模板管理")
19
+@RestController
20
+@RequestMapping("/bpm/template")
21
+@RequiredArgsConstructor
22
+public class ProcessTemplateController {
23
+
24
+    private final ProcessTemplateService templateService;
25
+
26
+    @Operation(summary = "创建模板")
27
+    @PostMapping
28
+    public R<ProcessTemplate> create(@RequestBody ProcessTemplate template) {
29
+        return R.ok(templateService.create(template));
30
+    }
31
+
32
+    @Operation(summary = "获取模板详情")
33
+    @GetMapping("/{id}")
34
+    public R<ProcessTemplate> getById(@PathVariable Long id) {
35
+        ProcessTemplate template = templateService.getById(id);
36
+        if (template == null) {
37
+            return R.fail("模板不存在");
38
+        }
39
+        return R.ok(template);
40
+    }
41
+
42
+    @Operation(summary = "更新模板")
43
+    @PutMapping("/{id}")
44
+    public R<ProcessTemplate> update(@PathVariable Long id, @RequestBody ProcessTemplate template) {
45
+        return R.ok(templateService.update(id, template));
46
+    }
47
+
48
+    @Operation(summary = "删除模板")
49
+    @DeleteMapping("/{id}")
50
+    public R<Void> delete(@PathVariable Long id) {
51
+        templateService.delete(id);
52
+        return R.ok();
53
+    }
54
+
55
+    @Operation(summary = "分页查询模板")
56
+    @GetMapping
57
+    public R<IPage<ProcessTemplate>> page(ProcessTemplateQuery query) {
58
+        return R.ok(templateService.page(query));
59
+    }
60
+
61
+    @Operation(summary = "发布模板")
62
+    @PutMapping("/{id}/publish")
63
+    public R<ProcessTemplate> publish(@PathVariable Long id) {
64
+        return R.ok(templateService.publish(id));
65
+    }
66
+
67
+    @Operation(summary = "停用模板")
68
+    @PutMapping("/{id}/disable")
69
+    public R<ProcessTemplate> disable(@PathVariable Long id) {
70
+        return R.ok(templateService.disable(id));
71
+    }
72
+
73
+    @Operation(summary = "复制模板")
74
+    @PostMapping("/{id}/copy")
75
+    public R<ProcessTemplate> copy(@PathVariable Long id) {
76
+        return R.ok(templateService.copy(id));
77
+    }
78
+
79
+    @Operation(summary = "热门模板")
80
+    @GetMapping("/hot")
81
+    public R<List<ProcessTemplate>> hot(@RequestParam(defaultValue = "10") int limit) {
82
+        return R.ok(templateService.hotTemplates(limit));
83
+    }
84
+}

+ 46
- 0
wm-bpm/src/main/java/com/water/bpm/entity/ProcessTemplate.java Ver fichero

@@ -0,0 +1,46 @@
1
+package com.water.bpm.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+/**
9
+ * 流程模板实体
10
+ * 支持 BPMN.js 可视化设计器保存的模板
11
+ */
12
+@Data
13
+@EqualsAndHashCode(callSuper = true)
14
+@TableName("bpm_process_template")
15
+public class ProcessTemplate extends BaseEntity {
16
+
17
+    /** 模板名称 */
18
+    private String name;
19
+
20
+    /** 模板描述 */
21
+    private String description;
22
+
23
+    /** BPMN XML 内容 */
24
+    private String bpmnXml;
25
+
26
+    /** 模板分类: approval/workflow/notification/inspection 等 */
27
+    private String category;
28
+
29
+    /** 标签,逗号分隔 */
30
+    private String tags;
31
+
32
+    /** 使用次数 */
33
+    private Integer useCount;
34
+
35
+    /** 状态: 0-草稿 1-已发布 2-已停用 */
36
+    private Integer status;
37
+
38
+    /** 创建人ID */
39
+    private Long creatorId;
40
+
41
+    /** 创建人名称 */
42
+    private String creatorName;
43
+
44
+    /** 租户ID */
45
+    private String tenantId;
46
+}

+ 28
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessTemplateQuery.java Ver fichero

@@ -0,0 +1,28 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+/**
6
+ * 流程模板查询参数
7
+ */
8
+@Data
9
+public class ProcessTemplateQuery {
10
+
11
+    /** 模板名称关键字 */
12
+    private String keyword;
13
+
14
+    /** 分类 */
15
+    private String category;
16
+
17
+    /** 状态: 0-草稿 1-已发布 2-已停用 */
18
+    private Integer status;
19
+
20
+    /** 标签 */
21
+    private String tag;
22
+
23
+    /** 页码 */
24
+    private Integer page = 1;
25
+
26
+    /** 每页大小 */
27
+    private Integer size = 10;
28
+}

+ 29
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/ProcessTemplateMapper.java Ver fichero

@@ -0,0 +1,29 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.ProcessTemplate;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.util.List;
11
+
12
+/**
13
+ * 流程模板 Mapper
14
+ */
15
+@Mapper
16
+public interface ProcessTemplateMapper extends BaseMapper<ProcessTemplate> {
17
+
18
+    /**
19
+     * 获取热门模板(按使用次数倒序)
20
+     */
21
+    @Select("SELECT * FROM bpm_process_template WHERE status = 1 AND deleted = 0 ORDER BY use_count DESC LIMIT #{limit}")
22
+    List<ProcessTemplate> selectHotTemplates(@Param("limit") int limit);
23
+
24
+    /**
25
+     * 递增使用次数
26
+     */
27
+    @Update("UPDATE bpm_process_template SET use_count = use_count + 1 WHERE id = #{id}")
28
+    int incrementUseCount(@Param("id") Long id);
29
+}

+ 175
- 0
wm-bpm/src/main/java/com/water/bpm/service/ProcessTemplateService.java Ver fichero

@@ -0,0 +1,175 @@
1
+package com.water.bpm.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.bpm.entity.ProcessTemplate;
7
+import com.water.bpm.entity.dto.ProcessTemplateQuery;
8
+import com.water.bpm.mapper.ProcessTemplateMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.transaction.annotation.Transactional;
12
+import org.springframework.util.StringUtils;
13
+
14
+import java.time.LocalDateTime;
15
+import java.util.List;
16
+
17
+/**
18
+ * 流程模板 Service
19
+ * 提供模板 CRUD、发布/停用/复制/热门/分类搜索能力
20
+ */
21
+@Service
22
+@RequiredArgsConstructor
23
+public class ProcessTemplateService {
24
+
25
+    private final ProcessTemplateMapper templateMapper;
26
+
27
+    /**
28
+     * 创建模板
29
+     */
30
+    @Transactional
31
+    public ProcessTemplate create(ProcessTemplate template) {
32
+        if (template.getUseCount() == null) {
33
+            template.setUseCount(0);
34
+        }
35
+        if (template.getStatus() == null) {
36
+            template.setStatus(0); // 草稿
37
+        }
38
+        template.setCreatedAt(LocalDateTime.now());
39
+        template.setUpdatedAt(LocalDateTime.now());
40
+        templateMapper.insert(template);
41
+        return template;
42
+    }
43
+
44
+    /**
45
+     * 根据 ID 获取模板
46
+     */
47
+    public ProcessTemplate getById(Long id) {
48
+        return templateMapper.selectById(id);
49
+    }
50
+
51
+    /**
52
+     * 更新模板
53
+     */
54
+    @Transactional
55
+    public ProcessTemplate update(Long id, ProcessTemplate update) {
56
+        ProcessTemplate existing = templateMapper.selectById(id);
57
+        if (existing == null) {
58
+            throw new RuntimeException("模板不存在: " + id);
59
+        }
60
+        update.setId(id);
61
+        update.setUpdatedAt(LocalDateTime.now());
62
+        templateMapper.updateById(update);
63
+        return templateMapper.selectById(id);
64
+    }
65
+
66
+    /**
67
+     * 删除模板(逻辑删除)
68
+     */
69
+    @Transactional
70
+    public void delete(Long id) {
71
+        ProcessTemplate existing = templateMapper.selectById(id);
72
+        if (existing == null) {
73
+            throw new RuntimeException("模板不存在: " + id);
74
+        }
75
+        templateMapper.deleteById(id);
76
+    }
77
+
78
+    /**
79
+     * 分页查询模板
80
+     */
81
+    public IPage<ProcessTemplate> page(ProcessTemplateQuery query) {
82
+        Page<ProcessTemplate> page = new Page<>(query.getPage(), query.getSize());
83
+        LambdaQueryWrapper<ProcessTemplate> wrapper = new LambdaQueryWrapper<>();
84
+
85
+        if (StringUtils.hasText(query.getKeyword())) {
86
+            wrapper.and(w -> w
87
+                    .like(ProcessTemplate::getName, query.getKeyword())
88
+                    .or()
89
+                    .like(ProcessTemplate::getDescription, query.getKeyword()));
90
+        }
91
+        if (StringUtils.hasText(query.getCategory())) {
92
+            wrapper.eq(ProcessTemplate::getCategory, query.getCategory());
93
+        }
94
+        if (query.getStatus() != null) {
95
+            wrapper.eq(ProcessTemplate::getStatus, query.getStatus());
96
+        }
97
+        if (StringUtils.hasText(query.getTag())) {
98
+            wrapper.like(ProcessTemplate::getTags, query.getTag());
99
+        }
100
+
101
+        wrapper.orderByDesc(ProcessTemplate::getCreatedAt);
102
+        return templateMapper.selectPage(page, wrapper);
103
+    }
104
+
105
+    /**
106
+     * 发布模板
107
+     */
108
+    @Transactional
109
+    public ProcessTemplate publish(Long id) {
110
+        ProcessTemplate template = templateMapper.selectById(id);
111
+        if (template == null) {
112
+            throw new RuntimeException("模板不存在: " + id);
113
+        }
114
+        template.setStatus(1);
115
+        template.setUpdatedAt(LocalDateTime.now());
116
+        templateMapper.updateById(template);
117
+        return template;
118
+    }
119
+
120
+    /**
121
+     * 停用模板
122
+     */
123
+    @Transactional
124
+    public ProcessTemplate disable(Long id) {
125
+        ProcessTemplate template = templateMapper.selectById(id);
126
+        if (template == null) {
127
+            throw new RuntimeException("模板不存在: " + id);
128
+        }
129
+        template.setStatus(2);
130
+        template.setUpdatedAt(LocalDateTime.now());
131
+        templateMapper.updateById(template);
132
+        return template;
133
+    }
134
+
135
+    /**
136
+     * 复制模板
137
+     */
138
+    @Transactional
139
+    public ProcessTemplate copy(Long id) {
140
+        ProcessTemplate source = templateMapper.selectById(id);
141
+        if (source == null) {
142
+            throw new RuntimeException("模板不存在: " + id);
143
+        }
144
+        ProcessTemplate copy = new ProcessTemplate();
145
+        copy.setName(source.getName() + " (副本)");
146
+        copy.setDescription(source.getDescription());
147
+        copy.setBpmnXml(source.getBpmnXml());
148
+        copy.setCategory(source.getCategory());
149
+        copy.setTags(source.getTags());
150
+        copy.setUseCount(0);
151
+        copy.setStatus(0); // 草稿
152
+        copy.setCreatorId(source.getCreatorId());
153
+        copy.setCreatorName(source.getCreatorName());
154
+        copy.setTenantId(source.getTenantId());
155
+        copy.setCreatedAt(LocalDateTime.now());
156
+        copy.setUpdatedAt(LocalDateTime.now());
157
+        templateMapper.insert(copy);
158
+        return copy;
159
+    }
160
+
161
+    /**
162
+     * 获取热门模板
163
+     */
164
+    public List<ProcessTemplate> hotTemplates(int limit) {
165
+        return templateMapper.selectHotTemplates(limit);
166
+    }
167
+
168
+    /**
169
+     * 递增使用次数
170
+     */
171
+    @Transactional
172
+    public void incrementUseCount(Long id) {
173
+        templateMapper.incrementUseCount(id);
174
+    }
175
+}

+ 115
- 0
wm-bpm/src/main/resources/db/V_bpm_template.sql Ver fichero

@@ -0,0 +1,115 @@
1
+-- 流程模板表(BPMN.js 设计器集成)
2
+CREATE TABLE IF NOT EXISTS bpm_process_template (
3
+    id              BIGSERIAL       PRIMARY KEY,
4
+    name            VARCHAR(200)    NOT NULL,
5
+    description     TEXT,
6
+    bpmn_xml        TEXT,
7
+    category        VARCHAR(50),
8
+    tags            VARCHAR(500),
9
+    use_count       INT             DEFAULT 0,
10
+    status          SMALLINT        DEFAULT 0,      -- 0-草稿 1-已发布 2-已停用
11
+    creator_id      BIGINT,
12
+    creator_name    VARCHAR(100),
13
+    tenant_id       VARCHAR(50),
14
+    deleted         SMALLINT        DEFAULT 0,
15
+    created_at      TIMESTAMP       DEFAULT CURRENT_TIMESTAMP,
16
+    updated_at      TIMESTAMP       DEFAULT CURRENT_TIMESTAMP
17
+);
18
+
19
+-- 索引
20
+CREATE INDEX IF NOT EXISTS idx_template_category ON bpm_process_template(category);
21
+CREATE INDEX IF NOT EXISTS idx_template_status ON bpm_process_template(status);
22
+CREATE INDEX IF NOT EXISTS idx_template_use_count ON bpm_process_template(use_count DESC);
23
+
24
+-- 预置数据
25
+INSERT INTO bpm_process_template (name, description, bpmn_xml, category, tags, use_count, status, creator_name)
26
+VALUES
27
+('通用审批流程', '适用于一般性审批场景,包含发起-审批-完成三个节点',
28
+ '<?xml version="1.0" encoding="UTF-8"?>
29
+<definitions xmlns="http://www.omg.org/spec/BPMN20" xmlns:bpmndi="http://www.omg.org/spec/BPMN20/di" xmlns:dc="http://www.omg.org/spec/DD/20100524/DI" xmlns:di="http://www.omg.org/spec/DD/20100524/DC" id="Definitions_1" targetNamespace="http://water.com/bpm">
30
+  <process id="Process_1" name="通用审批流程" isExecutable="true">
31
+    <startEvent id="StartEvent_1" name="开始"/>
32
+    <userTask id="UserTask_1" name="审批节点"/>
33
+    <endEvent id="EndEvent_1" name="结束"/>
34
+    <sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="UserTask_1"/>
35
+    <sequenceFlow id="Flow_2" sourceRef="UserTask_1" targetRef="EndEvent_1"/>
36
+  </process>
37
+</definitions>',
38
+ 'approval', '审批,通用,基础', 15, 1, '系统管理员'),
39
+
40
+('设备巡检流程', '适用于设备巡检任务的分派与执行',
41
+ '<?xml version="1.0" encoding="UTF-8"?>
42
+<definitions xmlns="http://www.omg.org/spec/BPMN20" id="Definitions_2" targetNamespace="http://water.com/bpm">
43
+  <process id="Process_2" name="设备巡检流程" isExecutable="true">
44
+    <startEvent id="StartEvent_1" name="创建巡检任务"/>
45
+    <userTask id="UserTask_1" name="分派巡检员"/>
46
+    <userTask id="UserTask_2" name="执行巡检"/>
47
+    <exclusiveGateway id="Gateway_1" name="巡检结果"/>
48
+    <userTask id="UserTask_3" name="异常处理"/>
49
+    <endEvent id="EndEvent_1" name="完成"/>
50
+    <sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="UserTask_1"/>
51
+    <sequenceFlow id="Flow_2" sourceRef="UserTask_1" targetRef="UserTask_2"/>
52
+    <sequenceFlow id="Flow_3" sourceRef="UserTask_2" targetRef="Gateway_1"/>
53
+    <sequenceFlow id="Flow_4" sourceRef="Gateway_1" targetRef="EndEvent_1"/>
54
+    <sequenceFlow id="Flow_5" sourceRef="Gateway_1" targetRef="UserTask_3"/>
55
+    <sequenceFlow id="Flow_6" sourceRef="UserTask_3" targetRef="EndEvent_1"/>
56
+  </process>
57
+</definitions>',
58
+ 'inspection', '巡检,设备,外勤', 8, 1, '系统管理员'),
59
+
60
+('水质异常处理流程', '水质监测异常时的应急处理流程',
61
+ '<?xml version="1.0" encoding="UTF-8"?>
62
+<definitions xmlns="http://www.omg.org/spec/BPMN20" id="Definitions_3" targetNamespace="http://water.com/bpm">
63
+  <process id="Process_3" name="水质异常处理流程" isExecutable="true">
64
+    <startEvent id="StartEvent_1" name="异常报警"/>
65
+    <userTask id="UserTask_1" name="值班确认"/>
66
+    <userTask id="UserTask_2" name="现场取样"/>
67
+    <userTask id="UserTask_3" name="化验分析"/>
68
+    <exclusiveGateway id="Gateway_1" name="是否超标"/>
69
+    <userTask id="UserTask_4" name="应急处置"/>
70
+    <userTask id="UserTask_5" name="上报主管部门"/>
71
+    <endEvent id="EndEvent_1" name="归档"/>
72
+    <sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="UserTask_1"/>
73
+    <sequenceFlow id="Flow_2" sourceRef="UserTask_1" targetRef="UserTask_2"/>
74
+    <sequenceFlow id="Flow_3" sourceRef="UserTask_2" targetRef="UserTask_3"/>
75
+    <sequenceFlow id="Flow_4" sourceRef="UserTask_3" targetRef="Gateway_1"/>
76
+    <sequenceFlow id="Flow_5" sourceRef="Gateway_1" targetRef="EndEvent_1"/>
77
+    <sequenceFlow id="Flow_6" sourceRef="Gateway_1" targetRef="UserTask_4"/>
78
+    <sequenceFlow id="Flow_7" sourceRef="UserTask_4" targetRef="UserTask_5"/>
79
+    <sequenceFlow id="Flow_8" sourceRef="UserTask_5" targetRef="EndEvent_1"/>
80
+  </process>
81
+</definitions>',
82
+ 'workflow', '水质,应急,监测', 12, 1, '系统管理员'),
83
+
84
+('请假审批流程', '员工请假多级审批流程',
85
+ '<?xml version="1.0" encoding="UTF-8"?>
86
+<definitions xmlns="http://www.omg.org/spec/BPMN20" id="Definitions_4" targetNamespace="http://water.com/bpm">
87
+  <process id="Process_4" name="请假审批流程" isExecutable="true">
88
+    <startEvent id="StartEvent_1" name="提交申请"/>
89
+    <userTask id="UserTask_1" name="直属主管审批"/>
90
+    <exclusiveGateway id="Gateway_1" name="天数判断"/>
91
+    <userTask id="UserTask_2" name="部门经理审批"/>
92
+    <endEvent id="EndEvent_1" name="审批完成"/>
93
+    <sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="UserTask_1"/>
94
+    <sequenceFlow id="Flow_2" sourceRef="UserTask_1" targetRef="Gateway_1"/>
95
+    <sequenceFlow id="Flow_3" sourceRef="Gateway_1" targetRef="EndEvent_1"/>
96
+    <sequenceFlow id="Flow_4" sourceRef="Gateway_1" targetRef="UserTask_2"/>
97
+    <sequenceFlow id="Flow_5" sourceRef="UserTask_2" targetRef="EndEvent_1"/>
98
+  </process>
99
+</definitions>',
100
+ 'approval', '请假,人事,审批', 20, 1, '系统管理员'),
101
+
102
+('通知发布流程', '内部通知/公告的审批与发布',
103
+ '<?xml version="1.0" encoding="UTF-8"?>
104
+<definitions xmlns="http://www.omg.org/spec/BPMN20" id="Definitions_5" targetNamespace="http://water.com/bpm">
105
+  <process id="Process_5" name="通知发布流程" isExecutable="true">
106
+    <startEvent id="StartEvent_1" name="拟稿"/>
107
+    <userTask id="UserTask_1" name="主管审核"/>
108
+    <userTask id="UserTask_2" name="发布通知"/>
109
+    <endEvent id="EndEvent_1" name="已发布"/>
110
+    <sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="UserTask_1"/>
111
+    <sequenceFlow id="Flow_2" sourceRef="UserTask_1" targetRef="UserTask_2"/>
112
+    <sequenceFlow id="Flow_3" sourceRef="UserTask_2" targetRef="EndEvent_1"/>
113
+  </process>
114
+</definitions>',
115
+ 'notification', '通知,公告,发布', 5, 1, '系统管理员');

+ 175
- 0
wm-bpm/src/test/java/com/water/bpm/service/ProcessTemplateServiceTest.java Ver fichero

@@ -0,0 +1,175 @@
1
+package com.water.bpm.service;
2
+
3
+import com.water.bpm.entity.ProcessTemplate;
4
+import com.water.bpm.entity.dto.ProcessTemplateQuery;
5
+import com.water.bpm.mapper.ProcessTemplateMapper;
6
+import com.baomidou.mybatisplus.core.metadata.IPage;
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.DisplayName;
9
+import org.junit.jupiter.api.Test;
10
+import org.junit.jupiter.api.extension.ExtendWith;
11
+import org.mockito.InjectMocks;
12
+import org.mockito.Mock;
13
+import org.mockito.junit.jupiter.MockitoExtension;
14
+
15
+import java.util.Arrays;
16
+import java.util.List;
17
+
18
+import static org.junit.jupiter.api.Assertions.*;
19
+import static org.mockito.ArgumentMatchers.any;
20
+import static org.mockito.Mockito.*;
21
+
22
+/**
23
+ * ProcessTemplateService 单元测试
24
+ */
25
+@ExtendWith(MockitoExtension.class)
26
+@DisplayName("流程模板服务测试")
27
+class ProcessTemplateServiceTest {
28
+
29
+    @Mock
30
+    private ProcessTemplateMapper templateMapper;
31
+
32
+    @InjectMocks
33
+    private ProcessTemplateService templateService;
34
+
35
+    private ProcessTemplate sampleTemplate;
36
+
37
+    @BeforeEach
38
+    void setUp() {
39
+        sampleTemplate = new ProcessTemplate();
40
+        sampleTemplate.setId(1L);
41
+        sampleTemplate.setName("测试模板");
42
+        sampleTemplate.setDescription("用于单元测试的模板");
43
+        sampleTemplate.setBpmnXml("<?xml version=\"1.0\"?><definitions/>");
44
+        sampleTemplate.setCategory("approval");
45
+        sampleTemplate.setTags("测试,审批");
46
+        sampleTemplate.setUseCount(0);
47
+        sampleTemplate.setStatus(0);
48
+    }
49
+
50
+    @Test
51
+    @DisplayName("创建模板 - 成功")
52
+    void create_success() {
53
+        when(templateMapper.insert(any(ProcessTemplate.class))).thenReturn(1);
54
+
55
+        ProcessTemplate result = templateService.create(sampleTemplate);
56
+
57
+        assertNotNull(result);
58
+        assertEquals("测试模板", result.getName());
59
+        assertEquals(0, result.getUseCount());
60
+        assertEquals(0, result.getStatus());
61
+        verify(templateMapper, times(1)).insert(any(ProcessTemplate.class));
62
+    }
63
+
64
+    @Test
65
+    @DisplayName("获取模板详情 - 存在")
66
+    void getById_exists() {
67
+        when(templateMapper.selectById(1L)).thenReturn(sampleTemplate);
68
+
69
+        ProcessTemplate result = templateService.getById(1L);
70
+
71
+        assertNotNull(result);
72
+        assertEquals(1L, result.getId());
73
+        assertEquals("测试模板", result.getName());
74
+    }
75
+
76
+    @Test
77
+    @DisplayName("更新模板 - 成功")
78
+    void update_success() {
79
+        when(templateMapper.selectById(1L)).thenReturn(sampleTemplate);
80
+        when(templateMapper.updateById(any(ProcessTemplate.class))).thenReturn(1);
81
+        when(templateMapper.selectById(1L)).thenReturn(sampleTemplate);
82
+
83
+        ProcessTemplate update = new ProcessTemplate();
84
+        update.setName("更新后的模板");
85
+
86
+        ProcessTemplate result = templateService.update(1L, update);
87
+
88
+        assertNotNull(result);
89
+        verify(templateMapper, times(1)).updateById(any(ProcessTemplate.class));
90
+    }
91
+
92
+    @Test
93
+    @DisplayName("更新模板 - 不存在抛异常")
94
+    void update_notFound() {
95
+        when(templateMapper.selectById(99L)).thenReturn(null);
96
+
97
+        ProcessTemplate update = new ProcessTemplate();
98
+        assertThrows(RuntimeException.class, () -> templateService.update(99L, update));
99
+    }
100
+
101
+    @Test
102
+    @DisplayName("删除模板 - 成功")
103
+    void delete_success() {
104
+        when(templateMapper.selectById(1L)).thenReturn(sampleTemplate);
105
+        when(templateMapper.deleteById(1L)).thenReturn(1);
106
+
107
+        assertDoesNotThrow(() -> templateService.delete(1L));
108
+        verify(templateMapper, times(1)).deleteById(1L);
109
+    }
110
+
111
+    @Test
112
+    @DisplayName("删除模板 - 不存在抛异常")
113
+    void delete_notFound() {
114
+        when(templateMapper.selectById(99L)).thenReturn(null);
115
+
116
+        assertThrows(RuntimeException.class, () -> templateService.delete(99L));
117
+    }
118
+
119
+    @Test
120
+    @DisplayName("发布模板 - 状态变更为1")
121
+    void publish_success() {
122
+        when(templateMapper.selectById(1L)).thenReturn(sampleTemplate);
123
+        when(templateMapper.updateById(any(ProcessTemplate.class))).thenReturn(1);
124
+
125
+        ProcessTemplate result = templateService.publish(1L);
126
+
127
+        assertEquals(1, result.getStatus());
128
+    }
129
+
130
+    @Test
131
+    @DisplayName("停用模板 - 状态变更为2")
132
+    void disable_success() {
133
+        when(templateMapper.selectById(1L)).thenReturn(sampleTemplate);
134
+        when(templateMapper.updateById(any(ProcessTemplate.class))).thenReturn(1);
135
+
136
+        ProcessTemplate result = templateService.disable(1L);
137
+
138
+        assertEquals(2, result.getStatus());
139
+    }
140
+
141
+    @Test
142
+    @DisplayName("复制模板 - 生成副本")
143
+    void copy_success() {
144
+        when(templateMapper.selectById(1L)).thenReturn(sampleTemplate);
145
+        when(templateMapper.insert(any(ProcessTemplate.class))).thenReturn(1);
146
+
147
+        ProcessTemplate result = templateService.copy(1L);
148
+
149
+        assertNotNull(result);
150
+        assertTrue(result.getName().contains("副本"));
151
+        assertEquals(0, result.getUseCount());
152
+        assertEquals(0, result.getStatus());
153
+    }
154
+
155
+    @Test
156
+    @DisplayName("热门模板 - 返回按使用次数排序的列表")
157
+    void hotTemplates_success() {
158
+        ProcessTemplate hot = new ProcessTemplate();
159
+        hot.setUseCount(100);
160
+        when(templateMapper.selectHotTemplates(5)).thenReturn(Arrays.asList(hot, sampleTemplate));
161
+
162
+        List<ProcessTemplate> result = templateService.hotTemplates(5);
163
+
164
+        assertEquals(2, result.size());
165
+    }
166
+
167
+    @Test
168
+    @DisplayName("递增使用次数")
169
+    void incrementUseCount_success() {
170
+        when(templateMapper.incrementUseCount(1L)).thenReturn(1);
171
+
172
+        assertDoesNotThrow(() -> templateService.incrementUseCount(1L));
173
+        verify(templateMapper, times(1)).incrementUseCount(1L);
174
+    }
175
+}