流程图的功能
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2025-10-13 01:38:54 +08:00
parent 2aec0abca2
commit 2c31acaba7
18 changed files with 2669 additions and 47 deletions

127
README.md
View File

@@ -1 +1,126 @@
OJ next
# OJ Next
基于 Vue 3 + TypeScript 的在线判题系统前端项目。
## 项目特性
- 🎯 **现代化技术栈** - Vue 3 + TypeScript + Vite
- 📊 **数据可视化** - 丰富的图表和统计功能
- 🎨 **流程图编辑器** - 内置 FlowchartEditor 组件
- 📱 **响应式设计** - 支持多端适配
-**高性能** - 优化的组件和状态管理
## 核心组件
### FlowchartEditor 流程图编辑器
一个功能完整的流程图编辑器组件,支持:
- 🎯 **拖拽创建节点** - 从工具栏拖拽节点到画布
- 🔗 **节点连接** - 支持节点间的连线操作
- ✏️ **节点编辑** - 双击节点进行文本编辑
- 📋 **节点操作** - 复制、删除、批量操作
- ↩️ **撤销重做** - 完整的历史记录管理
- ⌨️ **键盘快捷键** - Ctrl+Z/Ctrl+Y 撤销重做Delete 删除
- 💾 **自动保存** - 本地缓存,防止数据丢失
- 🎨 **多种节点类型** - 开始、输入、处理、判断、循环、输出、结束
- 🖱️ **交互优化** - 流畅的拖拽体验和视觉反馈
**核心特性**
- 基于 Vue Flow 构建,性能优异
- 模块化设计,易于扩展
- 响应式布局,支持多端适配
- 完整的 TypeScript 支持
详细文档:[FlowchartEditor 文档](./docs/FlowchartEditor.md)
## 技术栈
- **Vue 3** - 渐进式 JavaScript 框架
- **TypeScript** - JavaScript 的超集
- **Rsbuild** - 基于 Rspack 的构建工具
- **Vue Flow** - 流程图组件库
- **Composition API** - Vue 3 组合式 API
- **Naive UI** - Vue 3 组件库
- **Chart.js** - 图表可视化库
- **CodeMirror** - 代码编辑器
- **Yjs** - 实时协作框架
## 项目结构
```
src/
├── shared/ # 共享组件和工具
│ ├── components/
│ │ └── FlowchartEditor/ # 流程图编辑器
│ │ ├── index.vue # 主组件
│ │ ├── CustomNode.vue # 自定义节点
│ │ ├── Toolbar.vue # 工具栏
│ │ ├── NodeHandles.vue # 节点操作
│ │ ├── NodeActions.vue # 节点动作
│ │ ├── useCache.ts # 缓存管理
│ │ ├── useDnD.ts # 拖拽处理
│ │ ├── useFlowOperations.ts # 流程操作
│ │ ├── useHistory.ts # 历史记录
│ │ └── useNodeStyles.ts # 节点样式
│ ├── composables/ # 组合式函数
│ ├── layout/ # 布局组件
│ ├── store/ # 状态管理
│ └── themes/ # 主题配置
├── oj/ # 主要业务组件
│ ├── ai/ # AI分析模块
│ ├── problem/ # 题目相关
│ ├── contest/ # 竞赛相关
│ ├── submission/ # 提交相关
│ └── store/ # 业务状态管理
├── admin/ # 管理后台组件
└── utils/ # 工具函数
```
## 开发指南
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm start
# 或
npm run start
```
### 构建生产版本
```bash
npm run build
```
### 构建测试环境版本
```bash
npm run build:test
```
### 构建预发布版本
```bash
npm run build:staging
```
## 文档
- [FlowchartEditor 流程图编辑器](./docs/FlowchartEditor.md)
- [图表组件说明](./docs/图表.md)
- [API 接口对比分析](./docs/API接口对比分析.md)
## 贡献
欢迎提交 Issue 和 Pull Request
## 许可证
MIT License

View File

@@ -2,7 +2,23 @@
## 更新日志
### 最近更新2025-10
### 最近更新2025-01-15
-**FlowchartEditor 流程图编辑器**:新增完整的流程图编辑功能
- 基于 Vue Flow 构建的流程图编辑器组件
- 支持7种节点类型开始、输入、处理、判断、循环、输出、结束
- 完整的拖拽创建、节点连接、编辑功能
- 撤销重做、自动保存、键盘快捷键支持
- 模块化设计包含9个独立的功能模块
- 🎨 **前端组件优化**:完善了组件文档和项目结构说明
- 更新了 README.md 中的技术栈和项目结构
- 创建了详细的 FlowchartEditor 使用文档
- 优化了开发指南和构建流程说明
- 🔧 **构建工具升级**:从 Vite 迁移到 Rsbuild
- 使用 Rsbuild 作为新的构建工具
- 支持多环境构建test、staging、production
- 优化了构建性能和开发体验
### 历史更新2025-10
-**AI分析功能增强**完善了AI智能分析模块的文档说明
- 详细说明了4个AI相关接口的功能和参数
- 新增等级系统说明S/A/B/C包含特殊规则
@@ -434,3 +450,99 @@ const hasTriedButNotPassed = computed(() => {
**结论**
此接口是系统内部通信接口,前端完全不需要调用。类似的内部接口还可能存在于分布式系统的其他服务间通信中。
---
## 六、新增功能模块
### FlowchartEditor 流程图编辑器 ✨
**功能概述**: 基于 Vue Flow 构建的完整流程图编辑器,支持拖拽创建、节点连接、编辑等核心功能。
**技术实现**:
- **核心库**: Vue Flow (@vue-flow/core)
- **组件架构**: 模块化设计9个独立功能模块
- **状态管理**: 基于 Vue 3 Composition API
- **数据持久化**: localStorage 自动缓存
- **交互体验**: 拖拽、键盘快捷键、撤销重做
**组件结构**:
```
FlowchartEditor/
├── index.vue # 主组件 - 整合所有功能
├── CustomNode.vue # 自定义节点 - 7种节点类型
├── Toolbar.vue # 工具栏 - 节点创建和操作
├── NodeHandles.vue # 节点操作手柄 - 连接点管理
├── NodeActions.vue # 节点动作 - 删除、编辑按钮
├── useCache.ts # 缓存管理 - 自动保存/恢复
├── useDnD.ts # 拖拽处理 - 节点创建逻辑
├── useFlowOperations.ts # 流程操作 - 增删改查
├── useHistory.ts # 历史记录 - 撤销重做
└── useNodeStyles.ts # 节点样式 - 类型配置
```
**节点类型支持**:
- **开始节点** (start) - 流程开始,绿色主题
- **输入节点** (input) - 数据输入,蓝色主题
- **处理节点** (default) - 数据处理,紫色主题
- **判断节点** (decision) - 条件判断,橙色主题
- **循环节点** (loop) - 循环处理,青色主题
- **输出节点** (output) - 数据输出,粉色主题
- **结束节点** (end) - 流程结束,红色主题
**核心功能**:
1. **拖拽创建**: 从工具栏拖拽节点到画布
2. **节点连接**: 支持节点间的连线操作
3. **节点编辑**: 双击节点进行文本编辑
4. **撤销重做**: 完整的历史记录管理
5. **自动保存**: 本地缓存,防止数据丢失
6. **键盘快捷键**: Ctrl+Z/Ctrl+Y 撤销重做Delete 删除
7. **批量操作**: 支持多选和批量删除
**数据格式**:
```typescript
// 节点数据
interface Node {
id: string
type: string
position: { x: number, y: number }
data: {
customLabel: string
[key: string]: any
}
}
// 边数据
interface Edge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
type?: string
}
```
**使用场景**:
- 算法流程图绘制
- 业务流程设计
- 教学演示工具
- 问题分析图表
**性能优化**:
- 基于 Vue Flow 的高性能渲染
- 模块化设计,按需加载
- 本地缓存减少重复计算
- 响应式状态管理
**扩展性**:
- 支持自定义节点类型
- 可扩展工具栏功能
- 支持主题定制
- 支持数据导入导出
**文档支持**:
- 详细的使用文档: `docs/FlowchartEditor.md`
- 完整的 API 说明
- 最佳实践指南
- 故障排除手册

311
docs/FlowchartEditor.md Normal file
View File

@@ -0,0 +1,311 @@
# FlowchartEditor 流程图编辑器
一个基于 Vue 3 + Vue Flow 构建的功能完整的流程图编辑器组件。
## 功能特性
### 🎯 核心功能
- **拖拽创建节点** - 从工具栏拖拽节点到画布
- **节点连接** - 支持节点间的连线操作
- **节点编辑** - 双击节点进行文本编辑
- **撤销重做** - 完整的历史记录管理
- **自动保存** - 本地缓存,防止数据丢失
- **键盘快捷键** - 支持常用快捷键操作
### 🎨 节点类型
- **开始节点** (start) - 流程开始
- **输入节点** (input) - 数据输入
- **处理节点** (default) - 数据处理
- **判断节点** (decision) - 条件判断
- **循环节点** (loop) - 循环处理
- **输出节点** (output) - 数据输出
- **结束节点** (end) - 流程结束
### ⌨️ 快捷键
- `Ctrl+Z` / `Cmd+Z` - 撤销
- `Ctrl+Y` / `Cmd+Shift+Z` - 重做
- `Delete` / `Backspace` - 删除选中节点
## 组件结构
```
FlowchartEditor/
├── index.vue # 主组件
├── CustomNode.vue # 自定义节点组件
├── Toolbar.vue # 工具栏组件
├── NodeHandles.vue # 节点操作手柄
├── NodeActions.vue # 节点动作按钮
├── useCache.ts # 缓存管理
├── useDnD.ts # 拖拽处理
├── useFlowOperations.ts # 流程操作
├── useHistory.ts # 历史记录
└── useNodeStyles.ts # 节点样式
```
## 使用方法
### 基本用法
```vue
<template>
<FlowchartEditor ref="flowchartRef" />
</template>
<script setup>
import { ref } from 'vue'
import FlowchartEditor from '@/shared/components/FlowchartEditor/index.vue'
const flowchartRef = ref()
// 获取流程图数据
const getFlowchartData = () => {
return flowchartRef.value?.getFlowchartData()
}
</script>
```
### 获取流程图数据
```javascript
// 获取当前流程图数据
const data = flowchartRef.value.getFlowchartData()
console.log(data.nodes) // 节点数组
console.log(data.edges) // 边数组
```
## 组件详解
### 主组件 (index.vue)
主组件负责整合所有功能模块,提供完整的流程图编辑体验。
**主要功能**
- 整合 Vue Flow 核心功能
- 管理节点和边的状态
- 处理用户交互事件
- 提供数据暴露接口
**暴露的方法**
- `getFlowchartData()` - 获取当前流程图数据
### 自定义节点 (CustomNode.vue)
基于 Vue Flow 的自定义节点组件,支持多种节点类型。
**节点类型配置**
- 每种节点类型都有独特的样式和图标
- 支持自定义标签文本
- 提供删除和编辑功能
### 工具栏 (Toolbar.vue)
提供节点创建和操作的工具集合。
**功能**
- 节点类型选择
- 撤销/重做操作
- 清空画布
- 保存状态显示
### 缓存管理 (useCache.ts)
提供本地存储功能,防止数据丢失。
**功能**
- 自动保存到 localStorage
- 页面刷新后恢复数据
- 保存状态提示
- 清空缓存功能
### 拖拽处理 (useDnD.ts)
处理节点拖拽创建的逻辑。
**功能**
- 拖拽开始处理
- 拖拽悬停效果
- 拖拽放置处理
- 节点位置计算
### 流程操作 (useFlowOperations.ts)
处理流程图的增删改操作。
**功能**
- 节点连接处理
- 边删除处理
- 节点删除处理
- 节点更新处理
- 清空画布
### 历史记录 (useHistory.ts)
提供撤销重做功能。
**功能**
- 状态快照保存
- 撤销操作
- 重做操作
- 历史记录管理
### 节点样式 (useNodeStyles.ts)
定义各种节点类型的样式配置。
**功能**
- 节点类型配置
- 样式定义
- 图标配置
- 颜色主题
## 样式定制
### 节点样式
可以通过修改 `useNodeStyles.ts` 来自定义节点样式:
```typescript
export function getNodeTypeConfig(type: string) {
const configs = {
start: {
label: '开始',
icon: 'play-circle',
color: '#10b981',
// 自定义样式
},
// 其他节点类型...
}
return configs[type] || configs.default
}
```
### 边样式
边的样式在主组件中定义:
```vue
:default-edge-options="{
type: 'step',
style: {
stroke: '#6366f1',
strokeWidth: 2.5,
cursor: 'pointer',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))'
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#6366f1',
width: 16,
height: 16,
},
}"
```
## 数据格式
### 节点数据格式
```typescript
interface Node {
id: string
type: string
position: { x: number, y: number }
data: {
customLabel: string
[key: string]: any
}
}
```
### 边数据格式
```typescript
interface Edge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
type?: string
}
```
## 最佳实践
### 1. 性能优化
- 大量节点时考虑虚拟化
- 合理使用缓存机制
- 避免频繁的状态更新
### 2. 用户体验
- 提供清晰的操作反馈
- 支持键盘快捷键
- 保持操作的直观性
### 3. 数据管理
- 定期保存到服务器
- 提供数据导入导出功能
- 支持版本控制机制
## 扩展开发
### 添加新节点类型
1.`useNodeStyles.ts` 中添加新类型配置
2.`Toolbar.vue` 中添加新节点按钮
3.`CustomNode.vue` 中添加新节点渲染逻辑
### 添加新功能
1. 创建新的 composable 函数
2. 在主组件中集成新功能
3. 更新工具栏和用户界面
## 故障排除
### 常见问题
1. **节点无法拖拽**
- 检查拖拽事件处理
- 确认节点类型配置正确
2. **撤销重做不工作**
- 检查历史记录状态
- 确认状态保存时机
3. **数据丢失**
- 检查缓存配置
- 确认 localStorage 可用
### 调试技巧
1. 使用 Vue DevTools 查看组件状态
2. 检查浏览器控制台错误
3. 验证数据格式正确性
## 更新日志
### v1.0.0 (2024-01-15)
- ✨ 初始版本发布
- 🎯 基础流程图编辑功能
- 🔧 拖拽创建节点
- 🔗 节点连接功能
- ↩️ 撤销重做支持
- 💾 本地缓存功能
### v1.1.0 (2024-01-20)
- 🎨 新增多种节点类型
- ⌨️ 键盘快捷键支持
- 🖱️ 优化拖拽体验
- 📱 响应式布局改进
### v1.2.0 (2024-01-25)
- 🔧 模块化重构
- 📦 组合式函数优化
- 🎯 性能提升
- 🐛 修复已知问题
## 许可证
MIT License

View File

@@ -1,41 +1,216 @@
📊 可以添加的图表类型
1. 提交效率趋势图 (折线图)
数据来源: durationData 中的 submission_count / problem_count
展示内容: 每个时间段的提交效率(提交次数/完成题目数值越接近1说明一次AC率越高
价值: 反映刷题质量的提升
2. 排名分布图 (直方图/箱线图)
数据来源: solved 数组中每道题的 rank 和 ac_count
展示内容: 用户解题排名的分布情况前10%、10-30%、30-50%等区间的题目数量)
价值: 了解解题速度和竞争力
3. 等级分布饼图/环形图
数据来源: solved 数组中每道题的 grade
展示内容: S/A/B/C 各等级题目的数量和占比
价值: 直观看出题目质量分布
4. 标签雷达图
数据来源: tags 对象
展示内容: 多维度展示各类标签的掌握程度(可以归一化处理)
价值: 可视化知识点覆盖面
5. 时间活跃度分析 (热力矩阵)
数据来源: solved 数组中的 ac_time
展示内容: 按星期几和时间段统计做题分布工作日vs周末早中晚时段
价值: 了解学习习惯和时间规律
6. 难度-等级关联散点图
数据来源: solved 数组中的难度信息和 grade
展示内容: X轴为难度Y轴为等级每个点代表一道题
价值: 分析在不同难度下的表现
7. 做题加速度图
数据来源: durationData
展示内容: 每个时间段完成题目数的变化率
价值: 看出学习动力的变化趋势
8. 竞赛题目占比
数据来源: solved 数组中的 contest_id 和 contest_count
展示内容: 竞赛题 vs 常规题的数量对比
价值: 了解竞赛参与情况
9. 连续做题天数统计
数据来源: heatmapData
展示内容: 最长连续做题天数、当前连续天数等
价值: 激励持续学习
10. 月度对比雷达图
数据来源: durationData
展示内容: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
价值: 全面评估进步情况
# 图表组件说明
基于 Chart.js 和 Vue-ChartJS 构建的数据可视化组件库,为 OJ Next 项目提供丰富的图表展示功能。
## 技术栈
- **Chart.js** - 强大的图表库,支持多种图表类型
- **Vue-ChartJS** - Chart.js 的 Vue 3 封装
- **Vue 3 Composition API** - 现代化的组件开发方式
- **TypeScript** - 完整的类型支持
## 现有图表组件
### 1. DurationChart 混合图表
**文件位置**: `src/oj/ai/components/DurationChart.vue`
**功能描述**: 展示用户学习进度的时间趋势,结合柱状图和折线图。
**数据来源**: `durationData` API 接口
- 每周/每月的综合情况
- 题目数、提交数、等级变化
**图表类型**:
- 柱状图:完成题目数
- 折线图:提交次数、等级变化
**使用场景**: AI分析页面的主要图表
### 2. Heatmap 热力图
**文件位置**: `src/oj/ai/components/Heatmap.vue`
**功能描述**: 展示用户的提交活跃度分布。
**数据来源**: `heatmapData` API 接口
- 按日期统计提交数
- 可视化用户活跃度
**图表类型**: 热力图矩阵
**使用场景**: 展示学习习惯和时间规律
### 3. Details 详细数据
**文件位置**: `src/oj/ai/components/Details.vue`
**功能描述**: 展示用户详细的学习数据。
**数据来源**: `detailsData` API 接口
- 用户等级、已解决题目列表
- 标签统计、难度统计
- 参赛次数等
**展示方式**: 数据表格和统计卡片
## 可扩展的图表类型
### 1. 提交效率趋势图 (折线图)
**数据来源**: `durationData` 中的 `submission_count / problem_count`
**展示内容**: 每个时间段的提交效率(提交次数/完成题目数)
**价值**: 反映刷题质量的提升值越接近1说明一次AC率越高
### 2. 排名分布图 (直方图/箱线图)
**数据来源**: `solved` 数组中每道题的 `rank``ac_count`
**展示内容**: 用户解题排名的分布情况前10%、10-30%、30-50%等区间的题目数量)
**价值**: 了解解题速度和竞争力
### 3. 等级分布饼图/环形图
**数据来源**: `solved` 数组中每道题的 `grade`
**展示内容**: S/A/B/C 各等级题目的数量和占比
**价值**: 直观看出题目质量分布
### 4. 标签雷达图
**数据来源**: `tags` 对象
**展示内容**: 多维度展示各类标签的掌握程度(可以归一化处理)
**价值**: 可视化知识点覆盖面
### 5. 时间活跃度分析 (热力矩阵)
**数据来源**: `solved` 数组中的 `ac_time`
**展示内容**: 按星期几和时间段统计做题分布工作日vs周末早中晚时段
**价值**: 了解学习习惯和时间规律
### 6. 难度-等级关联散点图
**数据来源**: `solved` 数组中的难度信息和 `grade`
**展示内容**: X轴为难度Y轴为等级每个点代表一道题
**价值**: 分析在不同难度下的表现
### 7. 做题加速度图
**数据来源**: `durationData`
**展示内容**: 每个时间段完成题目数的变化率
**价值**: 看出学习动力的变化趋势
### 8. 竞赛题目占比
**数据来源**: `solved` 数组中的 `contest_id``contest_count`
**展示内容**: 竞赛题 vs 常规题的数量对比
**价值**: 了解竞赛参与情况
### 9. 连续做题天数统计
**数据来源**: `heatmapData`
**展示内容**: 最长连续做题天数、当前连续天数等
**价值**: 激励持续学习
### 10. 月度对比雷达图
**数据来源**: `durationData`
**展示内容**: 多个维度(完成题目数、提交次数、等级、效率等)的月度对比
**价值**: 全面评估进步情况
## 组件开发指南
### 创建新图表组件
```vue
<template>
<div class="chart-container">
<Line
:data="chartData"
:options="chartOptions"
/>
</div>
</template>
<script setup lang="ts">
import { Line } from 'vue-chartjs'
import { computed } from 'vue'
// 定义 props
const props = defineProps<{
data: any[]
}>()
// 计算图表数据
const chartData = computed(() => ({
labels: props.data.map(item => item.label),
datasets: [{
label: '数据',
data: props.data.map(item => item.value),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
}))
// 图表配置
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
}
}
</script>
```
### 图表样式定制
```typescript
// 主题配置
const theme = {
colors: {
primary: '#6366f1',
secondary: '#8b5cf6',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444'
},
gradients: {
primary: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
success: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'
}
}
```
## 性能优化
### 1. 数据懒加载
- 按需加载图表数据
- 使用虚拟滚动处理大量数据
### 2. 图表缓存
- 缓存计算结果
- 避免重复渲染
### 3. 响应式设计
- 自适应容器大小
- 移动端优化
## 最佳实践
### 1. 数据预处理
- 在组件外部处理数据
- 使用 computed 属性缓存计算结果
### 2. 错误处理
- 添加数据验证
- 提供降级方案
### 3. 用户体验
- 添加加载状态
- 提供交互反馈
## 更新日志
### v1.0.0 (2024-01-15)
- ✨ 初始版本发布
- 📊 基础图表组件
- 🎨 主题样式支持
- 📱 响应式设计
### v1.1.0 (2024-01-20)
- 🔧 性能优化
- 📈 新增混合图表
- 🎯 交互体验改进
## 许可证
MIT License

175
package-lock.json generated
View File

@@ -10,6 +10,12 @@
"dependencies": {
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-python": "^6.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.47.0",
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.0",
"@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^13.9.0",
"@vueuse/router": "^13.9.0",
"@wangeditor-next/editor": "^5.6.46",
@@ -1722,6 +1728,175 @@
"integrity": "sha512-YIfAvArSFVXmWvoF+DEGD0FhkhVNcCtVWWkfYtj76eSrwHh/wuEEFhiEubg1XLNM3tChO8FH8xJCT/hnizjgFQ==",
"license": "MIT"
},
"node_modules/@vue-flow/background": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz",
"integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/controls": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz",
"integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/core": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.47.0.tgz",
"integrity": "sha512-w+qrm/xjQP5NUeKUOMIbQvpOeivTbGZtY2lGffK5kHiN3ZLyEazhESc8OeIV9NZkK2T5DIeyX/nhHxCC45HLiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/core/node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@vue-flow/core/node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vue-flow/minimap": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@vue-flow/minimap/-/minimap-1.5.4.tgz",
"integrity": "sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==",
"license": "MIT",
"dependencies": {
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/node-resizer": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@vue-flow/node-resizer/-/node-resizer-1.5.0.tgz",
"integrity": "sha512-FmvOZ6+yVrBEf+8oJcCU20PUZ105QsyM01iiP4vTKHGJ01hzoh9d0/wP9iJkxkIpvBU59CyOHyTKQZlDr4qDhA==",
"license": "MIT",
"dependencies": {
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0"
},
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/node-toolbar": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@vue-flow/node-toolbar/-/node-toolbar-1.1.1.tgz",
"integrity": "sha512-vgSnGMLd3FXstdpvC721qcBDzGkPsudmyA8urEP55/EMikCp59klgzPvVULTDxxsew5MdXtTBCumsqOi+PXJdg==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.22",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.22.tgz",

View File

@@ -12,6 +12,12 @@
"dependencies": {
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-python": "^6.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.47.0",
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.0",
"@vue-flow/node-toolbar": "^1.1.1",
"@vueuse/core": "^13.9.0",
"@vueuse/router": "^13.9.0",
"@wangeditor-next/editor": "^5.6.46",

View File

@@ -16,6 +16,7 @@ const FlowchartEditor = defineAsyncComponent(
const route = useRoute()
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore()
const problemStore = useProblemStore()
@@ -78,6 +79,9 @@ const handleSyncStatusChange = (status: {
}) => {
syncStatus.setOtherUser(status.otherUser)
}
// 提供FlowchartEditor的ref给子组件
provide('flowchartEditorRef', flowchartEditorRef)
</script>
<template>
@@ -89,7 +93,7 @@ const handleSyncStatusChange = (status: {
@change-language="changeLanguage"
@toggle-sync="toggleSync"
/>
<FlowchartEditor v-if="codeStore.code.language === 'Flowchart'" />
<FlowchartEditor v-if="codeStore.code.language === 'Flowchart'" ref="flowchartEditorRef" />
<SyncCodeEditor
v-else
v-model:value="codeStore.code.value"

View File

@@ -4,8 +4,89 @@ import { useBreakpoints } from "shared/composables/breakpoints"
const { isDesktop } = useBreakpoints()
const message = useMessage()
// 通过inject获取FlowchartEditor组件的引用
const flowchartEditorRef = inject<any>("flowchartEditorRef")
// 将流程图JSON数据转换为Mermaid格式
const convertToMermaid = (flowchartData: any) => {
const { nodes, edges } = flowchartData
if (!nodes || nodes.length === 0) {
return "graph TD\n A[空流程图]"
}
let mermaid = "graph TD\n"
// 处理节点 - 根据原始类型和自定义标签
nodes.forEach((node: any) => {
const nodeId = node.id
const label = node.data?.customLabel || node.data?.label || "节点"
const originalType = node.data?.originalType || node.type
// 根据节点原始类型确定Mermaid语法
switch (originalType) {
case "start":
mermaid += ` ${nodeId}[${label}]\n`
break
case "end":
mermaid += ` ${nodeId}[${label}]\n`
break
case "input":
mermaid += ` ${nodeId}[${label}]\n`
break
case "output":
mermaid += ` ${nodeId}[${label}]\n`
break
case "default":
mermaid += ` ${nodeId}[${label}]\n`
break
case "decision":
mermaid += ` ${nodeId}{${label}}\n`
break
case "loop":
mermaid += ` ${nodeId}[${label}]\n`
break
default:
mermaid += ` ${nodeId}[${label}]\n`
}
})
// 处理边
edges.forEach((edge: any) => {
const source = edge.source
const target = edge.target
const label = edge.label || edge.data?.label || ""
if (label) {
mermaid += ` ${source} -->|${label}| ${target}\n`
} else {
mermaid += ` ${source} --> ${target}\n`
}
})
return mermaid
}
const submit = () => {
message.warning("暂未实现")
if (!flowchartEditorRef?.value) return
// 获取流程图的JSON数据
const flowchartData = flowchartEditorRef.value.getFlowchartData()
if (flowchartData.nodes.length === 0 || flowchartData.edges.length === 0) {
message.error("流程图节点或边不能为空")
return
}
// 打印JSON数据到控制台
console.log("流程图JSON数据:", JSON.stringify(flowchartData, null, 2))
// 转换为Mermaid格式
const mermaidCode = convertToMermaid(flowchartData)
console.log("Mermaid代码:")
console.log(mermaidCode)
// 显示成功消息
message.success("敬请期待,快了~")
}
</script>

View File

@@ -0,0 +1,276 @@
<template>
<div
class="custom-node"
:class="{ 'is-hovered': isHovered, 'is-editing': isEditing }"
:data-node-type="nodeType"
:draggable="!isEditing"
@mouseenter="isHovered = true"
@mouseleave="handleMouseLeave"
@dblclick="handleDoubleClick"
@dragstart="handleDragStart"
@mousedown="handleMouseDown"
>
<!-- 连线点 - 根据节点类型动态显示 -->
<NodeHandles
:node-type="nodeType"
:node-config="nodeConfig"
/>
<!-- 节点内容 -->
<div class="node-content">
<!-- 显示模式 -->
<span v-if="!isEditing" class="node-label">{{ displayLabel }}</span>
<!-- 编辑模式 -->
<input
v-if="isEditing"
ref="editInput"
v-model="editText"
class="node-input"
@blur="handleSaveEdit"
@keydown.enter="handleSaveEdit"
@keydown.escape="handleCancelEdit"
@click.stop
@focusout="handleSaveEdit"
/>
<!-- 隐藏的文字用于保持尺寸 -->
<span v-if="isEditing" class="node-label-hidden" aria-hidden="true">{{ displayLabel }}</span>
</div>
<!-- 悬停时显示的操作按钮 -->
<NodeActions
v-if="isHovered"
@delete="handleDelete"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onUnmounted, nextTick, computed, watch } from 'vue'
import { getNodeTypeConfig } from './useNodeStyles'
import NodeHandles from './NodeHandles.vue'
import NodeActions from './NodeActions.vue'
// 类型定义
interface Props {
id: string
type: string
data: any
}
interface Emits {
delete: [nodeId: string]
update: [nodeId: string, newLabel: string]
}
// Props 和 Emits
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 响应式状态
const isHovered = ref(false)
const isEditing = ref(false)
const editText = ref('')
const editInput = ref<HTMLInputElement>()
// 定时器和事件处理器
let hideTimeout: ReturnType<typeof setTimeout> | null = null
let globalClickHandler: ((event: MouseEvent) => void) | null = null
// 计算属性
const nodeType = computed(() => props.data.originalType || props.type)
const nodeConfig = computed(() => getNodeTypeConfig(nodeType.value))
const displayLabel = computed(() => props.data.customLabel || nodeConfig.value.label)
// 事件处理器
const handleDelete = () => emit('delete', props.id)
const handleMouseDown = (event: MouseEvent) => {
// 检查是否点击在连线点区域
const target = event.target as HTMLElement
if (target.closest('.vue-flow__handle')) {
// 如果在连线点区域,禁用节点拖拽
event.preventDefault()
return false
}
}
const handleDragStart = (event: DragEvent) => {
if (isEditing.value) {
return
}
// 检查是否在连线点区域开始拖拽
const target = event.target as HTMLElement
if (target.closest('.vue-flow__handle')) {
event.preventDefault()
return false
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const handleDoubleClick = (event: MouseEvent) => {
event.stopPropagation()
if (!isEditing.value) {
isEditing.value = true
editText.value = displayLabel.value
nextTick(() => {
editInput.value?.focus()
editInput.value?.select()
})
addGlobalClickHandler()
}
}
const handleSaveEdit = () => {
if (isEditing.value) {
// 保存编辑的文本
if (editText.value.trim()) {
emit('update', props.id, editText.value.trim())
}
isEditing.value = false
removeGlobalClickHandler()
}
}
const handleCancelEdit = () => {
isEditing.value = false
editText.value = ''
removeGlobalClickHandler()
}
const handleMouseEnter = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const handleMouseLeave = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
}
hideTimeout = setTimeout(() => {
isHovered.value = false
}, 300)
}
// 全局点击处理器
const addGlobalClickHandler = () => {
if (globalClickHandler) return
globalClickHandler = (event: MouseEvent) => {
if (isEditing.value && !(event.target as Element)?.closest('.custom-node')) {
handleSaveEdit()
}
}
document.addEventListener('click', globalClickHandler, { capture: true })
}
const removeGlobalClickHandler = () => {
if (globalClickHandler) {
document.removeEventListener('click', globalClickHandler, { capture: true })
globalClickHandler = null
}
}
// 清理函数
onUnmounted(() => {
if (hideTimeout) {
clearTimeout(hideTimeout)
}
removeGlobalClickHandler()
})
</script>
<style scoped>
/* 主容器 */
.custom-node {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: inherit;
transition: all 0.2s ease;
padding: 0 20px;
}
.custom-node.is-hovered {
z-index: 1;
}
.custom-node.is-hovered .node-content {
filter: brightness(1.1);
}
/* 节点内容区域 */
.node-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 100%;
border-radius: inherit;
transition: all 0.2s ease;
}
/* 节点标签 */
.node-label {
font-size: 16px;
font-weight: 500;
white-space: nowrap;
display: inline-block;
text-align: center;
line-height: 1.2;
}
/* 编辑输入框 */
.node-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
border: none;
outline: none;
font-size: 16px;
font-weight: 500;
color: inherit;
text-align: center;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
resize: none;
font-family: inherit;
line-height: 1.2;
}
/* 隐藏标签(用于保持尺寸) */
.node-label-hidden {
font-size: 16px;
font-weight: 500;
white-space: nowrap;
display: inline-block;
visibility: hidden;
pointer-events: none;
text-align: center;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div
class="node-actions"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
>
<button
class="action-btn delete-btn"
@click.stop="$emit('delete')"
title="删除节点"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
</template>
<script lang="ts" setup>
defineEmits<{
delete: []
mouseenter: []
mouseleave: []
}>()
</script>
<style scoped>
.node-actions {
position: absolute;
top: -40px;
right: -20px;
padding: 8px;
}
.action-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
font-size: 10px;
}
.delete-btn {
background: #ef4444;
color: white;
}
.delete-btn:hover {
background: #dc2626;
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<!-- 开始节点只有输出 handle -->
<template v-if="nodeType === 'start'">
<Handle
type="source"
:position="Position.Bottom"
:style="getHandleStyle('#10b981', { bottom: '-10px' })"
/>
</template>
<!-- 结束节点只有输入 handle -->
<template v-else-if="nodeType === 'end'">
<Handle
type="target"
:position="Position.Top"
:style="getHandleStyle('#ef4444', { top: '-10px' })"
/>
</template>
<!-- 选择判断节点一个输入 + 两个输出/ -->
<template v-else-if="nodeType === 'decision'">
<Handle
type="target"
:position="Position.Top"
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
/>
<Handle
type="source"
:position="Position.Left"
id="yes"
:style="{
background: '#10b981',
width: '12px',
height: '12px',
border: '2px solid white',
zIndex: 10,
left: '-10px',
top: '50%',
transform: 'translateY(-50%)'
}"
/>
<Handle
type="source"
:position="Position.Right"
id="no"
:style="{
background: '#ef4444',
width: '12px',
height: '12px',
border: '2px solid white',
zIndex: 10,
right: '-10px',
top: '50%',
transform: 'translateY(-50%)'
}"
/>
<!-- /否标签 -->
<div class="decision-labels">
<span class="decision-label decision-label-yes"></span>
<span class="decision-label decision-label-no"></span>
</div>
</template>
<!-- 循环判断节点两个输入 + 两个输出进入/循环体返回 + 继续/退出 -->
<template v-else-if="nodeType === 'loop'">
<!-- 进入循环的输入 -->
<Handle
type="target"
:position="Position.Top"
id="enter"
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
/>
<!-- 循环体返回的输入 -->
<Handle
type="target"
:position="Position.Bottom"
id="return"
:style="
getHandleStyle('#8b5cf6', {
bottom: '-16px',
})
"
/>
<!-- 继续执行循环体 -->
<Handle
type="source"
:position="Position.Right"
id="continue"
:style="
getHandleStyle('#10b981', {
right: '-10px',
top: '50%',
transform: 'translateY(-50%)',
})
"
/>
<!-- 退出循环 -->
<Handle
type="source"
:position="Position.Left"
id="exit"
:style="
getHandleStyle('#ef4444', {
left: '-10px',
top: '50%',
transform: 'translateY(-50%)',
})
"
/>
<!-- 标签 -->
<div class="loop-labels">
<span class="loop-label loop-label-enter">进入</span>
<span class="loop-label loop-label-return">返回</span>
<span class="loop-label loop-label-continue">继续</span>
<span class="loop-label loop-label-exit">退出</span>
</div>
</template>
<!-- 上下两个 handle -->
<template v-else>
<Handle
type="target"
:position="Position.Top"
:style="getHandleStyle(nodeConfig.color, { top: '-10px' })"
/>
<Handle
type="source"
:position="Position.Bottom"
:style="getHandleStyle(nodeConfig.color, { bottom: '-10px' })"
/>
</template>
</template>
<script lang="ts" setup>
import { Handle, Position } from "@vue-flow/core"
interface Props {
nodeType: string
nodeConfig: {
color: string
label: string
}
}
defineProps<Props>()
// 获取 handle 样式
const getHandleStyle = (color: string, position: Record<string, string>) => ({
background: color,
width: "12px",
height: "12px",
border: "2px solid white",
zIndex: 10,
...position,
})
</script>
<style scoped>
/* 判断节点标签样式 */
.decision-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
.decision-label {
position: absolute;
font-size: 16px;
font-weight: 600;
color: #000;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.decision-label-yes {
left: -25px;
top: -20px;
}
.decision-label-no {
right: -25px;
top: -20px;
}
/* 循环节点标签样式 */
.loop-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.loop-label {
position: absolute;
font-size: 16px;
font-weight: 600;
color: #000;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.loop-label-enter {
right: 20px;
top: -45px;
}
.loop-label-return {
right: 20px;
bottom: -45px;
}
.loop-label-continue {
right: -40px;
top: -16px;
transform: translateY(-50%);
}
.loop-label-exit {
left: -40px;
top: -16px;
transform: translateY(-50%);
}
</style>

View File

@@ -0,0 +1,398 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getNodeTypeConfig } from './useNodeStyles'
// 拖拽开始处理
const onDragStart = (event: DragEvent, type: string) => {
if (!event.dataTransfer || !type) return
event.dataTransfer.setData("application/vueflow", type)
event.dataTransfer.effectAllowed = "move"
}
// Props
const props = defineProps<{
canUndo?: boolean
canRedo?: boolean
isSaving?: boolean
lastSaved?: Date | null
hasUnsavedChanges?: boolean
}>()
// Emits
const emit = defineEmits<{
deleteNode: [nodeId: string]
undo: []
redo: []
clear: []
}>()
// 工具栏状态
// 节点类型定义 - 优化性能
const nodeTypes = computed(() => [
'start',
'input',
'default',
'decision',
'loop',
'output',
'end'
].map(type => {
const config = getNodeTypeConfig(type)
return {
type,
...config
}
}))
// 获取保存状态标题
const getSaveStatusTitle = () => {
if (props.isSaving) {
return '正在保存...'
} else if (props.hasUnsavedChanges) {
return '有未保存的更改'
} else if (props.lastSaved) {
return `已保存 - ${new Date(props.lastSaved).toLocaleTimeString()}`
} else {
return '已保存'
}
}
</script>
<template>
<div class="toolbar">
<!-- 工具栏头部 -->
<div class="toolbar-header">
<div class="header-content">
<h3>节点库</h3>
<div class="save-status-indicator" :class="{
'saving': props.isSaving,
'unsaved': props.hasUnsavedChanges && !props.isSaving,
'saved': !props.hasUnsavedChanges && !props.isSaving
}" :title="getSaveStatusTitle()">
<span v-if="props.isSaving" class="spinner"></span>
<span v-else-if="props.hasUnsavedChanges"></span>
<span v-else></span>
</div>
</div>
<p class="description">拖拽节点到画布中</p>
</div>
<!-- 节点列表 -->
<div class="nodes">
<div
v-for="nodeType in nodeTypes"
:key="nodeType.type"
class="node-item"
:draggable="true"
@dragstart="onDragStart($event, nodeType.type)"
:style="{ borderColor: nodeType.color }"
:title="`${nodeType.label} - ${nodeType.description}`"
>
<div class="node-icon" :style="{ backgroundColor: nodeType.color }">
{{ nodeType.icon }}
</div>
<div class="node-info">
<div class="node-label">{{ nodeType.label }}</div>
<div class="node-description">{{ nodeType.description }}</div>
</div>
</div>
</div>
<!-- 工具栏操作 -->
<div class="toolbar-actions">
<div class="history-controls">
<button
class="action-btn history-btn"
:disabled="!canUndo"
@click="$emit('undo')"
title="撤销 (Ctrl+Z)"
>
<span class="btn-icon"></span>
<span class="btn-text">撤销</span>
</button>
<button
class="action-btn history-btn"
:disabled="!canRedo"
@click="$emit('redo')"
title="重做 (Ctrl+Y)"
>
<span class="btn-icon"></span>
<span class="btn-text">重做</span>
</button>
</div>
<button class="action-btn clear-btn" @click="$emit('clear')" title="清空画布">
<span class="btn-icon">🗑</span>
<span class="btn-text">清空画布</span>
</button>
</div>
</div>
</template>
<style scoped>
.toolbar {
width: 140px;
height: auto;
max-height: calc(100vh - 40px);
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
padding: 16px;
overflow-y: auto;
transition: all 0.3s ease;
}
.toolbar-header {
margin-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 12px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.toolbar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
/* 保存状态指示器样式 */
.save-status-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
transition: all 0.3s ease;
cursor: pointer;
}
.save-status-indicator.saving {
background: #fef3c7;
color: #d97706;
animation: pulse 1.5s ease-in-out infinite;
}
.save-status-indicator.unsaved {
background: #fef2f2;
color: #dc2626;
animation: pulse 1.5s ease-in-out infinite;
}
.save-status-indicator.saved {
background: #f0fdf4;
color: #16a34a;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.description {
margin: 0;
font-size: 12px;
color: #6b7280;
}
/* 节点列表样式 */
.nodes {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.node-item {
display: flex;
align-items: center;
padding: 10px;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: grab;
transition: all 0.2s ease;
background: white;
}
.node-item:hover {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-2px);
}
.node-item:active {
cursor: grabbing;
transform: translateY(0);
}
.node-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: white;
margin-right: 10px;
flex-shrink: 0;
}
.node-info {
flex: 1;
min-width: 0;
}
.node-label {
font-weight: 500;
color: #1f2937;
margin-bottom: 2px;
font-size: 14px;
}
.node-description {
font-size: 12px;
color: #6b7280;
line-height: 1.3;
}
/* 工具栏操作按钮样式 */
.toolbar-actions {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid #e5e7eb;
padding-top: 12px;
}
.history-controls {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #374151;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
}
.action-btn:hover {
background: #f9fafb;
border-color: #9ca3af;
transform: translateY(-1px);
}
.action-btn:active {
background: #f3f4f6;
transform: translateY(0);
}
.history-btn {
flex: 1;
font-size: 11px;
padding: 8px 10px;
}
.history-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f9fafb;
color: #9ca3af;
}
.history-btn:disabled:hover {
background: #f9fafb;
border-color: #d1d5db;
transform: none;
}
.clear-btn {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.clear-btn:hover {
background: #fee2e2;
border-color: #fca5a5;
}
.btn-icon {
font-size: 14px;
}
.btn-text {
font-size: 12px;
}
/* 滚动条样式 */
.toolbar::-webkit-scrollbar {
width: 6px;
}
.toolbar::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.toolbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.toolbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.toolbar {
width: 180px;
top: 10px;
left: 10px;
}
}
</style>

View File

@@ -1,14 +1,221 @@
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from 'vue'
import Toolbar from "./Toolbar.vue"
import "@vue-flow/core/dist/style.css"
import "@vue-flow/core/dist/theme-default.css"
import "@vue-flow/controls/dist/style.css"
import { useVueFlow, VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core"
import { Controls } from "@vue-flow/controls"
import { Background } from "@vue-flow/background"
import { useDnD } from "./useDnD"
import { useHistory } from "./useHistory"
import { useFlowOperations } from "./useFlowOperations"
import { useCache } from "./useCache"
import CustomNode from "./CustomNode.vue"
// Vue Flow 实例
const { addNodes, addEdges, removeNodes, removeEdges } = useVueFlow()
// 节点和边的响应式数据
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
// 历史记录管理
const { canUndo, canRedo, saveState, undo, redo } = useHistory()
// 缓存管理
const { isSaving, lastSaved, hasUnsavedChanges, saveToCache, loadFromCache, clearCache } = useCache(
nodes,
edges,
'flowchart-editor-data'
)
// 拖拽处理
const { isDragOver, onDragOver, onDragLeave, onDrop } = useDnD()
// 流程操作
const {
handleConnect,
handleEdgeClick,
handleNodeDelete,
handleNodeUpdate,
clearCanvas,
deleteSelected
} = useFlowOperations(
nodes,
edges,
addNodes,
addEdges,
removeNodes,
removeEdges,
saveState
)
// 拖拽处理包装
const handleDragOver = (event: DragEvent) => {
onDragOver(event)
}
const handleDragLeave = () => {
onDragLeave()
}
const handleDrop = (event: DragEvent) => {
// 处理正常的节点创建拖拽
const newNode = onDrop(event)
if (newNode) {
saveState(nodes.value, edges.value)
}
}
// 撤销/重做处理
const handleUndo = () => {
const state = undo()
if (state) {
nodes.value = [...state.nodes]
edges.value = [...state.edges]
}
}
const handleRedo = () => {
const state = redo()
if (state) {
nodes.value = [...state.nodes]
edges.value = [...state.edges]
}
}
// 清空画布
const handleClear = () => {
clearCanvas()
clearCache()
}
// 键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement) return
if (event.key === 'Delete' || event.key === 'Backspace') {
deleteSelected()
}
if (event.ctrlKey || event.metaKey) {
if (event.key === 'z' && !event.shiftKey) {
event.preventDefault()
handleUndo()
} else if (event.key === 'z' && event.shiftKey) {
event.preventDefault()
handleRedo()
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
// 从缓存恢复数据
loadFromCache()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
// 暴露节点和边数据给父组件
defineExpose({
nodes,
edges,
getFlowchartData: () => ({
nodes: nodes.value,
edges: edges.value
})
})
</script>
<template>
<div class="container">可拖拽的流程图编辑器</div>
<div class="container">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@connect="handleConnect"
@edge-click="handleEdgeClick"
:default-edge-options="{
type: 'step',
style: {
stroke: '#6366f1',
strokeWidth: 2.5,
cursor: 'pointer',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))'
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#6366f1',
width: 16,
height: 16,
},
}"
:connection-line-style="{
stroke: '#6366f1',
strokeWidth: 2.5,
strokeDasharray: '8,4',
markerEnd: 'url(#connection-arrow)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}"
:fit-view-on-init="false"
:connect-on-click="false"
:multi-selection-key-code="null"
:delete-key-code="null"
>
<!-- SVG 定义用于连接线箭头 -->
<defs>
<marker
id="connection-arrow"
markerWidth="12"
markerHeight="12"
refX="10"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L10,3 z" fill="#6366f1" stroke="#6366f1" strokeWidth="0.5" />
</marker>
</defs>
<template #node-custom="{ data, id, type }">
<CustomNode
:id="id"
:type="type"
:data="data"
@delete="handleNodeDelete"
@update="handleNodeUpdate"
/>
</template>
<Background variant="lines" :gap="20" :size="1" />
<Controls />
<Toolbar
:can-undo="canUndo"
:can-redo="canRedo"
:is-saving="isSaving"
:last-saved="lastSaved"
:has-unsaved-changes="hasUnsavedChanges"
@clear="handleClear"
@undo="handleUndo"
@redo="handleRedo"
@deleteNode="handleNodeDelete"
/>
</VueFlow>
</div>
</template>
<style scoped>
.container {
width: 100%;
height: calc(100vh - 133px);
position: relative;
}
</style>

View File

@@ -0,0 +1,83 @@
import { ref, watch } from 'vue'
import { useStorage, useDebounceFn } from '@vueuse/core'
import type { Node, Edge } from '@vue-flow/core'
/**
* 缓存管理 - 使用 @vueuse 的 useStorage
*/
export function useCache(
nodes: any,
edges: any,
storageKey: string = 'flowchart-editor-data'
) {
const isSaving = ref(false)
const lastSaved = ref<Date | null>(null)
const hasUnsavedChanges = ref(false)
// 使用 useStorage 管理数据存储
const storedData = useStorage<{
nodes: Node[]
edges: Edge[]
timestamp: string
}>(storageKey, {
nodes: [],
edges: [],
timestamp: ''
})
// 防抖保存
const debouncedSave = useDebounceFn(() => {
isSaving.value = true
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false
isSaving.value = false
}, 500)
// 立即保存
const saveToCache = () => {
isSaving.value = true
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false
isSaving.value = false
}
// 从缓存加载数据
const loadFromCache = () => {
if (storedData.value.nodes?.length || storedData.value.edges?.length) {
nodes.value = storedData.value.nodes
edges.value = storedData.value.edges
lastSaved.value = storedData.value.timestamp ? new Date(storedData.value.timestamp) : null
hasUnsavedChanges.value = false
return true
}
return false
}
// 清除缓存数据
const clearCache = () => {
storedData.value = { nodes: [], edges: [], timestamp: '' }
lastSaved.value = null
hasUnsavedChanges.value = false
}
// 监听节点和边的变化
watch([nodes, edges], () => {
hasUnsavedChanges.value = true
debouncedSave()
}, { deep: true })
return {
isSaving,
lastSaved,
hasUnsavedChanges,
saveToCache,
loadFromCache,
clearCache
}
}

View File

@@ -0,0 +1,71 @@
import { ref } from 'vue'
import { useVueFlow } from "@vue-flow/core"
import { nanoid } from "nanoid"
import { getNodeTypeConfig, createNodeStyle, getNodeDimensions } from "./useNodeStyles"
/**
* 简化的拖拽处理
*/
export function useDnD() {
const { addNodes, screenToFlowCoordinate } = useVueFlow()
const isDragOver = ref(false)
// 拖拽悬停处理
const onDragOver = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
}
// 拖拽离开处理
const onDragLeave = () => {
isDragOver.value = false
}
// 拖拽放置处理
const onDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
const type = event.dataTransfer?.getData("application/vueflow")
if (!type) return
// 获取鼠标在画布中的坐标
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
})
// 根据节点类型获取实际尺寸
const dimensions = getNodeDimensions(type)
// 调整位置,使节点中心点对齐到鼠标位置
const adjustedPosition = {
x: position.x - dimensions.width / 2,
y: position.y - dimensions.height / 2
}
const nodeId = `node-${nanoid()}`
const config = getNodeTypeConfig(type)
const newNode = {
id: nodeId,
type: 'custom',
position: adjustedPosition,
data: {
label: config.label,
color: config.color,
originalType: type
},
style: createNodeStyle(type)
}
addNodes([newNode])
return newNode
}
return {
isDragOver,
onDragOver,
onDragLeave,
onDrop
}
}

View File

@@ -0,0 +1,107 @@
import { nanoid } from "nanoid"
import type { Ref } from 'vue'
import type { Node, Edge } from '@vue-flow/core'
/**
* 简化的流程操作
*/
export function useFlowOperations(
nodes: Ref<Node[]>,
edges: Ref<Edge[]>,
addNodes: (nodes: Node[]) => void,
addEdges: (edges: Edge[]) => void,
removeNodes: (nodeIds: string[]) => void,
removeEdges: (edgeIds: string[]) => void,
saveState: (nodes: Node[], edges: Edge[]) => void
) {
// 连接处理
const handleConnect = (params: any) => {
const newEdge: Edge = {
id: `edge-${nanoid()}`,
source: params.source,
target: params.target,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
type: 'default'
}
addEdges([newEdge])
saveState(nodes.value, edges.value)
}
// 边点击删除
const handleEdgeClick = (event: any) => {
removeEdges([event.edge.id])
saveState(nodes.value, edges.value)
}
// 节点删除
const handleNodeDelete = (nodeId: string) => {
// 删除相关边
const relatedEdges = edges.value.filter(edge =>
edge.source === nodeId || edge.target === nodeId
)
if (relatedEdges.length > 0) {
removeEdges(relatedEdges.map(edge => edge.id))
}
removeNodes([nodeId])
saveState(nodes.value, edges.value)
}
// 节点更新
const handleNodeUpdate = (nodeId: string, newLabel: string) => {
const nodeIndex = nodes.value.findIndex(node => node.id === nodeId)
if (nodeIndex !== -1) {
const oldNode = nodes.value[nodeIndex]
// 创建新的节点对象以确保响应式更新
const updatedNode = {
...oldNode,
data: {
...oldNode.data,
customLabel: newLabel
}
}
// 使用 Vue Flow 的更新方法
nodes.value[nodeIndex] = updatedNode
// 强制触发响应式更新
nodes.value = [...nodes.value]
saveState(nodes.value, edges.value)
}
}
// 清空画布
const clearCanvas = () => {
nodes.value = []
edges.value = []
saveState(nodes.value, edges.value)
}
// 删除选中的节点和边
const deleteSelected = () => {
const selectedNodes = nodes.value.filter(node => (node as any).selected)
const selectedEdges = edges.value.filter(edge => (edge as any).selected)
if (selectedNodes.length > 0) {
removeNodes(selectedNodes.map(node => node.id))
}
if (selectedEdges.length > 0) {
removeEdges(selectedEdges.map(edge => edge.id))
}
saveState(nodes.value, edges.value)
}
return {
handleConnect,
handleEdgeClick,
handleNodeDelete,
handleNodeUpdate,
clearCanvas,
deleteSelected
}
}

View File

@@ -0,0 +1,63 @@
import { ref, computed } from 'vue'
import type { Node, Edge } from '@vue-flow/core'
/**
* 简化的历史记录管理
*/
export function useHistory() {
const history = ref<{ nodes: Node[], edges: Edge[] }[]>([])
const historyIndex = ref(-1)
// 是否可以撤销
const canUndo = computed(() => historyIndex.value > 0)
// 是否可以重做
const canRedo = computed(() => historyIndex.value < history.value.length - 1)
// 保存状态到历史记录
const saveState = (nodes: Node[], edges: Edge[]) => {
const currentState = { nodes: [...nodes], edges: [...edges] }
// 如果当前不在历史记录的末尾,删除后面的记录
if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1)
}
history.value.push(currentState)
historyIndex.value = history.value.length - 1
// 限制历史记录数量
if (history.value.length > 20) {
history.value.shift()
historyIndex.value--
}
}
// 撤销
const undo = () => {
if (canUndo.value) {
historyIndex.value--
const state = history.value[historyIndex.value]
return state
}
return null
}
// 重做
const redo = () => {
if (canRedo.value) {
historyIndex.value++
const state = history.value[historyIndex.value]
return state
}
return null
}
return {
canUndo,
canRedo,
saveState,
undo,
redo
}
}

View File

@@ -0,0 +1,137 @@
/**
* 节点样式管理
*/
/**
* 获取节点类型配置
*/
export function getNodeTypeConfig(type: string) {
const configs: Record<
string,
{ label: string; color: string; icon: string; description: string }
> = {
start: {
label: "开始",
color: "#10b981",
icon: "▶",
description: "流程开始",
},
input: {
label: "输入",
color: "#06b6d4",
icon: "📥",
description: "数据输入",
},
default: {
label: "赋值",
color: "#3b82f6",
icon: "⚙",
description: "赋值语句",
},
decision: {
label: "判断",
color: "#f59e0b",
icon: "❓",
description: "条件语句",
},
loop: {
label: "循环",
color: "#8b5cf6",
icon: "🔄",
description: "循环语句",
},
output: {
label: "输出",
color: "#84cc16",
icon: "📤",
description: "数据输出",
},
end: {
label: "结束",
color: "#ef4444",
icon: "⏹",
description: "流程结束",
},
}
return (
configs[type] || {
label: "节点",
color: "#6b7280",
icon: "⚪",
description: "未知节点",
}
)
}
/**
* 获取节点样式
*/
export function getNodeStyle(type: string, color: string) {
const baseStyle = {
background: color,
color: "white",
border: `2px solid ${color}`,
borderRadius: "10px",
fontSize: "16px",
fontWeight: "500",
width: "auto", // 自动宽度
height: "auto", // 自动高度
minWidth: "100px",
minHeight: "40px",
maxWidth: "400px",
maxHeight: "160px",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
}
// 根据节点类型调整样式
switch (type) {
case "start":
case "end":
return {
...baseStyle,
}
case "decision":
return {
...baseStyle,
borderRadius: "8px",
minWidth: "140px",
minHeight: "50px",
}
case "loop":
return {
...baseStyle,
borderRadius: "8px",
minWidth: "140px",
minHeight: "50px",
}
default:
return baseStyle
}
}
/**
* 创建节点样式
*/
export function createNodeStyle(type: string) {
const config = getNodeTypeConfig(type)
return getNodeStyle(type, config.color)
}
/**
* 获取节点尺寸
*/
export function getNodeDimensions(type: string) {
switch (type) {
case "start":
case "end":
return { width: 100, height: 40 }
case "decision":
case "loop":
return { width: 140, height: 50 }
default:
return { width: 120, height: 40 }
}
}