Compare commits

..
2 Commits
5 changed files with 725 additions and 10 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules/
logs/
data/
*.png
*.svg
+276
View File
@@ -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);
}
});
// ===== 数据解析 =====
@@ -1510,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);
}
+20 -2
View File
@@ -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">
@@ -230,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;">
@@ -420,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>
+206 -2
View File
@@ -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');
@@ -994,12 +995,17 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.14.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': '健康检查'
}
});
@@ -1009,7 +1015,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.14.0',
version: '1.15.0',
endpoints: [
{
method: 'POST',
@@ -1142,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: '删除收藏'
}
]
});
@@ -1269,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(`🚀 数据可视化图表生成器已启动`);
@@ -1276,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`);
});
+222 -6
View File
@@ -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);
}