Kaynağa Gözat

流程图功能修复

徐滕 1 hafta önce
ebeveyn
işleme
40556776f3

+ 19 - 0
forge-admin-ui/src/api/flow.js

@@ -249,6 +249,25 @@ export default {
   getModelList: params =>
     request.get('/api/flow/model/list', { params }),
 
+  /**
+   * 导入BPMN模型(创建新模型)
+   */
+  importModel: (file, modelName, category) => {
+    const formData = new FormData()
+    formData.append('file', file)
+    if (modelName)
+      formData.append('modelName', modelName)
+    if (category)
+      formData.append('category', category)
+    return request.post('/api/flow/model/import', formData)
+  },
+
+  /**
+   * 导出BPMN XML
+   */
+  exportModel: id =>
+    request.get(`/api/flow/model/${id}/export`),
+
   // ========== 流程模板 ==========
 
   /**

+ 128 - 0
forge-admin-ui/src/views/flow/design.vue

@@ -44,6 +44,12 @@
           </template>
           AI生成
         </n-button>
+        <n-button @click="handleImportXmlOpen">
+          <template #icon>
+            <i class="i-material-symbols:upload-file" />
+          </template>
+          导入
+        </n-button>
         <n-button :loading="saving" @click="handleSaveDraft">
           <template #icon>
             <i class="i-material-symbols:save" />
@@ -719,6 +725,51 @@
       </n-modal>
     </Teleport>
 
+    <Teleport to="body">
+      <n-modal
+        v-model:show="showImportXmlModal"
+        preset="card"
+        title="导入 BPMN XML"
+        style="width: min(760px, 92vw)"
+        :mask-closable="false"
+      >
+        <div class="import-xml-panel">
+          <n-alert type="info" :show-icon="false">
+            可以选择本地 .bpmn/.xml 文件,也可以直接粘贴 BPMN XML 内容。导入后将替换当前画布内容。
+          </n-alert>
+          <div class="import-xml-actions">
+            <n-button @click="triggerImportXmlFile">
+              <template #icon>
+                <i class="i-material-symbols:upload-file" />
+              </template>
+              选择文件
+            </n-button>
+            <span v-if="importXmlFileName" class="import-xml-file-name">
+              <i class="i-material-symbols:check-circle" />
+              {{ importXmlFileName }}
+            </span>
+          </div>
+          <n-input
+            v-model:value="importXmlText"
+            type="textarea"
+            :autosize="{ minRows: 12, maxRows: 18 }"
+            placeholder="在这里粘贴 BPMN XML..."
+          />
+          <input ref="importXmlFileInputRef" type="file" accept=".bpmn,.bpmn20.xml,.xml" style="display: none" @change="handleImportXmlFileChange">
+        </div>
+        <template #footer>
+          <n-space justify="end">
+            <n-button @click="showImportXmlModal = false">
+              取消
+            </n-button>
+            <n-button type="primary" :loading="importXmlLoading" :disabled="!importXmlText.trim()" @click="handleImportXmlConfirm">
+              导入到画布
+            </n-button>
+          </n-space>
+        </template>
+      </n-modal>
+    </Teleport>
+
     <VersionHistory
       v-if="showVersionHistory"
       :model-id="modelInfo.id"
@@ -822,6 +873,11 @@ const aiMessages = ref([])
 const aiDraft = ref(null)
 const showAiXmlPreview = ref(false)
 const showVersionHistory = ref(false)
+const showImportXmlModal = ref(false)
+const importXmlText = ref('')
+const importXmlLoading = ref(false)
+const importXmlFileName = ref('')
+const importXmlFileInputRef = ref(null)
 const aiAbortController = ref(null)
 const aiRawContent = ref('')
 const aiReasoningContent = ref('')
@@ -1967,6 +2023,53 @@ function handleOpenVersionHistory() {
   showVersionHistory.value = true
 }
 
+function handleImportXmlOpen() {
+  importXmlText.value = ''
+  importXmlFileName.value = ''
+  importXmlLoading.value = false
+  showImportXmlModal.value = true
+}
+
+function triggerImportXmlFile() {
+  importXmlFileInputRef.value?.click()
+}
+
+function handleImportXmlFileChange(e) {
+  const file = e.target.files?.[0]
+  if (!file)
+    return
+  importXmlFileName.value = file.name
+  const reader = new FileReader()
+  reader.onload = (event) => {
+    importXmlText.value = event.target?.result || ''
+  }
+  reader.readAsText(file)
+  e.target.value = ''
+}
+
+async function handleImportXmlConfirm() {
+  const xml = importXmlText.value.trim()
+  if (!xml)
+    return
+  importXmlLoading.value = true
+  try {
+    applyProcessConfigFromXml(xml)
+    bpmnXml.value = xml
+    await nextTick()
+    await modelerRef.value?.setXML?.(xml)
+    hasChanges.value = true
+    showImportXmlModal.value = false
+    window.$message?.success('导入成功,已加载到画布')
+  }
+  catch (error) {
+    console.error('导入 XML 失败:', error)
+    window.$message?.error('导入失败,请检查 XML 格式是否正确')
+  }
+  finally {
+    importXmlLoading.value = false
+  }
+}
+
 async function handleVersionHistoryRefresh() {
   if (!modelInfo.id)
     return
@@ -4128,6 +4231,31 @@ function getElementIcon(el) {
   background: transparent;
 }
 
+.import-xml-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.import-xml-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.import-xml-file-name {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 12px;
+  color: #16a34a;
+  font-weight: 600;
+}
+
+.import-xml-file-name i {
+  font-size: 16px;
+}
+
 .ai-flow-input {
   flex-shrink: 0;
   padding: 10px 12px;

+ 291 - 2
forge-admin-ui/src/views/flow/model.vue

@@ -60,6 +60,10 @@
             <i class="i-material-symbols:restart-alt" />
             清空
           </button>
+          <button type="button" class="model-import-button" @click="handleImportOpen">
+            <i class="i-material-symbols:upload-file" />
+            导入模型
+          </button>
           <button type="button" class="model-create-button" @click="handleAdd">
             <i class="i-material-symbols:add" />
             新增模型
@@ -406,6 +410,53 @@
       </NModal>
     </Teleport>
 
+    <!-- 导入模型弹窗 -->
+    <Teleport to="body">
+      <NModal
+        v-model:show="showImportModal"
+        preset="card"
+        title="导入流程模型"
+        style="width: min(560px, calc(100vw - 32px))"
+        :mask-closable="false"
+      >
+        <div class="import-model-panel">
+          <n-alert type="info" :show-icon="false">
+            上传 .bpmn 或 .xml 文件,系统会自动解析 BPMN 内容并创建新的流程模型。
+          </n-alert>
+          <div class="import-model-dropzone" :class="{ 'dragging': importDragging, 'has-file': importFile }" @dragover.prevent="importDragging = true" @dragleave="importDragging = false" @drop.prevent="handleImportDrop">
+            <div v-if="!importFile" class="import-dropzone-placeholder">
+              <i class="i-material-symbols:upload-file" />
+              <span>拖拽文件到此处,或</span>
+              <button type="button" class="import-choose-btn" @click="triggerImportFileInput">
+                选择文件
+              </button>
+            </div>
+            <div v-else class="import-dropzone-file">
+              <i class="i-material-symbols:description" />
+              <div class="import-file-info">
+                <span class="import-file-name">{{ importFile.name }}</span>
+                <span class="import-file-size">{{ formatFileSize(importFile.size) }}</span>
+              </div>
+              <button type="button" class="import-file-clear" @click="importFile = null">
+                <i class="i-material-symbols:close" />
+              </button>
+            </div>
+          </div>
+          <input ref="importFileInputRef" type="file" accept=".bpmn,.bpmn20.xml,.xml" style="display: none" @change="handleImportFileChange">
+        </div>
+        <template #footer>
+          <n-space justify="end">
+            <n-button @click="showImportModal = false">
+              取消
+            </n-button>
+            <n-button type="primary" :loading="importLoading" :disabled="!importFile" @click="handleImportSubmit">
+              导入
+            </n-button>
+          </n-space>
+        </template>
+      </NModal>
+    </Teleport>
+
     <VersionHistory
       v-if="showVersionHistory"
       :model-id="currentModelId"
@@ -417,7 +468,7 @@
 </template>
 
 <script setup>
-import { CopyOutline, CreateOutline, PauseCircleOutline, PlayCircleOutline, TimeOutline, TrashOutline } from '@vicons/ionicons5'
+import { CopyOutline, CreateOutline, DownloadOutline, PauseCircleOutline, PlayCircleOutline, TimeOutline, TrashOutline } from '@vicons/ionicons5'
 import { NIcon, NModal, NTreeSelect } from 'naive-ui'
 import { computed, defineAsyncComponent, h, onMounted, reactive, ref } from 'vue'
 import { useRouter } from 'vue-router'
@@ -589,6 +640,7 @@ function getActionOptions(row) {
     { label: '编辑信息', key: 'edit', icon: renderIcon(CreateOutline) },
     { label: '版本历史', key: 'versionHistory', icon: renderIcon(TimeOutline) },
     { label: '复制模型', key: 'copy', icon: renderIcon(CopyOutline) },
+    { label: '导出 BPMN', key: 'export', icon: renderIcon(DownloadOutline) },
   ]
   if (row.status === 1) {
     opts.splice(1, 0, { label: '发起测试', key: 'startTest', icon: renderIcon(PlayCircleOutline) })
@@ -603,7 +655,7 @@ function getActionOptions(row) {
 }
 
 function handleActionSelect(key, row) {
-  const map = { edit: handleEdit, startTest: handleStartTest, copy: handleCopy, versionHistory: handleVersionHistory, suspend: handleSuspend, activate: handleActivate, delete: handleDelete }
+  const map = { edit: handleEdit, startTest: handleStartTest, copy: handleCopy, export: handleExport, versionHistory: handleVersionHistory, suspend: handleSuspend, activate: handleActivate, delete: handleDelete }
   map[key]?.(row)
 }
 
@@ -747,6 +799,12 @@ const showModal = ref(false)
 const modalTitle = ref('新增模型')
 const isEdit = ref(false)
 const submitLoading = ref(false)
+
+const showImportModal = ref(false)
+const importLoading = ref(false)
+const importFile = ref(null)
+const importDragging = ref(false)
+const importFileInputRef = ref(null)
 const formRef = ref(null)
 const formData = reactive({
   id: '',
@@ -768,6 +826,64 @@ const rules = {
   designerType: { required: true, message: '请选择流程模式', trigger: 'change' },
 }
 
+function handleImportOpen() {
+  importFile.value = null
+  importDragging.value = false
+  showImportModal.value = true
+}
+
+function triggerImportFileInput() {
+  importFileInputRef.value?.click()
+}
+
+function handleImportFileChange(e) {
+  const file = e.target.files?.[0]
+  if (file)
+    importFile.value = file
+  e.target.value = ''
+}
+
+function handleImportDrop(e) {
+  importDragging.value = false
+  const file = e.dataTransfer?.files?.[0]
+  if (file && /\.(?:bpmn|xml)$/i.test(file.name))
+    importFile.value = file
+}
+
+function formatFileSize(bytes) {
+  if (!bytes)
+    return ''
+  if (bytes < 1024)
+    return `${bytes} B`
+  if (bytes < 1024 * 1024)
+    return `${(bytes / 1024).toFixed(1)} KB`
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+async function handleImportSubmit() {
+  if (!importFile.value)
+    return
+  importLoading.value = true
+  try {
+    const res = await flowApi.importModel(importFile.value)
+    if (res.code === 200) {
+      window.$message?.success('导入成功')
+      showImportModal.value = false
+      fetchData()
+    }
+    else {
+      window.$message?.error(res.message || '导入失败')
+    }
+  }
+  catch (error) {
+    console.error('导入失败:', error)
+    window.$message?.error(error?.message || '导入失败')
+  }
+  finally {
+    importLoading.value = false
+  }
+}
+
 function handleAdd() {
   isEdit.value = false
   modalTitle.value = '新增模型'
@@ -970,6 +1086,32 @@ async function handleCopy(row) {
   })
 }
 
+async function handleExport(row) {
+  try {
+    const res = await flowApi.exportModel(row.id)
+    if (res.code === 200 && res.data) {
+      const xml = res.data
+      const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' })
+      const url = URL.createObjectURL(blob)
+      const a = document.createElement('a')
+      a.href = url
+      a.download = `${row.modelKey || row.modelName || 'model'}.bpmn`
+      document.body.appendChild(a)
+      a.click()
+      document.body.removeChild(a)
+      URL.revokeObjectURL(url)
+      window.$message?.success('导出成功')
+    }
+    else {
+      window.$message?.error(res.message || '导出失败')
+    }
+  }
+  catch (error) {
+    console.error('导出失败:', error)
+    window.$message?.error(error?.message || '导出失败')
+  }
+}
+
 async function handleSuspend(row) {
   window.$dialog?.warning({
     title: '确认挂起',
@@ -1201,6 +1343,153 @@ onMounted(() => {
   background: #1e293b;
 }
 
+.model-import-button {
+  height: 34px;
+  gap: 5px;
+  padding: 0 12px;
+  border: 1px solid #dbe3ee;
+  border-radius: 6px;
+  background: #fff;
+  color: #1d4ed8;
+  font-size: 13px;
+  font-weight: 500;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  white-space: nowrap;
+  transition:
+    background-color 150ms ease,
+    border-color 150ms ease,
+    color 150ms ease;
+}
+
+.model-import-button:hover {
+  border-color: #93c5fd;
+  background: #eff6ff;
+  color: #1e40af;
+}
+
+.model-import-button i {
+  font-size: 16px;
+  line-height: 1;
+}
+
+.import-model-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.import-model-dropzone {
+  border: 2px dashed #e2e8f0;
+  border-radius: 10px;
+  padding: 28px 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition:
+    border-color 160ms ease,
+    background 160ms ease;
+  cursor: default;
+}
+
+.import-model-dropzone.dragging {
+  border-color: #93c5fd;
+  background: #eff6ff;
+}
+
+.import-model-dropzone.has-file {
+  border-color: #86efac;
+  background: #f0fdf4;
+  padding: 14px 16px;
+  justify-content: flex-start;
+}
+
+.import-dropzone-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+  color: #94a3b8;
+  font-size: 13px;
+}
+
+.import-dropzone-placeholder i {
+  font-size: 32px;
+  color: #94a3b8;
+}
+
+.import-choose-btn {
+  background: none;
+  border: none;
+  color: #2563eb;
+  font-size: 13px;
+  font-weight: 600;
+  cursor: pointer;
+  padding: 0;
+}
+
+.import-choose-btn:hover {
+  color: #1d4ed8;
+  text-decoration: underline;
+}
+
+.import-dropzone-file {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  min-width: 0;
+}
+
+.import-dropzone-file > i {
+  font-size: 24px;
+  color: #16a34a;
+  flex-shrink: 0;
+}
+
+.import-file-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.import-file-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: #0f172a;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.import-file-size {
+  font-size: 12px;
+  color: #64748b;
+}
+
+.import-file-clear {
+  width: 28px;
+  height: 28px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #94a3b8;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.import-file-clear:hover {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
 .model-toolbar-button i,
 .model-create-button i {
   font-size: 16px;

Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 2
forge-server/forge-business/forge-restaurant/餐饮模块迁移分析文档.md


+ 0 - 2
forge-server/forge-flow/forge-flow-server/src/main/java/com/mdframe/forge/flow/controller/FlowModelController.java

@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.mdframe.forge.starter.core.annotation.crypto.ApiDecrypt;
 import com.mdframe.forge.starter.core.annotation.crypto.ApiEncrypt;
-import com.mdframe.forge.starter.core.annotation.tenant.IgnoreTenant;
 import com.mdframe.forge.starter.core.domain.RespInfo;
 import com.mdframe.forge.starter.flow.entity.FlowModel;
 import com.mdframe.forge.starter.flow.service.FlowModelService;
@@ -24,7 +23,6 @@ import java.util.Map;
 @RequiredArgsConstructor
 @ApiDecrypt
 @ApiEncrypt
-@IgnoreTenant
 public class FlowModelController {
 
     private final FlowModelService flowModelService;

+ 15 - 23
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowModelServiceImpl.java

@@ -63,30 +63,32 @@ public class FlowModelServiceImpl extends ServiceImpl<FlowModelMapper, FlowModel
 
     @Override
     public IPage<FlowModel> pageFlowModel(Page<FlowModel> page, String modelName, String category, Integer status) {
-        String currentUsername = SessionHelper.getUsername();
-        if (currentUsername == null || currentUsername.isEmpty()) {
-            return new Page<>(page.getCurrent(), page.getSize());
-        }
-        return this.getBaseMapper().selectModelPage(page, modelName, category, status, currentUsername);
+        // 系统管理员不过滤创建人,租户用户只能看到自己创建的模型
+        String createBy = SessionHelper.isAdmin() ? null : SessionHelper.getUsername();
+        return this.getBaseMapper().selectModelPage(page, modelName, category, status, createBy);
     }
 
     @Override
     public List<FlowModel> getEnabledModels(String category) {
         LambdaQueryWrapper<FlowModel> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(FlowModel::getStatus, 1)
-                .eq(category != null && !category.isEmpty(), FlowModel::getCategory, category)
-                .orderByDesc(FlowModel::getCreateTime);
+                .eq(category != null && !category.isEmpty(), FlowModel::getCategory, category);
+        // 系统管理员不过滤创建人,租户用户只能看到自己创建的模型
+        if (!SessionHelper.isAdmin()) {
+            String username = SessionHelper.getUsername();
+            if (username != null && !username.isEmpty()) {
+                wrapper.eq(FlowModel::getCreateBy, username);
+            }
+        }
+        wrapper.orderByDesc(FlowModel::getCreateTime);
         return list(wrapper);
     }
 
     @Override
     public Map<String, Object> getStatusStatistics(String modelName, String category) {
-        String currentUsername = SessionHelper.getUsername();
-        if (currentUsername == null || currentUsername.isEmpty()) {
-            return emptyStatusStatistics();
-        }
-
-        Map<String, Object> raw = this.getBaseMapper().selectStatusStatistics(modelName, category, currentUsername);
+        // 统计查询与分页查询保持相同的过滤范围
+        String createBy = SessionHelper.isAdmin() ? null : SessionHelper.getUsername();
+        Map<String, Object> raw = this.getBaseMapper().selectStatusStatistics(modelName, category, createBy);
         Map<String, Object> result = new HashMap<>();
         result.put("total", readCount(raw, "total"));
         result.put("designing", readCount(raw, "designing"));
@@ -96,16 +98,6 @@ public class FlowModelServiceImpl extends ServiceImpl<FlowModelMapper, FlowModel
         return result;
     }
 
-    private Map<String, Object> emptyStatusStatistics() {
-        Map<String, Object> result = new HashMap<>();
-        result.put("total", 0L);
-        result.put("designing", 0L);
-        result.put("deployed", 0L);
-        result.put("suspended", 0L);
-        result.put("disabled", 0L);
-        return result;
-    }
-
     private long readCount(Map<String, Object> raw, String key) {
         if (raw == null || raw.isEmpty()) {
             return 0L;