Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94aa37e612 | ||
|
|
606610f8d3 | ||
|
|
7a03ecc063 | ||
|
|
d7c7c08407 | ||
|
|
6b5223daec |
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
logs/
|
||||
data/
|
||||
*.png
|
||||
*.svg
|
||||
@@ -32,7 +32,7 @@ Content-Type: application/json
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|:----:|--------|------|
|
||||
| `data` | string | ✅ | — | CSV 格式数据,`\n` 换行,第一行表头,第一列横坐标 |
|
||||
| `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` |
|
||||
| `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` / `pie` / `radar` |
|
||||
| `title` | string | — | `""` | 图表标题 |
|
||||
| `theme` | string | — | `default` | 主题风格:`default` / `dark` / `macarons` / `gradient` / `retro` |
|
||||
| `showLegend` | boolean | — | `true` | 显示图例 |
|
||||
@@ -159,7 +159,7 @@ curl "http://192.168.0.101:16016/api/chart?data=%E4%BA%A7%E5%93%81,Q1,Q2%0A%E6%8
|
||||
|
||||
## 3. POST /api/combine(多图合并)
|
||||
|
||||
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(左右并排)** 或 **竖排(上下堆叠)** 两种方向,返回 PNG 图片。
|
||||
将**多张图表**合并到一张图片中(默认 2 张,可多张),支持**横排(单行)**、**竖排(单列)**、**多行多列网格**三种排布,返回 PNG 图片。
|
||||
|
||||
### 请求
|
||||
|
||||
@@ -173,14 +173,15 @@ Content-Type: application/json
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `charts` | array | ✅ | 图表配置数组(N 张),每项同 `/api/chart` 参数:data/chartType/title/theme 等 |
|
||||
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 |
|
||||
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 / `grid` 多行多列 |
|
||||
| `cols` | number | 否 | 多行多列时的每行列数(默认 2,仅 `grid` 生效) |
|
||||
| `gap` | number | 否 | 图间距(默认 24px) |
|
||||
| `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) |
|
||||
| `background` | string | 否 | 背景色,默认 `#ffffff` |
|
||||
|
||||
> 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`)。
|
||||
|
||||
每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`。
|
||||
每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line/pie/radar)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`。
|
||||
|
||||
### curl 示例
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
- **柱状图** - 支持单系列/多系列对比
|
||||
- **折线图** - 支持平滑曲线、面积填充
|
||||
- **混合图** - 柱状图+折线图组合展示
|
||||
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
|
||||
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
|
||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
|
||||
|
||||
### 📋 表格功能
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
### 🖼️ 多图合并
|
||||
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
|
||||
- 支持**横排(左右并排)** 和 **竖排(上下堆叠)**,一键切换实时预览
|
||||
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
|
||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
||||
- 支持导出 PNG
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 初始化导出设置
|
||||
renderHistory();
|
||||
refreshExportHint();
|
||||
|
||||
// 收藏:URL 带 id 时,在新标签页加载该收藏配置进行再次制作
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const favId = urlParams.get('id');
|
||||
if (favId) {
|
||||
loadFavoriteForEdit(favId);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 数据解析 =====
|
||||
@@ -157,6 +164,91 @@ function initChart() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 饼图/雷达图 option 构建 =====
|
||||
function buildPieRadarOption(chartType) {
|
||||
if (!parsedData || parsedData.seriesNames.length === 0) return null;
|
||||
const title = document.getElementById('chartTitle').value;
|
||||
const theme = document.getElementById('themeStyle').value;
|
||||
const showLegend = document.getElementById('showLegend').checked;
|
||||
const showLabel = document.getElementById('showLabel').checked;
|
||||
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
||||
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
const palette = colorPalettes[theme] || colorPalettes.default;
|
||||
const colors = seriesOrder.map((origIdx, di) => seriesColors[origIdx] || palette[di % palette.length]);
|
||||
|
||||
const baseTitle = title ? {
|
||||
text: title, left: 'center', top: 10,
|
||||
textStyle: { color: textColor, fontSize: 18, fontWeight: 600 }
|
||||
} : undefined;
|
||||
|
||||
if (chartType === 'pie') {
|
||||
// 饼图:第一列=名称,第一个系列=数值
|
||||
const sName = parsedData.seriesNames[0];
|
||||
const data = parsedData.seriesData[sName];
|
||||
const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
title: baseTitle,
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: showLegend ? {
|
||||
orient: 'vertical', left: 'left', top: title ? 50 : 20,
|
||||
textStyle: { color: textColor, fontSize: 12 }
|
||||
} : { show: false },
|
||||
color: colors,
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['38%', '68%'],
|
||||
center: ['52%', '55%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
|
||||
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
|
||||
labelLine: { show: showLabel },
|
||||
data: pieData
|
||||
}]
|
||||
};
|
||||
} else {
|
||||
// 雷达图:第一列=维度名,每个系列=一个雷达多边形
|
||||
const indicator = parsedData.categories.map(c => {
|
||||
let max = 0;
|
||||
parsedData.seriesNames.forEach(n => {
|
||||
parsedData.seriesData[n].forEach(v => { if (v > max) max = v; });
|
||||
});
|
||||
return { name: c, max: Math.ceil(max * 1.2) || 100 };
|
||||
});
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
title: baseTitle,
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: showLegend ? {
|
||||
orient: 'horizontal', left: 'center', top: title ? 48 : 15,
|
||||
textStyle: { color: textColor, fontSize: 12 }
|
||||
} : { show: false },
|
||||
color: colors,
|
||||
radar: {
|
||||
indicator,
|
||||
radius: '62%',
|
||||
center: ['50%', '55%'],
|
||||
splitNumber: 5,
|
||||
axisName: { color: textColor, fontSize: 12 },
|
||||
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
|
||||
}
|
||||
},
|
||||
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
|
||||
},
|
||||
series: parsedData.seriesNames.map(n => ({
|
||||
type: 'radar',
|
||||
name: n,
|
||||
data: [{ value: parsedData.seriesData[n], name: n }],
|
||||
symbolSize: 4,
|
||||
areaStyle: { opacity: 0.15 }
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 更新图表 =====
|
||||
function updateChart() {
|
||||
if (!parsedData || !chartInstance) return;
|
||||
@@ -174,6 +266,16 @@ function updateChart() {
|
||||
const leftAxisName = document.getElementById('leftAxisName').value;
|
||||
const rightAxisName = document.getElementById('rightAxisName').value;
|
||||
|
||||
// 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割)
|
||||
if (chartType === 'pie' || chartType === 'radar') {
|
||||
const option = buildPieRadarOption(chartType);
|
||||
if (option) {
|
||||
chartInstance.setOption(option, true);
|
||||
refreshPreview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示/隐藏分割配置
|
||||
document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none';
|
||||
|
||||
@@ -1016,7 +1118,7 @@ function quickSwitchTheme(theme) {
|
||||
document.getElementById('tableTheme').value = theme;
|
||||
updateTable();
|
||||
}
|
||||
// 双图合并模式:仅切换按钮高亮,不联动(两图各自独立主题)
|
||||
// 多图合并模式:仅切换按钮高亮,不联动(各图独立主题)
|
||||
}
|
||||
|
||||
// ===== 同步风格按钮状态(下拉框变更时) =====
|
||||
@@ -1039,7 +1141,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initThemeButtons();
|
||||
});
|
||||
|
||||
// ===== 双图合并 =====
|
||||
// ===== 多图合并 =====
|
||||
let combinedCanvas = null;
|
||||
|
||||
// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用)
|
||||
@@ -1058,6 +1160,66 @@ function buildCombineChartOption(dataText, cfg) {
|
||||
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
|
||||
const palette = colorPalettes[theme] || colorPalettes.default;
|
||||
|
||||
// 饼图/雷达图:专用构建逻辑
|
||||
if (chartType === 'pie' || chartType === 'radar') {
|
||||
if (chartType === 'pie') {
|
||||
const sName = parsed.seriesNames[0];
|
||||
const data = parsed.seriesData[sName];
|
||||
const pieData = parsed.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 40 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
|
||||
color: palette,
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['38%', '68%'],
|
||||
center: ['55%', '55%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
|
||||
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
|
||||
labelLine: { show: showLabel },
|
||||
data: pieData
|
||||
}],
|
||||
animation: false
|
||||
};
|
||||
} else {
|
||||
const indicator = parsed.categories.map(c => {
|
||||
let max = 0;
|
||||
parsed.seriesNames.forEach(n => { parsed.seriesData[n].forEach(v => { if (v > max) max = v; }); });
|
||||
return { name: c, max: Math.ceil(max * 1.2) || 100 };
|
||||
});
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 38 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
|
||||
color: palette,
|
||||
radar: {
|
||||
indicator,
|
||||
radius: '62%',
|
||||
center: ['50%', '55%'],
|
||||
splitNumber: 5,
|
||||
axisName: { color: textColor, fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
|
||||
}
|
||||
},
|
||||
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
|
||||
},
|
||||
series: parsed.seriesNames.map(n => ({
|
||||
type: 'radar', name: n,
|
||||
data: [{ value: parsed.seriesData[n], name: n }],
|
||||
symbolSize: 4,
|
||||
areaStyle: { opacity: 0.15 }
|
||||
})),
|
||||
animation: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const series = parsed.seriesNames.map((name, idx) => {
|
||||
const color = palette[idx % palette.length];
|
||||
let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType;
|
||||
@@ -1177,7 +1339,7 @@ function initCombineCharts() {
|
||||
renderCombineCharts();
|
||||
}
|
||||
|
||||
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合']];
|
||||
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合'], ['pie', '饼图'], ['radar', '雷达图']];
|
||||
const COMBINE_THEMES = [['default', '默认'], ['dark', '深色'], ['macarons', '马卡龙'], ['gradient', '渐变'], ['retro', '复古'], ['ocean', '海洋'], ['forest', '森林'], ['sunset', '日落'], ['lavender', '薰衣草'], ['minimal', '极简'], ['cherry', '樱花'], ['midnight', '午夜'], ['gold', '金色'], ['coral', '珊瑚'], ['mint', '薄荷'], ['slate', '石板灰'], ['sky', '天空蓝'], ['rose', '玫瑰红'], ['amber', '琥珀黄'], ['emerald', '翡翠绿'], ['indigo', '靛蓝色'], ['stone', '石灰白']];
|
||||
|
||||
function escHtml(str) {
|
||||
@@ -1245,6 +1407,13 @@ function removeCombineChart(i) {
|
||||
generateCombine();
|
||||
}
|
||||
|
||||
// 多行多列切换:显示/隐藏列数输入
|
||||
function onCombineGridToggle() {
|
||||
const grid = document.querySelector('input[name="combineDirection"]:checked').value === 'grid';
|
||||
document.getElementById('gridColsGroup').style.display = grid ? 'block' : 'none';
|
||||
generateCombine();
|
||||
}
|
||||
|
||||
// 生成合并图(多图)
|
||||
function generateCombine() {
|
||||
const validCharts = combineCharts.filter(c => c.data && c.data.trim());
|
||||
@@ -1284,34 +1453,55 @@ function generateCombine() {
|
||||
const m = measureCombineChart(c.data);
|
||||
return renderOne(c, m.w, m.h);
|
||||
})).then(canvases => {
|
||||
let W, H, scaled;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排:等宽
|
||||
const tw = Math.max(...canvases.map(c => c.width));
|
||||
scaled = canvases.map(c => ({ c, w: tw, h: Math.round(c.height * tw / c.width) }));
|
||||
W = tw;
|
||||
H = scaled.reduce((s, x) => s + x.h, 0) + gap * (scaled.length - 1);
|
||||
} else {
|
||||
// 横排:等高
|
||||
const th = Math.max(...canvases.map(c => c.height));
|
||||
scaled = canvases.map(c => ({ c, w: Math.round(c.width * th / c.height), h: th }));
|
||||
W = scaled.reduce((s, x) => s + x.w, 0) + gap * (scaled.length - 1);
|
||||
H = th;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
let y = 0;
|
||||
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
|
||||
if (direction === 'grid') {
|
||||
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
|
||||
const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 2);
|
||||
const rows = Math.ceil(canvases.length / cols);
|
||||
const cellW = Math.max(...canvases.map(c => c.width));
|
||||
const cellH = Math.max(...canvases.map(c => c.height));
|
||||
canvas.width = cols * cellW + (cols - 1) * gap;
|
||||
canvas.height = rows * cellH + (rows - 1) * gap;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
canvases.forEach((c, i) => {
|
||||
const r = Math.floor(i / cols), col = i % cols;
|
||||
const scale = Math.min(cellW / c.width, cellH / c.height);
|
||||
const dw = Math.round(c.width * scale), dh = Math.round(c.height * scale);
|
||||
const x = col * (cellW + gap) + Math.round((cellW - dw) / 2);
|
||||
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
|
||||
ctx.drawImage(c, x, y, dw, dh);
|
||||
});
|
||||
} else {
|
||||
let x = 0;
|
||||
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
|
||||
let W, H, scaled;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排:等宽
|
||||
const tw = Math.max(...canvases.map(c => c.width));
|
||||
scaled = canvases.map(c => ({ c, w: tw, h: Math.round(c.height * tw / c.width) }));
|
||||
W = tw;
|
||||
H = scaled.reduce((s, x) => s + x.h, 0) + gap * (scaled.length - 1);
|
||||
} else {
|
||||
// 横排:等高
|
||||
const th = Math.max(...canvases.map(c => c.height));
|
||||
scaled = canvases.map(c => ({ c, w: Math.round(c.width * th / c.height), h: th }));
|
||||
W = scaled.reduce((s, x) => s + x.w, 0) + gap * (scaled.length - 1);
|
||||
H = th;
|
||||
}
|
||||
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
let y = 0;
|
||||
scaled.forEach(x => { ctx.drawImage(x.c, 0, y, x.w, x.h); y += x.h + gap; });
|
||||
} else {
|
||||
let x = 0;
|
||||
scaled.forEach(s => { ctx.drawImage(s.c, x, 0, s.w, s.h); x += s.w + gap; });
|
||||
}
|
||||
}
|
||||
|
||||
combinedCanvas = canvas;
|
||||
@@ -1327,3 +1517,272 @@ function generateCombine() {
|
||||
function exportCombine() {
|
||||
exportCurrent('combine');
|
||||
}
|
||||
|
||||
// ===== 收藏功能 =====
|
||||
function collectChartConfig() {
|
||||
return {
|
||||
mode: 'chart',
|
||||
data: document.getElementById('dataInput').value,
|
||||
chartType: document.getElementById('chartType').value,
|
||||
title: document.getElementById('chartTitle').value,
|
||||
theme: document.getElementById('themeStyle').value,
|
||||
showLegend: document.getElementById('showLegend').checked,
|
||||
showGrid: document.getElementById('showGrid').checked,
|
||||
showLabel: document.getElementById('showLabel').checked,
|
||||
stackMode: document.getElementById('stackMode').checked,
|
||||
smoothLine: document.getElementById('smoothLine').checked,
|
||||
dualAxis: document.getElementById('dualAxis').checked,
|
||||
leftAxisName: document.getElementById('leftAxisName').value,
|
||||
rightAxisName: document.getElementById('rightAxisName').value,
|
||||
enableSplit: document.getElementById('enableSplit').checked,
|
||||
splitIndex: parseInt(document.getElementById('splitIndex').value) || 3,
|
||||
leftLabel: document.getElementById('leftLabel').value,
|
||||
rightLabel: document.getElementById('rightLabel').value,
|
||||
splitStyle: document.getElementById('splitStyle').value,
|
||||
seriesColors: [...seriesColors],
|
||||
seriesOrder: [...seriesOrder],
|
||||
seriesAxis: [...seriesAxis],
|
||||
seriesTypeOverrides: window.seriesTypeOverrides || {}
|
||||
};
|
||||
}
|
||||
|
||||
function collectTableConfig() {
|
||||
return {
|
||||
mode: 'table',
|
||||
data: document.getElementById('dataInput').value,
|
||||
title: document.getElementById('tableTitle').value,
|
||||
theme: document.getElementById('tableTheme').value,
|
||||
fontSize: parseInt(document.getElementById('tableFontSize').value) || 14,
|
||||
stripeRows: document.getElementById('stripeRows').checked,
|
||||
borderStyle: document.getElementById('borderStyle').value
|
||||
};
|
||||
}
|
||||
|
||||
function collectCombineConfig() {
|
||||
return {
|
||||
mode: 'combine',
|
||||
charts: combineCharts.map(c => ({ ...c })),
|
||||
direction: (document.querySelector('input[name="combineDirection"]:checked') || {}).value || 'horizontal',
|
||||
gridCols: parseInt(document.getElementById('gridCols').value) || 2
|
||||
};
|
||||
}
|
||||
|
||||
function collectConfig() {
|
||||
if (currentMode === 'chart') return collectChartConfig();
|
||||
if (currentMode === 'table') return collectTableConfig();
|
||||
return collectCombineConfig();
|
||||
}
|
||||
|
||||
function favoriteTitle(cfg) {
|
||||
if (cfg.mode === 'chart') return cfg.title || '未命名图表';
|
||||
if (cfg.mode === 'table') return cfg.title || '未命名表格';
|
||||
return '多图合并';
|
||||
}
|
||||
|
||||
// 点击「⭐ 收藏」:把当前图/表按原始大小保存到收藏区
|
||||
function hasFavoriteContent(cfg) {
|
||||
if (cfg.mode === 'combine') {
|
||||
return Array.isArray(cfg.charts) && cfg.charts.some(c => c.data && c.data.trim());
|
||||
}
|
||||
return !!(cfg.data && cfg.data.trim());
|
||||
}
|
||||
|
||||
function saveFavorite() {
|
||||
let cfg;
|
||||
try { cfg = collectConfig(); } catch (e) { alert(e.message); return; }
|
||||
if (!hasFavoriteContent(cfg)) { alert('没有可收藏的内容,请先生成图或表'); return; }
|
||||
|
||||
const btn = document.getElementById('btnFavorite');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ 收藏中...'; }
|
||||
|
||||
getSourceCanvas()
|
||||
.then(canvas => {
|
||||
const image = canvas.toDataURL('image/png');
|
||||
const title = favoriteTitle(cfg);
|
||||
return fetch('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: cfg.mode, title, config: cfg, image })
|
||||
});
|
||||
})
|
||||
.then(res => res.json().then(data => ({ ok: res.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
if (!ok) throw new Error((data && data.error) || '收藏失败');
|
||||
showToast('⭐ 已收藏:' + (data.title || ''));
|
||||
refreshFavorites();
|
||||
})
|
||||
.catch(err => alert(err.message))
|
||||
.finally(() => {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '⭐ 收藏'; }
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 收藏区(弹窗) =====
|
||||
function openFavorites() {
|
||||
const modal = document.getElementById('favoritesModal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
refreshFavorites();
|
||||
}
|
||||
|
||||
function closeFavorites() {
|
||||
const modal = document.getElementById('favoritesModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function refreshFavorites() {
|
||||
fetch('/api/favorites')
|
||||
.then(res => res.json())
|
||||
.then(data => renderFavorites((data && data.favorites) || []))
|
||||
.catch(err => {
|
||||
const list = document.getElementById('favoritesList');
|
||||
if (list) list.innerHTML = `<div class="fav-empty">加载失败:${escHtml(err.message)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderFavorites(favs) {
|
||||
const list = document.getElementById('favoritesList');
|
||||
const count = document.getElementById('favCount');
|
||||
if (count) count.textContent = String(favs.length);
|
||||
if (!list) return;
|
||||
|
||||
if (!favs.length) {
|
||||
list.innerHTML = `<div class="fav-empty">🕊️ 暂无收藏,点击「⭐ 收藏」保存当前图表或表格</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const MODE_LABEL = { chart: '📈 图表', table: '📋 表格', combine: '🖼️ 多图' };
|
||||
list.innerHTML = favs.map(f => `
|
||||
<div class="fav-card">
|
||||
<div class="fav-card-img">
|
||||
<img src="/api/favorites/${f.id}/image" alt="${escHtml(f.title)}" loading="lazy"
|
||||
onclick="window.open('/api/favorites/${f.id}/image','_blank')" title="点击查看原图">
|
||||
<span class="fav-badge">${MODE_LABEL[f.mode] || escHtml(f.mode)}</span>
|
||||
</div>
|
||||
<div class="fav-card-body">
|
||||
<div class="fav-card-title" title="${escHtml(f.title)}">${escHtml(f.title)}</div>
|
||||
<div class="fav-card-meta">${f.width || '?'} × ${f.height || '?'}px · ${formatFavTime(f.createdAt)}</div>
|
||||
<div class="fav-card-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="editFavorite('${f.id}')" title="在新标签页再次制作">✏️ 编辑</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="downloadFavorite('${f.id}')" title="下载原图">⬇️ 下载</button>
|
||||
<button class="btn btn-remove btn-sm" onclick="deleteFavorite('${f.id}')" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function formatFavTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// 编辑:新标签页打开主页面并加载该收藏配置
|
||||
function editFavorite(id) {
|
||||
window.open('/?id=' + id, '_blank');
|
||||
}
|
||||
|
||||
function downloadFavorite(id) {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/api/favorites/' + id + '/image?download=1';
|
||||
a.download = 'favorite_' + id + '.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
function deleteFavorite(id) {
|
||||
if (!confirm('确定删除这条收藏吗?')) return;
|
||||
fetch('/api/favorites/' + id, { method: 'DELETE' })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.ok) { refreshFavorites(); showToast('🗑️ 已删除'); }
|
||||
else alert((data && data.error) || '删除失败');
|
||||
})
|
||||
.catch(err => alert(err.message));
|
||||
}
|
||||
|
||||
// 加载收藏配置(新标签页再次制作)
|
||||
function loadFavoriteForEdit(id) {
|
||||
fetch('/api/favorites/' + id)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data || !data.ok || !data.favorite) throw new Error('收藏不存在或已删除');
|
||||
applyFavoriteConfig(data.favorite);
|
||||
showToast('✏️ 已载入收藏配置,可继续编辑制作');
|
||||
})
|
||||
.catch(err => showToast('❌ ' + err.message));
|
||||
}
|
||||
|
||||
function applyFavoriteConfig(fav) {
|
||||
const cfg = fav.config || {};
|
||||
const mode = fav.mode || cfg.mode || 'chart';
|
||||
|
||||
switchMode(mode);
|
||||
|
||||
if (mode === 'chart') {
|
||||
document.getElementById('dataInput').value = cfg.data || '';
|
||||
document.getElementById('chartType').value = cfg.chartType || 'bar';
|
||||
document.getElementById('chartTitle').value = cfg.title || '';
|
||||
document.getElementById('themeStyle').value = cfg.theme || 'default';
|
||||
document.getElementById('showLegend').checked = cfg.showLegend !== false;
|
||||
document.getElementById('showGrid').checked = cfg.showGrid !== false;
|
||||
document.getElementById('showLabel').checked = !!cfg.showLabel;
|
||||
document.getElementById('stackMode').checked = !!cfg.stackMode;
|
||||
document.getElementById('smoothLine').checked = cfg.smoothLine !== false;
|
||||
document.getElementById('dualAxis').checked = !!cfg.dualAxis;
|
||||
document.getElementById('leftAxisName').value = cfg.leftAxisName || '';
|
||||
document.getElementById('rightAxisName').value = cfg.rightAxisName || '';
|
||||
document.getElementById('enableSplit').checked = !!cfg.enableSplit;
|
||||
document.getElementById('splitIndex').value = cfg.splitIndex || 3;
|
||||
document.getElementById('leftLabel').value = cfg.leftLabel || '';
|
||||
document.getElementById('rightLabel').value = cfg.rightLabel || '';
|
||||
document.getElementById('splitStyle').value = cfg.splitStyle || 'solid';
|
||||
|
||||
generateChart();
|
||||
|
||||
// 覆盖系列顺序/颜色/轴/独立类型
|
||||
if (Array.isArray(cfg.seriesOrder)) seriesOrder = [...cfg.seriesOrder];
|
||||
if (Array.isArray(cfg.seriesColors)) seriesColors = [...cfg.seriesColors];
|
||||
if (Array.isArray(cfg.seriesAxis)) seriesAxis = [...cfg.seriesAxis];
|
||||
window.seriesTypeOverrides = cfg.seriesTypeOverrides || {};
|
||||
renderSeriesConfig();
|
||||
updateChart();
|
||||
onDualAxisChange();
|
||||
} else if (mode === 'table') {
|
||||
document.getElementById('dataInput').value = cfg.data || '';
|
||||
document.getElementById('tableTitle').value = cfg.title || '';
|
||||
document.getElementById('tableTheme').value = cfg.theme || 'default';
|
||||
document.getElementById('tableFontSize').value = cfg.fontSize || 14;
|
||||
updateFontSize();
|
||||
document.getElementById('stripeRows').checked = cfg.stripeRows !== false;
|
||||
document.getElementById('borderStyle').value = cfg.borderStyle || 'all';
|
||||
generateTable();
|
||||
} else if (mode === 'combine') {
|
||||
if (Array.isArray(cfg.charts) && cfg.charts.length) {
|
||||
combineCharts = cfg.charts.map(c => ({ ...defaultCombineChart(), ...c }));
|
||||
}
|
||||
const dir = document.querySelector(`input[name="combineDirection"][value="${cfg.direction || 'horizontal'}"]`);
|
||||
if (dir) dir.checked = true;
|
||||
document.getElementById('gridCols').value = cfg.gridCols || 2;
|
||||
renderCombineCharts();
|
||||
generateCombine();
|
||||
onCombineGridToggle();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Toast =====
|
||||
let toastTimer = null;
|
||||
function showToast(msg) {
|
||||
let t = document.getElementById('appToast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.id = 'appToast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
|
||||
}
|
||||
+33
-7
@@ -11,8 +11,13 @@
|
||||
<div class="app-container">
|
||||
<!-- 顶部标题 -->
|
||||
<header class="app-header">
|
||||
<h1>📊 数据可视化图表生成器</h1>
|
||||
<p class="subtitle">粘贴数据,生成精美图表或表格</p>
|
||||
<div class="header-title">
|
||||
<h1>📊 数据可视化图表生成器</h1>
|
||||
<p class="subtitle">粘贴数据,生成精美图表或表格</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-fav-outline" onclick="openFavorites()">⭐ 收藏区</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
@@ -23,7 +28,7 @@
|
||||
<div class="mode-switch">
|
||||
<button class="mode-btn active" id="btnChartMode" onclick="switchMode('chart')">📈 图表模式</button>
|
||||
<button class="mode-btn" id="btnTableMode" onclick="switchMode('table')">📋 表格模式</button>
|
||||
<button class="mode-btn" id="btnCombineMode" onclick="switchMode('combine')">🖼️ 双图合并</button>
|
||||
<button class="mode-btn" id="btnCombineMode" onclick="switchMode('combine')">🖼️ 多图合并</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +61,8 @@ C, 20, 30, 40</pre>
|
||||
<option value="bar">柱状图</option>
|
||||
<option value="line">折线图</option>
|
||||
<option value="bar-line">柱状图+折线图混合</option>
|
||||
<option value="pie">饼图</option>
|
||||
<option value="radar">雷达图</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -228,6 +235,7 @@ C, 20, 30, 40</pre>
|
||||
<span class="export-toggle" id="exportToggle">▸</span>
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" onclick="exportImage('png')">📥 下载</button>
|
||||
<button class="btn btn-fav btn-sm" id="btnFavorite" onclick="saveFavorite()">⭐ 收藏</button>
|
||||
</div>
|
||||
|
||||
<div id="exportSettingsBody" class="export-settings-body" style="display:none;">
|
||||
@@ -272,18 +280,24 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双图合并配置区 -->
|
||||
<!-- 多图合并配置区 -->
|
||||
<div class="panel-section" id="combineConfigSection" style="display:none;">
|
||||
<h2>🖼️ 多图合并配置</h2>
|
||||
|
||||
<div class="config-group">
|
||||
<label>排列方向</label>
|
||||
<label>排列方式</label>
|
||||
<div class="combine-direction">
|
||||
<label class="dir-option"><input type="radio" name="combineDirection" value="horizontal" checked onchange="generateCombine()"> ⇄ 横排(左右)</label>
|
||||
<label class="dir-option"><input type="radio" name="combineDirection" value="vertical" onchange="generateCombine()"> ⇅ 竖排(上下)</label>
|
||||
<label class="dir-option"><input type="radio" name="combineDirection" value="horizontal" checked onchange="generateCombine()"> ⇄ 横排(单行)</label>
|
||||
<label class="dir-option"><input type="radio" name="combineDirection" value="vertical" onchange="generateCombine()"> ⇅ 竖排(单列)</label>
|
||||
<label class="dir-option"><input type="radio" name="combineDirection" value="grid" onchange="onCombineGridToggle()"> ▦ 多行多列</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-group" id="gridColsGroup" style="display:none;">
|
||||
<label>每行列数</label>
|
||||
<input type="number" id="gridCols" value="2" min="1" max="6" oninput="generateCombine()">
|
||||
</div>
|
||||
|
||||
<div id="combineChartsContainer">
|
||||
<!-- 图表卡片由 JS 动态渲染 -->
|
||||
</div>
|
||||
@@ -412,5 +426,17 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
<!-- 收藏区弹窗 -->
|
||||
<div class="fav-modal" id="favoritesModal" onclick="if(event.target===this)closeFavorites()">
|
||||
<div class="fav-modal-box">
|
||||
<div class="fav-modal-head">
|
||||
<h2>⭐ 收藏区</h2>
|
||||
<button class="btn btn-remove" onclick="closeFavorites()" title="关闭">✕</button>
|
||||
</div>
|
||||
<div class="fav-modal-tip">共 <span id="favCount">0</span> 条 · 点击图片看原图 · 「编辑」在新标签页再次制作</div>
|
||||
<div class="fav-list" id="favoritesList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+11
-1
@@ -6,6 +6,12 @@ PORT=16016
|
||||
APP_DIR="/home/openclaw/.openclaw/workspace-hz4th_coder/works/data-chart-tool"
|
||||
LOG_FILE="$APP_DIR/logs/server.log"
|
||||
|
||||
# node 绝对路径(cron 环境 PATH 不含 nvm,必须用全路径)
|
||||
NODE_BIN="$HOME/.nvm/versions/node/v24.15.0/bin/node"
|
||||
if [ ! -x "$NODE_BIN" ]; then
|
||||
NODE_BIN="$(command -v node 2>/dev/null)"
|
||||
fi
|
||||
|
||||
# 检查端口是否被占用
|
||||
if ss -tlnp | grep -q ":$PORT "; then
|
||||
# 检查是不是 node 进程
|
||||
@@ -28,9 +34,13 @@ if ss -tlnp | grep -q ":$PORT "; then
|
||||
fi
|
||||
|
||||
# 启动 node 服务
|
||||
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
|
||||
echo "[$(date)] ERROR: node 未找到,无法启动"
|
||||
exit 1
|
||||
fi
|
||||
echo "[$(date)] Starting data-chart-tool..."
|
||||
cd "$APP_DIR"
|
||||
nohup node server.js >> "$LOG_FILE" 2>&1 &
|
||||
nohup "$NODE_BIN" server.js >> "$LOG_FILE" 2>&1 &
|
||||
echo "[$(date)] Started with PID $!"
|
||||
|
||||
# 等待启动
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
const { createCanvas, registerFont, loadImage } = require('@napi-rs/canvas');
|
||||
const echarts = require('echarts');
|
||||
const path = require('path');
|
||||
@@ -130,6 +131,72 @@ function buildChartOption(params) {
|
||||
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
|
||||
|
||||
// 饼图/雷达图:专用构建逻辑(无坐标轴/网格)
|
||||
if (chartType === 'pie' || chartType === 'radar') {
|
||||
const bgC = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
||||
const txtC = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
const tTitle = title ? { text: title, left: 'center', top: 15, textStyle: { color: txtC, fontSize: 18, fontWeight: 600 } } : undefined;
|
||||
if (chartType === 'pie') {
|
||||
// 饼图:第一列=名称,第一个系列=数值
|
||||
const sName = parsedData.seriesNames[0];
|
||||
const data = parsedData.seriesData[sName];
|
||||
const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
|
||||
return {
|
||||
backgroundColor: bgC,
|
||||
title: tTitle,
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 50 : 20, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
|
||||
color: seriesColorsArr,
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['38%', '68%'],
|
||||
center: ['52%', '55%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: bgC, borderWidth: 2 },
|
||||
label: { show: showLabel, formatter: '{b}: {d}%', color: txtC },
|
||||
labelLine: { show: showLabel },
|
||||
data: pieData
|
||||
}],
|
||||
animation: false
|
||||
};
|
||||
} else {
|
||||
// 雷达图:第一列=维度名,每个系列=一个雷达多边形
|
||||
const indicator = parsedData.categories.map(c => {
|
||||
let max = 0;
|
||||
parsedData.seriesNames.forEach(n => { parsedData.seriesData[n].forEach(v => { if (v > max) max = v; }); });
|
||||
return { name: c, max: Math.ceil(max * 1.2) || 100 };
|
||||
});
|
||||
return {
|
||||
backgroundColor: bgC,
|
||||
title: tTitle,
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 48 : 15, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
|
||||
color: seriesColorsArr,
|
||||
radar: {
|
||||
indicator,
|
||||
radius: '62%',
|
||||
center: ['50%', '55%'],
|
||||
splitNumber: 5,
|
||||
axisName: { color: txtC, fontSize: 12 },
|
||||
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
|
||||
}
|
||||
},
|
||||
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
|
||||
},
|
||||
series: parsedData.seriesNames.map(n => ({
|
||||
type: 'radar',
|
||||
name: n,
|
||||
data: [{ value: parsedData.seriesData[n], name: n }],
|
||||
symbolSize: 4,
|
||||
areaStyle: { opacity: 0.15 }
|
||||
})),
|
||||
animation: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系列
|
||||
const series = parsedData.seriesNames.map((name, idx) => {
|
||||
const dataArr = parsedData.seriesData[name];
|
||||
@@ -928,12 +995,17 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.13.0',
|
||||
version: '1.15.0',
|
||||
endpoints: {
|
||||
'POST /api/chart': '生成图表图片(JSON body)',
|
||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||
'POST /api/table': '生成表格图片(JSON body)',
|
||||
'GET /api/table': '生成表格图片(URL 参数)',
|
||||
'POST /api/favorites': '保存收藏(原图+配置)',
|
||||
'GET /api/favorites': '收藏列表',
|
||||
'GET /api/favorites/:id': '单个收藏详情(含配置)',
|
||||
'GET /api/favorites/:id/image': '收藏原图',
|
||||
'DELETE /api/favorites/:id': '删除收藏',
|
||||
'GET /api/health': '健康检查'
|
||||
}
|
||||
});
|
||||
@@ -943,7 +1015,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.13.0',
|
||||
version: '1.15.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -952,7 +1024,7 @@ app.get('/api/docs', (req, res) => {
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头,第一列横坐标)' },
|
||||
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line'], description: '图表类型' },
|
||||
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line', 'pie', 'radar'], description: '图表类型' },
|
||||
title: { type: 'string', default: '', description: '图表标题' },
|
||||
theme: { type: 'string', default: 'default', options: ['default', 'dark', 'macarons', 'gradient', 'retro', 'ocean', 'forest', 'sunset', 'lavender', 'minimal', 'cherry', 'midnight', 'gold', 'coral', 'mint'], description: '主题风格' },
|
||||
showLegend: { type: 'boolean', default: true, description: '是否显示图例' },
|
||||
@@ -1059,7 +1131,8 @@ app.get('/api/docs', (req, res) => {
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
|
||||
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical'], description: '排列方向:horizontal 横排(左右)/ vertical 竖排(上下)' },
|
||||
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical', 'grid'], description: '排布方式:horizontal 横排(单行)/ vertical 竖排(单列)/ grid 多行多列' },
|
||||
cols: { type: 'number', default: 2, description: '多行多列时的每行列数(仅 grid 生效)' },
|
||||
gap: { type: 'number', default: 24, description: '图间距(px)' },
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||
background: { type: 'string', default: '#ffffff', description: '背景色' }
|
||||
@@ -1075,6 +1148,40 @@ app.get('/api/docs', (req, res) => {
|
||||
}' -o combine.png`,
|
||||
response: 'PNG 图片二进制流'
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/favorites',
|
||||
description: '保存收藏(原始大小原图 + 完整配置,用于重新编辑)',
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
mode: { type: 'string', required: true, options: ['chart', 'table', 'combine'], description: '收藏类型' },
|
||||
title: { type: 'string', default: '', description: '收藏标题' },
|
||||
config: { type: 'object', required: true, description: '完整配置(数据/图表类型/主题/系列颜色顺序/轴/分割等)' },
|
||||
image: { type: 'string', description: '原始大小图片 dataURL(可选,未传则服务端按配置生成)' }
|
||||
},
|
||||
returns: 'json { ok, id, title, width, height }'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/favorites',
|
||||
description: '收藏列表(按时间倒序)',
|
||||
returns: 'json { ok, favorites: [{id, mode, title, createdAt, width, height}] }'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/favorites/:id',
|
||||
description: '单个收藏详情(含完整 config,供编辑页加载)'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/favorites/:id/image',
|
||||
description: '收藏原图 PNG(?download=1 强制下载)'
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/api/favorites/:id',
|
||||
description: '删除收藏'
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1098,7 +1205,8 @@ app.post('/api/combine', async (req, res) => {
|
||||
return res.status(400).json({ error: '至少需要一个有数据的图表(data 参数)' });
|
||||
}
|
||||
|
||||
const direction = body.direction === 'vertical' ? 'vertical' : 'horizontal';
|
||||
const direction = ['vertical', 'grid'].includes(body.direction) ? body.direction : 'horizontal';
|
||||
const cols = Math.max(1, parseInt(body.cols) || 2);
|
||||
const gap = parseInt(body.gap) || 24;
|
||||
const pixelRatio = parseInt(body.pixelRatio) || 2;
|
||||
const background = body.background || '#ffffff';
|
||||
@@ -1125,39 +1233,64 @@ app.post('/api/combine', async (req, res) => {
|
||||
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
|
||||
));
|
||||
|
||||
let W, H, scaled;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽对齐
|
||||
const tw = Math.max(...subs.map(s => s.w));
|
||||
scaled = subs.map(s => ({ img: s.img, w: tw, h: Math.round(s.h * (tw / s.w)) }));
|
||||
W = tw;
|
||||
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
|
||||
} else {
|
||||
// 横排(左右):等高对齐
|
||||
const th = Math.max(...subs.map(s => s.h));
|
||||
scaled = subs.map(s => ({ img: s.img, w: Math.round(s.w * (th / s.h)), h: th }));
|
||||
W = scaled.reduce((sum, s) => sum + s.w, 0) + gap * (scaled.length - 1);
|
||||
H = th;
|
||||
}
|
||||
|
||||
const canvas = createCanvas(W * pixelRatio, H * pixelRatio);
|
||||
let W, H;
|
||||
const canvas = createCanvas(1, 1);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
|
||||
let y = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
|
||||
y += s.h + gap;
|
||||
if (direction === 'grid') {
|
||||
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
|
||||
const rows = Math.ceil(subs.length / cols);
|
||||
const cellW = Math.max(...subs.map(s => s.w));
|
||||
const cellH = Math.max(...subs.map(s => s.h));
|
||||
W = cols * cellW + (cols - 1) * gap;
|
||||
H = rows * cellH + (rows - 1) * gap;
|
||||
canvas.width = W * pixelRatio;
|
||||
canvas.height = H * pixelRatio;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
subs.forEach((s, i) => {
|
||||
const r = Math.floor(i / cols), c = i % cols;
|
||||
const scale = Math.min(cellW / s.w, cellH / s.h);
|
||||
const dw = Math.round(s.w * scale), dh = Math.round(s.h * scale);
|
||||
const x = c * (cellW + gap) + Math.round((cellW - dw) / 2);
|
||||
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
|
||||
ctx.drawImage(s.img, x * pixelRatio, y * pixelRatio, dw * pixelRatio, dh * pixelRatio);
|
||||
});
|
||||
} else {
|
||||
let x = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
|
||||
x += s.w + gap;
|
||||
});
|
||||
let scaled;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽对齐
|
||||
const tw = Math.max(...subs.map(s => s.w));
|
||||
scaled = subs.map(s => ({ img: s.img, w: tw, h: Math.round(s.h * (tw / s.w)) }));
|
||||
W = tw;
|
||||
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
|
||||
} else {
|
||||
// 横排(左右):等高对齐
|
||||
const th = Math.max(...subs.map(s => s.h));
|
||||
scaled = subs.map(s => ({ img: s.img, w: Math.round(s.w * (th / s.h)), h: th }));
|
||||
W = scaled.reduce((sum, s) => sum + s.w, 0) + gap * (scaled.length - 1);
|
||||
H = th;
|
||||
}
|
||||
|
||||
canvas.width = W * pixelRatio;
|
||||
canvas.height = H * pixelRatio;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
|
||||
let y = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
|
||||
y += s.h + gap;
|
||||
});
|
||||
} else {
|
||||
let x = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
|
||||
x += s.w + gap;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
@@ -1165,7 +1298,7 @@ app.post('/api/combine', async (req, res) => {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buffer.length,
|
||||
'X-Combine-Direction': direction,
|
||||
'X-Combine-Charts': String(scaled.length),
|
||||
'X-Combine-Charts': String(subs.length),
|
||||
'X-Chart-Width': W,
|
||||
'X-Chart-Height': H
|
||||
});
|
||||
@@ -1176,6 +1309,169 @@ app.post('/api/combine', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 收藏功能 =====
|
||||
const FAV_DIR = path.join(__dirname, 'data', 'favorites');
|
||||
const FAV_INDEX = path.join(FAV_DIR, 'index.json');
|
||||
|
||||
function ensureFavDir() {
|
||||
if (!fs.existsSync(FAV_DIR)) fs.mkdirSync(FAV_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function loadFavIndex() {
|
||||
ensureFavDir();
|
||||
try {
|
||||
if (fs.existsSync(FAV_INDEX)) {
|
||||
const arr = JSON.parse(fs.readFileSync(FAV_INDEX, 'utf8'));
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('收藏索引读取失败:', e.message);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveFavIndex(index) {
|
||||
ensureFavDir();
|
||||
fs.writeFileSync(FAV_INDEX, JSON.stringify(index, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function genFavId() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function dataUrlToBuffer(dataUrl) {
|
||||
if (!dataUrl) return null;
|
||||
const m = /^data:image\/(png|jpeg|webp);base64,(.+)$/.exec(dataUrl);
|
||||
if (!m) return null;
|
||||
return Buffer.from(m[2], 'base64');
|
||||
}
|
||||
|
||||
function getPngSize(buf) {
|
||||
// PNG 签名 8 字节后 IHDR 块:length(4) + type(4) + width(4) + height(4)
|
||||
if (buf && buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50) {
|
||||
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
||||
}
|
||||
return { w: 0, h: 0 };
|
||||
}
|
||||
|
||||
// 服务端兜底渲染(前端未传图时按原始大小生成)
|
||||
function renderFavoriteImage(mode, config) {
|
||||
if (mode === 'chart') {
|
||||
const width = parseInt(config.width) || 800;
|
||||
const height = parseInt(config.height) || 500;
|
||||
const pixelRatio = parseInt(config.pixelRatio) || 2;
|
||||
const option = buildChartOption(config);
|
||||
const canvas = createCanvas(width * pixelRatio, height * pixelRatio);
|
||||
const chart = echarts.init(canvas, null, {
|
||||
renderer: 'canvas', width, height, devicePixelRatio: pixelRatio
|
||||
});
|
||||
chart.setOption(option);
|
||||
return canvas;
|
||||
}
|
||||
if (mode === 'table') {
|
||||
return generateTableImage(config);
|
||||
}
|
||||
throw new Error('combine 模式需由前端提供原图');
|
||||
}
|
||||
|
||||
// 保存收藏(前端传原始大小原图 + 完整配置,便于重新编辑)
|
||||
app.post('/api/favorites', (req, res) => {
|
||||
try {
|
||||
const { mode = 'chart', title = '', config = {}, image } = req.body || {};
|
||||
if (!['chart', 'table', 'combine'].includes(mode)) {
|
||||
return res.status(400).json({ error: 'mode 必须是 chart / table / combine' });
|
||||
}
|
||||
ensureFavDir();
|
||||
const id = genFavId();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
let imagePath = null;
|
||||
let width = 0, height = 0;
|
||||
|
||||
// 1) 优先保存前端回传的原图(原始大小)
|
||||
const buf = dataUrlToBuffer(image);
|
||||
if (buf) {
|
||||
imagePath = path.join(FAV_DIR, id + '.png');
|
||||
fs.writeFileSync(imagePath, buf);
|
||||
} else {
|
||||
// 2) 兜底:服务端按原始大小渲染
|
||||
try {
|
||||
const canvas = renderFavoriteImage(mode, config);
|
||||
imagePath = path.join(FAV_DIR, id + '.png');
|
||||
fs.writeFileSync(imagePath, canvas.toBuffer('image/png'));
|
||||
} catch (e) {
|
||||
console.warn('服务端渲染收藏图失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath && fs.existsSync(imagePath)) {
|
||||
try {
|
||||
const size = getPngSize(fs.readFileSync(imagePath));
|
||||
width = size.w; height = size.h;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const fav = { id, mode, title, createdAt, width, height, config };
|
||||
const index = loadFavIndex();
|
||||
index.unshift(fav);
|
||||
saveFavIndex(index);
|
||||
|
||||
res.json({ ok: true, id, title, width, height, message: '收藏成功' });
|
||||
} catch (err) {
|
||||
console.error('Save favorite error:', err);
|
||||
res.status(500).json({ error: '收藏失败: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 收藏列表
|
||||
app.get('/api/favorites', (req, res) => {
|
||||
res.json({ ok: true, favorites: loadFavIndex() });
|
||||
});
|
||||
|
||||
// 单个收藏(含配置,用于编辑)
|
||||
app.get('/api/favorites/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
|
||||
const fav = loadFavIndex().find(f => f.id === id);
|
||||
if (!fav) return res.status(404).json({ error: '收藏不存在' });
|
||||
res.json({ ok: true, favorite: fav });
|
||||
});
|
||||
|
||||
// 收藏原图
|
||||
app.get('/api/favorites/:id/image', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
|
||||
const imgPath = path.join(FAV_DIR, id + '.png');
|
||||
if (!fs.existsSync(imgPath)) return res.status(404).json({ error: '图片不存在' });
|
||||
const buf = fs.readFileSync(imgPath);
|
||||
res.set({
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buf.length,
|
||||
'Cache-Control': 'public, max-age=86400'
|
||||
});
|
||||
if (req.query.download === '1') {
|
||||
res.set('Content-Disposition', `attachment; filename="favorite_${id}.png"`);
|
||||
}
|
||||
res.send(buf);
|
||||
});
|
||||
|
||||
// 删除收藏
|
||||
app.delete('/api/favorites/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
|
||||
const index = loadFavIndex();
|
||||
const next = index.filter(f => f.id !== id);
|
||||
if (next.length === index.length) return res.status(404).json({ error: '收藏不存在' });
|
||||
saveFavIndex(next);
|
||||
try {
|
||||
const p = path.join(FAV_DIR, id + '.png');
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
} catch (e) {}
|
||||
res.json({ ok: true, message: '已删除' });
|
||||
});
|
||||
|
||||
// ===== 启动服务 =====
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 数据可视化图表生成器已启动`);
|
||||
@@ -1183,6 +1479,7 @@ app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`📡 图表API: http://0.0.0.0:${PORT}/api/chart`);
|
||||
console.log(`📋 表格API: http://0.0.0.0:${PORT}/api/table`);
|
||||
console.log(`🖼️ 合并API: http://0.0.0.0:${PORT}/api/combine`);
|
||||
console.log(`⭐ 收藏API: http://0.0.0.0:${PORT}/api/favorites`);
|
||||
console.log(`📖 文档: http://0.0.0.0:${PORT}/api/docs`);
|
||||
console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`);
|
||||
});
|
||||
@@ -35,12 +35,22 @@ body {
|
||||
}
|
||||
|
||||
.app-header {
|
||||
text-align: center;
|
||||
padding: 20px 0 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
text-align: left;
|
||||
padding: 6px 0 10px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.35rem;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--primary), #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -49,8 +59,8 @@ body {
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-light);
|
||||
margin-top: 5px;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 2px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -65,7 +75,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: calc(100vh - 140px);
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
@@ -775,3 +785,209 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== 收藏功能 ===== */
|
||||
.header-actions {
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-fav-outline {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.btn-fav-outline:hover {
|
||||
background: #ffedd5;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-fav {
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-fav:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-fav:disabled {
|
||||
background: #fcd34d;
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 收藏区弹窗 */
|
||||
.fav-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.fav-modal-box {
|
||||
width: min(1080px, 96vw);
|
||||
max-height: 88vh;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fav-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.fav-modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.fav-modal-tip {
|
||||
padding: 8px 22px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fav-modal-tip #favCount {
|
||||
font-weight: 700;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.fav-list {
|
||||
padding: 18px 22px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.fav-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fav-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.fav-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.fav-card-img {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
background: repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 0 0 / 20px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.fav-card-img img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.fav-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(79, 70, 229, 0.9);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fav-card-body {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.fav-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fav-card-meta {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fav-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fav-card-actions .btn-sm {
|
||||
padding: 3px 10px;
|
||||
font-size: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fav-card-actions .btn-remove {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Toast 提示 */
|
||||
#appToast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: rgba(17, 24, 39, 0.92);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.88rem;
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
max-width: 80vw;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#appToast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
Reference in New Issue
Block a user