test: 集成 Vitest 测试框架和开发规范

- 安装 vitest, @vue/test-utils, jsdom 等测试依赖
- 配置 vitest.config.js 测试环境
- 添加 schema.test.ts (7个数据结构验证测试)
- 添加 useStore.test.ts (7个状态管理测试)
- 创建测试指南文档 (docs/testing.md)
- 创建测试规范文档 (docs/testing-rules.md)
- 创建开发规范文档 (docs/development-rules.md)
- 创建开发工作流程文档 (docs/1management/workflow.md)
- 添加测试相关 npm scripts (test, test:watch, test:ui, test:coverage)
- 所有测试通过 (14/14)
This commit is contained in:
2026-02-12 23:25:13 +08:00
parent c4d701b443
commit 92aa4094f5
13 changed files with 4245 additions and 17 deletions

View File

@@ -0,0 +1,363 @@
# 开发工作流程
本文档描述在 yys-editor 项目中开发新功能的标准流程。
## 开发流程概览
```
确定任务 → 需求分析 → 设计方案 → 编写测试 → 实现功能 → 测试验证 → 更新文档 → 提交代码
```
## 详细步骤
### 0. 确定任务(第一步)
**在开始任何开发工作前,先确定要做什么:**
#### 0.1 查看任务列表
1. **优先查看** `docs/1management/next.md`
- 这里记录了下一步要做的任务
- 按优先级排序
- 包含任务的简要描述
2. **参考** `docs/1management/plan.md`
- 查看项目整体规划
- 了解任务的背景和目标
- 确认任务的优先级和依赖关系
#### 0.2 确定任务来源
任务可能来自:
- `next.md` 中的待办事项
- 用户直接提出的需求
- Bug 修复需求
- 技术债务优化
#### 0.3 任务确认
- 明确任务的具体目标
- 确认任务的优先级
- 评估任务的工作量
- 检查是否有依赖的前置任务
### 1. 需求分析
- 明确功能需求和预期效果
- 确定影响范围数据层、UI 层、业务逻辑)
- 评估是否需要修改现有数据结构
-`plan.md` 中记录设计思路(如果是重要功能)
### 2. 设计方案
- 确定实现方案和技术选型
- 如果涉及数据模型变更,更新 `schema.ts`
- 如果涉及状态管理,规划 Store 的修改
- 考虑向后兼容性和数据迁移
### 3. 编写测试(重要!)
**在实现功能之前,先编写测试用例**
#### 3.1 确定测试范围
根据改动类型选择测试文件:
| 改动类型 | 测试文件 | 示例 |
|---------|---------|------|
| 数据模型 | `schema.test.ts` | 添加新的节点类型 |
| Store 操作 | `useStore.test.ts` | 文件导入导出逻辑 |
| 工具函数 | `<功能名>.test.ts` | 数据转换、验证函数 |
| 组件逻辑 | `<组件名>.test.ts` | 复杂的组件交互 |
#### 3.2 编写测试用例
`src/__tests__/` 目录创建或更新测试文件:
```typescript
import { describe, it, expect, beforeEach } from 'vitest'
describe('新功能名称', () => {
beforeEach(() => {
// 测试前的准备工作
})
it('应该正确处理正常情况', () => {
// 准备测试数据
const input = { /* ... */ }
// 执行功能
const result = newFeature(input)
// 验证结果
expect(result).toBe(expectedValue)
})
it('应该处理边界情况', () => {
// 测试空值、极端值等
})
it('应该处理错误情况', () => {
// 测试异常处理
})
})
```
#### 3.3 运行测试(此时应该失败)
```bash
npm test
```
测试失败是正常的,因为功能还没实现。
### 4. 实现功能
根据测试用例的描述,实现具体功能:
- 保持代码简洁清晰
- 添加必要的注释
- 遵循项目代码风格
- 考虑性能和安全性
### 5. 测试验证
#### 5.1 运行单元测试
```bash
# 监听模式,实时查看测试结果
npm run test:watch
# 或单次运行
npm test
```
确保所有测试通过(包括新增和已有的测试)。
#### 5.2 手动测试
- 启动开发服务器:`npm run dev`
- 在浏览器中测试新功能
- 验证 UI 交互和用户体验
- 测试边界情况和异常场景
#### 5.3 测试覆盖率(可选)
```bash
npm run test:coverage
```
查看测试覆盖率报告,确保关键代码被测试覆盖。
### 6. 代码质量检查
#### 6.1 代码格式化
```bash
npm run format
```
#### 6.2 代码检查
```bash
npm run lint
```
修复所有 ESLint 警告和错误。
#### 6.3 一键检查(推荐)
```bash
npm test && npm run lint && npm run format
```
### 7. 更新文档
**完成功能后,必须更新项目管理文档:**
#### 7.1 更新 next.md
- 将已完成的任务标记为完成或删除
- 如果发现新的待办事项,添加到列表中
- 调整任务优先级(如有必要)
#### 7.2 更新 plan.md
- 记录已完成的功能
- 更新项目进度
- 如果有重要的设计决策,记录下来
- 更新已知问题列表
#### 7.3 更新其他文档(如有必要)
- 如果添加了新的 API 或功能,更新相关文档
- 如果修改了数据结构,更新 schema 说明
- 如果有用户可见的变化,更新 README 或更新日志
### 8. 提交代码
#### 8.1 提交前检查清单
- [ ] 所有测试通过
- [ ] 新功能有对应的测试用例
- [ ] 代码已格式化
- [ ] 没有 ESLint 错误
- [ ] 手动测试通过
- [ ] **已更新 next.md 和 plan.md**
- [ ] 更新了相关文档(如有必要)
#### 8.2 编写 Commit 消息
遵循规范格式:
```
<type>: <subject>
<body>
```
**Type 类型:**
- `feat`: 新功能
- `fix`: 修复 bug
- `test`: 添加或修改测试
- `refactor`: 重构代码
- `style`: 样式调整
- `docs`: 文档更新
- `chore`: 构建或工具变动
**示例:**
```
feat: 添加式神筛选功能
- 实现按稀有度筛选
- 添加搜索框支持名称搜索
- 补充单元测试覆盖筛选逻辑
```
#### 8.3 提交代码
```bash
git add .
git commit -m "feat: 添加式神筛选功能"
git push
```
#### 8.4 任务完成后的最终确认
- [ ] 代码已提交
- [ ] `next.md` 已更新(任务已完成或移除)
- [ ] `plan.md` 已更新(记录进度)
- [ ] 相关文档已同步更新
## 文档更新示例
### 示例 1: 完成 next.md 中的任务
**任务前 (next.md):**
```markdown
## 下一步任务
1. [ ] 添加式神筛选功能
2. [ ] 优化文件导出性能
```
**任务后 (next.md):**
```markdown
## 下一步任务
1. [x] ~~添加式神筛选功能~~ (已完成 2024-01-15)
2. [ ] 优化文件导出性能
```
或直接删除已完成的任务:
```markdown
## 下一步任务
1. [ ] 优化文件导出性能
```
### 示例 2: 更新 plan.md
**在 plan.md 的相应章节添加:**
```markdown
## 已完成功能
### 式神筛选功能 (2024-01-15)
- 实现按稀有度筛选
- 添加搜索框支持名称搜索
- 测试覆盖率: 95%
```
## 特殊场景处理
### 场景 1: 紧急 Bug 修复
1. 创建 `fix/` 分支
2. 先写测试复现 bug
3. 修复代码直到测试通过
4. 快速提交和部署
### 场景 2: 重构现有代码
1. 确保现有测试覆盖要重构的代码
2. 如果没有测试,先补充测试
3. 重构代码,保持测试通过
4. 提交时使用 `refactor:` 类型
### 场景 3: 仅 UI 调整
- 可以不写单元测试
- 但必须手动测试验证
- 确保没有破坏现有功能
### 场景 4: 数据模型变更
1. 更新 `schema.ts` 类型定义
2.`schema.test.ts` 添加测试
3. 实现数据迁移逻辑(如需要)
4. 测试新旧数据的兼容性
## 开发工具推荐
### 测试相关
```bash
# 可视化测试界面
npm run test:ui
# 监听模式(开发时推荐)
npm run test:watch
```
### 调试技巧
- 在测试中使用 `console.log` 查看中间状态
- 使用 `it.only()` 只运行特定测试
- 使用 `it.skip()` 跳过某些测试(临时)
## 常见问题
### Q: 测试失败了怎么办?
1. 仔细阅读错误信息
2. 检查是代码问题还是测试用例问题
3. 使用 `console.log` 调试
4. 不要跳过或删除失败的测试
### Q: 改动很小,必须写测试吗?
- 如果涉及数据层或业务逻辑:**必须**
- 如果只是 UI 样式调整:可以不写
- 如果不确定:**建议写测试**
### Q: 测试写起来很慢怎么办?
- 参考现有测试用例的写法
- 使用测试模板快速开始
- 测试投入的时间会在后续维护中节省回来
## 相关文档
- [测试指南](../testing.md) - 如何运行和编写测试
- [测试规范](../testing-rules.md) - 测试相关的强制要求
- [开发规范](../development-rules.md) - 代码提交规范

