feat: 现代化翻页组件 + 完整项目更新
- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航 - PickModal 和 SwapModal 集成分页功能,每页20项 - 搜索/筛选切换自动回到第1页 - 现代化CSS样式:hover浮起动效、主色高亮、移动端适配 - 新增 dashboard、admin 管理模板 - 菜系(cuisine)模型和迁移 - 构建脚本 build.py 支持 PyInstaller 打包 - 前端资源重新构建
This commit is contained in:
@@ -2,7 +2,13 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon_512.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="/icon_512.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="学校订餐菜单生成器 - 自动生成营养均衡的周菜单" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>学校订餐菜单生成器</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,71 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="55%" stop-color="#5457e5"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bowlGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#dbeafe"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bowlRim" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#eef2ff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="chopstick" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#fbbf24"/>
|
||||
<stop offset="100%" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brothGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#fde68a"/>
|
||||
<stop offset="100%" stop-color="#fcd34d"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="8"/>
|
||||
<feOffset dx="0" dy="6" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.15"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Rounded square background -->
|
||||
<rect width="512" height="512" rx="112" fill="url(#bgGrad)"/>
|
||||
|
||||
<!-- Subtle decorative circle (menu/cycle concept) -->
|
||||
<circle cx="256" cy="256" r="200" fill="none" stroke="#818cf8" stroke-width="3" stroke-dasharray="2 14" opacity="0.25"/>
|
||||
|
||||
<!-- Steam lines (smooth waves) -->
|
||||
<path d="M196 154 C188 134 204 114 196 94 C188 74 204 54 196 40" stroke="#c7d2fe" stroke-width="13" stroke-linecap="round" fill="none" opacity="0.65"/>
|
||||
<path d="M256 154 C248 134 264 114 256 94 C248 74 264 54 256 40" stroke="#c7d2fe" stroke-width="13" stroke-linecap="round" fill="none" opacity="0.65"/>
|
||||
<path d="M316 154 C308 134 324 114 316 94 C308 74 324 54 316 40" stroke="#c7d2fe" stroke-width="13" stroke-linecap="round" fill="none" opacity="0.65"/>
|
||||
|
||||
<!-- Chopsticks (pair, angled) -->
|
||||
<rect x="296" y="118" width="15" height="175" rx="7.5" fill="url(#chopstick)" transform="rotate(22 303.5 205.5)"/>
|
||||
<rect x="322" y="128" width="15" height="170" rx="7.5" fill="url(#chopstick)" transform="rotate(28 329.5 213)"/>
|
||||
|
||||
<!-- Bowl body -->
|
||||
<path d="M120 242 L392 242 Q376 350 256 370 Q136 350 120 242 Z" fill="url(#bowlGrad)" filter="url(#shadow)"/>
|
||||
|
||||
<!-- Bowl rim (ellipse on top) -->
|
||||
<ellipse cx="256" cy="242" rx="136" ry="24" fill="url(#bowlRim)"/>
|
||||
|
||||
<!-- Bowl inner area (filled broth) -->
|
||||
<ellipse cx="256" cy="242" rx="120" ry="18" fill="url(#brothGrad)" opacity="0.7"/>
|
||||
|
||||
<!-- Food items in bowl -->
|
||||
<!-- Rice / grains -->
|
||||
<ellipse cx="210" cy="240" rx="24" ry="8" fill="#fef3c7" opacity="0.9"/>
|
||||
<ellipse cx="256" cy="236" rx="28" ry="9" fill="#fef3c7" opacity="0.9"/>
|
||||
<ellipse cx="300" cy="240" rx="20" ry="7" fill="#fef3c7" opacity="0.85"/>
|
||||
|
||||
<!-- Small vegetable / herb accents -->
|
||||
<circle cx="224" cy="234" r="6" fill="#86efac" opacity="0.85"/>
|
||||
<circle cx="288" cy="234" r="6" fill="#86efac" opacity="0.85"/>
|
||||
<circle cx="256" cy="228" r="5" fill="#fb923c" opacity="0.85"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
+308
-79
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
|
||||
const DAYS = ['周一', '周二', '周三', '周四', '周五']
|
||||
@@ -18,7 +18,34 @@ const TYPE_GROUPS = [
|
||||
]
|
||||
const TYPE_LABELS = { meats: '荤菜', vegs: '素菜', soup: '营养汤', staple: '主食' }
|
||||
const TYPE_TO_TAB = { meats: 'meat', vegs: 'veg', soup: 'soup', staple: 'staple' }
|
||||
const DEFAULT_SCHOOL = '上海外国语大学附属宝山双语学校'
|
||||
const TYPE_ALL_LABELS = { meat: '荤菜', veg: '素菜', soup: '营养汤', staple: '主食' }
|
||||
const DEFAULT_SCHOOL = '学校菜单'
|
||||
const MODAL_PAGE_SIZE = 20
|
||||
|
||||
/** Compute ISO week Monday/Friday as Date objects */
|
||||
const weekDates = (weekNo) => {
|
||||
const now = new Date()
|
||||
const isoYear = now.getFullYear()
|
||||
const jan4 = new Date(isoYear, 0, 4)
|
||||
const startOfISOWeek1 = new Date(jan4)
|
||||
startOfISOWeek1.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7))
|
||||
const monday = new Date(startOfISOWeek1)
|
||||
monday.setDate(startOfISOWeek1.getDate() + (weekNo - 1) * 7)
|
||||
const friday = new Date(monday)
|
||||
friday.setDate(monday.getDate() + 4)
|
||||
return { monday, friday }
|
||||
}
|
||||
|
||||
/** Format Date as M.D for title */
|
||||
const fmtDateShort = (d) => `${d.getMonth() + 1}.${d.getDate()}`
|
||||
|
||||
/** Format Date as YYYY-MM-DD for date input */
|
||||
const fmtDateISO = (d) => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
const currentIsoWeek = () => {
|
||||
const now = new Date()
|
||||
@@ -33,8 +60,147 @@ const currentIsoWeek = () => {
|
||||
|
||||
const slotKey = (menu, day, slot, idx) => `${menu}|${day}|${slot}|${idx}`
|
||||
|
||||
/* ============================
|
||||
Modern Pagination Component
|
||||
============================ */
|
||||
function Pagination({ page, total, pageSize, onChange }) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(Math.max(1, page), totalPages)
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const go = (p) => onChange(Math.min(Math.max(1, p), totalPages))
|
||||
|
||||
// Build page number list with ellipsis
|
||||
const pages = []
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
|
||||
pages.push(i)
|
||||
} else if (pages.length > 0 && pages[pages.length - 1] !== '...') {
|
||||
pages.push('...')
|
||||
}
|
||||
}
|
||||
|
||||
const startItem = (currentPage - 1) * pageSize + 1
|
||||
const endItem = Math.min(currentPage * pageSize, total)
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<div className="pg-info">
|
||||
显示 {startItem}-{endItem} / 共 {total} 项
|
||||
</div>
|
||||
<div className="pg-controls">
|
||||
<button
|
||||
className="pg-btn pg-arrow"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => go(1)}
|
||||
title="首页"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
|
||||
</button>
|
||||
<button
|
||||
className="pg-btn pg-arrow"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => go(currentPage - 1)}
|
||||
title="上一页"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`e${i}`} className="pg-ellipsis">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`pg-btn pg-num ${p === currentPage ? 'active' : ''}`}
|
||||
onClick={() => go(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
className="pg-btn pg-arrow"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => go(currentPage + 1)}
|
||||
title="下一页"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
<button
|
||||
className="pg-btn pg-arrow"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => go(totalPages)}
|
||||
title="末页"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>
|
||||
</button>
|
||||
<span className="pg-jump">
|
||||
跳至
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value)
|
||||
if (v >= 1 && v <= totalPages) go(v)
|
||||
}}
|
||||
className="pg-jump-input"
|
||||
/>
|
||||
页
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================
|
||||
FlatDishList — paginated dish grid for modals
|
||||
============================ */
|
||||
function FlatDishList({ items, currentId, hover, setHover, onSelect, renderItem }) {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Reset page when items change (e.g. search/filter)
|
||||
const itemsKey = items.map(d => d.id).join(',')
|
||||
const prevItemsKey = useMemo(() => itemsKey, [])
|
||||
if (itemsKey !== prevItemsKey) {
|
||||
// Use effect-less pattern: we'll reset via key prop
|
||||
}
|
||||
|
||||
const startIdx = (page - 1) * MODAL_PAGE_SIZE
|
||||
const pageItems = items.slice(startIdx, startIdx + MODAL_PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div className="flat-dish-list">
|
||||
<div className="modal-items-grid">
|
||||
{pageItems.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={`modal-item ${d.id === currentId ? 'current' : ''}`}
|
||||
onClick={() => onSelect(d)}
|
||||
onMouseEnter={(e) => setHover({ dish: d, x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => setHover((h) => (h && h.dish.id === d.id ? { dish: d, x: e.clientX, y: e.clientY } : h))}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<span className="modal-item-badge">{TYPE_ALL_LABELS[d.dish_type] || d.dish_type}</span>
|
||||
<span className="item-name">{d.name}</span>
|
||||
{d.ingredient_detail && <span className="item-ing">{d.ingredient_detail}</span>}
|
||||
</button>
|
||||
))}
|
||||
{pageItems.length === 0 && <p className="hint warn">没有匹配的菜品</p>}
|
||||
</div>
|
||||
<Pagination page={page} total={items.length} pageSize={MODAL_PAGE_SIZE} onChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, setSwapKeyword, setSwapType, onReplace, onClose }) {
|
||||
const [hover, setHover] = useState(null)
|
||||
const [swapPage, setSwapPage] = useState(1)
|
||||
|
||||
// Reset page on filter/search change
|
||||
useEffect(() => { setSwapPage(1) }, [swapKeyword, swapType])
|
||||
|
||||
if (!editing || !menus) return null
|
||||
const { menuKey, day, type, idx } = editing
|
||||
|
||||
@@ -51,9 +217,20 @@ function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, s
|
||||
const p = pinyinMap[d.id]
|
||||
return d.name.toLowerCase().includes(kw) || (p && (p.full.includes(kw) || p.initials.includes(kw)))
|
||||
}
|
||||
const groups = (swapType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === swapType))
|
||||
.map(([t, label]) => [t, label, dishes[t].filter(match)])
|
||||
.filter(([, , pool]) => pool.length)
|
||||
|
||||
const flatList = useMemo(() => {
|
||||
const result = []
|
||||
const groups = (swapType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === swapType))
|
||||
for (const [t] of groups) {
|
||||
for (const d of dishes[t]) {
|
||||
if (match(d)) result.push(d)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [swapType, kw, dishes])
|
||||
|
||||
const startIdx = (swapPage - 1) * MODAL_PAGE_SIZE
|
||||
const pageItems = flatList.slice(startIdx, startIdx + MODAL_PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -65,11 +242,11 @@ function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, s
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h3>
|
||||
替换{menuLabel}·{day}「{slotLabel}」
|
||||
替换{menuLabel}·{day}«{slotLabel}»
|
||||
{current ? `(当前:${current.name})` : ''}
|
||||
</h3>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-toolbar">
|
||||
@@ -83,43 +260,32 @@ function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, s
|
||||
</div>
|
||||
<input
|
||||
className="pick-search"
|
||||
placeholder="搜索菜名…"
|
||||
placeholder="搜索菜名或拼音..."
|
||||
value={swapKeyword}
|
||||
onChange={(e) => setSwapKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{groups.map(([t, label, pool]) => {
|
||||
const cuisines = [...new Set(pool.map((d) => d.cuisine || '未分类'))]
|
||||
return (
|
||||
<div className="modal-group" key={t}>
|
||||
<div className="modal-group-label">{label}</div>
|
||||
{cuisines.map((g) => (
|
||||
<div key={g} className="modal-subgroup">
|
||||
<div className="modal-subgroup-label">{g}</div>
|
||||
<div className="modal-items">
|
||||
{pool
|
||||
.filter((d) => (d.cuisine || '未分类') === g)
|
||||
.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={`modal-item ${d.id === current?.id ? 'current' : ''}`}
|
||||
onClick={() => onReplace(menuKey, day, type, idx, d.id)}
|
||||
onMouseEnter={(e) => setHover({ dish: d, x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => setHover((h) => (h && h.dish.id === d.id ? { dish: d, x: e.clientX, y: e.clientY } : h))}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<span className="item-name">{d.name}</span>
|
||||
{d.ingredient_detail && <span className="item-ing">({d.ingredient_detail})</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!groups.length && <p className="hint warn">没有匹配的菜品</p>}
|
||||
<div className="flat-dish-list">
|
||||
<div className="modal-items-grid">
|
||||
{pageItems.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={`modal-item ${d.id === current?.id ? 'current' : ''}`}
|
||||
onClick={() => onReplace(menuKey, day, type, idx, d.id)}
|
||||
onMouseEnter={(e) => setHover({ dish: d, x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => setHover((h) => (h && h.dish.id === d.id ? { dish: d, x: e.clientX, y: e.clientY } : h))}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<span className="modal-item-badge">{TYPE_ALL_LABELS[d.dish_type] || d.dish_type}</span>
|
||||
<span className="item-name">{d.name}</span>
|
||||
{d.ingredient_detail && <span className="item-ing">{d.ingredient_detail}</span>}
|
||||
</button>
|
||||
))}
|
||||
{pageItems.length === 0 && <p className="hint warn">没有匹配的菜品</p>}
|
||||
</div>
|
||||
<Pagination page={swapPage} total={flatList.length} pageSize={MODAL_PAGE_SIZE} onChange={setSwapPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hover && <DishTooltip dish={hover.dish} x={hover.x} y={hover.y} />}
|
||||
@@ -129,6 +295,11 @@ function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, s
|
||||
|
||||
function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeyword, setPickType, onChoose, onClose }) {
|
||||
const [hover, setHover] = useState(null)
|
||||
const [pickPage, setPickPage] = useState(1)
|
||||
|
||||
// Reset page on filter/search change
|
||||
useEffect(() => { setPickPage(1) }, [keyword, pickType])
|
||||
|
||||
if (!pick) return null
|
||||
const { menu, day, slot, idx } = pick
|
||||
const key = slotKey(menu, day, slot, idx)
|
||||
@@ -141,9 +312,20 @@ function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeywo
|
||||
const p = pinyinMap[d.id]
|
||||
return d.name.toLowerCase().includes(kw) || (p && (p.full.includes(kw) || p.initials.includes(kw)))
|
||||
}
|
||||
const groups = (pickType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === pickType))
|
||||
.map(([t, label]) => [t, label, dishes[t].filter(match)])
|
||||
.filter(([, , pool]) => pool.length)
|
||||
|
||||
const flatList = useMemo(() => {
|
||||
const result = []
|
||||
const groups = (pickType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === pickType))
|
||||
for (const [t] of groups) {
|
||||
for (const d of dishes[t]) {
|
||||
if (match(d)) result.push(d)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [pickType, kw, dishes])
|
||||
|
||||
const startIdx = (pickPage - 1) * MODAL_PAGE_SIZE
|
||||
const pageItems = flatList.slice(startIdx, startIdx + MODAL_PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -155,11 +337,11 @@ function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeywo
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h3>
|
||||
自选{menuLabel}·{day}「{slotLabel}」
|
||||
自选{menuLabel}·{day}«{slotLabel}»
|
||||
{current ? `(当前:${current.name})` : '(未选择,可留空自动生成)'}
|
||||
</h3>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-toolbar">
|
||||
@@ -175,43 +357,32 @@ function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeywo
|
||||
</div>
|
||||
<input
|
||||
className="pick-search"
|
||||
placeholder="搜索菜名…"
|
||||
placeholder="搜索菜名或拼音..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{groups.map(([t, label, pool]) => {
|
||||
const cuisines = [...new Set(pool.map((d) => d.cuisine || '未分类'))]
|
||||
return (
|
||||
<div className="modal-group" key={t}>
|
||||
<div className="modal-group-label">{label}</div>
|
||||
{cuisines.map((g) => (
|
||||
<div key={g} className="modal-subgroup">
|
||||
<div className="modal-subgroup-label">{g}</div>
|
||||
<div className="modal-items">
|
||||
{pool
|
||||
.filter((d) => (d.cuisine || '未分类') === g)
|
||||
.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={`modal-item ${d.id === current?.id ? 'current' : ''}`}
|
||||
onClick={() => onChoose(menu, day, slot, idx, d)}
|
||||
onMouseEnter={(e) => setHover({ dish: d, x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => setHover((h) => (h && h.dish.id === d.id ? { dish: d, x: e.clientX, y: e.clientY } : h))}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<span className="item-name">{d.name}</span>
|
||||
{d.ingredient_detail && <span className="item-ing">({d.ingredient_detail})</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!groups.length && <p className="hint warn">没有匹配的菜品</p>}
|
||||
<div className="flat-dish-list">
|
||||
<div className="modal-items-grid">
|
||||
{pageItems.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={`modal-item ${d.id === current?.id ? 'current' : ''}`}
|
||||
onClick={() => onChoose(menu, day, slot, idx, d)}
|
||||
onMouseEnter={(e) => setHover({ dish: d, x: e.clientX, y: e.clientY })}
|
||||
onMouseMove={(e) => setHover((h) => (h && h.dish.id === d.id ? { dish: d, x: e.clientX, y: e.clientY } : h))}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<span className="modal-item-badge">{TYPE_ALL_LABELS[d.dish_type] || d.dish_type}</span>
|
||||
<span className="item-name">{d.name}</span>
|
||||
{d.ingredient_detail && <span className="item-ing">{d.ingredient_detail}</span>}
|
||||
</button>
|
||||
))}
|
||||
{pageItems.length === 0 && <p className="hint warn">没有匹配的菜品</p>}
|
||||
</div>
|
||||
<Pagination page={pickPage} total={flatList.length} pageSize={MODAL_PAGE_SIZE} onChange={setPickPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hover && <DishTooltip dish={hover.dish} x={hover.x} y={hover.y} />}
|
||||
@@ -248,6 +419,12 @@ function App() {
|
||||
const [error, setError] = useState('')
|
||||
const [schoolName, setSchoolName] = useState(DEFAULT_SCHOOL)
|
||||
const [weekNo, setWeekNo] = useState(String(currentIsoWeek()))
|
||||
|
||||
const initDates = weekDates(Number(currentIsoWeek()))
|
||||
|
||||
const [dateStart, setDateStart] = useState(fmtDateISO(initDates.monday))
|
||||
|
||||
const [dateEnd, setDateEnd] = useState(fmtDateISO(initDates.friday))
|
||||
const [pinyinMap, setPinyinMap] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -262,6 +439,14 @@ function App() {
|
||||
setPinyinMap(map)
|
||||
}, [dishes])
|
||||
|
||||
useEffect(() => {
|
||||
if (weekNo) {
|
||||
const d = weekDates(Number(weekNo))
|
||||
setDateStart(fmtDateISO(d.monday))
|
||||
setDateEnd(fmtDateISO(d.friday))
|
||||
}
|
||||
}, [weekNo])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/dishes/')
|
||||
.then((r) => r.json())
|
||||
@@ -294,6 +479,8 @@ function App() {
|
||||
selections,
|
||||
school_name: schoolName.trim() || DEFAULT_SCHOOL,
|
||||
week_no: Number(weekNo),
|
||||
date_start: dateStart || undefined,
|
||||
date_end: dateEnd || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -479,7 +666,7 @@ function App() {
|
||||
}
|
||||
const nutritionText = `营养分析:蛋白质≥${Math.min(...proteins).toFixed(1)}g 脂肪≤${Math.max(...fats).toFixed(1)}g 热量≥${Math.round(Math.min(...calories))}kcal`
|
||||
return (
|
||||
<section className="panel table-wrap" style={{ width: '920px' }}>
|
||||
<section className="panel table-wrap menu-block">
|
||||
<div className="panel-head">
|
||||
<h2>{idx === 0 ? '营养A菜单' : '营养B菜单'}</h2>
|
||||
<span className="tip">
|
||||
@@ -602,8 +789,50 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="title-date-fields">
|
||||
|
||||
<div className="title-field date-field">
|
||||
|
||||
<label className="label">{`菜单日期范围`}</label>
|
||||
|
||||
<div className="date-range-input">
|
||||
|
||||
<input
|
||||
|
||||
type="date"
|
||||
|
||||
value={dateStart}
|
||||
|
||||
onChange={(e) => setDateStart(e.target.value)}
|
||||
|
||||
/>
|
||||
|
||||
<span className="date-sep">{'—'}</span>
|
||||
|
||||
<input
|
||||
|
||||
type="date"
|
||||
|
||||
value={dateEnd}
|
||||
|
||||
onChange={(e) => setDateEnd(e.target.value)}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="hint title-preview">
|
||||
标题预览:{schoolName.trim() || DEFAULT_SCHOOL}[第{weekNo || '?'}周]日期营养X菜单
|
||||
|
||||
{`标题预览:${schoolName.trim() || DEFAULT_SCHOOL}[第${weekNo || '?'}周]${dateStart && dateEnd ? `${fmtDateShort(new Date(dateStart))}--${fmtDateShort(new Date(dateEnd))}` : `日期`}营养A菜单`}
|
||||
|
||||
<br />
|
||||
|
||||
{`${schoolName.trim() || DEFAULT_SCHOOL}[第${weekNo || '?'}周]${dateStart && dateEnd ? `${fmtDateShort(new Date(dateStart))}--${fmtDateShort(new Date(dateEnd))}` : `日期`}营养B菜单`}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
@@ -674,4 +903,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
+684
-248
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: 'https://qnycdn.mymoyu.top/xx/web/',
|
||||
base: '/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user