Files
zhangmenu/templates/index.html
T
DeveloperandClaude Opus 4.6 0a50c09dba Initial commit - Zhangmenu Django project
A menu management application built with Django, featuring:
- Dish management with cover images
- Order system with party dates and participants
- REST API with Django REST Framework
- Bootstrap-styled frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:27:23 +08:00

6110 lines
210 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>玉姐的菜单</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700;800&family=ZCOOL+XiaoWei&family=Nunito:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/bootstrap.min.css">
<link rel="stylesheet" href="/static/bootstrap-icons.min.css">
<style>
:root {
/* 温暖厨房风格配色 - 陶土色系 */
--primary: #c2410c;
--primary-light: #ea580c;
--primary-dark: #9a3412;
--primary-soft: #fed7aa;
--secondary: #059669;
--secondary-light: #10b981;
--secondary-soft: #d1fae5;
--accent: #f59e0b;
--accent-soft: #fef3c7;
--bg-primary: #fffaf0;
--bg-secondary: #fff7ed;
--bg-card: #ffffff;
--bg-dark: #1c1917;
--text-primary: #1c1917;
--text-secondary: #57534e;
--text-muted: #a8a29e;
--text-inverse: #ffffff;
--border-light: #fed7aa;
--border-medium: #fde68a;
--shadow-sm: 0 1px 3px rgba(194, 65, 12, 0.08), 0 1px 2px rgba(194, 65, 12, 0.04);
--shadow-md: 0 4px 12px rgba(194, 65, 12, 0.12), 0 2px 4px rgba(194, 65, 12, 0.08);
--shadow-lg: 0 12px 32px rgba(194, 65, 12, 0.18), 0 4px 8px rgba(194, 65, 12, 0.12);
--shadow-xl: 0 24px 48px rgba(194, 65, 12, 0.22), 0 8px 16px rgba(194, 65, 12, 0.16);
--radius-sm: 12px;
--radius-md: 20px;
--radius-lg: 28px;
--radius-xl: 36px;
--radius-full: 9999px;
--font-display: 'ZCOOL XiaoWei', 'Noto Sans SC', serif;
--font-body: 'Nunito', 'Noto Sans SC', sans-serif;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--header-height: 180px;
--bottom-nav-height: 80px;
}
* {
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
min-height: 100vh;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding-bottom: var(--bottom-nav-height);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ============ HEADER ============ */
.app-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 50%, #fb923c 100%);
padding: 16px 20px 28px;
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
.app-header::before {
content: '';
position: absolute;
top: -60%;
right: -10%;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 70%);
pointer-events: none;
}
.app-header::after {
content: '';
position: absolute;
bottom: -40%;
left: -20%;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
pointer-events: none;
}
.header-content {
position: relative;
z-index: 1;
}
.brand-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-icon {
width: 52px;
height: 52px;
background: rgba(255,255,255,0.22);
backdrop-filter: blur(12px);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 26px;
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
}
.brand-text {
color: white;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.5px;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.header-actions {
display: flex;
gap: 10px;
}
.header-btn {
width: 42px;
height: 42px;
border-radius: 14px;
background: rgba(255,255,255,0.2);
backdrop-filter: blur(12px);
border: none;
color: white;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
}
.header-btn:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.05);
}
/* ============ MENU HEADER (No Background) ============ */
.menu-header {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.menu-header .search-container {
width: 100%;
}
.category-filter {
display: flex;
overflow-x: auto;
gap: 10px;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.category-filter::-webkit-scrollbar {
display: none;
}
.category-filter .category-chip {
flex-shrink: 0;
padding: 8px 16px;
background: white;
border-radius: var(--radius-full);
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
box-shadow: var(--shadow-xs);
transition: all var(--transition-fast);
border: 1px solid #e7e5e4;
color: var(--text-secondary);
}
.category-filter .category-chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.category-filter .category-chip.active {
background: var(--primary);
color: white;
border-color: var(--primary);
box-shadow: none;
transform: none;
}
/* ============ SEARCH ============ */
.search-container {
background: white;
border-radius: var(--radius-lg);
padding: 6px 8px 6px 18px;
display: flex;
align-items: center;
box-shadow: var(--shadow-md);
border: 2px solid transparent;
transition: all var(--transition-normal);
}
.search-container:focus-within {
border-color: rgba(194, 65, 12, 0.3);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.search-container i {
color: var(--primary);
font-size: 20px;
opacity: 0.8;
}
.search-container input {
border: none;
padding: 14px;
flex: 1;
font-size: 16px;
outline: none;
background: transparent;
font-family: var(--font-body);
color: var(--text-primary);
}
.search-container input::placeholder {
color: var(--text-muted);
}
.search-btn {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
border: none;
border-radius: var(--radius-md);
padding: 12px 20px;
color: white;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all var(--transition-fast);
}
.search-btn:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(194, 65, 12, 0.3);
}
/* ============ CATEGORIES ============ */
.categories-section {
padding: 24px 0 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
margin-bottom: 16px;
}
.section-title {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.section-title i {
color: var(--primary);
font-size: 22px;
}
.see-all {
color: var(--primary);
font-weight: 700;
font-size: 14px;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
}
.see-all:hover {
color: var(--primary-dark);
}
.category-scroll {
display: flex;
overflow-x: auto;
padding: 0 20px 8px;
gap: 12px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.category-scroll::-webkit-scrollbar {
display: none;
}
.category-chip {
flex-shrink: 0;
padding: 12px 24px;
background: white;
border-radius: var(--radius-full);
font-size: 15px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
box-shadow: var(--shadow-sm);
transition: all var(--transition-normal);
border: 2px solid transparent;
color: var(--text-secondary);
}
.category-chip:hover {
box-shadow: var(--shadow-md);
color: var(--text-primary);
transform: translateY(-2px);
}
.category-chip:active {
transform: scale(0.96);
}
.category-chip.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
box-shadow: none;
transform: none;
}
/* ============ DISH GRID ============ */
.dish-section {
padding: 8px 0 24px;
}
.dish-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 0 20px;
}
.dish-card {
background: white;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: all var(--transition-normal);
border: 1px solid rgba(194, 65, 12, 0.06);
}
.dish-card:active {
transform: scale(0.97);
}
.dish-card:hover {
transform: translateY(-8px);
box-shadow: var(--shadow-xl);
}
.dish-card .img-wrapper {
position: relative;
padding-top: 70%;
overflow: hidden;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 50%, #fed7aa 100%);
}
.dish-card img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.dish-card:hover img {
transform: scale(1.12);
}
.dish-card .category-tag {
position: absolute;
top: 12px;
left: 12px;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(12px);
padding: 6px 14px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
color: var(--primary);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dish-card .order-indicator {
position: absolute;
top: 12px;
right: 12px;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: transform var(--transition-fast);
}
.dish-card .order-indicator.in-order {
background: linear-gradient(135deg, var(--secondary) 0%, var(--secondary-light) 100%);
color: white;
animation: popIn 0.3s ease;
}
.dish-card .order-indicator.not-in-order {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(12px);
color: #d6d3d1;
border: 2px solid #e7e5e4;
}
@keyframes popIn {
0% { transform: scale(0); }
70% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.dish-card .card-content {
padding: 16px;
}
.dish-card .dish-name {
font-size: 16px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 6px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dish-card .dish-meta {
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.dish-card .dish-meta::before {
content: '';
width: 6px;
height: 6px;
background: var(--accent);
border-radius: 50%;
}
/* ============ BOTTOM NAV ============ */
.bottom-nav {
position: fixed;
bottom: 16px;
left: 16px;
right: 16px;
background: rgba(255,255,255,0.98);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: 0 8px 40px rgba(194, 65, 12, 0.15), 0 0 0 1px rgba(254, 215, 170, 0.3);
z-index: 1000;
padding: 10px 8px 10px;
border-radius: var(--radius-xl);
}
.nav-items {
display: flex;
justify-content: space-around;
align-items: center;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 16px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all var(--transition-normal);
position: relative;
border-radius: var(--radius-md);
}
.nav-item::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%) scaleX(0);
width: 20px;
height: 3px;
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-light) 100%);
border-radius: 3px;
transition: transform var(--transition-fast);
}
.nav-item.active {
color: var(--primary);
}
.nav-item.active::after {
transform: translateX(-50%) scaleX(1);
}
.nav-item i {
font-size: 24px;
transition: transform var(--transition-fast);
}
.nav-item.active i {
transform: none;
}
.nav-item:hover:not(.active) {
color: var(--text-secondary);
background: var(--bg-secondary);
}
/* ============ FLOATING ACTION BUTTON ============ */
.fab {
position: fixed;
bottom: 130px;
right: 20px;
width: 60px;
height: 60px;
border-radius: 20px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
box-shadow: 0 12px 28px rgba(194, 65, 12, 0.4);
cursor: pointer;
z-index: 999;
border: none;
transition: transform 0.2s ease;
}
.fab-top {
bottom: 200px;
background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);
}
.fab:hover {
transform: scale(1.1);
}
.fab:active {
transform: scale(0.92);
}
/* ============ ORDERS PAGE ============ */
.orders-header {
padding: 16px 20px 0;
background: linear-gradient(180deg, var(--bg-primary) 0%, transparent 100%);
}
.page-title {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 20px;
}
.filter-pills {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 8px;
scrollbar-width: none;
}
.filter-pills::-webkit-scrollbar {
display: none;
}
.filter-pill {
flex-shrink: 0;
padding: 10px 20px;
background: white;
border-radius: var(--radius-full);
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: all var(--transition-normal);
border: 2px solid transparent;
color: var(--text-secondary);
}
.filter-pill:hover {
box-shadow: var(--shadow-md);
color: var(--text-primary);
}
.filter-pill.active {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
box-shadow: none;
transform: none;
}
.order-list {
padding: 8px 20px 24px;
}
.order-card {
background: white;
border-radius: var(--radius-xl);
padding: 20px;
margin-bottom: 14px;
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: all var(--transition-normal);
border: 1px solid rgba(194, 65, 12, 0.06);
position: relative;
overflow: hidden;
}
.order-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 5px;
background: linear-gradient(180deg, var(--primary) 0%, var(--primary-light) 100%);
}
.order-card:active {
transform: scale(0.98);
}
.order-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-4px);
}
.order-card .order-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.order-card .order-name {
font-size: 18px;
font-weight: 800;
color: var(--text-primary);
}
.order-card .order-status {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
}
.order-card .status-progress {
background: var(--accent-soft);
color: #b45309;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
}
.order-card .status-done {
background: var(--secondary-soft);
color: #047857;
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.2);
}
.order-card .order-date {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 14px;
font-weight: 600;
}
.order-card .dish-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.order-card .dish-tag {
padding: 8px 14px;
background: linear-gradient(135deg, var(--primary-soft) 0%, #fff7ed 100%);
border: 1px solid rgba(194, 65, 12, 0.15);
border-radius: 20px;
font-size: 13px;
color: var(--primary);
font-weight: 600;
}
.order-card .dish-tag-cold {
background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
border-color: rgba(59, 130, 246, 0.2);
color: #2563eb;
}
/* Gathering detail dish list */
#order-detail-dishes {
display: flex;
flex-direction: column;
gap: 10px;
}
.dish-item-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
transition: all var(--transition-fast);
}
.dish-item-row:hover {
background: var(--primary-soft);
}
.dish-item-row .dish-info {
display: flex;
align-items: center;
gap: 12px;
}
.dish-item-row .dish-thumb {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: cover;
}
.dish-item-row .dish-name {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.dish-item-row .remove-dish-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: #fee2e2;
border: none;
color: #dc2626;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.dish-item-row .remove-dish-btn:hover {
background: #fecaca;
transform: scale(1.1);
}
.order-card .dish-tag i:hover {
opacity: 1;
color: #dc2626;
}
/* ============ PROFILE PAGE ============ */
.profile-header {
padding: 20px;
text-align: center;
}
.profile-avatar {
width: 96px;
height: 96px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 40px;
color: white;
box-shadow: 0 8px 24px rgba(194, 65, 12, 0.3);
}
.profile-name {
font-size: 24px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 4px;
}
.profile-subtitle {
font-size: 14px;
color: var(--text-muted);
font-weight: 600;
}
.profile-section {
padding: 20px 20px 24px;
}
.profile-card {
background: white;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--shadow-sm);
border: 1px solid rgba(194, 65, 12, 0.06);
}
.profile-menu-item {
display: flex;
align-items: center;
padding: 18px 20px;
cursor: pointer;
transition: all var(--transition-fast);
border-bottom: 1px solid #f5f5f4;
}
.profile-menu-item:last-child {
border-bottom: none;
}
.profile-menu-item:hover {
background: var(--bg-secondary);
}
.profile-menu-item .menu-icon {
width: 44px;
height: 44px;
background: var(--primary-soft);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--primary);
margin-right: 14px;
}
.profile-menu-item .menu-text {
flex: 1;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.profile-menu-item .menu-arrow {
color: var(--text-muted);
font-size: 18px;
transition: transform var(--transition-fast);
}
.profile-menu-item:hover .menu-arrow {
transform: translateX(4px);
color: var(--primary);
}
/* ============ EMPTY STATE ============ */
.empty-state {
text-align: center;
padding: 60px 24px;
}
.empty-state .empty-icon {
width: 100px;
height: 100px;
background: linear-gradient(135deg, var(--primary-soft) 0%, #fef3c7 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
font-size: 44px;
color: var(--primary);
}
.empty-state .empty-title {
font-size: 18px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-state .empty-desc {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 20px;
font-weight: 600;
}
.empty-state .empty-action {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
border-radius: var(--radius-full);
font-weight: 700;
font-size: 15px;
text-decoration: none;
box-shadow: 0 8px 24px rgba(194, 65, 12, 0.3);
transition: all var(--transition-fast);
}
.empty-state .empty-action:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(194, 65, 12, 0.4);
}
/* ============ MODALS ============ */
.modal-content {
border-radius: var(--radius-xl);
border: none;
box-shadow: 0 24px 80px rgba(0,0,0,0.2);
overflow: hidden;
}
.modal-dialog.modal-fullscreen {
margin: 0;
width: 100vw;
max-width: none;
min-height: 100dvh;
height: auto;
padding: 0;
display: flex;
align-items: stretch;
}
#dish-detail-modal,
#order-detail-modal {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
}
#dish-detail-modal .modal-dialog.modal-fullscreen,
#order-detail-modal .modal-dialog.modal-fullscreen {
min-height: 100%;
}
.modal-dialog.modal-fullscreen .modal-content {
width: 100%;
min-height: 100dvh;
height: auto;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: visible;
touch-action: pan-y;
}
.modal-header {
border-bottom: 1px solid #f5f5f4;
padding: 24px;
background: white;
}
.modal-title {
font-weight: 800;
font-size: 20px;
color: var(--text-primary);
}
.modal-body {
padding: 24px;
background: #fafaf9;
}
.modal-footer {
border-top: 1px solid #f5f5f4;
padding: 20px 24px;
background: white;
}
.btn-close {
opacity: 0.5;
padding: 10px;
margin: -10px -10px -10px auto;
transition: all var(--transition-fast);
border-radius: 50%;
background: #f5f5f4;
}
.btn-close:hover {
opacity: 1;
transform: rotate(90deg);
background: #e7e5e4;
}
/* ============ FORMS ============ */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-weight: 700;
font-size: 14px;
color: var(--text-primary);
margin-bottom: 8px;
}
.form-control, .form-select {
width: 100%;
border-radius: var(--radius-lg);
border: 2px solid #e7e5e4;
padding: 16px 18px;
font-size: 16px;
transition: all var(--transition-fast);
font-family: var(--font-body);
background: white;
}
.form-control:focus, .form-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(194, 65, 12, 0.12);
outline: none;
}
.form-control::placeholder {
color: var(--text-muted);
}
/* ============ BUTTONS ============ */
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
border: none;
border-radius: var(--radius-lg);
padding: 16px 28px;
font-weight: 800;
font-size: 15px;
color: white;
box-shadow: 0 8px 24px rgba(194, 65, 12, 0.35);
transition: all var(--transition-normal);
font-family: var(--font-body);
}
.btn-primary:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(194, 65, 12, 0.45);
}
.btn-primary:active {
transform: translateY(0);
box-shadow: none;
}
.btn-secondary {
background: #f5f5f4;
border: none;
border-radius: var(--radius-lg);
padding: 16px 28px;
font-weight: 700;
font-size: 15px;
color: var(--text-secondary);
transition: all var(--transition-fast);
font-family: var(--font-body);
}
.btn-secondary:hover {
background: #e7e5e4;
color: var(--text-primary);
}
.btn-success {
background: linear-gradient(135deg, var(--secondary) 0%, var(--secondary-light) 100%);
border: none;
border-radius: var(--radius-lg);
padding: 16px 28px;
font-weight: 800;
font-size: 15px;
color: white;
box-shadow: 0 8px 24px rgba(5, 150, 105, 0.35);
transition: all var(--transition-normal);
font-family: var(--font-body);
}
.btn-success:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(5, 150, 105, 0.45);
}
.btn-danger {
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
border: none;
border-radius: var(--radius-lg);
padding: 16px 28px;
font-weight: 800;
font-size: 15px;
color: white;
box-shadow: 0 8px 24px rgba(220, 38, 38, 0.35);
transition: all var(--transition-normal);
font-family: var(--font-body);
}
.btn-danger:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(220, 38, 38, 0.45);
}
/* ============ DETAIL MODAL ============ */
.detail-cover {
width: 100%;
height: 280px;
object-fit: cover;
}
.detail-cover-wrapper {
position: relative;
}
.detail-close-btn {
position: absolute;
top: 16px;
right: 16px;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(12px);
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--text-primary);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
cursor: pointer;
transition: all var(--transition-fast);
}
.detail-close-btn:hover {
transform: scale(1.1);
}
.detail-content {
background: white;
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
margin-top: -40px;
position: relative;
padding: 28px 24px 120px;
box-shadow: 0 -8px 40px rgba(0,0,0,0.1);
max-height: none;
overflow-y: visible;
}
.detail-name {
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 12px;
line-height: 1.3;
}
.detail-category-tag {
display: inline-block;
padding: 8px 16px;
background: var(--primary-soft);
color: var(--primary);
border-radius: 20px;
font-size: 13px;
font-weight: 700;
border: 1px solid rgba(194, 65, 12, 0.2);
}
.detail-section {
background: white;
border-radius: var(--radius-xl);
padding: 22px;
margin: 20px 0;
box-shadow: var(--shadow-sm);
border: 1px solid rgba(194, 65, 12, 0.06);
}
.detail-section .md-content {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.8;
}
.detail-section .md-content img {
max-width: 50%;
border-radius: 12px;
margin: 12px 0;
display: block;
}
.detail-section-title {
font-size: 17px;
font-weight: 800;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
}
.detail-section-title i {
color: var(--primary);
font-size: 20px;
}
.ingredient-row {
display: flex;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid #f5f5f4;
font-size: 15px;
transition: background var(--transition-fast);
margin: 0 -8px;
padding-left: 8px;
padding-right: 8px;
border-radius: 10px;
}
.ingredient-row:hover {
background: var(--bg-secondary);
}
.ingredient-row:last-child {
border-bottom: none;
}
.ingredient-row .ingredient-name {
color: var(--text-primary);
font-weight: 600;
}
.ingredient-row .ingredient-amount {
color: var(--primary);
font-weight: 800;
}
.detail-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 16px 20px 24px;
box-shadow: 0 -8px 32px rgba(0,0,0,0.1);
display: flex;
gap: 12px;
z-index: 100;
}
.detail-footer .btn {
flex: 1;
padding: 16px;
font-size: 15px;
}
/* ============ INGREDIENT ITEMS ============ */
.ingredient-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f5f5f4;
}
.ingredient-item input {
flex: 1;
border: 2px solid #e7e5e4;
border-radius: var(--radius-md);
padding: 12px 14px;
font-size: 14px;
transition: all var(--transition-fast);
}
.ingredient-item input:focus {
border-color: var(--primary);
outline: none;
}
.ingredient-item .remove-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fee2e2;
border: none;
color: #dc2626;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.ingredient-item .remove-btn:hover {
background: #fecaca;
transform: scale(1.1);
}
.add-ingredient-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px;
margin-top: 12px;
background: var(--bg-secondary);
border: 2px dashed #d6d3d1;
border-radius: var(--radius-lg);
color: var(--text-secondary);
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all var(--transition-fast);
}
.add-ingredient-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-soft);
}
/* ============ CHECKBOX ============ */
.dish-checkbox {
display: flex;
align-items: center;
padding: 14px;
background: white;
border-radius: var(--radius-lg);
margin-bottom: 10px;
cursor: pointer;
transition: all var(--transition-fast);
border: 2px solid transparent;
}
.dish-checkbox:hover {
background: var(--bg-secondary);
}
.dish-checkbox input {
width: 22px;
height: 22px;
margin-right: 14px;
accent-color: var(--primary);
}
.dish-checkbox .dish-label {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
/* ============ STEP EDITOR ============ */
.step-editor {
background: white;
border-radius: var(--radius-xl);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.step-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.step-editor-title {
font-size: 16px;
font-weight: 800;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.step-editor-title i {
color: var(--primary);
}
.step-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.step-item {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 16px;
position: relative;
border: 2px solid transparent;
transition: all var(--transition-fast);
}
.step-item:hover {
border-color: rgba(194, 65, 12, 0.15);
}
.step-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
border-radius: 50%;
font-weight: 800;
font-size: 14px;
}
.step-delete-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: #fee2e2;
border: none;
color: #dc2626;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
opacity: 0.7;
}
.step-delete-btn:hover {
background: #fecaca;
opacity: 1;
transform: scale(1.1);
}
.step-input {
width: 100%;
border: 2px solid #e7e5e4;
border-radius: var(--radius-md);
padding: 14px;
font-size: 15px;
font-family: var(--font-body);
resize: vertical;
min-height: 80px;
transition: all var(--transition-fast);
background: white;
}
.step-input:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 4px rgba(194, 65, 12, 0.1);
}
.step-input::placeholder {
color: var(--text-muted);
}
.step-image-upload {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.step-image-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: white;
border: 2px dashed #d6d3d1;
border-radius: var(--radius-md);
color: var(--text-secondary);
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: all var(--transition-fast);
}
.step-image-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-soft);
}
.step-image-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.step-image-preview img {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.step-preview {
margin-top: 14px;
padding: 14px;
background: white;
border-radius: var(--radius-md);
border: 1px solid #e7e5e4;
}
.step-preview-label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.step-preview-content {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.8;
}
.step-preview-content img {
max-width: 50%;
border-radius: var(--radius-md);
margin: 12px 0;
display: block;
}
.add-step-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 16px;
margin-top: 12px;
background: white;
border: 2px dashed #d6d3d1;
border-radius: var(--radius-lg);
color: var(--text-secondary);
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all var(--transition-fast);
}
.add-step-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-soft);
transform: translateY(-2px);
}
/* ============ PARSE RESULT ============ */
.parse-result-card {
background: linear-gradient(135deg, var(--primary-soft) 0%, #fff7ed 100%);
border-radius: var(--radius-xl);
padding: 20px;
margin-top: 16px;
border: 1px solid rgba(194, 65, 12, 0.15);
}
.parse-result-card .parse-dish-name {
font-size: 18px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 8px;
}
.parse-result-card .parse-info {
font-size: 14px;
color: var(--text-secondary);
font-weight: 600;
}
/* ============ PAGES ============ */
.page {
display: none;
min-height: 100vh;
animation: fadeIn 0.3s ease;
}
.page.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ============ SEARCH PANEL ============ */
.search-panel {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
z-index: 2000;
overflow-y: auto;
padding: 20px;
}
.search-panel.active {
display: block;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.search-panel-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.search-panel-back {
width: 44px;
height: 44px;
border-radius: 14px;
background: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--text-primary);
box-shadow: var(--shadow-sm);
cursor: pointer;
}
.search-panel-back:hover {
background: var(--bg-secondary);
}
/* ============ ORDER DETAIL MODAL ============ */
.order-detail-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
padding: 32px 24px 48px;
color: white;
}
.order-detail-title {
font-size: 28px;
font-weight: 800;
margin-bottom: 12px;
text-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.order-detail-status {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 700;
}
.order-detail-date {
color: var(--text-muted);
font-size: 14px;
}
.order-detail-body {
background: white;
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
margin-top: -24px;
padding: 28px 24px 120px;
box-shadow: 0 -8px 40px rgba(0,0,0,0.1);
max-height: none;
overflow-y: visible;
}
.order-detail-section {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 22px;
margin-bottom: 16px;
}
.order-detail-section-title {
font-size: 16px;
font-weight: 800;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
}
.order-detail-section-title i {
color: var(--primary);
}
/* ============ STATS PAGE ============ */
.stats-header {
padding: 20px;
background: linear-gradient(180deg, var(--bg-primary) 0%, transparent 100%);
}
.stats-content {
padding: 20px 20px 24px;
}
.stats-section {
background: white;
border-radius: var(--radius-xl);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
border: 1px solid rgba(194, 65, 12, 0.06);
}
.stats-section-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.stats-section-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--primary-soft), #fef3c7);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--primary);
}
.stats-section-title h3 {
font-size: 18px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 2px;
}
.stats-section-title p {
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
margin: 0;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.stat-card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 16px 12px;
text-align: center;
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10px;
font-size: 20px;
}
.stat-value {
font-size: 24px;
font-weight: 800;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-top: 4px;
}
.dish-rank-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
transition: all var(--transition-fast);
}
.rank-item:hover {
transform: translateX(4px);
background: var(--primary-soft);
}
.rank-number {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 14px;
flex-shrink: 0;
}
.rank-number.gold {
background: linear-gradient(135deg, #fcd34d, #fbbf24);
color: #92400e;
}
.rank-number.silver {
background: linear-gradient(135deg, #e5e7eb, #d1d5db);
color: #374151;
}
.rank-number.bronze {
background: linear-gradient(135deg, #fed7aa, #fb923c);
color: #9a3412;
}
.rank-number.default {
background: white;
color: var(--text-muted);
border: 2px solid #e7e5e4;
}
.rank-img {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
}
.rank-info {
flex: 1;
min-width: 0;
}
.rank-name {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-count {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
margin-top: 2px;
}
.loading-text {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-weight: 600;
}
.empty-rank {
text-align: center;
padding: 30px 20px;
color: var(--text-muted);
}
.empty-rank i {
font-size: 40px;
margin-bottom: 10px;
opacity: 0.5;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 380px) {
.brand-text {
font-size: 20px;
}
.dish-grid {
gap: 12px;
padding: 0 16px;
}
.bottom-nav {
left: 8px;
right: 8px;
bottom: 8px;
}
.fab {
right: 16px;
bottom: 100px;
}
}
.party-images-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.party-image-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.party-image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.party-image-item .remove-image-btn {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
background: rgba(0,0,0,0.6);
color: white;
border: none;
border-radius: 50%;
font-size: 12px;
cursor: pointer;
}
.upload-image-btn-wrapper {
text-align: center;
}
.upload-image-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 24px;
background: linear-gradient(135deg, var(--secondary) 0%, var(--secondary-light) 100%);
color: white;
border-radius: 24px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3);
}
.upload-image-btn:hover {
background: var(--secondary);
color: white;
}
/* ============ COOKBOOK REDESIGN OVERRIDES ============ */
:root {
--primary: #a85a32;
--primary-light: #c97a52;
--primary-dark: #7f4224;
--primary-soft: #f2dcc8;
--secondary: #6f8b63;
--secondary-light: #89a37d;
--secondary-soft: #e5efdf;
--accent: #d4a35f;
--accent-soft: #f5e6c8;
--bg-primary: #f6efe3;
--bg-secondary: #efe4d3;
--bg-card: #fffaf2;
--paper: #fffdf8;
--paper-strong: #fff7ec;
--wood: #d7b894;
--ink: #33251d;
--line: rgba(110, 76, 51, 0.14);
--line-strong: rgba(110, 76, 51, 0.24);
--shadow-sm: 0 6px 16px rgba(103, 72, 45, 0.08);
--shadow-md: 0 12px 28px rgba(103, 72, 45, 0.12);
--shadow-lg: 0 22px 44px rgba(103, 72, 45, 0.14);
--shadow-xl: 0 28px 56px rgba(103, 72, 45, 0.16);
--radius-sm: 10px;
--radius-md: 18px;
--radius-lg: 24px;
--radius-xl: 32px;
--radius-full: 999px;
--bottom-nav-height: 88px;
}
html {
background: var(--bg-primary);
}
body {
background:
radial-gradient(circle at top left, rgba(212, 163, 95, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(111, 139, 99, 0.08), transparent 24%),
linear-gradient(180deg, #f8f2e9 0%, #f3eadc 52%, #f0e6d8 100%);
color: var(--ink);
padding-bottom: calc(var(--bottom-nav-height) + 24px);
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,0.35), rgba(255,255,255,0.35)),
repeating-linear-gradient(0deg, transparent 0 23px, rgba(129, 92, 59, 0.03) 23px 24px);
opacity: 0.5;
z-index: -1;
}
a, button, input, select, textarea, .nav-item, .category-chip, .filter-pill, .dish-card, .order-card, .profile-menu-item, .search-container {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.nav-item:focus-visible,
.category-chip:focus-visible,
.filter-pill:focus-visible,
.dish-card:focus-visible,
.order-card:focus-visible,
.profile-menu-item:focus-visible,
.search-container:focus-visible,
.search-panel-back:focus-visible,
.fab:focus-visible {
outline: 3px solid rgba(168, 90, 50, 0.22);
outline-offset: 2px;
box-shadow: 0 0 0 5px rgba(168, 90, 50, 0.12);
}
.page {
min-height: calc(100vh - var(--bottom-nav-height));
padding-bottom: 24px;
animation: fadeIn 200ms ease;
}
.menu-header,
.orders-header,
.stats-content,
.profile-section {
padding-top: 18px;
padding-left: 18px;
padding-right: 18px;
background: none;
}
.menu-header .cookbook-hero {
margin-bottom: 14px;
}
.cookbook-hero {
position: relative;
margin: 0;
padding: 14px 16px 14px;
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,250,242,0.94)),
linear-gradient(135deg, rgba(242,220,200,0.95), rgba(255,247,236,0.95));
border: 1px solid rgba(132, 93, 62, 0.14);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.cookbook-hero::before,
.cookbook-hero::after {
content: '';
position: absolute;
border-radius: 50%;
background: rgba(201, 122, 82, 0.08);
pointer-events: none;
}
.cookbook-hero::before {
width: 180px;
height: 180px;
top: -90px;
right: -70px;
}
.cookbook-hero::after {
width: 120px;
height: 120px;
bottom: -56px;
left: -34px;
background: rgba(111, 139, 99, 0.08);
}
.hero-bookmark {
position: absolute;
top: 0;
right: 18px;
width: 38px;
height: 52px;
background: linear-gradient(180deg, #bb6d44 0%, #965332 100%);
border-radius: 0 0 12px 12px;
box-shadow: 0 8px 16px rgba(127, 66, 36, 0.16);
}
.hero-bookmark::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
margin: auto;
width: 0;
height: 0;
border-left: 19px solid transparent;
border-right: 19px solid transparent;
border-bottom: 14px solid var(--bg-primary);
}
.hero-intro,
.hero-actions,
.hero-search-shell,
.search-summary-row,
.menu-section-header,
.result-feedback,
.page-section-title,
.modal-note,
.order-summary-strip,
.search-panel-title-row {
position: relative;
z-index: 1;
}
.hero-intro {
padding-right: 36px;
}
.hero-kicker,
.section-kicker,
.page-kicker,
.modal-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 5px 11px;
border-radius: var(--radius-full);
background: rgba(255, 248, 238, 0.95);
border: 1px solid rgba(168, 90, 50, 0.16);
color: var(--primary-dark);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
}
.hero-title,
.page-title,
.modal-feature-title,
.detail-name,
.order-detail-title {
font-family: var(--font-display);
letter-spacing: 0.02em;
}
.hero-title {
margin: 8px 0 4px;
font-size: 26px;
line-height: 1.08;
color: #5e3721;
}
.hero-subtitle {
margin: 0;
max-width: 25em;
color: #6d5848;
font-size: 13px;
line-height: 1.45;
font-weight: 700;
}
.hero-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.hero-action {
min-height: 46px;
padding: 9px 10px;
border-radius: 16px;
border: 1px solid rgba(125, 88, 58, 0.12);
background: rgba(255, 252, 247, 0.96);
color: var(--ink);
text-align: left;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.hero-action:hover,
.hero-action:active {
background: #fffdf8;
border-color: rgba(168, 90, 50, 0.22);
transform: translateY(-1px);
}
.hero-action-icon {
width: 34px;
height: 34px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #f5e5d6, #f8efe3);
color: var(--primary-dark);
font-size: 15px;
flex-shrink: 0;
}
.hero-action-copy strong,
.search-summary-copy strong {
display: block;
font-size: 13px;
color: #4f3323;
font-weight: 800;
}
.hero-action-copy span,
.search-summary-copy span {
display: block;
margin-top: 1px;
font-size: 11px;
color: #88705f;
font-weight: 700;
line-height: 1.3;
}
.menu-header {
display: block;
padding-bottom: 0;
}
.hero-search-shell,
.page-shell,
.stats-section,
.profile-card,
.search-panel-shell,
.search-panel-results,
.modal-paper,
.detail-content,
.order-detail-body,
.order-detail-header {
background: rgba(255, 252, 247, 0.92);
border: 1px solid var(--line);
box-shadow: var(--shadow-md);
}
.hero-search-shell {
margin-top: 12px;
padding: 14px;
border-radius: 24px;
background: rgba(255, 252, 247, 0.7);
border: 1px solid rgba(126, 93, 67, 0.1);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.search-summary-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.search-summary-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 12px;
border-radius: var(--radius-full);
background: #f8f0e6;
color: #8b6445;
font-size: 12px;
font-weight: 800;
border: 1px solid rgba(168, 90, 50, 0.1);
}
.search-container {
min-height: 56px;
border-radius: 22px;
padding: 6px 10px 6px 16px;
background: var(--paper);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
border: 1px solid rgba(126, 93, 67, 0.12);
}
.search-container:focus-within {
border-color: rgba(168, 90, 50, 0.28);
box-shadow: 0 0 0 4px rgba(168, 90, 50, 0.08);
transform: none;
}
.search-container i {
color: #9b6f52;
font-size: 18px;
}
.search-container input {
font-size: 16px;
font-weight: 700;
color: var(--ink);
padding: 12px 12px 12px 10px;
}
.search-container input[readonly] {
cursor: pointer;
}
.category-filter,
.filter-pills {
display: flex;
gap: 10px;
overflow-x: auto;
padding: 4px 2px 2px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.category-filter::-webkit-scrollbar,
.filter-pills::-webkit-scrollbar {
display: none;
}
.category-chip,
.filter-pill {
flex-shrink: 0;
min-height: 42px;
padding: 10px 16px;
border-radius: var(--radius-full);
background: #fffaf3;
border: 1px solid rgba(125, 88, 58, 0.12);
color: #7b6453;
font-size: 13px;
font-weight: 800;
box-shadow: none;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.category-chip:hover,
.filter-pill:hover {
color: var(--primary-dark);
border-color: rgba(168, 90, 50, 0.2);
background: #fff6ec;
transform: none;
box-shadow: none;
}
.category-chip:active,
.filter-pill:active {
transform: scale(0.98);
}
.category-chip.active,
.filter-pill.active,
.category-filter .category-chip.active {
background: linear-gradient(180deg, #b96c43 0%, #a85a32 100%);
color: white;
border-color: transparent;
}
.dish-section,
.order-list {
padding: 0 18px 24px;
}
.dish-section {
padding-top: 0;
}
.page-shell {
border-radius: 28px;
padding: 18px 14px 20px;
}
.menu-section-header,
.page-section-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 14px;
margin-bottom: 16px;
}
.page-title {
margin: 10px 0 4px;
font-size: 32px;
line-height: 1.15;
color: #543320;
}
.page-subtitle,
.menu-section-subtitle,
.result-feedback,
.modal-copy,
.order-summary-strip,
.page-summary-note {
font-size: 13px;
line-height: 1.65;
color: #856c59;
font-weight: 700;
}
.menu-section-title,
.search-panel-title,
.stats-section-title h3,
.modal-title {
font-size: 20px;
font-weight: 900;
color: #4d3021;
}
.dish-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 0;
}
.dish-grid.search-results-grid {
padding: 0;
}
.dish-card {
display: flex;
flex-direction: column;
min-height: 100%;
background: linear-gradient(180deg, rgba(255,253,249,0.98), rgba(255,247,236,0.98));
border-radius: 24px;
border: 1px solid rgba(124, 88, 58, 0.12);
box-shadow: var(--shadow-sm);
overflow: hidden;
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
}
.dish-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: rgba(168, 90, 50, 0.22);
}
.dish-card:active {
transform: scale(0.985);
}
.dish-card .img-wrapper {
position: relative;
padding-top: 66%;
background: linear-gradient(135deg, #f4e3cf, #ead2b3);
}
.dish-card img {
transition: opacity 180ms ease;
}
.dish-card:hover img,
.dish-card .img-wrapper:hover img {
transform: none;
}
.dish-card .category-tag {
top: 12px;
left: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-full);
background: rgba(255, 249, 240, 0.96);
color: #8b5c3f;
border: 1px solid rgba(125, 88, 58, 0.1);
box-shadow: none;
backdrop-filter: none;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: none;
}
.dish-card .order-indicator {
top: auto;
right: 12px;
bottom: 12px;
width: auto;
min-width: 44px;
height: 30px;
padding: 0 10px;
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 800;
box-shadow: none;
}
.dish-card .order-indicator.in-order {
background: rgba(111, 139, 99, 0.95);
color: white;
animation: none;
}
.dish-card .card-content {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
padding: 14px 14px 16px;
}
.dish-card .dish-name {
margin: 0;
font-size: 17px;
font-weight: 900;
line-height: 1.4;
color: #452a1b;
}
.dish-card .dish-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
color: #876f5d;
font-weight: 800;
}
.dish-card .dish-meta::before {
display: none;
}
.dish-card-note {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
width: fit-content;
max-width: 100%;
padding: 6px 11px;
border-radius: 999px;
background: rgba(168, 90, 50, 0.12);
color: #8a5432;
font-size: 12px;
line-height: 1.35;
font-weight: 800;
}
.dish-card-note::before {
content: '';
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
flex-shrink: 0;
opacity: 0.7;
}
.dish-card-note.is-in-order {
background: rgba(111, 139, 99, 0.16);
color: #4f6a44;
}
.dish-status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dish-note,
.dish-usage,
.dish-state-pill,
.inline-note,
.order-mini-pill,
.participant-chip,
.ingredient-chip,
.feedback-pill {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 5px 10px;
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 800;
line-height: 1.3;
}
.dish-note {
color: #8f755f;
background: #f8efe3;
}
.dish-usage {
color: #6a5b50;
background: #f3ede6;
}
.dish-state-pill.is-in-order {
background: rgba(111, 139, 99, 0.16);
color: #4f6a44;
}
.dish-state-pill.is-ready {
background: rgba(168, 90, 50, 0.14);
color: #8a522f;
}
.result-feedback {
min-height: 22px;
margin-bottom: 14px;
}
.result-feedback strong {
color: #5a3925;
}
.empty-state {
padding: 44px 20px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,252,247,0.95), rgba(248,239,227,0.96));
border: 1px dashed rgba(143, 112, 84, 0.28);
}
.empty-state .empty-icon {
width: 74px;
height: 74px;
margin-bottom: 16px;
font-size: 30px;
background: #f4e7d7;
color: #996244;
}
.empty-state .empty-title {
font-size: 18px;
color: #4f3021;
}
.empty-state .empty-desc {
max-width: 20em;
margin-left: auto;
margin-right: auto;
color: #8a715f;
font-size: 13px;
line-height: 1.7;
}
.empty-state .empty-action {
min-height: 46px;
padding: 12px 20px;
border-radius: 18px;
background: linear-gradient(180deg, #b96c43, #a85a32);
box-shadow: none;
}
.empty-state .empty-action:hover {
transform: none;
box-shadow: none;
background: linear-gradient(180deg, #c1734a, #a85a32);
}
.page-header-card {
padding: 18px;
border-radius: 28px;
background: rgba(255, 252, 247, 0.92);
border: 1px solid var(--line);
box-shadow: var(--shadow-md);
margin-bottom: 14px;
}
.order-list {
padding-top: 0;
}
.order-card {
padding: 18px;
margin-bottom: 14px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,253,249,0.98), rgba(255,246,234,0.96));
box-shadow: var(--shadow-sm);
border: 1px solid rgba(124, 88, 58, 0.12);
}
.order-card::before {
width: 4px;
background: linear-gradient(180deg, #c07c4c, #9f5b33);
}
.order-card:hover {
transform: none;
box-shadow: var(--shadow-md);
}
.order-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.order-name {
font-size: 19px;
font-weight: 900;
color: #4b2e1f;
line-height: 1.35;
}
.order-date {
font-size: 13px;
color: #806858;
font-weight: 800;
margin-bottom: 12px;
}
.order-status,
.order-detail-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 5px 12px;
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 900;
}
.status-progress {
background: rgba(111, 139, 99, 0.16);
color: #4e6a45;
}
.status-done {
background: rgba(168, 90, 50, 0.14);
color: #8a512e;
}
.dish-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dish-tag {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 800;
color: #715847;
background: #f7ede1;
border: 1px solid rgba(124, 88, 58, 0.1);
}
.dish-tag-cold {
background: #edf4ef;
color: #5d7555;
}
.profile-card {
border-radius: 28px;
padding: 8px;
}
.profile-menu-item {
min-height: 72px;
border-radius: 22px;
padding: 16px;
background: rgba(255,255,255,0.6);
}
.profile-menu-item + .profile-menu-item {
margin-top: 8px;
}
.menu-icon {
width: 48px;
height: 48px;
border-radius: 16px;
background: #f4e7d7;
color: var(--primary-dark);
}
.profile-menu-item:hover {
transform: none;
background: rgba(255, 248, 240, 0.92);
}
.stats-content {
padding-top: 18px;
}
.stats-section {
padding: 18px;
border-radius: 28px;
margin-bottom: 14px;
}
.stats-section-header {
align-items: center;
margin-bottom: 16px;
}
.stats-section-icon,
.stat-icon {
background: #f3e5d4 !important;
color: var(--primary-dark);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.stats-section-title p,
.stat-label,
.rank-count,
.loading-text,
.empty-rank {
color: #8a715f;
}
.stats-cards {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-card {
padding: 16px 12px;
border-radius: 20px;
background: #f8efe4;
border: 1px solid rgba(124, 88, 58, 0.08);
}
.rank-item {
padding: 14px;
background: #fbf4e8;
border-radius: 20px;
}
.rank-item:hover {
transform: none;
background: #f7ecde;
}
.bottom-nav {
left: 12px;
right: 12px;
bottom: 12px;
padding: 10px 8px;
border-radius: 26px;
background: rgba(255, 250, 242, 0.96);
backdrop-filter: blur(12px);
box-shadow: 0 18px 34px rgba(88, 60, 38, 0.16);
border: 1px solid rgba(124, 88, 58, 0.12);
}
.nav-item {
min-width: 64px;
min-height: 60px;
justify-content: center;
gap: 5px;
color: #8c7564;
font-size: 11px;
font-weight: 800;
}
.nav-item::after {
width: 24px;
height: 4px;
bottom: 0;
background: linear-gradient(90deg, #c07c4c, #a85a32);
}
.nav-item.active {
color: #9a5632;
background: rgba(255, 245, 234, 0.86);
}
.nav-item i {
font-size: 21px;
}
.nav-item:hover:not(.active) {
background: rgba(247, 236, 221, 0.8);
}
.fab {
right: 18px;
bottom: 118px;
width: auto;
min-width: 62px;
height: 62px;
padding: 0 18px;
border-radius: 22px;
font-size: 24px;
gap: 10px;
background: linear-gradient(180deg, #be7148, #a85a32);
box-shadow: 0 16px 28px rgba(127, 66, 36, 0.28);
transition: transform var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.fab:hover {
transform: translateY(-1px);
}
.fab:active {
transform: scale(0.98);
}
.fab-top {
width: 52px;
min-width: 52px;
height: 52px;
bottom: 190px;
padding: 0;
border-radius: 18px;
background: rgba(80, 62, 49, 0.88);
box-shadow: 0 10px 22px rgba(50, 39, 31, 0.2);
}
.fab-label {
font-size: 14px;
font-weight: 900;
letter-spacing: 0.02em;
}
.search-panel {
padding: 18px;
background: rgba(246, 239, 227, 0.96);
}
.search-panel.active {
animation: fadeIn 180ms ease;
}
.search-panel-shell {
min-height: calc(100vh - 36px);
padding: 18px;
border-radius: 28px;
overflow: hidden;
}
.search-panel-header {
display: block;
margin-bottom: 14px;
}
.search-panel-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.search-panel-back {
width: 46px;
height: 46px;
border-radius: 16px;
background: #fffaf3;
box-shadow: none;
border: 1px solid rgba(124, 88, 58, 0.12);
}
.search-panel-results {
margin-top: 14px;
padding: 14px;
border-radius: 24px;
}
.modal-dialog {
padding: 12px;
}
.modal-content {
border-radius: 30px;
background: linear-gradient(180deg, rgba(255,253,249,0.99), rgba(248,239,227,0.98));
box-shadow: 0 24px 54px rgba(70, 48, 31, 0.18);
}
.modal-header,
.modal-footer,
.modal-body {
background: transparent;
}
.modal-header {
position: sticky;
top: 0;
z-index: 3;
padding: 20px 20px 10px;
border-bottom: 1px solid rgba(124, 88, 58, 0.1);
}
.modal-body {
padding: 18px 20px;
}
.modal-footer {
position: sticky;
bottom: 0;
z-index: 3;
padding: 14px 20px 20px;
border-top: 1px solid rgba(124, 88, 58, 0.1);
gap: 10px;
}
.modal-title {
font-size: 22px;
}
.modal-subtitle {
margin-top: 4px;
color: #8b715f;
font-size: 13px;
line-height: 1.6;
font-weight: 700;
}
.btn,
.btn-primary,
.btn-secondary,
.btn-success,
.btn-danger,
.upload-image-btn,
.search-btn,
.add-ingredient-btn,
.add-step-btn,
.step-image-btn {
min-height: 46px;
border-radius: 18px;
font-weight: 800;
font-size: 14px;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast), opacity var(--transition-fast);
box-shadow: none;
}
.btn:hover,
.btn-primary:hover,
.btn-secondary:hover,
.btn-success:hover,
.btn-danger:hover,
.upload-image-btn:hover,
.search-btn:hover,
.add-ingredient-btn:hover,
.add-step-btn:hover,
.step-image-btn:hover {
transform: none;
box-shadow: none;
}
.btn:disabled,
.btn.disabled,
.upload-image-btn:disabled,
.search-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-primary,
.search-btn {
background: linear-gradient(180deg, #bc7046 0%, #a85a32 100%);
}
.btn-success,
.upload-image-btn {
background: linear-gradient(180deg, #86a178 0%, #6f8b63 100%);
}
.btn-secondary {
background: #efe5d8;
color: #6d5646;
}
.btn-danger {
background: linear-gradient(180deg, #cb6e5a 0%, #b45444 100%);
}
.form-control,
.form-select,
.ingredient-item input,
.step-input {
min-height: 48px;
border-radius: 18px;
border: 1px solid rgba(124, 88, 58, 0.16);
background: rgba(255, 253, 249, 0.98);
color: #412a1f;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
}
.form-control:focus,
.form-select:focus,
.ingredient-item input:focus,
.step-input:focus {
border-color: rgba(168, 90, 50, 0.32);
box-shadow: 0 0 0 4px rgba(168, 90, 50, 0.08);
}
.form-label {
color: #624837;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.02em;
}
.ingredient-item,
.step-item,
.dish-checkbox,
.parse-result-card,
.order-detail-section,
.detail-section,
.participant-entry,
.selection-summary,
.selection-section {
background: rgba(255, 252, 247, 0.8);
border: 1px solid rgba(124, 88, 58, 0.1);
border-radius: 20px;
}
.ingredient-item {
padding: 12px;
gap: 10px;
border-bottom: none;
margin-bottom: 10px;
}
.ingredient-item .remove-btn,
.step-delete-btn,
.remove-dish-btn,
.remove-image-btn {
min-width: 36px;
width: 36px;
height: 36px;
border-radius: 14px;
background: #f5e0db;
color: #9d4f47;
border: none;
}
.step-item {
padding: 14px;
margin-bottom: 12px;
}
.step-number {
width: 34px;
height: 34px;
border-radius: 12px;
background: #f3e5d4;
color: var(--primary-dark);
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
}
.step-input {
min-height: 120px;
padding: 14px;
}
.step-preview {
border-radius: 16px;
background: #f8efe4;
}
.dish-checkbox {
display: flex;
align-items: center;
gap: 10px;
min-height: 54px;
padding: 12px 14px;
margin-bottom: 10px;
}
.dish-checkbox input {
width: 18px;
height: 18px;
accent-color: var(--primary);
flex-shrink: 0;
}
.dish-label {
font-weight: 800;
color: #513426;
}
.detail-cover-wrapper {
background: linear-gradient(180deg, rgba(243, 229, 212, 0.96), rgba(233, 208, 179, 0.92));
padding: 18px 18px 0;
}
.detail-cover {
height: 270px;
border-radius: 26px;
box-shadow: var(--shadow-md);
}
.detail-close-btn {
top: 28px;
right: 28px;
width: 46px;
height: 46px;
border-radius: 16px;
background: rgba(255, 251, 244, 0.94);
backdrop-filter: none;
box-shadow: none;
}
.detail-content {
margin: -18px 12px 0;
padding: 22px 18px 110px;
border-radius: 28px 28px 0 0;
max-height: none;
overflow: visible;
touch-action: pan-y;
}
.modal-dialog.modal-fullscreen .detail-cover-wrapper,
.modal-dialog.modal-fullscreen .order-detail-header,
.modal-dialog.modal-fullscreen .detail-content,
.modal-dialog.modal-fullscreen .order-detail-body,
.modal-dialog.modal-fullscreen .detail-footer {
touch-action: pan-y;
}
.modal-dialog.modal-fullscreen .detail-content,
.modal-dialog.modal-fullscreen .order-detail-body {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
}
.detail-header-block,
.order-detail-heading,
.modal-section-head {
margin-bottom: 16px;
}
.detail-category-tag {
margin-top: 10px;
background: #f2e3d3;
color: #8a5634;
border: 1px solid rgba(168, 90, 50, 0.12);
}
.detail-summary {
margin-top: 12px;
color: #7f6958;
font-size: 14px;
line-height: 1.7;
font-weight: 700;
}
.order-summary-strip {
display: block;
margin-top: 12px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 246, 236, 0.18);
color: rgba(255,255,255,0.9);
border: 1px solid rgba(255,255,255,0.16);
}
.detail-empty-note {
margin: 0;
padding: 14px 16px;
border-radius: 16px;
background: rgba(243, 230, 215, 0.72);
color: #7c654f;
font-size: 13px;
line-height: 1.65;
font-weight: 700;
}
.detail-section,
.order-detail-section {
padding: 16px;
margin: 0 0 14px;
box-shadow: none;
background: rgba(255,255,255,0.58);
}
.detail-section .md-content,
.order-detail-copy {
color: #5f4a3b;
line-height: 1.8;
}
.detail-footer {
position: sticky;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
margin-top: auto;
display: grid;
grid-template-columns: 1fr 1fr 1.3fr;
gap: 10px;
padding: 14px 16px calc(env(safe-area-inset-bottom, 0px) + 16px);
background: rgba(255, 251, 244, 0.97);
box-shadow: 0 -10px 24px rgba(70, 48, 31, 0.08);
backdrop-filter: blur(12px);
}
.detail-footer .btn {
min-width: 0;
padding: 0 12px;
}
.order-detail-header {
margin: 12px 12px 0;
padding: 20px 18px 28px;
border-radius: 28px 28px 22px 22px;
background: linear-gradient(180deg, rgba(189, 113, 72, 0.95), rgba(162, 88, 51, 0.95));
color: white;
border-color: rgba(255,255,255,0.14);
}
.order-detail-date {
color: rgba(255,255,255,0.85);
font-weight: 700;
}
.order-detail-body {
margin: -14px 12px 0;
padding: 18px 18px 112px;
border-radius: 28px 28px 0 0;
max-height: none;
overflow: visible;
touch-action: pan-y;
}
.order-detail-section {
padding: 16px;
margin: 0 0 14px;
border-radius: 24px;
box-shadow: none;
background: rgba(255,255,255,0.58);
}
.order-detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.order-section-note {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(242, 227, 211, 0.8);
color: #8a5a38;
font-size: 12px;
font-weight: 800;
border: 1px solid rgba(168, 90, 50, 0.12);
white-space: nowrap;
}
#order-detail-dishes {
display: flex;
flex-direction: column;
gap: 10px;
}
.dish-item-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid rgba(124, 88, 58, 0.08);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(248,240,231,0.92));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.dish-item-row + .dish-item-row {
margin-top: 0;
}
.dish-item-row:last-child {
border-bottom: 1px solid rgba(124, 88, 58, 0.08);
}
.dish-info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.dish-thumb {
width: 56px;
height: 56px;
border-radius: 18px;
object-fit: cover;
background: #f3e5d4;
flex-shrink: 0;
}
.dish-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.dish-item-row .dish-name {
font-size: 15px;
font-weight: 900;
color: #4e3020;
margin: 0;
line-height: 1.35;
}
.dish-item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.dish-meta-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(242, 227, 211, 0.85);
color: #8a5a38;
font-size: 11px;
font-weight: 800;
border: 1px solid rgba(168, 90, 50, 0.1);
}
.dish-meta-note {
font-size: 12px;
color: #7d6656;
font-weight: 700;
}
.remove-dish-btn {
width: 38px;
height: 38px;
border-radius: 14px;
background: rgba(254, 226, 226, 0.9);
border: 1px solid rgba(239, 68, 68, 0.12);
color: #dc2626;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), transform var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.remove-dish-btn:hover,
.remove-dish-btn:active {
background: #fecaca;
transform: none;
}
.participant-chip {
margin: 0 8px 8px 0;
background: #f2e4d4;
color: #7d573b;
}
.participants-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ingredient-row {
margin: 0 0 6px;
padding: 12px 10px;
border-radius: 16px;
border-bottom: none;
background: rgba(255,255,255,0.54);
}
.ingredient-row:hover {
background: rgba(255,255,255,0.7);
}
.party-images-container {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.party-image-item {
border-radius: 16px;
background: #f3e5d4;
}
.upload-image-btn-wrapper {
text-align: left;
}
.parse-result-card {
padding: 16px;
margin-top: 14px;
background: rgba(255, 252, 247, 0.94);
}
.loading-paper {
padding: 24px 18px;
border-radius: 28px;
background: linear-gradient(180deg, rgba(255,253,249,0.98), rgba(247,238,225,0.98));
border: 1px solid rgba(124, 88, 58, 0.12);
}
.loading-spinner-shell {
width: 62px;
height: 62px;
margin: 0 auto 16px;
border-radius: 20px;
background: #f4e7d7;
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner-shell .spinner-border {
width: 2rem !important;
height: 2rem !important;
border-width: 0.24rem;
color: var(--primary) !important;
}
.feedback-pill {
background: #f3e6d7;
color: #7c654f;
}
.visually-soft {
color: #8b715f;
}
.btn-close {
border-radius: 14px;
background: #f2e6d9;
opacity: 1;
}
.btn-close:hover {
transform: none;
background: #ead8c4;
}
.toast-stack {
position: fixed;
top: max(16px, env(safe-area-inset-top, 0px) + 8px);
left: 16px;
right: 16px;
z-index: 1200;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.app-toast {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: 20px;
border: 1px solid rgba(124, 88, 58, 0.12);
background: rgba(255, 251, 244, 0.98);
box-shadow: var(--shadow-md);
color: #583a29;
pointer-events: auto;
}
.app-toast.is-success {
border-color: rgba(111, 139, 99, 0.2);
}
.app-toast.is-error {
border-color: rgba(173, 87, 78, 0.24);
background: rgba(255, 247, 245, 0.98);
}
.app-toast-icon {
width: 34px;
height: 34px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: #f4e7d7;
color: #9a603f;
font-size: 16px;
}
.app-toast.is-success .app-toast-icon {
background: rgba(111, 139, 99, 0.16);
color: #4f6a44;
}
.app-toast.is-error .app-toast-icon {
background: rgba(173, 87, 78, 0.14);
color: #a14d46;
}
.app-toast-copy {
min-width: 0;
flex: 1;
font-size: 13px;
line-height: 1.65;
font-weight: 700;
}
.confirm-sheet .modal-content {
border-radius: 26px;
overflow: hidden;
}
.confirm-sheet-body {
padding: 22px 20px 18px;
}
.confirm-sheet-icon {
width: 52px;
height: 52px;
margin-bottom: 14px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f4e7d7;
color: #9a603f;
font-size: 22px;
}
.confirm-sheet-title {
margin: 0 0 8px;
font-size: 22px;
line-height: 1.25;
color: #4d3021;
}
.confirm-sheet-text {
margin: 0;
color: #7c654f;
font-size: 14px;
line-height: 1.75;
font-weight: 700;
}
.confirm-sheet .modal-footer {
padding-top: 0;
}
@media (max-width: 760px) {
.hero-title {
font-size: 24px;
}
.hero-actions,
.stats-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stats-cards .stat-card:last-child {
grid-column: span 2;
}
}
@media (max-width: 560px) {
.dish-grid,
.dish-grid.search-results-grid {
grid-template-columns: 1fr 1fr;
}
.detail-footer,
.modal-footer {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detail-footer .btn,
.modal-footer .btn {
width: 100%;
min-width: 0;
}
}
@media (max-width: 420px) {
.cookbook-hero,
.page-shell,
.page-header-card,
.search-panel-shell,
.stats-section,
.profile-card,
.detail-content,
.order-detail-header,
.order-detail-body {
border-radius: 24px;
}
.menu-header {
padding-left: 14px;
padding-right: 14px;
}
#dish-detail-modal .modal-dialog.modal-fullscreen,
#order-detail-modal .modal-dialog.modal-fullscreen,
#dish-detail-modal .modal-dialog.modal-fullscreen .modal-content,
#order-detail-modal .modal-dialog.modal-fullscreen .modal-content {
min-height: 100svh;
}
.cookbook-hero,
.dish-section,
.orders-header,
.order-list,
.stats-content,
.profile-section,
.search-panel {
margin-left: 0;
margin-right: 0;
}
.cookbook-hero {
margin: 0 0 8px;
padding: 12px 12px 12px;
}
.dish-section,
.orders-header,
.order-list,
.stats-content,
.profile-section {
padding-left: 14px;
padding-right: 14px;
}
.page-title {
font-size: 28px;
}
.hero-title {
font-size: 24px;
}
.hero-subtitle {
max-width: none;
font-size: 12px;
}
.stats-cards,
.dish-grid,
.dish-grid.search-results-grid {
grid-template-columns: 1fr;
}
.hero-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.search-summary-row,
.order-detail-section-head {
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.stats-cards .stat-card:last-child {
grid-column: auto;
}
.bottom-nav {
left: 8px;
right: 8px;
bottom: 8px;
}
.fab {
right: 14px;
bottom: 108px;
min-width: 58px;
height: 58px;
padding: 0 16px;
}
.fab-top {
bottom: 176px;
width: 48px;
min-width: 48px;
height: 48px;
}
.fab-label {
font-size: 13px;
}
}
</style>
</head>
<body>
<!-- Search Panel -->
<div class="search-panel" id="search-panel">
<div class="search-panel-shell">
<div class="search-panel-header">
<div class="search-panel-title-row">
<button class="search-panel-back" id="btn-close-search" aria-label="关闭搜索">
<i class="bi bi-arrow-left"></i>
</button>
<div>
<div class="section-kicker"><i class="bi bi-journal-bookmark"></i> 菜谱检索</div>
<div class="search-panel-title">找一道今天想做的家常菜</div>
</div>
</div>
<div class="search-container" style="flex: 1;">
<i class="bi bi-search"></i>
<input type="text" placeholder="搜菜名、食材、凉菜或汤" id="search-input">
</div>
</div>
<div class="result-feedback" id="search-feedback">输入关键词后,这里会显示匹配到的菜谱。</div>
<div class="search-panel-results">
<div class="dish-grid search-results-grid" id="search-results"></div>
</div>
</div>
</div>
<!-- Menu Page -->
<div class="page active" id="page-menu">
<div class="menu-header">
<section class="cookbook-hero">
<div class="hero-bookmark"></div>
<div class="hero-intro">
<div class="hero-kicker"><i class="bi bi-journal-richtext"></i> 家庭菜谱本</div>
<h1 class="hero-title">今天做什么,翻开就知道。</h1>
<p class="hero-subtitle">先挑今天想做的,再翻翻常做菜和备菜安排。</p>
</div>
<div class="hero-actions">
<button class="hero-action" type="button" id="hero-search-trigger">
<span class="hero-action-icon"><i class="bi bi-search"></i></span>
<span class="hero-action-copy">
<strong>找菜</strong>
<span>菜名或食材</span>
</span>
</button>
<button class="hero-action" type="button" id="hero-order-trigger">
<span class="hero-action-icon"><i class="bi bi-basket2"></i></span>
<span class="hero-action-copy">
<strong>看聚会单</strong>
<span>整理备菜</span>
</span>
</button>
</div>
<div class="hero-search-shell">
<span class="visually-hidden" id="menu-current-filter">全部菜谱</span>
<div class="category-filter" id="category-filter">
<div class="category-chip active" data-id="">全部</div>
</div>
</div>
</section>
</div>
<section class="dish-section">
<div class="page-shell">
<div class="result-feedback" id="dish-feedback">正在整理菜谱目录…</div>
<div class="dish-grid" id="dish-list">
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-icon">
<i class="bi bi-cup-hot"></i>
</div>
<div class="empty-title">还没有菜品</div>
<div class="empty-desc">先记下第一道拿手菜,菜谱本就能慢慢丰富起来。</div>
</div>
</div>
</div>
</section>
</div>
<!-- Orders Page -->
<div class="page" id="page-orders">
<div class="orders-header">
<div class="page-header-card">
<div class="page-kicker">备菜清单</div>
<h2 class="page-title">聚会安排</h2>
<p class="page-subtitle">把时间、参与人和待做菜品收在一起,做饭前先把单子列清楚。</p>
<div class="filter-pills">
<div class="filter-pill active" data-status="">全部</div>
<div class="filter-pill" data-status="in_progress">进行中</div>
<div class="filter-pill" data-status="completed">已完成</div>
</div>
</div>
</div>
<div class="order-list" id="order-list">
<div class="empty-state">
<div class="empty-icon">
<i class="bi bi-receipt"></i>
</div>
<div class="empty-title">还没有聚会单</div>
<div class="empty-desc">点右下角新建一张备菜清单,把要做的菜先安排起来。</div>
</div>
</div>
</div>
<!-- Profile Page -->
<div class="page" id="page-profile">
<div class="profile-section">
<div class="page-header-card">
<div class="page-kicker">我的菜谱本</div>
<h2 class="page-title">收纳与备份</h2>
<p class="page-subtitle">把这本家里的菜谱本保存好,换设备时也能继续翻。</p>
</div>
<div class="profile-card">
<div class="profile-menu-item" id="btn-export">
<div class="menu-icon">
<i class="bi bi-download"></i>
</div>
<span class="menu-text">导出数据备份</span>
<i class="bi bi-chevron-right menu-arrow"></i>
</div>
<div class="profile-menu-item">
<div class="menu-icon">
<i class="bi bi-info-circle"></i>
</div>
<span class="menu-text">版本 v1.0.0</span>
<i class="bi bi-chevron-right menu-arrow"></i>
</div>
</div>
</div>
</div>
<!-- Stats Page -->
<div class="page" id="page-stats">
<div class="stats-content">
<div class="page-header-card">
<div class="page-kicker">常做记录</div>
<h2 class="page-title">家里最常做什么</h2>
<p class="page-subtitle">从聚会次数和参与记录里,看看哪些菜最常上桌。</p>
</div>
<div class="stats-section">
<div class="stats-section-header">
<div class="stats-section-icon">
<i class="bi bi-trophy-fill"></i>
</div>
<div class="stats-section-title">
<h3>菜品排行榜</h3>
<p>按聚会次数排序</p>
</div>
</div>
<div class="dish-rank-list" id="dish-rank-list">
<div class="loading-text">加载中...</div>
</div>
</div>
<div class="stats-section">
<div class="stats-section-header">
<div class="stats-section-icon">
<i class="bi bi-bar-chart-fill"></i>
</div>
<div class="stats-section-title">
<h3>数据概览</h3>
<p>这本菜谱本目前记了多少内容</p>
</div>
</div>
<div class="stats-cards">
<div class="stat-card">
<div class="stat-icon">
<i class="bi bi-cup-hot" style="color: #a85a32;"></i>
</div>
<div class="stat-info">
<div class="stat-value" id="total-dishes">0</div>
<div class="stat-label">菜品总数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="bi bi-basket2" style="color: #6f8b63;"></i>
</div>
<div class="stat-info">
<div class="stat-value" id="total-gatherings">0</div>
<div class="stat-label">聚会总数</div>
</div>
</div>
<div class="stat-card" style="cursor: pointer;" onclick="showParticipantRanking()">
<div class="stat-icon">
<i class="bi bi-people" style="color: #8a5634;"></i>
</div>
<div class="stat-info">
<div class="stat-value" id="total-participants">0</div>
<div class="stat-label">参与人数</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Navigation -->
<nav class="bottom-nav">
<div class="nav-items">
<div class="nav-item active" data-page="menu">
<i class="bi bi-book-fill"></i>
<span>菜单</span>
</div>
<div class="nav-item" data-page="orders">
<i class="bi bi-receipt"></i>
<span>聚会</span>
</div>
<div class="nav-item" data-page="stats">
<i class="bi bi-bar-chart-fill"></i>
<span>统计</span>
</div>
<div class="nav-item" data-page="profile">
<i class="bi bi-person-fill"></i>
<span>我的</span>
</div>
</div>
</nav>
<!-- Loading Modal -->
<div class="modal fade" id="loading-modal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content loading-paper">
<div class="modal-body text-center py-4">
<div class="loading-spinner-shell">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="modal-kicker">生成分享图</div>
<p class="mb-0 mt-3" style="font-size: 15px; font-weight: 700; color: var(--text-primary);">正在把聚会清单整理成一张好看的菜单卡…</p>
</div>
</div>
</div>
</div>
<!-- Participant Ranking Modal -->
<div class="modal fade" id="participant-ranking-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">参与次数排行</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="participant-ranking-list"></div>
</div>
</div>
</div>
</div>
<!-- FAB -->
<button class="fab fab-top" id="fab-top" onclick="window.scroll(0, 0)" aria-label="回到顶部">
<i class="bi bi-arrow-up"></i>
</button>
<button class="fab" id="fab-add" aria-label="新增">
<i class="bi bi-plus-lg"></i>
<span class="fab-label" id="fab-label">记菜</span>
</button>
<!-- Dish Detail Modal -->
<div class="modal fade" id="dish-detail-modal" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="detail-cover-wrapper">
<img src="" alt="菜品封面" class="detail-cover" id="detail-cover">
<button class="detail-close-btn" data-bs-dismiss="modal" aria-label="关闭菜品详情">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="detail-content">
<div class="detail-header-block">
<div class="modal-kicker">菜谱详情</div>
<h2 class="detail-name" id="detail-name"></h2>
<span class="detail-category-tag" id="detail-category"></span>
<div class="detail-summary" id="detail-summary-text">翻翻这道菜需要什么材料,再决定要不要加入聚会单。</div>
</div>
<div class="detail-section" id="detail-ingredients">
<h3 class="detail-section-title">
<i class="bi bi-basket-fill"></i>
所需材料
</h3>
<div id="detail-ingredients-list"></div>
</div>
<div class="detail-section">
<h3 class="detail-section-title">
<i class="bi bi-list-ol"></i>
做法步骤
</h3>
<div class="md-content" id="detail-description"></div>
</div>
</div>
<div class="detail-footer">
<button class="btn btn-secondary" id="btn-delete-dish">删除</button>
<button class="btn btn-primary" id="btn-edit-dish">编辑</button>
<button class="btn btn-success" id="btn-add-to-order">添加到聚会</button>
</div>
</div>
</div>
</div>
<!-- Dish Form Modal -->
<div class="modal fade" id="dish-form-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="modal-kicker">记录一道新菜</div>
<h5 class="modal-title" id="dish-form-title">添加菜品</h5>
<div class="modal-subtitle">保留封面、材料和做法,方便下次直接翻出来做。</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭添加菜品"></button>
</div>
<div class="modal-body">
<form id="dish-form">
<input type="hidden" id="dish-id">
<div class="form-group">
<label class="form-label">菜品名称</label>
<input type="text" class="form-control" id="dish-name" placeholder="请输入菜品名称" required>
</div>
<div class="form-group">
<label class="form-label">菜品分类</label>
<select class="form-select" id="dish-category"></select>
</div>
<div class="form-group">
<label class="form-label">封面图片</label>
<input type="file" class="form-control" id="dish-image" accept="image/*">
<input type="hidden" id="dish-image-url">
<div id="image-preview" class="mt-3" style="display: none;">
<img src="" style="width: 100px; height: 100px; border-radius: 12px; object-fit: cover;">
</div>
</div>
<div class="form-group">
<label class="form-label">材料清单</label>
<div id="ingredients-list"></div>
<button type="button" class="add-ingredient-btn" id="btn-add-ingredient">
<i class="bi bi-plus-lg"></i>
添加材料
</button>
</div>
<div class="form-group">
<div class="step-editor">
<div class="step-editor-header">
<div class="step-editor-title">
<i class="bi bi-list-ol"></i>
做法步骤
</div>
</div>
<div class="step-list" id="step-list"></div>
<button type="button" class="add-step-btn" id="btn-add-step">
<i class="bi bi-plus-lg"></i>
添加步骤
</button>
</div>
<input type="hidden" id="dish-description">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="btn-save-dish">保存菜品</button>
</div>
</div>
</div>
</div>
<!-- Parse Modal -->
<div class="modal fade" id="parse-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="modal-kicker">链接导入</div>
<h5 class="modal-title">从链接解析菜品</h5>
<div class="modal-subtitle">粘贴外部菜谱地址,先抓取内容,再手动确认后保存。</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭链接解析"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">菜谱链接</label>
<input type="url" class="form-control" id="parse-url" placeholder="粘贴下厨房、美食杰等菜谱链接">
</div>
<div id="parse-result" style="display: none;">
<div class="parse-result-card">
<div class="parse-dish-name" id="parse-name"></div>
<div class="parse-info" id="parse-ingredients-count"></div>
</div>
<button class="btn btn-primary w-100 mt-3" id="btn-apply-parse">应用并编辑</button>
</div>
</div>
</div>
</div>
</div>
<!-- Order Form Modal -->
<div class="modal fade" id="order-form-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="modal-kicker">备菜清单</div>
<h5 class="modal-title" id="order-form-title">新建聚会</h5>
<div class="modal-subtitle">先定时间和参与人,再把想做的菜一并放进这张清单里。</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭聚会表单"></button>
</div>
<div class="modal-body">
<input type="hidden" id="order-id">
<div class="selection-section p-3 mb-3">
<div class="modal-section-head">
<div class="section-kicker">基础信息</div>
</div>
<div class="form-group mb-3">
<label class="form-label">聚会名称</label>
<input type="text" class="form-control" id="order-name" placeholder="例如:周末家宴、除夕晚餐" required>
</div>
<div class="form-group mb-0">
<label class="form-label">聚会时间</label>
<input type="datetime-local" class="form-control" id="order-party-date">
</div>
</div>
<div class="selection-section p-3 mb-3">
<div class="modal-section-head">
<div class="section-kicker">参与人</div>
<div class="modal-copy">输入人名回车即可加入,方便统计这次聚餐都有谁。</div>
</div>
<div id="order-participants-container">
<div class="input-group mb-2">
<input type="text" class="form-control participant-input" placeholder="输入人名后回车添加">
</div>
</div>
<div id="order-participants-tags" class="d-flex flex-wrap gap-1 mt-2"></div>
</div>
<div class="selection-section p-3">
<div class="modal-section-head">
<div class="section-kicker">已选菜品</div>
<div class="modal-copy" id="order-dish-count-label">从下面勾选想做的菜。</div>
</div>
<div id="order-dish-select" style="max-height: 320px; overflow-y: auto;"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="btn-save-order">保存聚会</button>
</div>
</div>
</div>
</div>
<!-- Order Detail Modal -->
<div class="modal fade" id="order-detail-modal" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="order-detail-header">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 14px;">
<div class="order-detail-heading">
<div class="modal-kicker" style="background: rgba(255,255,255,0.16); color: rgba(255,255,255,0.94); border-color: rgba(255,255,255,0.18);">聚会详情</div>
<h2 class="order-detail-title" id="order-detail-title">聚会名称</h2>
<div class="order-detail-meta">
<span class="order-detail-status" id="order-detail-status"></span>
<span class="order-detail-date" id="order-detail-date"></span>
</div>
<div class="order-summary-strip" id="order-detail-summary">看看这次备菜都安排了什么,再决定是否完成清单。</div>
</div>
<button class="detail-close-btn" data-bs-dismiss="modal" aria-label="关闭聚会详情">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="order-detail-body">
<div class="order-detail-section">
<div class="order-detail-section-head">
<h3 class="order-detail-section-title">
<i class="bi bi-cup-hot-fill"></i>
已选菜品
</h3>
<div class="order-section-note" id="order-detail-dish-note">正在整理这次要做的菜。</div>
</div>
<div class="dish-list" id="order-detail-dishes"></div>
</div>
<div class="order-detail-section">
<h3 class="order-detail-section-title">
<i class="bi bi-people-fill"></i>
参与人员
</h3>
<div id="order-detail-participants"></div>
</div>
<div class="order-detail-section" id="order-ingredients-section">
<h3 class="order-detail-section-title">
<i class="bi bi-basket-fill"></i>
食材汇总
</h3>
<div id="order-detail-ingredients"></div>
</div>
<div class="order-detail-section" id="order-images-section">
<h3 class="order-detail-section-title">
<i class="bi bi-camera-fill"></i>
聚会照片
</h3>
<div class="party-images-container" id="order-detail-images"></div>
<div class="upload-image-btn-wrapper">
<label class="upload-image-btn">
<i class="bi bi-plus-lg"></i> 上传照片
<input type="file" accept="image/*" multiple id="party-image-upload" style="display: none;">
</label>
</div>
</div>
</div>
<div class="detail-footer">
<button class="btn btn-primary" id="btn-share-order">
<i class="bi bi-share"></i> 分享
</button>
<button class="btn btn-danger" id="btn-delete-order">删除</button>
<button class="btn btn-success" id="btn-complete-order">完成</button>
</div>
</div>
</div>
</div>
<div class="toast-stack" id="toast-stack" aria-live="polite" aria-atomic="true"></div>
<!-- Confirm Modal -->
<div class="modal fade" id="confirm-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered confirm-sheet">
<div class="modal-content">
<div class="confirm-sheet-body">
<div class="confirm-sheet-icon">
<i class="bi bi-journal-check"></i>
</div>
<h5 class="confirm-sheet-title" id="confirm-modal-title">确认一下这步操作</h5>
<p class="confirm-sheet-text" id="confirm-modal-message">确定要继续吗?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="confirm-modal-cancel">再想想</button>
<button type="button" class="btn btn-primary" id="confirm-modal-confirm">继续</button>
</div>
</div>
</div>
</div>
<script src="/static/bootstrap.min.js"></script>
<script src="/static/marked.min.js"></script>
<script src="/static/html2canvas.min.js"></script>
<script>
const API_BASE = '/api';
let currentPage = 'menu';
let categories = [];
let dishes = [];
let orders = [];
let currentCategory = '';
let currentOrderFilter = '';
let dishCompletedCounts = {}; // Store how many times each dish appears in completed orders
function getErrorMessage(error, fallback = '操作没有成功,请稍后再试。') {
if (!error) return fallback;
if (typeof error === 'string') return error;
if (Array.isArray(error)) return error.join('');
if (typeof error === 'object') {
const values = Object.values(error).flatMap(value => Array.isArray(value) ? value : [value]);
const text = values.filter(Boolean).join('');
return text || fallback;
}
return fallback;
}
function showAppToast(message, type = 'success') {
const stack = document.getElementById('toast-stack');
if (!stack || !message) return;
const toast = document.createElement('div');
toast.className = `app-toast ${type === 'error' ? 'is-error' : 'is-success'}`;
toast.innerHTML = `
<div class="app-toast-icon">
<i class="bi ${type === 'error' ? 'bi-exclamation-circle' : 'bi-check2-circle'}"></i>
</div>
<div class="app-toast-copy"></div>
`;
toast.querySelector('.app-toast-copy').textContent = message;
stack.appendChild(toast);
window.setTimeout(() => {
toast.remove();
}, 2600);
}
function confirmAction(message, title = '确认一下这步操作') {
return new Promise(resolve => {
const modalEl = document.getElementById('confirm-modal');
const titleEl = document.getElementById('confirm-modal-title');
const messageEl = document.getElementById('confirm-modal-message');
const cancelBtn = document.getElementById('confirm-modal-cancel');
const confirmBtn = document.getElementById('confirm-modal-confirm');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
let settled = false;
titleEl.textContent = title;
messageEl.textContent = message;
const cleanup = result => {
if (settled) return;
settled = true;
cancelBtn.removeEventListener('click', onCancel);
confirmBtn.removeEventListener('click', onConfirm);
modalEl.removeEventListener('hidden.bs.modal', onHidden);
resolve(result);
};
const onCancel = () => {
modal.hide();
cleanup(false);
};
const onConfirm = () => {
modal.hide();
cleanup(true);
};
const onHidden = () => cleanup(false);
cancelBtn.addEventListener('click', onCancel);
confirmBtn.addEventListener('click', onConfirm);
modalEl.addEventListener('hidden.bs.modal', onHidden);
modal.show();
});
}
document.addEventListener('DOMContentLoaded', () => {
loadCategories();
loadDishes();
loadOrders();
initEventListeners();
switchPage('menu');
});
function initEventListeners() {
// Navigation
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => switchPage(item.dataset.page));
});
// Search - click on search box to open search panel
const openSearchPanel = () => {
document.getElementById('search-panel').classList.add('active');
document.getElementById('search-input').focus();
};
const menuSearchBox = document.getElementById('menu-search-box');
if (menuSearchBox) {
menuSearchBox.addEventListener('click', openSearchPanel);
}
const heroSearchTrigger = document.getElementById('hero-search-trigger');
if (heroSearchTrigger) {
heroSearchTrigger.addEventListener('click', openSearchPanel);
}
const heroOrderTrigger = document.getElementById('hero-order-trigger');
if (heroOrderTrigger) {
heroOrderTrigger.addEventListener('click', () => switchPage('orders'));
}
document.getElementById('btn-close-search').addEventListener('click', () => {
document.getElementById('search-panel').classList.remove('active');
document.getElementById('search-input').value = '';
document.getElementById('search-results').innerHTML = '';
const searchFeedback = document.getElementById('search-feedback');
if (searchFeedback) {
searchFeedback.textContent = '输入关键词后,这里会显示匹配到的菜谱。';
}
});
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', debounce(loadSearchResults, 300));
}
// FAB
document.getElementById('fab-add').addEventListener('click', () => {
if (currentPage === 'menu') showDishForm();
else if (currentPage === 'orders') showOrderForm();
});
// Order filter
document.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => filterOrders(pill.dataset.status));
});
// Profile
document.getElementById('btn-export').addEventListener('click', exportData);
// Dish form
document.getElementById('btn-add-ingredient').addEventListener('click', addIngredientRow);
document.getElementById('btn-save-dish').addEventListener('click', saveDish);
document.getElementById('dish-image').addEventListener('change', handleImageUpload);
// Order form
document.getElementById('btn-save-order').addEventListener('click', saveOrder);
document.getElementById('btn-complete-order').addEventListener('click', () => {
const orderId = document.getElementById('order-detail-modal').dataset.orderId;
completeOrder(orderId);
});
document.getElementById('btn-delete-order').addEventListener('click', () => {
const orderId = document.getElementById('order-detail-modal').dataset.orderId;
deleteOrder(orderId);
});
// Parse
document.getElementById('btn-apply-parse').addEventListener('click', applyParse);
document.getElementById('parse-url').addEventListener('change', debounce(parseUrl, 800));
}
// 存储每个页面的滚动位置
const pageScrollPositions = {};
function switchPage(page) {
// 保存当前页面的滚动位置
if (currentPage) {
pageScrollPositions[currentPage] = window.scrollY;
}
currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(`page-${page}`).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-page="${page}"]`).classList.add('active');
// 恢复目标页面的滚动位置(无动画)
if (pageScrollPositions[page] !== undefined) {
window.scroll(0, pageScrollPositions[page]);
} else {
window.scroll(0, 0);
}
// Show/hide FAB based on page
const fab = document.getElementById('fab-add');
const fabTop = document.getElementById('fab-top');
const fabLabel = document.getElementById('fab-label');
if (page === 'stats' || page === 'profile') {
fab.style.display = 'none';
fabTop.style.display = 'none';
} else if (page === 'menu') {
fab.style.display = 'flex';
fabTop.style.display = 'flex';
if (fabLabel) fabLabel.textContent = '记菜';
fab.setAttribute('aria-label', '新增菜品');
} else {
fab.style.display = 'flex';
fabTop.style.display = 'none';
if (fabLabel) fabLabel.textContent = '建单';
fab.setAttribute('aria-label', '新建聚会单');
}
if (page === 'orders') loadOrders();
if (page === 'stats') loadStats();
}
async function loadStats() {
try {
// Load dishes
const dishesRes = await fetch(`${API_BASE}/dishes/`);
const allDishes = await dishesRes.json();
// Load orders
const ordersRes = await fetch(`${API_BASE}/orders/`);
const allOrders = await ordersRes.json();
// Calculate dish completed order counts (only completed orders)
const dishCompletedCounts = {};
allOrders.filter(o => o.status === 'completed').forEach(order => {
(order.dishes || []).forEach(dishId => {
dishCompletedCounts[dishId] = (dishCompletedCounts[dishId] || 0) + 1;
});
});
// Add order counts to dishes
const dishesWithCounts = allDishes.map(dish => ({
...dish,
orderCount: dishCompletedCounts[dish.id] || 0
}));
// Sort by order count
dishesWithCounts.sort((a, b) => b.orderCount - a.orderCount);
// Filter out dishes with 0 order count
const dishesWithOrders = dishesWithCounts.filter(dish => dish.orderCount > 0);
// Calculate unique participants and their participation counts
const participantCounts = {};
allOrders.forEach(order => {
if (order.participants && Array.isArray(order.participants)) {
order.participants.forEach(name => {
participantCounts[name] = (participantCounts[name] || 0) + 1;
});
}
});
const uniqueParticipants = Object.keys(participantCounts).length;
// Store for ranking modal
window.participantRanking = Object.entries(participantCounts)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }));
// Update stats
document.getElementById('total-dishes').textContent = allDishes.length;
document.getElementById('total-gatherings').textContent = allOrders.length;
document.getElementById('total-participants').textContent = uniqueParticipants;
// Render ranking
const rankList = document.getElementById('dish-rank-list');
if (dishesWithOrders.length === 0) {
rankList.innerHTML = `
<div class="empty-rank">
<i class="bi bi-trophy"></i>
<div>还没有被加入聚会的菜品</div>
</div>
`;
} else {
rankList.innerHTML = dishesWithOrders.slice(0, 10).map((dish, index) => {
let rankClass = 'default';
if (index === 0) rankClass = 'gold';
else if (index === 1) rankClass = 'silver';
else if (index === 2) rankClass = 'bronze';
return `
<div class="rank-item">
<div class="rank-number ${rankClass}">${index + 1}</div>
<img class="rank-img" src="${dish.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22 viewBox=%220 0 48 48%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22 rx=%2214%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.32em%22 font-size=%2212%22 font-family=%22Arial,sans-serif%22 fill=%22%23c2410c%22>菜谱</text></svg>'}" alt="${dish.name}">
<div class="rank-info">
<div class="rank-name">${dish.name}</div>
<div class="rank-count">${dish.orderCount} 次</div>
</div>
</div>
`;
}).join('');
}
} catch (e) {
console.error('加载统计失败', e);
}
}
async function loadCategories() {
try {
const res = await fetch(`${API_BASE}/categories/`);
categories = await res.json();
renderCategories();
} catch (e) {
console.error('加载分类失败', e);
}
}
function renderCategories() {
const activeCategory = categories.find(cat => String(cat.id) === String(currentCategory));
const currentFilterLabel = document.getElementById('menu-current-filter');
if (currentFilterLabel) {
currentFilterLabel.textContent = activeCategory ? `${activeCategory.name}菜谱` : '全部菜谱';
}
// Render to orders page category list
const container = document.getElementById('category-list');
if (container) {
container.innerHTML = `<div class="category-chip ${!currentCategory ? 'active' : ''}" data-id="">全部</div>`;
categories.forEach(cat => {
container.innerHTML += `<div class="category-chip ${currentCategory == cat.id ? 'active' : ''}" data-id="${cat.id}">${cat.name}</div>`;
});
container.querySelectorAll('.category-chip').forEach(chip => {
chip.addEventListener('click', () => {
currentCategory = chip.dataset.id;
renderCategories();
loadDishes();
});
});
}
// Render to menu page category filter
const filterContainer = document.getElementById('category-filter');
if (filterContainer) {
filterContainer.innerHTML = `<div class="category-chip ${!currentCategory ? 'active' : ''}" data-id="">全部</div>`;
categories.forEach(cat => {
filterContainer.innerHTML += `<div class="category-chip ${currentCategory == cat.id ? 'active' : ''}" data-id="${cat.id}">${cat.name}</div>`;
});
filterContainer.querySelectorAll('.category-chip').forEach(chip => {
chip.addEventListener('click', () => {
currentCategory = chip.dataset.id;
renderCategories();
loadDishes();
});
});
}
}
async function loadDishes() {
let url = `${API_BASE}/dishes/`;
const params = new URLSearchParams();
if (currentCategory) params.append('category', currentCategory);
if (params.toString()) url += '?' + params.toString();
try {
const res = await fetch(url);
dishes = await res.json();
if (!currentCategory) {
window.allDishCount = dishes.length;
}
renderDishes();
} catch (e) {
console.error('加载菜品失败', e);
}
}
function renderDishes() {
const container = document.getElementById('dish-list');
const feedbackEl = document.getElementById('dish-feedback');
const inProgressOrders = orders.filter(order => order.status === 'in_progress');
const activeCategory = categories.find(cat => String(cat.id) === String(currentCategory));
const visibleCount = dishes.length;
const totalDishCount = currentCategory ? (window.allDishCount || visibleCount) : visibleCount;
if (feedbackEl) {
feedbackEl.dataset.totalDishes = String(totalDishCount);
feedbackEl.dataset.activeOrders = String(inProgressOrders.length);
feedbackEl.dataset.visibleDishes = String(visibleCount);
}
if (dishes.length === 0) {
if (feedbackEl) {
feedbackEl.textContent = activeCategory
? `这一页还没有“${activeCategory.name}”菜谱。`
: '还没有菜谱,先记下第一道拿手菜吧。';
}
container.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-icon"><i class="bi bi-cup-hot"></i></div>
<div class="empty-title">${activeCategory ? `还没有“${activeCategory.name}”菜谱` : '还没有菜品'}</div>
<div class="empty-desc">${activeCategory ? '换个分类看看,或者先把这类家常菜记下来。' : '先记下第一道拿手菜,菜谱本就能慢慢丰富起来。'}</div>
</div>`;
return;
}
if (feedbackEl) {
if (activeCategory) {
feedbackEl.textContent = `现在看到 ${visibleCount} 道「${activeCategory.name}」菜谱,点开就能查看做法和备菜情况。`;
} else {
feedbackEl.textContent = `现在收着 ${visibleCount} 道家常菜,看看哪些已经加入最近的聚会单。`;
}
}
container.innerHTML = dishes.map(dish => {
const inOrder = isDishInOrder(dish.id);
const cookedCount = dishCompletedCounts[dish.id] || 0;
const statusText = inOrder ? '已加入当前聚会单' : '还没加入聚会单';
const noteText = cookedCount > 0 ? `做过 ${cookedCount} 次` : '还没在聚会中做过';
return `
<article class="dish-card" data-id="${dish.id}">
<div class="img-wrapper">
<img src="${dish.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><rect fill=%22%23efe2cf%22 width=%22100%22 height=%22100%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23a85a32%22>菜谱</text></svg>'}" alt="${dish.name}">
<span class="category-tag">${dish.category_name || '未分类'}</span>
<span class="order-indicator ${inOrder ? 'in-order' : 'not-in-order'}">
<i class="bi ${inOrder ? 'bi-check-lg' : 'bi-bookmark'}"></i>
</span>
</div>
<div class="card-content">
<div class="dish-name">${dish.name}</div>
<div class="dish-meta">${noteText}</div>
<div class="dish-card-note ${inOrder ? 'is-in-order' : ''}">${statusText}</div>
</div>
</article>`;
}).join('');
container.querySelectorAll('.dish-card').forEach(card => {
card.addEventListener('click', () => showDishDetail(card.dataset.id));
});
}
async function loadSearchResults() {
const search = document.getElementById('search-input').value;
const container = document.getElementById('search-results');
const feedbackEl = document.getElementById('search-feedback');
if (!search.trim()) {
container.innerHTML = '';
if (feedbackEl) {
feedbackEl.textContent = '输入关键词后,这里会显示匹配到的菜谱。';
}
return;
}
try {
const res = await fetch(`${API_BASE}/dishes/?search=${encodeURIComponent(search)}`);
const results = await res.json();
if (results.length === 0) {
if (feedbackEl) {
feedbackEl.textContent = `没有找到和“${search.trim()}”相关的菜谱。`;
}
container.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-icon"><i class="bi bi-search"></i></div>
<div class="empty-title">没找到相关菜品</div>
<div class="empty-desc">换个菜名、食材或者口味词再试试。</div>
</div>`;
return;
}
if (feedbackEl) {
feedbackEl.textContent = `找到 ${results.length} 道和“${search.trim()}”相关的菜谱。`;
}
container.innerHTML = results.map(dish => {
const inOrder = isDishInOrder(dish.id);
const cookedCount = dishCompletedCounts[dish.id] || 0;
return `
<article class="dish-card" data-id="${dish.id}">
<div class="img-wrapper">
<img src="${dish.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><rect fill=%22%23efe2cf%22 width=%22100%22 height=%22100%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23a85a32%22>菜谱</text></svg>'}" alt="${dish.name}">
<span class="category-tag">${dish.category_name || '未分类'}</span>
<span class="order-indicator ${inOrder ? 'in-order' : 'not-in-order'}">
<i class="bi ${inOrder ? 'bi-check-lg' : 'bi-bookmark'}"></i>
</span>
</div>
<div class="card-content">
<div class="dish-name">${dish.name}</div>
<div class="dish-meta">${cookedCount > 0 ? `做过 ${cookedCount} 次` : '还没在聚会中做过'}</div>
<div class="dish-card-note ${inOrder ? 'is-in-order' : ''}">${inOrder ? '已加入当前聚会单' : '点开后可加入聚会单'}</div>
</div>
</article>
`;
}).join('');
container.querySelectorAll('.dish-card').forEach(card => {
card.addEventListener('click', () => {
document.getElementById('search-panel').classList.remove('active');
showDishDetail(card.dataset.id);
});
});
} catch (e) {
console.error('搜索失败', e);
if (feedbackEl) {
feedbackEl.textContent = '搜索出了点问题,请稍后再试。';
}
}
}
function getInProgressOrder() {
return orders.find(o => o.status === 'in_progress');
}
function isDishInOrder(dishId) {
const inProgressOrder = getInProgressOrder();
if (!inProgressOrder || !inProgressOrder.dishes) return false;
return inProgressOrder.dishes.some(d => d.id === dishId || d === dishId);
}
async function loadOrders() {
let url = `${API_BASE}/orders/`;
if (currentOrderFilter) url += `?status=${currentOrderFilter}`;
try {
const res = await fetch(url);
orders = await res.json();
// Calculate how many times each dish appears in completed orders
dishCompletedCounts = {};
orders.filter(o => o.status === 'completed').forEach(order => {
(order.dishes || []).forEach(dishId => {
dishCompletedCounts[dishId] = (dishCompletedCounts[dishId] || 0) + 1;
});
});
renderOrders();
renderDishes();
} catch (e) {
console.error('加载聚会失败', e);
}
}
function renderOrders() {
const container = document.getElementById('order-list');
if (orders.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon"><i class="bi bi-receipt"></i></div>
<div class="empty-title">还没有聚会单</div>
<div class="empty-desc">点右下角新建一张备菜清单,把要做的菜先安排起来。</div>
</div>`;
return;
}
container.innerHTML = orders.map(order => {
const dishCount = order.dishes_detail ? order.dishes_detail.length : 0;
const participantsCount = order.participants ? order.participants.length : 0;
const summaryText = [
order.party_date ? new Date(order.party_date).toLocaleDateString() : '未安排时间',
`${dishCount} 道菜`,
participantsCount > 0 ? `${participantsCount} 位参与人` : '待补充参与人'
].join(' · ');
return `
<article class="order-card" data-id="${order.id}">
<div class="order-top">
<div>
<span class="order-name">${order.name}</span>
<div class="order-date">${summaryText}</div>
</div>
<span class="order-status ${order.status === 'in_progress' ? 'status-progress' : 'status-done'}">${order.status === 'in_progress' ? '备菜中' : '已完成'}</span>
</div>
<div class="dish-list">
${(order.dishes_detail || []).map(d => {
const isCold = d.category_name && d.category_name.includes('凉菜');
return `<span class="dish-tag ${isCold ? 'dish-tag-cold' : ''}">${d.name}</span>`;
}).join('') || '<span class="dish-tag">还没选菜</span>'}
</div>
</article>
`;
}).join('');
container.querySelectorAll('.order-card').forEach(card => {
card.addEventListener('click', () => showOrderDetail(card.dataset.id));
});
}
function filterOrders(status) {
currentOrderFilter = status;
document.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
document.querySelector(`.filter-pill[data-status="${status}"]`).classList.add('active');
loadOrders();
}
function showDishDetail(id) {
const dish = dishes.find(d => d.id == id);
if (!dish) return;
document.getElementById('detail-cover').src = dish.cover_image || '';
document.getElementById('detail-name').textContent = dish.name;
document.getElementById('detail-category').textContent = dish.category_name || '未分类';
const detailSummary = document.getElementById('detail-summary-text');
if (detailSummary) {
const cookedCount = dishCompletedCounts[dish.id] || 0;
if (isDishInOrder(dish.id)) {
detailSummary.textContent = `这道菜已经在当前聚会单里了,材料和做法都可以现在确认。`;
} else if (cookedCount > 0) {
detailSummary.textContent = `这道菜已经做过 ${cookedCount} 次,适合直接拿来安排这次聚会。`;
} else {
detailSummary.textContent = '翻翻这道菜需要什么材料,再决定要不要加入聚会单。';
}
}
const ingList = document.getElementById('detail-ingredients-list');
if (dish.ingredients && dish.ingredients.length > 0) {
document.getElementById('detail-ingredients').style.display = 'block';
ingList.innerHTML = dish.ingredients.map(ing => `
<div class="ingredient-row">
<span class="ingredient-name">${ing.name}</span>
<span class="ingredient-amount">${ing.amount}</span>
</div>
`).join('');
} else {
document.getElementById('detail-ingredients').style.display = 'none';
}
document.getElementById('detail-description').innerHTML = marked.parse(dish.description || '暂无做法');
document.getElementById('btn-delete-dish').onclick = () => deleteDish(dish.id);
document.getElementById('btn-edit-dish').onclick = () => {
bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide();
setTimeout(() => showDishForm(dish), 300);
};
// Check if dish is in any in-progress gathering
const inProgressOrder = getInProgressOrder();
const isInOrder = inProgressOrder ? isDishInOrder(dish.id) : false;
const addBtn = document.getElementById('btn-add-to-order');
if (isInOrder) {
addBtn.textContent = '移出聚会';
addBtn.className = 'btn btn-danger flex-fill rounded-pill py-3';
addBtn.onclick = async () => {
const inProgressOrders = orders.filter(o => o.status === 'in_progress');
if (inProgressOrders.length > 0) {
const order = inProgressOrders[0];
const existingDishes = order.dishes || [];
const newDishIds = existingDishes.filter(d => d !== dish.id && d !== dish.id.toString());
await fetch(`${API_BASE}/orders/${order.id}/`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: order.name, dish_ids: newDishIds })
});
showAppToast(`已从「${order.name}」移出`);
loadOrders();
bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide();
}
};
} else {
addBtn.textContent = '添加到聚会';
addBtn.className = 'btn btn-success flex-fill rounded-pill py-3';
addBtn.onclick = async () => {
bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide();
const inProgressOrders = orders.filter(o => o.status === 'in_progress');
if (inProgressOrders.length > 0) {
const order = inProgressOrders[0];
const existingDishes = order.dishes || [];
const newDishIds = [...new Set([...existingDishes, dish.id])];
await fetch(`${API_BASE}/orders/${order.id}/`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: order.name, dish_ids: newDishIds })
});
showAppToast(`已添加到「${order.name}」`);
loadOrders();
} else {
setTimeout(() => showOrderForm(null, dish), 300);
}
};
}
new bootstrap.Modal(document.getElementById('dish-detail-modal')).show();
}
function showDishForm(dish = null) {
document.getElementById('dish-form').reset();
document.getElementById('dish-form-title').textContent = dish ? '编辑菜品' : '添加菜品';
document.getElementById('dish-id').value = dish ? dish.id : '';
document.getElementById('dish-image-url').value = dish && dish.cover_image ? dish.cover_image : '';
const preview = document.getElementById('image-preview');
if (dish && dish.cover_image) {
preview.style.display = 'block';
preview.querySelector('img').src = dish.cover_image;
} else {
preview.style.display = 'none';
}
const catSelect = document.getElementById('dish-category');
catSelect.innerHTML = categories.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
if (dish) {
document.getElementById('dish-name').value = dish.name;
document.getElementById('dish-category').value = dish.category || '';
renderIngredients(dish.ingredients || []);
renderSteps(dish.description || '');
} else {
document.getElementById('ingredients-list').innerHTML = '';
renderSteps('');
}
// Setup add step button
document.getElementById('btn-add-step').onclick = addStep;
new bootstrap.Modal(document.getElementById('dish-form-modal')).show();
}
function renderSteps(description) {
const container = document.getElementById('step-list');
let steps = [];
// Parse existing description into steps
if (description) {
// Try to parse markdown into steps
const lines = description.split('\n');
let currentStep = { text: '', images: [] };
let stepImages = [];
lines.forEach(line => {
if (line.match(/^```|!\[.*\]\(http/)) {
// Image line
const match = line.match(/!\[([^\]]*)\]\(([^)]+)\)/);
if (match) {
stepImages.push({ url: match[2], alt: match[1] });
}
} else if (line.match(/^\d+[\.\)]\s/)) {
// New step starts
if (currentStep.text || stepImages.length > 0) {
currentStep.images = [...stepImages];
steps.push(currentStep);
}
currentStep = { text: line.replace(/^\d+[\.\)]\s*/, ''), images: [] };
stepImages = [];
} else if (line.trim()) {
currentStep.text += (currentStep.text ? '\n' : '') + line;
}
});
// Add last step
if (currentStep.text || stepImages.length > 0) {
currentStep.images = [...stepImages];
steps.push(currentStep);
}
}
// If no steps parsed, create empty step
if (steps.length === 0) {
steps = [{ text: '', images: [] }];
}
container.innerHTML = steps.map((step, index) => `
<div class="step-item" data-index="${index}">
<div class="step-item-header">
<div class="step-number">${index + 1}</div>
${steps.length > 1 ? `
<button type="button" class="step-delete-btn" onclick="deleteStep(this)">
<i class="bi bi-x-lg"></i>
</button>
` : ''}
</div>
<textarea class="step-input" placeholder="输入步骤 ${index + 1} 的内容..." oninput="updateStepPreview(this)">${step.text || ''}</textarea>
<div class="step-image-upload">
<label class="step-image-btn">
<i class="bi bi-image"></i>
上传图片
<input type="file" accept="image/*" style="display: none;" onchange="uploadStepImage(this)">
</label>
<span style="font-size: 12px; color: var(--text-muted);">支持 JPG、PNG</span>
</div>
<div class="step-image-preview">
${(step.images || []).map(img => `
<div style="position: relative;">
<img src="${img.url}" alt="${img.alt || ''}">
<button type="button" onclick="removeStepImage(this, '${img.url}')" style="position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; border-radius: 50%; background: #dc2626; color: white; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px;">×</button>
</div>
`).join('')}
</div>
<div class="step-preview" style="display: ${step.text ? 'block' : 'none'};">
<div class="step-preview-label">预览</div>
<div class="step-preview-content">${formatStepPreview(step.text, step.images || [])}</div>
</div>
</div>
`).join('');
}
function addStep() {
const container = document.getElementById('step-list');
const stepCount = container.querySelectorAll('.step-item').length;
const newStep = document.createElement('div');
newStep.className = 'step-item';
newStep.dataset.index = stepCount;
newStep.innerHTML = `
<div class="step-item-header">
<div class="step-number">${stepCount + 1}</div>
<button type="button" class="step-delete-btn" onclick="deleteStep(this)">
<i class="bi bi-x-lg"></i>
</button>
</div>
<textarea class="step-input" placeholder="输入步骤 ${stepCount + 1} 的内容..." oninput="updateStepPreview(this)"></textarea>
<div class="step-image-upload">
<label class="step-image-btn">
<i class="bi bi-image"></i>
上传图片
<input type="file" accept="image/*" style="display: none;" onchange="uploadStepImage(this)">
</label>
<span style="font-size: 12px; color: var(--text-muted);">支持 JPG、PNG</span>
</div>
<div class="step-image-preview"></div>
<div class="step-preview" style="display: none;">
<div class="step-preview-label">预览</div>
<div class="step-preview-content"></div>
</div>
`;
container.appendChild(newStep);
renumberSteps();
}
function deleteStep(btn) {
const stepItem = btn.closest('.step-item');
const stepList = document.getElementById('step-list');
if (stepList.querySelectorAll('.step-item').length > 1) {
stepItem.remove();
renumberSteps();
}
}
function renumberSteps() {
const steps = document.querySelectorAll('.step-item');
steps.forEach((step, index) => {
step.dataset.index = index;
step.querySelector('.step-number').textContent = index + 1;
const input = step.querySelector('.step-input');
input.placeholder = `输入步骤 ${index + 1} 的内容...`;
});
}
function updateStepPreview(textarea) {
const stepItem = textarea.closest('.step-item');
const preview = stepItem.querySelector('.step-preview');
const previewContent = stepItem.querySelector('.step-preview-content');
const images = [];
// Get existing images from preview
stepItem.querySelectorAll('.step-image-preview img').forEach(img => {
images.push({ url: img.src, alt: '' });
});
if (textarea.value.trim()) {
preview.style.display = 'block';
previewContent.innerHTML = formatStepPreview(textarea.value, images);
} else {
preview.style.display = 'none';
}
}
function formatStepPreview(text, images) {
let html = text.replace(/\n/g, '<br>');
// Add images with spacing before and after
images.forEach(img => {
html += `<br><br><img src="${img.url}" alt="${img.alt}" style="max-width: 50%; border-radius: 12px; margin: 12px 0; display: block;"><br>`;
});
return html;
}
async function uploadStepImage(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch('/api/upload/', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.url) {
const stepItem = input.closest('.step-item');
const imagePreview = stepItem.querySelector('.step-image-preview');
const newImage = document.createElement('div');
newImage.style.position = 'relative';
newImage.innerHTML = `
<img src="${data.url}" alt="步骤图片">
<button type="button" onclick="removeStepImage(this, '${data.url}')" style="position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; border-radius: 50%; background: #dc2626; color: white; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px;">×</button>
`;
imagePreview.appendChild(newImage);
// Update preview
const textarea = stepItem.querySelector('.step-input');
updateStepPreview(textarea);
}
} catch (e) {
console.error('上传图片失败', e);
showAppToast('上传图片失败,请换张图片再试试。', 'error');
}
}
function removeStepImage(btn, url) {
const stepItem = btn.closest('.step-item');
btn.parentElement.remove();
// Update preview
const textarea = stepItem.querySelector('.step-input');
updateStepPreview(textarea);
}
function generateDescriptionFromSteps() {
const steps = document.querySelectorAll('.step-item');
let description = '';
steps.forEach((step, index) => {
const textarea = step.querySelector('.step-input');
let stepText = textarea.value.trim();
// Get images
const images = [];
step.querySelectorAll('.step-image-preview img').forEach(img => {
images.push(img.src);
});
if (stepText || images.length > 0) {
// Add step number and text
description += `${index + 1}. ${stepText}\n`;
// Add images with blank lines before and after
images.forEach(url => {
description += `\n![步骤图](${url})\n`;
});
description += '\n';
}
});
return description.trim();
}
function renderIngredients(ingredients) {
const container = document.getElementById('ingredients-list');
container.innerHTML = ingredients.map((ing, i) => `
<div class="ingredient-item">
<input type="text" placeholder="材料名" value="${ing.name || ''}">
<input type="text" placeholder="用量" value="${ing.amount || ''}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()">
<i class="bi bi-x-lg"></i>
</button>
</div>
`).join('');
}
function addIngredientRow() {
const container = document.getElementById('ingredients-list');
const div = document.createElement('div');
div.className = 'ingredient-item';
div.innerHTML = `
<input type="text" placeholder="材料名">
<input type="text" placeholder="用量">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()">
<i class="bi bi-x-lg"></i>
</button>
`;
container.appendChild(div);
}
async function handleImageUpload(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch('/api/upload/', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.url) {
document.getElementById('dish-image-url').value = data.url;
const preview = document.getElementById('image-preview');
preview.style.display = 'block';
preview.querySelector('img').src = data.url;
}
} catch (e) {
console.error('上传图片失败', e);
showAppToast('上传图片失败,请换张图片再试试。', 'error');
}
}
async function saveDish() {
const id = document.getElementById('dish-id').value;
const name = document.getElementById('dish-name').value;
const category = document.getElementById('dish-category').value;
// Generate description from steps
const description = generateDescriptionFromSteps();
const cover_image = document.getElementById('dish-image-url').value;
if (!name) {
showAppToast('先写下这道菜的名字,再保存到菜谱本里。', 'error');
return;
}
const ingredients = [];
document.querySelectorAll('#ingredients-list .ingredient-item').forEach(item => {
const inputs = item.querySelectorAll('input');
if (inputs[0].value) {
ingredients.push({ name: inputs[0].value, amount: inputs[1].value });
}
});
const data = { name, description, ingredients };
if (cover_image && cover_image.trim()) data.cover_image = cover_image.trim();
if (category) data.category_id = parseInt(category);
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `${API_BASE}/dishes/${id}/` : `${API_BASE}/dishes/`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('dish-form-modal')).hide();
showAppToast(id ? '菜谱已经更新好了。' : '新菜谱已经收进菜谱本了。');
loadDishes();
} else {
const err = await res.json();
showAppToast(getErrorMessage(err, '保存失败,请稍后再试。'), 'error');
}
} catch (e) {
console.error('保存失败', e);
showAppToast('保存失败,请稍后再试。', 'error');
}
}
async function deleteDish(id) {
const shouldDelete = await confirmAction('删掉后这道菜会从菜谱本里移除。', '确定要删除这道菜吗?');
if (!shouldDelete) return;
try {
await fetch(`${API_BASE}/dishes/${id}/`, { method: 'DELETE' });
bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide();
showAppToast('这道菜已经从菜谱本移走了。');
loadDishes();
} catch (e) {
showAppToast('删除失败,请稍后再试。', 'error');
}
}
async function parseUrl() {
const url = document.getElementById('parse-url').value;
if (!url) return;
try {
const res = await fetch(`${API_BASE}/parse-url/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.error) {
showAppToast(getErrorMessage(data.error, '解析失败,请换个链接再试试。'), 'error');
return;
}
window.parseData = data;
document.getElementById('parse-name').textContent = `菜品: ${data.name || '未识别'}`;
document.getElementById('parse-ingredients-count').textContent = `材料: ${data.ingredients ? data.ingredients.length : 0}项`;
document.getElementById('parse-result').style.display = 'block';
showAppToast('链接里的菜谱内容已经整理好了。');
} catch (e) {
showAppToast('解析失败,请换个链接再试试。', 'error');
}
}
function applyParse() {
const data = window.parseData;
if (!data) return;
document.getElementById('dish-name').value = data.name || '';
document.getElementById('dish-image-url').value = data.cover_image || '';
if (data.cover_image) {
const preview = document.getElementById('image-preview');
preview.style.display = 'block';
preview.querySelector('img').src = data.cover_image;
}
renderIngredients(data.ingredients || []);
renderSteps(data.description || '');
// Setup add step button
document.getElementById('btn-add-step').onclick = addStep;
bootstrap.Modal.getInstance(document.getElementById('parse-modal')).hide();
document.getElementById('dish-form-title').textContent = '添加菜品';
document.getElementById('dish-id').value = '';
new bootstrap.Modal(document.getElementById('dish-form-modal')).show();
}
function showOrderForm(order = null, preselectedDish = null) {
document.getElementById('order-form-title').textContent = order ? '编辑聚会' : '新建聚会';
document.getElementById('order-id').value = order ? order.id : '';
document.getElementById('order-name').value = order ? order.name : '';
// Set party date (default to today if new order)
const partyDateInput = document.getElementById('order-party-date');
if (order && order.party_date) {
// Convert ISO string to datetime-local format using local time
const date = new Date(order.party_date);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
partyDateInput.value = localDate.toISOString().slice(0, 16);
} else if (!order) {
// Default to today
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
partyDateInput.value = now.toISOString().slice(0, 16);
} else {
partyDateInput.value = '';
}
// Set participants
const participantsTags = document.getElementById('order-participants-tags');
participantsTags.innerHTML = '';
window.orderParticipants = order && order.participants ? [...order.participants] : [];
renderParticipantTags();
// Setup participant input
setupParticipantInput();
const container = document.getElementById('order-dish-select');
container.innerHTML = dishes.map(dish => `
<label class="dish-checkbox">
<input type="checkbox" value="${dish.id}"
${order && order.dishes.some(d => d.id === dish.id) ? 'checked' : ''}
${preselectedDish && preselectedDish.id === dish.id ? 'checked' : ''}>
<span class="dish-label">${dish.name}</span>
</label>
`).join('');
new bootstrap.Modal(document.getElementById('order-form-modal')).show();
}
function setupParticipantInput() {
const input = document.querySelector('.participant-input');
if (!input) return;
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && this.value.trim()) {
e.preventDefault();
const name = this.value.trim();
if (name && !window.orderParticipants.includes(name)) {
window.orderParticipants.push(name);
renderParticipantTags();
}
this.value = '';
}
});
}
function renderParticipantTags() {
const container = document.getElementById('order-participants-tags');
container.innerHTML = window.orderParticipants.map(name => `
<span class="badge bg-primary d-flex align-items-center gap-1">
${name}
<button type="button" class="btn-close btn-close-white ms-1" style="font-size: 8px;" onclick="removeParticipant('${name}')"></button>
</span>
`).join('');
}
function removeParticipant(name) {
window.orderParticipants = window.orderParticipants.filter(p => p !== name);
renderParticipantTags();
}
async function saveOrder() {
const id = document.getElementById('order-id').value;
const name = document.getElementById('order-name').value;
if (!name) {
showAppToast('先给这次聚会起个名字吧。', 'error');
return;
}
const partyDate = document.getElementById('order-party-date').value;
const participants = window.orderParticipants || [];
const dishIds = [];
document.querySelectorAll('#order-dish-select input:checked').forEach(cb => {
dishIds.push(parseInt(cb.value));
});
const data = {
name,
dish_ids: dishIds,
party_date: partyDate || null,
participants: participants
};
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `${API_BASE}/orders/${id}/` : `${API_BASE}/orders/`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('order-form-modal')).hide();
showAppToast(id ? '聚会单已经更新好了。' : '新的备菜清单已经建好了。');
loadOrders();
} else {
const err = await res.json().catch(() => null);
showAppToast(getErrorMessage(err, '保存失败,请稍后再试。'), 'error');
}
} catch (e) {
showAppToast('保存失败,请稍后再试。', 'error');
}
}
function formatDateTime(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
async function showOrderDetail(id) {
const order = orders.find(o => o.id == id);
if (!order) return;
document.getElementById('order-detail-modal').dataset.orderId = id;
document.getElementById('order-detail-title').textContent = order.name;
const statusEl = document.getElementById('order-detail-status');
statusEl.textContent = order.status === 'in_progress' ? '进行中' : '已完成';
statusEl.className = `order-detail-status ${order.status === 'in_progress' ? 'status-progress' : 'status-done'}`;
// 显示聚会时间
const dateEl = document.getElementById('order-detail-date');
if (order.party_date) {
const date = new Date(order.party_date);
dateEl.textContent = formatDateTime(date);
dateEl.style.display = 'inline';
} else {
dateEl.style.display = 'none';
}
const detailSummaryEl = document.getElementById('order-detail-summary');
const dishCountLabelEl = document.getElementById('order-dish-count-label');
const detailDishNoteEl = document.getElementById('order-detail-dish-note');
const dishCount = order.dishes_detail ? order.dishes_detail.length : 0;
const participantCount = order.participants ? order.participants.length : 0;
if (detailSummaryEl) {
detailSummaryEl.textContent = order.status === 'in_progress'
? `这张备菜清单里有 ${dishCount} 道菜${participantCount > 0 ? `,共 ${participantCount} 位参与人` : ''}。`
: `这次聚会已经完成,留下了 ${dishCount} 道上桌菜品的记录。`;
}
if (dishCountLabelEl) {
dishCountLabelEl.textContent = dishCount > 0
? `已经选了 ${dishCount} 道菜,随时可以继续调整。`
: '先从下面勾选想做的菜。';
}
if (detailDishNoteEl) {
detailDishNoteEl.textContent = dishCount > 0
? `共 ${dishCount} 道菜${order.status === 'in_progress' ? ',还可以继续删减或补充。' : ',都已经留在这次记录里。'}`
: '还没选菜,先从菜谱页挑几道拿手菜。';
}
// Render dishes with delete button (only for in_progress orders)
const dishesContainer = document.getElementById('order-detail-dishes');
const renderDishMeta = dish => {
const parts = [];
if (dish.category_name) {
parts.push(`<span class="dish-meta-chip">${dish.category_name}</span>`);
}
parts.push(`<span class="dish-meta-note">${order.status === 'in_progress' ? '备菜中,可随时调整' : '已完成记录'}</span>`);
return parts.join('');
};
if (order.status === 'in_progress') {
dishesContainer.innerHTML = (order.dishes_detail || []).map(d => `
<div class="dish-item-row">
<div class="dish-info">
<img class="dish-thumb" src="${d.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22 viewBox=%220 0 48 48%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22 rx=%2214%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.32em%22 font-size=%2212%22 font-family=%22Arial,sans-serif%22 fill=%22%23c2410c%22>菜谱</text></svg>'}" alt="${d.name}">
<div class="dish-copy">
<span class="dish-name">${d.name}</span>
<div class="dish-item-meta">${renderDishMeta(d)}</div>
</div>
</div>
<button class="remove-dish-btn" aria-label="从聚会中移除${d.name}" onclick="removeDishFromOrder(${order.id}, ${d.id})">
<i class="bi bi-x-lg"></i>
</button>
</div>
`).join('');
} else {
dishesContainer.innerHTML = (order.dishes_detail || []).map(d => `
<div class="dish-item-row">
<div class="dish-info">
<img class="dish-thumb" src="${d.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22 viewBox=%220 0 48 48%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22 rx=%2214%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.32em%22 font-size=%2212%22 font-family=%22Arial,sans-serif%22 fill=%22%23c2410c%22>菜谱</text></svg>'}" alt="${d.name}">
<div class="dish-copy">
<span class="dish-name">${d.name}</span>
<div class="dish-item-meta">${renderDishMeta(d)}</div>
</div>
</div>
</div>
`).join('');
}
const ingContainer = document.getElementById('order-detail-ingredients');
if (order.ingredients_summary && order.ingredients_summary.length > 0) {
ingContainer.innerHTML = order.ingredients_summary.map(ing => `
<div class="ingredient-row">
<span class="ingredient-name">${ing.name}</span>
<span class="ingredient-amount">${ing.amount}</span>
</div>
`).join('');
} else {
ingContainer.innerHTML = '<p class="detail-empty-note">暂时还没有汇总出食材,等选好菜后这里会自动整理。</p>';
}
// Render participants
const participantsContainer = document.getElementById('order-detail-participants');
if (order.participants && order.participants.length > 0) {
participantsContainer.innerHTML = `<div class="participants-wrap">${order.participants.map(name => `
<span class="participant-chip">${name}</span>
`).join('')}</div>`;
} else {
participantsContainer.innerHTML = '<p class="detail-empty-note">这次还没记录参与人。</p>';
}
document.getElementById('btn-complete-order').style.display = order.status === 'in_progress' ? 'block' : 'none';
// Hide ingredients section for completed orders
const ingredientsSection = document.getElementById('order-ingredients-section');
ingredientsSection.style.display = order.status === 'completed' ? 'none' : 'block';
// Render party images
renderOrderImages(order);
// Add share button handler
document.getElementById('btn-share-order').onclick = () => shareOrderAsImage(order);
new bootstrap.Modal(document.getElementById('order-detail-modal')).show();
}
// Render party images
function renderOrderImages(order) {
const container = document.getElementById('order-detail-images');
const images = order.images || [];
if (images.length === 0) {
container.innerHTML = '<p class="detail-empty-note">还没有留下聚会照片,饭桌热闹时记得补一张。</p>';
} else {
container.innerHTML = images.map((img, idx) => `
<div class="party-image-item">
<img src="${img}" alt="聚会照片">
<button class="remove-image-btn" onclick="removePartyImage(${idx})">×</button>
</div>
`).join('');
}
}
// Remove image from party
async function removePartyImage(idx) {
const orderId = document.getElementById('order-detail-modal').dataset.orderId;
const order = orders.find(o => o.id == orderId);
if (!order) return;
const images = order.images || [];
images.splice(idx, 1);
await updateOrderImages(orderId, images);
order.images = images;
renderOrderImages(order);
}
// Update order images via API
async function updateOrderImages(orderId, images) {
try {
const res = await fetch(`${API_BASE}/orders/${orderId}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ images: images })
});
if (!res.ok) throw new Error('更新失败');
showAppToast('聚会照片已经更新。');
} catch (e) {
console.error('更新图片失败:', e);
showAppToast('更新图片失败,请稍后再试。', 'error');
}
}
// Handle party image upload
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('party-image-upload').addEventListener('change', async function(e) {
const files = e.target.files;
if (!files || files.length === 0) return;
const orderId = document.getElementById('order-detail-modal').dataset.orderId;
if (!orderId) return;
const order = orders.find(o => o.id == orderId);
if (!order) return;
const currentImages = order.images || [];
let uploadFailed = false;
// Upload each image
for (let file of files) {
try {
const formData = new FormData();
formData.append('image', file);
const res = await fetch(`/api/upload/`, {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.url) {
currentImages.push(data.url);
}
} catch (e) {
console.error('上传图片失败:', e);
uploadFailed = true;
}
}
await updateOrderImages(orderId, currentImages);
order.images = currentImages;
renderOrderImages(order);
if (uploadFailed) {
showAppToast('有照片没上传成功,剩下的已经先收进聚会记录里。', 'error');
}
// Reset input
this.value = '';
});
});
async function shareOrderAsImage(order) {
// Disable share button and show loading state
const shareBtn = document.getElementById('btn-share-order');
const originalBtnContent = shareBtn.innerHTML;
shareBtn.disabled = true;
shareBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span> 生成中...';
try {
// Create a temporary container for the share image
const shareContainer = document.createElement('div');
shareContainer.style.cssText = 'position: fixed; left: -9999px; top: 0; width: 400px; background: linear-gradient(135deg, #fff7ed 0%, #fffaf0 100%); padding: 30px; font-family: "Noto Sans SC", sans-serif;';
// Build the share content
const allDishes = order.dishes_detail || [];
const groupedDishes = {
'热菜': allDishes.filter(d => !(d.category_name || '').includes('凉菜') && !(d.category_name || '').includes('汤')),
'凉菜': allDishes.filter(d => (d.category_name || '').includes('凉菜')),
'汤': allDishes.filter(d => (d.category_name || '').includes('汤'))
};
const dishSections = Object.entries(groupedDishes)
.filter(([, dishes]) => dishes.length > 0)
.map(([title, dishes]) => `
<div style="margin-bottom: 20px;">
<h3 style="font-size: 16px; font-weight: 700; color: #1f2937; margin-bottom: 12px;">${title}</h3>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px;">
${dishes.map(d => `
<div style="display: flex; align-items: center; min-width: 0; padding: 12px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
<img src="${d.cover_image || ''}" style="width: 44px; height: 44px; border-radius: 10px; object-fit: cover; margin-right: 10px; flex-shrink: 0;" onerror="this.style.display='none'">
<span style="font-size: 14px; font-weight: 600; color: #1f2937; line-height: 1.4; word-break: break-word;">${d.name}</span>
</div>
`).join('')}
</div>
</div>
`).join('');
shareContainer.innerHTML = `
<div style="text-align: center; margin-bottom: 24px;">
<h1 style="font-size: 24px; font-weight: 800; color: #c2410c; margin-bottom: 8px;">${order.name}</h1>
<p style="font-size: 14px; color: #9ca3af;">${new Date(order.created_at).toLocaleDateString()} · ${allDishes.length}道菜</p>
</div>
<div>
${dishSections || '<p style="font-size: 14px; color: #9ca3af; text-align: center; margin: 0;">暂无菜品</p>'}
</div>
`;
document.body.appendChild(shareContainer);
// Generate image
const canvas = await html2canvas(shareContainer, {
scale: 2,
useCORS: true,
backgroundColor: null
});
// Remove temp container
document.body.removeChild(shareContainer);
// Convert to blob and share
canvas.toBlob(async (blob) => {
const file = new File([blob], '聚会菜单.png', { type: 'image/png' });
if (navigator.share && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
files: [file],
title: order.name,
text: `${order.name} 的菜品清单`
});
showAppToast('分享图已经准备好了。');
} catch (e) {
// User cancelled or share failed
if (e.name !== 'AbortError') {
downloadImage(canvas);
showAppToast('系统分享没打开,已经改为下载到本地。');
}
}
} else {
// Fallback: download
downloadImage(canvas);
showAppToast('设备不支持直接分享,已经下载到本地。');
}
// Restore button
shareBtn.disabled = false;
shareBtn.innerHTML = originalBtnContent;
}, 'image/png');
} catch (e) {
console.error('分享失败', e);
// Restore button
shareBtn.disabled = false;
shareBtn.innerHTML = originalBtnContent;
showAppToast('分享失败,请重试。', 'error');
}
}
function showParticipantRanking() {
const ranking = window.participantRanking || [];
const container = document.getElementById('participant-ranking-list');
if (ranking.length === 0) {
container.innerHTML = '<p class="text-center text-muted py-4">暂无参与记录</p>';
} else {
const maxCount = Math.max(...ranking.map(r => r.count));
container.innerHTML = ranking.map((item, index) => {
const percentage = maxCount > 0 ? (item.count / maxCount) * 100 : 0;
let rankBadge = '';
if (index === 0) rankBadge = '<span class="badge bg-warning text-dark ms-2">🥇</span>';
else if (index === 1) rankBadge = '<span class="badge bg-secondary ms-2">🥈</span>';
else if (index === 2) rankBadge = '<span class="badge bg-brown ms-2">🥉</span>';
return `
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-medium">${index + 1}. ${item.name} ${rankBadge}</span>
<span class="text-muted">${item.count}次</span>
</div>
<div class="progress" style="height: 12px; border-radius: 6px;">
<div class="progress-bar" style="width: ${percentage}%; background: linear-gradient(90deg, #4f46e5, #7c3aed); border-radius: 6px;"></div>
</div>
</div>
`;
}).join('');
}
new bootstrap.Modal(document.getElementById('participant-ranking-modal')).show();
}
function downloadImage(canvas) {
const link = document.createElement('a');
link.download = '聚会菜单.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
async function removeDishFromOrder(orderId, dishId) {
const shouldRemove = await confirmAction('移走后,这道菜会从这张备菜清单里消失。', '确定要移除这道菜吗?');
if (!shouldRemove) return;
try {
const order = orders.find(o => o.id == orderId);
const existingDishes = order.dishes || [];
const newDishIds = existingDishes.filter(d => d !== dishId && d !== dishId.toString());
await fetch(`${API_BASE}/orders/${orderId}/`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: order.name, dish_ids: newDishIds })
});
showAppToast('这道菜已经从聚会单里移走了。');
await loadOrders();
showOrderDetail(orderId);
} catch (e) {
showAppToast('移除失败,请稍后再试。', 'error');
}
}
async function deleteOrder(id) {
const shouldDelete = await confirmAction('删掉后,这张聚会单和里面的记录会一起移除。', '确定要删除这个聚会吗?');
if (!shouldDelete) return;
try {
await fetch(`${API_BASE}/orders/${id}/`, { method: 'DELETE' });
bootstrap.Modal.getInstance(document.getElementById('order-detail-modal')).hide();
showAppToast('这张聚会单已经从菜谱本里移走了。');
loadOrders();
} catch (e) {
showAppToast('删除失败,请稍后再试。', 'error');
}
}
async function completeOrder(id) {
const shouldComplete = await confirmAction('完成后会保留这次聚会记录,并归档到已完成里。', '确定要完成这个聚会吗?');
if (!shouldComplete) return;
try {
// Use the complete endpoint to preserve dishes
await fetch(`${API_BASE}/orders/${id}/complete/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
bootstrap.Modal.getInstance(document.getElementById('order-detail-modal')).hide();
showAppToast('这次聚会已经归档完成。');
loadOrders();
} catch (e) {
showAppToast('操作失败,请稍后再试。', 'error');
}
}
async function exportData() {
try {
const dishesRes = await fetch(`${API_BASE}/dishes/`);
const dishes = await dishesRes.json();
const ordersRes = await fetch(`${API_BASE}/orders/`);
const orders = await ordersRes.json();
const categoriesRes = await fetch(`${API_BASE}/categories/`);
const categories = await categoriesRes.json();
const data = { dishes, orders, categories };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `zhangmenu-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
showAppToast('备份文件已经导出到本地。');
} catch (e) {
showAppToast('导出失败,请稍后再试。', 'error');
}
}
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
</script>
</body>
</html>