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

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>