update at 2026-02-13 15:02:51
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
[更新时间] 2026-02-13
|
[更新时间] 2026-02-13(第二次更新)
|
||||||
[项目] 星程桑基图
|
[项目] 星程桑基图
|
||||||
|
|
||||||
一、已完成(Done)
|
一、已完成(Done)
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
6. 已实现默认样例加载:页面首次进入自动读取 `data/example0.xlsx`。
|
6. 已实现默认样例加载:页面首次进入自动读取 `data/example0.xlsx`。
|
||||||
7. 已有核心单测(parser + sankey 聚合 + xlsx 读取)。
|
7. 已有核心单测(parser + sankey 聚合 + xlsx 读取)。
|
||||||
8. 小程序端已完成视觉骨架(非完整业务)。
|
8. 小程序端已完成视觉骨架(非完整业务)。
|
||||||
|
9. 已实现本地持久化:用户上传文件、映射配置与预览选项会写入 localStorage,刷新后自动恢复。
|
||||||
|
10. 已新增“汇聚对齐”配置(Between/Middle/Top/Bottom),可控制 target 侧对齐,且 gap 作为源侧基准。
|
||||||
|
|
||||||
二、当前状态(In Progress)
|
二、当前状态(In Progress)
|
||||||
1. 无进行中的代码重构任务。
|
1. 无进行中的代码重构任务。
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
- IMPLEMENTATION_PLAN.md
|
- IMPLEMENTATION_PLAN.md
|
||||||
|
|
||||||
三、已知问题 / 风险(Known Issues)
|
三、已知问题 / 风险(Known Issues)
|
||||||
1. 当前无后端、无持久化,刷新页面后状态丢失(符合当前范围)。
|
1. 本地持久化基于 localStorage,受浏览器容量限制;超大文件可能无法完整保存。
|
||||||
2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。
|
2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。
|
||||||
3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。
|
3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。
|
||||||
4. 小程序仅骨架,尚未接入真实解析、渲染与导出。
|
4. 小程序仅骨架,尚未接入真实解析、渲染与导出。
|
||||||
|
|||||||
448
src/App.vue
448
src/App.vue
@@ -260,6 +260,18 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="target-align-control">
|
||||||
|
<span class="target-align-label">汇聚对齐</span>
|
||||||
|
<select v-model="targetAlignMode" class="target-align-select">
|
||||||
|
<option
|
||||||
|
v-for="option in targetAlignOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -301,7 +313,7 @@ import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
|
|||||||
import {
|
import {
|
||||||
applyDirection,
|
applyDirection,
|
||||||
buildSankeyData,
|
buildSankeyData,
|
||||||
parseDataFile,
|
parseCsvText,
|
||||||
parseXlsxBuffer,
|
parseXlsxBuffer,
|
||||||
type MappingConfig,
|
type MappingConfig,
|
||||||
type RawTable,
|
type RawTable,
|
||||||
@@ -328,6 +340,34 @@ import iconPadding from '../assets/icons/padding.svg';
|
|||||||
|
|
||||||
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
|
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 themes = THEME_PRESETS;
|
||||||
const selectedThemeId = ref<string>('figma-violet');
|
const selectedThemeId = ref<string>('figma-violet');
|
||||||
const showThemePicker = ref(false);
|
const showThemePicker = ref(false);
|
||||||
@@ -336,6 +376,7 @@ const parseError = ref('');
|
|||||||
const buildError = ref('');
|
const buildError = ref('');
|
||||||
|
|
||||||
const chartRef = ref<HTMLDivElement | null>(null);
|
const chartRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const chartClientHeight = ref(0);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const themeWheelRef = ref<HTMLDivElement | null>(null);
|
const themeWheelRef = ref<HTMLDivElement | null>(null);
|
||||||
const themeTriggerRef = ref<HTMLDivElement | null>(null);
|
const themeTriggerRef = ref<HTMLDivElement | null>(null);
|
||||||
@@ -348,6 +389,8 @@ const THEME_ROW_HEIGHT = 42;
|
|||||||
|
|
||||||
const rawTable = ref<RawTable | null>(null);
|
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 isRestoringWorkspace = ref(false);
|
||||||
|
|
||||||
const mapping = reactive<MappingConfig>({
|
const mapping = reactive<MappingConfig>({
|
||||||
sourceDataColumn: 2,
|
sourceDataColumn: 2,
|
||||||
@@ -359,7 +402,6 @@ 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);
|
||||||
type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
|
|
||||||
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: '向内' },
|
||||||
@@ -367,6 +409,13 @@ const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> =
|
|||||||
{ value: 'left', label: '向左' },
|
{ value: 'left', label: '向左' },
|
||||||
{ value: 'right', 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' }
|
||||||
|
];
|
||||||
/**
|
/**
|
||||||
* 左侧字段区块的展开/折叠状态。
|
* 左侧字段区块的展开/折叠状态。
|
||||||
* true 表示展开,false 表示折叠。
|
* true 表示展开,false 表示折叠。
|
||||||
@@ -426,11 +475,103 @@ function resolveEdgeLabelPosition(
|
|||||||
return isLeftEdge ? 'left' : 'right';
|
return isLeftEdge ? 'left' : 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 target 侧节点 localY(0~1)。
|
||||||
|
* - gap 滑块只作为源侧基准
|
||||||
|
* - target 侧通过“汇聚对齐”决定布局
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
if (layoutHeight <= 0 || links.length === 0) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomingCountMap = new Map<string, number>();
|
||||||
|
const outgoingCountMap = new Map<string, number>();
|
||||||
|
const sourceValueMap = new Map<string, number>();
|
||||||
|
const targetValueMap = new Map<string, number>();
|
||||||
|
const appearanceOrder: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
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 chartNodes = computed(() => {
|
||||||
const links = chartLinks.value;
|
const links = chartLinks.value;
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
const incomingCountMap = new Map<string, number>();
|
const incomingCountMap = new Map<string, number>();
|
||||||
const outgoingCountMap = new Map<string, number>();
|
const outgoingCountMap = new Map<string, number>();
|
||||||
|
const targetLocalYMap = buildTargetLocalYMap(links);
|
||||||
|
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
names.add(link.source);
|
names.add(link.source);
|
||||||
@@ -450,6 +591,7 @@ const chartNodes = computed(() => {
|
|||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: palette[index % palette.length]
|
color: palette[index % palette.length]
|
||||||
},
|
},
|
||||||
|
localY: targetLocalYMap.get(name),
|
||||||
label: position
|
label: position
|
||||||
? {
|
? {
|
||||||
position
|
position
|
||||||
@@ -548,7 +690,34 @@ watch(
|
|||||||
{ deep: true }
|
{ 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 {
|
function syncChartSize(): void {
|
||||||
|
chartClientHeight.value = chartRef.value?.clientHeight ?? chartClientHeight.value;
|
||||||
chartInstance?.resize();
|
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<void> {
|
|||||||
parseError.value = '';
|
parseError.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = await parseDataFile(file);
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
const parsed = parseTableByFileName(file.name, fileBuffer);
|
||||||
rawTable.value = parsed;
|
rawTable.value = parsed;
|
||||||
setDefaultMappingByHeaders(parsed.headers);
|
setDefaultMappingByHeaders(parsed.headers);
|
||||||
|
uploadedFileSnapshot.value = createUploadedFileSnapshot(file.name, file.type, fileBuffer);
|
||||||
uploadMessage.value = `已加载: ${file.name}(${parsed.rows.length} 行)`;
|
uploadMessage.value = `已加载: ${file.name}(${parsed.rows.length} 行)`;
|
||||||
|
persistWorkspace();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseError.value = error instanceof Error ? error.message : '文件解析失败';
|
parseError.value = error instanceof Error ? error.message : '文件解析失败';
|
||||||
}
|
}
|
||||||
@@ -842,6 +1270,7 @@ async function loadDefaultExampleFile(): Promise<void> {
|
|||||||
const parsed = parseXlsxBuffer(buffer);
|
const parsed = parseXlsxBuffer(buffer);
|
||||||
rawTable.value = parsed;
|
rawTable.value = parsed;
|
||||||
setDefaultMappingByHeaders(parsed.headers);
|
setDefaultMappingByHeaders(parsed.headers);
|
||||||
|
uploadedFileSnapshot.value = null;
|
||||||
uploadMessage.value = `已加载: example0.xlsx(${parsed.rows.length} 行)`;
|
uploadMessage.value = `已加载: example0.xlsx(${parsed.rows.length} 行)`;
|
||||||
parseError.value = '';
|
parseError.value = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -920,8 +1349,19 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
|
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
|
||||||
|
chartClientHeight.value = container.clientHeight;
|
||||||
chartInstance.setOption(chartOption.value);
|
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', syncChartSize);
|
||||||
window.addEventListener('resize', updateThemePopoverPosition);
|
window.addEventListener('resize', updateThemePopoverPosition);
|
||||||
window.addEventListener('scroll', updateThemePopoverPosition, true);
|
window.addEventListener('scroll', updateThemePopoverPosition, true);
|
||||||
|
|||||||
@@ -609,6 +609,32 @@ body {
|
|||||||
line-height: 22px;
|
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 {
|
.example-line {
|
||||||
margin: 4px 0 8px;
|
margin: 4px 0 8px;
|
||||||
color: var(--text-4);
|
color: var(--text-4);
|
||||||
@@ -694,6 +720,10 @@ body {
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.target-align-control {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user