78
docs/development-rules.md Normal file
View File

@@ -0,0 +1,78 @@
# yys-editor 项目规范
## 代码提交规范
### 必须遵守的规则
1. **测试优先原则**
- 所有涉及数据层和业务逻辑的改动,必须先编写或更新测试用例
- 提交前必须运行 `npm test` 确保所有测试通过
- 详见 [测试规范](./testing-rules.md)
2. **代码格式化**
- 提交前运行 `npm run format` 格式化代码
- 使用 Prettier 统一代码风格
3. **代码检查**
- 提交前运行 `npm run lint` 检查代码质量
- 修复所有 ESLint 警告和错误
## 开发工作流
```
需求分析 → 编写测试 → 实现功能 → 测试通过 → 格式化 → Lint → 提交
```
## 快速检查命令
```bash
# 一键检查(推荐在提交前运行)
npm test && npm run lint && npm run format
```
## 分支管理
- `main` - 主分支,保持稳定
- `dev` - 开发分支
- `feature/*` - 功能分支
- `fix/*` - 修复分支
## Commit 消息规范
```
<type>: <subject>
<body>
```
### Type 类型
- `feat`: 新功能
- `fix`: 修复 bug
- `test`: 添加或修改测试
- `refactor`: 重构代码
- `style`: 样式调整
- `docs`: 文档更新
- `chore`: 构建或工具变动
### 示例
```
feat: 添加式神数据导入功能
- 实现 JSON 格式导入
- 添加数据验证逻辑
- 补充单元测试
```
## 代码审查要点
1. 是否有对应的测试用例
2. 测试是否全部通过
3. 代码是否符合项目风格
4. 是否有明显的性能问题
5. 是否有安全隐患
## 相关文档
- [测试指南](./testing.md) - 如何运行和编写测试
- [测试规范](./testing-rules.md) - 测试相关的强制要求
- [项目结构](../docs/1management/plan.md) - 项目整体架构

