update at 2026-02-13 17:44:25
This commit is contained in:
352
src/App.vue
352
src/App.vue
@@ -92,8 +92,8 @@
|
||||
|
||||
<main class="content">
|
||||
<section class="left-pane">
|
||||
<article class="panel block-panel">
|
||||
<h2>源数据</h2>
|
||||
<article class="panel block-panel select-panel">
|
||||
<img :src="iconSelectTitle" alt="数据选择器" class="panel-title-svg panel-title-select" />
|
||||
|
||||
<div class="field-block">
|
||||
<button class="field-title-wrap" type="button" @click="toggleSection('sourceData')">
|
||||
@@ -102,7 +102,7 @@
|
||||
:alt="sectionVisible.sourceData ? '折叠' : '展开'"
|
||||
class="expand-icon"
|
||||
/>
|
||||
<h3>数据列</h3>
|
||||
<h3>源数据(link value)</h3>
|
||||
</button>
|
||||
|
||||
<template v-if="sectionVisible.sourceData">
|
||||
@@ -136,7 +136,7 @@
|
||||
:alt="sectionVisible.sourceDesc ? '折叠' : '展开'"
|
||||
class="expand-icon"
|
||||
/>
|
||||
<h3>描述列</h3>
|
||||
<h3>源标签(Source label)</h3>
|
||||
</button>
|
||||
|
||||
<template v-if="sectionVisible.sourceDesc">
|
||||
@@ -146,7 +146,7 @@
|
||||
:key="`source-desc-${index}`"
|
||||
class="column-row"
|
||||
>
|
||||
<img :src="iconDescription" alt="描述列" class="column-icon" />
|
||||
<img :src="iconDescription" alt="源标签列" class="column-icon" />
|
||||
<span class="column-label">{{ header }}</span>
|
||||
<button class="select-btn" type="button" @click="toggleSourceDescription(index)">
|
||||
<img
|
||||
@@ -162,10 +162,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel block-panel target-panel">
|
||||
<h2>目标数据</h2>
|
||||
<div class="field-block">
|
||||
<button class="field-title-wrap" type="button" @click="toggleSection('targetDesc')">
|
||||
<img
|
||||
@@ -173,7 +170,7 @@
|
||||
:alt="sectionVisible.targetDesc ? '折叠' : '展开'"
|
||||
class="expand-icon"
|
||||
/>
|
||||
<h3>描述列</h3>
|
||||
<h3>目标标签(target label)</h3>
|
||||
</button>
|
||||
|
||||
<template v-if="sectionVisible.targetDesc">
|
||||
@@ -183,7 +180,7 @@
|
||||
:key="`target-desc-${index}`"
|
||||
class="column-row"
|
||||
>
|
||||
<img :src="iconDescription" alt="描述列" class="column-icon" />
|
||||
<img :src="iconDescription" alt="目标标签列" class="column-icon" />
|
||||
<span class="column-label">{{ header }}</span>
|
||||
<button class="select-btn" type="button" @click="toggleTargetDescription(index)">
|
||||
<img
|
||||
@@ -200,54 +197,104 @@
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel info-panel">
|
||||
<img :src="iconInformationTitle" alt="信息日志" class="panel-title-svg panel-title-info" />
|
||||
<ul v-if="infoLogs.length > 0" class="info-log-list">
|
||||
<li
|
||||
v-for="(log, index) in infoLogs"
|
||||
:key="`${log.level}-${index}-${log.text}`"
|
||||
:class="['info-log-item', `level-${log.level}`]"
|
||||
>
|
||||
{{ log.text }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="info-log-empty">暂无日志</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel preview-panel">
|
||||
<div class="preview-head">
|
||||
<h2>桑基图预览</h2>
|
||||
<img :src="iconSankeyViewTitle" alt="桑基图预览" class="panel-title-svg panel-title-preview" />
|
||||
|
||||
<div class="preview-controls">
|
||||
<label class="slider-label">
|
||||
<img :src="iconGap" alt="间距" class="slider-icon" />
|
||||
<div class="slider-track-wrap">
|
||||
<span class="slider-value" :style="getSliderValueStyle(nodeGap, 0, 30)">{{
|
||||
nodeGap
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="nodeGap"
|
||||
class="slider-input"
|
||||
:style="getSliderTrackStyle(nodeGap, 0, 30)"
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label class="slider-label">
|
||||
<img :src="iconPadding" alt="边距" class="slider-icon" />
|
||||
<div class="slider-track-wrap">
|
||||
<span class="slider-value" :style="getSliderValueStyle(chartPadding, 0, 80)">
|
||||
{{ chartPadding }}
|
||||
<template v-if="isPhoneViewport">
|
||||
<label class="compact-control mobile-only">
|
||||
<img :src="iconGap" alt="间距" class="slider-icon" />
|
||||
<select v-model.number="nodeGap" class="compact-select">
|
||||
<option v-for="value in mobileGapOptions" :key="`gap-${value}`" :value="value">
|
||||
{{ value }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="compact-control mobile-only">
|
||||
<img :src="iconPadding" alt="边距" class="slider-icon" />
|
||||
<select v-model.number="chartPadding" class="compact-select">
|
||||
<option
|
||||
v-for="value in mobilePaddingOptions"
|
||||
:key="`padding-${value}`"
|
||||
:value="value"
|
||||
>
|
||||
{{ value }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="compact-control mobile-only">
|
||||
<span class="compact-label">方向</span>
|
||||
<select v-model="direction" class="compact-select">
|
||||
<option
|
||||
v-for="option in directionOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="slider-label desktop-only">
|
||||
<img :src="iconGap" alt="间距" class="slider-icon" />
|
||||
<div class="slider-track-wrap">
|
||||
<span class="slider-value" :style="getSliderValueStyle(nodeGap, 0, 30)">{{
|
||||
nodeGap
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="nodeGap"
|
||||
class="slider-input"
|
||||
:style="getSliderTrackStyle(nodeGap, 0, 30)"
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label class="slider-label desktop-only">
|
||||
<img :src="iconPadding" alt="边距" class="slider-icon" />
|
||||
<div class="slider-track-wrap">
|
||||
<span class="slider-value" :style="getSliderValueStyle(chartPadding, 0, 80)">
|
||||
{{ chartPadding }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="chartPadding"
|
||||
class="slider-input"
|
||||
:style="getSliderTrackStyle(chartPadding, 0, 80)"
|
||||
type="range"
|
||||
min="0"
|
||||
max="80"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<button class="direction-control desktop-only" 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' ? '源' : '目标' }}
|
||||
</span>
|
||||
<span class="direction-switch-thumb" />
|
||||
</span>
|
||||
<input
|
||||
v-model.number="chartPadding"
|
||||
class="slider-input"
|
||||
:style="getSliderTrackStyle(chartPadding, 0, 80)"
|
||||
type="range"
|
||||
min="0"
|
||||
max="80"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</button>
|
||||
</template>
|
||||
<label class="label-position-control">
|
||||
<span class="label-position-label">标签位置</span>
|
||||
<select v-model="labelPositionMode" class="label-position-select">
|
||||
@@ -275,17 +322,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="buildError" class="error-text">{{ buildError }}</div>
|
||||
<div v-if="parseError" class="error-text">{{ parseError }}</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>
|
||||
|
||||
@@ -337,6 +374,9 @@ import iconExpand from '../assets/icons/expand.svg';
|
||||
import iconZhedie from '../assets/icons/zhedie.svg';
|
||||
import iconGap from '../assets/icons/gap.svg';
|
||||
import iconPadding from '../assets/icons/padding.svg';
|
||||
import iconSankeyViewTitle from '../assets/icons/sankeyview.svg';
|
||||
import iconInformationTitle from '../assets/icons/information.svg';
|
||||
import iconSelectTitle from '../assets/icons/select.svg';
|
||||
|
||||
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
|
||||
|
||||
@@ -368,6 +408,13 @@ interface PersistedWorkspace {
|
||||
}>;
|
||||
}
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
interface InfoLogEntry {
|
||||
level: LogLevel;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const themes = THEME_PRESETS;
|
||||
const selectedThemeId = ref<string>('figma-violet');
|
||||
const showThemePicker = ref(false);
|
||||
@@ -383,6 +430,7 @@ const themeTriggerRef = ref<HTMLDivElement | null>(null);
|
||||
const themePopoverRef = ref<HTMLDivElement | null>(null);
|
||||
let chartInstance: echarts.EChartsType | null = null;
|
||||
let themeWheelRafId = 0;
|
||||
let chartResizeObserver: ResizeObserver | null = null;
|
||||
const themePopoverStyle = ref<CSSProperties>({});
|
||||
|
||||
const THEME_ROW_HEIGHT = 42;
|
||||
@@ -391,6 +439,7 @@ const rawTable = ref<RawTable | null>(null);
|
||||
const buildResult = ref<SankeyBuildResult | null>(null);
|
||||
const uploadedFileSnapshot = ref<PersistedUploadedFile | null>(null);
|
||||
const isRestoringWorkspace = ref(false);
|
||||
const isPhoneViewport = ref(false);
|
||||
|
||||
const mapping = reactive<MappingConfig>({
|
||||
sourceDataColumn: null,
|
||||
@@ -402,20 +451,31 @@ const mapping = reactive<MappingConfig>({
|
||||
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
|
||||
const nodeGap = ref(5);
|
||||
const chartPadding = ref(24);
|
||||
/**
|
||||
* 桑基图上下额外留白,避免 gap 变大时首尾节点/标签轻微溢出。
|
||||
*/
|
||||
const CHART_TOP_SAFE_PADDING = 8;
|
||||
const CHART_BOTTOM_SAFE_PADDING = 8;
|
||||
const labelPositionMode = ref<LabelPositionMode>('inner');
|
||||
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
|
||||
{ value: 'inner', label: '向内' },
|
||||
{ value: 'outer', label: '向外' },
|
||||
{ value: 'left', label: '向左' },
|
||||
{ value: 'right', label: '向右' }
|
||||
{ value: 'inner', label: '内' },
|
||||
{ value: 'outer', label: '外' },
|
||||
{ value: 'left', label: '左' },
|
||||
{ value: 'right', label: '右' }
|
||||
];
|
||||
const targetAlignMode = ref<TargetAlignMode>('between');
|
||||
const targetAlignOptions: Array<{ value: TargetAlignMode; label: string }> = [
|
||||
{ value: 'between', label: 'Between' },
|
||||
{ value: 'middle', label: 'Middle' },
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'bottom', label: 'Bottom' }
|
||||
{ value: 'between', label: '两端' },
|
||||
{ value: 'middle', label: '中间' },
|
||||
{ value: 'top', label: '顶部' },
|
||||
{ value: 'bottom', label: '底部' }
|
||||
];
|
||||
const directionOptions: Array<{ value: 'source-to-target' | 'target-to-source'; label: string }> = [
|
||||
{ value: 'source-to-target', label: '源' },
|
||||
{ value: 'target-to-source', label: '目标' }
|
||||
];
|
||||
const mobileGapOptions = [0, 5, 10, 15, 20, 25, 30];
|
||||
const mobilePaddingOptions = [0, 10, 20, 30, 40, 50, 60, 70, 80];
|
||||
/**
|
||||
* 左侧字段区块的展开/折叠状态。
|
||||
* true 表示展开,false 表示折叠。
|
||||
@@ -443,6 +503,98 @@ const columnHeaders = computed(() => {
|
||||
});
|
||||
|
||||
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
|
||||
const selectedSourceDataHeader = computed(() => {
|
||||
if (mapping.sourceDataColumn === null) {
|
||||
return '未选择';
|
||||
}
|
||||
return columnHeaders.value[mapping.sourceDataColumn] ?? `列${mapping.sourceDataColumn + 1}`;
|
||||
});
|
||||
const selectedSourceLabelHeaders = computed(() => {
|
||||
if (mapping.sourceDescriptionColumns.length === 0) {
|
||||
return '未选择';
|
||||
}
|
||||
return mapping.sourceDescriptionColumns
|
||||
.map((index) => columnHeaders.value[index] ?? `列${index + 1}`)
|
||||
.join(',');
|
||||
});
|
||||
const selectedTargetLabelHeaders = computed(() => {
|
||||
if (mapping.targetDescriptionColumns.length === 0) {
|
||||
return '未选择';
|
||||
}
|
||||
return mapping.targetDescriptionColumns
|
||||
.map((index) => columnHeaders.value[index] ?? `列${index + 1}`)
|
||||
.join(',');
|
||||
});
|
||||
/**
|
||||
* 左下“信息日志”统一输出解析信息、运行告警、错误和关键配置快照。
|
||||
* 这样告警不再挤在预览底部,且用户可以持续看到当前映射状态。
|
||||
*/
|
||||
const infoLogs = computed<InfoLogEntry[]>(() => {
|
||||
const logs: InfoLogEntry[] = [];
|
||||
const table = rawTable.value;
|
||||
const result = buildResult.value;
|
||||
|
||||
if (table) {
|
||||
logs.push({
|
||||
level: 'info',
|
||||
text: `解析信息: 已加载 ${table.rows.length} 行,${table.headers.length} 列`
|
||||
});
|
||||
} else {
|
||||
logs.push({
|
||||
level: 'info',
|
||||
text: '解析信息: 尚未加载数据文件'
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
logs.push({
|
||||
level: 'info',
|
||||
text: `解析信息: 已生成 ${result.nodes.length} 个节点,${result.links.length} 条连线`
|
||||
});
|
||||
if (result.meta.droppedRows > 0) {
|
||||
logs.push({
|
||||
level: 'warn',
|
||||
text: `告警: 已跳过 ${result.meta.droppedRows} 行异常数据`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logs.push({
|
||||
level: 'info',
|
||||
text: `日志: 源数据列=${selectedSourceDataHeader.value}`
|
||||
});
|
||||
logs.push({
|
||||
level: 'info',
|
||||
text: `日志: 源标签列=${selectedSourceLabelHeaders.value}`
|
||||
});
|
||||
logs.push({
|
||||
level: 'info',
|
||||
text: `日志: 目标标签列=${selectedTargetLabelHeaders.value}`
|
||||
});
|
||||
|
||||
buildWarnings.value.forEach((warning) => {
|
||||
logs.push({
|
||||
level: 'warn',
|
||||
text: `告警: ${warning}`
|
||||
});
|
||||
});
|
||||
|
||||
if (parseError.value) {
|
||||
logs.push({
|
||||
level: 'error',
|
||||
text: `错误: ${parseError.value}`
|
||||
});
|
||||
}
|
||||
|
||||
if (buildError.value) {
|
||||
logs.push({
|
||||
level: 'error',
|
||||
text: `错误: ${buildError.value}`
|
||||
});
|
||||
}
|
||||
|
||||
return logs;
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据“标签位置”下拉框值,计算左右端节点的标签朝向。
|
||||
@@ -484,7 +636,13 @@ function buildTargetLocalYMap(
|
||||
links: Array<{ source: string; target: string; value: number }>
|
||||
): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
const layoutHeight = Math.max(0, chartClientHeight.value - chartPadding.value * 2);
|
||||
const layoutHeight = Math.max(
|
||||
0,
|
||||
chartClientHeight.value -
|
||||
chartPadding.value * 2 -
|
||||
CHART_TOP_SAFE_PADDING -
|
||||
CHART_BOTTOM_SAFE_PADDING
|
||||
);
|
||||
if (layoutHeight <= 0 || links.length === 0) {
|
||||
return map;
|
||||
}
|
||||
@@ -619,9 +777,9 @@ const chartOption = computed<EChartsOption>(() => {
|
||||
{
|
||||
type: 'sankey',
|
||||
left: chartPadding.value,
|
||||
top: chartPadding.value,
|
||||
top: chartPadding.value + CHART_TOP_SAFE_PADDING,
|
||||
right: chartPadding.value,
|
||||
bottom: chartPadding.value,
|
||||
bottom: chartPadding.value + CHART_BOTTOM_SAFE_PADDING,
|
||||
nodeAlign: 'justify',
|
||||
nodeGap: nodeGap.value,
|
||||
nodeWidth: 14,
|
||||
@@ -717,10 +875,50 @@ watch(
|
||||
);
|
||||
|
||||
function syncChartSize(): void {
|
||||
updateAppViewportHeight();
|
||||
updatePhoneViewportFlag();
|
||||
chartClientHeight.value = chartRef.value?.clientHeight ?? chartClientHeight.value;
|
||||
chartInstance?.resize();
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS Safari 的 100vh 会受到地址栏显隐影响,使用 visualViewport 实时回写 CSS 变量,
|
||||
* 让页面高度和 preview 面板高度始终跟随可视区域。
|
||||
*/
|
||||
function updateAppViewportHeight(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-vh', `${viewportHeight * 0.01}px`);
|
||||
}
|
||||
|
||||
function updatePhoneViewportFlag(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
isPhoneViewport.value = window.matchMedia('(max-width: 640px)').matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听图表容器尺寸变化(不仅是 window resize),
|
||||
* 解决 iOS 下地址栏收起/展开或弹性布局变化时,图表尺寸不同步的问题。
|
||||
*/
|
||||
function observeChartContainerSize(): void {
|
||||
const container = chartRef.value;
|
||||
if (!container || typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
chartResizeObserver?.disconnect();
|
||||
chartResizeObserver = new ResizeObserver(() => {
|
||||
syncChartSize();
|
||||
});
|
||||
chartResizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function closeThemePicker(): void {
|
||||
showThemePicker.value = false;
|
||||
}
|
||||
@@ -1372,9 +1570,13 @@ onMounted(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAppViewportHeight();
|
||||
updatePhoneViewportFlag();
|
||||
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
|
||||
chartClientHeight.value = container.clientHeight;
|
||||
chartInstance.setOption(chartOption.value);
|
||||
observeChartContainerSize();
|
||||
syncChartSize();
|
||||
void (async () => {
|
||||
const { restoredUploadedFile, restoredMapping } = await restoreWorkspaceFromStorage();
|
||||
if (restoredUploadedFile) {
|
||||
@@ -1387,6 +1589,8 @@ onMounted(() => {
|
||||
}
|
||||
})();
|
||||
window.addEventListener('resize', syncChartSize);
|
||||
window.visualViewport?.addEventListener('resize', syncChartSize);
|
||||
window.visualViewport?.addEventListener('scroll', syncChartSize);
|
||||
window.addEventListener('resize', updateThemePopoverPosition);
|
||||
window.addEventListener('scroll', updateThemePopoverPosition, true);
|
||||
window.addEventListener('pointerdown', handleGlobalPointerDown);
|
||||
@@ -1394,9 +1598,13 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncChartSize);
|
||||
window.visualViewport?.removeEventListener('resize', syncChartSize);
|
||||
window.visualViewport?.removeEventListener('scroll', syncChartSize);
|
||||
window.removeEventListener('resize', updateThemePopoverPosition);
|
||||
window.removeEventListener('scroll', updateThemePopoverPosition, true);
|
||||
window.removeEventListener('pointerdown', handleGlobalPointerDown);
|
||||
chartResizeObserver?.disconnect();
|
||||
chartResizeObserver = null;
|
||||
chartInstance?.dispose();
|
||||
chartInstance = null;
|
||||
if (themeWheelRafId !== 0) {
|
||||
|
||||
@@ -31,6 +31,34 @@ function parseNumericValue(text: string): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单元格值格式化为可读文本,用于告警输出。
|
||||
* 空字符串会明确展示为“(空)”,便于定位“看起来没填值”的问题行。
|
||||
*/
|
||||
function formatCellValueForWarning(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return '(空)';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装“列位置 + 列名 + 原始值”的告警片段,便于用户快速定位问题字段。
|
||||
*/
|
||||
function buildColumnDebugText(row: string[], headers: string[], columns: number[]): string {
|
||||
if (columns.length === 0) {
|
||||
return '未选择列';
|
||||
}
|
||||
|
||||
return columns
|
||||
.map((columnIndex) => {
|
||||
const headerName = headers[columnIndex] ?? `列${columnIndex + 1}`;
|
||||
const rawValue = row[columnIndex] ?? '';
|
||||
return `第 ${columnIndex + 1} 列(${headerName})="${formatCellValueForWarning(rawValue)}"`;
|
||||
})
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按照配置生成 source 名称。
|
||||
* 若未选择描述列,则回退为数据列文本。
|
||||
@@ -87,30 +115,48 @@ export function buildSankeyData(table: RawTable, config: MappingConfig): SankeyB
|
||||
const linkValueMap = new Map<string, number>();
|
||||
const warnings: string[] = [];
|
||||
let droppedRows = 0;
|
||||
const sourceDataColumnIndex = config.sourceDataColumn;
|
||||
const sourceDataColumnName =
|
||||
table.headers[sourceDataColumnIndex] ?? `列${sourceDataColumnIndex + 1}`;
|
||||
|
||||
const lastNonEmptyTargetValueByColumn = new Map<number, string>();
|
||||
|
||||
table.rows.forEach((row, rowIndex) => {
|
||||
const excelRow = rowIndex + 2;
|
||||
const sourceRaw = normalizeText(row[config.sourceDataColumn as number] ?? '');
|
||||
const sourceCellRaw = row[sourceDataColumnIndex] ?? '';
|
||||
const sourceRaw = normalizeText(sourceCellRaw);
|
||||
const sourceValue = parseNumericValue(sourceRaw);
|
||||
|
||||
if (sourceValue === null) {
|
||||
warnings.push(`第 ${excelRow} 行: 源数据不是有效数字,已跳过`);
|
||||
warnings.push(
|
||||
`第 ${excelRow} 行, 第 ${sourceDataColumnIndex + 1} 列(${sourceDataColumnName}): 源数据不是有效数字,原始值="${formatCellValueForWarning(sourceCellRaw)}",已跳过`
|
||||
);
|
||||
droppedRows += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceName = buildSourceName(row, config);
|
||||
if (!sourceName) {
|
||||
warnings.push(`第 ${excelRow} 行: 源描述为空,已跳过`);
|
||||
const sourceDescDebugText = buildColumnDebugText(
|
||||
row,
|
||||
table.headers,
|
||||
config.sourceDescriptionColumns
|
||||
);
|
||||
warnings.push(`第 ${excelRow} 行: 源描述为空,字段=${sourceDescDebugText},已跳过`);
|
||||
droppedRows += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetName = buildTargetName(row, config, lastNonEmptyTargetValueByColumn);
|
||||
if (!targetName) {
|
||||
warnings.push(`第 ${excelRow} 行: 目标描述为空,已跳过`);
|
||||
const targetDescDebugText = buildColumnDebugText(
|
||||
row,
|
||||
table.headers,
|
||||
config.targetDescriptionColumns
|
||||
);
|
||||
warnings.push(
|
||||
`第 ${excelRow} 行: 目标描述为空,字段=${targetDescDebugText},且无可继承的上方值,已跳过`
|
||||
);
|
||||
droppedRows += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
293
src/styles.css
293
src/styles.css
@@ -31,8 +31,11 @@ body {
|
||||
.page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: calc(var(--app-vh, 1vh) * 100);
|
||||
padding: 16px 16px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
@@ -280,13 +283,15 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 8px;
|
||||
min-height: calc(100vh - 140px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -296,22 +301,35 @@ body {
|
||||
}
|
||||
|
||||
.block-panel {
|
||||
padding: 8px;
|
||||
padding: 8px 8px 10px;
|
||||
}
|
||||
|
||||
.target-panel {
|
||||
.select-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.block-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
.panel-title-svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.panel-title-select {
|
||||
width: 189px;
|
||||
}
|
||||
|
||||
.panel-title-preview {
|
||||
width: 238px;
|
||||
}
|
||||
|
||||
.panel-title-info {
|
||||
width: 186px;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
margin-top: 12px;
|
||||
margin-top: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -417,6 +435,7 @@ body {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
@@ -428,20 +447,20 @@ body {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preview-head h2 {
|
||||
margin: 0;
|
||||
width: 223px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-controls .slider-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -635,6 +654,32 @@ body {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.compact-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.compact-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compact-select {
|
||||
height: 24px;
|
||||
min-width: 52px;
|
||||
border: 1px solid #c9aee0;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
color: #1d2129;
|
||||
font-size: 12px;
|
||||
padding: 0 16px 0 4px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.example-line {
|
||||
margin: 4px 0 8px;
|
||||
color: var(--text-4);
|
||||
@@ -644,29 +689,53 @@ body {
|
||||
.chart-area {
|
||||
background: var(--fill-1);
|
||||
border-radius: 8px;
|
||||
min-height: 480px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.warning-area {
|
||||
margin-top: 8px;
|
||||
.info-panel {
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
max-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-log-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.info-log-item {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-4);
|
||||
}
|
||||
|
||||
.warning-area ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
.info-log-item.level-warn {
|
||||
color: #8d5b00;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
.info-log-item.level-error {
|
||||
color: #cb272d;
|
||||
}
|
||||
|
||||
.info-log-empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -679,49 +748,90 @@ body {
|
||||
|
||||
.toolbar {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
gap: 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-item,
|
||||
.export-box,
|
||||
.upload-area {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
order: 1;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
order: 2;
|
||||
min-height: 0;
|
||||
max-height: 42%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.block-panel {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
min-height: 360px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
min-height: auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-head h2 {
|
||||
width: auto;
|
||||
font-size: 28px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
justify-content: flex-start;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.preview-controls::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-controls .slider-label {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.slider-track-wrap {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.label-position-control {
|
||||
margin-left: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.target-align-control {
|
||||
margin-left: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -749,18 +859,107 @@ body {
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
min-width: 220px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block-panel h2 {
|
||||
font-size: 20px;
|
||||
.top-bar {
|
||||
padding: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-head h2 {
|
||||
font-size: 24px;
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-item,
|
||||
.export-box {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
max-height: 46%;
|
||||
}
|
||||
|
||||
.field-title-wrap h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panel-title-preview {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.preview-controls::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.preview-controls .slider-label {
|
||||
width: 74px;
|
||||
}
|
||||
|
||||
.slider-track-wrap {
|
||||
width: 61px;
|
||||
}
|
||||
|
||||
.label-position-control,
|
||||
.target-align-control {
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-position-label,
|
||||
.target-align-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.label-position-select {
|
||||
min-width: 46px;
|
||||
font-size: 12px;
|
||||
padding: 0 14px 0 4px;
|
||||
}
|
||||
|
||||
.target-align-select {
|
||||
min-width: 58px;
|
||||
font-size: 12px;
|
||||
padding: 0 14px 0 4px;
|
||||
}
|
||||
|
||||
.direction-control {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user