update at 2026-02-12 17:30:41

This commit is contained in:
douboer@gmail.com
2026-02-12 17:30:41 +08:00
parent 8ce67dae5e
commit b6804cc2f1
32 changed files with 5765 additions and 0 deletions

23
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,23 @@
module.exports = {
root: true,
env: {
browser: true,
es2022: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier'
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module'
},
rules: {
'vue/multi-word-component-names': 'off'
}
};

2
.gitignore vendored
View File

@@ -9,6 +9,8 @@ lerna-debug.log*
node_modules
.npm-cache
dist
coverage
*.local
# Editor directories and files

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# 星程桑基图
基于 `Vue3 + TypeScript + ECharts` 的桑基图制作工具,支持 `CSV / XLS / XLSX` 上传、列映射配置、目标描述列合并语义聚合、实时预览与 `PNG / SVG` 导出。
## 已实现(本轮)
- Web 端Figma `3044:158`
- 顶部工具条:主题选择、文件上传、导出按钮
- 列映射:源数据列单选、源描述列多选、目标描述列多选
- 解析规则:支持“目标描述列空值沿用上行非空值”
- 图表预览:方向切换、节点间距、预览边距配置
- 导出PNG / SVG
- 小程序端骨架Figma `584:64`
- 页面结构、图标布局与主题底部弹层
## 本地运行
```bash
npm install
npm run dev
```
## 质量检查
```bash
npm run test
npm run type-check
npm run lint
```
## 测试数据
按你的要求,测试 Excel 文件放在 `data/`
- `data/example.xlsx`
- `data/example0.xlsx`
- `data/example00.xlsx`
- `data/example1.xlsx`
## 图标资源
所有 SVG 图标统一放在 `assets/icons/`Web 与小程序都直接复用该目录。

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>星程桑基图</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10
miniapp/README.md Normal file
View File

@@ -0,0 +1,10 @@
# 小程序端骨架
当前目录提供了与 Figma `node-id=584:64` 对齐的页面骨架:
- 顶部 Logo/主题/上传/导出区域
- 效果预览区域
- 源数据与目标数据列选择区域
- 主题底部选择器(底部弹层)
后续接入时建议直接复用 `src/core` 的解析与聚合逻辑,保持 Web 与小程序一致的数据行为。

1
miniapp/app.js Normal file
View File

@@ -0,0 +1 @@
App({});

10
miniapp/app.json Normal file
View File

@@ -0,0 +1,10 @@
{
"pages": ["pages/index/index"],
"window": {
"navigationBarTitleText": "星程桑基图",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}

5
miniapp/app.wxss Normal file
View File

@@ -0,0 +1,5 @@
page {
background: #f3f4f6;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: #1d2129;
}

View File

@@ -0,0 +1,25 @@
Page({
data: {
selectedThemeIndex: 1,
sourceColumns: ['列1', '列2'],
targetColumns: ['列1', '列2'],
sourceDataIndex: 1,
sourceDescChecked: [1],
targetDescChecked: [1],
showThemeSheet: false
},
/**
* 主题选择按钮点击后,切换底部选择器。
*/
onToggleThemeSheet() {
this.setData({ showThemeSheet: !this.data.showThemeSheet });
},
/**
* 关闭主题底部选择器。
*/
onCloseThemeSheet() {
this.setData({ showThemeSheet: false });
}
});

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "星程桑基图",
"usingComponents": {}
}

View File

@@ -0,0 +1,100 @@
<view class="page">
<view class="header">
<image class="logo" src="/assets/icons/webicon.png" mode="aspectFill" />
<image class="title" src="/assets/icons/星程字体转换.svg" mode="widthFix" />
</view>
<view class="toolbar">
<view class="tool-item" bindtap="onToggleThemeSheet">
<text>选择主题</text>
<image class="tool-icon" src="/assets/icons/choose-color.svg" mode="aspectFit" />
</view>
<view class="tool-item">
<image class="tiny-icon" src="/assets/icons/content.svg" mode="aspectFit" />
<text>文件上传</text>
<image class="tool-icon" src="/assets/icons/upload.svg" mode="aspectFit" />
</view>
<view class="export-box">
<image class="export-main" src="/assets/icons/export.svg" mode="aspectFit" />
<image class="export-icon" src="/assets/icons/export-svg.svg" mode="aspectFit" />
<image class="export-icon" src="/assets/icons/export-png.svg" mode="aspectFit" />
</view>
</view>
<view class="preview-block">
<text class="block-title">效果预览</text>
<view class="preview-canvas" />
</view>
<view class="bottom-grid">
<view class="block">
<text class="block-title">源数据</text>
<view class="field">
<view class="field-title">
<image src="/assets/icons/expand.svg" mode="aspectFit" />
<text>数据列</text>
</view>
<view class="row" wx:for="{{sourceColumns}}" wx:key="*this">
<image src="/assets/icons/data.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{sourceDataIndex === index ? '/assets/icons/radiobutton.svg' : '/assets/icons/radiobutton-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
<view class="field">
<view class="field-title">
<image src="/assets/icons/expand.svg" mode="aspectFit" />
<text>描述列</text>
</view>
<view class="row" wx:for="{{sourceColumns}}" wx:key="*this">
<image src="/assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{sourceDescChecked.indexOf(index) > -1 ? '/assets/icons/checkbox.svg' : '/assets/icons/checkbox-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="block">
<text class="block-title">目标数据</text>
<view class="field">
<view class="field-title">
<image src="/assets/icons/expand.svg" mode="aspectFit" />
<text>描述列</text>
</view>
<view class="row" wx:for="{{targetColumns}}" wx:key="*this">
<image src="/assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{targetDescChecked.indexOf(index) > -1 ? '/assets/icons/checkbox.svg' : '/assets/icons/checkbox-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
</view>
</view>
<view class="footer">@版权说明星程社所有反馈邮箱douboer@gmail.com</view>
<view class="theme-sheet-mask" wx:if="{{showThemeSheet}}" bindtap="onCloseThemeSheet" />
<view class="theme-sheet" wx:if="{{showThemeSheet}}">
<text class="theme-title">选择配色主题</text>
<view class="theme-row" wx:for="{{4}}" wx:key="index">
<image
src="{{selectedThemeIndex === index ? '/assets/icons/radiobutton.svg' : '/assets/icons/radiobutton-no.svg'}}"
mode="aspectFit"
/>
<view class="theme-bar" />
</view>
</view>
</view>

