diff --git a/APP_FLOW.md b/APP_FLOW.md index 1f0f36e..1eaeeeb 100644 --- a/APP_FLOW.md +++ b/APP_FLOW.md @@ -1,141 +1,124 @@ # APP_FLOW.md ## 1. Scope -This file defines every user-visible flow for the current product scope. -Primary web route is `/` (single-page app). Miniapp flow is skeleton-only. +本文件定义当前版本所有用户可见流程,覆盖 Web 与小程序两端。 -Related docs: +关联文档: - PRD.md - FRONTEND_GUIDELINES.md - BACKEND_STRUCTURE.md -## 2. Screen List and Route Map -- `Web /` : Main Sankey workspace (header + mapping panels + chart preview). -- `Miniapp /pages/index/index` : Layout skeleton only (not full feature parity). +## 2. 页面与路由清单 +- `Web /`:主工作区(顶部工具栏 + 数据选择器 + 信息日志 + 桑基图预览) +- `Miniapp /pages/index/index`:小程序可用页(上传、映射、预览、导出) -## 3. Web Flow: Initial Page Load -Trigger: -- User opens `/`. +## 3. Web 流程:首次加载 +触发条件: +- 用户打开 `/` -Steps: -1. Render header, mapping panels, and chart container. -2. Initialize ECharts instance. -3. Try loading `/data/example0.xlsx`. -4. Parse file and set default column mapping. -5. Build Sankey data and render preview. +步骤: +1. 渲染页面结构并初始化 ECharts +2. 尝试读取 `/data/example0.xlsx` +3. 解析文件并应用默认列映射 +4. 构建 Sankey 数据并渲染预览 -Decision points: -- If sample file request fails, show parse error text and keep page interactive. +决策点: +- 若样例文件加载失败,显示错误信息,但页面仍可继续上传文件 -Success result: -- User sees a chart without manual upload. +成功结果: +- 用户无需上传即可看到可操作示例图 -Error result: -- User sees a clear message and can still upload a file manually. +失败结果: +- 页面给出明确错误提示,保持可恢复 -## 4. Web Flow: File Upload (Click) -Trigger: -- User clicks upload icon and selects a local file. +## 4. Web 流程:文件上传(点击/拖拽) +触发条件: +- 点击上传并选择文件,或将文件拖入上传区 -Steps: -1. Validate extension (`csv/xls/xlsx`). -2. Parse file to `RawTable`. -3. Reset default mapping by column count. -4. Rebuild Sankey data via reactive effect. -5. Update upload message with filename and row count. +步骤: +1. 校验后缀:`csv/xls/xlsx` +2. 解析为 `RawTable` +3. 应用默认映射或恢复持久化映射 +4. 重建聚合并刷新图表 +5. 更新上传提示文案(文件名 + 行数) -Decision points: -- Unsupported file type. -- Parse failure (CSV parser/XLSX parser errors). +决策点: +- 不支持的后缀直接报错 +- 解析异常显示错误并保留上一次有效状态 -Success result: -- Mapping list updates to current headers and preview updates. +## 5. Web 流程:列映射与预览配置 +触发条件: +- 用户修改数据列、描述列、方向、gap、padding、主题、汇聚对齐等配置 -Error result: -- Error message appears; previous valid state remains usable. +步骤: +1. 更新映射或显示配置 +2. 触发重建逻辑 +3. 刷新图表与信息日志 +4. 持久化配置到 localStorage -## 5. Web Flow: File Upload (Drag and Drop) -Trigger: -- User drops one file into upload area. +决策点: +- `source data` 未选:阻断构建并提示 +- `target description` 为空:阻断构建并提示 +- 非法数值/空描述:跳过行并写入告警 -Steps: -1. Read first file from drop payload. -2. Reuse the same pipeline as click upload. +## 6. Web 流程:导出 +触发条件: +- 点击导出 `SVG` 或 `PNG` -Decision points: -- No file in payload. +步骤: +1. 检查图表实例是否可用 +2. 组装时间戳文件名 +3. SVG:临时创建 SVG 渲染器导出 +4. PNG:`pixelRatio=2` 导出 +5. 触发浏览器下载 -Success and error behaviors: -- Same as click upload. +## 7. 小程序流程:文件上传与默认映射 +触发条件: +- 用户点击“文件上传” -## 6. Web Flow: Mapping Configuration -Trigger: -- User changes any mapping item in the left panels. +步骤: +1. 选择 `csv/xls/xlsx` 文件 +2. 按后缀分流解析: + - `csv`:文本解析 + - `xls/xlsx`:SheetJS 解析首个工作表 +3. 应用默认映射规则 +4. 重建 Sankey 聚合并更新日志 +5. 触发 canvas 重绘 -Steps: -1. Select one source data column (radio). -2. Toggle source description columns (checkbox list). -3. Toggle target description columns (checkbox list). -4. Optional: toggle target total label display. -5. Reactive builder recomputes links and warnings. +决策点: +- 若 xlsx 模块不可用,提示先在开发者工具执行“构建 npm” +- 解析失败显示错误,不会导致页面失效 -Decision points: -- Missing source data column -> block build with message. -- Empty target description selection -> block build with message. -- Invalid numeric source value -> row dropped with warning. -- Empty source/target name after rules -> row dropped with warning. +## 8. 小程序流程:列映射与预览 +触发条件: +- 用户点击任意列项调整 source/target 映射 -Success result: -- Chart updates immediately. +步骤: +1. 更新映射状态 +2. 调用聚合构建 +3. 更新统计(节点数、连线数、跳过行) +4. 更新“信息日志” +5. 重绘 canvas 预览 -Error result: -- Build error or warning list shown in preview panel. +## 9. 小程序流程:导出 +触发条件: +- 点击导出 PNG 或 SVG -## 7. Web Flow: Preview Controls -Trigger: -- User changes direction, gap, padding, or theme. +步骤: +1. PNG: + - 将 canvas 导出临时图片 + - 保存到系统相册 +2. SVG: + - 复用布局算法生成 SVG 字符串 + - 写入用户目录 `.svg` 文件 + - 优先尝试打开文档;若失败则复制文件路径 -Steps: -1. Direction toggle swaps link direction (`source<->target`). -2. Gap/padding sliders update chart series layout. -3. Theme picker updates node palette. - -Decision points: -- Theme popover closes on outside pointer down. - -Success result: -- Chart visual changes apply in-place. - -## 8. Web Flow: Export -Trigger: -- User clicks `Export SVG` or `Export PNG`. - -Steps: -1. Ensure chart instance exists. -2. Build filename with timestamp. -3. For SVG: prefer serializing DOM ``; fallback to `getDataURL`. -4. For PNG: use `getDataURL(type='png', pixelRatio=2)`. -5. Trigger browser download. - -Decision points: -- No chart instance -> no action. - -Success result: -- Downloaded file appears with expected naming. - -## 9. Miniapp Skeleton Flow (Current State) -Trigger: -- Open miniapp index page. - -Steps: -1. Render static layout blocks. -2. Allow opening/closing theme bottom sheet. - -Current limitations: -- No real file parsing, no Sankey rendering, no export pipeline. +决策点: +- 无可用数据时阻断导出并提示 +- 相册权限不足时提示用户授权 ## 10. Flow Completion Checklist -A flow is considered complete only when: -1. Success path works end-to-end. -2. Error path shows deterministic message. -3. UI state remains recoverable after errors. +流程视为完成需满足: +1. 成功路径可端到端执行 +2. 失败路径有明确且可定位的错误信息 +3. 错误后页面仍可恢复继续操作 diff --git a/BACKEND_STRUCTURE.md b/BACKEND_STRUCTURE.md index d49333c..0fbef59 100644 --- a/BACKEND_STRUCTURE.md +++ b/BACKEND_STRUCTURE.md @@ -13,7 +13,10 @@ 所有数据处理均在客户端完成: - 文件解析:`src/core/parser.ts` - 聚合构建:`src/core/sankey.ts` -- 状态承载:`src/App.vue`(内存态) +- 状态承载: + - Web:`src/App.vue`(响应式状态 + localStorage 持久化) + - 小程序:`miniapp/pages/index/index.js`(页面状态) +- 小程序端聚合与解析:`miniapp/utils/sankey.js` ## 3. 当前“数据结构合同” 虽然没有数据库,但有稳定的数据结构合同。 @@ -49,9 +52,12 @@ interface SankeyBuildResult { ``` ## 4. 存储规则 -- 运行时数据仅驻留内存(浏览器刷新后丢失)。 -- 不写入 localStorage / IndexedDB / 远程存储。 -- 导出结果通过浏览器下载能力交付给用户。 +- Web: + - 映射配置与上传文件快照持久化到 localStorage(刷新可恢复)。 + - 导出结果通过浏览器下载能力交付给用户。 +- 小程序: + - 页面状态在当前会话内存中维护(当前未做本地持久化恢复)。 + - PNG 导出保存到系统相册;SVG 导出写入用户数据目录文件。 ## 5. 认证与权限 - 当前不存在用户登录、权限校验、租户隔离。 diff --git a/FRONTEND_GUIDELINES.md b/FRONTEND_GUIDELINES.md index 7321b98..277d3d4 100644 --- a/FRONTEND_GUIDELINES.md +++ b/FRONTEND_GUIDELINES.md @@ -103,8 +103,17 @@ - 文本截断区域需保留可读主体(列名优先显示前缀) ## 10. 小程序样式约束(当前) -- 仅用于骨架视觉对齐,不作为完整功能规范 - 维持与 Web 一致的色板、图标命名和信息分区 +- 页面结构固定为: + - 顶部工具栏(主题、上传、导出) + - 预览区(统计、错误、canvas) + - 数据选择区(源/目标列映射) + - 信息日志区 +- 小程序使用紧凑密度: + - 工具条图标优先,文本次要 + - 上传框允许单行省略,避免整体溢出 +- 导出图标必须同时展示 `SVG` 与 `PNG` 两个入口 +- 日志区与预览区视觉边框与 Web 一致,统一使用 `#fbaca3` 描边 ## 11. 变更规则 任何新增组件或样式改动,必须同步更新本文件对应条目。 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 99ab143..9a602ea 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -15,7 +15,7 @@ - M2:Web 交互与图表预览 - M3:导出与体验增强 - M4:质量门禁与文档闭环 -- M5:小程序从骨架到可用(未来) +- M5:小程序从骨架到可用 ## 3. 详细步骤 @@ -72,12 +72,12 @@ - `npm run lint` - `npm run format` -### M5 小程序从骨架到可用(未来) -- [ ] 5.1 接入文件上传能力 -- [ ] 5.2 复用 `src/core` 解析与聚合逻辑 -- [ ] 5.3 接入小程序图表渲染容器 -- [ ] 5.4 接入导出或保存图片能力 -- [ ] 5.5 建立小程序端测试与验收用例 +### M5 小程序从骨架到可用 +- [x] 5.1 接入文件上传能力(csv/xls/xlsx) +- [x] 5.2 对齐 Web 聚合规则(默认映射、向下补全、告警) +- [x] 5.3 接入小程序原生 canvas 预览容器 +- [x] 5.4 接入导出能力(PNG 到相册、SVG 文件导出) +- [ ] 5.5 建立小程序端测试与验收用例(待补) ## 4. 每次迭代执行模板 1. 从 `progress.txt` 读取上下文 diff --git a/PRD.md b/PRD.md index eeabf2b..a83c5be 100644 --- a/PRD.md +++ b/PRD.md @@ -1,7 +1,7 @@ # PRD.md ## 1. 文档目的 -本文件定义「星程桑基图」的产品需求合同(v0.1),用于约束范围、功能验收标准与成功标准。 +本文件定义「星程桑基图」的产品需求合同(v0.2),用于约束范围、功能验收标准与成功标准。 关联文档: - APP_FLOW.md @@ -34,7 +34,8 @@ - 不做云端存储、多人协作、历史版本。 - 不做后端 API 与数据库(见 BACKEND_STRUCTURE.md)。 - 不做三级/多级桑基图编辑器(当前仅由列拼接形成单级 source->target 关系)。 -- 不做 APP 端正式实现(仅保留小程序骨架)。 +- 不做账号体系下的小程序云同步与跨端协作。 +- 不追求 Web 与小程序像素级一致,只要求关键流程一致可用。 ## 7. 功能范围与验收标准 @@ -73,7 +74,7 @@ - 支持方向切换:`source->target` / `target->source`(仅交换链接方向)。 - 支持节点间距(gap)与图内边距(padding)调整。 - 支持主题选择并应用到节点颜色。 -- 支持“目标总和”显示开关(只影响 target 标签展示,不影响聚合值)。 +- 支持标签位置、汇聚对齐等关键可视化配置。 ### F5 导出 需求:支持导出 `SVG` 与 `PNG`。 @@ -83,18 +84,21 @@ - PNG 使用 `pixelRatio=2` 导出。 - 若 DOM 可获取 ``,优先序列化导出 SVG,否则使用图表实例导出。 -### F6 小程序骨架 -需求:提供页面结构与视觉骨架,不承诺完整业务逻辑。 +### F6 小程序可用版 +需求:提供轻量可用的小程序版本,覆盖上传、映射、预览、导出主流程。 验收标准: -- 存在页面结构、列选择区、主题底部弹层。 -- 明确标注为“骨架”,不作为完整功能验收对象。 +- 支持上传 `csv/xls/xlsx` 并完成解析。 +- 支持默认映射、列选择、聚合构建与日志展示。 +- 支持原生 canvas 预览。 +- 支持导出 PNG(保存相册)与 SVG(生成文件并可访问路径)。 ## 8. 成功标准(Success Metrics) - S1:核心单元测试稳定通过(`npm run test`)。 - S2:类型检查与 lint 稳定通过(`npm run type-check && npm run lint`)。 - S3:核心样例 `data/example0.xlsx` 可在默认加载后正常出图。 - S4:导出文件在本地可打开且内容与当前视图一致。 +- S5:小程序端可独立完成“上传 -> 映射 -> 预览 -> 导出”闭环。 ## 9. 约束与依赖 - 技术实现必须遵循 TECH_STACK.md 固定版本。 @@ -102,5 +106,5 @@ - 不得在未确认情况下引入新生产依赖。 ## 10. 版本状态 -- 当前合同版本:`v0.1` +- 当前合同版本:`v0.2` - 最后更新:`2026-02-13` diff --git a/TECH_STACK.md b/TECH_STACK.md index 67bba69..abac46e 100644 --- a/TECH_STACK.md +++ b/TECH_STACK.md @@ -52,19 +52,30 @@ - `src/App.vue`: Web 页面与交互 - `src/styles.css`: 全局样式 - `src/theme-presets.ts`: 主题色预设 -- `miniapp/*`: 小程序骨架 +- `miniapp/pages/index/*`: 小程序页面与交互 +- `miniapp/utils/sankey.js`: 小程序解析与聚合逻辑(含 xlsx 分流) +- `miniapp/README.md`: 小程序端能力与限制说明 -## 9. 开发服务器约束 +## 9. 小程序运行时能力 +- 运行平台:微信小程序原生运行时(非浏览器 DOM 环境) +- 文件读写:`wx.getFileSystemManager()` +- 图表绘制:`canvas` + `wx.createCanvasContext` +- 导出: + - PNG:`wx.canvasToTempFilePath` + `wx.saveImageToPhotosAlbum` + - SVG:生成 XML 字符串后写本地文件(`writeFile`) +- npm 依赖:小程序端 xlsx 解析依赖 `xlsx@0.18.5`,需在开发者工具执行“构建 npm” + +## 10. 开发服务器约束 `vite.config.ts` 当前使用本地 HTTPS 证书路径: - `~/mac.biboer.cn_ecc/fullchain.cer` - `~/mac.biboer.cn_ecc/mac.biboer.cn.key` 若本机无该证书,`npm run dev` 需要先调整 Vite 配置。 -## 10. 依赖治理规则 +## 11. 依赖治理规则 - 生产依赖新增/升级必须先确认。 - 版本锁定以 `package-lock.json` + 本文档为准。 - 未在本文档出现的库,默认不可在实现中使用。 -## 11. 后端说明 +## 12. 后端说明 当前无后端服务、无数据库、无认证(详见 BACKEND_STRUCTURE.md)。 diff --git a/assets/icons/checkbox-no.svg b/assets/icons/checkbox-no.svg index 65dde3e..972d296 100644 --- a/assets/icons/checkbox-no.svg +++ b/assets/icons/checkbox-no.svg @@ -1,3 +1,3 @@ - + diff --git a/miniapp/README.md b/miniapp/README.md index 2886bce..99f3dc3 100644 --- a/miniapp/README.md +++ b/miniapp/README.md @@ -1,10 +1,21 @@ -# 小程序端骨架 +# 小程序端(当前可用范围) -当前目录提供了与 Figma `node-id=584:64` 对齐的页面骨架: +当前目录已从骨架升级为可用版,支持以下能力: -- 顶部 Logo/主题/上传/导出区域 -- 效果预览区域 -- 源数据与目标数据列选择区域 -- 主题底部选择器(底部弹层) +- 顶部 Logo / 主题 / 上传 / 导出区域 +- 文件上传与解析:`csv / xls / xlsx` +- 默认列映射: + - 源数据列优先匹配 `data/value/数据/值` + - 源描述列优先匹配 `source/源` + - 目标描述列优先匹配 `target/目标` + - 若无显式数值表头,则回退到第二行首个数字列 +- 源/目标列选择交互与聚合统计 +- 信息日志(解析信息 / 告警 / 错误) +- 原生 canvas 桑基图预览 +- 导出能力: + - `PNG`:保存到系统相册 + - `SVG`:生成 `.svg` 文件写入用户目录,优先尝试打开,失败时复制路径 -后续接入时建议直接复用 `src/core` 的解析与聚合逻辑,保持 Web 与小程序一致的数据行为。 +注意事项: +- `xlsx` 解析依赖 npm 包,若在微信开发者工具中提示模块缺失,请先执行“工具 -> 构建 npm”。 +- SVG 文件导出后是否可直接预览,取决于当前系统与微信版本对 SVG 文档的支持。 diff --git a/miniapp/assets/icons/Button.svg b/miniapp/assets/icons/Button.svg new file mode 100644 index 0000000..9e170e7 --- /dev/null +++ b/miniapp/assets/icons/Button.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniapp/assets/icons/checkbox-no.svg b/miniapp/assets/icons/checkbox-no.svg new file mode 100644 index 0000000..972d296 --- /dev/null +++ b/miniapp/assets/icons/checkbox-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/checkbox.svg b/miniapp/assets/icons/checkbox.svg new file mode 100644 index 0000000..5057433 --- /dev/null +++ b/miniapp/assets/icons/checkbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/choose-color.svg b/miniapp/assets/icons/choose-color.svg new file mode 100644 index 0000000..16400ab --- /dev/null +++ b/miniapp/assets/icons/choose-color.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/miniapp/assets/icons/content.svg b/miniapp/assets/icons/content.svg new file mode 100644 index 0000000..ca82dfa --- /dev/null +++ b/miniapp/assets/icons/content.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/data-select.svg b/miniapp/assets/icons/data-select.svg new file mode 100644 index 0000000..3660533 --- /dev/null +++ b/miniapp/assets/icons/data-select.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniapp/assets/icons/data.svg b/miniapp/assets/icons/data.svg new file mode 100644 index 0000000..48c4cf9 --- /dev/null +++ b/miniapp/assets/icons/data.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/description.svg b/miniapp/assets/icons/description.svg new file mode 100644 index 0000000..9f73c4b --- /dev/null +++ b/miniapp/assets/icons/description.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniapp/assets/icons/download.svg b/miniapp/assets/icons/download.svg new file mode 100644 index 0000000..0bdb236 --- /dev/null +++ b/miniapp/assets/icons/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/expand.svg b/miniapp/assets/icons/expand.svg new file mode 100644 index 0000000..1eb83c1 --- /dev/null +++ b/miniapp/assets/icons/expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/export-png-s.svg b/miniapp/assets/icons/export-png-s.svg new file mode 100644 index 0000000..22300cc --- /dev/null +++ b/miniapp/assets/icons/export-png-s.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/export-png.svg b/miniapp/assets/icons/export-png.svg new file mode 100644 index 0000000..117b697 --- /dev/null +++ b/miniapp/assets/icons/export-png.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniapp/assets/icons/export-svg-s.svg b/miniapp/assets/icons/export-svg-s.svg new file mode 100644 index 0000000..83fb1bd --- /dev/null +++ b/miniapp/assets/icons/export-svg-s.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/export-svg.svg b/miniapp/assets/icons/export-svg.svg new file mode 100644 index 0000000..1e581d2 --- /dev/null +++ b/miniapp/assets/icons/export-svg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniapp/assets/icons/export.svg b/miniapp/assets/icons/export.svg new file mode 100644 index 0000000..1d20331 --- /dev/null +++ b/miniapp/assets/icons/export.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/favorite-red.svg b/miniapp/assets/icons/favorite-red.svg new file mode 100644 index 0000000..2151bb8 --- /dev/null +++ b/miniapp/assets/icons/favorite-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/favorite.svg b/miniapp/assets/icons/favorite.svg new file mode 100644 index 0000000..10a312a --- /dev/null +++ b/miniapp/assets/icons/favorite.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/font-icon.svg b/miniapp/assets/icons/font-icon.svg new file mode 100644 index 0000000..a24279a --- /dev/null +++ b/miniapp/assets/icons/font-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/font-size-decrease.svg b/miniapp/assets/icons/font-size-decrease.svg new file mode 100644 index 0000000..d86d60e --- /dev/null +++ b/miniapp/assets/icons/font-size-decrease.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/font-size-increase.svg b/miniapp/assets/icons/font-size-increase.svg new file mode 100644 index 0000000..104d16b --- /dev/null +++ b/miniapp/assets/icons/font-size-increase.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/gap.svg b/miniapp/assets/icons/gap.svg new file mode 100644 index 0000000..0272d4f --- /dev/null +++ b/miniapp/assets/icons/gap.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/information.svg b/miniapp/assets/icons/information.svg new file mode 100644 index 0000000..3b3945c --- /dev/null +++ b/miniapp/assets/icons/information.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/list.svg b/miniapp/assets/icons/list.svg new file mode 100644 index 0000000..fd53050 --- /dev/null +++ b/miniapp/assets/icons/list.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/padding.svg b/miniapp/assets/icons/padding.svg new file mode 100644 index 0000000..086aac9 --- /dev/null +++ b/miniapp/assets/icons/padding.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/radiobutton-no.svg b/miniapp/assets/icons/radiobutton-no.svg new file mode 100644 index 0000000..3827bda --- /dev/null +++ b/miniapp/assets/icons/radiobutton-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/radiobutton.svg b/miniapp/assets/icons/radiobutton.svg new file mode 100644 index 0000000..b5c717d --- /dev/null +++ b/miniapp/assets/icons/radiobutton.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/miniapp/assets/icons/sankeyview.svg b/miniapp/assets/icons/sankeyview.svg new file mode 100644 index 0000000..a9a2a0b --- /dev/null +++ b/miniapp/assets/icons/sankeyview.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/miniapp/assets/icons/search.svg b/miniapp/assets/icons/search.svg new file mode 100644 index 0000000..b6d1550 --- /dev/null +++ b/miniapp/assets/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/select.svg b/miniapp/assets/icons/select.svg new file mode 100644 index 0000000..3660533 --- /dev/null +++ b/miniapp/assets/icons/select.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniapp/assets/icons/selectall.svg b/miniapp/assets/icons/selectall.svg new file mode 100644 index 0000000..ef85dac --- /dev/null +++ b/miniapp/assets/icons/selectall.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniapp/assets/icons/unselectall.svg b/miniapp/assets/icons/unselectall.svg new file mode 100644 index 0000000..e8c1f1f --- /dev/null +++ b/miniapp/assets/icons/unselectall.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniapp/assets/icons/upload.svg b/miniapp/assets/icons/upload.svg new file mode 100644 index 0000000..89723a8 --- /dev/null +++ b/miniapp/assets/icons/upload.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/assets/icons/webicon.png b/miniapp/assets/icons/webicon.png new file mode 100644 index 0000000..05220b4 Binary files /dev/null and b/miniapp/assets/icons/webicon.png differ diff --git a/miniapp/assets/icons/zhedie.svg b/miniapp/assets/icons/zhedie.svg new file mode 100644 index 0000000..161ef97 --- /dev/null +++ b/miniapp/assets/icons/zhedie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniapp/assets/icons/星程字体转换.svg b/miniapp/assets/icons/星程字体转换.svg new file mode 100644 index 0000000..49b7f9a --- /dev/null +++ b/miniapp/assets/icons/星程字体转换.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/miniapp/assets/icons/星程桑基图.svg b/miniapp/assets/icons/星程桑基图.svg new file mode 100644 index 0000000..011baf9 --- /dev/null +++ b/miniapp/assets/icons/星程桑基图.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index 1779c63..492d8f1 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -1,12 +1,419 @@ +const { parseTableByFileName, buildSankeyData } = require('../../utils/sankey'); + +/** + * 将表头标准化,便于做中英文别名匹配。 + */ +function normalizeHeaderName(header) { + return String(header || '') + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ''); +} + +/** + * 根据候选别名查找列索引。 + */ +function findHeaderIndex(headers, aliases) { + const aliasSet = {}; + aliases.forEach((item) => { + aliasSet[normalizeHeaderName(item)] = true; + }); + for (let i = 0; i < headers.length; i += 1) { + if (aliasSet[normalizeHeaderName(headers[i])]) { + return i; + } + } + return -1; +} + +/** + * 判断文本是否可作为数值。 + */ +function isNumericCell(text) { + const normalized = String(text || '') + .replace(/,/g, '') + .trim(); + if (!normalized) { + return false; + } + return !Number.isNaN(Number(normalized)); +} + +/** + * 取第二行中的首个数字列索引。 + */ +function findNumericColumnFromSecondRow(rows) { + const secondRow = rows[0] || []; + for (let i = 0; i < secondRow.length; i += 1) { + if (isNumericCell(secondRow[i])) { + return i; + } + } + return -1; +} + +/** + * 从文件名中提取后缀(不含点)。 + */ +function getFileExtension(fileName) { + const lowerName = String(fileName || '').toLowerCase(); + const lastDotIndex = lowerName.lastIndexOf('.'); + if (lastDotIndex < 0) { + return ''; + } + return lowerName.slice(lastDotIndex + 1); +} + +/** + * 节点配色:与当前画布渲染保持一致,双色交替。 + */ +function getNodeColor(index) { + return index % 2 === 0 ? '#9b6bc2' : '#7e95f7'; +} + +/** + * 标签纵向位置:小节点也能保持可读,不贴边。 + */ +function getNodeLabelCenterY(nodeHeight) { + return Math.min(12, Math.max(8, nodeHeight / 2)); +} + +/** + * 构建导出文件时间戳,格式与 Web 保持一致。 + */ +function formatFileTimestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad( + now.getMinutes() + )}${pad(now.getSeconds())}`; +} + +/** + * SVG 文本转义,避免标签名包含特殊字符导致 XML 非法。 + */ +function escapeSvgText(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * SVG 数值输出做轻量格式化,避免太长小数。 + */ +function formatSvgNumber(value) { + const normalized = Number(value || 0); + if (!Number.isFinite(normalized)) { + return '0'; + } + return String(Number(normalized.toFixed(2))); +} + +/** + * 下拉选项:与 Web 手机端保持一致。 + */ +const GAP_OPTIONS = [0, 5, 10, 15, 20, 25, 30]; +const PADDING_OPTIONS = [0, 10, 20, 30, 40, 50, 60, 70, 80]; +const LABEL_POSITION_OPTIONS = [ + { value: 'inner', label: '内' }, + { value: 'outer', label: '外' }, + { value: 'left', label: '左' }, + { value: 'right', label: '右' } +]; +const TARGET_ALIGN_OPTIONS = [ + { value: 'between', label: '两端' }, + { value: 'middle', label: '中间' }, + { value: 'top', label: '顶部' }, + { value: 'bottom', label: '底部' } +]; + +/** + * 数值限制,避免 UI 参数导致布局异常。 + */ +function clampNumber(value, min, max, fallback) { + const normalized = Number(value); + if (!Number.isFinite(normalized)) { + return fallback; + } + return Math.min(max, Math.max(min, normalized)); +} + +/** + * 方向切换:target->source 时对连线做镜像翻转。 + */ +function applyDirection(links, direction) { + if (!Array.isArray(links)) { + return []; + } + if (direction !== 'target-to-source') { + return links; + } + return links.map((link) => ({ + source: link.target, + target: link.source, + value: link.value + })); +} + +/** + * 根据标签位置模式返回 Canvas 文本绘制参数。 + */ +function getCanvasLabelPlacement(layout, isSource, labelPositionMode) { + const sourceInnerX = layout.leftX + layout.nodeWidth + 4; + const sourceOuterX = Math.max(2, layout.leftX - 4); + const targetInnerX = Math.max(2, layout.rightX - 4); + const targetOuterX = layout.rightX + layout.nodeWidth + 4; + + if (labelPositionMode === 'outer') { + return isSource + ? { x: sourceOuterX, textAlign: 'right' } + : { x: targetOuterX, textAlign: 'left' }; + } + if (labelPositionMode === 'left') { + return isSource + ? { x: sourceOuterX, textAlign: 'right' } + : { x: targetInnerX, textAlign: 'right' }; + } + if (labelPositionMode === 'right') { + return isSource + ? { x: sourceInnerX, textAlign: 'left' } + : { x: targetOuterX, textAlign: 'left' }; + } + return isSource + ? { x: sourceInnerX, textAlign: 'left' } + : { x: targetInnerX, textAlign: 'right' }; +} + +/** + * 根据标签位置模式返回 SVG 文本锚点参数。 + */ +function getSvgLabelPlacement(layout, isSource, labelPositionMode) { + const canvasPlacement = getCanvasLabelPlacement(layout, isSource, labelPositionMode); + return { + x: canvasPlacement.x, + textAnchor: canvasPlacement.textAlign === 'right' ? 'end' : 'start' + }; +} + +/** + * 统一生成桑基图布局数据,供 canvas 渲染与 SVG 导出共用。 + */ +function buildSankeyLayout(links, width, height, renderOptions) { + if (!Array.isArray(links) || links.length === 0) { + return null; + } + + const nodeGap = clampNumber(renderOptions && renderOptions.nodeGap, 0, 60, 8); + const padding = clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16); + const targetAlignMode = (renderOptions && renderOptions.targetAlignMode) || 'between'; + const nodeWidth = 10; + const leftX = padding; + const rightX = Math.max(padding + nodeWidth + 80, width - padding - nodeWidth); + + const sourceValueMap = {}; + const targetValueMap = {}; + links.forEach((link) => { + sourceValueMap[link.source] = (sourceValueMap[link.source] || 0) + Number(link.value || 0); + targetValueMap[link.target] = (targetValueMap[link.target] || 0) + Number(link.value || 0); + }); + + const sourceNames = Object.keys(sourceValueMap); + const targetNames = Object.keys(targetValueMap); + const totalValue = sourceNames.reduce((sum, name) => sum + sourceValueMap[name], 0); + if (totalValue <= 0) { + return null; + } + + const sourceGapCount = Math.max(0, sourceNames.length - 1); + const sourceContentHeight = Math.max(10, height - padding * 2 - sourceGapCount * nodeGap); + const sourceUnitHeight = sourceContentHeight / totalValue; + + const sourcePos = {}; + let sourceCursorY = padding; + sourceNames.forEach((name) => { + const nodeHeight = Math.max(2, sourceValueMap[name] * sourceUnitHeight); + sourcePos[name] = { y: sourceCursorY, h: nodeHeight }; + sourceCursorY += nodeHeight + nodeGap; + }); + + const targetGapCount = Math.max(0, targetNames.length - 1); + const targetTotalValue = targetNames.reduce((sum, name) => sum + targetValueMap[name], 0); + const targetUnitHeight = targetTotalValue > 0 ? sourceUnitHeight : 0; + const targetTotalNodeHeight = targetTotalValue * targetUnitHeight; + const targetBlockHeight = targetTotalNodeHeight + targetGapCount * nodeGap; + const layoutHeight = Math.max(10, height - padding * 2); + let targetStartY = padding; + if (targetAlignMode === 'middle') { + targetStartY = padding + Math.max(0, (layoutHeight - targetBlockHeight) / 2); + } else if (targetAlignMode === 'bottom') { + targetStartY = padding + Math.max(0, layoutHeight - targetBlockHeight); + } else if (targetAlignMode === 'top') { + targetStartY = padding; + } + + const targetPos = {}; + let targetCursorY = targetStartY; + targetNames.forEach((name) => { + const nodeHeight = Math.max(2, targetValueMap[name] * targetUnitHeight); + targetPos[name] = { y: targetCursorY, h: nodeHeight }; + targetCursorY += nodeHeight + nodeGap; + }); + + const sourceOffset = {}; + const targetOffset = {}; + sourceNames.forEach((name) => { + sourceOffset[name] = 0; + }); + targetNames.forEach((name) => { + targetOffset[name] = 0; + }); + + const linkSegments = []; + links.forEach((link) => { + const sourceNode = sourcePos[link.source]; + const targetNode = targetPos[link.target]; + if (!sourceNode || !targetNode) { + return; + } + + const linkHeight = Math.max(1, Number(link.value || 0) * sourceUnitHeight); + const sy = sourceNode.y + sourceOffset[link.source] + linkHeight / 2; + const ty = targetNode.y + targetOffset[link.target] + linkHeight / 2; + sourceOffset[link.source] += linkHeight; + targetOffset[link.target] += linkHeight; + + const startX = leftX + nodeWidth; + const endX = rightX; + const controlX = (startX + endX) / 2; + + linkSegments.push({ + startX, + endX, + controlX, + sy, + ty, + linkHeight + }); + }); + + return { + leftX, + rightX, + nodeWidth, + sourceNames, + targetNames, + sourcePos, + targetPos, + linkSegments + }; +} + +/** + * 基于布局结果构建可下载的 SVG 字符串。 + */ +function buildSankeySvgText(links, width, height, renderOptions) { + const layout = buildSankeyLayout(links, width, height, renderOptions); + if (!layout) { + return ''; + } + const labelPositionMode = (renderOptions && renderOptions.labelPositionMode) || 'inner'; + + const segments = []; + segments.push( + `` + ); + segments.push( + `` + ); + + layout.linkSegments.forEach((segment) => { + const pathData = `M ${formatSvgNumber(segment.startX)} ${formatSvgNumber(segment.sy)} C ${formatSvgNumber( + segment.controlX + )} ${formatSvgNumber(segment.sy)} ${formatSvgNumber(segment.controlX)} ${formatSvgNumber(segment.ty)} ${formatSvgNumber( + segment.endX + )} ${formatSvgNumber(segment.ty)}`; + segments.push( + `` + ); + }); + + layout.sourceNames.forEach((name, index) => { + const node = layout.sourcePos[name]; + const textY = node.y + getNodeLabelCenterY(node.h); + const textPlacement = getSvgLabelPlacement(layout, true, labelPositionMode); + segments.push( + `` + ); + segments.push( + `${escapeSvgText(name)}` + ); + }); + + layout.targetNames.forEach((name, index) => { + const node = layout.targetPos[name]; + const textY = node.y + getNodeLabelCenterY(node.h); + const textPlacement = getSvgLabelPlacement(layout, false, labelPositionMode); + segments.push( + `` + ); + segments.push( + `${escapeSvgText(name)}` + ); + }); + + segments.push(''); + return segments.join('\n'); +} + Page({ data: { selectedThemeIndex: 1, - sourceColumns: ['列1', '列2'], - targetColumns: ['列1', '列2'], - sourceDataIndex: 1, - sourceDescChecked: [1], - targetDescChecked: [1], - showThemeSheet: false + showThemeSheet: false, + uploadMessage: '点击上传或将csv/xls文件拖到这里上传', + parseError: '', + buildError: '', + columnHeaders: ['列1', '列2', '列3'], + tableRows: [], + sourceDataColumn: null, + sourceDescriptionColumns: [], + targetDescriptionColumns: [], + nodesCount: 0, + linksCount: 0, + droppedRows: 0, + buildWarnings: [], + infoLogs: ['解析信息: 尚未加载数据文件'], + sankeyLinks: [], + sankeyNodes: [], + gapOptions: GAP_OPTIONS, + gapOptionIndex: 1, + nodeGap: GAP_OPTIONS[1], + paddingOptions: PADDING_OPTIONS, + paddingOptionIndex: 2, + chartPadding: PADDING_OPTIONS[2], + direction: 'source-to-target', + labelPositionOptionLabels: LABEL_POSITION_OPTIONS.map((item) => item.label), + labelPositionValues: LABEL_POSITION_OPTIONS.map((item) => item.value), + labelPositionIndex: 0, + labelPositionMode: LABEL_POSITION_OPTIONS[0].value, + targetAlignOptionLabels: TARGET_ALIGN_OPTIONS.map((item) => item.label), + targetAlignValues: TARGET_ALIGN_OPTIONS.map((item) => item.value), + targetAlignIndex: 0, + targetAlignMode: TARGET_ALIGN_OPTIONS[0].value }, /** @@ -21,5 +428,538 @@ Page({ */ onCloseThemeSheet() { this.setData({ showThemeSheet: false }); + }, + + /** + * 更新“节点间距”下拉值并立即重绘。 + */ + onChangeGap(e) { + const pickedIndex = Number(e.detail && e.detail.value); + const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0; + const nextValue = this.data.gapOptions[safeIndex]; + this.setData( + { + gapOptionIndex: safeIndex, + nodeGap: Number.isFinite(nextValue) ? nextValue : this.data.nodeGap + }, + () => { + this.drawSankey(); + } + ); + }, + + /** + * 更新“预览边距”下拉值并立即重绘。 + */ + onChangePadding(e) { + const pickedIndex = Number(e.detail && e.detail.value); + const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0; + const nextValue = this.data.paddingOptions[safeIndex]; + this.setData( + { + paddingOptionIndex: safeIndex, + chartPadding: Number.isFinite(nextValue) ? nextValue : this.data.chartPadding + }, + () => { + this.drawSankey(); + } + ); + }, + + /** + * 切换连线方向(source->target / target->source)。 + */ + onToggleDirection() { + this.setData( + { + direction: + this.data.direction === 'source-to-target' ? 'target-to-source' : 'source-to-target' + }, + () => { + this.drawSankey(); + } + ); + }, + + /** + * 更新标签位置模式并重绘。 + */ + onChangeLabelPosition(e) { + const pickedIndex = Number(e.detail && e.detail.value); + const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0; + const nextMode = this.data.labelPositionValues[safeIndex]; + this.setData( + { + labelPositionIndex: safeIndex, + labelPositionMode: nextMode || 'inner' + }, + () => { + this.drawSankey(); + } + ); + }, + + /** + * 更新汇聚对齐模式并重绘。 + */ + onChangeTargetAlign(e) { + const pickedIndex = Number(e.detail && e.detail.value); + const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0; + const nextMode = this.data.targetAlignValues[safeIndex]; + this.setData( + { + targetAlignIndex: safeIndex, + targetAlignMode: nextMode || 'between' + }, + () => { + this.drawSankey(); + } + ); + }, + + /** + * 页面 ready 后绘制一次空白画布,避免首次显示延迟。 + */ + onReady() { + this.drawSankey(); + }, + + /** + * 上传数据文件并解析为表头与数据行。 + */ + onChooseFile() { + const that = this; + wx.chooseMessageFile({ + count: 1, + type: 'file', + extension: ['csv', 'xls', 'xlsx'], + success(res) { + const picked = res.tempFiles && res.tempFiles[0]; + if (!picked) { + return; + } + 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); + }, + fail(err) { + if (err && String(err.errMsg || '').indexOf('cancel') >= 0) { + return; + } + that.setData({ + parseError: `选择文件失败: ${err && err.errMsg ? err.errMsg : '未知错误'}` + }); + that.refreshInfoLogs(); + } + }); + }, + + /** + * 应用解析结果,并按 Web 规则设置默认映射后触发重建。 + */ + applyParsedTable(table, fileName) { + const headers = table.headers || []; + const rows = table.rows || []; + const sourceByName = findHeaderIndex(headers, ['data', 'value', '数据', '值']); + const sourceBySecondRow = findNumericColumnFromSecondRow(rows); + const sourceDescByName = findHeaderIndex(headers, ['source', '源']); + const targetDescByName = findHeaderIndex(headers, ['target', '目标']); + + const sourceDataColumn = + sourceByName >= 0 ? sourceByName : sourceBySecondRow >= 0 ? sourceBySecondRow : null; + const sourceDescriptionColumns = sourceDescByName >= 0 ? [sourceDescByName] : []; + const targetDescriptionColumns = targetDescByName >= 0 ? [targetDescByName] : []; + + this.setData( + { + uploadMessage: `已加载: ${fileName}(${rows.length} 行)`, + parseError: '', + buildError: '', + columnHeaders: headers.length > 0 ? headers : ['列1', '列2', '列3'], + tableRows: rows, + sourceDataColumn, + sourceDescriptionColumns, + targetDescriptionColumns + }, + () => { + this.rebuildSankey(); + } + ); + }, + + onSelectSourceData(e) { + const index = Number(e.currentTarget.dataset.index); + this.setData( + { + sourceDataColumn: Number.isNaN(index) ? null : index + }, + () => { + this.rebuildSankey(); + } + ); + }, + + onToggleSourceDesc(e) { + const index = Number(e.currentTarget.dataset.index); + if (Number.isNaN(index)) { + return; + } + const current = this.data.sourceDescriptionColumns || []; + const exists = current.indexOf(index) >= 0; + const next = exists + ? current.filter((item) => item !== index) + : current.concat(index).sort((a, b) => a - b); + this.setData( + { + sourceDescriptionColumns: next + }, + () => { + this.rebuildSankey(); + } + ); + }, + + onToggleTargetDesc(e) { + const index = Number(e.currentTarget.dataset.index); + if (Number.isNaN(index)) { + return; + } + const current = this.data.targetDescriptionColumns || []; + const exists = current.indexOf(index) >= 0; + const next = exists + ? current.filter((item) => item !== index) + : current.concat(index).sort((a, b) => a - b); + this.setData( + { + targetDescriptionColumns: next + }, + () => { + this.rebuildSankey(); + } + ); + }, + + /** + * 根据当前映射重建聚合结果,输出统计与告警。 + */ + rebuildSankey() { + const headers = this.data.columnHeaders || []; + const rows = this.data.tableRows || []; + if (rows.length === 0) { + this.setData({ + nodesCount: 0, + linksCount: 0, + droppedRows: 0, + buildWarnings: [], + buildError: '', + sankeyLinks: [], + sankeyNodes: [] + }); + this.refreshInfoLogs(); + this.drawSankey(); + return; + } + + try { + const result = buildSankeyData( + { + headers, + rows + }, + { + sourceDataColumn: this.data.sourceDataColumn, + sourceDescriptionColumns: this.data.sourceDescriptionColumns || [], + targetDescriptionColumns: this.data.targetDescriptionColumns || [], + delimiter: '-' + } + ); + + this.setData({ + nodesCount: result.nodes.length, + linksCount: result.links.length, + droppedRows: result.meta.droppedRows, + buildWarnings: (result.meta.warnings || []).slice(0, 8), + buildError: '', + sankeyLinks: result.links || [], + sankeyNodes: result.nodes || [] + }); + } catch (error) { + this.setData({ + nodesCount: 0, + linksCount: 0, + droppedRows: 0, + buildWarnings: [], + buildError: error && error.message ? error.message : '聚合失败', + sankeyLinks: [], + sankeyNodes: [] + }); + } + + this.refreshInfoLogs(); + this.drawSankey(); + }, + + /** + * 汇总日志到“信息日志”区域。 + */ + refreshInfoLogs() { + const logs = []; + const rows = this.data.tableRows || []; + const headers = this.data.columnHeaders || []; + if (rows.length > 0) { + logs.push(`解析信息: 已加载 ${rows.length} 行,${headers.length} 列`); + logs.push(`解析信息: 已生成 ${this.data.nodesCount} 个节点,${this.data.linksCount} 条连线`); + } else { + logs.push('解析信息: 尚未加载数据文件'); + } + + if (this.data.parseError) { + logs.push(`错误: ${this.data.parseError}`); + } + if (this.data.buildError) { + logs.push(`错误: ${this.data.buildError}`); + } + if (this.data.droppedRows > 0) { + logs.push(`告警: 已跳过 ${this.data.droppedRows} 行异常数据`); + } + + (this.data.buildWarnings || []).forEach((warning) => { + logs.push(`告警: ${warning}`); + }); + + this.setData({ + infoLogs: logs.length > 0 ? logs : ['暂无日志'] + }); + }, + + /** + * 在小程序 canvas 上绘制简化版桑基图。 + * 说明: + * - 不依赖第三方图表库,保证小程序端零额外依赖可运行 + * - 布局规则尽量对齐 Web:左 source、右 target、按值缩放高度 + */ + drawSankey() { + const rawLinks = this.data.sankeyLinks || []; + const links = applyDirection(rawLinks, this.data.direction); + const query = wx.createSelectorQuery().in(this); + query.select('#sankeyCanvas').boundingClientRect(); + query.exec((res) => { + const rect = res && res[0]; + if (!rect || !rect.width || !rect.height) { + return; + } + + const width = rect.width; + const height = rect.height; + const ctx = wx.createCanvasContext('sankeyCanvas', this); + ctx.clearRect(0, 0, width, height); + ctx.setFillStyle('#f7f8fa'); + ctx.fillRect(0, 0, width, height); + + const layout = buildSankeyLayout(links, width, height, { + nodeGap: this.data.nodeGap, + chartPadding: this.data.chartPadding, + targetAlignMode: this.data.targetAlignMode + }); + if (!layout) { + ctx.setFillStyle('#86909c'); + ctx.setFontSize(12); + ctx.fillText('暂无可预览数据', 12, 24); + ctx.draw(); + return; + } + + layout.linkSegments.forEach((segment) => { + ctx.setStrokeStyle('rgba(155,107,194,0.35)'); + ctx.setLineWidth(segment.linkHeight); + ctx.setLineCap('round'); + ctx.beginPath(); + ctx.moveTo(segment.startX, segment.sy); + ctx.bezierCurveTo( + segment.controlX, + segment.sy, + segment.controlX, + segment.ty, + segment.endX, + segment.ty + ); + ctx.stroke(); + }); + + layout.sourceNames.forEach((name, index) => { + const node = layout.sourcePos[name]; + const labelPlacement = getCanvasLabelPlacement(layout, true, this.data.labelPositionMode); + ctx.setFillStyle(getNodeColor(index)); + ctx.fillRect(layout.leftX, node.y, layout.nodeWidth, node.h); + ctx.setFillStyle('#4e5969'); + ctx.setFontSize(10); + ctx.setTextAlign(labelPlacement.textAlign); + ctx.setTextBaseline('middle'); + ctx.fillText(name, labelPlacement.x, node.y + getNodeLabelCenterY(node.h)); + }); + + layout.targetNames.forEach((name, index) => { + const node = layout.targetPos[name]; + const labelPlacement = getCanvasLabelPlacement(layout, false, this.data.labelPositionMode); + ctx.setFillStyle(getNodeColor(index)); + ctx.fillRect(layout.rightX, node.y, layout.nodeWidth, node.h); + ctx.setFillStyle('#4e5969'); + ctx.setFontSize(10); + ctx.setTextAlign(labelPlacement.textAlign); + ctx.setTextBaseline('middle'); + ctx.fillText(name, labelPlacement.x, node.y + getNodeLabelCenterY(node.h)); + }); + + ctx.draw(); + }); + }, + + /** + * 小程序端目前仅支持 PNG 导出(保存到相册)。 + */ + onExportPng() { + const that = this; + wx.canvasToTempFilePath( + { + canvasId: 'sankeyCanvas', + fileType: 'png', + success(res) { + wx.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success() { + wx.showToast({ + title: 'PNG 已保存到相册', + icon: 'success' + }); + }, + fail(err) { + wx.showToast({ + title: err && err.errMsg ? '保存失败,请检查相册权限' : '保存失败', + icon: 'none' + }); + } + }); + }, + fail() { + wx.showToast({ + title: '导出 PNG 失败', + icon: 'none' + }); + } + }, + that + ); + }, + + /** + * 小程序 SVG 导出: + * - 使用与画布一致的布局算法生成 SVG 字符串 + * - 写入用户数据目录,支持后续转发/分享 + */ + onExportSvg() { + const rawLinks = this.data.sankeyLinks || []; + const links = applyDirection(rawLinks, this.data.direction); + if (!Array.isArray(links) || links.length === 0) { + wx.showToast({ + title: '暂无可导出的数据', + icon: 'none' + }); + return; + } + + const query = wx.createSelectorQuery().in(this); + query.select('#sankeyCanvas').boundingClientRect(); + query.exec((res) => { + const rect = res && res[0]; + if (!rect || !rect.width || !rect.height) { + wx.showToast({ + title: '导出 SVG 失败:画布尺寸无效', + icon: 'none' + }); + return; + } + + const svgText = buildSankeySvgText(links, rect.width, rect.height, { + nodeGap: this.data.nodeGap, + chartPadding: this.data.chartPadding, + labelPositionMode: this.data.labelPositionMode, + targetAlignMode: this.data.targetAlignMode + }); + if (!svgText) { + wx.showToast({ + title: '暂无可导出的数据', + icon: 'none' + }); + return; + } + + const svgFilePath = `${wx.env.USER_DATA_PATH}/sankey_${formatFileTimestamp()}.svg`; + wx.getFileSystemManager().writeFile({ + filePath: svgFilePath, + data: svgText, + encoding: 'utf8', + success() { + wx.openDocument({ + filePath: svgFilePath, + showMenu: true, + success() { + wx.showToast({ + title: 'SVG 已导出', + icon: 'success' + }); + }, + fail() { + wx.setClipboardData({ + data: svgFilePath, + success() { + wx.showToast({ + title: 'SVG 已导出,路径已复制', + icon: 'none' + }); + }, + fail() { + wx.showToast({ + title: 'SVG 已导出', + icon: 'success' + }); + } + }); + } + }); + }, + fail(err) { + wx.showToast({ + title: `导出 SVG 失败: ${err && err.errMsg ? err.errMsg : '未知错误'}`, + icon: 'none' + }); + } + }); + }); } }); diff --git a/miniapp/pages/index/index.wxml b/miniapp/pages/index/index.wxml index 9556135..6e90231 100644 --- a/miniapp/pages/index/index.wxml +++ b/miniapp/pages/index/index.wxml @@ -1,85 +1,149 @@ - - + + - - 选择主题 - + + + + + {{uploadMessage}} - - - 文件上传 - - + - - - + + + - - 效果预览 - - + + + - - - 源数据 - - - - 数据列 + + + + + + {{gapOptions[gapOptionIndex]}} + + + - - + + + + + {{paddingOptions[paddingOptionIndex]}} + + + + + + + + + + + + {{direction === 'source-to-target' ? 'source' : 'target'}} + + + + + + + + + + + + {{labelPositionOptionLabels[labelPositionIndex]}} + + + + + + + + + + + + + {{targetAlignOptionLabels[targetAlignIndex]}} + + + + + + + + {{buildError}} + {{parseError}} + + + + + + + + + + + 源数据(link value) + + + {{item}} - + - - 描述列 + + 源标签(Source label) - - - + + {{item}} + + + + + + + 目标标签(target label) + + + + {{item}} + - - 目标数据 - - - - 描述列 - - - - - {{item}} - - + + + + {{item}} @@ -91,7 +155,7 @@ 选择配色主题 diff --git a/miniapp/pages/index/index.wxss b/miniapp/pages/index/index.wxss index 8a53ea9..c850bcc 100644 --- a/miniapp/pages/index/index.wxss +++ b/miniapp/pages/index/index.wxss @@ -1,5 +1,8 @@ .page { + min-height: 100vh; padding: 8px; + background: #f3f4f6; + box-sizing: border-box; } .header { @@ -13,50 +16,77 @@ width: 48px; height: 48px; border-radius: 12px; + flex-shrink: 0; } .title { width: 160px; + height: auto; } .toolbar { margin-top: 8px; + height: 38px; display: flex; align-items: center; gap: 6px; -} - -.tool-item { - display: flex; - align-items: center; - gap: 3px; - font-size: 12px; + padding-left: 6px; } .tool-icon { width: 24px; height: 24px; + flex-shrink: 0; } -.tiny-icon { - width: 10px; - height: 10px; +.upload-trigger { + width: 32px; +} + +.upload-box { + flex: 1; + min-width: 0; + height: 28px; + border-radius: 6px; + border: 0.5px solid #4e5969; + background: #f7f8fa; + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px; + box-sizing: border-box; +} + +.upload-text { + width: 100%; + color: #4e5969; + font-size: 10px; + line-height: 1; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toolbar-spacer { + flex: 1; + min-width: 0; } .export-box { - margin-left: auto; background: #fff; - border: 1px solid #e5e6eb; + border: 0.5px solid #e5e6eb; border-radius: 4px; padding: 2px 4px; display: flex; align-items: center; gap: 4px; + flex-shrink: 0; } .export-main { - width: 10px; - height: 20px; + width: 9px; + height: 21px; } .export-icon { @@ -64,44 +94,193 @@ height: 24px; } -.preview-block, -.block { +.preview-panel { margin-top: 8px; - border: 1px solid #fbaca3; - border-radius: 8px; + border: 0.4px solid #f99595; + border-radius: 6.4px; background: #fff; - padding: 4px; + padding: 3.2px; + box-sizing: border-box; } -.block-title { - font-size: 16px; +.preview-head { + display: flex; + align-items: center; + gap: 4px; +} + +.preview-title { + width: 166px; + height: auto; + flex-shrink: 0; +} + +.preview-controls { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; +} + +.preview-controls::-webkit-scrollbar { + display: none; +} + +.control-item, +.picker-item, +.direction-item { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.control-icon { + width: 14px; + height: 14px; +} + +.select-pill { + height: 14px; + border: 0.5px solid #c9aee0; + border-radius: 4px; + padding: 0 2px; + display: flex; + align-items: center; + gap: 2px; + color: #606060; + font-size: 12px; + line-height: 1; + box-sizing: border-box; +} + +.select-pill.wide { + min-width: 40px; +} + +.select-arrow { + width: 10px; + height: 8px; +} + +.vertical-label { + display: flex; + flex-direction: column; + color: #000; + font-size: 12px; font-weight: 600; + line-height: 1; +} + +.direction-switch { + min-width: 43px; + height: 14px; + border-radius: 58px; + background: #c9cdd4; + padding: 1px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1px; +} + +.direction-switch.on { + background: #9b6bc2; +} + +.direction-text { + color: #4e5969; + font-size: 8px; + line-height: 1; + padding: 0 1px; +} + +.direction-switch.on .direction-text { + color: #fff; +} + +.direction-thumb { + width: 11.67px; + height: 11.67px; + border-radius: 999px; + background: #fff; +} + +.direction-switch.on .direction-thumb { + margin-left: 0; +} + +.direction-switch:not(.on) { + flex-direction: row-reverse; +} + +.error-text { + margin-top: 4px; + color: #cb272d; + font-size: 12px; } .preview-canvas { - margin-top: 4px; - min-height: 300px; - background: #f7f8fa; + margin-top: 3px; + width: 100%; + height: 371px; border-radius: 4px; + background: #f7f8fa; } -.bottom-grid { +.bottom-panels { margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } -.field { - margin-top: 8px; +.panel { + border: 1px solid #f7e0e0; + border-radius: 16px; + background: #fff; + padding: 8px; + box-sizing: border-box; +} + +.data-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.log-panel { + display: flex; + flex-direction: column; + gap: 8px; +} + +.panel-title { + width: 128px; + height: auto; +} + +.panel-title-log { + width: 123px; +} + +.field-group { + display: flex; + flex-direction: column; + gap: 6px; } .field-title { display: flex; align-items: center; gap: 4px; + color: #1d2129; font-size: 14px; - font-weight: 600; + font-weight: 500; } .field-title image { @@ -114,32 +293,54 @@ border-bottom: 1px solid #c9cdd4; display: flex; align-items: center; - gap: 6px; - padding-bottom: 6px; - margin-top: 4px; + gap: 8px; + padding-bottom: 8px; + box-sizing: border-box; } .row image { width: 18px; height: 18px; + flex-shrink: 0; } .label { flex: 1; + min-width: 0; color: #86909c; font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.log-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + overflow: auto; +} + +.log-item { + color: #1d2129; + font-size: 12px; + line-height: 1.4; } .footer { - margin-top: 8px; + margin-top: 6px; color: #86909c; font-size: 14px; + line-height: 1.3; } .theme-sheet-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.2); + z-index: 10; } .theme-sheet { @@ -150,6 +351,7 @@ background: #fff; border-radius: 16px 16px 0 0; padding: 12px; + z-index: 11; } .theme-title { diff --git a/miniapp/sitemap.json b/miniapp/sitemap.json index 96e0352..ede565d 100644 --- a/miniapp/sitemap.json +++ b/miniapp/sitemap.json @@ -1,4 +1,9 @@ { "desc": "星程桑基图小程序", - "rules": [] + "rules": [ + { + "action": "allow", + "page": "*" + } + ] } diff --git a/miniapp/utils/sankey.js b/miniapp/utils/sankey.js new file mode 100644 index 0000000..2475d60 --- /dev/null +++ b/miniapp/utils/sankey.js @@ -0,0 +1,320 @@ +/** + * 统一清洗文本,避免空格导致节点重复。 + */ +function normalizeText(value) { + return String(value || '').trim(); +} + +/** + * 尝试懒加载 xlsx 解析库。 + * 说明: + * - 小程序若未完成 npm 构建,此处会拿不到模块 + * - 使用缓存避免每次解析都重复 require + */ +let cachedXlsxModule; +function getXlsxModule() { + if (cachedXlsxModule !== undefined) { + return cachedXlsxModule; + } + try { + cachedXlsxModule = require('xlsx'); + } catch (error) { + cachedXlsxModule = null; + } + return cachedXlsxModule; +} + +/** + * 将二维数组统一整理为 headers + rows 结构。 + * 约定第一行为表头,后续行为数据行。 + */ +function toRawTable(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return { headers: [], rows: [] }; + } + + const firstRow = Array.isArray(rows[0]) ? rows[0] : []; + const maxColumns = rows.reduce((max, row) => { + const length = Array.isArray(row) ? row.length : 0; + return Math.max(max, length); + }, firstRow.length); + + const headers = Array.from({ length: maxColumns }, (_, index) => { + const header = normalizeText(firstRow[index] || ''); + return header || `列${index + 1}`; + }); + + const dataRows = rows.slice(1).map((row) => { + const safeRow = Array.isArray(row) ? row : []; + return Array.from({ length: maxColumns }, (_, index) => normalizeText(safeRow[index] || '')); + }); + + return { headers, rows: dataRows }; +} + +/** + * 解析数字,支持千分位。 + */ +function parseNumericValue(text) { + const normalized = normalizeText(text).replace(/,/g, ''); + if (!normalized) { + return null; + } + const parsed = Number(normalized); + if (Number.isNaN(parsed)) { + return null; + } + return parsed; +} + +/** + * 将单元格值格式化为日志可读文本。 + */ +function formatCellValueForWarning(value) { + const text = String(value || ''); + return text.length > 0 ? text : '(空)'; +} + +/** + * 组装“列位置 + 列名 + 原始值”的调试文本。 + */ +function buildColumnDebugText(row, headers, columns) { + if (!Array.isArray(columns) || columns.length === 0) { + return '未选择列'; + } + return columns + .map((columnIndex) => { + const headerName = headers[columnIndex] || `列${columnIndex + 1}`; + const rawValue = row[columnIndex] || ''; + return `第 ${columnIndex + 1} 列(${headerName})="${formatCellValueForWarning(rawValue)}"`; + }) + .join(','); +} + +/** + * 简单 CSV 解析(支持双引号与双引号转义)。 + */ +function parseCsvText(csvText) { + const text = String(csvText || '').replace(/^\uFEFF/, ''); + const rows = []; + let row = []; + let cell = ''; + let inQuotes = false; + + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + const next = text[i + 1]; + if (ch === '"') { + if (inQuotes && next === '"') { + cell += '"'; + i += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (!inQuotes && ch === ',') { + row.push(cell); + cell = ''; + continue; + } + + if (!inQuotes && (ch === '\n' || ch === '\r')) { + if (ch === '\r' && next === '\n') { + i += 1; + } + row.push(cell); + cell = ''; + rows.push(row); + row = []; + continue; + } + + cell += ch; + } + + row.push(cell); + rows.push(row); + + const normalizedRows = rows + .map((items) => items.map((item) => normalizeText(item))) + .filter((items) => items.some((item) => item.length > 0)); + + return toRawTable(normalizedRows); +} + +/** + * 解析 xls/xlsx 二进制内容。 + */ +function parseXlsxBuffer(buffer) { + const xlsx = getXlsxModule(); + if (!xlsx) { + throw new Error('当前环境未启用 xlsx 解析,请先在开发者工具执行“构建 npm”'); + } + + const workbook = xlsx.read(buffer, { type: 'array' }); + const firstSheetName = workbook.SheetNames[0]; + if (!firstSheetName) { + throw new Error('Excel 文件中没有工作表'); + } + + const sheet = workbook.Sheets[firstSheetName]; + const rows = xlsx.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + defval: '' + }); + return toRawTable(rows); +} + +/** + * 按文件名后缀自动分流解析器。 + */ +function parseTableByFileName(fileName, payload) { + const lowerName = String(fileName || '').toLowerCase(); + if (lowerName.endsWith('.csv')) { + return parseCsvText(String(payload || '')); + } + if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) { + return parseXlsxBuffer(payload); + } + throw new Error('仅支持 .csv / .xlsx / .xls 文件'); +} + +/** + * 构建 source 名称。 + */ +function buildSourceName(row, config) { + if (!Array.isArray(config.sourceDescriptionColumns) || config.sourceDescriptionColumns.length === 0) { + return normalizeText(row[config.sourceDataColumn] || ''); + } + const parts = config.sourceDescriptionColumns + .map((column) => normalizeText(row[column] || '')) + .filter((item) => item.length > 0); + return parts.join(config.delimiter || '-'); +} + +/** + * 构建 target 名称,支持向下补全。 + */ +function buildTargetName(row, config, lastNonEmptyTargetValueByColumn) { + const parts = (config.targetDescriptionColumns || []) + .map((column) => { + const raw = normalizeText(row[column] || ''); + if (raw.length > 0) { + lastNonEmptyTargetValueByColumn[column] = raw; + return raw; + } + return lastNonEmptyTargetValueByColumn[column] || ''; + }) + .filter((item) => item.length > 0); + return parts.join(config.delimiter || '-'); +} + +/** + * 与 Web 端保持一致的聚合规则。 + */ +function buildSankeyData(table, config) { + if (config.sourceDataColumn === null || config.sourceDataColumn === undefined) { + throw new Error('必须选择源数据列'); + } + if (!Array.isArray(config.targetDescriptionColumns) || config.targetDescriptionColumns.length === 0) { + throw new Error('必须至少选择一个目标描述列'); + } + + const sourceDataColumnIndex = config.sourceDataColumn; + const sourceDataColumnName = table.headers[sourceDataColumnIndex] || `列${sourceDataColumnIndex + 1}`; + const linkValueMap = {}; + const warnings = []; + let droppedRows = 0; + const lastNonEmptyTargetValueByColumn = {}; + + (table.rows || []).forEach((row, rowIndex) => { + const excelRow = rowIndex + 2; + const sourceCellRaw = row[sourceDataColumnIndex] || ''; + const sourceValue = parseNumericValue(sourceCellRaw); + + if (sourceValue === null) { + warnings.push( + `第 ${excelRow} 行, 第 ${sourceDataColumnIndex + 1} 列(${sourceDataColumnName}): 源数据不是有效数字,原始值="${formatCellValueForWarning(sourceCellRaw)}",已跳过` + ); + droppedRows += 1; + return; + } + + const sourceName = buildSourceName(row, config); + if (!sourceName) { + warnings.push( + `第 ${excelRow} 行: 源描述为空,字段=${buildColumnDebugText( + row, + table.headers || [], + config.sourceDescriptionColumns || [] + )},已跳过` + ); + droppedRows += 1; + return; + } + + const targetName = buildTargetName(row, config, lastNonEmptyTargetValueByColumn); + if (!targetName) { + warnings.push( + `第 ${excelRow} 行: 目标描述为空,字段=${buildColumnDebugText( + row, + table.headers || [], + config.targetDescriptionColumns || [] + )},且无可继承的上方值,已跳过` + ); + droppedRows += 1; + return; + } + + const key = `${sourceName}@@${targetName}`; + linkValueMap[key] = (linkValueMap[key] || 0) + sourceValue; + }); + + const links = []; + const sourceSet = {}; + const targetSet = {}; + Object.keys(linkValueMap).forEach((key) => { + const pair = key.split('@@'); + const source = pair[0]; + const target = pair[1]; + if (!source || !target) { + return; + } + sourceSet[source] = true; + targetSet[target] = true; + links.push({ + source, + target, + value: linkValueMap[key] + }); + }); + + const nodes = []; + Object.keys(sourceSet).forEach((name) => { + nodes.push({ name, kind: 'source' }); + }); + Object.keys(targetSet).forEach((name) => { + if (!sourceSet[name]) { + nodes.push({ name, kind: 'target' }); + } + }); + + return { + nodes, + links, + meta: { + droppedRows, + warnings + } + }; +} + +module.exports = { + parseCsvText, + parseXlsxBuffer, + parseTableByFileName, + buildSankeyData +}; diff --git a/progress.txt b/progress.txt index 141c7d6..751e9f2 100644 --- a/progress.txt +++ b/progress.txt @@ -1,4 +1,4 @@ -[更新时间] 2026-02-13(第三次更新) +[更新时间] 2026-02-13(第四次更新) [项目] 星程桑基图 一、已完成(Done) @@ -13,7 +13,7 @@ 5. 已实现导出:PNG/SVG(带时间戳命名)。 6. 已实现默认样例加载:页面首次进入自动读取 `data/example0.xlsx`。 7. 已有核心单测(parser + sankey 聚合 + xlsx 读取)。 -8. 小程序端已完成视觉骨架(非完整业务)。 +8. 小程序端已从视觉骨架升级为可用流程(上传 -> 映射 -> 预览 -> 导出)。 9. 已实现本地持久化:用户上传文件、映射配置与预览选项会写入 localStorage,刷新后自动恢复。 10. 已新增“汇聚对齐”配置(Between/Middle/Top/Bottom),可控制 target 侧对齐,且 gap 作为源侧基准。 11. 已优化“无配置初始化映射”:优先按表头别名自动匹配,缺失时按第二行首个数字列兜底。 @@ -31,6 +31,19 @@ 20. 已完成手机端控件压缩:隐藏上传文本框;工具条单行滚动显示;预览区 gap/padding 改为下拉(0-30 步长 5 / 0-80 步长 10);方向改为“源/目标”;标签位置改为“内/外/左/右”;汇聚对齐改为“两端/中间/顶部/底部”;控件尽量单行展示。 21. 已强制手机端仅使用下拉控件模式:手机视口不渲染 gap/padding 滑动条与方向拨杆,仅渲染下拉选择,避免样式条件失效时回退到滑动条模式。 22. 已扩展到平板单行模式:`<=1024px` 下工具条与预览控件均单行展示(不换行,超出横向滚动),并压缩滑动条宽度以提升一行容纳能力。 +23. 已按 Figma(3764:138) 调整 switch 与下拉样式:switch 改为紧凑胶囊尺寸;下拉统一替换 `list.svg` 箭头并改为 hug/fit-content 宽度,控件宽度随内容变化。 +24. 已恢复手机端上传文本框显示,并缩窄为单行可容纳宽度(固定窄幅 + 文本省略),保持工具条一行布局。 +25. 已进一步压缩手机工具条以避免整体溢出:隐藏工具标签文本、缩小主题/上传按钮与导出按钮、上传框改为弹性窄宽度,工具条取消横向滚动并保持单行。 +26. 已继续收紧 switch/下拉宽度:移除 switch 最小宽度与手机下拉最小宽度,统一按内容自适应(hug)显示。 +27. 已恢复手机端 `export.svg` 主图标显示,并缩小尺寸以兼顾单行工具条布局。 +28. 已为顶部工具栏所有 SVG 图标补充悬停提示(title)与按钮 aria-label,鼠标悬停可显示操作含义。 +29. 已为预览框区域 SVG(预览标题、gap/padding 图标)补充悬停提示,并为相关控件补充 aria-label。 +30. 已统一 pad 与手机的 gap/padding 调节方式:`<=1024px` 统一使用下拉框(与手机一致),仅桌面保留滑动条。 +31. 已启动小程序可用化第一阶段:支持 CSV 上传、默认列自动映射、列选择交互、聚合统计与信息日志展示;聚合规则与 Web 端核心逻辑对齐。 +32. 已完成小程序第二阶段(基础渲染与导出):接入原生 canvas 桑基图绘制、PNG 导出到相册。 +33. 已完成小程序第三阶段(格式与导出补齐): + - 上传解析支持 `csv/xls/xlsx` 三种格式。 + - 小程序端接入真实 SVG 导出:按当前布局生成 SVG 文件并写入用户目录,优先尝试打开,失败时复制文件路径。 二、当前状态(In Progress) 1. 无进行中的代码重构任务。 @@ -46,12 +59,12 @@ 1. 本地持久化基于 localStorage,受浏览器容量限制;超大文件可能无法完整保存。 2. Vite 开发配置依赖本机 HTTPS 证书路径,换机器可能无法直接启动。 3. 当前“目标数据”无独立数值列,数值始终来自 source data 列;若未来业务需要需先改 PRD。 -4. 小程序仅骨架,尚未接入真实解析、渲染与导出。 +4. 小程序 xlsx 解析依赖开发者工具“构建 npm”;若未构建将无法读取 xls/xlsx 文件。 5. 信息日志当前仅展示最近告警(前 8 条),若后续需要完整历史需引入分页或虚拟滚动。 四、下一步建议(Next) 1. 决策是否引入“目标数值列”能力(先更新 PRD 后实现)。 -2. 将小程序由骨架升级为可用版本(优先复用 `src/core`)。 +2. 小程序下一阶段:补充持久化恢复(上传文件与映射配置刷新不丢)。 3. 补充更多异常用例测试(空文件、超大文件、乱码表头、极端数值)。 4. 评估并处理 dev HTTPS 证书本地耦合问题,降低新环境接入成本。 5. 评估“信息日志”是否需要支持导出或清空操作。 diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..4ffee60 --- /dev/null +++ b/project.config.json @@ -0,0 +1,19 @@ +{ + "description": "sankey miniapp project config", + "packOptions": { + "ignore": [] + }, + "setting": { + "es6": true, + "enhance": true, + "postcss": true, + "minified": true + }, + "compileType": "miniprogram", + "libVersion": "trial", + "appid": "wxcf0f89b6eb65759e", + "projectname": "sankey", + "miniprogramRoot": "miniapp/", + "srcMiniprogramRoot": "miniapp/", + "condition": {} +} diff --git a/src/App.vue b/src/App.vue index b2998af..d3af834 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,8 +9,14 @@
选择主题 -
文件上传 -
@@ -79,12 +91,24 @@
- export - -
@@ -215,21 +239,26 @@
- 桑基图预览 + 桑基图预览
-