update at 2026-02-13 15:02:51
This commit is contained in:
@@ -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. 小程序仅骨架,尚未接入真实解析、渲染与导出。
|
||||
|
||||
448
src/App.vue
448
src/App.vue
@@ -260,6 +260,18 @@
|
||||
</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
@@ -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<string>('figma-violet');
|
||||
const showThemePicker = ref(false);
|
||||
@@ -336,6 +376,7 @@ const parseError = ref('');
|
||||
const buildError = ref('');
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const chartClientHeight = ref(0);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const themeWheelRef = 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 buildResult = ref<SankeyBuildResult | null>(null);
|
||||
const uploadedFileSnapshot = ref<PersistedUploadedFile | null>(null);
|
||||
const isRestoringWorkspace = ref(false);
|
||||
|
||||
const mapping = reactive<MappingConfig>({
|
||||
sourceDataColumn: 2,
|
||||
@@ -359,7 +402,6 @@ const mapping = reactive<MappingConfig>({
|
||||
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<LabelPositionMode>('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<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 表示折叠。
|
||||
@@ -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<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 links = chartLinks.value;
|
||||
const names = new Set<string>();
|
||||
const incomingCountMap = new Map<string, number>();
|
||||
const outgoingCountMap = new Map<string, number>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user