View File

@@ -0,0 +1,178 @@
.page {
padding: 8px;
}
.header {
height: 48px;
display: flex;
align-items: center;
gap: 8px;
}
.logo {
width: 48px;
height: 48px;
border-radius: 12px;
}
.title {
width: 160px;
}
.toolbar {
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.tool-item {
display: flex;
align-items: center;
gap: 3px;
font-size: 12px;
}
.tool-icon {
width: 24px;
height: 24px;
}
.tiny-icon {
width: 10px;
height: 10px;
}
.export-box {
margin-left: auto;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 2px 4px;
display: flex;
align-items: center;
gap: 4px;
}
.export-main {
width: 10px;
height: 20px;
}
.export-icon {
width: 24px;
height: 24px;
}
.preview-block,
.block {
margin-top: 8px;
border: 1px solid #fbaca3;
border-radius: 8px;
background: #fff;
padding: 4px;
}
.block-title {
font-size: 16px;
font-weight: 600;
}
.preview-canvas {
margin-top: 4px;
min-height: 300px;
background: #f7f8fa;
border-radius: 4px;
}
.bottom-grid {
margin-top: 8px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field {
margin-top: 8px;
}
.field-title {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
}
.field-title image {
width: 14px;
height: 14px;
}
.row {
height: 24px;
border-bottom: 1px solid #c9cdd4;
display: flex;
align-items: center;
gap: 6px;
padding-bottom: 6px;
margin-top: 4px;
}
.row image {
width: 18px;
height: 18px;
}
.label {
flex: 1;
color: #86909c;
font-size: 12px;
}
.footer {
margin-top: 8px;
color: #86909c;
font-size: 14px;
}
.theme-sheet-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.2);
}
.theme-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 16px 16px 0 0;
padding: 12px;
}
.theme-title {
color: #9b6bc2;
font-size: 14px;
text-align: center;
}
.theme-row {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.theme-row image {
width: 18px;
height: 18px;
}
.theme-bar {
flex: 1;
height: 20px;
border-radius: 2px;
background: linear-gradient(90deg, #f72585, #3f37c9, #4cc9f0);
}

4
miniapp/sitemap.json Normal file
View File

@@ -0,0 +1,4 @@
{
"desc": "星程桑基图小程序",
"rules": []
}

3821
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "xingcheng-sankey",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .ts,.vue --max-warnings=0",
"test": "vitest run",
"format": "prettier --check ."
},
"dependencies": {
"echarts": "^5.6.0",
"papaparse": "^5.5.3",
"vue": "^3.5.18",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^24.3.1",
"@types/papaparse": "^5.3.16",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^9.33.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vitest": "^3.2.4",
"vue-eslint-parser": "^9.4.3",
"vue-tsc": "^3.0.5"
}
}

BIN
public/data/example.xlsx Normal file

Binary file not shown.

BIN
public/data/example0.xlsx Normal file

Binary file not shown.

BIN
public/data/example00.xlsx Normal file

Binary file not shown.

BIN
public/data/example1.xlsx Normal file

Binary file not shown.

View File