142
docs/testing-rules.md Normal file
View File

@@ -0,0 +1,142 @@
# 测试规范
## 核心原则
**所有涉及数据层和业务逻辑的改动,必须在提交前通过测试验证。**
## 必须编写测试的场景
### 1. 数据模型变更
- 修改 `schema.ts` 中的类型定义
- 添加新的数据结构或接口
- 修改默认值配置
**要求**: 在 `schema.test.ts` 中添加对应测试用例
### 2. Store 状态管理
- 修改 `useStore.ts` 中的任何方法
- 添加新的状态管理逻辑
- 修改文件操作(增删改查)
**要求**: 在 `useStore.test.ts` 中添加对应测试用例
### 3. 核心业务逻辑
- 数据导入导出功能
- 数据迁移和兼容性处理
- localStorage 持久化逻辑
- 数据验证和规范化
**要求**: 编写独立测试文件或在现有测试中补充
### 4. 工具函数和辅助方法
- 添加新的工具函数
- 修改现有的数据处理逻辑
**要求**: 创建对应的 `*.test.ts` 文件
## 开发流程
```
1. 需求分析
2. 编写/更新测试用例(描述预期行为)
3. 运行测试(此时应该失败)
4. 实现功能代码
5. 运行测试直到全部通过
6. 代码审查和提交
```
## 提交前检查清单
- [ ] 运行 `npm test` 确保所有测试通过
- [ ] 新增功能已添加对应测试用例
- [ ] 修改的功能已更新相关测试
- [ ] 测试覆盖了正常情况和边界情况
- [ ] 没有跳过或注释掉失败的测试
## 测试命令
```bash
# 提交前必须运行
npm test
# 开发时推荐使用监听模式
npm run test:watch
# 查看测试覆盖率
npm run test:coverage
```
## 测试编写规范
### 1. 测试文件命名
- 测试文件放在 `src/__tests__/` 目录
- 命名格式: `<源文件名>.test.ts`
- 例如: `schema.ts``schema.test.ts`
### 2. 测试用例结构
```typescript
describe('功能模块名称', () => {
beforeEach(() => {
// 每个测试前的准备工作
})
it('应该做某件事(正常情况)', () => {
// 准备数据
const input = { /* ... */ }
// 执行操作
const result = someFunction(input)
// 验证结果
expect(result).toBe(expectedValue)
})
it('应该处理边界情况', () => {
// 测试空值、极端值等
})
it('应该处理错误情况', () => {
// 测试异常处理
})
})
```
### 3. 测试描述规范
- 使用中文描述,清晰表达测试意图
- 格式: "应该 + 动词 + 预期结果"
- 例如: "应该正确导入包含式神数据的文件"
### 4. Mock 使用原则
- Mock 外部依赖Element Plus、LogicFlow 等)
- 不要 Mock 被测试的核心逻辑
- Mock 要尽可能简单和稳定
## 不需要测试的场景
以下情况可以不写单元测试:
- 纯 UI 组件的样式调整
- 简单的 getter/setter
- 第三方库的直接调用
- 临时的调试代码
但建议通过手动测试验证这些改动。
## 测试失败处理
如果测试失败:
1. **不要跳过或删除测试** - 测试失败说明代码有问题
2. 分析失败原因 - 是代码错误还是测试用例需要更新
3. 修复代码或更新测试
4. 确保所有测试通过后再提交
## 持续改进
- 定期审查测试覆盖率
- 补充缺失的测试用例
- 重构重复的测试代码
- 更新过时的测试用例

