Files
Chunyu d7553520ce feat: 现代化翻页组件 + 完整项目更新
- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航
- PickModal 和 SwapModal 集成分页功能,每页20项
- 搜索/筛选切换自动回到第1页
- 现代化CSS样式:hover浮起动效、主色高亮、移动端适配
- 新增 dashboard、admin 管理模板
- 菜系(cuisine)模型和迁移
- 构建脚本 build.py 支持 PyInstaller 打包
- 前端资源重新构建
2026-08-28 13:44:21 +08:00

315 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学校订餐菜单 - 可视化管理面板</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Microsoft YaHei','PingFang SC',system-ui,sans-serif;background:#f0f4f8;color:#1a202c;min-height:100vh}
.dashboard{max-width:1400px;margin:0 auto;padding:24px}
/* Header */
.dash-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px;padding:20px 28px;background:linear-gradient(135deg,#1e3a5f 0%,#2d5a8e 100%);border-radius:16px;color:#fff;box-shadow:0 4px 20px rgba(30,58,95,0.3)}
.dash-header h1{font-size:24px;font-weight:700;letter-spacing:1px}
.dash-header .subtitle{font-size:13px;color:rgba(255,255,255,0.7);margin-top:4px}
.header-links{display:flex;gap:10px}
.header-links a{padding:8px 18px;border-radius:8px;text-decoration:none;font-size:13px;font-weight:600;transition:all .15s}
.header-links a.primary{background:#38bdf8;color:#0f172a}
.header-links a.primary:hover{background:#7dd3fc}
.header-links a.secondary{background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.25)}
.header-links a.secondary:hover{background:rgba(255,255,255,0.25)}
/* Stat Cards */
.stat-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
.stat-card{background:#fff;border-radius:12px;padding:20px 22px;box-shadow:0 2px 12px rgba(0,0,0,0.06);display:flex;align-items:center;gap:16px;transition:transform .15s}
.stat-card:hover{transform:translateY(-2px)}
.stat-icon{width:52px;height:52px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:24px}
.stat-icon.blue{background:#dbeafe;color:#2563eb}
.stat-icon.green{background:#dcfce7;color:#16a34a}
.stat-icon.amber{background:#fef3c7;color:#d97706}
.stat-icon.red{background:#fee2e2;color:#dc2626}
.stat-info .num{font-size:28px;font-weight:800;color:#1e293b;line-height:1}
.stat-info .label{font-size:13px;color:#64748b;margin-top:4px}
/* Chart Grid */
.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px}
.chart-box{background:#fff;border-radius:12px;padding:22px;box-shadow:0 2px 12px rgba(0,0,0,0.06)}
.chart-box h3{font-size:15px;font-weight:700;color:#1e293b;margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid #e2e8f0}
.chart-container{position:relative;height:280px}
/* Nutrition Summary */
.nutrition-row{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px}
.nutri-card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 12px rgba(0,0,0,0.06);text-align:center}
.nutri-card .value{font-size:32px;font-weight:800;line-height:1.1}
.nutri-card .unit{font-size:14px;color:#64748b;margin-top:2px}
.nutri-card .label{font-size:13px;color:#94a3b8;margin-top:8px}
.nutri-card.protein .value{color:#2563eb}
.nutri-card.fat .value{color:#d97706}
.nutri-card.calorie .value{color:#dc2626}
/* Tables */
.table-section{background:#fff;border-radius:12px;padding:22px;box-shadow:0 2px 12px rgba(0,0,0,0.06);margin-bottom:24px}
.table-section h3{font-size:15px;font-weight:700;color:#1e293b;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between}
.table-section h3 .refresh-btn{font-size:12px;padding:4px 12px;border:1px solid #e2e8f0;border-radius:6px;background:#fff;cursor:pointer;color:#64748b;transition:all .15s}
.table-section h3 .refresh-btn:hover{border-color:#3b82f6;color:#3b82f6}
.data-table{width:100%;border-collapse:collapse;font-size:13px}
.data-table th{text-align:left;padding:10px 12px;background:#f8fafc;color:#64748b;font-weight:600;border-bottom:2px solid #e2e8f0;font-size:12px;text-transform:uppercase;letter-spacing:0.5px}
.data-table td{padding:10px 12px;border-bottom:1px solid #f1f5f9}
.data-table tr:hover td{background:#f8fafc}
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:11px;font-weight:600}
.badge.meat{background:#fef3c7;color:#92400e}
.badge.veg{background:#dcfce7;color:#166534}
.badge.soup{background:#dbeafe;color:#1e40af}
.badge.staple{background:#fce7f3;color:#9d174d}
.badge.active{background:#dcfce7;color:#16a34a}
.badge.inactive{background:#fee2e2;color:#dc2626}
/* Loading */
.loading{text-align:center;padding:60px;color:#94a3b8;font-size:14px}
.loading .spinner{display:inline-block;width:32px;height:32px;border:3px solid #e2e8f0;border-top-color:#3b82f6;border-radius:50%;animation:spin .6s linear infinite;margin-bottom:12px}
@keyframes spin{to{transform:rotate(360deg)}}
/* Responsive */
@media(max-width:900px){
.stat-cards{grid-template-columns:repeat(2,1fr)}
.chart-grid{grid-template-columns:1fr}
.nutrition-row{grid-template-columns:1fr}
.dash-header{flex-direction:column;gap:14px;text-align:center}
}
</style>
</head>
<body>
<div class="dashboard" id="app">
<div class="loading" id="loading-state">
<div class="spinner"></div>
<div>正在加载数据...</div>
</div>
</div>
<script>
const TYPE_LABELS = {meat:'荤菜',veg:'素菜',soup:'营养汤',staple:'主食'};
const TYPE_COLORS = {meat:'#f59e0b',veg:'#22c55e',soup:'#3b82f6',staple:'#ec4899'};
const TYPE_BG = {meat:'rgba(245,158,11,0.15)',veg:'rgba(34,197,94,0.15)',soup:'rgba(59,130,246,0.15)',staple:'rgba(236,72,153,0.15)'};
async function loadData() {
const resp = await fetch('/api/dashboard/');
return resp.json();
}
function renderDashboard(d) {
const app = document.getElementById('app');
const cuisineEntries = Object.entries(d.cuisine_counts).sort((a,b)=>b[1]-a[1]);
const typeEntries = ['meat','veg','soup','staple'].map(k=>[k, d.type_counts[k]||0]);
app.innerHTML = `
<!-- Header -->
<div class="dash-header">
<div>
<h1>学校订餐菜单 · 可视化管理面板</h1>
<div class="subtitle">上海外国语大学附属宝山双语学校 — 菜品数据总览</div>
</div>
<div class="header-links">
<a href="/app/" class="primary">打开菜单生成器</a>
<a href="/admin/" class="secondary">后台管理</a>
<a href="/admin/meals/dish/" class="secondary">菜品管理</a>
</div>
</div>
<!-- Stat Cards -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-icon blue">🍽️</div>
<div class="stat-info"><div class="num">${d.total}</div><div class="label">菜品总数</div></div>
</div>
<div class="stat-card">
<div class="stat-icon green">✅</div>
<div class="stat-info"><div class="num">${d.active}</div><div class="label">启用中</div></div>
</div>
<div class="stat-card">
<div class="stat-icon amber">⏸️</div>
<div class="stat-info"><div class="num">${d.inactive}</div><div class="label">已停用</div></div>
</div>
<div class="stat-card">
<div class="stat-icon red">📊</div>
<div class="stat-info"><div class="num">${d.nutrition.avg_calorie}</div><div class="label">平均热量 (kcal)</div></div>
</div>
</div>
<!-- Charts -->
<div class="chart-grid">
<div class="chart-box">
<h3>菜品类型分布</h3>
<div class="chart-container"><canvas id="typeChart"></canvas></div>
</div>
<div class="chart-box">
<h3>菜系分布</h3>
<div class="chart-container"><canvas id="cuisineChart"></canvas></div>
</div>
<div class="chart-box">
<h3>各类型平均营养对比</h3>
<div class="chart-container"><canvas id="nutriRadar"></canvas></div>
</div>
<div class="chart-box">
<h3>各类型菜品数量</h3>
<div class="chart-container"><canvas id="typeBar"></canvas></div>
</div>
</div>
<!-- Nutrition Summary -->
<div class="nutrition-row">
<div class="nutri-card protein">
<div class="value">${d.nutrition.avg_protein}<span style="font-size:16px">g</span></div>
<div class="label">荤菜/素菜平均蛋白质</div>
</div>
<div class="nutri-card fat">
<div class="value">${d.nutrition.avg_fat}<span style="font-size:16px">g</span></div>
<div class="label">荤菜/素菜平均脂肪</div>
</div>
<div class="nutri-card calorie">
<div class="value">${d.nutrition.avg_calorie}<span style="font-size:16px">kcal</span></div>
<div class="label">平均热量 (${d.nutrition.min_calorie}~${d.nutrition.max_calorie})</div>
</div>
</div>
<!-- Recent Dishes Table -->
<div class="table-section">
<h3>最近添加的菜品 <button class="refresh-btn" onclick="refreshData()">刷新</button></h3>
<table class="data-table">
<thead><tr>
<th>菜品名称</th><th>类型</th><th>菜系</th><th>蛋白质</th><th>脂肪</th><th>热量</th><th>状态</th><th>创建时间</th>
</tr></thead>
<tbody>
${d.recent.map(r => `<tr>
<td style="font-weight:600">${r.name}</td>
<td><span class="badge ${r.dish_type}">${TYPE_LABELS[r.dish_type]||r.dish_type}</span></td>
<td>${r.cuisine||'-'}</td>
<td>${Number(r.protein).toFixed(1)}g</td>
<td>${Number(r.fat).toFixed(1)}g</td>
<td>${r.calorie}kcal</td>
<td><span class="badge ${r.is_active?'active':'inactive'}">${r.is_active?'启用':'停用'}</span></td>
<td style="color:#94a3b8">${r.created_at}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
`;
// Render charts after DOM is ready
setTimeout(() => {
// Type Pie
new Chart(document.getElementById('typeChart'), {
type: 'doughnut',
data: {
labels: typeEntries.map(([k])=>TYPE_LABELS[k]),
datasets: [{
data: typeEntries.map(([,v])=>v),
backgroundColor: typeEntries.map(([k])=>TYPE_COLORS[k]),
borderWidth: 2, borderColor: '#fff',
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {legend:{position:'right',labels:{padding:14,font:{size:13}}}}
}
});
// Cuisine Bar
new Chart(document.getElementById('cuisineChart'), {
type: 'bar',
data: {
labels: cuisineEntries.map(([k])=>k),
datasets: [{
label: '菜品数',
data: cuisineEntries.map(([,v])=>v),
backgroundColor: '#3b82f6',
borderRadius: 6,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
indexAxis: 'y',
plugins: {legend:{display:false}},
scales: {x:{grid:{display:false}},y:{grid:{display:false}}}
}
});
// Nutrition Radar
new Chart(document.getElementById('nutriRadar'), {
type: 'radar',
data: {
labels: ['蛋白质(g)','脂肪(g)','热量(kcal/10)'],
datasets: ['meat','veg','soup','staple'].filter(k=>d.type_nutrition[k]?.count>0).map(k=>({
label: TYPE_LABELS[k],
data: [
d.type_nutrition[k].avg_protein,
d.type_nutrition[k].avg_fat,
Math.round(d.type_nutrition[k].avg_calorie/10),
],
backgroundColor: TYPE_BG[k],
borderColor: TYPE_COLORS[k],
borderWidth: 2,
pointRadius: 3,
}))
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {r:{beginAtZero:true,grid:{color:'#e2e8f0'},ticks:{display:false}}},
plugins: {legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}
}
});
// Type Bar
new Chart(document.getElementById('typeBar'), {
type: 'bar',
data: {
labels: typeEntries.map(([k])=>TYPE_LABELS[k]),
datasets: [
{
label: '平均蛋白质(g)',
data: typeEntries.map(([k])=>d.type_nutrition[k]?.avg_protein||0),
backgroundColor: '#3b82f6', borderRadius: 4,
},
{
label: '平均脂肪(g)',
data: typeEntries.map(([k])=>d.type_nutrition[k]?.avg_fat||0),
backgroundColor: '#f59e0b', borderRadius: 4,
},
{
label: '平均热量(kcal/10)',
data: typeEntries.map(([k])=>Math.round((d.type_nutrition[k]?.avg_calorie||0)/10)),
backgroundColor: '#ef4444', borderRadius: 4,
}
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {legend:{position:'top',labels:{font:{size:11}}}},
scales: {x:{grid:{display:false}},y:{beginAtZero:true,grid:{color:'#f1f5f9'}}}
}
});
}, 50);
}
async function refreshData() {
const el = document.getElementById('loading-state');
if (el) { el.style.display = 'block'; }
try {
const d = await loadData();
renderDashboard(d);
} catch(e) {
document.getElementById('app').innerHTML = '<div class="loading" style="color:#dc2626">加载失败,请确认后端服务已启动</div>';
}
}
// Initial load
refreshData();
// Auto-refresh every 30s
setInterval(refreshData, 30000);
</script>
</body>
</html>