update at 2026-02-12 17:30:41
This commit is contained in:
23
.eslintrc.cjs
Normal file
23
.eslintrc.cjs
Normal 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
2
.gitignore
vendored
@@ -9,6 +9,8 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.npm-cache
|
||||
dist
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
42
README.md
Normal file
42
README.md
Normal 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
12
index.html
Normal 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
10
miniapp/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 小程序端骨架
|
||||
|
||||
当前目录提供了与 Figma `node-id=584:64` 对齐的页面骨架:
|
||||
|
||||
- 顶部 Logo/主题/上传/导出区域
|
||||
- 效果预览区域
|
||||
- 源数据与目标数据列选择区域
|
||||
- 主题底部选择器(底部弹层)
|
||||
|
||||
后续接入时建议直接复用 `src/core` 的解析与聚合逻辑,保持 Web 与小程序一致的数据行为。
|
||||
1
miniapp/app.js
Normal file
1
miniapp/app.js
Normal file
@@ -0,0 +1 @@
|
||||
App({});
|
||||
10
miniapp/app.json
Normal file
10
miniapp/app.json
Normal 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
5
miniapp/app.wxss
Normal file
@@ -0,0 +1,5 @@
|
||||
page {
|
||||
background: #f3f4f6;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: #1d2129;
|
||||
}
|
||||
25
miniapp/pages/index/index.js
Normal file
25
miniapp/pages/index/index.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
4
miniapp/pages/index/index.json
Normal file
4
miniapp/pages/index/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "星程桑基图",
|
||||
"usingComponents": {}
|
||||
}
|
||||
100
miniapp/pages/index/index.wxml
Normal file
100
miniapp/pages/index/index.wxml
Normal 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>
|
||||
178
miniapp/pages/index/index.wxss
Normal file
178
miniapp/pages/index/index.wxss
Normal 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
4
miniapp/sitemap.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"desc": "星程桑基图小程序",
|
||||
"rules": []
|
||||
}
|
||||
3821
package-lock.json
generated
Normal file
3821
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
BIN
public/data/example.xlsx
Normal file
Binary file not shown.
BIN
public/data/example0.xlsx
Normal file
BIN
public/data/example0.xlsx
Normal file
Binary file not shown.
BIN
public/data/example00.xlsx
Normal file
BIN
public/data/example00.xlsx
Normal file
Binary file not shown.
BIN
public/data/example1.xlsx
Normal file
BIN
public/data/example1.xlsx
Normal file
Binary file not shown.
32
public/data/拉流切图节点与算法池信息.csv
Normal file
32
public/data/拉流切图节点与算法池信息.csv
Normal 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,,,,,,
|
||||
|
588
src/App.vue
Normal file
588
src/App.vue
Normal 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 -> target</option>
|
||||
<option value="target-to-source">target -> 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
3
src/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './parser';
|
||||
export * from './sankey';
|
||||
96
src/core/parser.ts
Normal file
96
src/core/parser.ts
Normal 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
168
src/core/sankey.ts
Normal 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
49
src/core/types.ts
Normal 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
5
src/main.ts
Normal 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
450
src/styles.css
Normal 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
6
src/types/shims-vue.d.ts
vendored
Normal 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
47
tests/core.spec.ts
Normal 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
18
tsconfig.json
Normal 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
23
vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user