71
docs/testing.md Normal file
View File

@@ -0,0 +1,71 @@
# 测试指南
## 运行测试
项目已集成 Vitest 测试框架,支持以下测试命令:
```bash
# 运行所有测试(单次执行)
npm test
# 监听模式(文件变化时自动重新运行)
npm run test:watch
# 可视化界面运行测试
npm run test:ui
# 生成测试覆盖率报告
npm run test:coverage
```
## 测试文件结构
测试文件位于 `src/__tests__/` 目录:
- `schema.test.ts` - 数据结构和类型验证测试
- `useStore.test.ts` - Store 状态管理和数据操作测试
## 测试示例
### 1. Schema 数据结构测试
验证数据模型的正确性:
- 默认值检查
- 类型定义验证
- 式神/御魂数据结构
### 2. Store 状态管理测试
验证核心业务逻辑:
- 文件列表的增删改查
- 文件切换和重命名
- 数据导入导出
- localStorage 持久化
## 编写新测试
`src/__tests__/` 目录创建 `*.test.ts` 文件:
```typescript
import { describe, it, expect } from 'vitest'
describe('功能模块名称', () => {
it('应该做某件事', () => {
// 准备数据
const input = { /* ... */ }
// 执行操作
const result = someFunction(input)
// 验证结果
expect(result).toBe(expectedValue)
})
})
```
## 最佳实践
1. **数据验证优先** - 先测试数据层逻辑,确保核心功能正确
2. **独立测试** - 每个测试用例应该独立运行,不依赖其他测试
3. **清晰命名** - 测试描述应该清楚说明测试的内容和预期
4. **Mock 外部依赖** - 使用 `vi.mock()` 模拟外部模块,保持测试纯粹

