diff --git a/progress.txt b/progress.txt
index e946da2..a0d68f4 100644
--- a/progress.txt
+++ b/progress.txt
@@ -1,4 +1,4 @@
-[更新时间] 2026-02-13
+[更新时间] 2026-02-13(第二次更新)
[项目] 星程桑基图
一、已完成(Done)
@@ -14,6 +14,8 @@
6. 已实现默认样例加载:页面首次进入自动读取 `data/example0.xlsx`。
7. 已有核心单测(parser + sankey 聚合 + xlsx 读取)。
8. 小程序端已完成视觉骨架(非完整业务)。
+9. 已实现本地持久化:用户上传文件、映射配置与预览选项会写入 localStorage,刷新后自动恢复。
+10. 已新增“汇聚对齐”配置(Between/Middle/Top/Bottom),可控制 target 侧对齐,且 gap 作为源侧基准。
二、当前状态(In Progress)
1. 无进行中的代码重构任务。
@@ -26,7 +28,7 @@
- IMPLEMENTATION_PLAN.md
三、已知问题 / 风险(Known Issues)
-1. 当前无后端、无持久化,刷新页面后状态丢失(符合当前范围)。
+1. 本地持久化基于 localStorage,受浏览器容量限制;超大文件可能无法完整保存。
2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。
3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。
4. 小程序仅骨架,尚未接入真实解析、渲染与导出。
diff --git a/src/App.vue b/src/App.vue
index 552be6b..dbebda7 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -260,6 +260,18 @@
+
@@ -301,7 +313,7 @@ import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
import {
applyDirection,
buildSankeyData,
- parseDataFile,
+ parseCsvText,
parseXlsxBuffer,
type MappingConfig,
type RawTable,
@@ -328,6 +340,34 @@ import iconPadding from '../assets/icons/padding.svg';
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
+const WORKSPACE_STORAGE_KEY = 'sankey-workspace-v1';
+const WORKSPACE_STORAGE_VERSION = 1;
+type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
+type TargetAlignMode = 'between' | 'middle' | 'top' | 'bottom';
+
+interface PersistedUploadedFile {
+ name: string;
+ type: string;
+ base64: string;
+}
+
+interface PersistedWorkspace {
+ version: number;
+ uploadedFile?: PersistedUploadedFile;
+ mapping?: MappingConfig;
+ direction?: 'source-to-target' | 'target-to-source';
+ nodeGap?: number;
+ chartPadding?: number;
+ selectedThemeId?: string;
+ labelPositionMode?: LabelPositionMode;
+ targetAlignMode?: TargetAlignMode;
+ sectionVisible?: Partial<{
+ sourceData: boolean;
+ sourceDesc: boolean;
+ targetDesc: boolean;
+ }>;
+}
+
const themes = THEME_PRESETS;
const selectedThemeId = ref('figma-violet');
const showThemePicker = ref(false);
@@ -336,6 +376,7 @@ const parseError = ref('');
const buildError = ref('');
const chartRef = ref(null);
+const chartClientHeight = ref(0);
const fileInputRef = ref(null);
const themeWheelRef = ref(null);
const themeTriggerRef = ref(null);
@@ -348,6 +389,8 @@ const THEME_ROW_HEIGHT = 42;
const rawTable = ref(null);
const buildResult = ref(null);
+const uploadedFileSnapshot = ref(null);
+const isRestoringWorkspace = ref(false);
const mapping = reactive({
sourceDataColumn: 2,
@@ -359,7 +402,6 @@ const mapping = reactive({
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(5);
const chartPadding = ref(24);
-type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
const labelPositionMode = ref('inner');
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
{ value: 'inner', label: '向内' },
@@ -367,6 +409,13 @@ const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> =
{ value: 'left', label: '向左' },
{ value: 'right', label: '向右' }
];
+const targetAlignMode = ref('between');
+const targetAlignOptions: Array<{ value: TargetAlignMode; label: string }> = [
+ { value: 'between', label: 'Between' },
+ { value: 'middle', label: 'Middle' },
+ { value: 'top', label: 'Top' },
+ { value: 'bottom', label: 'Bottom' }
+];
/**
* 左侧字段区块的展开/折叠状态。
* true 表示展开,false 表示折叠。
@@ -426,11 +475,103 @@ function resolveEdgeLabelPosition(
return isLeftEdge ? 'left' : 'right';
}
+/**
+ * 计算 target 侧节点 localY(0~1)。
+ * - gap 滑块只作为源侧基准
+ * - target 侧通过“汇聚对齐”决定布局
+ */
+function buildTargetLocalYMap(
+ links: Array<{ source: string; target: string; value: number }>
+): Map {
+ const map = new Map();
+ const layoutHeight = Math.max(0, chartClientHeight.value - chartPadding.value * 2);
+ if (layoutHeight <= 0 || links.length === 0) {
+ return map;
+ }
+
+ const incomingCountMap = new Map();
+ const outgoingCountMap = new Map();
+ const sourceValueMap = new Map();
+ const targetValueMap = new Map();
+ const appearanceOrder: string[] = [];
+ const seen = new Set();
+
+ links.forEach((link) => {
+ outgoingCountMap.set(link.source, (outgoingCountMap.get(link.source) ?? 0) + 1);
+ incomingCountMap.set(link.target, (incomingCountMap.get(link.target) ?? 0) + 1);
+ sourceValueMap.set(link.source, (sourceValueMap.get(link.source) ?? 0) + link.value);
+ targetValueMap.set(link.target, (targetValueMap.get(link.target) ?? 0) + link.value);
+
+ if (!seen.has(link.source)) {
+ seen.add(link.source);
+ appearanceOrder.push(link.source);
+ }
+ if (!seen.has(link.target)) {
+ seen.add(link.target);
+ appearanceOrder.push(link.target);
+ }
+ });
+
+ const sourceNames = appearanceOrder.filter((name) => {
+ const incoming = incomingCountMap.get(name) ?? 0;
+ const outgoing = outgoingCountMap.get(name) ?? 0;
+ return incoming === 0 && outgoing > 0;
+ });
+ const targetNames = appearanceOrder.filter((name) => {
+ const incoming = incomingCountMap.get(name) ?? 0;
+ const outgoing = outgoingCountMap.get(name) ?? 0;
+ return outgoing === 0 && incoming > 0;
+ });
+
+ if (sourceNames.length === 0 || targetNames.length === 0) {
+ return map;
+ }
+
+ const totalValue = sourceNames.reduce((sum, name) => sum + (sourceValueMap.get(name) ?? 0), 0);
+ if (totalValue <= 0) {
+ return map;
+ }
+
+ const sourceGap = Math.max(0, nodeGap.value);
+ const sourceGapCount = Math.max(0, sourceNames.length - 1);
+ const sourceContentHeight = Math.max(0, layoutHeight - sourceGap * sourceGapCount);
+ const unitHeight = totalValue > 0 ? sourceContentHeight / totalValue : 0;
+ const totalNodeHeight = totalValue * unitHeight;
+ const sourceSpanHeight = totalNodeHeight + sourceGap * sourceGapCount;
+
+ let targetGap = sourceGap;
+ if (targetAlignMode.value === 'between' && targetNames.length > 1) {
+ targetGap = (sourceSpanHeight - totalNodeHeight) / (targetNames.length - 1);
+ }
+ targetGap = Math.max(0, targetGap);
+
+ const targetSpanHeight = totalNodeHeight + targetGap * Math.max(0, targetNames.length - 1);
+ let targetStartY = 0;
+ if (targetAlignMode.value === 'middle') {
+ targetStartY = (sourceSpanHeight - targetSpanHeight) / 2;
+ } else if (targetAlignMode.value === 'bottom') {
+ targetStartY = sourceSpanHeight - targetSpanHeight;
+ }
+ targetStartY = Math.max(0, targetStartY);
+
+ let cursorY = targetStartY;
+ targetNames.forEach((name, index) => {
+ map.set(name, cursorY / layoutHeight);
+ cursorY += (targetValueMap.get(name) ?? 0) * unitHeight;
+ if (index < targetNames.length - 1) {
+ cursorY += targetGap;
+ }
+ });
+
+ return map;
+}
+
const chartNodes = computed(() => {
const links = chartLinks.value;
const names = new Set();
const incomingCountMap = new Map();
const outgoingCountMap = new Map();
+ const targetLocalYMap = buildTargetLocalYMap(links);
links.forEach((link) => {
names.add(link.source);
@@ -450,6 +591,7 @@ const chartNodes = computed(() => {
itemStyle: {
color: palette[index % palette.length]
},
+ localY: targetLocalYMap.get(name),
label: position
? {
position
@@ -548,7 +690,34 @@ watch(
{ deep: true }
);
+watch(
+ () => [
+ mapping.sourceDataColumn,
+ mapping.sourceDescriptionColumns.join(','),
+ mapping.targetDescriptionColumns.join(','),
+ mapping.delimiter,
+ direction.value,
+ nodeGap.value,
+ chartPadding.value,
+ selectedThemeId.value,
+ labelPositionMode.value,
+ targetAlignMode.value,
+ sectionVisible.sourceData,
+ sectionVisible.sourceDesc,
+ sectionVisible.targetDesc,
+ uploadedFileSnapshot.value?.name ?? '',
+ uploadedFileSnapshot.value?.base64.length ?? 0
+ ],
+ () => {
+ if (isRestoringWorkspace.value) {
+ return;
+ }
+ persistWorkspace();
+ }
+);
+
function syncChartSize(): void {
+ chartClientHeight.value = chartRef.value?.clientHeight ?? chartClientHeight.value;
chartInstance?.resize();
}
@@ -788,6 +957,262 @@ function setDefaultMappingByHeaders(headers: string[]): void {
);
}
+/**
+ * 将映射配置整体应用到响应式对象,避免散落赋值导致状态不一致。
+ */
+function applyMappingConfig(next: MappingConfig): void {
+ mapping.sourceDataColumn = next.sourceDataColumn;
+ mapping.sourceDescriptionColumns = [...next.sourceDescriptionColumns];
+ mapping.targetDescriptionColumns = [...next.targetDescriptionColumns];
+ mapping.delimiter = next.delimiter;
+}
+
+/**
+ * 根据文件名解析上传内容,统一返回 RawTable。
+ */
+function parseTableByFileName(fileName: string, buffer: ArrayBuffer): RawTable {
+ const lowerName = fileName.toLowerCase();
+ if (lowerName.endsWith('.csv')) {
+ const csvText = new TextDecoder().decode(buffer);
+ return parseCsvText(csvText);
+ }
+
+ if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
+ return parseXlsxBuffer(buffer);
+ }
+
+ throw new Error('仅支持 .csv / .xlsx / .xls 文件');
+}
+
+/**
+ * 将 ArrayBuffer 编码为 Base64,便于写入 localStorage。
+ */
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ const chunkSize = 0x8000;
+ let binary = '';
+
+ for (let index = 0; index < bytes.length; index += chunkSize) {
+ const chunk = bytes.subarray(index, index + chunkSize);
+ binary += String.fromCharCode(...chunk);
+ }
+
+ return btoa(binary);
+}
+
+/**
+ * 将 Base64 解码回 ArrayBuffer。
+ */
+function base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index);
+ }
+
+ return bytes.buffer;
+}
+
+/**
+ * 将上传文件快照化,写入持久层用于刷新后恢复。
+ */
+function createUploadedFileSnapshot(
+ fileName: string,
+ fileType: string,
+ fileBuffer: ArrayBuffer
+): PersistedUploadedFile {
+ return {
+ name: fileName,
+ type: fileType,
+ base64: arrayBufferToBase64(fileBuffer)
+ };
+}
+
+/**
+ * 对持久层中的 mapping 做安全化处理,避免列越界导致恢复失败。
+ */
+function sanitizePersistedMapping(
+ persistedMapping: MappingConfig | undefined,
+ columnSize: number
+): MappingConfig {
+ const safeSize = Math.max(columnSize, 1);
+ const safeColumn = (index: number): number => Math.min(Math.max(index, 0), safeSize - 1);
+ const normalizeColumns = (columns: unknown): number[] => {
+ if (!Array.isArray(columns)) {
+ return [];
+ }
+
+ return columns
+ .filter((item): item is number => Number.isInteger(item))
+ .map((item) => safeColumn(item))
+ .filter((item, index, list) => list.indexOf(item) === index)
+ .sort((a, b) => a - b);
+ };
+
+ const defaultMapping: MappingConfig = {
+ sourceDataColumn: safeColumn(2),
+ sourceDescriptionColumns: [safeColumn(0)].filter(
+ (item, index, list) => list.indexOf(item) === index
+ ),
+ targetDescriptionColumns: [safeColumn(2)].filter(
+ (item, index, list) => list.indexOf(item) === index
+ ),
+ delimiter: '-'
+ };
+
+ if (!persistedMapping) {
+ return defaultMapping;
+ }
+
+ const sourceDataColumn =
+ typeof persistedMapping.sourceDataColumn === 'number' && Number.isInteger(persistedMapping.sourceDataColumn)
+ ? safeColumn(persistedMapping.sourceDataColumn)
+ : defaultMapping.sourceDataColumn;
+ const sourceDescriptionColumns = normalizeColumns(persistedMapping.sourceDescriptionColumns);
+ const targetDescriptionColumns = normalizeColumns(persistedMapping.targetDescriptionColumns);
+
+ return {
+ sourceDataColumn,
+ sourceDescriptionColumns,
+ targetDescriptionColumns:
+ targetDescriptionColumns.length > 0 ? targetDescriptionColumns : defaultMapping.targetDescriptionColumns,
+ delimiter:
+ typeof persistedMapping.delimiter === 'string' && persistedMapping.delimiter.length > 0
+ ? persistedMapping.delimiter
+ : '-'
+ };
+}
+
+/**
+ * 保存当前工作区(文件 + 映射 + 控件状态),用于刷新后恢复。
+ */
+function persistWorkspace(): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const workspace: PersistedWorkspace = {
+ version: WORKSPACE_STORAGE_VERSION,
+ uploadedFile: uploadedFileSnapshot.value ?? undefined,
+ mapping: {
+ sourceDataColumn: mapping.sourceDataColumn,
+ sourceDescriptionColumns: [...mapping.sourceDescriptionColumns],
+ targetDescriptionColumns: [...mapping.targetDescriptionColumns],
+ delimiter: mapping.delimiter
+ },
+ direction: direction.value,
+ nodeGap: nodeGap.value,
+ chartPadding: chartPadding.value,
+ selectedThemeId: selectedThemeId.value,
+ labelPositionMode: labelPositionMode.value,
+ targetAlignMode: targetAlignMode.value,
+ sectionVisible: {
+ sourceData: sectionVisible.sourceData,
+ sourceDesc: sectionVisible.sourceDesc,
+ targetDesc: sectionVisible.targetDesc
+ }
+ };
+
+ try {
+ window.localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify(workspace));
+ } catch (error) {
+ // localStorage 可能因为容量上限失败,记录日志便于排查,但不阻断主流程。
+ console.warn('工作区保存失败:', error);
+ }
+}
+
+/**
+ * 从持久层恢复工作区。
+ * 返回值表示是否恢复了“用户上传文件”。
+ */
+async function restoreWorkspaceFromStorage(): Promise<{
+ restoredUploadedFile: boolean;
+ restoredMapping?: MappingConfig;
+}> {
+ if (typeof window === 'undefined') {
+ return { restoredUploadedFile: false };
+ }
+
+ const rawWorkspace = window.localStorage.getItem(WORKSPACE_STORAGE_KEY);
+ if (!rawWorkspace) {
+ return { restoredUploadedFile: false };
+ }
+
+ isRestoringWorkspace.value = true;
+ try {
+ const workspace = JSON.parse(rawWorkspace) as PersistedWorkspace;
+ if (workspace.version !== WORKSPACE_STORAGE_VERSION) {
+ return { restoredUploadedFile: false };
+ }
+
+ if (workspace.direction === 'source-to-target' || workspace.direction === 'target-to-source') {
+ direction.value = workspace.direction;
+ }
+
+ if (typeof workspace.nodeGap === 'number' && Number.isFinite(workspace.nodeGap)) {
+ nodeGap.value = Math.max(0, Math.min(30, workspace.nodeGap));
+ }
+
+ if (typeof workspace.chartPadding === 'number' && Number.isFinite(workspace.chartPadding)) {
+ chartPadding.value = Math.max(0, Math.min(80, workspace.chartPadding));
+ }
+
+ if (typeof workspace.selectedThemeId === 'string' && themes.some((item) => item.id === workspace.selectedThemeId)) {
+ selectedThemeId.value = workspace.selectedThemeId;
+ }
+
+ if (
+ workspace.labelPositionMode === 'inner' ||
+ workspace.labelPositionMode === 'outer' ||
+ workspace.labelPositionMode === 'left' ||
+ workspace.labelPositionMode === 'right'
+ ) {
+ labelPositionMode.value = workspace.labelPositionMode;
+ }
+
+ if (
+ workspace.targetAlignMode === 'between' ||
+ workspace.targetAlignMode === 'middle' ||
+ workspace.targetAlignMode === 'top' ||
+ workspace.targetAlignMode === 'bottom'
+ ) {
+ targetAlignMode.value = workspace.targetAlignMode;
+ }
+
+ if (workspace.sectionVisible) {
+ if (typeof workspace.sectionVisible.sourceData === 'boolean') {
+ sectionVisible.sourceData = workspace.sectionVisible.sourceData;
+ }
+ if (typeof workspace.sectionVisible.sourceDesc === 'boolean') {
+ sectionVisible.sourceDesc = workspace.sectionVisible.sourceDesc;
+ }
+ if (typeof workspace.sectionVisible.targetDesc === 'boolean') {
+ sectionVisible.targetDesc = workspace.sectionVisible.targetDesc;
+ }
+ }
+
+ if (!workspace.uploadedFile) {
+ return { restoredUploadedFile: false, restoredMapping: workspace.mapping };
+ }
+
+ const fileBuffer = base64ToArrayBuffer(workspace.uploadedFile.base64);
+ const parsed = parseTableByFileName(workspace.uploadedFile.name, fileBuffer);
+ rawTable.value = parsed;
+ uploadedFileSnapshot.value = workspace.uploadedFile;
+ applyMappingConfig(sanitizePersistedMapping(workspace.mapping, parsed.headers.length));
+ uploadMessage.value = `已恢复: ${workspace.uploadedFile.name}(${parsed.rows.length} 行)`;
+ parseError.value = '';
+ return { restoredUploadedFile: true };
+ } catch (error) {
+ window.localStorage.removeItem(WORKSPACE_STORAGE_KEY);
+ console.warn('工作区恢复失败,已清理坏数据:', error);
+ return { restoredUploadedFile: false };
+ } finally {
+ isRestoringWorkspace.value = false;
+ }
+}
+
/**
* 统一处理上传文件,支持点击上传和拖拽上传两种入口。
*/
@@ -795,10 +1220,13 @@ async function loadDataFile(file: File): Promise {
parseError.value = '';
try {
- const parsed = await parseDataFile(file);
+ const fileBuffer = await file.arrayBuffer();
+ const parsed = parseTableByFileName(file.name, fileBuffer);
rawTable.value = parsed;
setDefaultMappingByHeaders(parsed.headers);
+ uploadedFileSnapshot.value = createUploadedFileSnapshot(file.name, file.type, fileBuffer);
uploadMessage.value = `已加载: ${file.name}(${parsed.rows.length} 行)`;
+ persistWorkspace();
} catch (error) {
parseError.value = error instanceof Error ? error.message : '文件解析失败';
}
@@ -842,6 +1270,7 @@ async function loadDefaultExampleFile(): Promise {
const parsed = parseXlsxBuffer(buffer);
rawTable.value = parsed;
setDefaultMappingByHeaders(parsed.headers);
+ uploadedFileSnapshot.value = null;
uploadMessage.value = `已加载: example0.xlsx(${parsed.rows.length} 行)`;
parseError.value = '';
} catch (error) {
@@ -920,8 +1349,19 @@ onMounted(() => {
}
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
+ chartClientHeight.value = container.clientHeight;
chartInstance.setOption(chartOption.value);
- void loadDefaultExampleFile();
+ void (async () => {
+ const { restoredUploadedFile, restoredMapping } = await restoreWorkspaceFromStorage();
+ if (restoredUploadedFile) {
+ return;
+ }
+
+ await loadDefaultExampleFile();
+ if (restoredMapping && rawTable.value) {
+ applyMappingConfig(sanitizePersistedMapping(restoredMapping, rawTable.value.headers.length));
+ }
+ })();
window.addEventListener('resize', syncChartSize);
window.addEventListener('resize', updateThemePopoverPosition);
window.addEventListener('scroll', updateThemePopoverPosition, true);
diff --git a/src/styles.css b/src/styles.css
index 00d6ea0..4bfe613 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -609,6 +609,32 @@ body {
line-height: 22px;
}
+.target-align-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.target-align-label {
+ font-size: 14px;
+ font-weight: 600;
+ color: #000;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.target-align-select {
+ min-width: 88px;
+ height: 24px;
+ border: 1px solid #c9aee0;
+ border-radius: 2px;
+ background: #fff;
+ color: #1d2129;
+ font-size: 14px;
+ padding: 0 20px 0 6px;
+ line-height: 22px;
+}
+
.example-line {
margin: 4px 0 8px;
color: var(--text-4);
@@ -694,6 +720,10 @@ body {
margin-left: 0;
}
+ .target-align-control {
+ margin-left: 0;
+ }
+
.footer {
font-size: 14px;
}