update at 2026-02-13 14:20:39
This commit is contained in:
212
src/App.vue
212
src/App.vue
@@ -196,17 +196,6 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column-row total-row">
|
|
||||||
<img :src="iconDescription" alt="总和" class="column-icon" />
|
|
||||||
<span class="column-label">总和</span>
|
|
||||||
<button
|
|
||||||
class="select-btn"
|
|
||||||
type="button"
|
|
||||||
@click="targetShowTotal = !targetShowTotal"
|
|
||||||
>
|
|
||||||
<img :src="targetShowTotal ? iconCheckboxOn : iconCheckboxOff" alt="复选" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,6 +248,18 @@
|
|||||||
<span class="direction-switch-thumb" />
|
<span class="direction-switch-thumb" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<label class="label-position-control">
|
||||||
|
<span class="label-position-label">标签位置</span>
|
||||||
|
<select v-model="labelPositionMode" class="label-position-select">
|
||||||
|
<option
|
||||||
|
v-for="option in labelPositionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,7 +297,7 @@ import * as echarts from 'echarts/core';
|
|||||||
import type { EChartsOption } from 'echarts';
|
import type { EChartsOption } from 'echarts';
|
||||||
import { SankeyChart } from 'echarts/charts';
|
import { SankeyChart } from 'echarts/charts';
|
||||||
import { TooltipComponent } from 'echarts/components';
|
import { TooltipComponent } from 'echarts/components';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
|
||||||
import {
|
import {
|
||||||
applyDirection,
|
applyDirection,
|
||||||
buildSankeyData,
|
buildSankeyData,
|
||||||
@@ -325,7 +326,7 @@ import iconZhedie from '../assets/icons/zhedie.svg';
|
|||||||
import iconGap from '../assets/icons/gap.svg';
|
import iconGap from '../assets/icons/gap.svg';
|
||||||
import iconPadding from '../assets/icons/padding.svg';
|
import iconPadding from '../assets/icons/padding.svg';
|
||||||
|
|
||||||
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer]);
|
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
|
||||||
|
|
||||||
const themes = THEME_PRESETS;
|
const themes = THEME_PRESETS;
|
||||||
const selectedThemeId = ref<string>('figma-violet');
|
const selectedThemeId = ref<string>('figma-violet');
|
||||||
@@ -350,7 +351,7 @@ const buildResult = ref<SankeyBuildResult | null>(null);
|
|||||||
|
|
||||||
const mapping = reactive<MappingConfig>({
|
const mapping = reactive<MappingConfig>({
|
||||||
sourceDataColumn: 2,
|
sourceDataColumn: 2,
|
||||||
sourceDescriptionColumns: [0, 1],
|
sourceDescriptionColumns: [0],
|
||||||
targetDescriptionColumns: [2],
|
targetDescriptionColumns: [2],
|
||||||
delimiter: '-'
|
delimiter: '-'
|
||||||
});
|
});
|
||||||
@@ -358,7 +359,14 @@ const mapping = reactive<MappingConfig>({
|
|||||||
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
|
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
|
||||||
const nodeGap = ref(5);
|
const nodeGap = ref(5);
|
||||||
const chartPadding = ref(24);
|
const chartPadding = ref(24);
|
||||||
const targetShowTotal = ref(true);
|
type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
|
||||||
|
const labelPositionMode = ref<LabelPositionMode>('inner');
|
||||||
|
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
|
||||||
|
{ value: 'inner', label: '向内' },
|
||||||
|
{ value: 'outer', label: '向外' },
|
||||||
|
{ value: 'left', label: '向左' },
|
||||||
|
{ value: 'right', label: '向右' }
|
||||||
|
];
|
||||||
/**
|
/**
|
||||||
* 左侧字段区块的展开/折叠状态。
|
* 左侧字段区块的展开/折叠状态。
|
||||||
* true 表示展开,false 表示折叠。
|
* true 表示展开,false 表示折叠。
|
||||||
@@ -385,65 +393,70 @@ const columnHeaders = computed(() => {
|
|||||||
return ['列1', '列2', '列3', '列4'];
|
return ['列1', '列2', '列3', '列4'];
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化总和数值,统一使用千分位显示。
|
|
||||||
*/
|
|
||||||
function formatTotalValue(value: number): string {
|
|
||||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 10 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统计每个 target 的汇总值,用于展示“目标管道总和”。
|
|
||||||
*/
|
|
||||||
const targetTotalValueMap = computed(() => {
|
|
||||||
const result = buildResult.value;
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
if (!result) {
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.links.forEach((link) => {
|
|
||||||
map.set(link.target, (map.get(link.target) ?? 0) + link.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将原始 target 名称映射为“target + 总和”显示名。
|
|
||||||
* 例如:男 -> 男 1,200
|
|
||||||
*/
|
|
||||||
const targetDisplayNameMap = computed(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
if (!targetShowTotal.value) {
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetTotalValueMap.value.forEach((total, targetName) => {
|
|
||||||
map.set(targetName, `${targetName} ${formatTotalValue(total)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
|
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据“标签位置”下拉框值,计算左右端节点的标签朝向。
|
||||||
|
* - 向内:左端标签朝右,右端标签朝左
|
||||||
|
* - 向外:左端标签朝左,右端标签朝右
|
||||||
|
* - 向左:两端都朝左
|
||||||
|
* - 向右:两端都朝右
|
||||||
|
*/
|
||||||
|
function resolveEdgeLabelPosition(
|
||||||
|
incomingCount: number,
|
||||||
|
outgoingCount: number,
|
||||||
|
mode: LabelPositionMode
|
||||||
|
): 'left' | 'right' | undefined {
|
||||||
|
const isLeftEdge = incomingCount === 0 && outgoingCount > 0;
|
||||||
|
const isRightEdge = outgoingCount === 0 && incomingCount > 0;
|
||||||
|
|
||||||
|
if (!isLeftEdge && !isRightEdge) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'left') {
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
if (mode === 'right') {
|
||||||
|
return 'right';
|
||||||
|
}
|
||||||
|
if (mode === 'inner') {
|
||||||
|
return isLeftEdge ? 'right' : 'left';
|
||||||
|
}
|
||||||
|
return isLeftEdge ? 'left' : 'right';
|
||||||
|
}
|
||||||
|
|
||||||
const chartNodes = computed(() => {
|
const chartNodes = computed(() => {
|
||||||
const links = chartLinks.value;
|
const links = chartLinks.value;
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
const incomingCountMap = new Map<string, number>();
|
||||||
|
const outgoingCountMap = new Map<string, number>();
|
||||||
|
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
names.add(link.source);
|
names.add(link.source);
|
||||||
names.add(link.target);
|
names.add(link.target);
|
||||||
|
outgoingCountMap.set(link.source, (outgoingCountMap.get(link.source) ?? 0) + 1);
|
||||||
|
incomingCountMap.set(link.target, (incomingCountMap.get(link.target) ?? 0) + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const palette = selectedTheme.value.colors;
|
const palette = selectedTheme.value.colors;
|
||||||
return Array.from(names).map((name, index) => ({
|
return Array.from(names).map((name, index) => {
|
||||||
|
const incomingCount = incomingCountMap.get(name) ?? 0;
|
||||||
|
const outgoingCount = outgoingCountMap.get(name) ?? 0;
|
||||||
|
const position = resolveEdgeLabelPosition(incomingCount, outgoingCount, labelPositionMode.value);
|
||||||
|
|
||||||
|
return {
|
||||||
name,
|
name,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: palette[index % palette.length]
|
color: palette[index % palette.length]
|
||||||
|
},
|
||||||
|
label: position
|
||||||
|
? {
|
||||||
|
position
|
||||||
}
|
}
|
||||||
}));
|
: undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartLinks = computed(() => {
|
const chartLinks = computed(() => {
|
||||||
@@ -451,12 +464,7 @@ const chartLinks = computed(() => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const linksWithDisplayName = buildResult.value.links.map((link) => ({
|
return applyDirection(buildResult.value.links, direction.value);
|
||||||
...link,
|
|
||||||
target: targetDisplayNameMap.value.get(link.target) ?? link.target
|
|
||||||
}));
|
|
||||||
|
|
||||||
return applyDirection(linksWithDisplayName, direction.value);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartOption = computed<EChartsOption>(() => {
|
const chartOption = computed<EChartsOption>(() => {
|
||||||
@@ -742,15 +750,40 @@ function openFileDialog(): void {
|
|||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDefaultMappingByColumns(columnSize: number): void {
|
/**
|
||||||
const safeSize = Math.max(columnSize, 1);
|
* 将表头统一为可匹配字符串:小写 + 去空格/下划线/中划线。
|
||||||
const safeColumn = (index: number): number => Math.min(index, safeSize - 1);
|
*/
|
||||||
|
function normalizeHeaderName(header: string): string {
|
||||||
|
return header.trim().toLowerCase().replace(/[\s_-]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
mapping.sourceDataColumn = safeColumn(2);
|
/**
|
||||||
mapping.sourceDescriptionColumns = [safeColumn(0), safeColumn(1)].filter(
|
* 根据候选列名查找列索引;未命中返回 -1。
|
||||||
|
*/
|
||||||
|
function findHeaderIndex(headers: string[], aliases: string[]): number {
|
||||||
|
const aliasSet = new Set(aliases.map((item) => normalizeHeaderName(item)));
|
||||||
|
return headers.findIndex((header) => aliasSet.has(normalizeHeaderName(header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件后按列名设置默认映射:
|
||||||
|
* - 源数据-数据列:value
|
||||||
|
* - 源数据-描述列:source
|
||||||
|
* - 目标数据-描述列:target
|
||||||
|
* 若未找到对应列名,回退到安全列索引。
|
||||||
|
*/
|
||||||
|
function setDefaultMappingByHeaders(headers: string[]): void {
|
||||||
|
const safeSize = Math.max(headers.length, 1);
|
||||||
|
const safeColumn = (index: number): number => Math.min(index, safeSize - 1);
|
||||||
|
const sourceDataByName = findHeaderIndex(headers, ['value']);
|
||||||
|
const sourceDescByName = findHeaderIndex(headers, ['source']);
|
||||||
|
const targetDescByName = findHeaderIndex(headers, ['target']);
|
||||||
|
|
||||||
|
mapping.sourceDataColumn = sourceDataByName >= 0 ? sourceDataByName : safeColumn(2);
|
||||||
|
mapping.sourceDescriptionColumns = [sourceDescByName >= 0 ? sourceDescByName : safeColumn(0)].filter(
|
||||||
(item, index, list) => list.indexOf(item) === index
|
(item, index, list) => list.indexOf(item) === index
|
||||||
);
|
);
|
||||||
mapping.targetDescriptionColumns = [safeColumn(2), safeColumn(3)].filter(
|
mapping.targetDescriptionColumns = [targetDescByName >= 0 ? targetDescByName : safeColumn(2)].filter(
|
||||||
(item, index, list) => list.indexOf(item) === index
|
(item, index, list) => list.indexOf(item) === index
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -764,7 +797,7 @@ async function loadDataFile(file: File): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const parsed = await parseDataFile(file);
|
const parsed = await parseDataFile(file);
|
||||||
rawTable.value = parsed;
|
rawTable.value = parsed;
|
||||||
setDefaultMappingByColumns(parsed.headers.length);
|
setDefaultMappingByHeaders(parsed.headers);
|
||||||
uploadMessage.value = `已加载: ${file.name}(${parsed.rows.length} 行)`;
|
uploadMessage.value = `已加载: ${file.name}(${parsed.rows.length} 行)`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseError.value = error instanceof Error ? error.message : '文件解析失败';
|
parseError.value = error instanceof Error ? error.message : '文件解析失败';
|
||||||
@@ -808,7 +841,7 @@ async function loadDefaultExampleFile(): Promise<void> {
|
|||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
const parsed = parseXlsxBuffer(buffer);
|
const parsed = parseXlsxBuffer(buffer);
|
||||||
rawTable.value = parsed;
|
rawTable.value = parsed;
|
||||||
setDefaultMappingByColumns(parsed.headers.length);
|
setDefaultMappingByHeaders(parsed.headers);
|
||||||
uploadMessage.value = `已加载: example0.xlsx(${parsed.rows.length} 行)`;
|
uploadMessage.value = `已加载: example0.xlsx(${parsed.rows.length} 行)`;
|
||||||
parseError.value = '';
|
parseError.value = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -838,22 +871,33 @@ function exportSvg(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgEl = chartRef.value?.querySelector('svg');
|
const width = chartInstance.getWidth();
|
||||||
if (svgEl) {
|
const height = chartInstance.getHeight();
|
||||||
const serialized = new XMLSerializer().serializeToString(svgEl);
|
const tempContainer = document.createElement('div');
|
||||||
const blob = new Blob([serialized], { type: 'image/svg+xml;charset=utf-8' });
|
tempContainer.style.position = 'fixed';
|
||||||
const url = URL.createObjectURL(blob);
|
tempContainer.style.left = '-99999px';
|
||||||
downloadByDataUrl(url, `sankey_${formatFileTimestamp()}.svg`);
|
tempContainer.style.top = '-99999px';
|
||||||
URL.revokeObjectURL(url);
|
tempContainer.style.width = `${width}px`;
|
||||||
return;
|
tempContainer.style.height = `${height}px`;
|
||||||
}
|
tempContainer.style.opacity = '0';
|
||||||
|
tempContainer.style.pointerEvents = 'none';
|
||||||
|
document.body.append(tempContainer);
|
||||||
|
|
||||||
const dataUrl = chartInstance.getDataURL({
|
let svgChart: echarts.EChartsType | null = null;
|
||||||
|
try {
|
||||||
|
// 主图继续走 canvas,导出时单独用 SVG 渲染器重建一份,保证导出格式正确。
|
||||||
|
svgChart = echarts.init(tempContainer, undefined, { renderer: 'svg', width, height });
|
||||||
|
svgChart.setOption(chartInstance.getOption() as EChartsOption, true);
|
||||||
|
|
||||||
|
const dataUrl = svgChart.getDataURL({
|
||||||
type: 'svg',
|
type: 'svg',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff'
|
||||||
pixelRatio: 2
|
|
||||||
});
|
});
|
||||||
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
|
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
|
||||||
|
} finally {
|
||||||
|
svgChart?.dispose();
|
||||||
|
tempContainer.remove();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPng(): void {
|
function exportPng(): void {
|
||||||
|
|||||||
@@ -399,10 +399,6 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-row .column-label {
|
|
||||||
color: var(--text-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn {
|
.select-btn {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -587,6 +583,32 @@ body {
|
|||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-position-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-position-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-position-select {
|
||||||
|
min-width: 64px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid #c9aee0;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1d2129;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 20px 0 6px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.example-line {
|
.example-line {
|
||||||
margin: 4px 0 8px;
|
margin: 4px 0 8px;
|
||||||
color: var(--text-4);
|
color: var(--text-4);
|
||||||
@@ -668,6 +690,10 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-position-control {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user