2902
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,13 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest --run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
"format": "prettier --write src/",
"precommit": "npm test && npm run lint"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -29,10 +34,17 @@
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@vitejs/plugin-vue": "^4.5.1",
"@vitest/ui": "^4.0.18",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"happy-dom": "^20.6.1",
"husky": "^9.1.7",
"jsdom": "^28.0.0",
"lint-staged": "^16.2.7",
"prettier": "^3.0.3",
"vite": "^5.0.5"
"vite": "^5.0.5",
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest'
import {
CURRENT_SCHEMA_VERSION,
DefaultNodeStyle,
type GraphNode,
type GraphEdge,
type NodeProperties,
type RootDocument
} from '../ts/schema'
describe('Schema 数据结构验证', () => {
it('当前 schema 版本应该是 1.0.0', () => {
expect(CURRENT_SCHEMA_VERSION).toBe('1.0.0')
})
it('DefaultNodeStyle 应该包含正确的默认值', () => {
expect(DefaultNodeStyle).toMatchObject({
width: 180,
height: 120,
rotate: 0,
fill: '#ffffff',
stroke: '#dcdfe6'
})
})
it('创建 GraphNode 应该符合类型定义', () => {
const node: GraphNode = {
id: 'node-1',
type: 'rect',
x: 100,
y: 200,
properties: {
style: {
width: 200,
height: 150,
fill: '#ff0000'
}
}
}
expect(node.id).toBe('node-1')
expect(node.type).toBe('rect')
expect(node.properties.style.width).toBe(200)
})
it('创建 GraphEdge 应该包含必需字段', () => {
const edge: GraphEdge = {
id: 'edge-1',
sourceNodeId: 'node-1',
targetNodeId: 'node-2'
}
expect(edge.id).toBe('edge-1')
expect(edge.sourceNodeId).toBe('node-1')
expect(edge.targetNodeId).toBe('node-2')
})
it('NodeProperties 应该支持式神数据', () => {
const properties: NodeProperties = {
style: DefaultNodeStyle,
shikigami: {
name: '茨木童子',
avatar: '/assets/Shikigami/ibaraki.png',
rarity: 'SSR'
}
}
expect(properties.shikigami?.name).toBe('茨木童子')
expect(properties.shikigami?.rarity).toBe('SSR')
})
it('NodeProperties 应该支持御魂数据', () => {
const properties: NodeProperties = {
style: DefaultNodeStyle,
yuhun: {
name: '破势',
type: '攻击',
avatar: '/assets/Yuhun/poshi.png'
}
}
expect(properties.yuhun?.name).toBe('破势')
expect(properties.yuhun?.type).toBe('攻击')
})
it('RootDocument 应该包含文件列表和活动文件', () => {
const doc: RootDocument = {
schemaVersion: '1.0.0',
fileList: [],
activeFile: 'File 1',
activeFileId: 'f_123'
}
expect(doc.schemaVersion).toBe('1.0.0')
expect(doc.activeFile).toBe('File 1')
expect(doc.activeFileId).toBe('f_123')
})
})

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFilesStore } from '../ts/useStore'
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} }
}
})()
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
// Mock ElMessageBox 和 useGlobalMessage
vi.mock('element-plus', () => ({
ElMessageBox: {
confirm: vi.fn()
}
}))
vi.mock('../ts/useGlobalMessage', () => ({
useGlobalMessage: () => ({
showMessage: vi.fn()
})
}))
vi.mock('../ts/useLogicFlow', () => ({
getLogicFlowInstance: vi.fn(() => ({
getGraphRawData: vi.fn(() => ({ nodes: [], edges: [] })),
getTransform: vi.fn(() => ({
SCALE_X: 1,
SCALE_Y: 1,
TRANSLATE_X: 0,
TRANSLATE_Y: 0
}))
}))
}))
describe('useFilesStore 数据操作测试', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorageMock.clear()
})
it('应该初始化默认文件列表', () => {
const store = useFilesStore()
store.initializeWithPrompt()
expect(store.fileList.length).toBeGreaterThan(0)
expect(store.fileList[0].name).toBe('File 1')
expect(store.fileList[0].type).toBe('FLOW')
})
it('添加新文件应该增加文件列表长度', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
const initialLength = store.fileList.length
store.addTab()
// 等待 requestAnimationFrame 完成
await new Promise(resolve => setTimeout(resolve, 50))
expect(store.fileList.length).toBe(initialLength + 1)
expect(store.fileList[store.fileList.length - 1].name).toContain('File')
})
it('删除文件应该减少文件列表长度', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 50))
const initialLength = store.fileList.length
const fileToDelete = store.fileList[0]
store.removeTab(fileToDelete.id)
expect(store.fileList.length).toBe(initialLength - 1)
})
it('切换活动文件应该更新 activeFileId', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 50))
const secondFile = store.fileList[1]
store.activeFileId = secondFile.id
expect(store.activeFileId).toBe(secondFile.id)
})
it('visibleFiles 应该只返回可见文件', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 50))
// 隐藏第一个文件
store.fileList[0].visible = false
expect(store.visibleFiles.length).toBe(store.fileList.length - 1)
expect(store.visibleFiles.every(f => f.visible)).toBe(true)
})
it('导入数据应该正确恢复文件列表', () => {
const store = useFilesStore()
const mockData = {
schemaVersion: '1.0.0',
fileList: [
{
id: 'test-1',
name: 'Test File',
label: 'Test File',
visible: true,
type: 'FLOW',
graphRawData: { nodes: [], edges: [] }
}
],
activeFileId: 'test-1',
activeFile: 'Test File'
}
store.importData(mockData)
expect(store.fileList.length).toBe(1)
expect(store.fileList[0].name).toBe('Test File')
expect(store.activeFileId).toBe('test-1')
})
it('重置工作区应该恢复到默认状态', async () => {
const store = useFilesStore()
store.initializeWithPrompt()
store.addTab()
store.addTab()
// 等待添加完成
await new Promise(resolve => setTimeout(resolve, 100))
store.resetWorkspace()
expect(store.fileList.length).toBe(1)
expect(store.fileList[0].name).toBe('File 1')
})
})

