update at 2026-02-13 17:44:25
This commit is contained in:
6
assets/icons/select.svg
Normal file
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 |
18
progress.txt
18
progress.txt
@@ -1,4 +1,4 @@
|
|||||||
[更新时间] 2026-02-13(第二次更新)
|
[更新时间] 2026-02-13(第三次更新)
|
||||||
[项目] 星程桑基图
|
[项目] 星程桑基图
|
||||||
|
|
||||||
一、已完成(Done)
|
一、已完成(Done)
|
||||||
@@ -17,6 +17,20 @@
|
|||||||
9. 已实现本地持久化:用户上传文件、映射配置与预览选项会写入 localStorage,刷新后自动恢复。
|
9. 已实现本地持久化:用户上传文件、映射配置与预览选项会写入 localStorage,刷新后自动恢复。
|
||||||
10. 已新增“汇聚对齐”配置(Between/Middle/Top/Bottom),可控制 target 侧对齐,且 gap 作为源侧基准。
|
10. 已新增“汇聚对齐”配置(Between/Middle/Top/Bottom),可控制 target 侧对齐,且 gap 作为源侧基准。
|
||||||
11. 已优化“无配置初始化映射”:优先按表头别名自动匹配,缺失时按第二行首个数字列兜底。
|
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)
|
二、当前状态(In Progress)
|
||||||
1. 无进行中的代码重构任务。
|
1. 无进行中的代码重构任务。
|
||||||
@@ -33,12 +47,14 @@
|
|||||||
2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。
|
2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。
|
||||||
3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。
|
3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。
|
||||||
4. 小程序仅骨架,尚未接入真实解析、渲染与导出。
|
4. 小程序仅骨架,尚未接入真实解析、渲染与导出。
|
||||||
|
5. 信息日志当前仅展示最近告警(前 8 条),若后续需要完整历史需引入分页或虚拟滚动。
|
||||||
|
|
||||||
四、下一步建议(Next)
|
四、下一步建议(Next)
|
||||||
1. 决策是否引入“目标数值列”能力(先更新 PRD 后实现)。
|
1. 决策是否引入“目标数值列”能力(先更新 PRD 后实现)。
|
||||||
2. 将小程序由骨架升级为可用版本(优先复用 `src/core`)。
|
2. 将小程序由骨架升级为可用版本(优先复用 `src/core`)。
|
||||||
3. 补充更多异常用例测试(空文件、超大文件、乱码表头、极端数值)。
|
3. 补充更多异常用例测试(空文件、超大文件、乱码表头、极端数值)。
|
||||||
4. 评估并处理 dev HTTPS 证书本地耦合问题,降低新环境接入成本。
|
4. 评估并处理 dev HTTPS 证书本地耦合问题,降低新环境接入成本。
|
||||||
|
5. 评估“信息日志”是否需要支持导出或清空操作。
|
||||||
|
|
||||||
五、执行约定(Session Memory Rules)
|
五、执行约定(Session Memory Rules)
|
||||||
1. 新会话开始先读取本文件,再读取 6 份规范文档。
|
1. 新会话开始先读取本文件,再读取 6 份规范文档。
|
||||||
|
|||||||
280
src/App.vue
280
src/App.vue
@@ -92,8 +92,8 @@
|
|||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<section class="left-pane">
|
<section class="left-pane">
|
||||||
<article class="panel block-panel">
|
<article class="panel block-panel select-panel">
|
||||||
<h2>源数据</h2>
|
<img :src="iconSelectTitle" alt="数据选择器" class="panel-title-svg panel-title-select" />
|
||||||
|
|
||||||
<div class="field-block">
|
<div class="field-block">
|
||||||
<button class="field-title-wrap" type="button" @click="toggleSection('sourceData')">
|
<button class="field-title-wrap" type="button" @click="toggleSection('sourceData')">
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
:alt="sectionVisible.sourceData ? '折叠' : '展开'"
|
:alt="sectionVisible.sourceData ? '折叠' : '展开'"
|
||||||
class="expand-icon"
|
class="expand-icon"
|
||||||
/>
|
/>
|
||||||
<h3>数据列</h3>
|
<h3>源数据(link value)</h3>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<template v-if="sectionVisible.sourceData">
|
<template v-if="sectionVisible.sourceData">
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
:alt="sectionVisible.sourceDesc ? '折叠' : '展开'"
|
:alt="sectionVisible.sourceDesc ? '折叠' : '展开'"
|
||||||
class="expand-icon"
|
class="expand-icon"
|
||||||
/>
|
/>
|
||||||
<h3>描述列</h3>
|
<h3>源标签(Source label)</h3>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<template v-if="sectionVisible.sourceDesc">
|
<template v-if="sectionVisible.sourceDesc">
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
:key="`source-desc-${index}`"
|
:key="`source-desc-${index}`"
|
||||||
class="column-row"
|
class="column-row"
|
||||||
>
|
>
|
||||||
<img :src="iconDescription" alt="描述列" class="column-icon" />
|
<img :src="iconDescription" alt="源标签列" class="column-icon" />
|
||||||
<span class="column-label">{{ header }}</span>
|
<span class="column-label">{{ header }}</span>
|
||||||
<button class="select-btn" type="button" @click="toggleSourceDescription(index)">
|
<button class="select-btn" type="button" @click="toggleSourceDescription(index)">
|
||||||
<img
|
<img
|
||||||
@@ -162,10 +162,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel block-panel target-panel">
|
|
||||||
<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')">
|
||||||
<img
|
<img
|
||||||
@@ -173,7 +170,7 @@
|
|||||||
:alt="sectionVisible.targetDesc ? '折叠' : '展开'"
|
:alt="sectionVisible.targetDesc ? '折叠' : '展开'"
|
||||||
class="expand-icon"
|
class="expand-icon"
|
||||||
/>
|
/>
|
||||||
<h3>描述列</h3>
|
<h3>目标标签(target label)</h3>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<template v-if="sectionVisible.targetDesc">
|
<template v-if="sectionVisible.targetDesc">
|
||||||
@@ -183,7 +180,7 @@
|
|||||||
:key="`target-desc-${index}`"
|
:key="`target-desc-${index}`"
|
||||||
class="column-row"
|
class="column-row"
|
||||||
>
|
>
|
||||||
<img :src="iconDescription" alt="描述列" class="column-icon" />
|
<img :src="iconDescription" alt="目标标签列" class="column-icon" />
|
||||||
<span class="column-label">{{ header }}</span>
|
<span class="column-label">{{ header }}</span>
|
||||||
<button class="select-btn" type="button" @click="toggleTargetDescription(index)">
|
<button class="select-btn" type="button" @click="toggleTargetDescription(index)">
|
||||||
<img
|
<img
|
||||||
@@ -200,14 +197,63 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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>
|
||||||
|
|
||||||
<section class="panel preview-panel">
|
<section class="panel preview-panel">
|
||||||
<div class="preview-head">
|
<div class="preview-head">
|
||||||
<h2>桑基图预览</h2>
|
<img :src="iconSankeyViewTitle" alt="桑基图预览" class="panel-title-svg panel-title-preview" />
|
||||||
|
|
||||||
<div class="preview-controls">
|
<div class="preview-controls">
|
||||||
<label class="slider-label">
|
<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" />
|
<img :src="iconGap" alt="间距" class="slider-icon" />
|
||||||
<div class="slider-track-wrap">
|
<div class="slider-track-wrap">
|
||||||
<span class="slider-value" :style="getSliderValueStyle(nodeGap, 0, 30)">{{
|
<span class="slider-value" :style="getSliderValueStyle(nodeGap, 0, 30)">{{
|
||||||
@@ -223,7 +269,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="slider-label">
|
<label class="slider-label desktop-only">
|
||||||
<img :src="iconPadding" alt="边距" class="slider-icon" />
|
<img :src="iconPadding" alt="边距" class="slider-icon" />
|
||||||
<div class="slider-track-wrap">
|
<div class="slider-track-wrap">
|
||||||
<span class="slider-value" :style="getSliderValueStyle(chartPadding, 0, 80)">
|
<span class="slider-value" :style="getSliderValueStyle(chartPadding, 0, 80)">
|
||||||
@@ -239,15 +285,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button class="direction-control" type="button" @click="toggleDirection">
|
<button class="direction-control desktop-only" type="button" @click="toggleDirection">
|
||||||
<span class="direction-label">方向</span>
|
<span class="direction-label">方向</span>
|
||||||
<span class="direction-switch" :class="{ on: direction === 'source-to-target' }">
|
<span class="direction-switch" :class="{ on: direction === 'source-to-target' }">
|
||||||
<span class="direction-switch-text">
|
<span class="direction-switch-text">
|
||||||
{{ direction === 'source-to-target' ? 'source' : 'target' }}
|
{{ direction === 'source-to-target' ? '源' : '目标' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="direction-switch-thumb" />
|
<span class="direction-switch-thumb" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
<label class="label-position-control">
|
<label class="label-position-control">
|
||||||
<span class="label-position-label">标签位置</span>
|
<span class="label-position-label">标签位置</span>
|
||||||
<select v-model="labelPositionMode" class="label-position-select">
|
<select v-model="labelPositionMode" class="label-position-select">
|
||||||
@@ -275,17 +322,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -337,6 +374,9 @@ 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 iconGap from '../assets/icons/gap.svg';
|
||||||
import iconPadding from '../assets/icons/padding.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]);
|
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 themes = THEME_PRESETS;
|
||||||
const selectedThemeId = ref<string>('figma-violet');
|
const selectedThemeId = ref<string>('figma-violet');
|
||||||
const showThemePicker = ref(false);
|
const showThemePicker = ref(false);
|
||||||
@@ -383,6 +430,7 @@ const themeTriggerRef = ref<HTMLDivElement | null>(null);
|
|||||||
const themePopoverRef = ref<HTMLDivElement | null>(null);
|
const themePopoverRef = ref<HTMLDivElement | null>(null);
|
||||||
let chartInstance: echarts.EChartsType | null = null;
|
let chartInstance: echarts.EChartsType | null = null;
|
||||||
let themeWheelRafId = 0;
|
let themeWheelRafId = 0;
|
||||||
|
let chartResizeObserver: ResizeObserver | null = null;
|
||||||
const themePopoverStyle = ref<CSSProperties>({});
|
const themePopoverStyle = ref<CSSProperties>({});
|
||||||
|
|
||||||
const THEME_ROW_HEIGHT = 42;
|
const THEME_ROW_HEIGHT = 42;
|
||||||
@@ -391,6 +439,7 @@ const rawTable = ref<RawTable | null>(null);
|
|||||||
const buildResult = ref<SankeyBuildResult | null>(null);
|
const buildResult = ref<SankeyBuildResult | null>(null);
|
||||||
const uploadedFileSnapshot = ref<PersistedUploadedFile | null>(null);
|
const uploadedFileSnapshot = ref<PersistedUploadedFile | null>(null);
|
||||||
const isRestoringWorkspace = ref(false);
|
const isRestoringWorkspace = ref(false);
|
||||||
|
const isPhoneViewport = ref(false);
|
||||||
|
|
||||||
const mapping = reactive<MappingConfig>({
|
const mapping = reactive<MappingConfig>({
|
||||||
sourceDataColumn: null,
|
sourceDataColumn: null,
|
||||||
@@ -402,20 +451,31 @@ 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);
|
||||||
|
/**
|
||||||
|
* 桑基图上下额外留白,避免 gap 变大时首尾节点/标签轻微溢出。
|
||||||
|
*/
|
||||||
|
const CHART_TOP_SAFE_PADDING = 8;
|
||||||
|
const CHART_BOTTOM_SAFE_PADDING = 8;
|
||||||
const labelPositionMode = ref<LabelPositionMode>('inner');
|
const labelPositionMode = ref<LabelPositionMode>('inner');
|
||||||
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
|
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
|
||||||
{ value: 'inner', label: '向内' },
|
{ value: 'inner', label: '内' },
|
||||||
{ value: 'outer', label: '向外' },
|
{ value: 'outer', label: '外' },
|
||||||
{ value: 'left', label: '向左' },
|
{ value: 'left', label: '左' },
|
||||||
{ value: 'right', label: '向右' }
|
{ value: 'right', label: '右' }
|
||||||
];
|
];
|
||||||
const targetAlignMode = ref<TargetAlignMode>('between');
|
const targetAlignMode = ref<TargetAlignMode>('between');
|
||||||
const targetAlignOptions: Array<{ value: TargetAlignMode; label: string }> = [
|
const targetAlignOptions: Array<{ value: TargetAlignMode; label: string }> = [
|
||||||
{ value: 'between', label: 'Between' },
|
{ value: 'between', label: '两端' },
|
||||||
{ value: 'middle', label: 'Middle' },
|
{ value: 'middle', label: '中间' },
|
||||||
{ value: 'top', label: 'Top' },
|
{ value: 'top', label: '顶部' },
|
||||||
{ value: 'bottom', label: 'Bottom' }
|
{ 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 表示折叠。
|
* true 表示展开,false 表示折叠。
|
||||||
@@ -443,6 +503,98 @@ const columnHeaders = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
|
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 }>
|
links: Array<{ source: string; target: string; value: number }>
|
||||||
): Map<string, number> {
|
): Map<string, number> {
|
||||||
const map = new 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) {
|
if (layoutHeight <= 0 || links.length === 0) {
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -619,9 +777,9 @@ const chartOption = computed<EChartsOption>(() => {
|
|||||||
{
|
{
|
||||||
type: 'sankey',
|
type: 'sankey',
|
||||||
left: chartPadding.value,
|
left: chartPadding.value,
|
||||||
top: chartPadding.value,
|
top: chartPadding.value + CHART_TOP_SAFE_PADDING,
|
||||||
right: chartPadding.value,
|
right: chartPadding.value,
|
||||||
bottom: chartPadding.value,
|
bottom: chartPadding.value + CHART_BOTTOM_SAFE_PADDING,
|
||||||
nodeAlign: 'justify',
|
nodeAlign: 'justify',
|
||||||
nodeGap: nodeGap.value,
|
nodeGap: nodeGap.value,
|
||||||
nodeWidth: 14,
|
nodeWidth: 14,
|
||||||
@@ -717,10 +875,50 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function syncChartSize(): void {
|
function syncChartSize(): void {
|
||||||
|
updateAppViewportHeight();
|
||||||
|
updatePhoneViewportFlag();
|
||||||
chartClientHeight.value = chartRef.value?.clientHeight ?? chartClientHeight.value;
|
chartClientHeight.value = chartRef.value?.clientHeight ?? chartClientHeight.value;
|
||||||
chartInstance?.resize();
|
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 {
|
function closeThemePicker(): void {
|
||||||
showThemePicker.value = false;
|
showThemePicker.value = false;
|
||||||
}
|
}
|
||||||
@@ -1372,9 +1570,13 @@ onMounted(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAppViewportHeight();
|
||||||
|
updatePhoneViewportFlag();
|
||||||
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
|
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
|
||||||
chartClientHeight.value = container.clientHeight;
|
chartClientHeight.value = container.clientHeight;
|
||||||
chartInstance.setOption(chartOption.value);
|
chartInstance.setOption(chartOption.value);
|
||||||
|
observeChartContainerSize();
|
||||||
|
syncChartSize();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { restoredUploadedFile, restoredMapping } = await restoreWorkspaceFromStorage();
|
const { restoredUploadedFile, restoredMapping } = await restoreWorkspaceFromStorage();
|
||||||
if (restoredUploadedFile) {
|
if (restoredUploadedFile) {
|
||||||
@@ -1387,6 +1589,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
window.addEventListener('resize', syncChartSize);
|
window.addEventListener('resize', syncChartSize);
|
||||||
|
window.visualViewport?.addEventListener('resize', syncChartSize);
|
||||||
|
window.visualViewport?.addEventListener('scroll', syncChartSize);
|
||||||
window.addEventListener('resize', updateThemePopoverPosition);
|
window.addEventListener('resize', updateThemePopoverPosition);
|
||||||
window.addEventListener('scroll', updateThemePopoverPosition, true);
|
window.addEventListener('scroll', updateThemePopoverPosition, true);
|
||||||
window.addEventListener('pointerdown', handleGlobalPointerDown);
|
window.addEventListener('pointerdown', handleGlobalPointerDown);
|
||||||
@@ -1394,9 +1598,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', syncChartSize);
|
window.removeEventListener('resize', syncChartSize);
|
||||||
|
window.visualViewport?.removeEventListener('resize', syncChartSize);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', syncChartSize);
|
||||||
window.removeEventListener('resize', updateThemePopoverPosition);
|
window.removeEventListener('resize', updateThemePopoverPosition);
|
||||||
window.removeEventListener('scroll', updateThemePopoverPosition, true);
|
window.removeEventListener('scroll', updateThemePopoverPosition, true);
|
||||||
window.removeEventListener('pointerdown', handleGlobalPointerDown);
|
window.removeEventListener('pointerdown', handleGlobalPointerDown);
|
||||||
|
chartResizeObserver?.disconnect();
|
||||||
|
chartResizeObserver = null;
|
||||||
chartInstance?.dispose();
|
chartInstance?.dispose();
|
||||||
chartInstance = null;
|
chartInstance = null;
|
||||||
if (themeWheelRafId !== 0) {
|
if (themeWheelRafId !== 0) {
|
||||||
|
|||||||
@@ -31,6 +31,34 @@ function parseNumericValue(text: string): number | null {
|
|||||||
return parsed;
|
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 名称。
|
* 按照配置生成 source 名称。
|
||||||
* 若未选择描述列,则回退为数据列文本。
|
* 若未选择描述列,则回退为数据列文本。
|
||||||
@@ -87,30 +115,48 @@ export function buildSankeyData(table: RawTable, config: MappingConfig): SankeyB
|
|||||||
const linkValueMap = new Map<string, number>();
|
const linkValueMap = new Map<string, number>();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let droppedRows = 0;
|
let droppedRows = 0;
|
||||||
|
const sourceDataColumnIndex = config.sourceDataColumn;
|
||||||
|
const sourceDataColumnName =
|
||||||
|
table.headers[sourceDataColumnIndex] ?? `列${sourceDataColumnIndex + 1}`;
|
||||||
|
|
||||||
const lastNonEmptyTargetValueByColumn = new Map<number, string>();
|
const lastNonEmptyTargetValueByColumn = new Map<number, string>();
|
||||||
|
|
||||||
table.rows.forEach((row, rowIndex) => {
|
table.rows.forEach((row, rowIndex) => {
|
||||||
const excelRow = rowIndex + 2;
|
const excelRow = rowIndex + 2;
|
||||||
const sourceRaw = normalizeText(row[config.sourceDataColumn as number] ?? '');
|
const sourceCellRaw = row[sourceDataColumnIndex] ?? '';
|
||||||
|
const sourceRaw = normalizeText(sourceCellRaw);
|
||||||
const sourceValue = parseNumericValue(sourceRaw);
|
const sourceValue = parseNumericValue(sourceRaw);
|
||||||
|
|
||||||
if (sourceValue === null) {
|
if (sourceValue === null) {
|
||||||
warnings.push(`第 ${excelRow} 行: 源数据不是有效数字,已跳过`);
|
warnings.push(
|
||||||
|
`第 ${excelRow} 行, 第 ${sourceDataColumnIndex + 1} 列(${sourceDataColumnName}): 源数据不是有效数字,原始值="${formatCellValueForWarning(sourceCellRaw)}",已跳过`
|
||||||
|
);
|
||||||
droppedRows += 1;
|
droppedRows += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceName = buildSourceName(row, config);
|
const sourceName = buildSourceName(row, config);
|
||||||
if (!sourceName) {
|
if (!sourceName) {
|
||||||
warnings.push(`第 ${excelRow} 行: 源描述为空,已跳过`);
|
const sourceDescDebugText = buildColumnDebugText(
|
||||||
|
row,
|
||||||
|
table.headers,
|
||||||
|
config.sourceDescriptionColumns
|
||||||
|
);
|
||||||
|
warnings.push(`第 ${excelRow} 行: 源描述为空,字段=${sourceDescDebugText},已跳过`);
|
||||||
droppedRows += 1;
|
droppedRows += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetName = buildTargetName(row, config, lastNonEmptyTargetValueByColumn);
|
const targetName = buildTargetName(row, config, lastNonEmptyTargetValueByColumn);
|
||||||
if (!targetName) {
|
if (!targetName) {
|
||||||
warnings.push(`第 ${excelRow} 行: 目标描述为空,已跳过`);
|
const targetDescDebugText = buildColumnDebugText(
|
||||||
|
row,
|
||||||
|
table.headers,
|
||||||
|
config.targetDescriptionColumns
|
||||||
|
);
|
||||||
|
warnings.push(
|
||||||
|
`第 ${excelRow} 行: 目标描述为空,字段=${targetDescDebugText},且无可继承的上方值,已跳过`
|
||||||
|
);
|
||||||
droppedRows += 1;
|
droppedRows += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
293
src/styles.css
293
src/styles.css
@@ -31,8 +31,11 @@ body {
|
|||||||
.page {
|
.page {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
height: calc(var(--app-vh, 1vh) * 100);
|
||||||
padding: 16px 16px 10px;
|
padding: 16px 16px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
@@ -280,13 +283,15 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 360px 1fr;
|
grid-template-columns: 360px 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: calc(100vh - 140px);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-pane {
|
.left-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -296,22 +301,35 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-panel {
|
.block-panel {
|
||||||
padding: 8px;
|
padding: 8px 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-panel {
|
.select-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-panel h2 {
|
.panel-title-svg {
|
||||||
margin: 0;
|
display: block;
|
||||||
font-size: 24px;
|
max-width: 100%;
|
||||||
font-weight: 400;
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title-select {
|
||||||
|
width: 189px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title-preview {
|
||||||
|
width: 238px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title-info {
|
||||||
|
width: 186px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-block {
|
.field-block {
|
||||||
margin-top: 12px;
|
margin-top: 14px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +435,7 @@ body {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-head {
|
.preview-head {
|
||||||
@@ -428,20 +447,20 @@ body {
|
|||||||
margin-bottom: 8px;
|
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: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-controls .slider-label {
|
.preview-controls .slider-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -635,6 +654,32 @@ body {
|
|||||||
line-height: 22px;
|
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 {
|
.example-line {
|
||||||
margin: 4px 0 8px;
|
margin: 4px 0 8px;
|
||||||
color: var(--text-4);
|
color: var(--text-4);
|
||||||
@@ -644,29 +689,53 @@ body {
|
|||||||
.chart-area {
|
.chart-area {
|
||||||
background: var(--fill-1);
|
background: var(--fill-1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-height: 480px;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning-area {
|
.info-panel {
|
||||||
margin-top: 8px;
|
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;
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
color: var(--text-4);
|
color: var(--text-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning-area ul {
|
.info-log-item.level-warn {
|
||||||
margin: 6px 0 0;
|
color: #8d5b00;
|
||||||
padding-left: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
.info-log-item.level-error {
|
||||||
color: #cb272d;
|
color: #cb272d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-log-empty {
|
||||||
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-bottom: 6px;
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -679,49 +748,90 @@ body {
|
|||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
justify-content: flex-start;
|
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 {
|
.content {
|
||||||
grid-template-columns: 1fr;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-panel {
|
||||||
|
order: 1;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-pane {
|
.left-pane {
|
||||||
flex-direction: row;
|
order: 2;
|
||||||
flex-wrap: wrap;
|
min-height: 0;
|
||||||
|
max-height: 42%;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-panel {
|
.block-panel {
|
||||||
flex: 1;
|
min-height: auto;
|
||||||
min-width: 280px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-area {
|
.chart-area {
|
||||||
min-height: 360px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-head {
|
.preview-head {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
}
|
gap: 8px;
|
||||||
|
|
||||||
.preview-head h2 {
|
|
||||||
width: auto;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-controls {
|
.preview-controls {
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 12px;
|
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 {
|
.label-position-control {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-align-control {
|
.target-align-control {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
max-height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
@@ -749,18 +859,107 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-area {
|
.upload-area {
|
||||||
min-width: 220px;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-panel h2 {
|
.top-bar {
|
||||||
font-size: 20px;
|
padding: 0;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-head h2 {
|
.toolbar {
|
||||||
font-size: 24px;
|
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 {
|
.field-title-wrap h3 {
|
||||||
font-size: 16px;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,4 +44,65 @@ describe('core parser & sankey', () => {
|
|||||||
expect(table.headers.length).toBeGreaterThan(1);
|
expect(table.headers.length).toBeGreaterThan(1);
|
||||||
expect(table.rows.length).toBeGreaterThan(0);
|
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('无可继承的上方值');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user