- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航 - PickModal 和 SwapModal 集成分页功能,每页20项 - 搜索/筛选切换自动回到第1页 - 现代化CSS样式:hover浮起动效、主色高亮、移动端适配 - 新增 dashboard、admin 管理模板 - 菜系(cuisine)模型和迁移 - 构建脚本 build.py 支持 PyInstaller 打包 - 前端资源重新构建
315 lines
13 KiB
HTML
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>
|