View File

@@ -3,6 +3,7 @@
<div>
<el-button icon="Upload" type="primary" @click="handleImport">{{ t('import') }}</el-button>
<el-button icon="Download" type="primary" @click="handleExport">{{ t('export') }}</el-button>
<el-button icon="View" type="success" @click="handlePreviewData">数据预览</el-button>
<el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button>
<el-button icon="Setting" type="primary" @click="state.showWatermarkDialog = true">{{ t('setWatermark') }}</el-button>
<el-button type="info" @click="loadExample">{{ t('loadExample') }}</el-button>
@@ -97,6 +98,19 @@
</template>
</el-dialog>
<!-- 数据预览对话框 -->
<el-dialog v-model="state.showDataPreviewDialog" title="数据预览" width="70%">
<div style="max-height: 600px; overflow-y: auto;">
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; font-size: 12px; line-height: 1.5;">{{ state.previewDataContent }}</pre>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="state.showDataPreviewDialog = false">关闭</el-button>
<el-button type="primary" @click="copyDataToClipboard">复制到剪贴板</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
@@ -124,6 +138,8 @@ const state = reactive({
showWatermarkDialog: false, // 控制水印设置弹窗的显示状态,
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
previewDataContent: '', // 存储预览的数据内容
});
// 重新渲染 LogicFlow 画布的通用方法
@@ -216,6 +232,39 @@ const handleExport = () => {
}, 2000);
};
const handlePreviewData = () => {
// 预览前先更新当前数据
filesStore.updateTab();
// 延迟一点确保更新完成后再预览
setTimeout(() => {
try {
const activeName = filesStore.fileList.find(f => f.id === filesStore.activeFileId)?.name || '';
const dataObj = {
schemaVersion: 1,
fileList: filesStore.fileList,
activeFileId: filesStore.activeFileId,
activeFile: activeName,
};
state.previewDataContent = JSON.stringify(dataObj, null, 2);
state.showDataPreviewDialog = true;
} catch (error) {
console.error('生成预览数据失败:', error);
showMessage('error', '数据预览失败');
}
}, 100);
};
const copyDataToClipboard = async () => {
try {
await navigator.clipboard.writeText(state.previewDataContent);
showMessage('success', '已复制到剪贴板');
} catch (error) {
console.error('复制失败:', error);
showMessage('error', '复制失败');
}
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';

View File

@@ -197,6 +197,24 @@ function normalizeAllNodes() {
const lfInstance = lf.value;
if (!lfInstance) return;
lfInstance.graphModel?.nodes.forEach((model: BaseNodeModel) => normalizeNodeModel(model));
// 检查是否所有节点的 zIndex 都相同且为默认值(通常是从历史恢复的情况)
const allNodes = lfInstance.graphModel?.nodes || [];
if (allNodes.length > 1) {
const firstZIndex = allNodes[0]?.zIndex;
const allSameZIndex = allNodes.every(n => n.zIndex === firstZIndex);
// 只有当所有节点的 zIndex 都是默认值 1 时才重新分配
if (allSameZIndex && firstZIndex === 1) {
console.log('[初始化] 检测到所有节点 zIndex 都为默认值 1开始重新分配 zIndex');
// 为所有节点分配递增的 zIndex避免层级操作异常
allNodes.forEach((node, index) => {
const newZIndex = index + 1;
node.setZIndex(newZIndex);
});
console.log('[初始化] zIndex 重新分配完成:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
}
}
function updateNodeMeta(model: BaseNodeModel, updater: (meta: Record<string, any>) => Record<string, any>) {
@@ -262,7 +280,16 @@ function bringToFront(nodeId?: string) {
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
// 诊断日志:查看所有节点的 zIndex
const allNodes = lfInstance.graphModel.nodes;
console.log('[置于顶层] 目标节点ID:', targetId);
console.log('[置于顶层] 所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
lfInstance.setElementZIndex(targetId, 'top');
// 操作后再次查看
console.log('[置于顶层] 操作后所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
function sendToBack(nodeId?: string) {
@@ -270,7 +297,16 @@ function sendToBack(nodeId?: string) {
if (!lfInstance) return;
const targetId = nodeId || selectedNode.value?.id;
if (!targetId) return;
// 诊断日志:查看所有节点的 zIndex
const allNodes = lfInstance.graphModel.nodes;
console.log('[置于底层] 目标节点ID:', targetId);
console.log('[置于底层] 所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
lfInstance.setElementZIndex(targetId, 'bottom');
// 操作后再次查看
console.log('[置于底层] 操作后所有节点的 zIndex:', allNodes.map(n => ({ id: n.id, zIndex: n.zIndex })));
}
function bringForward(nodeId?: string) {
@@ -663,7 +699,7 @@ onMounted(() => {
grid: { type: 'dot', size: 10 },
allowResize: true,
allowRotate: true,
overlapMode: -1,
overlapMode: 0,
snapline: snaplineEnabled.value,
keyboard: {
enabled: true
@@ -902,8 +938,20 @@ onMounted(() => {
lfInstance.on(EventType.NODE_DRAG, (args) => handleNodeDrag(args as any));
lfInstance.on(EventType.NODE_ADD, ({ data }) => {
console.log('[NODE_ADD 事件触发] 节点ID:', data.id);
const model = lfInstance.getNodeModelById(data.id);
if (model) normalizeNodeModel(model);
if (model) {
console.log('[NODE_ADD] 获取到节点模型,当前 zIndex:', model.zIndex);
normalizeNodeModel(model);
// 设置新节点的 zIndex 为 1000
const newZIndex = 1000;
console.log(`[NODE_ADD] 准备设置 zIndex: ${newZIndex}`);
model.setZIndex(newZIndex);
console.log(`[NODE_ADD] 设置后的 zIndex:`, model.zIndex);
} else {
console.log('[NODE_ADD] 未能获取到节点模型');
}
});
lfInstance.on(EventType.GRAPH_RENDERED, () => normalizeAllNodes());

View File

@@ -321,12 +321,24 @@ export const useFilesStore = defineStore('files', () => {
const transform = logicFlowInstance.getTransform();
if (graphData) {
// 手动添加 zIndex 信息到每个节点
const enrichedGraphData = {
...graphData,
nodes: (graphData.nodes || []).map((node: any) => {
const model = logicFlowInstance.getNodeModelById(node.id);
return {
...node,
zIndex: model?.zIndex ?? node.zIndex ?? 1
};
})
};
// 直接保存原始数据到 GraphRawData
const file = findById(targetId);
if (file) {
file.graphRawData = graphData;
file.graphRawData = enrichedGraphData;
file.transform = transform;
console.log(`已同步画布数据到文件 "${file.name}"(${targetId})`);
console.log(`已同步画布数据到文件 "${file.name}"(${targetId}),包含 zIndex 信息`);
}
}
} catch (error) {

303
view.html Normal file
View File

@@ -0,0 +1,303 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ECharts 旭日图 - 所有层级显示百分比 + 小扇区标签引出</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.0/dist/echarts.min.js"></script>
</head>
<body>
<div id="main" style="width: 1000px; height: 800px;"></div>
<script>
var rawData = [
{
"name": "现金",
"value": 7.023
},
{
"name": "债券",
"value": 25.809,
"children": [
{
"name": "中债",
"value": 4.6346,
"children": [
{
"name": "利率债",
"value": 2.4011
},
{
"name": "信用债",
"value": 2.2335
},
{
"name": "同业存单",
"value": 0
},
{
"name": "长债",
"value": 0
}
]
},
{
"name": "美债",
"value": 18.9602
},
{
"name": "其他债券",
"value": 2.2142
}
]
},
{
"name": "股票",
"value": 54.7189,
"children": [
{
"name": "A股",
"value": 30.9155,
"children": [
{
"name": "300指增",
"value": 2.9555
},
{
"name": "1000指增",
"value": 2.3238
},
{
"name": "HSTECH",
"value": 2.8813
},
{
"name": "红利",
"value": 4.6535
},
{
"name": "纺织服饰",
"value": 4.3308
},
{
"name": "传媒",
"value": 1.889
},
{
"name": "公用事业",
"value": 1.6032
},
{
"name": "通信",
"value": 2.8502
},
{
"name": "钢铁",
"value": 1.0868
},
{
"name": "银行",
"value": 1.1927
},
{
"name": "机械设备",
"value": 1.7767
},
{
"name": "有色金属",
"value": 0.9596
},
{
"name": "家用电器",
"value": 2.4124
}
]
},
{
"name": "美股",
"value": 8.0349,
"children": [
{
"name": "SPX",
"value": 5.073
},
{
"name": "NDX",
"value": 2.9619
}
]
},
{
"name": "欧洲股票",
"value": 5.4417,
"children": [
{
"name": "CAC40",
"value": 1.7798
},
{
"name": "DAX",
"value": 1.7545
},
{
"name": "富时100",
"value": 1.9074
}
]
},
{
"name": "日本股票",
"value": 3.6608,
"children": [
{
"name": "日经225",
"value": 3.6608
}
]
},
{
"name": "亚太股票",
"value": 4.5463,
"children": [
{
"name": "APAC",
"value": 4.5463
}
]
},
{
"name": "印度股票",
"value": 2.1197,
"children": [
{
"name": "印度",
"value": 2.1197
}
]
}
]
},
{
"name": "商品",
"value": 10.072,
"children": [
{
"name": "黄金",
"value": 10.072,
"children": [
{
"name": "纸黄金",
"value": 6.8458
},
{
"name": "黄金基金",
"value": 3.2262
}
]
}
]
},
{
"name": "REITS",
"value": 1.2986,
"children": [
{
"name": "REITs",
"value": 1.2986,
"children": [
{
"name": "REITS",
"value": 1.2986
}
]
}
]
},
{
"name": "数字货币",
"value": 1.0782
}
];
// Step 1: 递归补全所有节点的 value非叶子节点 = 子节点 value 之和)
function fillValue(node) {
if (node.children) {
let sum = 0;
for (let child of node.children) {
fillValue(child);
sum += child.value || 0;
}
node.value = sum;
}
return node;
}
const dataWithFilledValue = JSON.parse(JSON.stringify(rawData)).map(fillValue);
// Step 2: 计算总值(根节点总和)
const totalValue = dataWithFilledValue.reduce((sum, node) => sum + (node.value || 0), 0);
// Step 3: 为每个节点添加 percentage 字段
function addPercentage(node) {
node.percentage = ((node.value / totalValue) * 100).toFixed(2) + '%';
if (node.children) {
node.children.forEach(addPercentage);
}
}
dataWithFilledValue.forEach(addPercentage);
// Step 4: 配置 ECharts
const option = {
series: {
type: 'sunburst',
data: dataWithFilledValue,
radius: [60, '90%'],
itemStyle: {
borderRadius: 7,
borderWidth: 2
},
label: {
show: true,
formatter: (params) => {
return `${params.name}\n${params.data.percentage}`;
},
fontSize: 11,
color: '#000',
// 根据占比决定标签位置:小扇区 outside大扇区 inside
position: (params) => {
const ratio = params.data.value / totalValue;
return ratio < 0.03 ? 'outside' : 'inside'; // <1.5% 引出
},
align: 'center',
verticalAlign: 'middle'
},
labelLine: {
show: true,
length: 10,
length2: 10,
smooth: true
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
}
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}<br/>值: ${params.value}<br/>占比: ${params.data.percentage}`;
}
}
};
const myChart = echarts.init(document.getElementById('main'));
myChart.setOption(option);
window.addEventListener('resize', () => myChart.resize());
</script>
</body>
</html>

15
vitest.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/*'],
root: fileURLToPath(new URL('./', import.meta.url)),
globals: true,
}
})
)