From bb3a64b6245775404457777af252559a2d447b6c Mon Sep 17 00:00:00 2001 From: "douboer@gmail.com" Date: Fri, 13 Feb 2026 15:02:51 +0800 Subject: [PATCH] update at 2026-02-13 15:02:51 --- progress.txt | 6 +- src/App.vue | 448 ++++++++++++++++++++++++++++++++++++++++++++++++- src/styles.css | 30 ++++ 3 files changed, 478 insertions(+), 6 deletions(-) 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; }