575 lines
17 KiB
Vue
575 lines
17 KiB
Vue
<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 theme-trigger">
|
||
<span class="tool-label">选择主题</span>
|
||
<button class="icon-btn" type="button" @click="toggleThemePicker">
|
||
<img :src="iconChooseColor" alt="choose-color" />
|
||
</button>
|
||
|
||
<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>
|
||
</div>
|
||
|
||
<div class="tool-item">
|
||
<span class="tool-label">文件上传</span>
|
||
<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>
|
||
</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>
|
||
|
||
</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 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 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 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>
|