Compare commits

...

2 Commits

Author SHA1 Message Date
douboer@gmail.com
b2af1cab83 update at 2026-02-13 22:41:40 2026-02-13 22:41:40 +08:00
douboer@gmail.com
d93e2c812f update at 2026-02-13 22:40:22 2026-02-13 22:40:22 +08:00
6 changed files with 282 additions and 78 deletions

View File

@@ -3,6 +3,7 @@
当前目录已从骨架升级为可用版,支持以下能力:
- 顶部 Logo / 主题 / 上传 / 导出区域
- 启动默认加载:`data/sankey.xlsx`(位于小程序包内 `miniapp/data/sankey.xlsx`
- 文件上传与解析:`csv / xls / xlsx`
- 默认列映射:
- 源数据列优先匹配 `data/value/数据/值`
@@ -18,4 +19,5 @@
注意事项:
- `xlsx` 解析依赖 npm 包,若在微信开发者工具中提示模块缺失,请先执行“工具 -> 构建 npm”。
- 当默认文件或上传文件未加载成功时,“数据选择”仅显示空状态提示,不展示占位列名。
- SVG 文件导出后是否可直接预览,取决于当前系统与微信版本对 SVG 文档的支持。

BIN
miniapp/data/sankey.xlsx Normal file

Binary file not shown.

View File

@@ -129,6 +129,8 @@ const TARGET_ALIGN_OPTIONS = [
{ value: 'top', label: '顶部' },
{ value: 'bottom', label: '底部' }
];
const DEFAULT_SANKEY_FILE_NAME = 'data/sankey.xlsx';
const DEFAULT_SANKEY_FILE_PATHS = ['/data/sankey.xlsx', 'data/sankey.xlsx'];
/**
* 数值限制,避免 UI 参数导致布局异常。
@@ -141,6 +143,32 @@ function clampNumber(value, min, max, fallback) {
return Math.min(max, Math.max(min, normalized));
}
/**
* 统一错误文案:
* - xlsx 解析能力缺失时,固定提示用户去“构建 npm”
* - 其他错误返回原始 message便于定位
*/
function toFriendlyParseError(error, fallbackMessage) {
const message = error && error.message ? String(error.message) : '';
if (message.indexOf('xlsx 解析') >= 0) {
return '当前环境未启用 xlsx 解析,请先在开发者工具执行“构建 npm”';
}
return message || fallbackMessage;
}
/**
* 兼容 onWindowResize 不同回调结构,提取 windowHeight。
*/
function getWindowHeightFromResizePayload(payload) {
if (payload && payload.size && Number.isFinite(payload.size.windowHeight)) {
return payload.size.windowHeight;
}
if (payload && Number.isFinite(payload.windowHeight)) {
return payload.windowHeight;
}
return null;
}
/**
* 方向切换target->source 时对连线做镜像翻转。
*/
@@ -384,10 +412,10 @@ Page({
data: {
selectedThemeIndex: 1,
showThemeSheet: false,
uploadMessage: '点击上传或将csv/xls文件拖到这里上传',
uploadMessage: '默认加载 data/sankey.xlsx 中...',
parseError: '',
buildError: '',
columnHeaders: ['列1', '列2', '列3'],
columnHeaders: [],
tableRows: [],
sourceDataColumn: null,
sourceDescriptionColumns: [],
@@ -396,7 +424,7 @@ Page({
linksCount: 0,
droppedRows: 0,
buildWarnings: [],
infoLogs: ['解析信息: 尚未加载数据文件'],
infoLogs: ['解析信息: 正在加载默认数据文件 data/sankey.xlsx'],
sankeyLinks: [],
sankeyNodes: [],
gapOptions: GAP_OPTIONS,
@@ -413,7 +441,68 @@ Page({
targetAlignOptionLabels: TARGET_ALIGN_OPTIONS.map((item) => item.label),
targetAlignValues: TARGET_ALIGN_OPTIONS.map((item) => item.value),
targetAlignIndex: 0,
targetAlignMode: TARGET_ALIGN_OPTIONS[0].value
targetAlignMode: TARGET_ALIGN_OPTIONS[0].value,
bottomPanelsHeightPx: 300
},
/**
* 页面加载时:
* 1) 根据窗口高度计算底部双窗口的目标高度(默认 300低高度窗口自动压缩
* 2) 监听窗口变化,保持整体布局稳定
*/
onLoad() {
this.updateBottomPanelsHeight();
if (typeof wx.onWindowResize === 'function') {
this._handleWindowResize = (payload) => {
const nextWindowHeight = getWindowHeightFromResizePayload(payload);
this.updateBottomPanelsHeight(nextWindowHeight);
};
wx.onWindowResize(this._handleWindowResize);
}
},
/**
* 页面卸载时移除窗口变化监听,避免重复绑定。
*/
onUnload() {
if (this._handleWindowResize && typeof wx.offWindowResize === 'function') {
wx.offWindowResize(this._handleWindowResize);
}
},
/**
* 计算底部双窗口高度:
* - 优先保持 300px
* - 在小高度窗口中自动收缩到 180~300 区间,避免挤压主预览区
*/
updateBottomPanelsHeight(windowHeight) {
let resolvedWindowHeight = Number(windowHeight);
if (!Number.isFinite(resolvedWindowHeight)) {
try {
if (typeof wx.getWindowInfo === 'function') {
resolvedWindowHeight = wx.getWindowInfo().windowHeight;
} else {
resolvedWindowHeight = wx.getSystemInfoSync().windowHeight;
}
} catch (error) {
resolvedWindowHeight = 760;
}
}
const remainForBottomPanels = resolvedWindowHeight - 360;
const nextHeight = clampNumber(remainForBottomPanels, 180, 300, 300);
if (nextHeight === this.data.bottomPanelsHeightPx) {
return;
}
this.setData(
{
bottomPanelsHeightPx: nextHeight
},
() => {
this.drawSankey();
}
);
},
/**
@@ -522,6 +611,86 @@ Page({
*/
onReady() {
this.drawSankey();
this.loadDefaultSankeyFile();
},
/**
* 统一读取并解析文件。
* - CSV 按 utf8 文本读取
* - XLS/XLSX 按二进制读取
*/
readAndApplyFile(filePath, fileName, onReadFailPrefix) {
const that = this;
const extension = getFileExtension(fileName);
const isCsvFile = extension === 'csv';
const readOptions = {
filePath,
success(readRes) {
try {
const filePayload = isCsvFile ? String(readRes.data || '') : readRes.data;
const table = parseTableByFileName(fileName, filePayload);
that.applyParsedTable(table, fileName);
} catch (error) {
that.setData({
parseError: toFriendlyParseError(error, '文件解析失败')
});
that.refreshInfoLogs();
}
},
fail(err) {
that.setData({
parseError: `${onReadFailPrefix}: ${err && err.errMsg ? err.errMsg : '未知错误'}`
});
that.refreshInfoLogs();
}
};
if (isCsvFile) {
readOptions.encoding = 'utf8';
}
wx.getFileSystemManager().readFile(readOptions);
},
/**
* 默认加载项目内置数据data/sankey.xlsx
* 说明:
* - 多路径兜底,兼容不同开发者工具路径解析差异
* - 加载失败不展示占位列,保持“未加载文件”状态
*/
loadDefaultSankeyFile() {
const that = this;
const tryReadByIndex = (index, lastErrorMessage) => {
if (index >= DEFAULT_SANKEY_FILE_PATHS.length) {
that.setData({
uploadMessage: '点击上传或将csv/xls文件拖到这里上传',
parseError: `默认文件加载失败: ${lastErrorMessage || `未找到 ${DEFAULT_SANKEY_FILE_NAME}`}`
});
that.refreshInfoLogs();
return;
}
const candidatePath = DEFAULT_SANKEY_FILE_PATHS[index];
wx.getFileSystemManager().readFile({
filePath: candidatePath,
success(readRes) {
try {
const table = parseTableByFileName(DEFAULT_SANKEY_FILE_NAME, readRes.data);
that.applyParsedTable(table, DEFAULT_SANKEY_FILE_NAME);
} catch (error) {
that.setData({
uploadMessage: '点击上传或将csv/xls文件拖到这里上传',
parseError: toFriendlyParseError(error, '默认文件解析失败')
});
that.refreshInfoLogs();
}
},
fail(err) {
const errorMessage = err && err.errMsg ? err.errMsg : '未知错误';
tryReadByIndex(index + 1, errorMessage);
}
});
};
tryReadByIndex(0, '');
},
/**
@@ -540,33 +709,7 @@ Page({
}
const filePath = picked.path;
const fileName = picked.name || 'unknown.csv';
const extension = getFileExtension(fileName);
const isCsvFile = extension === 'csv';
const readOptions = {
filePath,
success(readRes) {
try {
const filePayload = isCsvFile ? String(readRes.data || '') : readRes.data;
const table = parseTableByFileName(fileName, filePayload);
that.applyParsedTable(table, fileName);
} catch (error) {
that.setData({
parseError: error && error.message ? error.message : '文件解析失败'
});
that.refreshInfoLogs();
}
},
fail(err) {
that.setData({
parseError: `读取文件失败: ${err && err.errMsg ? err.errMsg : '未知错误'}`
});
that.refreshInfoLogs();
}
};
if (isCsvFile) {
readOptions.encoding = 'utf8';
}
wx.getFileSystemManager().readFile(readOptions);
that.readAndApplyFile(filePath, fileName, '读取文件失败');
},
fail(err) {
if (err && String(err.errMsg || '').indexOf('cancel') >= 0) {
@@ -601,7 +744,7 @@ Page({
uploadMessage: `已加载: ${fileName}${rows.length} 行)`,
parseError: '',
buildError: '',
columnHeaders: headers.length > 0 ? headers : ['列1', '列2', '列3'],
columnHeaders: headers.length > 0 ? headers : [],
tableRows: rows,
sourceDataColumn,
sourceDescriptionColumns,

View File

@@ -90,61 +90,64 @@
<canvas class="preview-canvas" canvas-id="sankeyCanvas" id="sankeyCanvas" />
</view>
<view class="bottom-panels">
<view class="bottom-panels" style="height: {{bottomPanelsHeightPx}}px;">
<view class="panel data-panel">
<image class="panel-title" src="../../assets/icons/data-select.svg" mode="widthFix" />
<scroll-view class="data-scroll" scroll-y enhanced show-scrollbar="true">
<text wx:if="{{columnHeaders.length === 0}}" class="empty-tip">未加载文件,暂无列信息</text>
<view class="field-group">
<view class="field-title">
<image src="../../assets/icons/expand.svg" mode="aspectFit" />
<text>源数据(link value)</text>
<view class="field-group">
<view class="field-title">
<image src="../../assets/icons/expand.svg" mode="aspectFit" />
<text>源数据(link value)</text>
</view>
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onSelectSourceData">
<image src="../../assets/icons/data.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{sourceDataColumn === index ? '../../assets/icons/radiobutton.svg' : '../../assets/icons/radiobutton-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onSelectSourceData">
<image src="../../assets/icons/data.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{sourceDataColumn === index ? '../../assets/icons/radiobutton.svg' : '../../assets/icons/radiobutton-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
<view class="field-group">
<view class="field-title">
<image src="../../assets/icons/expand.svg" mode="aspectFit" />
<text>源标签(Source label)</text>
<view class="field-group">
<view class="field-title">
<image src="../../assets/icons/expand.svg" mode="aspectFit" />
<text>源标签(Source label)</text>
</view>
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleSourceDesc">
<image src="../../assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{sourceDescriptionColumns.indexOf(index) > -1 ? '../../assets/icons/checkbox.svg' : '../../assets/icons/checkbox-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleSourceDesc">
<image src="../../assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{sourceDescriptionColumns.indexOf(index) > -1 ? '../../assets/icons/checkbox.svg' : '../../assets/icons/checkbox-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
<view class="field-group">
<view class="field-title">
<image src="../../assets/icons/expand.svg" mode="aspectFit" />
<text>目标标签(target label)</text>
<view class="field-group">
<view class="field-title">
<image src="../../assets/icons/expand.svg" mode="aspectFit" />
<text>目标标签(target label)</text>
</view>
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleTargetDesc">
<image src="../../assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{targetDescriptionColumns.indexOf(index) > -1 ? '../../assets/icons/checkbox.svg' : '../../assets/icons/checkbox-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleTargetDesc">
<image src="../../assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text>
<image
src="{{targetDescriptionColumns.indexOf(index) > -1 ? '../../assets/icons/checkbox.svg' : '../../assets/icons/checkbox-no.svg'}}"
mode="aspectFit"
/>
</view>
</view>
</scroll-view>
</view>
<view class="panel log-panel">
<image class="panel-title panel-title-log" src="../../assets/icons/information.svg" mode="widthFix" />
<view class="log-list">
<scroll-view class="log-list" scroll-y enhanced show-scrollbar="true">
<text class="log-item" wx:for="{{infoLogs}}" wx:key="index">{{item}}</text>
</view>
</scroll-view>
</view>
</view>

View File

@@ -1,8 +1,11 @@
.page {
min-height: 100vh;
height: 100vh;
padding: 8px;
background: #f3f4f6;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
@@ -10,6 +13,7 @@
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.logo {
@@ -31,6 +35,7 @@
align-items: center;
gap: 6px;
padding-left: 6px;
flex-shrink: 0;
}
.tool-icon {
@@ -101,6 +106,11 @@
background: #fff;
padding: 3.2px;
box-sizing: border-box;
flex: 1;
min-height: 220px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-head {
@@ -227,7 +237,9 @@
.preview-canvas {
margin-top: 3px;
width: 100%;
height: 371px;
height: auto;
flex: 1;
min-height: 120px;
border-radius: 4px;
background: #f7f8fa;
}
@@ -237,6 +249,9 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
height: 300px;
min-height: 180px;
flex-shrink: 0;
}
.panel {
@@ -245,18 +260,37 @@
background: #fff;
padding: 8px;
box-sizing: border-box;
height: 100%;
overflow: hidden;
}
.data-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.data-scroll {
margin-top: 12px;
flex: 1;
height: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-tip {
color: #86909c;
font-size: 12px;
line-height: 1.4;
}
.log-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.panel-title {
@@ -316,11 +350,11 @@
.log-list {
flex: 1;
height: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: auto;
}
.log-item {
@@ -334,6 +368,7 @@
color: #86909c;
font-size: 14px;
line-height: 1.3;
flex-shrink: 0;
}
.theme-sheet-mask {

View File

@@ -1,4 +1,24 @@
{
<<<<<<< HEAD
"description": "sankey miniapp standalone config",
"packOptions": {
"ignore": []
},
"setting": {
"es6": true,
"enhance": true,
"postcss": true,
"minified": true
},
"compileType": "miniprogram",
"libVersion": "trial",
"appid": "wxcf0f89b6eb65759e",
"projectname": "sankey-miniapp",
"miniprogramRoot": "./",
"srcMiniprogramRoot": "./",
"condition": {}
}
=======
"setting": {
"es6": true,
"postcss": true,
@@ -23,3 +43,4 @@
"appid": "wxcf0f89b6eb65759e",
"editorSetting": {}
}
>>>>>>> 2cafdbb8fe6909bf187472a961427bb67e990218