@@ -0,0 +1,32 @@
分析云切图节点,当前负载,算力池信息,算法地址,模型,算法版本,最大容量(路数),当前算法负载
宁波北欧1,3197,宁波通算+嘉善智算,http://14.174.128.46:8899/api/v1/detect,小模型+大模型,0109,10000,9364
宁波北欧2,2729,,,,,,
宁波鄞中24,3438,,,,,,
宁波北欧5,1677,宁波通算+嘉善智算,http://14.174.128.49:8899/api/v1/detect,小模型+大模型,0109,10000,6876
宁波北欧8,2594,,,,,,
宁波鄞中25,2605,,,,,,
宁波北欧3,281,金华林田通算+嘉善智算,http://14.182.3.216:8899/api/v1/detect,小模型+大模型,0109,18000,15561
宁波北欧6,3154,,,,,,
宁波北欧7,2761,,,,,,
宁波北欧9,3321,,,,,,
宁波北欧11,2647,,,,,,
宁波鄞中14,3397,,,,,,
宁波北欧4,2014,金华林田通算+嘉善智算,http://14.182.3.218:8899/api/v1/detect,小模型+大模型,0109,18000,6399
宁波北欧13,0,,,,,,
宁波鄞中21,3571,,,,,,
宁波鄞中22,814,,,,,,
台州1,0,嘉善算力池0109版本(172.64.142.10:8888),http://172.64.142.10:8888/ai/aiFactoryServer/v1/apis/1/servicecode-qr22:1.1,,,,
宁波鄞中18,3363,湖州通算+嘉善智算,http://14.172.0.11:8897/api/v1/detect,小模型,0908,18000,6530
宁波鄞中19,3167,,,,,,
宁波鄞中20,2391,湖州通算+嘉善智算,http://14.172.0.12:8888/ai/aiFactoryServer/v1/apis/1/servicecode-bz0t:1.1,小模型,0908,8000,2391
宁波北欧10,2582,嘉兴四级算力池,http://14.173.1.216:8898/api/v1/detect,小模型,0908,12000,11623
宁波北欧12,2610,,,,,,
宁波鄞中15,507,,,,,,
宁波鄞中26,2914,,,,,,
宁波鄞中27,3010,,,,,,
嘉兴1,0,嘉兴四级算力池,http://172.61.142.10:8898/api/v1/detect,小模型,0908,2000,1418
丽水1,1418,,,,,,
宁波鄞中16,1534,台州四级算力池,http://172.67.14.15:8888/ai/aiFactoryServer/v1/apis/1/servicecode-tqs9:1.1,小模型,0908,8000,2292
宁波鄞中17,758,,,,,,
嘉兴2,0,,,,,,
丽水2,0,,,,,,
1 分析云切图节点 当前负载 算力池信息 算法地址 模型 算法版本 最大容量(路数) 当前算法负载
2 宁波北欧1 3197 宁波通算+嘉善智算 http://14.174.128.46:8899/api/v1/detect 小模型+大模型 0109 10000 9364
3 宁波北欧2 2729
4 宁波鄞中24 3438
5 宁波北欧5 1677 宁波通算+嘉善智算 http://14.174.128.49:8899/api/v1/detect 小模型+大模型 0109 10000 6876
6 宁波北欧8 2594
7 宁波鄞中25 2605
8 宁波北欧3 281 金华林田通算+嘉善智算 http://14.182.3.216:8899/api/v1/detect 小模型+大模型 0109 18000 15561
9 宁波北欧6 3154
10 宁波北欧7 2761
11 宁波北欧9 3321
12 宁波北欧11 2647
13 宁波鄞中14 3397
14 宁波北欧4 2014 金华林田通算+嘉善智算 http://14.182.3.218:8899/api/v1/detect 小模型+大模型 0109 18000 6399
15 宁波北欧13 0
16 宁波鄞中21 3571
17 宁波鄞中22 814
18 台州1 0 嘉善算力池0109版本(172.64.142.10:8888) http://172.64.142.10:8888/ai/aiFactoryServer/v1/apis/1/servicecode-qr22:1.1
19 宁波鄞中18 3363 湖州通算+嘉善智算 http://14.172.0.11:8897/api/v1/detect 小模型 0908 18000 6530
20 宁波鄞中19 3167
21 宁波鄞中20 2391 湖州通算+嘉善智算 http://14.172.0.12:8888/ai/aiFactoryServer/v1/apis/1/servicecode-bz0t:1.1 小模型 0908 8000 2391
22 宁波北欧10 2582 嘉兴四级算力池 http://14.173.1.216:8898/api/v1/detect 小模型 0908 12000 11623
23 宁波北欧12 2610
24 宁波鄞中15 507
25 宁波鄞中26 2914
26 宁波鄞中27 3010
27 嘉兴1 0 嘉兴四级算力池 http://172.61.142.10:8898/api/v1/detect 小模型 0908 2000 1418
28 丽水1 1418
29 宁波鄞中16 1534 台州四级算力池 http://172.67.14.15:8888/ai/aiFactoryServer/v1/apis/1/servicecode-tqs9:1.1 小模型 0908 8000 2292
30 宁波鄞中17 758
31 嘉兴2 0
32 丽水2 0

588
src/App.vue Normal file
View File

