Prechádzať zdrojové kódy

feat(frontend): 实现规则拖拽工作流交互

- 添加 Vue Flow 画布组件,支持节点拖放和连线
- 实现 Source/Action/Element 三种节点类型
- 添加左侧节点面板和右侧属性面板
- 实现撤销重做、键盘快捷键、右键菜单
- 添加工作流验证和规则预览功能
- 优化工作流到规则的转换逻辑
- 在顶部工具栏添加规则按钮入口
何文松 13 hodín pred
rodič
commit
c6d37fcd24

+ 563 - 0
docs/design/规则拖拽交互设计方案.md

@@ -0,0 +1,563 @@
+# 规则拖拽交互设计方案(工作流画布版)
+
+## 1. 概述
+
+本方案设计一种**工作流画布式**的规则配置交互,类似 n8n / Dify / 飞书自动化的节点连线模式。用户在画布上拖拽节点、连接边,可视化地构建数据提取规则。
+
+### 1.1 核心概念
+
+```
+┌─────────────┐      ┌─────────────┐      ┌─────────────┐
+│  来源节点    │─────▶│  动作节点    │─────▶│  输出节点    │
+│ (Source)    │      │ (Action)    │      │ (Element)   │
+└─────────────┘      └─────────────┘      └─────────────┘
+     附件              AI提取/引用           目标要素
+```
+
+### 1.2 设计目标
+
+- **可视化**:节点+连线,一目了然的数据流向
+- **灵活**:自由拖拽、连接、组合
+- **可扩展**:易于添加新的节点类型
+
+---
+
+## 2. 节点类型定义
+
+### 2.1 来源节点 (Source Node)
+
+从左侧面板拖入画布,代表数据来源。
+
+| 节点类型 | 图标 | 说明 | 配置项 |
+|----------|------|------|--------|
+| **原文** | 📄 | 项目原文档 | `locatorType`: 全文/章节/段落 |
+| **附件** | 📎 | 已上传的附件 | `attachmentId`, `entryPath` |
+| **摘选** | ✂️ | 手动框选的文本片段 | `sourceText`, `locator` |
+
+### 2.2 动作节点 (Action Node)
+
+处理数据的中间节点。
+
+| 节点类型 | 图标 | 颜色 | 说明 | 配置项 |
+|----------|------|------|------|--------|
+| **引用** | 📌 | 绿色 | 直接使用来源文本 | 无 |
+| **AI 总结** | ✨ | 橙色 | AI 总结内容 | `prompt`, `maxLength` |
+| **AI 提取** | 🔍 | 蓝色 | AI 提取特定信息 | `prompt`, `format` |
+| **表格提取** | 📊 | 灰色 | 从表格提取数据 | `tableSelector`, `column` |
+
+### 2.3 输出节点 (Element Node)
+
+从右侧要素面板拖入,代表目标要素。
+
+| 节点类型 | 图标 | 说明 |
+|----------|------|------|
+| **要素** | 🏷️ | 目标要素,如 `basicInfo.projectCode` |
+
+---
+
+## 3. 画布布局设计
+
+### 3.1 整体布局
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│  [保存] [运行全部] [清空]                              [缩放] [适应] [网格] │
+├────────────┬────────────────────────────────────────┬───────────────────┤
+│            │                                        │                   │
+│  节点面板   │              工作流画布                 │    属性面板        │
+│            │                                        │                   │
+│ ┌────────┐ │   ┌──────┐      ┌──────┐      ┌─────┐ │  ┌─────────────┐  │
+│ │ 来源   │ │   │ 附件1 │─────▶│AI提取│─────▶│要素1│ │  │ 节点属性     │  │
+│ │ ├ 原文 │ │   └──────┘      └──────┘      └─────┘ │  │             │  │
+│ │ ├ 附件 │ │                                       │  │ 名称: ...   │  │
+│ │ └ 摘选 │ │   ┌──────┐      ┌──────┐      ┌─────┐ │  │ 类型: ...   │  │
+│ ├────────┤ │   │ 附件2 │─────▶│ 引用 │─────▶│要素2│ │  │ 配置: ...   │  │
+│ │ 动作   │ │   └──────┘      └──────┘      └─────┘ │  │             │  │
+│ │ ├ 引用 │ │                                       │  └─────────────┘  │
+│ │ ├ 总结 │ │   ┌──────┐                    ┌─────┐ │                   │
+│ │ ├ 提取 │ │   │ 原文  │───────────────────▶│要素3│ │  ┌─────────────┐  │
+│ │ └ 表格 │ │   └──────┘                    └─────┘ │  │ 连线属性     │  │
+│ ├────────┤ │                                       │  │             │  │
+│ │ 要素   │ │          (可缩放、平移的画布)           │  │ 来源: ...   │  │
+│ │ ├ 项目 │ │                                       │  │ 目标: ...   │  │
+│ │ ├ 评审 │ │                                       │  └─────────────┘  │
+│ │ └ ... │ │                                       │                   │
+│ └────────┘ │                                       │                   │
+│            │                                        │                   │
+└────────────┴────────────────────────────────────────┴───────────────────┘
+```
+
+### 3.2 面板说明
+
+| 面板 | 宽度 | 功能 |
+|------|------|------|
+| **节点面板** | 200px | 可拖拽的节点列表,分组显示 |
+| **工作流画布** | 自适应 | 节点拖放、连线、缩放、平移 |
+| **属性面板** | 280px | 选中节点/边的配置表单 |
+
+---
+
+## 4. 交互流程
+
+### 4.1 创建规则流程
+
+```
+1. 从节点面板拖拽「附件」节点到画布
+   └─ 弹出附件选择器,选择具体附件
+   
+2. 从节点面板拖拽「AI提取」节点到画布
+   └─ 右侧属性面板显示配置表单
+   
+3. 从节点面板拖拽「要素」节点到画布
+   └─ 弹出要素选择器,选择目标要素
+   
+4. 连接节点:从「附件」输出端口拖线到「AI提取」输入端口
+   
+5. 连接节点:从「AI提取」输出端口拖线到「要素」输入端口
+   
+6. 配置「AI提取」节点:在属性面板填写 prompt
+   
+7. 点击「保存」,生成规则
+```
+
+### 4.2 快捷操作
+
+| 操作 | 快捷键 | 说明 |
+|------|--------|------|
+| 删除节点/边 | `Delete` / `Backspace` | 删除选中项 |
+| 全选 | `Ctrl+A` | 选中所有节点 |
+| 撤销 | `Ctrl+Z` | 撤销上一步 |
+| 重做 | `Ctrl+Shift+Z` | 重做 |
+| 缩放 | `Ctrl+滚轮` | 缩放画布 |
+| 平移 | `空格+拖拽` | 平移画布 |
+| 复制 | `Ctrl+C` | 复制选中节点 |
+| 粘贴 | `Ctrl+V` | 粘贴节点 |
+
+### 4.3 连线规则
+
+```
+来源节点 ──▶ 动作节点 ──▶ 输出节点
+   │              │            │
+   │              │            └── 只能接收,不能输出
+   │              └── 可接收多个来源,输出到一个要素
+   └── 只能输出,不能接收
+
+简化模式:来源节点 ──▶ 输出节点(默认使用「引用」动作)
+```
+
+---
+
+## 5. 节点数据结构
+
+### 5.1 画布数据模型
+
+```typescript
+interface WorkflowData {
+  nodes: WorkflowNode[]
+  edges: WorkflowEdge[]
+}
+
+interface WorkflowNode {
+  id: string
+  type: 'source' | 'action' | 'element'
+  subType: string          // 'document' | 'attachment' | 'quote' | 'ai_extract' | ...
+  position: { x: number, y: number }
+  data: {
+    label: string
+    // 来源节点
+    sourceNodeId?: number
+    sourceName?: string
+    sourceText?: string
+    locator?: { type: string, value: string }
+    // 动作节点
+    actionType?: string
+    prompt?: string
+    // 输出节点
+    elementKey?: string
+    elementName?: string
+  }
+}
+
+interface WorkflowEdge {
+  id: string
+  source: string           // 源节点 ID
+  target: string           // 目标节点 ID
+  sourceHandle?: string    // 源端口
+  targetHandle?: string    // 目标端口
+}
+```
+
+### 5.2 转换为后端 RuleCreateDTO
+
+```javascript
+function workflowToRules(workflow: WorkflowData): RuleCreateDTO[] {
+  const rules = []
+  
+  // 找到所有输出节点(要素)
+  const elementNodes = workflow.nodes.filter(n => n.type === 'element')
+  
+  for (const elementNode of elementNodes) {
+    // 反向追溯连接链
+    const chain = traceInputChain(workflow, elementNode.id)
+    
+    // 构建规则
+    const rule: RuleCreateDTO = {
+      elementKey: elementNode.data.elementKey,
+      ruleName: `${elementNode.data.elementName}-自动规则`,
+      ruleType: 'extraction',
+      actionType: chain.actionNode?.data.actionType || 'quote',
+      actionConfig: JSON.stringify({
+        prompt: chain.actionNode?.data.prompt,
+        locatorType: chain.sourceNode?.data.locator?.type || 'full_text'
+      }),
+      inputs: [{
+        sourceNodeId: chain.sourceNode?.data.sourceNodeId,
+        inputType: chain.sourceNode?.subType,
+        inputName: chain.sourceNode?.data.sourceName,
+        sourceText: chain.sourceNode?.data.sourceText
+      }]
+    }
+    
+    rules.push(rule)
+  }
+  
+  return rules
+}
+```
+
+---
+
+## 6. 技术选型
+
+### 6.1 画布库选择
+
+| 库 | 优点 | 缺点 | 推荐度 |
+|----|------|------|--------|
+| **Vue Flow** | Vue 3 原生、轻量、API 简洁 | 社区较小 | ⭐⭐⭐⭐⭐ |
+| **X6 (AntV)** | 功能强大、文档完善 | 体积大、学习曲线陡 | ⭐⭐⭐⭐ |
+| **LogicFlow** | 滴滴开源、流程图专用 | Vue 3 支持一般 | ⭐⭐⭐ |
+| **原生实现** | 完全可控 | 开发成本高 | ⭐⭐ |
+
+**推荐:Vue Flow** - 轻量、Vue 3 友好、满足需求
+
+```bash
+npm install @vue-flow/core @vue-flow/background @vue-flow/controls
+```
+
+### 6.2 组件结构
+
+```
+src/
+├── components/
+│   └── workflow/
+│       ├── RuleWorkflow.vue        # 主画布组件
+│       ├── nodes/
+│       │   ├── SourceNode.vue      # 来源节点
+│       │   ├── ActionNode.vue      # 动作节点
+│       │   └── ElementNode.vue     # 输出节点
+│       ├── panels/
+│       │   ├── NodePanel.vue       # 左侧节点面板
+│       │   └── PropertyPanel.vue   # 右侧属性面板
+│       └── hooks/
+│           ├── useWorkflow.js      # 画布逻辑
+│           └── useRuleConvert.js   # 规则转换
+├── stores/
+│   └── workflow.js                 # 画布状态管理
+```
+
+---
+
+## 7. 节点组件设计
+
+### 7.1 来源节点 (SourceNode.vue)
+
+```vue
+<template>
+  <div class="workflow-node source-node" :class="subType">
+    <div class="node-icon">{{ icon }}</div>
+    <div class="node-content">
+      <div class="node-label">{{ data.label }}</div>
+      <div class="node-desc" v-if="data.sourceName">{{ data.sourceName }}</div>
+    </div>
+    <!-- 输出端口 -->
+    <Handle type="source" position="right" />
+  </div>
+</template>
+
+<style scoped>
+.source-node {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border-radius: 8px;
+  padding: 12px 16px;
+  min-width: 140px;
+  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+}
+.source-node.document { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }
+.source-node.attachment { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
+.source-node.excerpt { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
+</style>
+```
+
+### 7.2 动作节点 (ActionNode.vue)
+
+```vue
+<template>
+  <div class="workflow-node action-node" :class="data.actionType">
+    <!-- 输入端口 -->
+    <Handle type="target" position="left" />
+    
+    <div class="node-icon">{{ icon }}</div>
+    <div class="node-content">
+      <div class="node-label">{{ actionLabel }}</div>
+      <div class="node-config" v-if="data.prompt">
+        {{ data.prompt.slice(0, 20) }}...
+      </div>
+    </div>
+    
+    <!-- 输出端口 -->
+    <Handle type="source" position="right" />
+  </div>
+</template>
+
+<style scoped>
+.action-node {
+  background: white;
+  border: 2px solid #ddd;
+  border-radius: 8px;
+  padding: 12px 16px;
+  min-width: 120px;
+}
+.action-node.quote { border-color: #67c23a; }
+.action-node.summary { border-color: #e6a23c; }
+.action-node.ai_extract { border-color: #409eff; }
+.action-node.table_extract { border-color: #909399; }
+</style>
+```
+
+### 7.3 输出节点 (ElementNode.vue)
+
+```vue
+<template>
+  <div class="workflow-node element-node">
+    <!-- 输入端口 -->
+    <Handle type="target" position="left" />
+    
+    <div class="node-icon">🏷️</div>
+    <div class="node-content">
+      <div class="node-label">{{ data.elementName }}</div>
+      <div class="node-key">{{ data.elementKey }}</div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.element-node {
+  background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
+  color: white;
+  border-radius: 8px;
+  padding: 12px 16px;
+  min-width: 140px;
+  box-shadow: 0 4px 12px rgba(250, 112, 154, 0.3);
+}
+</style>
+```
+
+---
+
+## 8. 属性面板设计
+
+### 8.1 来源节点属性
+
+```vue
+<template>
+  <div class="property-panel">
+    <h3>来源配置</h3>
+    
+    <el-form label-position="top" size="small">
+      <el-form-item label="来源类型">
+        <el-tag>{{ sourceTypeLabel }}</el-tag>
+      </el-form-item>
+      
+      <el-form-item label="选择附件" v-if="node.subType === 'attachment'">
+        <el-select v-model="node.data.sourceNodeId" @change="onAttachmentChange">
+          <el-option 
+            v-for="att in attachments" 
+            :key="att.id" 
+            :label="att.fileName" 
+            :value="att.id" 
+          />
+        </el-select>
+      </el-form-item>
+      
+      <el-form-item label="内容定位">
+        <el-select v-model="node.data.locator.type">
+          <el-option label="全文" value="full_text" />
+          <el-option label="章节" value="chapter" />
+          <el-option label="评审代码" value="review_code" />
+        </el-select>
+      </el-form-item>
+      
+      <el-form-item label="章节标题" v-if="node.data.locator.type === 'chapter'">
+        <el-input v-model="node.data.locator.value" placeholder="如:一、工作目的" />
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+```
+
+### 8.2 动作节点属性
+
+```vue
+<template>
+  <div class="property-panel">
+    <h3>动作配置</h3>
+    
+    <el-form label-position="top" size="small">
+      <el-form-item label="动作类型">
+        <el-radio-group v-model="node.data.actionType">
+          <el-radio-button value="quote">引用</el-radio-button>
+          <el-radio-button value="summary">总结</el-radio-button>
+          <el-radio-button value="ai_extract">提取</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      
+      <el-form-item 
+        label="AI 提示词" 
+        v-if="['summary', 'ai_extract'].includes(node.data.actionType)"
+      >
+        <el-input 
+          v-model="node.data.prompt" 
+          type="textarea" 
+          :rows="4"
+          placeholder="请输入 AI 处理提示词..."
+        />
+      </el-form-item>
+      
+      <el-form-item label="输出格式" v-if="node.data.actionType === 'ai_extract'">
+        <el-select v-model="node.data.format">
+          <el-option label="文本" value="text" />
+          <el-option label="日期" value="date" />
+          <el-option label="数字" value="number" />
+          <el-option label="列表" value="list" />
+        </el-select>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+```
+
+---
+
+## 9. 实现计划
+
+### Phase 1: 画布基础 (3天)
+- [ ] 安装 Vue Flow
+- [ ] 创建 `RuleWorkflow.vue` 主组件
+- [ ] 实现三种节点组件
+- [ ] 实现节点拖放到画布
+- [ ] 实现节点连线
+
+### Phase 2: 面板交互 (2天)
+- [ ] 左侧节点面板(分组、搜索)
+- [ ] 右侧属性面板(动态表单)
+- [ ] 节点选中/编辑状态
+
+### Phase 3: 规则转换 (2天)
+- [ ] 画布数据 → RuleCreateDTO 转换
+- [ ] 保存规则到后端
+- [ ] 加载已有规则到画布
+
+### Phase 4: 体验优化 (2天)
+- [ ] 快捷键支持
+- [ ] 撤销/重做
+- [ ] 小地图
+- [ ] 自动布局
+
+---
+
+## 10. 与现有系统集成
+
+### 10.1 入口位置
+
+在 `Editor.vue` 中添加「规则工作流」入口:
+
+```vue
+<!-- 规则管理弹窗改为全屏工作流 -->
+<el-dialog
+  v-model="showRuleWorkflow"
+  title="规则工作流"
+  fullscreen
+  :close-on-click-modal="false"
+>
+  <RuleWorkflow 
+    :project-id="currentProjectId"
+    :attachments="attachments"
+    :elements="elements"
+    @save="handleWorkflowSave"
+  />
+</el-dialog>
+```
+
+### 10.2 数据同步
+
+- 画布保存时,批量创建/更新规则
+- 规则列表与画布双向同步
+- 支持从现有规则反向生成画布
+
+---
+
+## 11. 附录:Vue Flow 示例
+
+```vue
+<script setup>
+import { ref } from 'vue'
+import { VueFlow, useVueFlow } from '@vue-flow/core'
+import { Background } from '@vue-flow/background'
+import { Controls } from '@vue-flow/controls'
+
+import SourceNode from './nodes/SourceNode.vue'
+import ActionNode from './nodes/ActionNode.vue'
+import ElementNode from './nodes/ElementNode.vue'
+
+const nodeTypes = {
+  source: SourceNode,
+  action: ActionNode,
+  element: ElementNode
+}
+
+const nodes = ref([])
+const edges = ref([])
+
+const { onConnect, addEdges } = useVueFlow()
+
+onConnect((params) => {
+  addEdges([params])
+})
+
+function onDrop(event) {
+  const type = event.dataTransfer.getData('application/vueflow')
+  const position = { x: event.clientX, y: event.clientY }
+  
+  nodes.value.push({
+    id: `node-${Date.now()}`,
+    type,
+    position,
+    data: { label: type }
+  })
+}
+</script>
+
+<template>
+  <VueFlow 
+    :nodes="nodes" 
+    :edges="edges"
+    :node-types="nodeTypes"
+    @drop="onDrop"
+    @dragover.prevent
+  >
+    <Background />
+    <Controls />
+  </VueFlow>
+</template>
+```

+ 4 - 0
frontend/vue-demo/package.json

@@ -10,6 +10,10 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
+    "@vue-flow/background": "^1.3.2",
+    "@vue-flow/controls": "^1.1.3",
+    "@vue-flow/core": "^1.48.2",
+    "@vue-flow/minimap": "^1.5.4",
     "axios": "^1.6.0",
     "element-plus": "^2.4.4",
     "jszip": "^3.10.1",

+ 1280 - 0
frontend/vue-demo/src/components/workflow/RuleWorkflow.vue

@@ -0,0 +1,1280 @@
+<script setup>
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
+import { VueFlow, useVueFlow } from '@vue-flow/core'
+import { Background } from '@vue-flow/background'
+import { Controls } from '@vue-flow/controls'
+import { MiniMap } from '@vue-flow/minimap'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import '@vue-flow/core/dist/style.css'
+import '@vue-flow/core/dist/theme-default.css'
+import '@vue-flow/controls/dist/style.css'
+import '@vue-flow/minimap/dist/style.css'
+
+import SourceNode from './nodes/SourceNode.vue'
+import ActionNode from './nodes/ActionNode.vue'
+import ElementNode from './nodes/ElementNode.vue'
+import NodePanel from './panels/NodePanel.vue'
+import PropertyPanel from './panels/PropertyPanel.vue'
+
+const props = defineProps({
+  projectId: { type: Number, required: true },
+  attachments: { type: Array, default: () => [] },
+  elements: { type: Array, default: () => [] },
+  rules: { type: Array, default: () => [] }
+})
+
+const emit = defineEmits(['save', 'close'])
+
+const nodeTypes = {
+  source: SourceNode,
+  action: ActionNode,
+  element: ElementNode
+}
+
+const nodes = ref([])
+const edges = ref([])
+const selectedNode = ref(null)
+const selectedEdge = ref(null)
+const selectedNodes = ref([])
+
+// 撤销重做
+const historyStack = ref([])
+const historyIndex = ref(-1)
+const maxHistory = 50
+
+// 右键菜单
+const contextMenu = ref({ visible: false, x: 0, y: 0, type: '', target: null })
+
+// 工作流验证
+const validationErrors = ref([])
+const showValidation = ref(false)
+
+// 复制粘贴
+const clipboard = ref(null)
+
+// 规则预览
+const showPreview = ref(false)
+const previewRules = ref([])
+const expandedRuleIdx = ref(null)
+
+const { 
+  onConnect, 
+  addEdges, 
+  onNodesChange, 
+  onEdgesChange,
+  onNodeClick,
+  onEdgeClick,
+  onPaneClick,
+  onNodeContextMenu,
+  onEdgeContextMenu,
+  onPaneContextMenu,
+  project,
+  fitView,
+  getSelectedNodes,
+  removeNodes,
+  removeEdges
+} = useVueFlow()
+
+// 历史记录
+function saveHistory() {
+  const state = {
+    nodes: JSON.parse(JSON.stringify(nodes.value)),
+    edges: JSON.parse(JSON.stringify(edges.value))
+  }
+  
+  if (historyIndex.value < historyStack.value.length - 1) {
+    historyStack.value = historyStack.value.slice(0, historyIndex.value + 1)
+  }
+  
+  historyStack.value.push(state)
+  if (historyStack.value.length > maxHistory) {
+    historyStack.value.shift()
+  } else {
+    historyIndex.value++
+  }
+}
+
+function undo() {
+  if (historyIndex.value > 0) {
+    historyIndex.value--
+    const state = historyStack.value[historyIndex.value]
+    nodes.value = JSON.parse(JSON.stringify(state.nodes))
+    edges.value = JSON.parse(JSON.stringify(state.edges))
+    selectedNode.value = null
+    selectedEdge.value = null
+    ElMessage.info('已撤销')
+  }
+}
+
+function redo() {
+  if (historyIndex.value < historyStack.value.length - 1) {
+    historyIndex.value++
+    const state = historyStack.value[historyIndex.value]
+    nodes.value = JSON.parse(JSON.stringify(state.nodes))
+    edges.value = JSON.parse(JSON.stringify(state.edges))
+    selectedNode.value = null
+    selectedEdge.value = null
+    ElMessage.info('已重做')
+  }
+}
+
+const canUndo = computed(() => historyIndex.value > 0)
+const canRedo = computed(() => historyIndex.value < historyStack.value.length - 1)
+
+// 连接验证
+onConnect((params) => {
+  if (validateConnection(params)) {
+    saveHistory()
+    addEdges([{
+      ...params,
+      id: `edge-${Date.now()}`,
+      animated: true,
+      style: { stroke: '#409eff', strokeWidth: 2 }
+    }])
+  } else {
+    ElMessage.warning('无效的连接:请检查节点类型')
+  }
+})
+
+onNodeClick(({ node }) => {
+  selectedNode.value = node
+  selectedEdge.value = null
+  hideContextMenu()
+})
+
+onEdgeClick(({ edge }) => {
+  selectedEdge.value = edge
+  selectedNode.value = null
+  hideContextMenu()
+})
+
+onPaneClick(() => {
+  selectedNode.value = null
+  selectedEdge.value = null
+  hideContextMenu()
+})
+
+// 右键菜单
+onNodeContextMenu(({ event, node }) => {
+  event.preventDefault()
+  selectedNode.value = node
+  showContextMenu(event.clientX, event.clientY, 'node', node)
+})
+
+onEdgeContextMenu(({ event, edge }) => {
+  event.preventDefault()
+  selectedEdge.value = edge
+  showContextMenu(event.clientX, event.clientY, 'edge', edge)
+})
+
+onPaneContextMenu(({ event }) => {
+  event.preventDefault()
+  showContextMenu(event.clientX, event.clientY, 'pane', null)
+})
+
+function showContextMenu(x, y, type, target) {
+  contextMenu.value = { visible: true, x, y, type, target }
+}
+
+function hideContextMenu() {
+  contextMenu.value.visible = false
+}
+
+function handleContextMenuAction(action) {
+  const { type, target } = contextMenu.value
+  hideContextMenu()
+  
+  switch (action) {
+    case 'delete':
+      if (type === 'node') handleDeleteNode(target.id)
+      else if (type === 'edge') handleDeleteEdge(target.id)
+      break
+    case 'copy':
+      if (type === 'node') copyNode(target)
+      break
+    case 'paste':
+      pasteNode()
+      break
+    case 'duplicate':
+      if (type === 'node') duplicateNode(target)
+      break
+  }
+}
+
+function validateConnection(params) {
+  const sourceNode = nodes.value.find(n => n.id === params.source)
+  const targetNode = nodes.value.find(n => n.id === params.target)
+  
+  if (!sourceNode || !targetNode) return false
+  if (params.source === params.target) return false
+  
+  // 检查是否已存在连接
+  const existingEdge = edges.value.find(e => 
+    e.source === params.source && e.target === params.target
+  )
+  if (existingEdge) return false
+  
+  // 验证连接规则
+  if (sourceNode.type === 'source' && targetNode.type === 'element') return true
+  if (sourceNode.type === 'source' && targetNode.type === 'action') return true
+  if (sourceNode.type === 'action' && targetNode.type === 'element') return true
+  if (sourceNode.type === 'action' && targetNode.type === 'action') return true
+  
+  return false
+}
+
+function onDragOver(event) {
+  event.preventDefault()
+  event.dataTransfer.dropEffect = 'move'
+}
+
+function onDrop(event) {
+  event.preventDefault()
+  
+  const dataStr = event.dataTransfer.getData('application/vueflow')
+  if (!dataStr) return
+  
+  saveHistory()
+  
+  const data = JSON.parse(dataStr)
+  const position = project({ x: event.clientX - 220, y: event.clientY - 60 })
+  
+  const newNode = {
+    id: `node-${Date.now()}`,
+    type: data.nodeType,
+    position,
+    data: {
+      ...data,
+      label: data.label || data.nodeType
+    }
+  }
+  
+  nodes.value.push(newNode)
+  
+  setTimeout(() => {
+    selectedNode.value = newNode
+  }, 50)
+}
+
+function handleNodeUpdate(nodeId, newData) {
+  saveHistory()
+  const node = nodes.value.find(n => n.id === nodeId)
+  if (node) {
+    node.data = { ...node.data, ...newData }
+  }
+}
+
+function handleDeleteNode(nodeId) {
+  saveHistory()
+  nodes.value = nodes.value.filter(n => n.id !== nodeId)
+  edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId)
+  selectedNode.value = null
+}
+
+function handleDeleteEdge(edgeId) {
+  saveHistory()
+  edges.value = edges.value.filter(e => e.id !== edgeId)
+  selectedEdge.value = null
+}
+
+// 复制粘贴
+function copyNode(node) {
+  clipboard.value = JSON.parse(JSON.stringify(node))
+  ElMessage.success('已复制节点')
+}
+
+function pasteNode() {
+  if (!clipboard.value) {
+    ElMessage.warning('剪贴板为空')
+    return
+  }
+  
+  saveHistory()
+  const newNode = {
+    ...clipboard.value,
+    id: `node-${Date.now()}`,
+    position: {
+      x: clipboard.value.position.x + 50,
+      y: clipboard.value.position.y + 50
+    }
+  }
+  nodes.value.push(newNode)
+  selectedNode.value = newNode
+  ElMessage.success('已粘贴节点')
+}
+
+function duplicateNode(node) {
+  saveHistory()
+  const newNode = {
+    ...JSON.parse(JSON.stringify(node)),
+    id: `node-${Date.now()}`,
+    position: {
+      x: node.position.x + 50,
+      y: node.position.y + 50
+    }
+  }
+  nodes.value.push(newNode)
+  selectedNode.value = newNode
+}
+
+// 工作流验证
+function validateWorkflow() {
+  const errors = []
+  
+  // 检查孤立节点
+  const connectedNodeIds = new Set()
+  edges.value.forEach(e => {
+    connectedNodeIds.add(e.source)
+    connectedNodeIds.add(e.target)
+  })
+  
+  nodes.value.forEach(node => {
+    if (!connectedNodeIds.has(node.id)) {
+      errors.push({ type: 'warning', nodeId: node.id, message: `节点 "${node.data.label}" 未连接` })
+    }
+  })
+  
+  // 检查来源节点配置
+  nodes.value.filter(n => n.type === 'source').forEach(node => {
+    if (node.data.subType === 'attachment' && !node.data.sourceNodeId) {
+      errors.push({ type: 'error', nodeId: node.id, message: `来源节点 "${node.data.label}" 未选择附件` })
+    }
+  })
+  
+  // 检查输出节点配置
+  nodes.value.filter(n => n.type === 'element').forEach(node => {
+    if (!node.data.elementKey) {
+      errors.push({ type: 'error', nodeId: node.id, message: `输出节点 "${node.data.label}" 未选择要素` })
+    }
+  })
+  
+  // 检查动作节点配置
+  nodes.value.filter(n => n.type === 'action').forEach(node => {
+    const actionType = node.data.actionType || node.data.subType
+    if (['summary', 'ai_extract'].includes(actionType) && !node.data.prompt) {
+      errors.push({ type: 'warning', nodeId: node.id, message: `动作节点 "${node.data.label}" 建议配置提示词` })
+    }
+  })
+  
+  // 检查完整的数据流
+  const elementNodes = nodes.value.filter(n => n.type === 'element')
+  elementNodes.forEach(elemNode => {
+    const hasInput = edges.value.some(e => e.target === elemNode.id)
+    if (!hasInput) {
+      errors.push({ type: 'error', nodeId: elemNode.id, message: `输出节点 "${elemNode.data.label}" 没有输入连接` })
+    }
+  })
+  
+  validationErrors.value = errors
+  showValidation.value = true
+  
+  if (errors.length === 0) {
+    ElMessage.success('工作流验证通过')
+  } else {
+    const errorCount = errors.filter(e => e.type === 'error').length
+    const warningCount = errors.filter(e => e.type === 'warning').length
+    ElMessage.warning(`发现 ${errorCount} 个错误,${warningCount} 个警告`)
+  }
+  
+  return errors.filter(e => e.type === 'error').length === 0
+}
+
+function highlightNode(nodeId) {
+  const node = nodes.value.find(n => n.id === nodeId)
+  if (node) {
+    selectedNode.value = node
+    // 滚动到节点位置
+  }
+}
+
+function handleSave() {
+  if (!validateWorkflow()) {
+    ElMessageBox.confirm('工作流存在错误,是否仍要保存?', '验证警告', {
+      confirmButtonText: '继续保存',
+      cancelButtonText: '返回修改',
+      type: 'warning'
+    }).then(() => {
+      showRulePreview()
+    }).catch(() => {})
+  } else {
+    showRulePreview()
+  }
+}
+
+function showRulePreview() {
+  const rules = generateRulesFromWorkflow()
+  if (rules.length === 0) {
+    ElMessage.warning('没有可保存的规则,请确保有完整的数据流(来源 → 输出)')
+    return
+  }
+  previewRules.value = rules
+  showPreview.value = true
+}
+
+function generateRulesFromWorkflow() {
+  const rules = []
+  const elementNodes = nodes.value.filter(n => n.type === 'element')
+  
+  function traceDataFlow(nodeId, visited = new Set()) {
+    if (visited.has(nodeId)) return { sources: [], actions: [] }
+    visited.add(nodeId)
+    
+    const node = nodes.value.find(n => n.id === nodeId)
+    if (!node) return { sources: [], actions: [] }
+    
+    if (node.type === 'source') {
+      return { sources: [node], actions: [] }
+    }
+    
+    if (node.type === 'action') {
+      const inEdges = edges.value.filter(e => e.target === nodeId)
+      let allSources = []
+      let allActions = [node]
+      
+      for (const edge of inEdges) {
+        const upstream = traceDataFlow(edge.source, visited)
+        allSources = [...allSources, ...upstream.sources]
+        allActions = [...allActions, ...upstream.actions]
+      }
+      
+      return { sources: allSources, actions: allActions }
+    }
+    
+    return { sources: [], actions: [] }
+  }
+  
+  for (const elementNode of elementNodes) {
+    if (!elementNode.data.elementKey) continue
+    
+    const incomingEdges = edges.value.filter(e => e.target === elementNode.id)
+    if (incomingEdges.length === 0) continue
+    
+    let allSources = []
+    let allActions = []
+    
+    for (const edge of incomingEdges) {
+      const { sources, actions } = traceDataFlow(edge.source)
+      allSources = [...allSources, ...sources]
+      allActions = [...allActions, ...actions]
+    }
+    
+    const uniqueSources = [...new Map(allSources.map(s => [s.id, s])).values()]
+    const uniqueActions = [...new Map(allActions.map(a => [a.id, a])).values()]
+    
+    const directInputNode = nodes.value.find(n => n.id === incomingEdges[0].source)
+    let primaryAction = directInputNode?.type === 'action' ? directInputNode : uniqueActions[0]
+    
+    const actionType = primaryAction?.data?.actionType || primaryAction?.data?.subType || 'quote'
+    
+    rules.push({
+      elementKey: elementNode.data.elementKey,
+      elementName: elementNode.data.elementName || elementNode.data.label,
+      actionType: actionType,
+      actionLabel: getActionLabel(actionType),
+      prompt: primaryAction?.data?.prompt || '',
+      sources: uniqueSources.map(s => ({
+        name: s.data.sourceName || s.data.label,
+        type: s.data.subType,
+        locatorType: s.data.locatorType
+      })),
+      actionsCount: uniqueActions.length
+    })
+  }
+  
+  return rules
+}
+
+function confirmSave() {
+  showPreview.value = false
+  doSave()
+}
+
+function doSave() {
+  const workflowData = {
+    nodes: nodes.value,
+    edges: edges.value
+  }
+  emit('save', workflowData)
+}
+
+function handleClear() {
+  ElMessageBox.confirm('确定要清空画布吗?此操作不可撤销。', '确认清空', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(() => {
+    saveHistory()
+    nodes.value = []
+    edges.value = []
+    selectedNode.value = null
+    selectedEdge.value = null
+    ElMessage.success('画布已清空')
+  }).catch(() => {})
+}
+
+function handleFitView() {
+  fitView({ padding: 0.2 })
+}
+
+// 快捷键
+function handleKeydown(event) {
+  // 忽略输入框中的快捷键
+  if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) return
+  
+  const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
+  const ctrlKey = isMac ? event.metaKey : event.ctrlKey
+  
+  switch (event.key) {
+    case 'Delete':
+    case 'Backspace':
+      if (selectedNode.value) {
+        handleDeleteNode(selectedNode.value.id)
+      } else if (selectedEdge.value) {
+        handleDeleteEdge(selectedEdge.value.id)
+      }
+      event.preventDefault()
+      break
+    case 'z':
+      if (ctrlKey && event.shiftKey) {
+        redo()
+        event.preventDefault()
+      } else if (ctrlKey) {
+        undo()
+        event.preventDefault()
+      }
+      break
+    case 'y':
+      if (ctrlKey) {
+        redo()
+        event.preventDefault()
+      }
+      break
+    case 'c':
+      if (ctrlKey && selectedNode.value) {
+        copyNode(selectedNode.value)
+        event.preventDefault()
+      }
+      break
+    case 'v':
+      if (ctrlKey) {
+        pasteNode()
+        event.preventDefault()
+      }
+      break
+    case 'd':
+      if (ctrlKey && selectedNode.value) {
+        duplicateNode(selectedNode.value)
+        event.preventDefault()
+      }
+      break
+    case 's':
+      if (ctrlKey) {
+        handleSave()
+        event.preventDefault()
+      }
+      break
+    case 'Escape':
+      selectedNode.value = null
+      selectedEdge.value = null
+      hideContextMenu()
+      break
+    case 'a':
+      if (ctrlKey) {
+        // 全选节点
+        event.preventDefault()
+      }
+      break
+  }
+}
+
+onMounted(() => {
+  if (props.rules && props.rules.length > 0) {
+    loadRulesAsWorkflow(props.rules)
+  }
+  
+  // 保存初始状态
+  saveHistory()
+  
+  // 注册快捷键
+  window.addEventListener('keydown', handleKeydown)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('keydown', handleKeydown)
+})
+
+function loadRulesAsWorkflow(rules) {
+  const newNodes = []
+  const newEdges = []
+  let xOffset = 100
+  let yOffset = 100
+  
+  rules.forEach((rule, index) => {
+    const y = yOffset + index * 150
+    
+    if (rule.inputs && rule.inputs.length > 0) {
+      const input = rule.inputs[0]
+      const sourceId = `source-${rule.id}-${index}`
+      newNodes.push({
+        id: sourceId,
+        type: 'source',
+        position: { x: xOffset, y },
+        data: {
+          nodeType: 'source',
+          subType: input.inputType || 'attachment',
+          label: input.sourceName || input.inputName || '来源',
+          sourceNodeId: input.sourceNodeId,
+          sourceName: input.sourceName || input.inputName,
+          sourceText: input.sourceText
+        }
+      })
+      
+      if (rule.actionType && rule.actionType !== 'quote') {
+        const actionId = `action-${rule.id}-${index}`
+        newNodes.push({
+          id: actionId,
+          type: 'action',
+          position: { x: xOffset + 200, y },
+          data: {
+            nodeType: 'action',
+            subType: rule.actionType,
+            label: getActionLabel(rule.actionType),
+            actionType: rule.actionType,
+            prompt: rule.actionConfig ? JSON.parse(rule.actionConfig).prompt : ''
+          }
+        })
+        
+        newEdges.push({
+          id: `edge-${sourceId}-${actionId}`,
+          source: sourceId,
+          target: actionId,
+          animated: true,
+          style: { stroke: '#409eff', strokeWidth: 2 }
+        })
+        
+        const elementId = `element-${rule.id}-${index}`
+        newNodes.push({
+          id: elementId,
+          type: 'element',
+          position: { x: xOffset + 400, y },
+          data: {
+            nodeType: 'element',
+            label: rule.elementKey,
+            elementKey: rule.elementKey,
+            elementName: getElementName(rule.elementKey)
+          }
+        })
+        
+        newEdges.push({
+          id: `edge-${actionId}-${elementId}`,
+          source: actionId,
+          target: elementId,
+          animated: true,
+          style: { stroke: '#409eff', strokeWidth: 2 }
+        })
+      } else {
+        const elementId = `element-${rule.id}-${index}`
+        newNodes.push({
+          id: elementId,
+          type: 'element',
+          position: { x: xOffset + 250, y },
+          data: {
+            nodeType: 'element',
+            label: rule.elementKey,
+            elementKey: rule.elementKey,
+            elementName: getElementName(rule.elementKey)
+          }
+        })
+        
+        newEdges.push({
+          id: `edge-${sourceId}-${elementId}`,
+          source: sourceId,
+          target: elementId,
+          animated: true,
+          style: { stroke: '#67c23a', strokeWidth: 2 }
+        })
+      }
+    }
+  })
+  
+  nodes.value = newNodes
+  edges.value = newEdges
+  
+  setTimeout(() => fitView({ padding: 0.2 }), 100)
+}
+
+function getActionLabel(actionType) {
+  const labels = {
+    quote: '引用',
+    summary: 'AI 总结',
+    ai_extract: 'AI 提取',
+    table_extract: '表格提取'
+  }
+  return labels[actionType] || actionType
+}
+
+function getElementName(elementKey) {
+  const elem = props.elements.find(e => e.elementKey === elementKey)
+  return elem ? elem.elementName : elementKey
+}
+
+function getActionTagType(actionType) {
+  const types = {
+    quote: 'success',
+    summary: 'warning',
+    ai_extract: '',
+    table_extract: 'info'
+  }
+  return types[actionType] || 'info'
+}
+
+defineExpose({
+  handleSave,
+  handleClear,
+  handleFitView,
+  undo,
+  redo,
+  validateWorkflow
+})
+</script>
+
+<template>
+  <div class="rule-workflow" @click="hideContextMenu">
+    <div class="workflow-toolbar">
+      <div class="toolbar-left">
+        <el-button-group>
+          <el-button size="small" :disabled="!canUndo" @click="undo" title="撤销 (Ctrl+Z)">
+            ↩️ 撤销
+          </el-button>
+          <el-button size="small" :disabled="!canRedo" @click="redo" title="重做 (Ctrl+Y)">
+            ↪️ 重做
+          </el-button>
+        </el-button-group>
+        
+        <el-divider direction="vertical" />
+        
+        <el-button size="small" @click="handleFitView" title="适应视图">📐 适应</el-button>
+        <el-button size="small" @click="validateWorkflow" title="验证工作流">✅ 验证</el-button>
+        <el-button size="small" type="danger" plain @click="handleClear">🗑️ 清空</el-button>
+        
+        <el-divider direction="vertical" />
+        
+        <el-button type="primary" size="small" @click="handleSave" title="保存 (Ctrl+S)">
+          💾 保存规则
+        </el-button>
+      </div>
+      <div class="toolbar-right">
+        <span class="workflow-stats">
+          节点: {{ nodes.length }} | 连线: {{ edges.length }}
+        </span>
+        <el-tag v-if="validationErrors.length > 0" type="warning" size="small">
+          {{ validationErrors.filter(e => e.type === 'error').length }} 错误
+        </el-tag>
+      </div>
+    </div>
+    
+    <!-- 验证结果面板 -->
+    <div class="validation-panel" v-if="showValidation && validationErrors.length > 0">
+      <div class="validation-header">
+        <span>验证结果</span>
+        <el-button text size="small" @click="showValidation = false">✕</el-button>
+      </div>
+      <div class="validation-list">
+        <div 
+          v-for="(err, idx) in validationErrors" 
+          :key="idx" 
+          class="validation-item"
+          :class="err.type"
+          @click="highlightNode(err.nodeId)"
+        >
+          <span class="validation-icon">{{ err.type === 'error' ? '❌' : '⚠️' }}</span>
+          <span class="validation-msg">{{ err.message }}</span>
+        </div>
+      </div>
+    </div>
+    
+    <div class="workflow-container">
+      <NodePanel 
+        :attachments="attachments" 
+        :elements="elements"
+        class="workflow-node-panel"
+      />
+      
+      <div class="workflow-canvas" @dragover="onDragOver" @drop="onDrop">
+        <VueFlow
+          v-model:nodes="nodes"
+          v-model:edges="edges"
+          :node-types="nodeTypes"
+          :default-viewport="{ zoom: 1, x: 0, y: 0 }"
+          :min-zoom="0.2"
+          :max-zoom="2"
+          fit-view-on-init
+          class="vue-flow-wrapper"
+        >
+          <Background pattern-color="#aaa" :gap="16" />
+          <Controls position="bottom-left" />
+          <MiniMap position="bottom-right" />
+        </VueFlow>
+        
+        <!-- 右键菜单 -->
+        <div 
+          v-if="contextMenu.visible" 
+          class="context-menu"
+          :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
+          @click.stop
+        >
+          <template v-if="contextMenu.type === 'node'">
+            <div class="context-menu-item" @click="handleContextMenuAction('copy')">
+              📋 复制 <span class="shortcut">Ctrl+C</span>
+            </div>
+            <div class="context-menu-item" @click="handleContextMenuAction('duplicate')">
+              📑 复制节点 <span class="shortcut">Ctrl+D</span>
+            </div>
+            <div class="context-menu-divider"></div>
+            <div class="context-menu-item danger" @click="handleContextMenuAction('delete')">
+              🗑️ 删除 <span class="shortcut">Delete</span>
+            </div>
+          </template>
+          <template v-else-if="contextMenu.type === 'edge'">
+            <div class="context-menu-item danger" @click="handleContextMenuAction('delete')">
+              🗑️ 删除连线 <span class="shortcut">Delete</span>
+            </div>
+          </template>
+          <template v-else>
+            <div class="context-menu-item" @click="handleContextMenuAction('paste')" :class="{ disabled: !clipboard }">
+              📋 粘贴 <span class="shortcut">Ctrl+V</span>
+            </div>
+            <div class="context-menu-divider"></div>
+            <div class="context-menu-item" @click="handleFitView">
+              📐 适应视图
+            </div>
+          </template>
+        </div>
+      </div>
+      
+      <PropertyPanel
+        :selected-node="selectedNode"
+        :selected-edge="selectedEdge"
+        :attachments="attachments"
+        :elements="elements"
+        class="workflow-property-panel"
+        @update-node="handleNodeUpdate"
+        @delete-node="handleDeleteNode"
+        @delete-edge="handleDeleteEdge"
+      />
+    </div>
+    
+    <!-- 快捷键提示 -->
+    <div class="shortcuts-hint">
+      <span>快捷键: Ctrl+S 保存 | Ctrl+Z 撤销 | Ctrl+Y 重做 | Delete 删除 | Ctrl+C/V 复制粘贴</span>
+    </div>
+    
+    <!-- 规则预览弹窗 -->
+    <el-dialog
+      v-model="showPreview"
+      title="📋 规则预览"
+      width="700"
+      :close-on-click-modal="false"
+    >
+      <div class="preview-content">
+        <p class="preview-desc">将创建以下 <strong>{{ previewRules.length }}</strong> 条规则:</p>
+        
+        <div class="preview-list">
+          <div 
+            v-for="(rule, idx) in previewRules" 
+            :key="idx" 
+            class="preview-item"
+            :class="{ expanded: expandedRuleIdx === idx }"
+          >
+            <div class="preview-header" @click="expandedRuleIdx = expandedRuleIdx === idx ? null : idx">
+              <span class="preview-index">{{ idx + 1 }}</span>
+              <span class="preview-element">{{ rule.elementName }}</span>
+              <el-tag size="small" :type="getActionTagType(rule.actionType)">
+                {{ rule.actionLabel }}
+              </el-tag>
+              <span class="preview-sources-count" v-if="rule.sources.length > 0">
+                📎 {{ rule.sources.length }}
+              </span>
+              <span class="preview-expand-icon">{{ expandedRuleIdx === idx ? '▼' : '▶' }}</span>
+            </div>
+            <div class="preview-body" v-show="expandedRuleIdx === idx">
+              <div class="preview-row">
+                <span class="preview-label">要素标识:</span>
+                <code class="preview-value">{{ rule.elementKey }}</code>
+              </div>
+              <div class="preview-row" v-if="rule.sources.length > 0">
+                <span class="preview-label">数据来源:</span>
+                <span class="preview-value">
+                  <el-tag v-for="(src, i) in rule.sources" :key="i" size="small" type="info" class="source-tag">
+                    📎 {{ src.name }}
+                  </el-tag>
+                </span>
+              </div>
+              <div class="preview-row" v-if="rule.prompt">
+                <span class="preview-label">提示词:</span>
+                <span class="preview-value preview-prompt">{{ rule.prompt }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+      
+      <template #footer>
+        <el-button @click="showPreview = false">取消</el-button>
+        <el-button type="primary" @click="confirmSave">
+          确认保存 ({{ previewRules.length }} 条规则)
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.rule-workflow {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  background: #f5f7fa;
+}
+
+.workflow-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 16px;
+  background: white;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.toolbar-left {
+  display: flex;
+  gap: 8px;
+}
+
+.toolbar-right {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.workflow-stats {
+  font-size: 13px;
+  color: #909399;
+}
+
+.workflow-container {
+  display: flex;
+  flex: 1;
+  overflow: hidden;
+}
+
+.workflow-node-panel {
+  width: 220px;
+  flex-shrink: 0;
+  background: white;
+  border-right: 1px solid #e4e7ed;
+  overflow-y: auto;
+}
+
+.workflow-canvas {
+  flex: 1;
+  position: relative;
+}
+
+.vue-flow-wrapper {
+  width: 100%;
+  height: 100%;
+}
+
+.workflow-property-panel {
+  width: 300px;
+  flex-shrink: 0;
+  background: white;
+  border-left: 1px solid #e4e7ed;
+  overflow-y: auto;
+}
+
+:deep(.vue-flow__node) {
+  cursor: grab;
+}
+
+:deep(.vue-flow__node:active) {
+  cursor: grabbing;
+}
+
+:deep(.vue-flow__edge-path) {
+  stroke-width: 2;
+}
+
+:deep(.vue-flow__handle) {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  background: #409eff;
+  border: 2px solid white;
+}
+
+:deep(.vue-flow__handle-left) {
+  left: -6px;
+}
+
+:deep(.vue-flow__handle-right) {
+  right: -6px;
+}
+
+/* 验证面板 */
+.validation-panel {
+  background: #fef0f0;
+  border-bottom: 1px solid #fbc4c4;
+  padding: 8px 16px;
+}
+
+.validation-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 13px;
+  font-weight: 500;
+  color: #f56c6c;
+  margin-bottom: 8px;
+}
+
+.validation-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.validation-item {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 4px;
+  font-size: 12px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.validation-item.error {
+  background: #fef0f0;
+  color: #f56c6c;
+  border: 1px solid #fbc4c4;
+}
+
+.validation-item.warning {
+  background: #fdf6ec;
+  color: #e6a23c;
+  border: 1px solid #f5dab1;
+}
+
+.validation-item:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 2px 6px rgba(0,0,0,0.1);
+}
+
+.validation-icon {
+  font-size: 12px;
+}
+
+.validation-msg {
+  max-width: 300px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* 右键菜单 */
+.context-menu {
+  position: fixed;
+  background: white;
+  border-radius: 8px;
+  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
+  min-width: 180px;
+  padding: 6px 0;
+  z-index: 1000;
+}
+
+.context-menu-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 16px;
+  font-size: 13px;
+  color: #303133;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+.context-menu-item:hover {
+  background: #f5f7fa;
+}
+
+.context-menu-item.danger {
+  color: #f56c6c;
+}
+
+.context-menu-item.danger:hover {
+  background: #fef0f0;
+}
+
+.context-menu-item.disabled {
+  color: #c0c4cc;
+  cursor: not-allowed;
+}
+
+.context-menu-item.disabled:hover {
+  background: transparent;
+}
+
+.context-menu-item .shortcut {
+  font-size: 11px;
+  color: #909399;
+  margin-left: 20px;
+}
+
+.context-menu-divider {
+  height: 1px;
+  background: #e4e7ed;
+  margin: 6px 0;
+}
+
+/* 快捷键提示 */
+.shortcuts-hint {
+  padding: 8px 16px;
+  background: #f5f7fa;
+  border-top: 1px solid #e4e7ed;
+  font-size: 11px;
+  color: #909399;
+  text-align: center;
+}
+
+/* 工具栏分隔线 */
+.toolbar-left :deep(.el-divider--vertical) {
+  height: 20px;
+  margin: 0 8px;
+}
+
+/* 规则预览弹窗 */
+.preview-content {
+  max-height: 60vh;
+  overflow-y: auto;
+}
+
+.preview-desc {
+  margin-bottom: 16px;
+  color: #606266;
+  font-size: 14px;
+}
+
+.preview-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.preview-item {
+  border: 1px solid #e4e7ed;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.preview-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 12px 16px;
+  background: #f5f7fa;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.preview-header:hover {
+  background: #ebeef5;
+}
+
+.preview-item.expanded .preview-header {
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.preview-sources-count {
+  font-size: 12px;
+  color: #909399;
+}
+
+.preview-expand-icon {
+  margin-left: auto;
+  font-size: 10px;
+  color: #909399;
+}
+
+.preview-index {
+  width: 24px;
+  height: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #409eff;
+  color: white;
+  border-radius: 50%;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.preview-element {
+  flex: 1;
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.preview-body {
+  padding: 12px 16px;
+}
+
+.preview-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.preview-row:last-child {
+  margin-bottom: 0;
+}
+
+.preview-label {
+  flex-shrink: 0;
+  width: 70px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.preview-value {
+  flex: 1;
+  font-size: 13px;
+  color: #303133;
+}
+
+.preview-value code {
+  background: #f5f7fa;
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-family: monospace;
+  font-size: 12px;
+}
+
+.preview-prompt {
+  background: #fdf6ec;
+  padding: 6px 10px;
+  border-radius: 4px;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #e6a23c;
+}
+
+.source-tag {
+  margin-right: 6px;
+  margin-bottom: 4px;
+}
+</style>

+ 110 - 0
frontend/vue-demo/src/components/workflow/nodes/ActionNode.vue

@@ -0,0 +1,110 @@
+<script setup>
+import { computed } from 'vue'
+import { Handle, Position } from '@vue-flow/core'
+
+const props = defineProps({
+  data: { type: Object, required: true },
+  selected: { type: Boolean, default: false }
+})
+
+const icon = computed(() => {
+  const icons = {
+    quote: '📌',
+    summary: '✨',
+    ai_extract: '🔍',
+    table_extract: '📊'
+  }
+  return icons[props.data.actionType || props.data.subType] || '⚙️'
+})
+
+const actionLabel = computed(() => {
+  const labels = {
+    quote: '引用',
+    summary: 'AI 总结',
+    ai_extract: 'AI 提取',
+    table_extract: '表格提取'
+  }
+  return labels[props.data.actionType || props.data.subType] || '动作'
+})
+
+const borderColor = computed(() => {
+  const colors = {
+    quote: '#67c23a',
+    summary: '#e6a23c',
+    ai_extract: '#409eff',
+    table_extract: '#909399'
+  }
+  return colors[props.data.actionType || props.data.subType] || '#ddd'
+})
+</script>
+
+<template>
+  <div 
+    class="workflow-node action-node" 
+    :class="{ selected }"
+    :style="{ borderColor }"
+  >
+    <Handle type="target" :position="Position.Left" />
+    <Handle type="source" :position="Position.Right" />
+    
+    <div class="node-header">
+      <span class="node-icon">{{ icon }}</span>
+      <span class="node-type">{{ actionLabel }}</span>
+    </div>
+    
+    <div class="node-body" v-if="data.prompt">
+      <div class="node-prompt">
+        {{ data.prompt.slice(0, 40) }}{{ data.prompt.length > 40 ? '...' : '' }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.workflow-node {
+  min-width: 120px;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  transition: all 0.2s;
+}
+
+.workflow-node.selected {
+  box-shadow: 0 0 0 2px #409eff, 0 4px 12px rgba(64, 158, 255, 0.3);
+}
+
+.action-node {
+  background: white;
+  border: 2px solid #ddd;
+}
+
+.node-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 10px 14px;
+  font-size: 13px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.node-icon {
+  font-size: 16px;
+}
+
+.node-type {
+  color: #606266;
+}
+
+.node-body {
+  padding: 0 14px 10px;
+}
+
+.node-prompt {
+  font-size: 11px;
+  color: #909399;
+  line-height: 1.4;
+  background: #f5f7fa;
+  padding: 6px 8px;
+  border-radius: 4px;
+}
+</style>

+ 86 - 0
frontend/vue-demo/src/components/workflow/nodes/ElementNode.vue

@@ -0,0 +1,86 @@
+<script setup>
+import { computed } from 'vue'
+import { Handle, Position } from '@vue-flow/core'
+
+const props = defineProps({
+  data: { type: Object, required: true },
+  selected: { type: Boolean, default: false }
+})
+
+const displayName = computed(() => {
+  return props.data.elementName || props.data.label || props.data.elementKey || '要素'
+})
+
+const displayKey = computed(() => {
+  return props.data.elementKey || ''
+})
+</script>
+
+<template>
+  <div class="workflow-node element-node" :class="{ selected }">
+    <Handle type="target" :position="Position.Left" />
+    
+    <div class="node-header">
+      <span class="node-icon">🏷️</span>
+      <span class="node-type">输出要素</span>
+    </div>
+    
+    <div class="node-body">
+      <div class="node-label">{{ displayName }}</div>
+      <div class="node-key" v-if="displayKey">{{ displayKey }}</div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.workflow-node {
+  min-width: 140px;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  transition: all 0.2s;
+}
+
+.workflow-node.selected {
+  box-shadow: 0 0 0 2px #409eff, 0 4px 12px rgba(64, 158, 255, 0.3);
+}
+
+.element-node {
+  background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
+  color: white;
+}
+
+.node-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 12px;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.2);
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.node-icon {
+  font-size: 14px;
+}
+
+.node-type {
+  opacity: 0.9;
+}
+
+.node-body {
+  padding: 10px 12px;
+}
+
+.node-label {
+  font-size: 13px;
+  font-weight: 500;
+  line-height: 1.4;
+}
+
+.node-key {
+  margin-top: 4px;
+  font-size: 11px;
+  opacity: 0.85;
+  font-family: monospace;
+}
+</style>

+ 106 - 0
frontend/vue-demo/src/components/workflow/nodes/SourceNode.vue

@@ -0,0 +1,106 @@
+<script setup>
+import { computed } from 'vue'
+import { Handle, Position } from '@vue-flow/core'
+
+const props = defineProps({
+  data: { type: Object, required: true },
+  selected: { type: Boolean, default: false }
+})
+
+const icon = computed(() => {
+  const icons = {
+    document: '📄',
+    attachment: '📎',
+    excerpt: '✂️'
+  }
+  return icons[props.data.subType] || '📎'
+})
+
+const nodeClass = computed(() => {
+  return `source-node-${props.data.subType || 'attachment'}`
+})
+</script>
+
+<template>
+  <div class="workflow-node source-node" :class="[nodeClass, { selected }]">
+    <Handle type="source" :position="Position.Right" />
+    
+    <div class="node-header">
+      <span class="node-icon">{{ icon }}</span>
+      <span class="node-type">{{ data.subType === 'document' ? '原文' : data.subType === 'excerpt' ? '摘选' : '附件' }}</span>
+    </div>
+    
+    <div class="node-body">
+      <div class="node-label">{{ data.sourceName || data.label || '未选择' }}</div>
+      <div class="node-desc" v-if="data.sourceText">
+        {{ data.sourceText.slice(0, 30) }}{{ data.sourceText.length > 30 ? '...' : '' }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.workflow-node {
+  min-width: 160px;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  transition: all 0.2s;
+}
+
+.workflow-node.selected {
+  box-shadow: 0 0 0 2px #409eff, 0 4px 12px rgba(64, 158, 255, 0.3);
+}
+
+.source-node {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+}
+
+.source-node-document {
+  background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
+}
+
+.source-node-attachment {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.source-node-excerpt {
+  background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+}
+
+.node-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 12px;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.2);
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.node-icon {
+  font-size: 14px;
+}
+
+.node-type {
+  opacity: 0.9;
+}
+
+.node-body {
+  padding: 10px 12px;
+}
+
+.node-label {
+  font-size: 13px;
+  font-weight: 500;
+  line-height: 1.4;
+  word-break: break-all;
+}
+
+.node-desc {
+  margin-top: 6px;
+  font-size: 11px;
+  opacity: 0.8;
+  line-height: 1.3;
+}
+</style>

+ 350 - 0
frontend/vue-demo/src/components/workflow/panels/NodePanel.vue

@@ -0,0 +1,350 @@
+<script setup>
+import { ref, computed } from 'vue'
+
+const props = defineProps({
+  attachments: { type: Array, default: () => [] },
+  elements: { type: Array, default: () => [] }
+})
+
+const searchQuery = ref('')
+const expandedGroups = ref(['source', 'action', 'element'])
+
+const sourceNodes = [
+  { subType: 'document', label: '原文', icon: '📄', desc: '项目原文档' },
+  { subType: 'attachment', label: '附件', icon: '📎', desc: '已上传的附件' },
+  { subType: 'excerpt', label: '摘选', icon: '✂️', desc: '框选的文本片段' }
+]
+
+const actionNodes = [
+  { subType: 'quote', label: '引用', icon: '📌', desc: '直接使用来源文本', color: '#67c23a' },
+  { subType: 'summary', label: 'AI 总结', icon: '✨', desc: 'AI 总结内容', color: '#e6a23c' },
+  { subType: 'ai_extract', label: 'AI 提取', icon: '🔍', desc: 'AI 提取特定信息', color: '#409eff' },
+  { subType: 'table_extract', label: '表格提取', icon: '📊', desc: '从表格提取数据', color: '#909399' }
+]
+
+const filteredAttachments = computed(() => {
+  if (!searchQuery.value) return props.attachments
+  const q = searchQuery.value.toLowerCase()
+  return props.attachments.filter(a => 
+    (a.fileName || '').toLowerCase().includes(q) ||
+    (a.displayName || '').toLowerCase().includes(q)
+  )
+})
+
+const filteredElements = computed(() => {
+  if (!searchQuery.value) return props.elements
+  const q = searchQuery.value.toLowerCase()
+  return props.elements.filter(e => 
+    (e.elementName || '').toLowerCase().includes(q) ||
+    (e.elementKey || '').toLowerCase().includes(q)
+  )
+})
+
+function toggleGroup(group) {
+  const idx = expandedGroups.value.indexOf(group)
+  if (idx >= 0) {
+    expandedGroups.value.splice(idx, 1)
+  } else {
+    expandedGroups.value.push(group)
+  }
+}
+
+function onDragStart(event, nodeType, data) {
+  const payload = {
+    nodeType,
+    ...data
+  }
+  event.dataTransfer.setData('application/vueflow', JSON.stringify(payload))
+  event.dataTransfer.effectAllowed = 'move'
+}
+
+function onDragStartAttachment(event, attachment) {
+  const payload = {
+    nodeType: 'source',
+    subType: 'attachment',
+    label: attachment.displayName || attachment.fileName,
+    sourceNodeId: attachment.id,
+    sourceName: attachment.displayName || attachment.fileName
+  }
+  event.dataTransfer.setData('application/vueflow', JSON.stringify(payload))
+  event.dataTransfer.effectAllowed = 'move'
+}
+
+function onDragStartElement(event, element) {
+  const payload = {
+    nodeType: 'element',
+    label: element.elementName,
+    elementKey: element.elementKey,
+    elementName: element.elementName
+  }
+  event.dataTransfer.setData('application/vueflow', JSON.stringify(payload))
+  event.dataTransfer.effectAllowed = 'move'
+}
+</script>
+
+<template>
+  <div class="node-panel">
+    <div class="panel-header">
+      <span class="panel-title">节点面板</span>
+    </div>
+    
+    <div class="panel-search">
+      <el-input
+        v-model="searchQuery"
+        size="small"
+        placeholder="搜索节点..."
+        clearable
+        :prefix-icon="'Search'"
+      />
+    </div>
+    
+    <div class="panel-content">
+      <!-- 来源节点组 -->
+      <div class="node-group">
+        <div class="group-header" @click="toggleGroup('source')">
+          <span class="group-icon">{{ expandedGroups.includes('source') ? '▼' : '▶' }}</span>
+          <span class="group-title">来源节点</span>
+          <span class="group-count">{{ sourceNodes.length }}</span>
+        </div>
+        <div class="group-content" v-show="expandedGroups.includes('source')">
+          <div
+            v-for="node in sourceNodes"
+            :key="node.subType"
+            class="node-item source-item"
+            :class="`source-${node.subType}`"
+            draggable="true"
+            @dragstart="onDragStart($event, 'source', node)"
+          >
+            <span class="node-icon">{{ node.icon }}</span>
+            <div class="node-info">
+              <span class="node-label">{{ node.label }}</span>
+              <span class="node-desc">{{ node.desc }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+      
+      <!-- 动作节点组 -->
+      <div class="node-group">
+        <div class="group-header" @click="toggleGroup('action')">
+          <span class="group-icon">{{ expandedGroups.includes('action') ? '▼' : '▶' }}</span>
+          <span class="group-title">动作节点</span>
+          <span class="group-count">{{ actionNodes.length }}</span>
+        </div>
+        <div class="group-content" v-show="expandedGroups.includes('action')">
+          <div
+            v-for="node in actionNodes"
+            :key="node.subType"
+            class="node-item action-item"
+            :style="{ borderLeftColor: node.color }"
+            draggable="true"
+            @dragstart="onDragStart($event, 'action', { ...node, actionType: node.subType })"
+          >
+            <span class="node-icon">{{ node.icon }}</span>
+            <div class="node-info">
+              <span class="node-label">{{ node.label }}</span>
+              <span class="node-desc">{{ node.desc }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+      
+      <!-- 附件列表 -->
+      <div class="node-group" v-if="attachments.length > 0">
+        <div class="group-header" @click="toggleGroup('attachments')">
+          <span class="group-icon">{{ expandedGroups.includes('attachments') ? '▼' : '▶' }}</span>
+          <span class="group-title">项目附件</span>
+          <span class="group-count">{{ filteredAttachments.length }}</span>
+        </div>
+        <div class="group-content" v-show="expandedGroups.includes('attachments')">
+          <div
+            v-for="att in filteredAttachments"
+            :key="att.id"
+            class="node-item attachment-item"
+            draggable="true"
+            @dragstart="onDragStartAttachment($event, att)"
+          >
+            <span class="node-icon">📎</span>
+            <div class="node-info">
+              <span class="node-label">{{ att.displayName || att.fileName }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+      
+      <!-- 要素列表 -->
+      <div class="node-group">
+        <div class="group-header" @click="toggleGroup('element')">
+          <span class="group-icon">{{ expandedGroups.includes('element') ? '▼' : '▶' }}</span>
+          <span class="group-title">输出要素</span>
+          <span class="group-count">{{ filteredElements.length }}</span>
+        </div>
+        <div class="group-content" v-show="expandedGroups.includes('element')">
+          <div
+            v-for="elem in filteredElements"
+            :key="elem.elementKey"
+            class="node-item element-item"
+            draggable="true"
+            @dragstart="onDragStartElement($event, elem)"
+          >
+            <span class="node-icon">🏷️</span>
+            <div class="node-info">
+              <span class="node-label">{{ elem.elementName }}</span>
+              <span class="node-desc">{{ elem.elementKey }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.node-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.panel-header {
+  padding: 14px 16px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.panel-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.panel-search {
+  padding: 12px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.panel-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px 0;
+}
+
+.node-group {
+  margin-bottom: 4px;
+}
+
+.group-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 16px;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.2s;
+}
+
+.group-header:hover {
+  background: #f5f7fa;
+}
+
+.group-icon {
+  font-size: 10px;
+  color: #909399;
+  width: 12px;
+}
+
+.group-title {
+  flex: 1;
+  font-size: 13px;
+  font-weight: 500;
+  color: #606266;
+}
+
+.group-count {
+  font-size: 12px;
+  color: #909399;
+  background: #f0f2f5;
+  padding: 2px 8px;
+  border-radius: 10px;
+}
+
+.group-content {
+  padding: 4px 8px;
+}
+
+.node-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 12px;
+  margin: 4px 0;
+  border-radius: 6px;
+  cursor: grab;
+  transition: all 0.2s;
+  border: 1px solid transparent;
+}
+
+.node-item:hover {
+  background: #f5f7fa;
+  border-color: #e4e7ed;
+}
+
+.node-item:active {
+  cursor: grabbing;
+  transform: scale(0.98);
+}
+
+.source-item {
+  background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
+}
+
+.source-item.source-document {
+  background: linear-gradient(135deg, rgba(17, 153, 142, 0.1) 0%, rgba(56, 239, 125, 0.1) 100%);
+}
+
+.source-item.source-excerpt {
+  background: linear-gradient(135deg, rgba(240, 147, 251, 0.1) 0%, rgba(245, 87, 108, 0.1) 100%);
+}
+
+.action-item {
+  background: white;
+  border-left: 3px solid #ddd;
+}
+
+.attachment-item {
+  background: rgba(102, 126, 234, 0.05);
+}
+
+.element-item {
+  background: linear-gradient(135deg, rgba(250, 112, 154, 0.1) 0%, rgba(254, 225, 64, 0.1) 100%);
+}
+
+.node-icon {
+  font-size: 18px;
+  flex-shrink: 0;
+}
+
+.node-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.node-label {
+  font-size: 13px;
+  font-weight: 500;
+  color: #303133;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.node-desc {
+  font-size: 11px;
+  color: #909399;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

+ 398 - 0
frontend/vue-demo/src/components/workflow/panels/PropertyPanel.vue

@@ -0,0 +1,398 @@
+<script setup>
+import { ref, computed, watch } from 'vue'
+
+const props = defineProps({
+  selectedNode: { type: Object, default: null },
+  selectedEdge: { type: Object, default: null },
+  attachments: { type: Array, default: () => [] },
+  elements: { type: Array, default: () => [] }
+})
+
+const emit = defineEmits(['update-node', 'delete-node', 'delete-edge'])
+
+const localData = ref({})
+
+watch(() => props.selectedNode, (node) => {
+  if (node) {
+    localData.value = { ...node.data }
+  } else {
+    localData.value = {}
+  }
+}, { immediate: true, deep: true })
+
+const nodeTypeLabel = computed(() => {
+  if (!props.selectedNode) return ''
+  const labels = {
+    source: '来源节点',
+    action: '动作节点',
+    element: '输出节点'
+  }
+  return labels[props.selectedNode.type] || '节点'
+})
+
+const sourceTypeLabel = computed(() => {
+  const labels = {
+    document: '原文',
+    attachment: '附件',
+    excerpt: '摘选'
+  }
+  return labels[localData.value.subType] || '来源'
+})
+
+const actionTypeOptions = [
+  { value: 'quote', label: '引用', icon: '📌' },
+  { value: 'summary', label: 'AI 总结', icon: '✨' },
+  { value: 'ai_extract', label: 'AI 提取', icon: '🔍' },
+  { value: 'table_extract', label: '表格提取', icon: '📊' }
+]
+
+const locatorTypeOptions = [
+  { value: 'full_text', label: '全文' },
+  { value: 'chapter', label: '章节定位' },
+  { value: 'review_code', label: '评审代码定位' },
+  { value: 'table', label: '表格定位' }
+]
+
+function updateNodeData() {
+  if (props.selectedNode) {
+    emit('update-node', props.selectedNode.id, localData.value)
+  }
+}
+
+function handleDeleteNode() {
+  if (props.selectedNode) {
+    emit('delete-node', props.selectedNode.id)
+  }
+}
+
+function handleDeleteEdge() {
+  if (props.selectedEdge) {
+    emit('delete-edge', props.selectedEdge.id)
+  }
+}
+
+function onAttachmentChange(attachmentId) {
+  const att = props.attachments.find(a => a.id === attachmentId)
+  if (att) {
+    localData.value.sourceNodeId = attachmentId
+    localData.value.sourceName = att.displayName || att.fileName
+    localData.value.label = att.displayName || att.fileName
+    updateNodeData()
+  }
+}
+
+function onElementChange(elementKey) {
+  const elem = props.elements.find(e => e.elementKey === elementKey)
+  if (elem) {
+    localData.value.elementKey = elementKey
+    localData.value.elementName = elem.elementName
+    localData.value.label = elem.elementName
+    updateNodeData()
+  }
+}
+</script>
+
+<template>
+  <div class="property-panel">
+    <div class="panel-header">
+      <span class="panel-title">属性面板</span>
+    </div>
+    
+    <div class="panel-content" v-if="selectedNode">
+      <div class="property-section">
+        <div class="section-header">
+          <span class="section-title">{{ nodeTypeLabel }}</span>
+          <el-button type="danger" size="small" text @click="handleDeleteNode">删除</el-button>
+        </div>
+        
+        <!-- 来源节点属性 -->
+        <template v-if="selectedNode.type === 'source'">
+          <el-form label-position="top" size="small">
+            <el-form-item label="来源类型">
+              <el-tag size="small">{{ sourceTypeLabel }}</el-tag>
+            </el-form-item>
+            
+            <el-form-item label="选择附件" v-if="localData.subType === 'attachment'">
+              <el-select 
+                v-model="localData.sourceNodeId" 
+                placeholder="选择附件"
+                style="width: 100%"
+                @change="onAttachmentChange"
+              >
+                <el-option
+                  v-for="att in attachments"
+                  :key="att.id"
+                  :label="att.displayName || att.fileName"
+                  :value="att.id"
+                />
+              </el-select>
+            </el-form-item>
+            
+            <el-form-item label="内容定位">
+              <el-select 
+                v-model="localData.locatorType" 
+                placeholder="选择定位方式"
+                style="width: 100%"
+                @change="updateNodeData"
+              >
+                <el-option
+                  v-for="opt in locatorTypeOptions"
+                  :key="opt.value"
+                  :label="opt.label"
+                  :value="opt.value"
+                />
+              </el-select>
+            </el-form-item>
+            
+            <el-form-item label="章节标题" v-if="localData.locatorType === 'chapter'">
+              <el-input 
+                v-model="localData.chapterTitle" 
+                placeholder="如:一、工作目的"
+                @change="updateNodeData"
+              />
+            </el-form-item>
+            
+            <el-form-item label="评审代码" v-if="localData.locatorType === 'review_code'">
+              <el-input 
+                v-model="localData.reviewCode" 
+                placeholder="如:5.1.5"
+                @change="updateNodeData"
+              />
+            </el-form-item>
+            
+            <el-form-item label="摘选文本" v-if="localData.subType === 'excerpt'">
+              <el-input 
+                v-model="localData.sourceText" 
+                type="textarea"
+                :rows="4"
+                placeholder="粘贴或输入摘选的文本内容"
+                @change="updateNodeData"
+              />
+            </el-form-item>
+          </el-form>
+        </template>
+        
+        <!-- 动作节点属性 -->
+        <template v-if="selectedNode.type === 'action'">
+          <el-form label-position="top" size="small">
+            <el-form-item label="动作类型">
+              <el-radio-group v-model="localData.actionType" @change="updateNodeData">
+                <el-radio-button 
+                  v-for="opt in actionTypeOptions" 
+                  :key="opt.value" 
+                  :value="opt.value"
+                >
+                  {{ opt.icon }} {{ opt.label }}
+                </el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            
+            <el-form-item 
+              label="AI 提示词" 
+              v-if="['summary', 'ai_extract'].includes(localData.actionType)"
+            >
+              <el-input 
+                v-model="localData.prompt" 
+                type="textarea"
+                :rows="4"
+                placeholder="请输入 AI 处理提示词..."
+                @change="updateNodeData"
+              />
+            </el-form-item>
+            
+            <el-form-item label="表格选择器" v-if="localData.actionType === 'table_extract'">
+              <el-input 
+                v-model="localData.tableSelector" 
+                placeholder="如:核心要素评审情况记录表"
+                @change="updateNodeData"
+              />
+            </el-form-item>
+            
+            <el-form-item label="输出格式" v-if="localData.actionType === 'ai_extract'">
+              <el-select 
+                v-model="localData.outputFormat" 
+                placeholder="选择输出格式"
+                style="width: 100%"
+                @change="updateNodeData"
+              >
+                <el-option label="文本" value="text" />
+                <el-option label="日期" value="date" />
+                <el-option label="数字" value="number" />
+                <el-option label="列表" value="list" />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </template>
+        
+        <!-- 输出节点属性 -->
+        <template v-if="selectedNode.type === 'element'">
+          <el-form label-position="top" size="small">
+            <el-form-item label="选择要素">
+              <el-select 
+                v-model="localData.elementKey" 
+                placeholder="选择目标要素"
+                style="width: 100%"
+                filterable
+                @change="onElementChange"
+              >
+                <el-option
+                  v-for="elem in elements"
+                  :key="elem.elementKey"
+                  :label="elem.elementName"
+                  :value="elem.elementKey"
+                >
+                  <span>{{ elem.elementName }}</span>
+                  <span style="color: #909399; font-size: 12px; margin-left: 8px;">{{ elem.elementKey }}</span>
+                </el-option>
+              </el-select>
+            </el-form-item>
+            
+            <el-form-item label="要素标识" v-if="localData.elementKey">
+              <el-input :model-value="localData.elementKey" disabled />
+            </el-form-item>
+          </el-form>
+        </template>
+      </div>
+    </div>
+    
+    <!-- 边属性 -->
+    <div class="panel-content" v-else-if="selectedEdge">
+      <div class="property-section">
+        <div class="section-header">
+          <span class="section-title">连线属性</span>
+          <el-button type="danger" size="small" text @click="handleDeleteEdge">删除</el-button>
+        </div>
+        
+        <div class="edge-info">
+          <div class="edge-info-row">
+            <span class="edge-label">连线 ID:</span>
+            <span class="edge-value">{{ selectedEdge.id }}</span>
+          </div>
+          <div class="edge-info-row">
+            <span class="edge-label">来源节点:</span>
+            <span class="edge-value">{{ selectedEdge.source }}</span>
+          </div>
+          <div class="edge-info-row">
+            <span class="edge-label">目标节点:</span>
+            <span class="edge-value">{{ selectedEdge.target }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+    
+    <!-- 空状态 -->
+    <div class="panel-empty" v-else>
+      <div class="empty-icon">👆</div>
+      <div class="empty-text">选择节点或连线<br/>查看和编辑属性</div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.property-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.panel-header {
+  padding: 14px 16px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.panel-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.panel-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px;
+}
+
+.property-section {
+  margin-bottom: 20px;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.section-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.panel-empty {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  padding: 40px 20px;
+}
+
+.empty-icon {
+  font-size: 48px;
+  margin-bottom: 16px;
+  opacity: 0.5;
+}
+
+.empty-text {
+  font-size: 13px;
+  text-align: center;
+  line-height: 1.6;
+}
+
+.edge-info {
+  background: #f5f7fa;
+  border-radius: 6px;
+  padding: 12px;
+}
+
+.edge-info-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 6px 0;
+  font-size: 12px;
+}
+
+.edge-label {
+  color: #909399;
+}
+
+.edge-value {
+  color: #303133;
+  font-family: monospace;
+}
+
+:deep(.el-form-item) {
+  margin-bottom: 16px;
+}
+
+:deep(.el-form-item__label) {
+  font-size: 12px;
+  color: #606266;
+  padding-bottom: 4px;
+}
+
+:deep(.el-radio-group) {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+:deep(.el-radio-button__inner) {
+  padding: 8px 12px;
+  font-size: 12px;
+}
+</style>

+ 642 - 7
frontend/vue-demo/src/views/Editor.vue

@@ -187,6 +187,7 @@
               </el-dropdown>
               <el-divider direction="vertical" />
               <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><Paperclip /></el-icon></el-button>
+              <el-button text circle size="small" title="规则" @click="showRuleDialog = true"><el-icon><List /></el-icon></el-button>
             </div>
           </div>
 
@@ -314,8 +315,6 @@
               <span class="rp-title-count">{{ filledValues.length }}</span>
             </div>
             <div class="rp-header-actions">
-              <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><CopyDocument /></el-icon></el-button>
-              <el-button text circle size="small" title="规则" @click="showRuleDialog = true"><el-icon><MoreFilled /></el-icon></el-button>
               <el-input
                 v-if="elementSearchVisible"
                 v-model="elementSearchQuery"
@@ -712,6 +711,9 @@
           :prefix-icon="Search"
         />
         <el-button size="small" :icon="Plus" @click="showNewRuleDialog = true">添加规则</el-button>
+        <el-button size="small" type="primary" @click="showRuleWorkflow = true; showRuleDialog = false">
+          🔀 工作流
+        </el-button>
         <el-button
           v-if="rules.length > 0"
           type="success"
@@ -854,7 +856,7 @@
     </el-dialog>
 
     <!-- 添加规则对话框 -->
-    <el-dialog v-model="showNewRuleDialog" title="添加规则" width="500">
+    <el-dialog v-model="showNewRuleDialog" title="添加规则" width="600">
       <el-form :model="newRuleForm" label-width="100px">
         <el-form-item label="规则名称" required>
           <el-input v-model="newRuleForm.ruleName" placeholder="如:项目编号-直接引用实体" />
@@ -863,6 +865,8 @@
           <el-select v-model="newRuleForm.ruleType" style="width: 100%">
             <el-option label="直接引用实体" value="direct_entity" />
             <el-option label="AI 提取" value="ai_extract" />
+            <el-option label="AI 总结" value="summary" />
+            <el-option label="表格提取" value="table_extract" />
             <el-option label="固定值" value="fixed_value" />
             <el-option label="计算公式" value="formula" />
           </el-select>
@@ -872,12 +876,126 @@
             <el-option v-for="elem in elements" :key="elem.elementKey" :label="elem.elementName" :value="elem.elementKey" />
           </el-select>
         </el-form-item>
+        <el-form-item label="来源附件" v-if="['ai_extract', 'summary', 'table_extract', 'direct_entity'].includes(newRuleForm.ruleType)">
+          <el-select v-model="newRuleForm.sourceAttachmentId" style="width: 100%" placeholder="选择来源附件" clearable>
+            <el-option v-for="att in attachments" :key="att.id" :label="att.fileName" :value="att.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="内容定位" v-if="['ai_extract', 'summary', 'table_extract'].includes(newRuleForm.ruleType)">
+          <el-select v-model="newRuleForm.locatorType" style="width: 100%" placeholder="选择内容定位方式">
+            <el-option label="全文" value="full_text" />
+            <el-option label="章节定位" value="chapter" />
+            <el-option label="评审代码定位" value="review_code" />
+            <el-option label="表格定位" value="table" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="章节标题" v-if="newRuleForm.locatorType === 'chapter'">
+          <el-input v-model="newRuleForm.chapterTitle" placeholder="如:一、工作目的" />
+        </el-form-item>
+        <el-form-item label="评审代码" v-if="newRuleForm.locatorType === 'review_code'">
+          <el-input v-model="newRuleForm.reviewCode" placeholder="如:5.1.5" />
+        </el-form-item>
+        <el-form-item label="表格选择器" v-if="newRuleForm.locatorType === 'table'">
+          <el-input v-model="newRuleForm.tableSelector" placeholder="如:核心要素评审情况记录表" />
+        </el-form-item>
+        <el-form-item label="AI 提示词" v-if="['ai_extract', 'summary'].includes(newRuleForm.ruleType)">
+          <el-input 
+            v-model="newRuleForm.prompt" 
+            type="textarea" 
+            :rows="3" 
+            placeholder="如:从原文提取评审对象公司的全称,只输出公司名称" 
+          />
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="showNewRuleDialog = false">取消</el-button>
         <el-button type="primary" @click="handleCreateRule" :disabled="!newRuleForm.ruleName">创建</el-button>
       </template>
     </el-dialog>
+
+    <!-- 规则引擎数据弹窗 -->
+    <el-dialog v-model="showRuleEngineDialog" :title="pendingExecuteRule ? '执行规则' : '批量执行规则'" width="900">
+      <div class="rule-engine-preview">
+        <p class="rule-engine-desc">{{ pendingExecuteRule ? '即将执行以下规则:' : '以下是非人工录入要素的规则信息,可用于规则引擎处理:' }}</p>
+        <div class="rule-engine-stats" v-if="!pendingExecuteRule">
+          <span>共 <strong>{{ ruleEngineData.length }}</strong> 条自动规则</span>
+        </div>
+        <div class="rule-engine-list">
+          <div 
+            v-for="rule in displayRulesForEngine" 
+            :key="rule.id" 
+            class="rule-engine-item"
+          >
+            <div class="rule-engine-header">
+              <span class="rule-engine-name">{{ rule.ruleName }}</span>
+              <el-tag size="small" :type="getActionTypeTagType(rule.actionType)">{{ rule.actionType }}</el-tag>
+              <el-tag size="small" type="info">{{ rule.ruleType }}</el-tag>
+            </div>
+            <div class="rule-engine-element">
+              <span class="label">目标要素:</span>
+              <code>{{ rule.elementKey }}</code>
+            </div>
+            <div class="rule-engine-inputs" v-if="rule.inputs && rule.inputs.length > 0">
+              <span class="label">输入来源:</span>
+              <div class="input-list">
+                <div v-for="(inp, idx) in rule.inputs" :key="idx" class="input-item">
+                  <span class="input-source">📎 {{ inp.sourceName || inp.inputName }}</span>
+                  <span v-if="inp.entryPath" class="input-entry">→ {{ inp.entryPath }}</span>
+                  <div v-if="inp.sourceText" class="input-text">
+                    <span class="text-label">引用文本:</span>
+                    <span class="text-content">{{ inp.sourceText.slice(0, 200) }}{{ inp.sourceText.length > 200 ? '...' : '' }}</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div class="rule-engine-source" v-else-if="rule.sourceText">
+              <span class="label">引用文本:</span>
+              <span class="source-text">{{ (rule.sourceText || '').slice(0, 200) }}{{ (rule.sourceText || '').length > 200 ? '...' : '' }}</span>
+            </div>
+            <div class="rule-engine-code" v-if="rule.reviewCode">
+              <span class="label">评审代码:</span>
+              <code>{{ rule.reviewCode }}</code>
+            </div>
+            <div class="rule-engine-desc-text" v-if="rule.description">
+              <span class="label">描述:</span>
+              <span>{{ rule.description }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="rule-engine-json">
+          <div class="json-header">
+            <span class="json-title">JSON 数据</span>
+            <el-button size="small" @click="copyRuleEngineJson">复制</el-button>
+          </div>
+          <pre class="json-content">{{ JSON.stringify(ruleEngineAdaptedData, null, 2) }}</pre>
+        </div>
+      </div>
+      <template #footer>
+        <el-button @click="showRuleEngineDialog = false">取消</el-button>
+        <el-button type="primary" @click="handleConfirmExecute" :loading="executingRules">
+          {{ pendingExecuteRule ? '确认执行' : `确认执行 (${ruleEngineData.length} 条规则)` }}
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 规则工作流弹窗 -->
+    <el-dialog
+      v-model="showRuleWorkflow"
+      title="规则工作流"
+      fullscreen
+      :close-on-click-modal="false"
+      class="rule-workflow-dialog"
+    >
+      <RuleWorkflow
+        v-if="showRuleWorkflow && currentProjectId"
+        :project-id="currentProjectId"
+        :attachments="attachments"
+        :elements="elements"
+        :rules="rules"
+        @save="handleWorkflowSave"
+        @close="showRuleWorkflow = false"
+      />
+    </el-dialog>
   </div>
 </template>
 
@@ -890,6 +1008,7 @@ import { projectApi, elementApi, valueApi, attachmentApi, ruleApi, parseApi } fr
 import { marked } from 'marked'
 import JSZip from 'jszip'
 import { useTaskCenterStore } from '@/stores/taskCenter'
+import RuleWorkflow from '@/components/workflow/RuleWorkflow.vue'
 
 const router = useRouter()
 const route = useRoute()
@@ -1084,6 +1203,7 @@ const showAddElementDialog = ref(false)
 const showNewRuleDialog = ref(false)
 const showAttachmentDialog = ref(false)
 const showRuleDialog = ref(false)
+const showRuleWorkflow = ref(false)
 const ruleSearchQuery = ref('')
 const ruleFilterType = ref('all')
 const expandedRuleId = ref(null)
@@ -1114,6 +1234,190 @@ const ruleTypeStats = computed(() => {
   return stats
 })
 
+// 规则引擎准备数据:获取非人工录入要素的规则信息
+const ruleEngineData = computed(() => {
+  // 过滤掉人工录入类型的规则
+  const autoRules = rules.value.filter(r => r.actionType !== 'use_entity_value')
+  
+  return autoRules.map(rule => {
+    // 获取输入文本
+    const inputs = Array.isArray(rule.inputs) ? rule.inputs : []
+    const inputTexts = inputs.map(inp => {
+      return {
+        sourceName: inp.sourceName || inp.inputName || '',
+        sourceText: inp.sourceText || '',
+        entryPath: inp.entryPath || '',
+        inputType: inp.inputType || ''
+      }
+    }).filter(inp => inp.sourceName || inp.sourceText)
+    
+    // 从 actionConfig 中提取额外信息
+    let actionConfigData = {}
+    try {
+      actionConfigData = typeof rule.actionConfig === 'string' 
+        ? JSON.parse(rule.actionConfig) 
+        : (rule.actionConfig || {})
+    } catch (e) {}
+    
+    return {
+      id: rule.id,
+      ruleName: rule.ruleName,
+      elementKey: rule.elementKey,
+      ruleType: rule.ruleType,
+      actionType: rule.actionType,
+      actionConfig: rule.actionConfig,
+      description: rule.description,
+      inputs: inputTexts,
+      sourceText: actionConfigData.sourceText || inputTexts[0]?.sourceText || '',
+      reviewCode: actionConfigData.description?.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/)?.[1] || ''
+    }
+  })
+})
+
+// 显示规则引擎数据弹窗
+const showRuleEngineDialog = ref(false)
+// 当前要执行的单条规则(用于弹窗展示)
+const pendingExecuteRule = ref(null)
+
+// 弹窗中显示的规则列表(单条或批量)
+const displayRulesForEngine = computed(() => {
+  if (pendingExecuteRule.value) {
+    // 单条规则模式:转换为统一格式
+    const rule = pendingExecuteRule.value
+    const inputs = Array.isArray(rule.inputs) ? rule.inputs : []
+    let actionConfigData = {}
+    try {
+      actionConfigData = typeof rule.actionConfig === 'string' 
+        ? JSON.parse(rule.actionConfig) 
+        : (rule.actionConfig || {})
+    } catch (e) {}
+    
+    return [{
+      id: rule.id,
+      ruleName: rule.ruleName,
+      elementKey: rule.elementKey,
+      ruleType: rule.ruleType,
+      actionType: rule.actionType,
+      actionConfig: rule.actionConfig,
+      description: rule.description,
+      inputs: inputs,
+      sourceText: actionConfigData.sourceText || '',
+      reviewCode: actionConfigData.description?.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/)?.[1] || ''
+    }]
+  }
+  // 批量模式
+  return ruleEngineData.value
+})
+
+// 生成规则引擎适配格式的数据
+const ruleEngineAdaptedData = computed(() => {
+  return displayRulesForEngine.value.map(rule => {
+    // 提取输入资源 ID 列表
+    const inputs = Array.isArray(rule.inputs) ? rule.inputs : []
+    const resourceIds = inputs
+      .filter(inp => inp.sourceNodeId)
+      .map(inp => ({
+        var_name: `resource_${inp.sourceNodeId}_id`,
+        id: inp.sourceNodeId,
+        name: inp.sourceName || inp.inputName || '',
+        entry_path: inp.entryPath || null
+      }))
+    
+    // 从 actionConfig 中提取详细配置
+    let actionConfigData = {}
+    try {
+      actionConfigData = typeof rule.actionConfig === 'string' 
+        ? JSON.parse(rule.actionConfig) 
+        : (rule.actionConfig || {})
+    } catch (e) {}
+    
+    // 提取 prompt(AI 提示词)
+    let prompt = actionConfigData.prompt || ''
+    const configDesc = actionConfigData.description || ''
+    if (!prompt) {
+      if (rule.actionType === 'summary') {
+        prompt = configDesc || `从原文生成${rule.ruleName}的摘要描述`
+      } else if (rule.actionType === 'ai_extract') {
+        prompt = configDesc || `从原文提取${rule.ruleName}相关信息`
+      } else if (rule.actionType === 'table_extract') {
+        prompt = configDesc || `从表格中提取${rule.ruleName}数据`
+      } else if (rule.actionType === 'quote') {
+        prompt = configDesc || `直接引用原文中的${rule.ruleName}内容`
+      }
+    }
+    
+    // 确定函数类型
+    let funcType = 'export_resource'  // 默认直接导出
+    if (rule.actionType === 'summary' || rule.actionType === 'ai_extract') {
+      funcType = 'ai_assistant'
+    } else if (rule.actionType === 'table_extract') {
+      funcType = 'table_extract'
+    } else if (rule.actionType === 'quote') {
+      funcType = 'export_resource'
+    }
+    
+    // 提取引用的原文内容
+    const sourceText = actionConfigData.sourceText || rule.sourceText || ''
+    
+    // 提取评审代码
+    const reviewCode = rule.reviewCode || 
+      actionConfigData.reviewCode ||
+      (configDesc.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/)?.[1]) || null
+    
+    // 内容定位信息
+    const contentLocator = {
+      type: actionConfigData.locatorType || 'full_text',
+      chapter_title: actionConfigData.chapterTitle || null,
+      review_code: reviewCode,
+      table_selector: actionConfigData.tableSelector || null
+    }
+    
+    // 根据定位类型自动推断
+    if (!actionConfigData.locatorType) {
+      if (reviewCode) {
+        contentLocator.type = 'review_code'
+      } else if (rule.actionType === 'table_extract') {
+        contentLocator.type = 'table'
+      }
+    }
+    
+    return {
+      // 规则基本信息
+      rule_id: rule.id,
+      rule_name: rule.ruleName,
+      element_key: rule.elementKey,
+      
+      // 规则引擎适配参数
+      func_type: funcType,
+      action_type: rule.actionType,
+      
+      // 输入资源(对应 *_ch_id 等参数)
+      resource_ids: resourceIds,
+      
+      // 内容定位方式
+      content_locator: contentLocator,
+      
+      // AI 提示词(对应 prompt 参数)
+      prompt: prompt,
+      
+      // 引用的原文内容(用于定位和上下文)
+      source_text: sourceText,
+      
+      // 原始描述(来源说明)
+      source_desc: rule.description
+    }
+  })
+})
+
+// 统一的确认执行函数
+function handleConfirmExecute() {
+  if (pendingExecuteRule.value) {
+    confirmExecuteSingleRule()
+  } else {
+    confirmExecuteRules()
+  }
+}
+
 function toggleRuleExpand(ruleId) {
   expandedRuleId.value = expandedRuleId.value === ruleId ? null : ruleId
 }
@@ -1438,7 +1742,17 @@ const attachmentFileCache = new Map()
 const creatingProject = ref(false)
 const newProjectForm = reactive({ title: '', description: '' })
 const newElementForm = reactive({ elementName: '', elementKey: '', dataType: 'text', description: '' })
-const newRuleForm = reactive({ ruleName: '', ruleType: 'direct_entity', targetElementKey: '' })
+const newRuleForm = reactive({ 
+  ruleName: '', 
+  ruleType: 'direct_entity', 
+  targetElementKey: '',
+  sourceAttachmentId: null,
+  locatorType: 'full_text',
+  chapterTitle: '',
+  reviewCode: '',
+  tableSelector: '',
+  prompt: ''
+})
 
 const filteredProjects = computed(() => {
   if (!projectSearchKeyword.value) return projects.value
@@ -1666,6 +1980,26 @@ function ruleActionLabel(actionType) {
   return map[actionType] || actionType
 }
 
+function getActionTypeTagType(actionType) {
+  const map = {
+    quote: '',
+    summary: 'success',
+    ai_extract: 'warning',
+    table_extract: 'danger',
+    use_entity_value: 'info',
+  }
+  return map[actionType] || ''
+}
+
+function copyRuleEngineJson() {
+  const json = JSON.stringify(ruleEngineAdaptedData.value, null, 2)
+  navigator.clipboard.writeText(json).then(() => {
+    ElMessage.success('已复制规则引擎数据到剪贴板')
+  }).catch(() => {
+    ElMessage.error('复制失败')
+  })
+}
+
 // 从规则中提取来源文本(引用原文 / 规则描述)
 function ruleSourceText(rule) {
   // 优先从 actionConfig.sourceText 取(前端引用创建的规则)
@@ -3282,7 +3616,17 @@ async function handleCreateRule() {
   try {
     const rule = await ruleApi.create(currentProjectId.value, { ...newRuleForm })
     rules.value.push(rule); showNewRuleDialog.value = false
-    Object.assign(newRuleForm, { ruleName: '', ruleType: 'direct_entity', targetElementKey: '' })
+    Object.assign(newRuleForm, { 
+      ruleName: '', 
+      ruleType: 'direct_entity', 
+      targetElementKey: '',
+      sourceAttachmentId: null,
+      locatorType: 'full_text',
+      chapterTitle: '',
+      reviewCode: '',
+      tableSelector: '',
+      prompt: ''
+    })
     ElMessage.success('规则创建成功')
   } catch (e) { ElMessage.error('创建失败: ' + e.message) }
 }
@@ -3293,17 +3637,192 @@ async function handleDeleteRule(rule) {
   } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
 }
 async function handleExecuteRule(rule) {
-  try { await ruleApi.execute(rule.id); ElMessage.success(`规则「${rule.ruleName}」执行成功`); await loadProjectData(currentProjectId.value) }
+  // 弹出规则详情预览
+  pendingExecuteRule.value = rule
+  showRuleEngineDialog.value = true
+}
+
+async function confirmExecuteSingleRule() {
+  const rule = pendingExecuteRule.value
+  if (!rule) return
+  showRuleEngineDialog.value = false
+  rule._executing = true
+  try { 
+    await ruleApi.execute(rule.id)
+    ElMessage.success(`规则「${rule.ruleName}」执行成功`)
+    await loadProjectData(currentProjectId.value) 
+  }
   catch (e) { ElMessage.error('执行失败: ' + e.message) }
+  finally { 
+    rule._executing = false
+    pendingExecuteRule.value = null
+  }
 }
 async function handleBatchExecuteRules() {
   if (!currentProjectId.value) return
+  // 先弹出规则引擎数据预览(批量模式)
+  pendingExecuteRule.value = null  // 清空单条规则,表示批量模式
+  if (ruleEngineData.value.length > 0) {
+    showRuleEngineDialog.value = true
+  } else {
+    ElMessage.warning('没有可执行的自动规则')
+  }
+}
+
+async function confirmExecuteRules() {
+  if (!currentProjectId.value) return
+  showRuleEngineDialog.value = false
   executingRules.value = true
-  try { await ruleApi.batchExecute(currentProjectId.value); ElMessage.success('批量执行完成'); await loadProjectData(currentProjectId.value) }
+  try { 
+    await ruleApi.batchExecute(currentProjectId.value)
+    ElMessage.success('批量执行完成')
+    await loadProjectData(currentProjectId.value) 
+  }
   catch (e) { ElMessage.error('执行失败: ' + e.message) }
   finally { executingRules.value = false }
 }
 
+// 工作流保存
+async function handleWorkflowSave(workflowData) {
+  if (!currentProjectId.value || !workflowData) return
+  
+  try {
+    const rulesToCreate = convertWorkflowToRules(workflowData)
+    
+    for (const ruleDTO of rulesToCreate) {
+      await ruleApi.create(currentProjectId.value, ruleDTO)
+    }
+    
+    ElMessage.success(`成功创建 ${rulesToCreate.length} 条规则`)
+    showRuleWorkflow.value = false
+    await loadProjectData(currentProjectId.value)
+  } catch (e) {
+    ElMessage.error('保存失败: ' + e.message)
+  }
+}
+
+function convertWorkflowToRules(workflowData) {
+  const { nodes, edges } = workflowData
+  const rules = []
+  
+  const elementNodes = nodes.filter(n => n.type === 'element')
+  
+  // 辅助函数:递归查找数据流路径
+  function traceDataFlow(nodeId, visited = new Set()) {
+    if (visited.has(nodeId)) return { sources: [], actions: [] }
+    visited.add(nodeId)
+    
+    const node = nodes.find(n => n.id === nodeId)
+    if (!node) return { sources: [], actions: [] }
+    
+    if (node.type === 'source') {
+      return { sources: [node], actions: [] }
+    }
+    
+    if (node.type === 'action') {
+      const inEdges = edges.filter(e => e.target === nodeId)
+      let allSources = []
+      let allActions = [node]
+      
+      for (const edge of inEdges) {
+        const upstream = traceDataFlow(edge.source, visited)
+        allSources = [...allSources, ...upstream.sources]
+        allActions = [...allActions, ...upstream.actions]
+      }
+      
+      return { sources: allSources, actions: allActions }
+    }
+    
+    return { sources: [], actions: [] }
+  }
+  
+  for (const elementNode of elementNodes) {
+    if (!elementNode.data.elementKey) continue
+    
+    const incomingEdges = edges.filter(e => e.target === elementNode.id)
+    if (incomingEdges.length === 0) continue
+    
+    // 收集所有输入路径
+    let allSources = []
+    let allActions = []
+    
+    for (const edge of incomingEdges) {
+      const { sources, actions } = traceDataFlow(edge.source)
+      allSources = [...allSources, ...sources]
+      allActions = [...allActions, ...actions]
+    }
+    
+    // 去重
+    const uniqueSources = [...new Map(allSources.map(s => [s.id, s])).values()]
+    const uniqueActions = [...new Map(allActions.map(a => [a.id, a])).values()]
+    
+    // 确定主要动作(最接近输出的动作节点)
+    const directInputEdge = incomingEdges[0]
+    const directInputNode = nodes.find(n => n.id === directInputEdge.source)
+    
+    let primaryAction = null
+    if (directInputNode?.type === 'action') {
+      primaryAction = directInputNode
+    } else if (uniqueActions.length > 0) {
+      primaryAction = uniqueActions[0]
+    }
+    
+    const actionType = primaryAction?.data?.actionType || primaryAction?.data?.subType || 'quote'
+    const prompt = primaryAction?.data?.prompt || ''
+    
+    // 构建输入列表
+    const inputs = uniqueSources.map(source => ({
+      sourceNodeId: source.data.sourceNodeId || null,
+      inputType: source.data.subType || 'attachment',
+      inputName: source.data.sourceName || source.data.label || '来源',
+      sourceText: source.data.sourceText || null,
+      locatorType: source.data.locatorType || 'full_text',
+      chapterTitle: source.data.chapterTitle || null,
+      reviewCode: source.data.reviewCode || null
+    })).filter(inp => inp.sourceNodeId || inp.sourceText)
+    
+    // 构建动作配置
+    const actionConfig = {
+      locatorType: uniqueSources[0]?.data?.locatorType || 'full_text',
+      chapterTitle: uniqueSources[0]?.data?.chapterTitle || null,
+      reviewCode: uniqueSources[0]?.data?.reviewCode || null,
+      tableSelector: primaryAction?.data?.tableSelector || null,
+      prompt: prompt || null,
+      outputFormat: primaryAction?.data?.outputFormat || 'text'
+    }
+    
+    // 如果有多个动作节点,记录动作链
+    if (uniqueActions.length > 1) {
+      actionConfig.actionChain = uniqueActions.map(a => ({
+        actionType: a.data.actionType || a.data.subType,
+        prompt: a.data.prompt || null
+      }))
+    }
+    
+    const rule = {
+      elementKey: elementNode.data.elementKey,
+      ruleName: `${elementNode.data.elementName || elementNode.data.elementKey}-${getActionLabel(actionType)}`,
+      ruleType: 'extraction',
+      actionType: actionType,
+      actionConfig: JSON.stringify(actionConfig),
+      inputs: inputs
+    }
+    
+    rules.push(rule)
+  }
+  
+  return rules
+}
+
+function getActionLabel(actionType) {
+  const labels = {
+    quote: '引用',
+    summary: 'AI总结',
+    ai_extract: 'AI提取',
+    table_extract: '表格提取'
+  }
+  return labels[actionType] || actionType
+}
 
 // 工具函数
 function formatTime(dateStr) {
@@ -6883,6 +7402,107 @@ onMounted(async () => {
     }
   }
 
+  // ---- 规则引擎预览 ----
+  .rule-engine-preview {
+    .rule-engine-desc {
+      color: #666;
+      font-size: 13px;
+      margin-bottom: 12px;
+    }
+    .rule-engine-stats {
+      margin-bottom: 16px;
+      font-size: 14px;
+      strong { color: #409eff; }
+    }
+    .rule-engine-list {
+      max-height: 500px;
+      overflow-y: auto;
+    }
+    .rule-engine-item {
+      padding: 12px;
+      margin-bottom: 12px;
+      background: #f9fafb;
+      border-radius: 8px;
+      border: 1px solid #e5e7eb;
+      
+      .rule-engine-header {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        margin-bottom: 8px;
+        .rule-engine-name {
+          font-weight: 600;
+          font-size: 14px;
+        }
+      }
+      .rule-engine-element, .rule-engine-inputs, .rule-engine-source, .rule-engine-code, .rule-engine-desc-text {
+        font-size: 13px;
+        margin-top: 6px;
+        .label {
+          color: #666;
+          margin-right: 4px;
+        }
+        code {
+          background: #e5e7eb;
+          padding: 2px 6px;
+          border-radius: 4px;
+          font-size: 12px;
+        }
+      }
+      .input-list {
+        margin-top: 4px;
+        padding-left: 12px;
+        .input-item {
+          margin-bottom: 6px;
+          .input-source { color: #409eff; }
+          .input-entry { color: #666; margin-left: 4px; }
+          .input-text {
+            margin-top: 4px;
+            padding: 6px 8px;
+            background: #fff;
+            border-radius: 4px;
+            border: 1px solid #e5e7eb;
+            .text-label { color: #999; font-size: 12px; }
+            .text-content { color: #333; font-size: 12px; display: block; margin-top: 2px; }
+          }
+        }
+      }
+      .source-text {
+        color: #333;
+        background: #fff;
+        padding: 4px 8px;
+        border-radius: 4px;
+        display: inline-block;
+      }
+    }
+    .rule-engine-json {
+      margin-top: 16px;
+      .json-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-bottom: 8px;
+        .json-title {
+          font-weight: 600;
+          font-size: 13px;
+          color: #333;
+        }
+      }
+      .json-content {
+        background: #1e1e1e;
+        color: #d4d4d4;
+        padding: 12px;
+        border-radius: 6px;
+        font-size: 12px;
+        font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
+        max-height: 300px;
+        overflow: auto;
+        white-space: pre-wrap;
+        word-break: break-all;
+      }
+    }
+  }
+
   // ---- 规则筛选栏 ----
   .rule-filter-bar {
     display: flex;
@@ -7060,4 +7680,19 @@ onMounted(async () => {
     }
   }
 }
+
+// 规则工作流弹窗样式
+.rule-workflow-dialog {
+  :deep(.el-dialog__body) {
+    padding: 0;
+    height: calc(100vh - 54px);
+    overflow: hidden;
+  }
+  
+  :deep(.el-dialog__header) {
+    padding: 12px 20px;
+    border-bottom: 1px solid #e4e7ed;
+    margin: 0;
+  }
+}
 </style>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 183 - 377
frontend/vue-demo/yarn.lock


Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov