update at 2026-02-13 14:20:39
This commit is contained in:
226
src/App.vue
226
src/App.vue
@@ -196,17 +196,6 @@
|
||||
/>
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -259,6 +248,18 @@
|
||||
<span class="direction-switch-thumb" />
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@@ -296,7 +297,7 @@ 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 { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
|
||||
import {
|
||||
applyDirection,
|
||||
buildSankeyData,
|
||||
@@ -325,7 +326,7 @@ import iconZhedie from '../assets/icons/zhedie.svg';
|
||||
import iconGap from '../assets/icons/gap.svg';
|
||||
import iconPadding from '../assets/icons/padding.svg';
|
||||
|
||||
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer]);
|
||||
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
|
||||
|
||||
const themes = THEME_PRESETS;
|
||||
const selectedThemeId = ref<string>('figma-violet');
|
||||
@@ -350,7 +351,7 @@ const buildResult = ref<SankeyBuildResult | null>(null);
|
||||
|
||||
const mapping = reactive<MappingConfig>({
|
||||
sourceDataColumn: 2,
|
||||
sourceDescriptionColumns: [0, 1],
|
||||
sourceDescriptionColumns: [0],
|
||||
targetDescriptionColumns: [2],
|
||||
delimiter: '-'
|
||||
});
|
||||
@@ -358,7 +359,14 @@ const mapping = reactive<MappingConfig>({
|
||||
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
|
||||
const nodeGap = ref(5);
|
||||
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 表示折叠。
|
||||
@@ -385,65 +393,70 @@ const columnHeaders = computed(() => {
|
||||
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) ?? []);
|
||||
|
||||
/**
|
||||
* 根据“标签位置”下拉框值,计算左右端节点的标签朝向。
|
||||
* - 向内:左端标签朝右,右端标签朝左
|
||||
* - 向外:左端标签朝左,右端标签朝右
|
||||
* - 向左:两端都朝左
|
||||
* - 向右:两端都朝右
|
||||
*/
|
||||
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 links = chartLinks.value;
|
||||
const names = new Set<string>();
|
||||
const incomingCountMap = new Map<string, number>();
|
||||
const outgoingCountMap = new Map<string, number>();
|
||||
|
||||
links.forEach((link) => {
|
||||
names.add(link.source);
|
||||
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;
|
||||
return Array.from(names).map((name, index) => ({
|
||||
name,
|
||||
itemStyle: {
|
||||
color: palette[index % palette.length]
|
||||
}
|
||||
}));
|
||||
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,
|
||||
itemStyle: {
|
||||
color: palette[index % palette.length]
|
||||
},
|
||||
label: position
|
||||
? {
|
||||
position
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const chartLinks = computed(() => {
|
||||
@@ -451,12 +464,7 @@ const chartLinks = computed(() => {
|
||||
return [];
|
||||
}
|
||||
|
||||
const linksWithDisplayName = buildResult.value.links.map((link) => ({
|
||||
...link,
|
||||
target: targetDisplayNameMap.value.get(link.target) ?? link.target
|
||||
}));
|
||||
|
||||
return applyDirection(linksWithDisplayName, direction.value);
|
||||
return applyDirection(buildResult.value.links, direction.value);
|
||||
});
|
||||
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
@@ -742,15 +750,40 @@ 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);
|
||||
/**
|
||||
* 将表头统一为可匹配字符串:小写 + 去空格/下划线/中划线。
|
||||
*/
|
||||
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
|
||||
);
|
||||
mapping.targetDescriptionColumns = [safeColumn(2), safeColumn(3)].filter(
|
||||
mapping.targetDescriptionColumns = [targetDescByName >= 0 ? targetDescByName : safeColumn(2)].filter(
|
||||
(item, index, list) => list.indexOf(item) === index
|
||||
);
|
||||
}
|
||||
@@ -764,7 +797,7 @@ async function loadDataFile(file: File): Promise<void> {
|
||||
try {
|
||||
const parsed = await parseDataFile(file);
|
||||
rawTable.value = parsed;
|
||||
setDefaultMappingByColumns(parsed.headers.length);
|
||||
setDefaultMappingByHeaders(parsed.headers);
|
||||
uploadMessage.value = `已加载: ${file.name}(${parsed.rows.length} 行)`;
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : '文件解析失败';
|
||||
@@ -808,7 +841,7 @@ async function loadDefaultExampleFile(): Promise<void> {
|
||||
const buffer = await response.arrayBuffer();
|
||||
const parsed = parseXlsxBuffer(buffer);
|
||||
rawTable.value = parsed;
|
||||
setDefaultMappingByColumns(parsed.headers.length);
|
||||
setDefaultMappingByHeaders(parsed.headers);
|
||||
uploadMessage.value = `已加载: example0.xlsx(${parsed.rows.length} 行)`;
|
||||
parseError.value = '';
|
||||
} catch (error) {
|
||||
@@ -838,22 +871,33 @@ function exportSvg(): void {
|
||||
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 width = chartInstance.getWidth();
|
||||
const height = chartInstance.getHeight();
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.style.position = 'fixed';
|
||||
tempContainer.style.left = '-99999px';
|
||||
tempContainer.style.top = '-99999px';
|
||||
tempContainer.style.width = `${width}px`;
|
||||
tempContainer.style.height = `${height}px`;
|
||||
tempContainer.style.opacity = '0';
|
||||
tempContainer.style.pointerEvents = 'none';
|
||||
document.body.append(tempContainer);
|
||||
|
||||
const dataUrl = chartInstance.getDataURL({
|
||||
type: 'svg',
|
||||
backgroundColor: '#ffffff',
|
||||
pixelRatio: 2
|
||||
});
|
||||
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
|
||||
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',
|
||||
backgroundColor: '#ffffff'
|
||||
});
|
||||
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
|
||||
} finally {
|
||||
svgChart?.dispose();
|
||||
tempContainer.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function exportPng(): void {
|
||||
|
||||
@@ -399,10 +399,6 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.total-row .column-label {
|
||||
color: var(--text-4);
|
||||
}
|
||||
|
||||
.select-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -587,6 +583,32 @@ body {
|
||||
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 {
|
||||
margin: 4px 0 8px;
|
||||
color: var(--text-4);
|
||||
@@ -668,6 +690,10 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label-position-control {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user