@@ -0,0 +1,588 @@
<template>
<div class="page">
<header class="top-bar">
<div class="brand">
<img :src="iconWebLogo" alt="webicon" class="logo" />
<img :src="iconTitle" alt="星程桑基图" class="title-logo" />
</div>
<div class="toolbar">
<div class="tool-item">
<span class="tool-label">选择主题</span>
<button class="icon-btn" type="button" @click="toggleThemePicker">
<img :src="iconChooseColor" alt="choose-color" />
</button>
</div>
<div class="tool-item">
<span class="tool-label">文件上传</span>
<button class="icon-btn tiny" type="button" @click="toggleUploadTip">
<img :src="iconContent" alt="内容说明" />
</button>
<button class="icon-btn" type="button" @click="openFileDialog">
<img :src="iconUpload" alt="upload" />
</button>
</div>
<label
class="upload-area"
@dragover.prevent
@drop.prevent="onDropFile"
@keydown.enter.prevent="openFileDialog"
@click="closeThemePicker"
>
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept=".csv,.xls,.xlsx"
@change="onFileChange"
/>
<span class="upload-text">{{ uploadMessage }}</span>
<span v-if="showUploadTip" class="upload-tip">
支持点击上传或拖拽 CSV/XLS/XLSX模板
<a href="/data/example.xlsx" download>xlsx</a>
/
<a href="/data/拉流切图节点与算法池信息.csv" download>csv</a>
</span>
</label>
<div class="export-box">
<img :src="iconExport" alt="export" class="export-main" />
<button class="icon-btn export-item" type="button" @click="exportSvg">
<img :src="iconExportSvg" alt="export-svg" />
</button>
<button class="icon-btn export-item" type="button" @click="exportPng">
<img :src="iconExportPng" alt="export-png" />
</button>
</div>
</div>
<div v-if="showThemePicker" class="theme-popover">
<div class="theme-header">选择配色主题</div>
<div class="theme-list">
<button
v-for="theme in themes"
:key="theme.id"
class="theme-row"
type="button"
@click="pickTheme(theme.id)"
>
<img :src="selectedThemeId === theme.id ? iconRadioOn : iconRadioOff" alt="主题选择" />
<div class="palette">
<span
v-for="color in theme.colors"
:key="`${theme.id}-${color}`"
class="palette-cell"
:style="{ backgroundColor: color }"
/>
</div>
</button>
</div>
</div>
</header>
<main class="content">
<section class="left-pane">
<article class="panel block-panel">
<h2>源数据</h2>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>数据列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`source-data-${index}`"
class="column-row"
>
<img :src="iconData" alt="数据列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="mapping.sourceDataColumn = index">
<img
:src="mapping.sourceDataColumn === index ? iconRadioOn : iconRadioOff"
alt="单选"
/>
</button>
</div>
</div>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>描述列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`source-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleSourceDescription(index)">
<img
:src="
mapping.sourceDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff
"
alt="复选"
/>
</button>
</div>
</div>
</article>
<article class="panel block-panel">
<h2>目标数据</h2>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>描述列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`target-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleTargetDescription(index)">
<img
:src="
mapping.targetDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff
"
alt="复选"
/>
</button>
</div>
</div>
</article>
</section>
<section class="panel preview-panel">
<div class="preview-head">
<h2>桑基图预览</h2>
<div class="preview-controls">
<label>
方向
<select v-model="direction">
<option value="source-to-target">source -&gt; target</option>
<option value="target-to-source">target -&gt; source</option>
</select>
</label>
<label>
间距
<input v-model.number="nodeGap" type="range" min="8" max="42" />
</label>
<label>
边距
<input v-model.number="chartPadding" type="range" min="10" max="70" />
</label>
</div>
</div>
<div v-if="buildError" class="error-text">{{ buildError }}</div>
<div v-if="parseError" class="error-text">{{ parseError }}</div>
<div class="example-line">示例{{ previewExample }}</div>
<div ref="chartRef" class="chart-area" />
<div v-if="buildWarnings.length > 0" class="warning-area">
<strong>解析告警 8 </strong>
<ul>
<li v-for="warning in buildWarnings" :key="warning">{{ warning }}</li>
</ul>
</div>
</section>
</main>
<footer class="footer">@版权说明星程社所有反馈邮箱douboer@gmail.com</footer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue';
import * as echarts from 'echarts/core';
import type { EChartsOption } from 'echarts';
import { SankeyChart } from 'echarts/charts';
import { TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import {
applyDirection,
buildSankeyData,
parseDataFile,
type MappingConfig,
type RawTable,
type SankeyBuildResult
} from './core';
import iconWebLogo from '../assets/icons/webicon.png';
import iconTitle from '../assets/icons/星程字体转换.svg';
import iconChooseColor from '../assets/icons/choose-color.svg';
import iconUpload from '../assets/icons/upload.svg';
import iconContent from '../assets/icons/content.svg';
import iconExport from '../assets/icons/export.svg';
import iconExportSvg from '../assets/icons/export-svg.svg';
import iconExportPng from '../assets/icons/export-png.svg';
import iconData from '../assets/icons/data.svg';
import iconDescription from '../assets/icons/description.svg';
import iconRadioOn from '../assets/icons/radiobutton.svg';
import iconRadioOff from '../assets/icons/radiobutton-no.svg';
import iconCheckboxOn from '../assets/icons/checkbox.svg';
import iconCheckboxOff from '../assets/icons/checkbox-no.svg';
import iconExpand from '../assets/icons/expand.svg';
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer]);
/**
* 主题色板列表。
* 颜色来自 Figma 示例,保证与设计稿观感一致。
*/
const themes = [
{
id: 'morandi',
colors: ['#F4F1DE', '#EAB69F', '#E07A5F', '#8F5D5D', '#3D405B', '#5F797B', '#81B29A', '#9EB998']
},
{
id: 'purple',
colors: ['#F72585', '#B5179E', '#7209B7', '#560BAD', '#480CA8', '#3A0CA3', '#3F37C9', '#4895EF', '#4CC9F0']
},
{
id: 'fog',
colors: ['#E8EDDF', '#CFDBD5', '#B7B7A4', '#A5A58D', '#6B705C', '#4F5D75', '#5D576B', '#6D597A']
},
{
id: 'sunset',
colors: ['#355070', '#515575', '#6D597A', '#915F78', '#B56576', '#CD6873', '#E56B6F', '#E88C7D', '#EAAC8B']
}
] as const;
const selectedThemeId = ref<(typeof themes)[number]['id']>('purple');
const showThemePicker = ref(false);
const showUploadTip = ref(false);
const uploadMessage = ref('点击上传或将csv/xls文件拖到这里上传');
const parseError = ref('');
const buildError = ref('');
const chartRef = ref<HTMLDivElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
let chartInstance: echarts.EChartsType | null = null;
const rawTable = ref<RawTable | null>(null);
const buildResult = ref<SankeyBuildResult | null>(null);
const mapping = reactive<MappingConfig>({
sourceDataColumn: 1,
sourceDescriptionColumns: [0, 1],
targetDescriptionColumns: [2],
delimiter: '-'
});
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(24);
const chartPadding = ref(30);
const selectedTheme = computed(() => themes.find((item) => item.id === selectedThemeId.value) ?? themes[0]);
const columnHeaders = computed(() => {
const headers = rawTable.value?.headers ?? [];
if (headers.length > 0) {
return headers.map((item, index) => item || `${index + 1}`);
}
return ['列1', '列2', '列3', '列4'];
});
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
const previewExample = computed(() => {
const table = rawTable.value;
if (!table || table.rows.length === 0) {
return '宁波北欧10-2582 -> 嘉兴四级算力池-11623-小模型';
}
const firstRow = table.rows[0];
const sourceParts =
mapping.sourceDescriptionColumns.length > 0
? mapping.sourceDescriptionColumns.map((column) => firstRow[column] ?? '').filter(Boolean)
: [firstRow[mapping.sourceDataColumn ?? 0] ?? ''];
const targetParts = mapping.targetDescriptionColumns
.map((column) => firstRow[column] ?? '')
.filter(Boolean);
return `${sourceParts.join(mapping.delimiter)} -> ${targetParts.join(mapping.delimiter)}`;
});
const chartNodes = computed(() => {
const links = buildResult.value ? applyDirection(buildResult.value.links, direction.value) : [];
const names = new Set<string>();
links.forEach((link) => {
names.add(link.source);
names.add(link.target);
});
const palette = selectedTheme.value.colors;
return Array.from(names).map((name, index) => ({
name,
itemStyle: {
color: palette[index % palette.length]
}
}));
});
const chartLinks = computed(() => {
if (!buildResult.value) {
return [];
}
return applyDirection(buildResult.value.links, direction.value);
});
const chartOption = computed<EChartsOption>(() => {
return {
backgroundColor: '#f7f8fa',
tooltip: {
trigger: 'item'
},
series: [
{
type: 'sankey',
left: chartPadding.value,
top: chartPadding.value,
right: chartPadding.value,
bottom: chartPadding.value,
nodeAlign: 'justify',
nodeGap: nodeGap.value,
nodeWidth: 14,
roam: true,
label: {
color: '#4e5969',
fontSize: 12
},
lineStyle: {
color: 'source',
curveness: 0.45,
opacity: 0.45
},
data: chartNodes.value,
links: chartLinks.value
}
]
};
});
/**
* 每次映射配置变化都实时重新聚合,保持“输入即预览”的交互。
*/
watchEffect(() => {
const table = rawTable.value;
if (!table) {
buildResult.value = null;
buildError.value = '';
return;
}
if (mapping.sourceDataColumn === null) {
buildResult.value = null;
buildError.value = '请选择源数据列';
return;
}
if (mapping.targetDescriptionColumns.length === 0) {
buildResult.value = null;
buildError.value = '请至少选择一个目标描述列';
return;
}
try {
buildResult.value = buildSankeyData(table, {
sourceDataColumn: mapping.sourceDataColumn,
sourceDescriptionColumns: [...mapping.sourceDescriptionColumns],
targetDescriptionColumns: [...mapping.targetDescriptionColumns],
delimiter: mapping.delimiter
});
buildError.value = '';
} catch (error) {
buildResult.value = null;
buildError.value = error instanceof Error ? error.message : '构建桑基图失败';
}
});
watch(
chartOption,
() => {
if (!chartInstance) {
return;
}
chartInstance.setOption(chartOption.value, true);
},
{ deep: true }
);
function syncChartSize(): void {
chartInstance?.resize();
}
function closeThemePicker(): void {
showThemePicker.value = false;
}
function toggleThemePicker(): void {
showThemePicker.value = !showThemePicker.value;
}
function pickTheme(themeId: (typeof themes)[number]['id']): void {
selectedThemeId.value = themeId;
showThemePicker.value = false;
}
function toggleUploadTip(): void {
showUploadTip.value = !showUploadTip.value;
}
function toggleSourceDescription(column: number): void {
if (mapping.sourceDescriptionColumns.includes(column)) {
mapping.sourceDescriptionColumns = mapping.sourceDescriptionColumns.filter((item) => item !== column);
return;
}
mapping.sourceDescriptionColumns = [...mapping.sourceDescriptionColumns, column].sort((a, b) => a - b);
}
function toggleTargetDescription(column: number): void {
if (mapping.targetDescriptionColumns.includes(column)) {
mapping.targetDescriptionColumns = mapping.targetDescriptionColumns.filter((item) => item !== column);
return;
}
mapping.targetDescriptionColumns = [...mapping.targetDescriptionColumns, column].sort((a, b) => a - b);
}
function openFileDialog(): void {
fileInputRef.value?.click();
}
function setDefaultMappingByColumns(columnSize: number): void {
const safeSize = Math.max(columnSize, 1);
const safeColumn = (index: number): number => Math.min(index, safeSize - 1);
mapping.sourceDataColumn = safeColumn(1);
mapping.sourceDescriptionColumns = [safeColumn(0), safeColumn(1)].filter(
(item, index, list) => list.indexOf(item) === index
);
mapping.targetDescriptionColumns = [safeColumn(2), safeColumn(3)].filter(
(item, index, list) => list.indexOf(item) === index
);
}
/**
* 统一处理上传文件,支持点击上传和拖拽上传两种入口。
*/
async function loadDataFile(file: File): Promise<void> {
parseError.value = '';
try {
const parsed = await parseDataFile(file);
rawTable.value = parsed;
setDefaultMappingByColumns(parsed.headers.length);
uploadMessage.value = `已加载: ${file.name}${parsed.rows.length} 行)`;
} catch (error) {
parseError.value = error instanceof Error ? error.message : '文件解析失败';
}
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
await loadDataFile(file);
input.value = '';
}
async function onDropFile(event: DragEvent): Promise<void> {
const file = event.dataTransfer?.files?.[0];
if (!file) {
return;
}
await loadDataFile(file);
}
function formatFileTimestamp(): string {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
now.getHours()
)}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
function downloadByDataUrl(dataUrl: string, filename: string): void {
const anchor = document.createElement('a');
anchor.href = dataUrl;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
function exportSvg(): void {
if (!chartInstance) {
return;
}
const svgEl = chartRef.value?.querySelector('svg');
if (svgEl) {
const serialized = new XMLSerializer().serializeToString(svgEl);
const blob = new Blob([serialized], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
downloadByDataUrl(url, `sankey_${formatFileTimestamp()}.svg`);
URL.revokeObjectURL(url);
return;
}
const dataUrl = chartInstance.getDataURL({
type: 'svg',
backgroundColor: '#ffffff',
pixelRatio: 2
});
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
}
function exportPng(): void {
if (!chartInstance) {
return;
}
const dataUrl = chartInstance.getDataURL({
type: 'png',
backgroundColor: '#ffffff',
pixelRatio: 2
});
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.png`);
}
onMounted(() => {
const container = chartRef.value;
if (!container) {
return;
}
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
chartInstance.setOption(chartOption.value);
window.addEventListener('resize', syncChartSize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', syncChartSize);
chartInstance?.dispose();
chartInstance = null;
});
</script>

3
src/core/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './types';
export * from './parser';
export * from './sankey';

96
src/core/parser.ts Normal file
View File

@@ -0,0 +1,96 @@
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import type { RawTable } from './types';
/**
* 将任意单元格值转换为字符串,统一处理 null/undefined 场景。
*/
function normalizeCell(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
/**
* 将二维数组标准化为 RawTable。
* 约定第一行为表头,后续为数据行。
*/
function toRawTable(rows: unknown[][]): RawTable {
if (rows.length === 0) {
return { headers: [], rows: [] };
}
const firstRow = rows[0] ?? [];
const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), firstRow.length);
const headers = Array.from({ length: maxColumns }, (_, index) => {
const header = normalizeCell(firstRow[index]);
return header || `${index + 1}`;
});
const dataRows = rows.slice(1).map((row) => {
return Array.from({ length: maxColumns }, (_, index) => normalizeCell(row[index]));
});
return {
headers,
rows: dataRows
};
}
/**
* 解析 CSV 文本为统一表结构。
*/
export function parseCsvText(csvText: string): RawTable {
const parsed = Papa.parse<string[]>(csvText, {
skipEmptyLines: false
});
if (parsed.errors.length > 0) {
const firstError = parsed.errors[0];
throw new Error(`CSV 解析失败: ${firstError.message}`);
}
const rows = parsed.data.map((row: string[]) => row ?? []);
return toRawTable(rows);
}
/**
* 解析 xlsx 的二进制数据。
*/
export function parseXlsxBuffer(buffer: ArrayBuffer): RawTable {
const workbook = XLSX.read(buffer, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
throw new Error('Excel 文件中没有工作表');
}
const sheet = workbook.Sheets[firstSheetName];
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
raw: false,
defval: ''
});
return toRawTable(rows);
}
/**
* 根据文件后缀自动判断并解析文件。
*/
export async function parseDataFile(file: File): Promise<RawTable> {
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith('.csv')) {
const text = await file.text();
return parseCsvText(text);
}
if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
const buffer = await file.arrayBuffer();
return parseXlsxBuffer(buffer);
}
throw new Error('仅支持 .csv / .xlsx / .xls 文件');
}

168
src/core/sankey.ts Normal file
View File

@@ -0,0 +1,168 @@
import type {
DirectionMode,
MappingConfig,
RawTable,
SankeyBuildResult,
SankeyLink,
SankeyNode
} from './types';
/**
* 统一清洗字符串,避免因为前后空格导致节点重复。
*/
function normalizeText(value: string): string {
return value.trim();
}
/**
* 将字符串解析为数字,支持千分位(例如 12,000
*/
function parseNumericValue(text: string): number | null {
const normalized = text.replace(/,/g, '').trim();
if (!normalized) {
return null;
}
const parsed = Number(normalized);
if (Number.isNaN(parsed)) {
return null;
}
return parsed;
}
/**
* 按照配置生成 source 名称。
* 若未选择描述列,则回退为数据列文本。
*/
function buildSourceName(row: string[], config: MappingConfig): string {
const sourceDataValue = config.sourceDataColumn === null ? '' : row[config.sourceDataColumn] ?? '';
if (config.sourceDescriptionColumns.length === 0) {
return normalizeText(sourceDataValue);
}
const parts = config.sourceDescriptionColumns
.map((column) => normalizeText(row[column] ?? ''))
.filter((item) => item.length > 0);
return parts.join(config.delimiter);
}
/**
* 生成 target 名称,并实现“合并单元格向下补全”的语义。
*/
function buildTargetName(
row: string[],
config: MappingConfig,
lastNonEmptyTargetValueByColumn: Map<number, string>
): string {
const parts = config.targetDescriptionColumns
.map((column) => {
const raw = normalizeText(row[column] ?? '');
if (raw.length > 0) {
lastNonEmptyTargetValueByColumn.set(column, raw);
return raw;
}
return lastNonEmptyTargetValueByColumn.get(column) ?? '';
})
.filter((item) => item.length > 0);
return parts.join(config.delimiter);
}
/**
* 将映射配置应用到表格数据,输出桑基图节点和连线。
*/
export function buildSankeyData(table: RawTable, config: MappingConfig): SankeyBuildResult {
if (config.sourceDataColumn === null) {
throw new Error('必须选择源数据列');
}
if (config.targetDescriptionColumns.length === 0) {
throw new Error('必须至少选择一个目标描述列');
}
const linkValueMap = new Map<string, number>();
const warnings: string[] = [];
let droppedRows = 0;
const lastNonEmptyTargetValueByColumn = new Map<number, string>();
table.rows.forEach((row, rowIndex) => {
const excelRow = rowIndex + 2;
const sourceRaw = normalizeText(row[config.sourceDataColumn as number] ?? '');
const sourceValue = parseNumericValue(sourceRaw);
if (sourceValue === null) {
warnings.push(`${excelRow} 行: 源数据不是有效数字,已跳过`);
droppedRows += 1;
return;
}
const sourceName = buildSourceName(row, config);
if (!sourceName) {
warnings.push(`${excelRow} 行: 源描述为空,已跳过`);
droppedRows += 1;
return;
}
const targetName = buildTargetName(row, config, lastNonEmptyTargetValueByColumn);
if (!targetName) {
warnings.push(`${excelRow} 行: 目标描述为空,已跳过`);
droppedRows += 1;
return;
}
const key = `${sourceName}@@${targetName}`;
const prev = linkValueMap.get(key) ?? 0;
linkValueMap.set(key, prev + sourceValue);
});
const links: SankeyLink[] = [];
const sourceSet = new Set<string>();
const targetSet = new Set<string>();
linkValueMap.forEach((value, key) => {
const [source, target] = key.split('@@');
if (!source || !target) {
return;
}
sourceSet.add(source);
targetSet.add(target);
links.push({ source, target, value });
});
const nodes: SankeyNode[] = [
...Array.from(sourceSet).map((name) => ({ name, kind: 'source' as const })),
...Array.from(targetSet)
.filter((name) => !sourceSet.has(name))
.map((name) => ({ name, kind: 'target' as const }))
];
return {
nodes,
links,
meta: {
droppedRows,
warnings
}
};
}
/**
* 用于方向切换:仅交换连线方向,不改动原始聚合结果。
*/
export function applyDirection(links: SankeyLink[], direction: DirectionMode): SankeyLink[] {
if (direction === 'source-to-target') {
return links;
}
return links.map((link) => ({
source: link.target,
target: link.source,
value: link.value
}));
}

49
src/core/types.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 统一后的表格结构。
* headers 表示首行列名rows 表示去掉首行后每一行的字符串值。
*/
export interface RawTable {
headers: string[];
rows: string[][];
}
/**
* 用户在界面上配置的列映射规则。
*/
export interface MappingConfig {
sourceDataColumn: number | null;
sourceDescriptionColumns: number[];
targetDescriptionColumns: number[];
delimiter: string;
}
/**
* 渲染桑基图所需的节点。
*/
export interface SankeyNode {
name: string;
kind: 'source' | 'target';
}
/**
* 渲染桑基图所需的边。
*/
export interface SankeyLink {
source: string;
target: string;
value: number;
}
/**
* 聚合后的业务输出,包括告警信息。
*/
export interface SankeyBuildResult {
nodes: SankeyNode[];
links: SankeyLink[];
meta: {
droppedRows: number;
warnings: string[];
};
}
export type DirectionMode = 'source-to-target' | 'target-to-source';

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#app');

450
src/styles.css Normal file
View File

@@ -0,0 +1,450 @@
:root {
--primary-7: #8552a1;
--primary-6: #9b6bc2;
--fill-1: #f7f8fa;
--fill-3: #e5e6eb;
--fill-4: #c9cdd4;
--text-1: #ffffff;
--text-3: #86909c;
--text-4: #4e5969;
--danger-3: #fbaca3;
--font-pingfang: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
background: #f3f4f6;
color: #1d2129;
font-family: var(--font-pingfang);
}
.page {
position: relative;
width: 100%;
min-height: 100vh;
padding: 16px 16px 10px;
}
.top-bar {
position: relative;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 12px;
padding: 0 8px;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.logo {
width: 64px;
height: 64px;
border-radius: 16px;
}
.title-logo {
width: 174px;
height: auto;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
justify-content: flex-end;
}
.tool-item {
display: flex;
align-items: center;
gap: 4px;
}
.tool-label {
color: #1d2129;
font-size: 14px;
white-space: nowrap;
}
.icon-btn {
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.icon-btn.tiny {
width: 14px;
height: 14px;
}
.upload-area {
min-width: 280px;
max-width: 420px;
flex: 1;
min-height: 38px;
border-radius: 6px;
display: flex;
align-items: center;
padding: 4px 10px;
background: var(--fill-1);
cursor: pointer;
position: relative;
}
.hidden-input {
display: none;
}
.upload-text {
color: var(--text-4);
font-size: 12px;
}
.upload-tip {
position: absolute;
left: 0;
top: 44px;
z-index: 5;
width: 100%;
border: 1px solid var(--primary-7);
background: #fff;
border-radius: 8px;
padding: 8px;
color: var(--text-4);
font-size: 12px;
}
.upload-tip a {
color: var(--primary-7);
text-decoration: none;
font-weight: 500;
}
.export-box {
display: flex;
gap: 6px;
align-items: center;
background: #fff;
border: 1px solid var(--fill-3);
border-radius: 8px;
padding: 2px 8px;
}
.export-main {
width: 18px;
height: 34px;
}
.export-item {
width: 40px;
height: 40px;
}
.theme-popover {
position: absolute;
top: 80px;
right: 290px;
width: 280px;
border: 1px solid var(--primary-7);
border-radius: 24px 24px 0 0;
background: #fff;
z-index: 10;
padding: 8px;
}
.theme-header {
text-align: center;
color: var(--primary-6);
font-size: 14px;
margin-bottom: 8px;
}
.theme-list {
max-height: 180px;
overflow: auto;
}
.theme-row {
width: 100%;
border: 0;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
cursor: pointer;
}
.theme-row img {
width: 18px;
height: 18px;
}
.palette {
flex: 1;
display: grid;
grid-template-columns: repeat(9, 1fr);
height: 20px;
}
.palette-cell {
display: block;
}
.content {
display: grid;
grid-template-columns: 360px 1fr;
gap: 8px;
min-height: calc(100vh - 140px);
}
.left-pane {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel {
border: 1px solid #f7dede;
border-radius: 16px;
background: #fff;
}
.block-panel {
padding: 8px;
}
.block-panel h2,
.preview-head h2 {
margin: 0;
font-size: 24px;
font-weight: 400;
}
.field-block {
margin-top: 12px;
}
.field-title-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.field-title-wrap h3 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.expand-icon {
width: 16px;
height: 16px;
}
.column-row {
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--fill-4);
height: 32px;
padding-bottom: 6px;
}
.column-icon {
width: 18px;
height: 18px;
}
.column-label {
flex: 1;
color: var(--text-3);
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-btn {
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
}
.select-btn img {
width: 20px;
height: 20px;
}
.preview-panel {
padding: 8px;
display: flex;
flex-direction: column;
}
.preview-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 6px;
}
.preview-controls {
display: flex;
align-items: center;
gap: 8px;
}
.preview-controls label {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-4);
font-size: 12px;
}
.preview-controls select,
.preview-controls input {
accent-color: var(--primary-7);
}
.example-line {
margin: 4px 0 8px;
color: var(--text-4);
font-size: 12px;
}
.chart-area {
background: var(--fill-1);
border-radius: 8px;
min-height: 480px;
flex: 1;
}
.warning-area {
margin-top: 8px;
font-size: 12px;
color: var(--text-4);
}
.warning-area ul {
margin: 6px 0 0;
padding-left: 18px;
}
.error-text {
color: #cb272d;
font-size: 12px;
margin-bottom: 6px;
}
.footer {
margin-top: 6px;
color: var(--text-3);
font-size: 16px;
}
@media (max-width: 1024px) {
.top-bar {
flex-direction: column;
align-items: stretch;
}
.toolbar {
justify-content: flex-start;
flex-wrap: wrap;
}
.theme-popover {
right: 12px;
top: 120px;
}
.content {
grid-template-columns: 1fr;
}
.left-pane {
flex-direction: row;
flex-wrap: wrap;
}
.block-panel {
flex: 1;
min-width: 280px;
}
.chart-area {
min-height: 360px;
}
.footer {
font-size: 14px;
}
}
@media (max-width: 640px) {
.page {
padding: 8px;
}
.logo {
width: 48px;
height: 48px;
border-radius: 12px;
}
.title-logo {
width: 160px;
}
.tool-label {
font-size: 12px;
}
.upload-area {
min-width: 220px;
}
.block-panel h2,
.preview-head h2 {
font-size: 20px;
}
.field-title-wrap h3 {
font-size: 16px;
}
}

6
src/types/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}

47
tests/core.spec.ts Normal file
View File

@@ -0,0 +1,47 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildSankeyData, parseCsvText, parseXlsxBuffer } from '../src/core';
describe('core parser & sankey', () => {
it('可以解析 CSV 并生成列名与数据行', () => {
const table = parseCsvText('A,B,C\n1,2,3\n4,5,6');
expect(table.headers).toEqual(['A', 'B', 'C']);
expect(table.rows).toEqual([
['1', '2', '3'],
['4', '5', '6']
]);
});
it('支持合并单元格语义向下补全并聚合', () => {
const table = {
headers: ['站点', '值', '目标', '模型'],
rows: [
['宁波北欧10', '2582', '嘉兴四级算力池', '小模型'],
['宁波北欧12', '2610', '', ''],
['宁波鄞中15', '507', '嘉兴四级算力池', '小模型']
]
};
const result = buildSankeyData(table, {
sourceDataColumn: 1,
sourceDescriptionColumns: [0, 1],
targetDescriptionColumns: [2, 3],
delimiter: '-'
});
expect(result.meta.droppedRows).toBe(0);
expect(result.links).toHaveLength(3);
expect(result.links[1].target).toBe('嘉兴四级算力池-小模型');
});
it('可以解析 data/example0.xlsx', () => {
const filePath = resolve(process.cwd(), 'data/example0.xlsx');
const fileBuffer = readFileSync(filePath);
const table = parseXlsxBuffer(fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength));
expect(table.headers.length).toBeGreaterThan(1);
expect(table.rows.length).toBeGreaterThan(0);
});
});

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vitest/globals", "node"],
"skipLibCheck": true,
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "vite.config.ts"]
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const certDir = join(homedir(), 'mac.biboer.cn_ecc');
const httpsCert = readFileSync(join(certDir, 'fullchain.cer'));
const httpsKey = readFileSync(join(certDir, 'mac.biboer.cn.key'));
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
allowedHosts: ['mac.biboer.cn'],
https: {
cert: httpsCert,
key: httpsKey
}
}
});