update at 2026-02-13 08:27:51

This commit is contained in:
douboer@gmail.com
2026-02-13 08:27:51 +08:00
parent 804936e97a
commit 9738329529
2 changed files with 283 additions and 93 deletions

View File

@@ -6,7 +6,7 @@
<img :src="iconTitle" alt="星程桑基图" class="title-logo" /> <img :src="iconTitle" alt="星程桑基图" class="title-logo" />
</div> </div>
<div class="toolbar"> <div class="toolbar">
<div ref="themeTriggerRef" class="tool-item theme-trigger"> <div ref="themeTriggerRef" class="tool-item theme-trigger">
<span class="tool-label">选择主题</span> <span class="tool-label">选择主题</span>
<button class="icon-btn" type="button" @click.stop="toggleThemePicker"> <button class="icon-btn" type="button" @click.stop="toggleThemePicker">
@@ -23,26 +23,32 @@
<div class="theme-header">选择配色主题</div> <div class="theme-header">选择配色主题</div>
<div class="theme-wheel-wrap"> <div class="theme-wheel-wrap">
<div ref="themeWheelRef" class="theme-wheel" @scroll="onThemeWheelScroll"> <div ref="themeWheelRef" class="theme-wheel" @scroll="onThemeWheelScroll">
<button <button
v-for="(theme, index) in themes" v-for="(theme, index) in themes"
:key="theme.id" :key="theme.id"
class="theme-row" class="theme-row"
:class="{ selected: selectedThemeId === theme.id }" :class="{ selected: selectedThemeId === theme.id }"
:style="{ opacity: getThemeRowOpacity(index) }" :style="{ opacity: getThemeRowOpacity(index) }"
type="button" type="button"
@click="pickTheme(theme.id)" @click="pickTheme(theme.id)"
> >
<img :src="selectedThemeId === theme.id ? iconRadioOn : iconRadioOff" alt="主题选择" /> <img
<div class="palette" :style="{ gridTemplateColumns: `repeat(${theme.colors.length}, 1fr)` }"> :src="selectedThemeId === theme.id ? iconRadioOn : iconRadioOff"
<span alt="主题选择"
v-for="color in theme.colors"
:key="`${theme.id}-${color}`"
class="palette-cell"
:style="{ backgroundColor: color }"
/> />
</div> <div
</button> class="palette"
</div> :style="{ gridTemplateColumns: `repeat(${theme.colors.length}, 1fr)` }"
>
<span
v-for="color in theme.colors"
:key="`${theme.id}-${color}`"
class="palette-cell"
:style="{ backgroundColor: color }"
/>
</div>
</button>
</div>
<div class="theme-mask" /> <div class="theme-mask" />
</div> </div>
</div> </div>
@@ -82,7 +88,6 @@
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<main class="content"> <main class="content">
@@ -109,7 +114,11 @@
> >
<img :src="iconData" alt="数据列" class="column-icon" /> <img :src="iconData" alt="数据列" class="column-icon" />
<span class="column-label">{{ header }}</span> <span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="mapping.sourceDataColumn = index"> <button
class="select-btn"
type="button"
@click="mapping.sourceDataColumn = index"
>
<img <img
:src="mapping.sourceDataColumn === index ? iconRadioOn : iconRadioOff" :src="mapping.sourceDataColumn === index ? iconRadioOn : iconRadioOff"
alt="单选" alt="单选"
@@ -142,7 +151,9 @@
<button class="select-btn" type="button" @click="toggleSourceDescription(index)"> <button class="select-btn" type="button" @click="toggleSourceDescription(index)">
<img <img
:src=" :src="
mapping.sourceDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff mapping.sourceDescriptionColumns.includes(index)
? iconCheckboxOn
: iconCheckboxOff
" "
alt="复选" alt="复选"
/> />
@@ -153,7 +164,7 @@
</div> </div>
</article> </article>
<article class="panel block-panel"> <article class="panel block-panel target-panel">
<h2>目标数据</h2> <h2>目标数据</h2>
<div class="field-block"> <div class="field-block">
<button class="field-title-wrap" type="button" @click="toggleSection('targetDesc')"> <button class="field-title-wrap" type="button" @click="toggleSection('targetDesc')">
@@ -177,12 +188,25 @@
<button class="select-btn" type="button" @click="toggleTargetDescription(index)"> <button class="select-btn" type="button" @click="toggleTargetDescription(index)">
<img <img
:src=" :src="
mapping.targetDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff mapping.targetDescriptionColumns.includes(index)
? iconCheckboxOn
: iconCheckboxOff
" "
alt="复选" alt="复选"
/> />
</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>
@@ -194,43 +218,47 @@
<h2>桑基图预览</h2> <h2>桑基图预览</h2>
<div class="preview-controls"> <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 class="slider-label"> <label class="slider-label">
间距 <img :src="iconGap" alt="间距" class="slider-icon" />
<div class="slider-track-wrap"> <div class="slider-track-wrap">
<span class="slider-value" :style="getSliderValueStyle(nodeGap, 8, 42)">{{ nodeGap }}</span> <span class="slider-value" :style="getSliderValueStyle(nodeGap, 0, 30)">{{
nodeGap
}}</span>
<input <input
v-model.number="nodeGap" v-model.number="nodeGap"
class="slider-input" class="slider-input"
:style="getSliderTrackStyle(nodeGap, 8, 42)" :style="getSliderTrackStyle(nodeGap, 0, 30)"
type="range" type="range"
min="8" min="0"
max="42" max="30"
/> />
</div> </div>
</label> </label>
<label class="slider-label"> <label class="slider-label">
边距 <img :src="iconPadding" alt="边距" class="slider-icon" />
<div class="slider-track-wrap"> <div class="slider-track-wrap">
<span class="slider-value" :style="getSliderValueStyle(chartPadding, 10, 70)"> <span class="slider-value" :style="getSliderValueStyle(chartPadding, 0, 80)">
{{ chartPadding }} {{ chartPadding }}
</span> </span>
<input <input
v-model.number="chartPadding" v-model.number="chartPadding"
class="slider-input" class="slider-input"
:style="getSliderTrackStyle(chartPadding, 10, 70)" :style="getSliderTrackStyle(chartPadding, 0, 80)"
type="range" type="range"
min="10" min="0"
max="70" max="80"
/> />
</div> </div>
</label> </label>
<button class="direction-control" type="button" @click="toggleDirection">
<span class="direction-label">方向</span>
<span class="direction-switch" :class="{ on: direction === 'source-to-target' }">
<span class="direction-switch-text">
{{ direction === 'source-to-target' ? 'source' : 'target' }}
</span>
<span class="direction-switch-thumb" />
</span>
</button>
</div> </div>
</div> </div>
@@ -253,7 +281,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect, type CSSProperties } from 'vue'; import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
watchEffect,
type CSSProperties
} from 'vue';
import * as echarts from 'echarts/core'; 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';
@@ -284,6 +322,8 @@ import iconCheckboxOn from '../assets/icons/checkbox.svg';
import iconCheckboxOff from '../assets/icons/checkbox-no.svg'; import iconCheckboxOff from '../assets/icons/checkbox-no.svg';
import iconExpand from '../assets/icons/expand.svg'; import iconExpand from '../assets/icons/expand.svg';
import iconZhedie from '../assets/icons/zhedie.svg'; 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]);
@@ -309,15 +349,16 @@ const rawTable = ref<RawTable | null>(null);
const buildResult = ref<SankeyBuildResult | null>(null); const buildResult = ref<SankeyBuildResult | null>(null);
const mapping = reactive<MappingConfig>({ const mapping = reactive<MappingConfig>({
sourceDataColumn: 1, sourceDataColumn: 2,
sourceDescriptionColumns: [0, 1], sourceDescriptionColumns: [0, 1],
targetDescriptionColumns: [2], targetDescriptionColumns: [2],
delimiter: '-' delimiter: '-'
}); });
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(24); const nodeGap = ref(5);
const chartPadding = ref(30); const chartPadding = ref(24);
const targetShowTotal = ref(true);
/** /**
* 左侧字段区块的展开/折叠状态。 * 左侧字段区块的展开/折叠状态。
* true 表示展开false 表示折叠。 * true 表示展开false 表示折叠。
@@ -344,10 +385,51 @@ 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) ?? []);
const chartNodes = computed(() => { const chartNodes = computed(() => {
const links = buildResult.value ? applyDirection(buildResult.value.links, direction.value) : []; const links = chartLinks.value;
const names = new Set<string>(); const names = new Set<string>();
links.forEach((link) => { links.forEach((link) => {
@@ -368,7 +450,13 @@ const chartLinks = computed(() => {
if (!buildResult.value) { if (!buildResult.value) {
return []; return [];
} }
return applyDirection(buildResult.value.links, direction.value);
const linksWithDisplayName = buildResult.value.links.map((link) => ({
...link,
target: targetDisplayNameMap.value.get(link.target) ?? link.target
}));
return applyDirection(linksWithDisplayName, direction.value);
}); });
const chartOption = computed<EChartsOption>(() => { const chartOption = computed<EChartsOption>(() => {
@@ -481,22 +569,18 @@ function pickTheme(themeId: string): void {
* 点击弹窗外部时关闭主题窗口,保持和设计稿一致的关闭行为。 * 点击弹窗外部时关闭主题窗口,保持和设计稿一致的关闭行为。
*/ */
function handleGlobalPointerDown(event: PointerEvent): void { function handleGlobalPointerDown(event: PointerEvent): void {
if (!showThemePicker.value) {
return;
}
const target = event.target as Node | null; const target = event.target as Node | null;
if (!target) { if (!target) {
return; return;
} }
const popover = themePopoverRef.value; if (showThemePicker.value) {
const trigger = themeTriggerRef.value; const popover = themePopoverRef.value;
if (popover?.contains(target) || trigger?.contains(target)) { const trigger = themeTriggerRef.value;
return; if (!popover?.contains(target) && !trigger?.contains(target)) {
showThemePicker.value = false;
}
} }
showThemePicker.value = false;
} }
/** /**
@@ -615,11 +699,15 @@ function getSliderTrackStyle(value: number, min: number, max: number): CSSProper
function toggleSourceDescription(column: number): void { function toggleSourceDescription(column: number): void {
if (mapping.sourceDescriptionColumns.includes(column)) { if (mapping.sourceDescriptionColumns.includes(column)) {
mapping.sourceDescriptionColumns = mapping.sourceDescriptionColumns.filter((item) => item !== column); mapping.sourceDescriptionColumns = mapping.sourceDescriptionColumns.filter(
(item) => item !== column
);
return; return;
} }
mapping.sourceDescriptionColumns = [...mapping.sourceDescriptionColumns, column].sort((a, b) => a - b); mapping.sourceDescriptionColumns = [...mapping.sourceDescriptionColumns, column].sort(
(a, b) => a - b
);
} }
/** /**
@@ -631,11 +719,23 @@ function toggleSection(section: keyof typeof sectionVisible): void {
function toggleTargetDescription(column: number): void { function toggleTargetDescription(column: number): void {
if (mapping.targetDescriptionColumns.includes(column)) { if (mapping.targetDescriptionColumns.includes(column)) {
mapping.targetDescriptionColumns = mapping.targetDescriptionColumns.filter((item) => item !== column); mapping.targetDescriptionColumns = mapping.targetDescriptionColumns.filter(
(item) => item !== column
);
return; return;
} }
mapping.targetDescriptionColumns = [...mapping.targetDescriptionColumns, column].sort((a, b) => a - b); mapping.targetDescriptionColumns = [...mapping.targetDescriptionColumns, column].sort(
(a, b) => a - b
);
}
/**
* 方向开关点击一次就在“source->target / target->source”之间切换。
*/
function toggleDirection(): void {
direction.value =
direction.value === 'source-to-target' ? 'target-to-source' : 'source-to-target';
} }
function openFileDialog(): void { function openFileDialog(): void {
@@ -646,7 +746,7 @@ function setDefaultMappingByColumns(columnSize: number): void {
const safeSize = Math.max(columnSize, 1); const safeSize = Math.max(columnSize, 1);
const safeColumn = (index: number): number => Math.min(index, safeSize - 1); const safeColumn = (index: number): number => Math.min(index, safeSize - 1);
mapping.sourceDataColumn = safeColumn(1); mapping.sourceDataColumn = safeColumn(2);
mapping.sourceDescriptionColumns = [safeColumn(0), safeColumn(1)].filter( mapping.sourceDescriptionColumns = [safeColumn(0), safeColumn(1)].filter(
(item, index, list) => list.indexOf(item) === index (item, index, list) => list.indexOf(item) === index
); );

View File

@@ -299,8 +299,12 @@ body {
padding: 8px; padding: 8px;
} }
.block-panel h2, .target-panel {
.preview-head h2 { flex: 1;
min-height: 0;
}
.block-panel h2 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
font-weight: 400; font-weight: 400;
@@ -395,18 +399,22 @@ 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;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 20px; width: 16px;
height: 20px; height: 16px;
} }
.select-btn img { .select-btn img {
width: 20px; width: 16px;
height: 20px; height: 16px;
} }
.preview-panel { .preview-panel {
@@ -420,38 +428,46 @@ body {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
margin-bottom: 6px; min-height: 60px;
margin-bottom: 8px;
}
.preview-head h2 {
margin: 0;
width: 223px;
font-size: 24px;
font-weight: 600;
line-height: 1;
} }
.preview-controls { .preview-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 24px;
}
.preview-controls label {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-4);
font-size: 12px;
} }
.preview-controls .slider-label { .preview-controls .slider-label {
display: inline-flex;
align-items: center; align-items: center;
color: #000; gap: 6px;
font-size: 14px; width: 148px;
} }
.slider-track-wrap { .slider-track-wrap {
position: relative; position: relative;
width: 130px; width: 122px;
height: 18px; height: 24px;
}
.slider-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
} }
.slider-input { .slider-input {
width: 100%; width: 100%;
height: 18px; height: 24px;
margin: 0; margin: 0;
background: transparent; background: transparent;
-webkit-appearance: none; -webkit-appearance: none;
@@ -463,10 +479,10 @@ body {
border-radius: 999px; border-radius: 999px;
background: linear-gradient( background: linear-gradient(
to right, to right,
#8552a1 0, var(--primary-6) 0,
#8552a1 var(--slider-percent, 0%), var(--primary-6) var(--slider-percent, 0%),
#d9d9d9 var(--slider-percent, 0%), var(--fill-3) var(--slider-percent, 0%),
#d9d9d9 100% var(--fill-3) 100%
); );
} }
@@ -476,29 +492,31 @@ body {
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;
border: 1px solid #d9d9d9; border: 1px solid var(--fill-3);
background: #fff; background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
margin-top: -7px; margin-top: -7px;
} }
.slider-input::-moz-range-track { .slider-input::-moz-range-track {
height: 4px; height: 4px;
border-radius: 999px; border-radius: 999px;
background: #d9d9d9; background: var(--fill-3);
} }
.slider-input::-moz-range-thumb { .slider-input::-moz-range-thumb {
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;
border: 1px solid #d9d9d9; border: 1px solid var(--fill-3);
background: #fff; background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
} }
.slider-input::-moz-range-progress { .slider-input::-moz-range-progress {
height: 4px; height: 4px;
border-radius: 999px; border-radius: 999px;
background: #8552a1; background: var(--primary-6);
} }
.slider-value { .slider-value {
@@ -512,8 +530,61 @@ body {
pointer-events: none; pointer-events: none;
} }
.preview-controls select { .direction-control {
accent-color: var(--primary-7); display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
}
.direction-label {
font-size: 14px;
font-weight: 600;
color: #000;
line-height: 1;
}
.direction-switch {
display: inline-flex;
align-items: center;
gap: 2px;
width: 80px;
height: 24px;
padding: 2px;
border-radius: 999px;
background: var(--fill-3);
justify-content: space-between;
}
.direction-switch-text {
font-size: 14px;
line-height: 1;
padding: 0 4px;
color: var(--text-4);
}
.direction-switch-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
border: 1px solid var(--fill-3);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.direction-switch.on {
background: var(--primary-6);
}
.direction-switch.on .direction-switch-text {
color: #fff;
}
.direction-switch:not(.on) {
flex-direction: row-reverse;
} }
.example-line { .example-line {
@@ -581,6 +652,22 @@ body {
min-height: 360px; min-height: 360px;
} }
.preview-head {
min-height: auto;
align-items: flex-start;
flex-direction: column;
}
.preview-head h2 {
width: auto;
font-size: 28px;
}
.preview-controls {
flex-wrap: wrap;
gap: 12px;
}
.footer { .footer {
font-size: 14px; font-size: 14px;
} }
@@ -609,11 +696,14 @@ body {
min-width: 220px; min-width: 220px;
} }
.block-panel h2, .block-panel h2 {
.preview-head h2 {
font-size: 20px; font-size: 20px;
} }
.preview-head h2 {
font-size: 24px;
}
.field-title-wrap h3 { .field-title-wrap h3 {
font-size: 16px; font-size: 16px;
} }