update at 2026-02-13 17:44:25

This commit is contained in:
douboer@gmail.com
2026-02-13 17:44:25 +08:00
parent b98da08f83
commit 2fe45888ba
6 changed files with 660 additions and 124 deletions

6
assets/icons/select.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,4 +1,4 @@
[更新时间] 2026-02-13次更新)
[更新时间] 2026-02-13次更新)
[项目] 星程桑基图
一、已完成Done
@@ -17,6 +17,20 @@
9. 已实现本地持久化:用户上传文件、映射配置与预览选项会写入 localStorage刷新后自动恢复。
10. 已新增“汇聚对齐”配置Between/Middle/Top/Bottom可控制 target 侧对齐,且 gap 作为源侧基准。
11. 已优化“无配置初始化映射”:优先按表头别名自动匹配,缺失时按第二行首个数字列兜底。
12. 已按 Figma 更新主界面布局与文案:
- 左侧将“源数据/目标数据”合并为单个“数据选择器”区块,并使用 `select.svg` 标题图。
- 新增独立“信息日志”面板(`information.svg`),集中显示解析信息、告警、错误与当前映射日志。
- 预览标题改为 `sankeyview.svg`,并移除预览区域底部告警展示。
13. 已完成总高度适配窗口:页面改为 `header + content + footer` 的 100vh 自适应布局,内容区按剩余空间伸缩并在必要时内部滚动。
14. 已修复桑基图底部轻微溢出:图表渲染底部新增固定 8px 安全内边距,并同步目标侧布局高度计算。
15. 已修复 gap 增大时顶部轻微溢出:图表渲染顶部新增固定 8px 安全内边距,并与上下可用高度计算保持一致。
16. 已增强日志定位能力:当“源数据不是有效数字 / 源描述为空 / 目标描述为空”时,告警会展示行号、列号、列名及该单元格原始值(目标为空时额外提示无可继承上方值)。
17. 已增强 iOS 预览尺寸同步:增加 `ResizeObserver` 与 `visualViewport` 监听,确保地址栏变化和容器尺寸变化时桑基图实时 resize。
18. 已调整移动端布局顺序:单列模式下“桑基图预览”位于“数据选择框”之上。
19. 已修复 iOS 下 preview-panel 高度异常:引入 `visualViewport` 动态高度变量驱动页面高度,并将移动端内容区改为“预览弹性占满 + 选择区独立滚动”布局。
20. 已完成手机端控件压缩:隐藏上传文本框;工具条单行滚动显示;预览区 gap/padding 改为下拉0-30 步长 5 / 0-80 步长 10方向改为“源/目标”;标签位置改为“内/外/左/右”;汇聚对齐改为“两端/中间/顶部/底部”;控件尽量单行展示。
21. 已强制手机端仅使用下拉控件模式:手机视口不渲染 gap/padding 滑动条与方向拨杆,仅渲染下拉选择,避免样式条件失效时回退到滑动条模式。
22. 已扩展到平板单行模式:`<=1024px` 下工具条与预览控件均单行展示(不换行,超出横向滚动),并压缩滑动条宽度以提升一行容纳能力。
二、当前状态In Progress
1. 无进行中的代码重构任务。
@@ -33,12 +47,14 @@
2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。
3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。
4. 小程序仅骨架,尚未接入真实解析、渲染与导出。
5. 信息日志当前仅展示最近告警(前 8 条),若后续需要完整历史需引入分页或虚拟滚动。
四、下一步建议Next
1. 决策是否引入“目标数值列”能力(先更新 PRD 后实现)。
2. 将小程序由骨架升级为可用版本(优先复用 `src/core`)。
3. 补充更多异常用例测试(空文件、超大文件、乱码表头、极端数值)。
4. 评估并处理 dev HTTPS 证书本地耦合问题,降低新环境接入成本。
5. 评估“信息日志”是否需要支持导出或清空操作。
五、执行约定Session Memory Rules
1. 新会话开始先读取本文件,再读取 6 份规范文档。

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -44,4 +44,65 @@ describe('core parser & sankey', () => {
expect(table.headers.length).toBeGreaterThan(1);
expect(table.rows.length).toBeGreaterThan(0);
});
it('源数据非法时,告警包含单元格内容和位置', () => {
const table = {
headers: ['source', 'value', 'target'],
rows: [['A', 'abc', 'T1']]
};
const result = buildSankeyData(table, {
sourceDataColumn: 1,
sourceDescriptionColumns: [0],
targetDescriptionColumns: [2],
delimiter: '-'
});
expect(result.meta.droppedRows).toBe(1);
expect(result.meta.warnings[0]).toContain('第 2 行');
expect(result.meta.warnings[0]).toContain('第 2 列');
expect(result.meta.warnings[0]).toContain('value');
expect(result.meta.warnings[0]).toContain('abc');
});
it('源描述为空时,告警包含描述字段内容和位置', () => {
const table = {
headers: ['source', 'value', 'target'],
rows: [['', '12', 'T1']]
};
const result = buildSankeyData(table, {
sourceDataColumn: 1,
sourceDescriptionColumns: [0],
targetDescriptionColumns: [2],
delimiter: '-'
});
expect(result.meta.droppedRows).toBe(1);
expect(result.meta.warnings[0]).toContain('源描述为空');
expect(result.meta.warnings[0]).toContain('第 1 列');
expect(result.meta.warnings[0]).toContain('source');
expect(result.meta.warnings[0]).toContain('(空)');
});
it('目标描述为空时,告警包含描述字段内容和位置', () => {
const table = {
headers: ['source', 'value', 'target'],
rows: [['S1', '12', '']]
};
const result = buildSankeyData(table, {
sourceDataColumn: 1,
sourceDescriptionColumns: [0],
targetDescriptionColumns: [2],
delimiter: '-'
});
expect(result.meta.droppedRows).toBe(1);
expect(result.meta.warnings[0]).toContain('目标描述为空');
expect(result.meta.warnings[0]).toContain('第 3 列');
expect(result.meta.warnings[0]).toContain('target');
expect(result.meta.warnings[0]).toContain('(空)');
expect(result.meta.warnings[0]).toContain('无可继承的上方值');
});
});