feat: 食堂菜单管理系统 + AI编程入门指南
This commit is contained in:
119
AI编程入门指南.md
Normal file
119
AI编程入门指南.md
Normal file
File diff suppressed because one or more lines are too long
713
index.html
Normal file
713
index.html
Normal file
@@ -0,0 +1,713 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>食堂菜单管理系统</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
--primary: #E8530E;
|
||||||
|
--primary-light: #FF7A3D;
|
||||||
|
--primary-dark: #C44200;
|
||||||
|
--bg: #FFF8F3;
|
||||||
|
--card: #FFFFFF;
|
||||||
|
--text: #2D2D2D;
|
||||||
|
--text-secondary: #666;
|
||||||
|
--border: #F0E0D6;
|
||||||
|
--shadow: 0 2px 12px rgba(232,83,14,0.08);
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||||
|
color: white;
|
||||||
|
padding: 24px 32px;
|
||||||
|
box-shadow: 0 4px 20px rgba(232,83,14,0.2);
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 28px; font-weight: 700; letter-spacing: 1px; }
|
||||||
|
.header p { font-size: 14px; opacity: 0.9; margin-top: 4px; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
|
||||||
|
.controls {
|
||||||
|
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px; padding: 16px 20px;
|
||||||
|
background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.controls label { font-weight: 600; font-size: 14px; color: var(--text-secondary); }
|
||||||
|
.controls select, .controls input[type="month"] {
|
||||||
|
padding: 8px 14px; border: 2px solid var(--border); border-radius: 8px;
|
||||||
|
font-size: 14px; background: white; cursor: pointer; outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.controls select:focus, .controls input[type="month"]:focus { border-color: var(--primary); }
|
||||||
|
.btn {
|
||||||
|
padding: 8px 20px; border: none; border-radius: 8px; font-size: 14px;
|
||||||
|
font-weight: 600; cursor: pointer; transition: all 0.2s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--primary); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); transform: translateY(-1px); }
|
||||||
|
.btn-outline { background: white; color: var(--primary); border: 2px solid var(--primary); }
|
||||||
|
.btn-outline:hover { background: var(--primary); color: white; }
|
||||||
|
.btn-green { background: #2E7D32; color: white; }
|
||||||
|
.btn-green:hover { background: #1B5E20; transform: translateY(-1px); }
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 0; margin-bottom: 24px;
|
||||||
|
background: var(--card); border-radius: var(--radius); overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
flex: 1; padding: 14px 20px; text-align: center; font-weight: 600;
|
||||||
|
font-size: 15px; cursor: pointer; transition: all 0.3s;
|
||||||
|
border-bottom: 3px solid transparent; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.tab.active { color: var(--primary); border-bottom-color: var(--primary); background: #FFF3ED; }
|
||||||
|
.tab:hover:not(.active) { background: #FFF8F3; }
|
||||||
|
/* Calendar View */
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px;
|
||||||
|
background: var(--card); border-radius: var(--radius); padding: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.cal-header { text-align: center; font-weight: 700; font-size: 14px; color: var(--primary); padding: 8px 0; }
|
||||||
|
.cal-header.weekend { color: #E53935; }
|
||||||
|
.cal-day {
|
||||||
|
min-height: 90px; border-radius: 8px; padding: 8px; font-size: 12px;
|
||||||
|
border: 2px solid transparent; cursor: pointer; transition: all 0.2s; position: relative;
|
||||||
|
}
|
||||||
|
.cal-day:hover { border-color: var(--primary-light); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||||
|
.cal-day.empty { background: #FAFAFA; cursor: default; }
|
||||||
|
.cal-day.empty:hover { border-color: transparent; transform: none; box-shadow: none; }
|
||||||
|
.cal-day .day-num { font-weight: 700; font-size: 16px; margin-bottom: 6px; color: var(--text); }
|
||||||
|
.cal-day.weekend .day-num { color: #E53935; }
|
||||||
|
.cal-day.today { border-color: var(--primary); background: #FFF3ED; }
|
||||||
|
.cal-day .meal-tag {
|
||||||
|
display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px;
|
||||||
|
margin: 1px 2px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.tag-breakfast { background: #FFF3E0; color: #E65100; }
|
||||||
|
.tag-lunch { background: #E8F5E9; color: #2E7D32; }
|
||||||
|
.tag-dinner { background: #E3F2FD; color: #1565C0; }
|
||||||
|
/* Week View */
|
||||||
|
.week-view { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
.week-nav {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 24px; background: linear-gradient(135deg, #FFF3ED, #FFF8F3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.week-nav h3 { font-size: 18px; color: var(--primary); }
|
||||||
|
.week-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.week-table th {
|
||||||
|
padding: 12px 8px; font-size: 13px; font-weight: 700; color: var(--primary);
|
||||||
|
background: #FFF8F3; border-bottom: 2px solid var(--border); text-align: center;
|
||||||
|
}
|
||||||
|
.week-table td {
|
||||||
|
padding: 10px 8px; font-size: 13px; text-align: center;
|
||||||
|
border-bottom: 1px solid #F5F0EC; vertical-align: top;
|
||||||
|
}
|
||||||
|
.week-table tr:hover td { background: #FFFAF7; }
|
||||||
|
.week-table .day-col { font-weight: 700; color: var(--primary-dark); white-space: nowrap; width: 70px; }
|
||||||
|
.week-table .day-col.weekend { color: #E53935; }
|
||||||
|
.dish-name {
|
||||||
|
display: inline-block; padding: 2px 8px; margin: 2px 1px; border-radius: 6px;
|
||||||
|
font-size: 12px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dish-darou { background: #FFEBEE; color: #C62828; }
|
||||||
|
.dish-xiaorou { background: #FFF3E0; color: #E65100; }
|
||||||
|
.dish-su { background: #E8F5E9; color: #2E7D32; }
|
||||||
|
.dish-tang { background: #E3F2FD; color: #1565C0; }
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); z-index: 1000; display: none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-overlay.show { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: white; border-radius: 16px; width: 90%; max-width: 700px;
|
||||||
|
max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||||
|
animation: modalIn 0.3s ease;
|
||||||
|
}
|
||||||
|
@keyframes modalIn { from { transform: translateY(30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px 24px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
position: sticky; top: 0; background: white; z-index: 1; border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
.modal-header h2 { font-size: 20px; color: var(--primary); }
|
||||||
|
.modal-close {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%; border: none; background: #F5F0EC;
|
||||||
|
font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.modal-close:hover { background: var(--primary); color: white; }
|
||||||
|
.modal-body { padding: 24px; }
|
||||||
|
.meal-section { margin-bottom: 20px; }
|
||||||
|
.meal-section h4 {
|
||||||
|
font-size: 16px; margin-bottom: 10px; padding-bottom: 6px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.meal-section h4.breakfast { color: #E65100; border-color: #FFE0B2; }
|
||||||
|
.meal-section h4.lunch { color: #2E7D32; border-color: #C8E6C9; }
|
||||||
|
.meal-section h4.dinner { color: #1565C0; border-color: #BBDEFB; }
|
||||||
|
.dish-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.dish-item {
|
||||||
|
padding: 6px 14px; border-radius: 8px; font-size: 14px; font-weight: 500;
|
||||||
|
}
|
||||||
|
/* Export area */
|
||||||
|
.export-section {
|
||||||
|
background: var(--card); border-radius: var(--radius); padding: 24px;
|
||||||
|
box-shadow: var(--shadow); display: none;
|
||||||
|
}
|
||||||
|
.export-section.show { display: block; }
|
||||||
|
.export-content {
|
||||||
|
background: #FAFAFA; border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 16px; font-family: 'Courier New', monospace; font-size: 13px;
|
||||||
|
max-height: 500px; overflow-y: auto; white-space: pre-wrap; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex; gap: 16px; flex-wrap: wrap; padding: 12px 20px;
|
||||||
|
background: var(--card); border-radius: var(--radius); margin-bottom: 16px;
|
||||||
|
box-shadow: var(--shadow); font-size: 13px;
|
||||||
|
}
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.legend-dot {
|
||||||
|
width: 14px; height: 14px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.header, .controls, .tabs, .legend, .footer, .week-nav { display: none !important; }
|
||||||
|
.week-table { font-size: 11px; }
|
||||||
|
.week-table td, .week-table th { padding: 4px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 12px 8px; }
|
||||||
|
.header { padding: 16px; }
|
||||||
|
.header h1 { font-size: 22px; }
|
||||||
|
.controls { flex-direction: column; align-items: stretch; }
|
||||||
|
.cal-day { min-height: 70px; }
|
||||||
|
.cal-day .meal-tag { font-size: 10px; }
|
||||||
|
.week-table { font-size: 11px; }
|
||||||
|
.week-table td { padding: 6px 4px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1>🍱 食堂菜单管理系统</h1>
|
||||||
|
<p>智能生成每周不重样菜单,支持日历查看与导出</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="controls">
|
||||||
|
<label>选择月份:</label>
|
||||||
|
<input type="month" id="monthPicker" />
|
||||||
|
<button class="btn btn-primary" onclick="generateMenu()">🎲 生成菜单</button>
|
||||||
|
<button class="btn btn-outline" onclick="switchView('calendar')">📅 日历视图</button>
|
||||||
|
<button class="btn btn-outline" onclick="switchView('week')">📋 周视图</button>
|
||||||
|
<button class="btn btn-green" onclick="showExport()">📥 导出菜单</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#FFEBEE;border:1px solid #C62828"></div> 大荤</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#FFF3E0;border:1px solid #E65100"></div> 小荤</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#E8F5E9;border:1px solid #2E7D32"></div> 素菜</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#E3F2FD;border:1px solid #1565C0"></div> 汤</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="calendarView"></div>
|
||||||
|
<div id="weekView" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="export-section" id="exportSection">
|
||||||
|
<h3 style="margin-bottom:16px;color:var(--primary)">📥 导出菜单</h3>
|
||||||
|
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-primary" onclick="exportText()">导出文本</button>
|
||||||
|
<button class="btn btn-outline" onclick="exportCSV()">导出CSV</button>
|
||||||
|
<button class="btn btn-green" onclick="printMenu()">🖨️ 打印</button>
|
||||||
|
</div>
|
||||||
|
<div class="export-content" id="exportContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="dayModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modalTitle">菜单详情</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">食堂菜单管理系统 © 2026 | 数据本地生成,无需服务器</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ==================== 菜品库 ====================
|
||||||
|
const DISHES = {
|
||||||
|
darou: [
|
||||||
|
'红烧排骨', '糖醋里脊', '清蒸鲈鱼', '宫保鸡丁', '东坡肉',
|
||||||
|
'剁椒鱼头', '红烧牛肉', '烤鸭', '水煮肉片', '梅菜扣肉',
|
||||||
|
'辣子鸡', '回锅肉', '红烧狮子头', '酸菜鱼', '油焖大虾',
|
||||||
|
'蒜蓉粉丝蒸扇贝', '黑椒牛柳', '啤酒鸭', '粉蒸肉', '红烧猪蹄'
|
||||||
|
],
|
||||||
|
xiaorou: [
|
||||||
|
'番茄炒蛋', '青椒肉丝', '木须肉', '鱼香肉丝', '肉末豆腐',
|
||||||
|
'蒜苔炒肉', '芹菜炒肉丝', '土豆烧鸡', '蘑菇炒肉', '豆角炒肉',
|
||||||
|
'西兰花炒肉片', '黄瓜炒鸡丁', '洋葱炒牛肉', '木耳炒肉片', '韭菜炒鸡蛋',
|
||||||
|
'莴笋炒肉丝', '胡萝卜炒肉丝', '花菜炒肉片', '干煸四季豆炒肉', '香菇滑鸡',
|
||||||
|
'彩椒炒鸡柳', '冬瓜烧肉丸', '茄子烧肉', '莲藕炒肉片', '白菜炒肉片',
|
||||||
|
'荷兰豆炒腊肉', '苦瓜炒肉片', '丝瓜炒鸡蛋', '平菇炒肉片', '包菜炒肉片'
|
||||||
|
],
|
||||||
|
su: [
|
||||||
|
'清炒时蔬', '蒜蓉西兰花', '醋溜白菜', '干煸四季豆', '地三鲜',
|
||||||
|
'麻婆豆腐', '红烧茄子', '蒜蓉菠菜', '清炒豆芽', '凉拌黄瓜',
|
||||||
|
'虎皮青椒', '素炒丝瓜', '蒜蓉生菜', '炒土豆丝', '素炒冬瓜',
|
||||||
|
'蒜蓉秋葵', '素炒西葫芦', '清炒芦笋', '白灼菜心', '素炒藕片',
|
||||||
|
'蒜蓉娃娃菜', '素炒茭白', '清炒山药', '素炒木耳', '蒜蓉空心菜',
|
||||||
|
'素炒莴笋丝', '清炒油麦菜', '素炒豆苗', '蒜蓉苋菜', '素炒豆角'
|
||||||
|
],
|
||||||
|
tang: [
|
||||||
|
'番茄蛋花汤', '紫菜蛋汤', '冬瓜排骨汤', '玉米排骨汤', '海带豆腐汤',
|
||||||
|
'莲藕排骨汤', '萝卜牛腩汤', '菌菇鸡汤', '酸辣汤', '南瓜浓汤'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEAL_NAMES = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐' };
|
||||||
|
const DAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ key: 'darou', name: '大荤', count: 2, css: 'dish-darou' },
|
||||||
|
{ key: 'xiaorou', name: '小荤', count: 3, css: 'dish-xiaorou' },
|
||||||
|
{ key: 'su', name: '素菜', count: 3, css: 'dish-su' },
|
||||||
|
{ key: 'tang', name: '汤', count: 1, css: 'dish-tang' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ==================== 随机工具 ====================
|
||||||
|
function seededRandom(seed) {
|
||||||
|
let s = seed;
|
||||||
|
return function() {
|
||||||
|
s = (s * 16807 + 0) % 2147483647;
|
||||||
|
return (s - 1) / 2147483646;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleWithSeed(arr, seed) {
|
||||||
|
const rng = seededRandom(seed);
|
||||||
|
const result = [...arr];
|
||||||
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[result[i], result[j]] = [result[j], result[i]];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 菜单生成算法 ====================
|
||||||
|
// 菜单存储: menuData[year][month][day] = { breakfast: {...}, lunch: {...}, dinner: {...} }
|
||||||
|
let menuData = {};
|
||||||
|
let currentYear, currentMonth;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const now = new Date();
|
||||||
|
currentYear = now.getFullYear();
|
||||||
|
currentMonth = now.getMonth() + 1;
|
||||||
|
document.getElementById('monthPicker').value = `${currentYear}-${String(currentMonth).padStart(2, '0')}`;
|
||||||
|
generateMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMenu() {
|
||||||
|
const val = document.getElementById('monthPicker').value;
|
||||||
|
if (val) {
|
||||||
|
const [y, m] = val.split('-').map(Number);
|
||||||
|
currentYear = y;
|
||||||
|
currentMonth = m;
|
||||||
|
}
|
||||||
|
menuData = {};
|
||||||
|
// 生成当月和下月
|
||||||
|
for (let offset = 0; offset <= 1; offset++) {
|
||||||
|
let y = currentYear, m = currentMonth + offset;
|
||||||
|
if (m > 12) { m = 1; y++; }
|
||||||
|
generateMonth(y, m);
|
||||||
|
}
|
||||||
|
renderCurrentView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMonth(year, month) {
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
// 找到本月所有周,按周生成
|
||||||
|
const firstDay = new Date(year, month - 1, 1);
|
||||||
|
const lastDay = new Date(year, month - 1, daysInMonth);
|
||||||
|
|
||||||
|
// 计算本月覆盖的所有周一
|
||||||
|
let startMonday = new Date(firstDay);
|
||||||
|
const dow = startMonday.getDay();
|
||||||
|
const diff = dow === 0 ? -6 : 1 - dow;
|
||||||
|
startMonday.setDate(startMonday.getDate() + diff);
|
||||||
|
|
||||||
|
const weeks = [];
|
||||||
|
let current = new Date(startMonday);
|
||||||
|
while (current <= lastDay || current.getMonth() === month - 1) {
|
||||||
|
const weekStart = new Date(current);
|
||||||
|
const weekDays = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(weekStart);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
if (d.getMonth() === month - 1) {
|
||||||
|
weekDays.push(d.getDate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (weekDays.length > 0) {
|
||||||
|
weeks.push({ start: weekStart, days: weekDays });
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 7);
|
||||||
|
if (current.getMonth() > month - 1 + (year !== current.getFullYear() ? 12 : 0) && weekDays.length === 0) break;
|
||||||
|
if (current.getFullYear() > year && current.getMonth() >= month) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算全局周编号(用于邻周去重)
|
||||||
|
const epoch = new Date(2026, 0, 5); // 2026-01-05 is a Monday
|
||||||
|
weeks.forEach((week, wi) => {
|
||||||
|
const weekNum = Math.floor((week.start - epoch) / (7 * 86400000));
|
||||||
|
generateWeek(year, month, week.days, weekNum);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateWeek(year, month, days, weekNum) {
|
||||||
|
if (!menuData[year]) menuData[year] = {};
|
||||||
|
if (!menuData[year][month]) menuData[year][month] = {};
|
||||||
|
|
||||||
|
const meals = ['breakfast', 'lunch', 'dinner'];
|
||||||
|
|
||||||
|
meals.forEach(meal => {
|
||||||
|
CATEGORIES.forEach(cat => {
|
||||||
|
const pool = DISHES[cat.key];
|
||||||
|
const needed = cat.count * 7; // 一周7天每顿所需
|
||||||
|
// 使用周编号和餐次作为种子,确保可复现且邻周不同
|
||||||
|
const baseSeed = weekNum * 1000 + meals.indexOf(meal) * 100 + ['darou','xiaorou','su','tang'].indexOf(cat.key);
|
||||||
|
|
||||||
|
// 用不同偏移生成多个排列来覆盖需求
|
||||||
|
// 对于每个餐次,我们需要7天的菜品,每天cat.count个
|
||||||
|
const shuffled1 = shuffleWithSeed(pool, baseSeed);
|
||||||
|
const shuffled2 = shuffleWithSeed(pool, baseSeed + 500);
|
||||||
|
|
||||||
|
// 合并两个排列以获得足够的菜品
|
||||||
|
let allDishes = [...shuffled1];
|
||||||
|
if (needed > pool.length) {
|
||||||
|
// 需要复用,但用不同顺序
|
||||||
|
allDishes = [...shuffled1, ...shuffled2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从排列中依次取菜,确保一周内不重复(在同一个meal+category内)
|
||||||
|
let assigned = [];
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const dayDishes = [];
|
||||||
|
for (let c = 0; c < cat.count; c++) {
|
||||||
|
const idx = d * cat.count + c;
|
||||||
|
dayDishes.push(allDishes[idx % allDishes.length]);
|
||||||
|
}
|
||||||
|
assigned.push(dayDishes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只为本月内的日期赋值
|
||||||
|
days.forEach((day, di) => {
|
||||||
|
// di 是在 weekDays 中的索引,但 weekDays 可能不满7天
|
||||||
|
// 需要找到该日在周中的实际位置
|
||||||
|
const dayOfWeek = new Date(year, month - 1, day).getDay();
|
||||||
|
const weekIdx = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 0=周一...6=周日
|
||||||
|
|
||||||
|
if (!menuData[year][month][day]) {
|
||||||
|
menuData[year][month][day] = { breakfast: {}, lunch: {}, dinner: {} };
|
||||||
|
}
|
||||||
|
menuData[year][month][day][meal][cat.key] = assigned[weekIdx];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 渲染 ====================
|
||||||
|
let currentView = 'calendar';
|
||||||
|
let currentWeekOffset = 0;
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
currentView = view;
|
||||||
|
document.getElementById('calendarView').style.display = view === 'calendar' ? '' : 'none';
|
||||||
|
document.getElementById('weekView').style.display = view === 'week' ? '' : 'none';
|
||||||
|
document.getElementById('exportSection').classList.remove('show');
|
||||||
|
renderCurrentView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentView() {
|
||||||
|
if (currentView === 'calendar') renderCalendar();
|
||||||
|
else renderWeek();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
const container = document.getElementById('calendarView');
|
||||||
|
const y = currentYear, m = currentMonth;
|
||||||
|
const daysInMonth = new Date(y, m, 0).getDate();
|
||||||
|
const firstDayOfWeek = new Date(y, m - 1, 1).getDay();
|
||||||
|
const startOffset = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||||
|
const today = new Date();
|
||||||
|
const isCurrentMonth = today.getFullYear() === y && today.getMonth() + 1 === m;
|
||||||
|
|
||||||
|
let html = '<div class="calendar-grid">';
|
||||||
|
// 星期头
|
||||||
|
DAY_NAMES.forEach((d, i) => {
|
||||||
|
html += `<div class="cal-header${i >= 5 ? ' weekend' : ''}">${d}</div>`;
|
||||||
|
});
|
||||||
|
// 空白填充
|
||||||
|
for (let i = 0; i < startOffset; i++) {
|
||||||
|
html += '<div class="cal-day empty"></div>';
|
||||||
|
}
|
||||||
|
// 日期
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const dayOfWeek = new Date(y, m - 1, d).getDay();
|
||||||
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
const isToday = isCurrentMonth && today.getDate() === d;
|
||||||
|
const data = getMenuDay(y, m, d);
|
||||||
|
|
||||||
|
let tags = '';
|
||||||
|
if (data) {
|
||||||
|
tags = `<div><span class="meal-tag tag-breakfast">早${(data.breakfast.darou||[]).length + (data.breakfast.xiaorou||[]).length + (data.breakfast.su||[]).length + (data.breakfast.tang||[]).length}道</span></div>`;
|
||||||
|
tags += `<div><span class="meal-tag tag-lunch">午${(data.lunch.darou||[]).length + (data.lunch.xiaorou||[]).length + (data.lunch.su||[]).length + (data.lunch.tang||[]).length}道</span></div>`;
|
||||||
|
tags += `<div><span class="meal-tag tag-dinner">晚${(data.dinner.darou||[]).length + (data.dinner.xiaorou||[]).length + (data.dinner.su||[]).length + (data.dinner.tang||[]).length}道</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="cal-day${isWeekend ? ' weekend' : ''}${isToday ? ' today' : ''}" onclick="showDayDetail(${y},${m},${d})">
|
||||||
|
<div class="day-num">${d}</div>${tags}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeek() {
|
||||||
|
const container = document.getElementById('weekView');
|
||||||
|
const y = currentYear, m = currentMonth;
|
||||||
|
const daysInMonth = new Date(y, m, 0).getDate();
|
||||||
|
const firstDay = new Date(y, m - 1, 1);
|
||||||
|
const lastDay = new Date(y, m - 1, daysInMonth);
|
||||||
|
|
||||||
|
// 计算周列表
|
||||||
|
const weeks = [];
|
||||||
|
let startMonday = new Date(firstDay);
|
||||||
|
const dow = startMonday.getDay();
|
||||||
|
const diff = dow === 0 ? -6 : 1 - dow;
|
||||||
|
startMonday.setDate(startMonday.getDate() + diff);
|
||||||
|
|
||||||
|
let cur = new Date(startMonday);
|
||||||
|
while (cur <= lastDay || (cur.getMonth() === m - 1)) {
|
||||||
|
const ws = new Date(cur);
|
||||||
|
const weekDates = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(ws);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
if (d.getMonth() === m - 1 && d.getFullYear() === y) {
|
||||||
|
weekDates.push(d.getDate());
|
||||||
|
} else {
|
||||||
|
weekDates.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (weekDates.some(d => d !== null)) {
|
||||||
|
weeks.push({ start: ws, dates: weekDates });
|
||||||
|
}
|
||||||
|
cur.setDate(cur.getDate() + 7);
|
||||||
|
if (cur.getFullYear() > y || (cur.getFullYear() === y && cur.getMonth() > m - 1)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWeekOffset < 0) currentWeekOffset = 0;
|
||||||
|
if (currentWeekOffset >= weeks.length) currentWeekOffset = weeks.length - 1;
|
||||||
|
|
||||||
|
const week = weeks[currentWeekOffset];
|
||||||
|
if (!week) { container.innerHTML = '<p style="text-align:center;padding:40px">暂无数据</p>'; return; }
|
||||||
|
|
||||||
|
const startDate = week.start;
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setDate(endDate.getDate() + 6);
|
||||||
|
|
||||||
|
function fmt(d) { return `${d.getMonth()+1}/${d.getDate()}`; }
|
||||||
|
|
||||||
|
let html = `<div class="week-view">
|
||||||
|
<div class="week-nav">
|
||||||
|
<button class="btn btn-outline" onclick="currentWeekOffset--;renderWeek()">◀ 上一周</button>
|
||||||
|
<h3>${fmt(startDate)} ~ ${fmt(endDate)}(第${currentWeekOffset+1}周)</h3>
|
||||||
|
<button class="btn btn-outline" onclick="currentWeekOffset++;renderWeek()">下一周 ▶</button>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="week-table">
|
||||||
|
<tr><th>餐次/类别</th>`;
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const isWe = i >= 5;
|
||||||
|
html += `<th class="${isWe ? 'weekend' : ''}">${DAY_NAMES[i]}<br/>`;
|
||||||
|
if (week.dates[i]) {
|
||||||
|
html += `<span style="font-weight:400;font-size:11px">${m}/${week.dates[i]}</span>`;
|
||||||
|
}
|
||||||
|
html += '</th>';
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
|
||||||
|
['breakfast', 'lunch', 'dinner'].forEach(meal => {
|
||||||
|
CATEGORIES.forEach(cat => {
|
||||||
|
const catLabel = cat.name;
|
||||||
|
html += `<tr><td style="font-weight:600;white-space:nowrap;color:var(--text-secondary)">${MEAL_NAMES[meal]}·${catLabel}</td>`;
|
||||||
|
for (let di = 0; di < 7; di++) {
|
||||||
|
const day = week.dates[di];
|
||||||
|
if (day) {
|
||||||
|
const data = getMenuDay(y, m, day);
|
||||||
|
if (data && data[meal] && data[meal][cat.key]) {
|
||||||
|
const dishes = data[meal][cat.key].map(d =>
|
||||||
|
`<span class="dish-name ${cat.css}">${d}</span>`
|
||||||
|
).join(' ');
|
||||||
|
html += `<td>${dishes}</td>`;
|
||||||
|
} else {
|
||||||
|
html += '<td>-</td>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += '<td style="color:#ccc">-</td>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</table></div></div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMenuDay(y, m, d) {
|
||||||
|
if (menuData[y] && menuData[y][m] && menuData[y][m][d]) return menuData[y][m][d];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 日详情弹窗 ====================
|
||||||
|
function showDayDetail(y, m, d) {
|
||||||
|
const data = getMenuDay(y, m, d);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
document.getElementById('modalTitle').textContent = `${y}年${m}月${d}日 菜单`;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
['breakfast', 'lunch', 'dinner'].forEach(meal => {
|
||||||
|
const mealClass = meal === 'breakfast' ? 'breakfast' : meal === 'lunch' ? 'lunch' : 'dinner';
|
||||||
|
html += `<div class="meal-section"><h4 class="${mealClass}">🍳 ${MEAL_NAMES[meal]}</h4><div class="dish-list">`;
|
||||||
|
CATEGORIES.forEach(cat => {
|
||||||
|
if (data[meal][cat.key]) {
|
||||||
|
data[meal][cat.key].forEach(dish => {
|
||||||
|
html += `<span class="dish-item ${cat.css}">${dish}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
html += '</div></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modalBody').innerHTML = html;
|
||||||
|
document.getElementById('dayModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('dayModal').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dayModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 导出 ====================
|
||||||
|
function showExport() {
|
||||||
|
document.getElementById('exportSection').classList.add('show');
|
||||||
|
exportText();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportText() {
|
||||||
|
const y = currentYear, m = currentMonth;
|
||||||
|
const nextY = m === 12 ? y + 1 : y;
|
||||||
|
const nextM = m === 12 ? 1 : m + 1;
|
||||||
|
|
||||||
|
let text = `═══════════════════════════════════════\n`;
|
||||||
|
text += ` 食堂菜单 (${y}年${m}月 ~ ${nextY}年${nextM}月)\n`;
|
||||||
|
text += `═══════════════════════════════════════\n\n`;
|
||||||
|
|
||||||
|
[[y, m], [nextY, nextM]].forEach(([yy, mm]) => {
|
||||||
|
const days = new Date(yy, mm, 0).getDate();
|
||||||
|
text += `──── ${yy}年${mm}月 ────\n\n`;
|
||||||
|
for (let d = 1; d <= days; d++) {
|
||||||
|
const data = getMenuDay(yy, mm, d);
|
||||||
|
const dow = new Date(yy, mm - 1, d).getDay();
|
||||||
|
const dayName = DAY_NAMES[dow === 0 ? 6 : dow - 1];
|
||||||
|
text += `【${mm}月${d}日 ${dayName}】\n`;
|
||||||
|
if (data) {
|
||||||
|
['breakfast', 'lunch', 'dinner'].forEach(meal => {
|
||||||
|
const items = [];
|
||||||
|
CATEGORIES.forEach(cat => {
|
||||||
|
if (data[meal][cat.key]) {
|
||||||
|
items.push(`${cat.name}: ${data[meal][cat.key].join('、')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
text += ` ${MEAL_NAMES[meal]} | ${items.join(' | ')}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
text += '\n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('exportContent').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const y = currentYear, m = currentMonth;
|
||||||
|
const nextY = m === 12 ? y + 1 : y;
|
||||||
|
const nextM = m === 12 ? 1 : m + 1;
|
||||||
|
|
||||||
|
let csv = '\uFEFF日期,星期,餐次,大荤1,大荤2,小荤1,小荤2,小荤3,素菜1,素菜2,素菜3,汤\n';
|
||||||
|
|
||||||
|
[[y, m], [nextY, nextM]].forEach(([yy, mm]) => {
|
||||||
|
const days = new Date(yy, mm, 0).getDate();
|
||||||
|
for (let d = 1; d <= days; d++) {
|
||||||
|
const data = getMenuDay(yy, mm, d);
|
||||||
|
const dow = new Date(yy, mm - 1, d).getDay();
|
||||||
|
const dayName = DAY_NAMES[dow === 0 ? 6 : dow - 1];
|
||||||
|
const dateStr = `${yy}-${String(mm).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||||
|
if (data) {
|
||||||
|
['breakfast', 'lunch', 'dinner'].forEach(meal => {
|
||||||
|
const row = [dateStr, dayName, MEAL_NAMES[meal]];
|
||||||
|
CATEGORIES.forEach(cat => {
|
||||||
|
const dishes = data[meal][cat.key] || [];
|
||||||
|
for (let i = 0; i < cat.count; i++) {
|
||||||
|
row.push(dishes[i] || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
csv += row.join(',') + '\n';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `食堂菜单_${y}年${m}月.csv`;
|
||||||
|
link.click();
|
||||||
|
document.getElementById('exportContent').textContent = 'CSV文件已下载!';
|
||||||
|
}
|
||||||
|
|
||||||
|
function printMenu() {
|
||||||
|
// 切换到周视图并打印
|
||||||
|
const current = currentView;
|
||||||
|
currentWeekOffset = 0;
|
||||||
|
switchView('week');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始化 ====================
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
安装codebuddy插件示意图.png
Normal file
BIN
安装codebuddy插件示意图.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
打开文件夹-1.png
Normal file
BIN
打开文件夹-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
打开文件夹-2.png
Normal file
BIN
打开文件夹-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
用glm5.1开发示意图.png
Normal file
BIN
用glm5.1开发示意图.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Reference in New Issue
Block a user