update at 2026-02-13 22:26:53
This commit is contained in:
@@ -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, '"')
|
||||
.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(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${formatSvgNumber(width)}" height="${formatSvgNumber(
|
||||
height
|
||||
)}" viewBox="0 0 ${formatSvgNumber(width)} ${formatSvgNumber(height)}">`
|
||||
);
|
||||
segments.push(
|
||||
`<rect x="0" y="0" width="${formatSvgNumber(width)}" height="${formatSvgNumber(height)}" fill="#f7f8fa" />`
|
||||
);
|
||||
|
||||
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(
|
||||
`<path d="${pathData}" fill="none" stroke="#9b6bc2" stroke-opacity="0.35" stroke-width="${formatSvgNumber(
|
||||
segment.linkHeight
|
||||
)}" stroke-linecap="round" />`
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
`<rect x="${formatSvgNumber(layout.leftX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
|
||||
layout.nodeWidth
|
||||
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(index)}" />`
|
||||
);
|
||||
segments.push(
|
||||
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
|
||||
textPlacement.textAnchor
|
||||
}" dominant-baseline="middle">${escapeSvgText(name)}</text>`
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
`<rect x="${formatSvgNumber(layout.rightX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
|
||||
layout.nodeWidth
|
||||
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(index)}" />`
|
||||
);
|
||||
segments.push(
|
||||
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
|
||||
textPlacement.textAnchor
|
||||
}" dominant-baseline="middle">${escapeSvgText(name)}</text>`
|
||||
);
|
||||
});
|
||||
|
||||
segments.push('</svg>');
|
||||
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'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,85 +1,149 @@
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<image class="logo" src="/assets/icons/webicon.png" mode="aspectFill" />
|
||||
<image class="title" src="/assets/icons/星程桑基图.svg" mode="widthFix" />
|
||||
<image class="logo" src="../../assets/icons/webicon.png" mode="aspectFill" />
|
||||
<image class="title" src="../../assets/icons/星程桑基图.svg" mode="widthFix" />
|
||||
</view>
|
||||
|
||||
<view class="toolbar">
|
||||
<view class="tool-item" bindtap="onToggleThemeSheet">
|
||||
<text>选择主题</text>
|
||||
<image class="tool-icon" src="/assets/icons/choose-color.svg" mode="aspectFit" />
|
||||
<image class="tool-icon" src="../../assets/icons/choose-color.svg" mode="aspectFit" bindtap="onToggleThemeSheet" />
|
||||
<image class="tool-icon upload-trigger" src="../../assets/icons/upload.svg" mode="aspectFit" bindtap="onChooseFile" />
|
||||
|
||||
<view class="upload-box" bindtap="onChooseFile">
|
||||
<text class="upload-text">{{uploadMessage}}</text>
|
||||
</view>
|
||||
|
||||
<view class="tool-item">
|
||||
<image class="tiny-icon" src="/assets/icons/content.svg" mode="aspectFit" />
|
||||
<text>文件上传</text>
|
||||
<image class="tool-icon" src="/assets/icons/upload.svg" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="toolbar-spacer" />
|
||||
|
||||
<view class="export-box">
|
||||
<image class="export-main" src="/assets/icons/export.svg" mode="aspectFit" />
|
||||
<image class="export-icon" src="/assets/icons/export-svg.svg" mode="aspectFit" />
|
||||
<image class="export-icon" src="/assets/icons/export-png.svg" mode="aspectFit" />
|
||||
<image class="export-main" src="../../assets/icons/export.svg" mode="aspectFit" />
|
||||
<image class="export-icon" src="../../assets/icons/export-svg.svg" mode="aspectFit" bindtap="onExportSvg" />
|
||||
<image class="export-icon" src="../../assets/icons/export-png.svg" mode="aspectFit" bindtap="onExportPng" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="preview-block">
|
||||
<text class="block-title">效果预览</text>
|
||||
<view class="preview-canvas" />
|
||||
</view>
|
||||
<view class="preview-panel">
|
||||
<view class="preview-head">
|
||||
<image class="preview-title" src="../../assets/icons/sankeyview.svg" mode="widthFix" />
|
||||
|
||||
<view class="bottom-grid">
|
||||
<view class="block">
|
||||
<text class="block-title">源数据</text>
|
||||
<view class="field">
|
||||
<view class="field-title">
|
||||
<image src="/assets/icons/expand.svg" mode="aspectFit" />
|
||||
<text>数据列</text>
|
||||
<view class="preview-controls">
|
||||
<view class="control-item">
|
||||
<image class="control-icon" src="../../assets/icons/gap.svg" mode="aspectFit" />
|
||||
<picker mode="selector" range="{{gapOptions}}" value="{{gapOptionIndex}}" bindchange="onChangeGap">
|
||||
<view class="select-pill">
|
||||
<text>{{gapOptions[gapOptionIndex]}}</text>
|
||||
<image class="select-arrow" src="../../assets/icons/list.svg" mode="aspectFit" />
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="row" wx:for="{{sourceColumns}}" wx:key="*this">
|
||||
<image src="/assets/icons/data.svg" mode="aspectFit" />
|
||||
<view class="control-item">
|
||||
<image class="control-icon" src="../../assets/icons/padding.svg" mode="aspectFit" />
|
||||
<picker mode="selector" range="{{paddingOptions}}" value="{{paddingOptionIndex}}" bindchange="onChangePadding">
|
||||
<view class="select-pill">
|
||||
<text>{{paddingOptions[paddingOptionIndex]}}</text>
|
||||
<image class="select-arrow" src="../../assets/icons/list.svg" mode="aspectFit" />
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="direction-item" bindtap="onToggleDirection">
|
||||
<view class="vertical-label">
|
||||
<text>方</text>
|
||||
<text>向</text>
|
||||
</view>
|
||||
<view class="direction-switch {{direction === 'source-to-target' ? 'on' : ''}}">
|
||||
<text class="direction-text">{{direction === 'source-to-target' ? 'source' : 'target'}}</text>
|
||||
<view class="direction-thumb" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="picker-item">
|
||||
<view class="vertical-label">
|
||||
<text>位</text>
|
||||
<text>置</text>
|
||||
</view>
|
||||
<picker mode="selector" range="{{labelPositionOptionLabels}}" value="{{labelPositionIndex}}" bindchange="onChangeLabelPosition">
|
||||
<view class="select-pill">
|
||||
<text>{{labelPositionOptionLabels[labelPositionIndex]}}</text>
|
||||
<image class="select-arrow" src="../../assets/icons/list.svg" mode="aspectFit" />
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="picker-item">
|
||||
<view class="vertical-label">
|
||||
<text>对</text>
|
||||
<text>齐</text>
|
||||
</view>
|
||||
<picker mode="selector" range="{{targetAlignOptionLabels}}" value="{{targetAlignIndex}}" bindchange="onChangeTargetAlign">
|
||||
<view class="select-pill wide">
|
||||
<text>{{targetAlignOptionLabels[targetAlignIndex]}}</text>
|
||||
<image class="select-arrow" src="../../assets/icons/list.svg" mode="aspectFit" />
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text wx:if="{{buildError}}" class="error-text">{{buildError}}</text>
|
||||
<text wx:if="{{parseError}}" class="error-text">{{parseError}}</text>
|
||||
<canvas class="preview-canvas" canvas-id="sankeyCanvas" id="sankeyCanvas" />
|
||||
</view>
|
||||
|
||||
<view class="bottom-panels">
|
||||
<view class="panel data-panel">
|
||||
<image class="panel-title" src="../../assets/icons/data-select.svg" mode="widthFix" />
|
||||
|
||||
<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="{{sourceDataIndex === index ? '/assets/icons/radiobutton.svg' : '/assets/icons/radiobutton-no.svg'}}"
|
||||
src="{{sourceDataColumn === index ? '../../assets/icons/radiobutton.svg' : '../../assets/icons/radiobutton-no.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<view class="field-group">
|
||||
<view class="field-title">
|
||||
<image src="/assets/icons/expand.svg" mode="aspectFit" />
|
||||
<text>描述列</text>
|
||||
<image src="../../assets/icons/expand.svg" mode="aspectFit" />
|
||||
<text>源标签(Source label)</text>
|
||||
</view>
|
||||
|
||||
<view class="row" wx:for="{{sourceColumns}}" wx:key="*this">
|
||||
<image src="/assets/icons/description.svg" mode="aspectFit" />
|
||||
<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="{{sourceDescChecked.indexOf(index) > -1 ? '/assets/icons/checkbox.svg' : '/assets/icons/checkbox-no.svg'}}"
|
||||
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>
|
||||
<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>
|
||||
|
||||
<view class="block">
|
||||
<text class="block-title">目标数据</text>
|
||||
<view class="field">
|
||||
<view class="field-title">
|
||||
<image src="/assets/icons/expand.svg" mode="aspectFit" />
|
||||
<text>描述列</text>
|
||||
</view>
|
||||
|
||||
<view class="row" wx:for="{{targetColumns}}" wx:key="*this">
|
||||
<image src="/assets/icons/description.svg" mode="aspectFit" />
|
||||
<text class="label">{{item}}</text>
|
||||
<image
|
||||
src="{{targetDescChecked.indexOf(index) > -1 ? '/assets/icons/checkbox.svg' : '/assets/icons/checkbox-no.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="panel log-panel">
|
||||
<image class="panel-title panel-title-log" src="../../assets/icons/information.svg" mode="widthFix" />
|
||||
<view class="log-list">
|
||||
<text class="log-item" wx:for="{{infoLogs}}" wx:key="index">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -91,7 +155,7 @@
|
||||
<text class="theme-title">选择配色主题</text>
|
||||
<view class="theme-row" wx:for="{{4}}" wx:key="index">
|
||||
<image
|
||||
src="{{selectedThemeIndex === index ? '/assets/icons/radiobutton.svg' : '/assets/icons/radiobutton-no.svg'}}"
|
||||
src="{{selectedThemeIndex === index ? '../../assets/icons/radiobutton.svg' : '../../assets/icons/radiobutton-no.svg'}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="theme-bar" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user