Files
web_template/web/dist/food3/index.html
Geliebte a84129330e feat: 全站20个模板添加国际化(i18n)支持 - 简体中文/繁体中文/英文三语切换
- 所有18套前端模板 + 管理后台共20个HTML文件添加i18n引擎
- 前端JavaScript方案:I18N_LOCALES翻译字典 + data-i18n属性 + t()函数调用
- 语言切换器UI:下拉菜单选择,匹配各模板主题风格
- 语言持久化:localStorage存储 + URL参数覆盖 + 浏览器语言自动检测
- onLangChange回调:语言切换时动态重新渲染所有内容
- 支持语言:zh-CN(简体中文) / zh-TW(繁体中文) / en(English)
2026-09-09 16:58:40 +08:00

2198 lines
106 KiB
HTML

<!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" />
<meta name="theme-color" content="#2D9A6C" />
<title>绿野鲜送 · 新鲜好味 绿色送达</title>
<style>
/* ============================================================
绿野鲜送 — 清新绿色风外卖平台
主题:健康轻食 / 文艺自然 / 大圆角 / 柔和阴影
============================================================ */
:root {
/* 主色调 */
--green: #2D9A6C;
--green-deep: #1B5E20;
--green-soft: #E8F5E9;
--green-mist: #F1F8F4;
--green-100: #C8E6C9;
--green-200: #A5D6A7;
--green-300: #81C784;
--green-400: #4CAF50;
--green-600: #258059;
--warm-white: #FEFDF9;
/* 文字 */
--ink: #1B5E20;
--ink-2: #355E3B;
--muted: #6B8170;
--line: #E3EFE6;
/* 表面 */
--bg: #F5FBF6;
--card: #FFFFFF;
/* 效果 */
--shadow-sm: 0 4px 16px rgba(45, 154, 108, 0.08);
--shadow: 0 10px 34px rgba(45, 154, 108, 0.12);
--shadow-lg: 0 20px 50px rgba(27, 94, 32, 0.18);
--radius-xl: 28px;
--radius: 20px;
--radius-md: 16px;
--radius-sm: 12px;
--grad-green: linear-gradient(135deg, #2D9A6C 0%, #4CAF50 55%, #66BB6A 100%);
--grad-soft: linear-gradient(160deg, #F1F8F4 0%, #E8F5E9 100%);
--grad-hero: linear-gradient(160deg, #E8F5E9 0%, #F1F8F4 45%, #FEFDF9 100%);
--font: "Nunito", "Quicksand", -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--ink);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
padding-bottom: 96px;
overflow-x: hidden;
}
img { max-width: 100%; display: block; }
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input { font-family: inherit; outline: none; }
a { color: inherit; text-decoration: none; }
::selection { background: var(--green-100); color: var(--green-deep); }
/* 滚动条 */
::-webkit-scrollbar { width: 10px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--green-200); border-radius: 10px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--green-300); }
.container { width: 100%; max-width: 1240px; margin: 0 auto; padding: 0 20px; }
/* ============ 顶部导航 ============ */
.navbar {
position: sticky; top: 0; z-index: 100;
background: rgba(232, 245, 233, 0.82);
backdrop-filter: saturate(180%) blur(18px);
-webkit-backdrop-filter: saturate(180%) blur(18px);
border-bottom: 1px solid rgba(165, 214, 167, 0.45);
}
.nav-inner {
display: flex; align-items: center; gap: 20px;
height: 70px;
}
.logo { display: flex; align-items: center; gap: 10px; flex-shrink: 0; cursor: pointer; }
.logo-mark {
width: 42px; height: 42px; border-radius: 14px;
background: var(--grad-green);
display: grid; place-items: center;
box-shadow: 0 6px 16px rgba(45, 154, 108, 0.35);
}
.logo-mark svg { width: 24px; height: 24px; }
.logo-text {
font-size: 21px; font-weight: 800; letter-spacing: 0.5px;
color: var(--green-deep);
}
.logo-text span { color: var(--green); }
.search-wrap { flex: 1; max-width: 520px; position: relative; }
.search-box {
width: 100%; height: 46px;
background: #fff;
border: 1.5px solid var(--line);
border-radius: 24px;
padding: 0 52px 0 22px;
font-size: 15px; color: var(--ink);
transition: all 0.25s;
box-shadow: var(--shadow-sm);
}
.search-box::placeholder { color: #9CB3A3; }
.search-box:focus {
border-color: var(--green);
box-shadow: 0 0 0 4px rgba(45, 154, 108, 0.14);
}
.search-btn {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 36px; height: 36px; border-radius: 50%;
background: var(--grad-green); color: #fff;
display: grid; place-items: center;
box-shadow: 0 4px 12px rgba(45, 154, 108, 0.35);
transition: transform 0.2s;
}
.search-btn:hover { transform: translateY(-50%) scale(1.06); }
.nav-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
.cart-btn {
position: relative;
width: 46px; height: 46px; border-radius: 16px;
background: #fff; border: 1.5px solid var(--line);
display: grid; place-items: center;
color: var(--green-deep);
transition: all 0.25s;
}
.cart-btn:hover { border-color: var(--green); color: var(--green); transform: translateY(-2px); }
.cart-badge {
position: absolute; top: -6px; right: -6px;
min-width: 22px; height: 22px; padding: 0 6px;
background: #FF7043; color: #fff;
border-radius: 11px; font-size: 12px; font-weight: 800;
display: grid; place-items: center;
border: 2px solid var(--warm-white);
transform: scale(0); transition: transform 0.25s cubic-bezier(.34,1.56,.64,1);
}
.cart-badge.show { transform: scale(1); }
.nav-cta {
height: 46px; padding: 0 22px; border-radius: 16px;
background: var(--grad-green); color: #fff;
font-weight: 700; font-size: 15px;
display: none; align-items: center; gap: 6px;
box-shadow: 0 8px 20px rgba(45, 154, 108, 0.32);
transition: transform 0.2s;
}
.nav-cta:hover { transform: translateY(-2px); }
@media (min-width: 900px) { .nav-cta { display: inline-flex; } }
/* ============ Hero ============ */
.hero {
position: relative; overflow: hidden;
background: var(--grad-hero);
padding: 48px 0 56px;
}
.hero-leaf { position: absolute; pointer-events: none; opacity: 0.5; }
.hero-leaf svg { width: 100%; height: 100%; }
.leaf-1 { top: -30px; left: -40px; width: 220px; transform: rotate(-25deg); opacity: 0.28; }
.leaf-2 { top: 30px; right: -50px; width: 180px; transform: rotate(40deg); opacity: 0.22; }
.leaf-3 { bottom: -60px; left: 30%; width: 260px; transform: rotate(15deg); opacity: 0.15; }
.hero-card {
position: relative;
background: #fff;
border-radius: var(--radius-xl);
padding: 52px 48px;
box-shadow: var(--shadow);
overflow: hidden;
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 40px;
align-items: center;
}
.hero-card::before {
content: ""; position: absolute; top: -80px; right: -80px;
width: 260px; height: 260px; border-radius: 50%;
background: radial-gradient(circle, rgba(102,187,106,0.18), transparent 70%);
}
.hero-tag {
display: inline-flex; align-items: center; gap: 6px;
background: var(--green-soft); color: var(--green-deep);
padding: 7px 16px; border-radius: 20px;
font-size: 13px; font-weight: 700;
margin-bottom: 20px;
}
.hero-tag .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 1.8s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
.hero-title {
font-size: clamp(30px, 4.4vw, 46px);
font-weight: 900; line-height: 1.15;
color: var(--green-deep); letter-spacing: 0.5px;
margin-bottom: 16px;
}
.hero-title em { font-style: normal; color: var(--green); position: relative; }
.hero-title em::after {
content: ""; position: absolute; left: 0; bottom: 4px; width: 100%; height: 10px;
background: rgba(165, 214, 167, 0.5); border-radius: 10px; z-index: -1;
}
.hero-desc { font-size: 16px; color: var(--muted); margin-bottom: 28px; max-width: 460px; }
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; }
.btn-primary {
display: inline-flex; align-items: center; gap: 8px;
height: 52px; padding: 0 30px; border-radius: 18px;
background: var(--grad-green); color: #fff;
font-weight: 800; font-size: 16px;
box-shadow: 0 12px 26px rgba(45, 154, 108, 0.35);
transition: transform 0.22s, box-shadow 0.22s;
}
.btn-primary:hover { transform: translateY(-3px); box-shadow: 0 18px 34px rgba(45, 154, 108, 0.42); }
.btn-ghost {
display: inline-flex; align-items: center; gap: 8px;
height: 52px; padding: 0 26px; border-radius: 18px;
background: #fff; color: var(--green-deep);
font-weight: 700; font-size: 16px;
border: 1.5px solid var(--green-100);
transition: all 0.22s;
}
.btn-ghost:hover { border-color: var(--green); background: var(--green-mist); }
.hero-stats { display: flex; gap: 30px; margin-top: 32px; }
.hero-stat .num { font-size: 26px; font-weight: 900; color: var(--green); }
.hero-stat .lbl { font-size: 13px; color: var(--muted); }
.hero-visual { position: relative; height: 320px; display: grid; place-items: center; }
.hero-bowl {
width: 280px; height: 280px; border-radius: 50%;
background: var(--grad-soft);
display: grid; place-items: center;
box-shadow: inset 0 -20px 40px rgba(45,154,108,0.1), 0 20px 50px rgba(45,154,108,0.18);
position: relative;
animation: float 5s ease-in-out infinite;
}
@keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-14px); } }
.hero-bowl svg { width: 170px; height: 170px; }
.hero-float {
position: absolute; background: #fff; border-radius: 16px;
padding: 10px 14px; box-shadow: var(--shadow);
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 700; color: var(--green-deep);
}
.hero-float .ic { width: 28px; height: 28px; border-radius: 9px; background: var(--green-soft); display: grid; place-items: center; color: var(--green); }
.f1 { top: 10px; left: 0; animation: float 4s ease-in-out infinite; }
.f2 { bottom: 30px; right: -10px; animation: float 4.5s ease-in-out 0.5s infinite; }
.f3 { top: 90px; right: 10px; animation: float 5.5s ease-in-out 1s infinite; }
@media (max-width: 880px) {
.hero-card { grid-template-columns: 1fr; padding: 36px 26px; gap: 24px; }
.hero-visual { height: 240px; order: -1; }
.hero-bowl { width: 210px; height: 210px; }
.hero-bowl svg { width: 130px; height: 130px; }
.hero-stats { gap: 22px; }
}
/* ============ 通用区块标题 ============ */
.section { padding: 56px 0; }
.section-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 30px; gap: 16px; flex-wrap: wrap; }
.section-title { display: flex; flex-direction: column; gap: 4px; }
.section-title .kicker { font-size: 13px; font-weight: 700; color: var(--green); letter-spacing: 1.5px; text-transform: uppercase; }
.section-title h2 { font-size: clamp(22px, 3vw, 30px); font-weight: 900; color: var(--green-deep); display: flex; align-items: center; gap: 12px; }
.section-title h2::before { content: ""; width: 6px; height: 26px; border-radius: 4px; background: var(--grad-green); }
.section-title p { font-size: 14px; color: var(--muted); margin-top: 2px; }
.link-more { font-size: 14px; font-weight: 700; color: var(--green); display: inline-flex; align-items: center; gap: 4px; transition: gap 0.2s; }
.link-more:hover { gap: 8px; }
/* ============ 分类网格 ============ */
.cat-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
}
.cat-card {
background: var(--card);
border-radius: var(--radius-md);
padding: 22px 12px 18px;
text-align: center;
cursor: pointer;
border: 1.5px solid transparent;
box-shadow: var(--shadow-sm);
transition: all 0.28s cubic-bezier(.34,1.2,.64,1);
position: relative; overflow: hidden;
}
.cat-card::after {
content: ""; position: absolute; inset: 0;
background: var(--grad-soft); opacity: 0; transition: opacity 0.28s; z-index: 0;
}
.cat-card > * { position: relative; z-index: 1; }
.cat-card:hover { transform: translateY(-8px); box-shadow: var(--shadow); border-color: var(--green-100); }
.cat-card:hover::after { opacity: 1; }
.cat-card.active { background: var(--grad-green); border-color: transparent; box-shadow: 0 14px 30px rgba(45,154,108,0.35); }
.cat-card.active .cat-ic { background: rgba(255,255,255,0.25); color: #fff; }
.cat-card.active .cat-name { color: #fff; }
.cat-card.active .cat-count { color: rgba(255,255,255,0.85); }
.cat-ic {
width: 60px; height: 60px; margin: 0 auto 12px;
border-radius: 50%;
background: var(--green-soft); color: var(--green);
display: grid; place-items: center;
transition: all 0.28s;
}
.cat-ic svg { width: 32px; height: 32px; }
.cat-card:hover .cat-ic { transform: rotate(-8deg) scale(1.06); }
.cat-name { font-size: 15px; font-weight: 800; color: var(--ink); }
.cat-count { font-size: 12px; color: var(--muted); margin-top: 2px; }
@media (max-width: 980px) { .cat-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 520px) { .cat-grid { grid-template-columns: repeat(2, 1fr); } }
/* ============ 推荐餐厅 横滑 ============ */
.featured-scroller {
display: flex; gap: 18px;
overflow-x: auto; padding: 6px 4px 18px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.featured-scroller::-webkit-scrollbar { height: 6px; }
.feat-card {
flex: 0 0 300px;
scroll-snap-align: start;
background: var(--card);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: all 0.28s;
cursor: pointer;
border: 1.5px solid transparent;
}
.feat-card:hover { transform: translateY(-6px); box-shadow: var(--shadow); border-color: var(--green-100); }
.feat-cover { height: 140px; position: relative; display: grid; place-items: center; }
.feat-cover .grad { position: absolute; inset: 0; }
.feat-cover .ic { position: relative; width: 64px; height: 64px; border-radius: 20px; background: rgba(255,255,255,0.85); display: grid; place-items: center; box-shadow: var(--shadow-sm); }
.feat-cover .ic svg { width: 38px; height: 38px; }
.feat-rank {
position: absolute; top: 12px; left: 12px;
width: 30px; height: 30px; border-radius: 10px;
background: rgba(27,94,32,0.78); color: #fff;
font-weight: 900; font-size: 14px;
display: grid; place-items: center; backdrop-filter: blur(6px);
}
.feat-body { padding: 16px 18px 20px; }
.feat-name { font-size: 17px; font-weight: 800; color: var(--ink); margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.feat-desc { font-size: 13px; color: var(--muted); margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.feat-meta { display: flex; align-items: center; justify-content: space-between; font-size: 13px; }
.feat-rating { display: flex; align-items: center; gap: 4px; font-weight: 800; color: var(--green); }
.feat-sales { color: var(--muted); font-size: 12px; }
.feat-tags { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
.feat-tags .t { font-size: 11px; padding: 3px 9px; border-radius: 10px; background: var(--green-mist); color: var(--green-600); font-weight: 700; }
/* ============ 工具栏 筛选/排序 ============ */
.toolbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
margin-bottom: 26px;
}
.sort-group {
display: flex; gap: 8px; background: var(--card);
padding: 6px; border-radius: 16px; box-shadow: var(--shadow-sm);
border: 1.5px solid var(--line);
}
.sort-btn {
padding: 9px 18px; border-radius: 11px;
font-size: 14px; font-weight: 700; color: var(--muted);
transition: all 0.22s;
display: inline-flex; align-items: center; gap: 6px;
}
.sort-btn:hover { color: var(--green); }
.sort-btn.active { background: var(--grad-green); color: #fff; box-shadow: 0 6px 14px rgba(45,154,108,0.3); }
.result-info { font-size: 14px; color: var(--muted); margin-left: auto; }
.result-info b { color: var(--green-deep); }
.chip-clear {
display: none; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: 12px;
background: #fff; border: 1.5px solid var(--green-100);
font-size: 13px; font-weight: 700; color: var(--green);
}
.chip-clear.show { display: inline-flex; }
/* ============ 餐厅列表 ============ */
.rest-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 22px;
}
.rest-card {
background: var(--card);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow-sm);
border: 1.5px solid transparent;
cursor: pointer;
transition: all 0.28s cubic-bezier(.34,1.2,.64,1);
display: flex; gap: 16px;
position: relative; overflow: hidden;
}
.rest-card::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--grad-green); opacity: 0; transition: opacity 0.28s;
}
.rest-card:hover { transform: translateY(-6px); box-shadow: var(--shadow-lg); border-color: var(--green-100); }
.rest-card:hover::before { opacity: 1; }
.rest-logo {
flex-shrink: 0;
width: 96px; height: 96px; border-radius: 20px;
display: grid; place-items: center;
position: relative; overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.5);
}
.rest-logo .grad { position: absolute; inset: 0; }
.rest-logo svg { position: relative; width: 50px; height: 50px; color: #fff; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.12)); }
.rest-info { flex: 1; min-width: 0; }
.rest-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.rest-name { font-size: 18px; font-weight: 800; color: var(--ink); line-height: 1.3; }
.rest-status { flex-shrink: 0; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 8px; }
.rest-status.open { background: var(--green-soft); color: var(--green-600); }
.rest-status.closed { background: #FBE9E7; color: #C62828; }
.rest-cat { font-size: 12px; color: var(--green); font-weight: 700; margin: 3px 0 8px; }
.rest-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.rest-tags .t { font-size: 11px; padding: 2px 9px; border-radius: 10px; background: var(--green-mist); color: var(--green-600); font-weight: 700; }
.rest-meta { display: flex; align-items: center; gap: 12px; font-size: 13px; flex-wrap: wrap; }
.rest-rating { display: flex; align-items: center; gap: 3px; font-weight: 800; color: var(--green); }
.rest-sales { color: var(--muted); font-size: 12px; }
.rest-delivery { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); margin-top: 8px; padding-top: 10px; border-top: 1px dashed var(--line); }
.rest-delivery .sep { width: 3px; height: 3px; border-radius: 50%; background: var(--green-200); }
.rest-delivery b { color: var(--ink-2); font-weight: 700; }
/* ============ 骨架屏 ============ */
.skeleton { position: relative; overflow: hidden; background: #EAF3EC; }
.skeleton::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.65), transparent);
transform: translateX(-100%);
animation: shimmer 1.4s infinite;
}
@keyframes shimmer { 100% { transform: translateX(100%); } }
.sk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px,1fr)); gap: 22px; }
.sk-card { background: #fff; border-radius: var(--radius); padding: 18px; display: flex; gap: 16px; box-shadow: var(--shadow-sm); }
.sk-logo { width: 96px; height: 96px; border-radius: 20px; flex-shrink: 0; }
.sk-lines { flex: 1; display: flex; flex-direction: column; gap: 10px; padding-top: 4px; }
.sk-line { height: 14px; border-radius: 7px; }
.sk-line.w70 { width: 70%; } .sk-line.w50 { width: 50%; } .sk-line.w90 { width: 90%; }
.sk-scroller { display: flex; gap: 18px; overflow: hidden; }
.sk-feat { flex: 0 0 300px; height: 260px; border-radius: var(--radius); }
.sk-cat-grid { display: grid; grid-template-columns: repeat(6,1fr); gap: 16px; }
.sk-cat { height: 130px; border-radius: var(--radius-md); }
@media (max-width: 980px) { .sk-cat-grid { grid-template-columns: repeat(3,1fr); } }
@media (max-width: 520px) { .sk-cat-grid { grid-template-columns: repeat(2,1fr); } }
/* ============ 空状态 / 错误 ============ */
.state-box { text-align: center; padding: 70px 20px; }
.state-box .ic { width: 88px; height: 88px; margin: 0 auto 18px; border-radius: 28px; background: var(--green-mist); display: grid; place-items: center; color: var(--green-300); }
.state-box .ic svg { width: 48px; height: 48px; }
.state-box h3 { font-size: 19px; color: var(--ink); font-weight: 800; margin-bottom: 8px; }
.state-box p { color: var(--muted); font-size: 14px; }
.state-box .retry { margin-top: 18px; }
/* ============ 加载更多 ============ */
.load-more-wrap { text-align: center; margin-top: 36px; }
.load-more {
display: inline-flex; align-items: center; gap: 8px;
height: 48px; padding: 0 30px; border-radius: 16px;
background: #fff; border: 1.5px solid var(--green-100);
color: var(--green-deep); font-weight: 700; font-size: 15px;
transition: all 0.22s;
}
.load-more:hover:not(:disabled) { border-color: var(--green); background: var(--green-mist); transform: translateY(-2px); }
.load-more:disabled { opacity: 0.6; cursor: default; }
.spinner { width: 16px; height: 16px; border: 2px solid var(--green-100); border-top-color: var(--green); border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
/* ============ 餐厅详情 模态 ============ */
.modal-mask {
position: fixed; inset: 0; z-index: 200;
background: rgba(27, 94, 32, 0.32);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
display: none; align-items: flex-start; justify-content: center;
padding: 30px 16px; overflow-y: auto;
opacity: 0; transition: opacity 0.25s;
}
.modal-mask.show { display: flex; opacity: 1; }
.modal {
width: 100%; max-width: 940px;
background: var(--warm-white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
transform: translateY(24px) scale(0.98); opacity: 0;
transition: transform 0.32s cubic-bezier(.34,1.2,.64,1), opacity 0.32s;
}
.modal-mask.show .modal { transform: translateY(0) scale(1); opacity: 1; }
.modal-hero {
position: relative; padding: 28px 30px 22px;
background: var(--grad-hero);
border-bottom: 1px solid var(--line);
}
.modal-hero::before {
content: ""; position: absolute; top: -60px; right: -40px; width: 200px; height: 200px;
border-radius: 50%; background: radial-gradient(circle, rgba(102,187,106,0.18), transparent 70%);
}
.modal-close {
position: absolute; top: 18px; right: 18px; z-index: 5;
width: 40px; height: 40px; border-radius: 13px;
background: rgba(255,255,255,0.85); color: var(--green-deep);
display: grid; place-items: center;
box-shadow: var(--shadow-sm);
transition: all 0.2s;
}
.modal-close:hover { background: #fff; transform: rotate(90deg); color: #C62828; }
.modal-rest { display: flex; gap: 18px; align-items: center; }
.modal-logo { width: 84px; height: 84px; border-radius: 22px; display: grid; place-items: center; position: relative; overflow: hidden; flex-shrink: 0; box-shadow: var(--shadow); }
.modal-logo .grad { position: absolute; inset: 0; }
.modal-logo svg { position: relative; width: 46px; height: 46px; color: #fff; }
.modal-rest-info { flex: 1; min-width: 0; }
.modal-rest-name { font-size: 24px; font-weight: 900; color: var(--green-deep); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.modal-rest-cat { font-size: 13px; color: var(--green); font-weight: 700; margin: 4px 0 8px; }
.modal-rest-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.modal-rest-tags .t { font-size: 11px; padding: 3px 10px; border-radius: 11px; background: #fff; color: var(--green-600); font-weight: 700; box-shadow: var(--shadow-sm); }
.modal-rest-stats { display: flex; gap: 22px; flex-wrap: wrap; font-size: 13px; }
.modal-rest-stats .item { display: flex; align-items: center; gap: 5px; color: var(--muted); }
.modal-rest-stats .item b { color: var(--green); font-weight: 800; font-size: 15px; }
.modal-notice {
margin-top: 14px; padding: 10px 14px; border-radius: 12px;
background: rgba(255,255,255,0.7); font-size: 13px; color: var(--ink-2);
display: flex; align-items: flex-start; gap: 8px;
border: 1px solid var(--green-100);
}
.modal-notice svg { width: 16px; height: 16px; color: var(--green); flex-shrink: 0; margin-top: 2px; }
.menu-body { display: flex; max-height: 60vh; min-height: 420px; }
.menu-left {
flex: 0 0 150px;
background: var(--green-mist);
padding: 14px 10px;
overflow-y: auto;
border-right: 1px solid var(--line);
}
.menu-cat {
padding: 12px 14px; border-radius: 12px;
font-size: 14px; font-weight: 700; color: var(--muted);
cursor: pointer; transition: all 0.22s;
margin-bottom: 4px; text-align: center;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.menu-cat:hover { color: var(--green); }
.menu-cat.active { background: #fff; color: var(--green-deep); box-shadow: var(--shadow-sm); }
.menu-cat .cnt { font-size: 11px; opacity: 0.6; font-weight: 600; }
.menu-right { flex: 1; overflow-y: auto; padding: 8px 18px 24px; scroll-behavior: smooth; }
.menu-section { padding-top: 16px; }
.menu-section-title { font-size: 16px; font-weight: 800; color: var(--green-deep); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; position: sticky; top: 0; background: var(--warm-white); padding: 8px 0; z-index: 2; }
.menu-section-title::before { content: ""; width: 5px; height: 18px; border-radius: 3px; background: var(--grad-green); }
.menu-section-title .cnt { font-size: 12px; color: var(--muted); font-weight: 600; }
.dish-card {
display: flex; gap: 14px; padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.dish-card:last-child { border-bottom: none; }
.dish-img {
flex-shrink: 0; width: 84px; height: 84px; border-radius: 16px;
display: grid; place-items: center; position: relative; overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.4);
}
.dish-img .grad { position: absolute; inset: 0; }
.dish-img svg { position: relative; width: 42px; height: 42px; color: rgba(255,255,255,0.92); }
.dish-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.dish-name { font-size: 15px; font-weight: 800; color: var(--ink); margin-bottom: 4px; }
.dish-desc { font-size: 12px; color: var(--muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 6px; }
.dish-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 6px; }
.dish-tags .t { font-size: 10px; padding: 2px 7px; border-radius: 8px; background: var(--green-mist); color: var(--green-600); font-weight: 700; }
.dish-foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.dish-price { display: flex; align-items: baseline; gap: 6px; }
.dish-price .now { font-size: 18px; font-weight: 900; color: var(--green); }
.dish-price .now small { font-size: 12px; }
.dish-price .old { font-size: 12px; color: #B0B8A8; text-decoration: line-through; }
.dish-rating { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 3px; }
.add-btn {
display: inline-flex; align-items: center; gap: 5px;
height: 34px; padding: 0 16px; border-radius: 17px;
background: var(--grad-green); color: #fff;
font-weight: 800; font-size: 13px;
box-shadow: 0 6px 14px rgba(45,154,108,0.32);
transition: transform 0.18s;
white-space: nowrap;
}
.add-btn:hover { transform: scale(1.05); }
.add-btn:active { transform: scale(0.96); }
.qty-stepper { display: none; align-items: center; gap: 4px; }
.qty-stepper.show { display: inline-flex; }
.qty-btn {
width: 30px; height: 30px; border-radius: 50%;
display: grid; place-items: center;
font-weight: 900; font-size: 16px;
transition: all 0.18s;
}
.qty-btn.minus { background: #fff; color: var(--green); border: 1.5px solid var(--green-200); }
.qty-btn.minus:hover { border-color: var(--green); }
.qty-btn.plus { background: var(--grad-green); color: #fff; box-shadow: 0 4px 10px rgba(45,154,108,0.3); }
.qty-btn:hover { transform: scale(1.12); }
.qty-num { min-width: 26px; text-align: center; font-weight: 800; color: var(--green-deep); font-size: 15px; }
.menu-loading { padding: 60px 20px; text-align: center; color: var(--muted); }
.menu-loading .spinner { width: 28px; height: 28px; border-width: 3px; margin-bottom: 12px; }
@media (max-width: 720px) {
.modal-hero { padding: 22px 20px 18px; }
.modal-rest { flex-direction: column; align-items: flex-start; gap: 12px; }
.menu-body { flex-direction: column; max-height: 56vh; }
.menu-left { flex: none; display: flex; gap: 8px; overflow-x: auto; overflow-y: hidden; padding: 10px 12px; border-right: none; border-bottom: 1px solid var(--line); }
.menu-cat { flex-shrink: 0; margin: 0; }
.menu-right { padding: 4px 14px 20px; }
}
/* ============ 购物车浮层 ============ */
.cart-bar {
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%) translateY(120%);
width: calc(100% - 32px); max-width: 480px;
background: #fff; border-radius: 22px;
box-shadow: var(--shadow-lg);
border: 1.5px solid var(--green-100);
padding: 12px 14px 12px 18px;
display: flex; align-items: center; gap: 14px;
z-index: 150;
transition: transform 0.35s cubic-bezier(.34,1.3,.64,1);
}
.cart-bar.show { transform: translateX(-50%) translateY(0); }
.cart-bar-icon {
position: relative; width: 50px; height: 50px; border-radius: 16px;
background: var(--grad-green); color: #fff;
display: grid; place-items: center; flex-shrink: 0;
box-shadow: 0 8px 18px rgba(45,154,108,0.35);
cursor: pointer;
}
.cart-bar-icon .cart-badge { top: -4px; right: -4px; }
.cart-bar-text { flex: 1; min-width: 0; cursor: pointer; }
.cart-bar-total { font-size: 20px; font-weight: 900; color: var(--green-deep); }
.cart-bar-total small { font-size: 13px; color: var(--green); }
.cart-bar-sub { font-size: 12px; color: var(--muted); }
.cart-bar-sub.warn { color: #E67E22; font-weight: 700; }
.cart-checkout {
height: 46px; padding: 0 26px; border-radius: 16px;
background: var(--grad-green); color: #fff;
font-weight: 800; font-size: 15px;
box-shadow: 0 8px 18px rgba(45,154,108,0.35);
transition: all 0.22s;
white-space: nowrap;
}
.cart-checkout:hover:not(:disabled) { transform: translateY(-2px); }
.cart-checkout:disabled { background: #C5D6C8; box-shadow: none; cursor: not-allowed; }
/* ============ 购物车抽屉 ============ */
.cart-drawer-mask {
position: fixed; inset: 0; z-index: 180;
background: rgba(27,94,32,0.3); backdrop-filter: blur(4px);
display: none; opacity: 0; transition: opacity 0.25s;
}
.cart-drawer-mask.show { display: block; opacity: 1; }
.cart-drawer {
position: fixed; left: 50%; bottom: 0; transform: translateX(-50%) translateY(100%);
width: 100%; max-width: 520px;
background: var(--warm-white);
border-radius: 26px 26px 0 0;
box-shadow: 0 -10px 40px rgba(27,94,32,0.2);
z-index: 190;
transition: transform 0.35s cubic-bezier(.34,1.2,.64,1);
max-height: 78vh; display: flex; flex-direction: column;
}
.cart-drawer.show { transform: translateX(-50%) translateY(0); }
.cart-drawer-head { display: flex; align-items: center; justify-content: space-between; padding: 20px 22px 14px; border-bottom: 1px solid var(--line); }
.cart-drawer-title { font-size: 18px; font-weight: 900; color: var(--green-deep); display: flex; align-items: center; gap: 8px; }
.cart-drawer-title .cnt { font-size: 13px; color: var(--muted); font-weight: 700; }
.cart-clear { font-size: 13px; color: #E57373; font-weight: 700; display: inline-flex; align-items: center; gap: 4px; }
.cart-drawer-body { overflow-y: auto; padding: 8px 18px 16px; flex: 1; }
.cart-empty { text-align: center; padding: 50px 20px; color: var(--muted); }
.cart-empty .ic { width: 70px; height: 70px; margin: 0 auto 14px; border-radius: 22px; background: var(--green-mist); display: grid; place-items: center; color: var(--green-200); }
.cart-item { display: flex; gap: 12px; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--line); }
.cart-item:last-child { border-bottom: none; }
.cart-item-img { width: 54px; height: 54px; border-radius: 14px; display: grid; place-items: center; position: relative; overflow: hidden; flex-shrink: 0; }
.cart-item-img .grad { position: absolute; inset: 0; }
.cart-item-img svg { position: relative; width: 28px; height: 28px; color: rgba(255,255,255,0.92); }
.cart-item-info { flex: 1; min-width: 0; }
.cart-item-name { font-size: 14px; font-weight: 700; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cart-item-price { font-size: 13px; color: var(--green); font-weight: 800; margin-top: 2px; }
.cart-item-stepper { display: flex; align-items: center; gap: 4px; }
.cart-drawer-foot { padding: 16px 22px 22px; border-top: 1px solid var(--line); background: #fff; border-radius: 0 0 26px 26px; }
.cart-drawer-total { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
.cart-drawer-total .lbl { font-size: 14px; color: var(--muted); }
.cart-drawer-total .val { font-size: 24px; font-weight: 900; color: var(--green-deep); }
.cart-drawer-total .val small { font-size: 14px; color: var(--green); }
.cart-drawer-checkout { width: 100%; height: 52px; border-radius: 16px; background: var(--grad-green); color: #fff; font-weight: 800; font-size: 16px; box-shadow: 0 10px 22px rgba(45,154,108,0.35); transition: transform 0.2s; }
.cart-drawer-checkout:hover:not(:disabled) { transform: translateY(-2px); }
.cart-drawer-checkout:disabled { background: #C5D6C8; box-shadow: none; cursor: not-allowed; }
/* ============ 页脚 ============ */
.footer { background: var(--green-soft); padding: 56px 0 30px; margin-top: 40px; position: relative; overflow: hidden; }
.footer::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 40px; background: var(--bg); border-radius: 0 0 50% 50% / 0 0 100% 100%; }
.footer-inner { position: relative; display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: 40px; }
.footer-brand .logo { margin-bottom: 16px; }
.footer-brand p { font-size: 14px; color: var(--muted); max-width: 280px; line-height: 1.7; }
.footer-col h4 { font-size: 15px; font-weight: 800; color: var(--green-deep); margin-bottom: 14px; }
.footer-col a { display: block; font-size: 14px; color: var(--muted); margin-bottom: 10px; transition: color 0.2s; }
.footer-col a:hover { color: var(--green); }
.footer-bottom { position: relative; margin-top: 40px; padding-top: 22px; border-top: 1px solid var(--green-100); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; font-size: 13px; color: var(--muted); }
.footer-social { display: flex; gap: 10px; }
.footer-social a { width: 38px; height: 38px; border-radius: 12px; background: #fff; display: grid; place-items: center; color: var(--green); box-shadow: var(--shadow-sm); transition: all 0.22s; }
.footer-social a:hover { transform: translateY(-3px); background: var(--grad-green); color: #fff; }
@media (max-width: 760px) { .footer-inner { grid-template-columns: 1fr 1fr; gap: 30px; } .footer-brand { grid-column: 1 / -1; } }
/* ============ Toast ============ */
.toast-wrap { position: fixed; top: 86px; left: 50%; transform: translateX(-50%); z-index: 300; display: flex; flex-direction: column; gap: 10px; align-items: center; pointer-events: none; }
.toast {
background: var(--grad-green); color: #fff;
padding: 12px 22px; border-radius: 14px;
font-size: 14px; font-weight: 700;
box-shadow: 0 12px 30px rgba(45,154,108,0.4);
display: flex; align-items: center; gap: 8px;
opacity: 0; transform: translateY(-16px) scale(0.95);
transition: all 0.32s cubic-bezier(.34,1.4,.64,1);
max-width: 90vw;
}
.toast.show { opacity: 1; transform: translateY(0) scale(1); }
.toast.warn { background: linear-gradient(135deg, #FF9800, #FF7043); box-shadow: 0 12px 30px rgba(255,112,67,0.4); }
.toast svg { width: 18px; height: 18px; }
/* 移动端导航 */
@media (max-width: 640px) {
.nav-inner { gap: 10px; height: 62px; }
.logo-text { font-size: 18px; }
.logo-mark { width: 38px; height: 38px; }
.search-wrap { max-width: none; }
.nav-cta { display: none; }
.section { padding: 40px 0; }
}
/* ===== LANGUAGE SWITCHER ===== */
.lang-switcher{position:relative;margin-left:8px}
.lang-btn{
display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:12px;
font-size:13px;font-weight:600;color:var(--muted);cursor:pointer;
background:#fff;border:1.5px solid var(--line);
transition:all .25s;
}
.lang-btn:hover{border-color:var(--green);color:var(--green);background:var(--green-mist)}
.lang-btn svg{width:16px;height:16px}
.lang-dropdown{
position:absolute;top:calc(100% + 8px);right:0;min-width:160px;
background:#fff;border:1.5px solid var(--green-100);border-radius:14px;
box-shadow:var(--shadow);overflow:hidden;
opacity:0;transform:translateY(-8px);pointer-events:none;
transition:all .25s;z-index:100;
}
.lang-dropdown.open{opacity:1;transform:none;pointer-events:auto}
.lang-option{
display:flex;align-items:center;gap:10px;padding:12px 16px;
font-size:14px;color:var(--muted);cursor:pointer;transition:all .2s;
}
.lang-option:hover{background:var(--green-mist);color:var(--green-deep)}
.lang-option.active{color:var(--green);font-weight:700}
.lang-option .check{width:16px;height:16px;opacity:0;transition:opacity .2s}
.lang-option.active .check{opacity:1}
</style>
</head>
<body>
<!-- ===== 顶部导航 ===== -->
<header class="navbar">
<div class="container nav-inner">
<div class="logo" onclick="scrollToTop()">
<div class="logo-mark" id="logoMark"></div>
<div class="logo-text" data-i18n="logo_text">绿野<span>鲜送</span></div>
</div>
<div class="search-wrap">
<input id="searchInput" class="search-box" type="text" data-i18n-placeholder="search_placeholder" placeholder="搜索餐厅或美食,如 轻食、寿司、奶茶…" autocomplete="off" />
<button class="search-btn" id="searchBtn" aria-label="搜索"></button>
</div>
<div class="nav-actions">
<button class="nav-cta" onclick="scrollToRestaurants()">
<span id="navCtaIcon"></span> <span data-i18n="nav_cta">立即点餐</span>
</button>
<div class="lang-switcher" id="langSwitcher">
<button class="lang-btn" id="langBtn" onclick="toggleLangDropdown()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20z"/></svg>
<span id="langLabel">中文</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="lang-dropdown" id="langDropdown">
<div class="lang-option" data-lang="zh-CN" onclick="switchLang('zh-CN')">
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>简体中文</span>
</div>
<div class="lang-option" data-lang="zh-TW" onclick="switchLang('zh-TW')">
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>繁體中文</span>
</div>
<div class="lang-option" data-lang="en" onclick="switchLang('en')">
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>English</span>
</div>
</div>
</div>
<button class="cart-btn" id="cartBtn" aria-label="购物车">
<span id="cartBtnIcon"></span>
<span class="cart-badge" id="cartBadge">0</span>
</button>
</div>
</div>
</header>
<!-- ===== Hero ===== -->
<section class="hero">
<div class="hero-leaf leaf-1" id="leaf1"></div>
<div class="hero-leaf leaf-2" id="leaf2"></div>
<div class="hero-leaf leaf-3" id="leaf3"></div>
<div class="container">
<div class="hero-card">
<div class="hero-text">
<span class="hero-tag"><span class="dot"></span> <span data-i18n="hero_tag">当日鲜配 · 0 防腐剂</span></span>
<h1 class="hero-title"><span data-i18n="hero_title_1">新鲜好味</span> <em>·</em> <span data-i18n="hero_title_2">绿色送达</span></h1>
<p class="hero-desc" data-i18n="hero_desc">甄选本地优质餐厅,从农场到餐桌的轻盈之旅。低卡轻食、匠心料理,用一份自然的绿意,点亮每一餐。</p>
<div class="hero-actions">
<button class="btn-primary" onclick="scrollToRestaurants()">
<span id="heroCtaIcon"></span> <span data-i18n="hero_cta1">开始点餐</span>
</button>
<button class="btn-ghost" onclick="scrollToCategories()"><span data-i18n="hero_cta2">浏览分类</span></button>
</div>
<div class="hero-stats">
<div class="hero-stat"><div class="num" id="statRest">12+</div><div class="lbl" data-i18n="stat_restaurants">精选餐厅</div></div>
<div class="hero-stat"><div class="num">30min</div><div class="lbl" data-i18n="stat_delivery">平均送达</div></div>
<div class="hero-stat"><div class="num">4.7★</div><div class="lbl" data-i18n="stat_rating">平均评分</div></div>
</div>
</div>
<div class="hero-visual">
<div class="hero-bowl" id="heroBowl"></div>
<div class="hero-float f1"><span class="ic" id="floatIc1"></span><span data-i18n="float_1">低卡轻食</span></div>
<div class="hero-float f2"><span class="ic" id="floatIc2"></span><span data-i18n="float_2">新鲜直送</span></div>
<div class="hero-float f3"><span class="ic" id="floatIc3"></span><span data-i18n="float_3">绿色包装</span></div>
</div>
</div>
</div>
</section>
<!-- ===== 分类网格 ===== -->
<section class="section" id="categoriesSection">
<div class="container">
<div class="section-head">
<div class="section-title">
<span class="kicker">CATEGORIES</span>
<h2 data-i18n="cat_title">美食分类</h2>
<p data-i18n="cat_desc">挑你所爱,一键直达心仪风味</p>
</div>
</div>
<div class="cat-grid" id="catGrid"></div>
</div>
</section>
<!-- ===== 推荐餐厅 ===== -->
<section class="section" id="featuredSection" style="padding-top:0">
<div class="container">
<div class="section-head">
<div class="section-title">
<span class="kicker">FEATURED</span>
<h2 data-i18n="feat_title">人气推荐</h2>
<p data-i18n="feat_desc">本月销量领先,大家都在点</p>
</div>
<a class="link-more" href="javascript:scrollToRestaurants()"><span data-i18n="feat_more">查看全部 →</span></a>
</div>
<div class="featured-scroller" id="featuredScroller"></div>
</div>
</section>
<!-- ===== 餐厅列表 ===== -->
<section class="section" id="restaurantsSection" style="padding-top:0">
<div class="container">
<div class="section-head">
<div class="section-title">
<span class="kicker">RESTAURANTS</span>
<h2 data-i18n="rest_title">全部餐厅</h2>
<p id="listSubtitle" data-i18n="rest_subtitle">为你找到附近优质好店</p>
</div>
</div>
<div class="toolbar">
<div class="sort-group" id="sortGroup">
<button class="sort-btn active" data-sort="" data-i18n="sort_comprehensive">综合</button>
<button class="sort-btn" data-sort="rating" data-i18n="sort_rating">评分</button>
<button class="sort-btn" data-sort="sales" data-i18n="sort_sales">销量</button>
<button class="sort-btn" data-sort="delivery_time" data-i18n="sort_delivery">送达</button>
</div>
<button class="chip-clear" id="chipClear">
<span id="chipClearIcon"></span> <span id="chipClearText" data-i18n="chip_clear">清除筛选</span>
</button>
<div class="result-info"><span data-i18n="result_prefix">共</span> <b id="resultCount">0</b> <span data-i18n="result_suffix">家餐厅</span></div>
</div>
<div id="restListWrap">
<div class="rest-grid" id="restGrid"></div>
</div>
<div class="load-more-wrap" id="loadMoreWrap" style="display:none">
<button class="load-more" id="loadMoreBtn" data-i18n="load_more">加载更多</button>
</div>
</div>
</section>
<!-- ===== 页脚 ===== -->
<footer class="footer">
<div class="container">
<div class="footer-inner">
<div class="footer-brand">
<div class="logo">
<div class="logo-mark" id="footerLogo"></div>
<div class="logo-text">绿野<span>鲜送</span></div>
</div>
<p data-i18n="footer_brand_desc">新鲜好味,绿色送达。致力于连接本地优质餐厅与健康食材,让每一餐都自然、轻盈、安心。</p>
<div class="footer-social" style="margin-top:16px">
<a href="javascript:void(0)" aria-label="微信" id="socialWx"></a>
<a href="javascript:void(0)" aria-label="微博" id="socialWb"></a>
<a href="javascript:void(0)" aria-label="邮箱" id="socialMail"></a>
</div>
</div>
<div class="footer-col">
<h4 data-i18n="footer_about">关于我们</h4>
<a href="javascript:void(0)"><span data-i18n="footer_story">品牌故事</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_green">绿色承诺</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_join">加入我们</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_partner">联系合作</span></a>
</div>
<div class="footer-col">
<h4 data-i18n="footer_help">帮助中心</h4>
<a href="javascript:void(0)"><span data-i18n="footer_guide">下单指南</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_delivery_info">配送说明</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_refund">退款规则</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_faq">常见问题</span></a>
</div>
<div class="footer-col">
<h4 data-i18n="footer_biz">商家合作</h4>
<a href="javascript:void(0)"><span data-i18n="footer_platform">入驻平台</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_backend">商家后台</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_marketing">营销推广</span></a>
<a href="javascript:void(0)"><span data-i18n="footer_api">开放接口</span></a>
</div>
</div>
<div class="footer-bottom">
<span>data-i18n="copyright">© 2026 绿野鲜送 FreshGreen · 用心连接每一份新鲜</span>
<span>data-i18n="copyright_slogan">新鲜 · 健康 · 自然 · 轻盈</span>
</div>
</div>
</footer>
<!-- ===== 餐厅详情模态 ===== -->
<div class="modal-mask" id="modalMask">
<div class="modal" id="modal">
<div class="modal-hero" id="modalHero"></div>
<div class="menu-body" id="menuBody"></div>
</div>
</div>
<!-- ===== 购物车抽屉 ===== -->
<div class="cart-drawer-mask" id="cartDrawerMask"></div>
<div class="cart-drawer" id="cartDrawer">
<div class="cart-drawer-head">
<div class="cart-drawer-title">
<span id="drawerTitleIcon"></span> <span data-i18n="drawer_title">购物车</span> <span class="cnt" id="drawerCount"></span>
</div>
<button class="cart-clear" id="cartClearBtn">
<span id="clearIcon"></span> 清空
</button>
</div>
<div class="cart-drawer-body" id="cartDrawerBody"></div>
<div class="cart-drawer-foot" id="cartDrawerFoot"></div>
</div>
<!-- ===== 购物车浮层 ===== -->
<div class="cart-bar" id="cartBar">
<div class="cart-bar-icon" id="cartBarIcon">
<span id="cartBarIconSvg"></span>
<span class="cart-badge" id="cartBarBadge">0</span>
</div>
<div class="cart-bar-text" id="cartBarText">
<div class="cart-bar-total"><small>¥</small><span id="cartBarTotal">0</span></div>
<div class="cart-bar-sub" id="cartBarSub" data-i18n="cart_empty">购物车是空的</div>
</div>
<button class="cart-checkout" id="cartCheckoutBtn" data-i18n="cart_checkout">去结算</button>
</div>
<!-- ===== Toast ===== -->
<div class="toast-wrap" id="toastWrap"></div>
<script>
/* =========================================================
i18n 国际化引擎
========================================================= */
const I18N_LOCALES = {
'zh-CN': {
logo_text: '绿野鲜送',
search_placeholder: '搜索餐厅或美食,如 轻食、寿司、奶茶…',
nav_cta: '立即点餐',
hero_tag: '当日鲜配 · 0 防腐剂',
hero_title_1: '新鲜好味',
hero_title_2: '绿色送达',
hero_desc: '甄选本地优质餐厅,从农场到餐桌的轻盈之旅。低卡轻食、匠心料理,用一份自然的绿意,点亮每一餐。',
hero_cta1: '开始点餐',
hero_cta2: '浏览分类',
stat_restaurants: '精选餐厅',
stat_delivery: '平均送达',
stat_rating: '平均评分',
float_1: '低卡轻食',
float_2: '新鲜直送',
float_3: '绿色包装',
cat_title: '美食分类',
cat_desc: '挑你所爱,一键直达心仪风味',
cat_all: '全部',
feat_title: '人气推荐',
feat_desc: '本月销量领先,大家都在点',
feat_more: '查看全部 →',
rest_title: '全部餐厅',
rest_subtitle: '为你找到附近优质好店',
sort_comprehensive: '综合',
sort_rating: '评分',
sort_sales: '销量',
sort_delivery: '送达',
chip_clear: '清除筛选',
result_prefix: '共',
result_suffix: '家餐厅',
load_more: '加载更多',
footer_brand_desc: '新鲜好味,绿色送达。致力于连接本地优质餐厅与健康食材,让每一餐都自然、轻盈、安心。',
footer_about: '关于我们',
footer_story: '品牌故事',
footer_green: '绿色承诺',
footer_join: '加入我们',
footer_partner: '联系合作',
footer_help: '帮助中心',
footer_guide: '下单指南',
footer_delivery_info: '配送说明',
footer_refund: '退款规则',
footer_faq: '常见问题',
footer_biz: '商家合作',
footer_platform: '入驻平台',
footer_backend: '商家后台',
footer_marketing: '营销推广',
footer_api: '开放接口',
copyright: '© 2026 绿野鲜送 FreshGreen · 用心连接每一份新鲜',
copyright_slogan: '新鲜 · 健康 · 自然 · 轻盈',
drawer_title: '购物车',
cart_clear: '清空',
cart_empty: '购物车是空的',
cart_checkout: '去结算',
status_open: '营业中',
status_closed: '休息',
monthly_sales: 't('monthly_sales')',
delivery_time_suffix: t('delivery_time_suffix'),
min_order_prefix: '起送',
delivery_prefix: '配送',
rating_label: '评分',
all_dishes: '全部菜品',
dish_count_suffix: '道',
no_restaurants: '暂无符合条件的餐厅',
no_restaurants_hint: '试试切换分类或调整搜索关键词',
load_failed: '加载失败',
load_failed_hint: '请检查网络后刷新',
cat_load_failed: '分类加载失败',
menu_load_failed: '菜单加载失败',
loading: '加载中…',
preparing_menu: '正在准备菜单…',
no_dishes: '暂无菜品',
no_dishes_hint: '该餐厅还未上架菜品',
added_to_cart: '已加入',
cart_cleared: '购物车已清空',
cart_empty_warn: '购物车还是空的',
switched_restaurant: '已切换餐厅,购物车已更新',
order_success: '下单成功!共',
order_delivery_hint: '预计 30 分钟送达',
subtotal_with_delivery: '合计 (含配送费',
min_order_warn: '还差',
min_order_reached: '满起送价',
from_restaurant: '来自',
delivery_fee_label: '另需配送费',
meets_min_order: '已满起送价',
cart_drawer_empty: '购物车空空如也',
cart_drawer_empty_hint: '快去挑选心仪美食吧',
items_label: '件',
price_per_serving: '/ 份',
current_filter: '当前筛选:',
no_filter: '为你找到附近优质好店',
clear_prefix: '清除:',
clear_all: '清除筛选',
new_total_label: '新合计',
checkout_btn: '去结算',
},
'en': {
logo_text: 'FreshGreen',
search_placeholder: 'Search restaurants or food, e.g. salad, sushi, bubble tea…',
nav_cta: 'Order Now',
hero_tag: 'Same-Day Delivery · Zero Preservatives',
hero_title_1: 'Fresh Taste',
hero_title_2: 'Green Delivery',
hero_desc: 'Curating local quality restaurants for a light farm-to-table journey. Low-calorie meals, artisan dishes — a touch of natural green for every meal.',
hero_cta1: 'Start Ordering',
hero_cta2: 'Browse Categories',
stat_restaurants: 'Top Restaurants',
stat_delivery: 'Avg Delivery',
stat_rating: 'Avg Rating',
float_1: 'Low-Cal Meals',
float_2: 'Fresh Delivery',
float_3: 'Eco Packaging',
cat_title: 'Food Categories',
cat_desc: 'Find what you love, one tap to your favorite flavor',
cat_all: 'All',
feat_title: 'Popular Picks',
feat_desc: 'Top sellers this month, everyone is ordering',
feat_more: 'View All →',
rest_title: 'All Restaurants',
rest_subtitle: 'Quality restaurants near you',
sort_comprehensive: 'Default',
sort_rating: 'Rating',
sort_sales: 'Sales',
sort_delivery: 'Delivery',
chip_clear: 'Clear Filter',
result_prefix: '',
result_suffix: 'restaurants',
load_more: 'Load More',
footer_brand_desc: 'Fresh taste, green delivery. Connecting local quality restaurants and healthy ingredients for a natural, light, and safe meal every time.',
footer_about: 'About',
footer_story: 'Our Story',
footer_green: 'Green Promise',
footer_join: 'Join Us',
footer_partner: 'Contact',
footer_help: 'Help Center',
footer_guide: 'Ordering Guide',
footer_delivery_info: 'Delivery Info',
footer_refund: 'Refund Policy',
footer_faq: 'FAQ',
footer_biz: 'Partners',
footer_platform: 'Join Platform',
footer_backend: 'Merchant Portal',
footer_marketing: 'Marketing',
footer_api: 'Open API',
copyright: '© 2026 FreshGreen · Connecting freshness with care',
copyright_slogan: 'Fresh · Healthy · Natural · Light',
drawer_title: 'Cart',
cart_clear: 'Clear',
cart_empty: 'Cart is empty',
cart_checkout: 'Checkout',
status_open: 'Open',
status_closed: 'Closed',
monthly_sales: 'sales',
delivery_time_suffix: 'min',
min_order_prefix: 'Min',
delivery_prefix: 'Delivery',
rating_label: 'Rating',
all_dishes: 'All Dishes',
dish_count_suffix: '',
no_restaurants: 'No restaurants found',
no_restaurants_hint: 'Try different categories or search keywords',
load_failed: 'Load Failed',
load_failed_hint: 'Please check your network',
cat_load_failed: 'Failed to load categories',
menu_load_failed: 'Failed to load menu',
loading: 'Loading…',
preparing_menu: 'Preparing menu…',
no_dishes: 'No dishes available',
no_dishes_hint: 'This restaurant has no dishes yet',
added_to_cart: 'Added',
cart_cleared: 'Cart cleared',
cart_empty_warn: 'Cart is still empty',
switched_restaurant: 'Restaurant changed, cart updated',
order_success: 'Order placed! Total',
order_delivery_hint: 'Estimated 30 min delivery',
subtotal_with_delivery: 'Total (incl. delivery',
min_order_warn: 'Still need',
min_order_reached: 'to reach minimum',
from_restaurant: 'From',
delivery_fee_label: 'delivery fee',
meets_min_order: 'Minimum met',
cart_drawer_empty: 'Cart is empty',
cart_drawer_empty_hint: 'Pick some delicious food!',
items_label: 'items',
price_per_serving: '/ serving',
current_filter: 'Filtering:',
no_filter: 'Quality restaurants near you',
clear_prefix: 'Clear: ',
clear_all: 'Clear Filter',
new_total_label: 'New total',
checkout_btn: 'Checkout',
},
'zh-TW': {
logo_text: '綠野鮮送',
search_placeholder: '搜尋餐廳或美食,如 輕食、壽司、奶茶…',
nav_cta: '立即點餐',
hero_tag: '當日鮮配 · 0 防腐劑',
hero_title_1: '新鮮好味',
hero_title_2: '綠色送達',
hero_desc: '甄選本地優質餐廳,從農場到餐桌的輕盈之旅。低卡輕食、匠心料理,用一份自然的綠意,點亮每一餐。',
hero_cta1: '開始點餐',
hero_cta2: '瀏覽分類',
stat_restaurants: '精選餐廳',
stat_delivery: '平均送達',
stat_rating: '平均評分',
float_1: '低卡輕食',
float_2: '新鮮直送',
float_3: '綠色包裝',
cat_title: '美食分類',
cat_desc: '挑你所愛,一鍵直達心儀風味',
cat_all: '全部',
feat_title: '人氣推薦',
feat_desc: '本月銷量領先,大家都在點',
feat_more: '查看全部 →',
rest_title: '全部餐廳',
rest_subtitle: '為你找到附近優質好店',
sort_comprehensive: '綜合',
sort_rating: '評分',
sort_sales: '銷量',
sort_delivery: '送達',
chip_clear: '清除篩選',
result_prefix: '共',
result_suffix: '家餐廳',
load_more: '載入更多',
footer_brand_desc: '新鮮好味,綠色送達。致力於連接本地優質餐廳與健康食材,讓每一餐都自然、輕盈、安心。',
footer_about: '關於我們',
footer_story: '品牌故事',
footer_green: '綠色承諾',
footer_join: '加入我們',
footer_partner: '聯繫合作',
footer_help: '幫助中心',
footer_guide: '下單指南',
footer_delivery_info: '配送說明',
footer_refund: '退款規則',
footer_faq: '常見問題',
footer_biz: '商家合作',
footer_platform: '入駐平台',
footer_backend: '商家後台',
footer_marketing: '行銷推廣',
footer_api: '開放接口',
copyright: '© 2026 綠野鮮送 FreshGreen · 用心連接每一份新鮮',
copyright_slogan: '新鮮 · 健康 · 自然 · 輕盈',
drawer_title: '購物車',
cart_clear: '清空',
cart_empty: '購物車是空的',
cart_checkout: '去結算',
status_open: '營業中',
status_closed: '休息',
monthly_sales: '月售',
delivery_time_suffix: '分鐘',
min_order_prefix: '起送',
delivery_prefix: '配送',
rating_label: '評分',
all_dishes: '全部菜品',
dish_count_suffix: '道',
no_restaurants: '暫無符合條件的餐廳',
no_restaurants_hint: '試試切換分類或調整搜尋關鍵詞',
load_failed: '載入失敗',
load_failed_hint: '請檢查網路後重新整理',
cat_load_failed: '分類載入失敗',
menu_load_failed: '菜單載入失敗',
loading: '載入中…',
preparing_menu: '正在準備菜單…',
no_dishes: '暫無菜品',
no_dishes_hint: '該餐廳還未上架菜品',
added_to_cart: '已加入',
cart_cleared: '購物車已清空',
cart_empty_warn: '購物車還是空的',
switched_restaurant: '已切換餐廳,購物車已更新',
order_success: '下單成功!共',
order_delivery_hint: '預計 30 分鐘送達',
subtotal_with_delivery: '合計 (含配送費',
min_order_warn: '還差',
min_order_reached: '滿起送價',
from_restaurant: '來自',
delivery_fee_label: '另需配送費',
meets_min_order: '已滿起送價',
cart_drawer_empty: '購物車空空如也',
cart_drawer_empty_hint: '快去挑選心儀美食吧',
items_label: '件',
price_per_serving: '/ 份',
current_filter: '當前篩選:',
no_filter: '為你找到附近優質好店',
clear_prefix: '清除:',
clear_all: '清除篩選',
new_total_label: '新合計',
checkout_btn: '去結算',
}
};
const LANG_LABELS = {'zh-CN':'中文','en':'EN','zh-TW':'繁中'};
let currentLang = (function(){
try{
var u=new URLSearchParams(location.search).get('lang');
if(u&&I18N_LOCALES[u]) return u;
}catch(e){}
try{var s=localStorage.getItem('lang');if(s&&I18N_LOCALES[s]) return s;}catch(e){}
var b=navigator.language||navigator.userLanguage||'';
if(b.startsWith('zh-TW')||b.startsWith('zh-Hant')) return 'zh-TW';
if(b.startsWith('zh')) return 'zh-CN';
return 'en';
})();
function t(key){return (I18N_LOCALES[currentLang]&&I18N_LOCALES[currentLang][key])||(I18N_LOCALES['zh-CN'][key])||key;}
function applyI18n(){
document.querySelectorAll('[data-i18n]').forEach(function(el){
var key=el.getAttribute('data-i18n');
if(key==='logo_text'){el.innerHTML=t(key);}
else{el.textContent=t(key);}
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function(el){
var key=el.getAttribute('data-i18n-placeholder');
el.placeholder=t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(function(el){
var key=el.getAttribute('data-i18n-title');
el.title=t(key);
});
document.documentElement.lang=currentLang==='zh-TW'?'zh-TW':currentLang==='zh-CN'?'zh-CN':'en';
document.getElementById('langLabel').textContent=LANG_LABELS[currentLang]||currentLang;
document.querySelectorAll('.lang-option').forEach(function(opt){
opt.classList.toggle('active',opt.getAttribute('data-lang')===currentLang);
});
}
function switchLang(lang){
if(!I18N_LOCALES[lang]) return;
currentLang=lang;
try{localStorage.setItem('lang',lang);}catch(e){}
/* --- I18N: language change handler --- */
var onLangChange = function(){
data._idx = {}; buildIdx(); updateFilter(); renderCategories(); renderFeatured(); renderRestaurants(true);
var u = currentRest && restaurants[currentRest];
if(u){
restDetailName.textContent = u.name;
restDetailBadge.textContent = t(u.open?'status_open':'status_closed');
restDetailBadge.className = 'badge '+(u.open?'open':'closed');
restDetailRating.textContent = u.rating;
restDetailSales.textContent = t('monthly_sales')+' '+u.monthly_sales;
}
applyI18n();
};
applyI18n();
if(typeof onLangChange==='function') onLangChange(lang);
document.getElementById('langDropdown').classList.remove('open');
}
function toggleLangDropdown(){
document.getElementById('langDropdown').classList.toggle('open');
}
document.addEventListener('click',function(e){
var sw=document.getElementById('langSwitcher');
if(sw&&!sw.contains(e.target)) document.getElementById('langDropdown').classList.remove('open');
});
/* ============================================================
绿野鲜送 — 前端逻辑
============================================================ */
(function () {
"use strict";
/* ---------- SVG 图标库 ---------- */
const S = (path, vb) => `<svg viewBox="${vb || '0 0 24 24'}" fill="none" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
const ICONS = {
logo: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 19c0-7 5-13 14-14 0 9-6 15-14 14Z" fill="#fff"/><path d="M5 19c2-4 5-7 9-9" stroke="#2D9A6C" stroke-width="1.6" stroke-linecap="round"/></svg>`,
search: `<svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="#fff" stroke-width="2.2"/><path d="m20 20-3.2-3.2" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg>`,
cart: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 7h14l-1.2 10.5a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8L5 7Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M9 10V7a3 3 0 0 1 6 0v3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>`,
bag: `<svg viewBox="0 0 24 24" fill="none"><path d="M6 8h12l-1 11.5a1.5 1.5 0 0 1-1.5 1.4h-9A1.5 1.5 0 0 1 5 19.5L6 8Z" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M9 8V6.5a3 3 0 0 1 6 0V8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>`,
star: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17.9 6.8 19.6l1-5.8-4.3-4.1 5.9-.9L12 3.5Z"/></svg>`,
clock: `<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" stroke-width="1.8"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
pin: `<svg viewBox="0 0 24 24" fill="none"><path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="10" r="2.6" stroke="currentColor" stroke-width="1.8"/></svg>`,
truck: `<svg viewBox="0 0 24 24" fill="none"><path d="M3 6h11v9H3V6Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M14 9h4l3 3v3h-7V9Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="7" cy="18" r="2" stroke="currentColor" stroke-width="1.8"/><circle cx="17" cy="18" r="2" stroke="currentColor" stroke-width="1.8"/></svg>`,
fire: `<svg viewBox="0 0 24 24" fill="none"><path d="M12 3c.5 3-2 4-2 6.5 0 1 .8 2 1.5 2 .8 0 1.5-1 1.5-2.2C14.5 11 16 12.5 16 15a4 4 0 0 1-8 0c0-3.5 4-5 4-12Z" fill="currentColor"/></svg>`,
close: `<svg viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>`,
plus: `<svg viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg>`,
minus: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg>`,
trash: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 7h14M9 7V5.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1V7m-9 0 .8 12a1.5 1.5 0 0 0 1.5 1.4h5.4a1.5 1.5 0 0 0 1.5-1.4L18 7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
bowl: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 11h16a8 8 0 0 1-16 0Z" fill="currentColor"/><path d="M4 11a8 8 0 0 0 16 0H4Z" stroke="currentColor" stroke-width="1.6"/><path d="M2 11h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 5c0 1.5 1 2 1 3.5M13 4c0 1.5 1 2 1 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
megaphone: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 10v4a1 1 0 0 0 1 1h2l9 4V5L7 9H5a1 1 0 0 0-1 1Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M19 9a3 3 0 0 1 0 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
leaf: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 19c0-8 6-14 14-14 0 8-6 14-14 14Z" fill="currentColor" opacity="0.85"/><path d="M5 19c3-5 7-8 11-10" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>`,
arrow: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
sparkle: `<svg viewBox="0 0 24 24" fill="none"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" fill="currentColor"/></svg>`,
check: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 12.5l4.5 4.5L19 7" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
warn: `<svg viewBox="0 0 24 24" fill="none"><path d="M12 4l9 16H3L12 4Z" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M12 10v4" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/><circle cx="12" cy="17" r="1.2" fill="#fff"/></svg>`,
empty: `<svg viewBox="0 0 24 24" fill="none"><path d="M6 9h12l-1 10a1.5 1.5 0 0 1-1.5 1.4h-7A1.5 1.5 0 0 1 7 19L6 9Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M9 9V7a3 3 0 0 1 6 0v2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 14h6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-dasharray="2 2"/></svg>`,
error: `<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M12 7v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="16" r="1.2" fill="currentColor"/></svg>`,
// 社交
wx: `<svg viewBox="0 0 24 24" fill="none"><path d="M9 4C5 4 2 6.8 2 10.2c0 1.9 1 3.6 2.6 4.7L4 17l2.5-1.3c.8.2 1.6.3 2.5.3.3 0 .6 0 .9-.1-.2-.5-.2-1-.2-1.5 0-3 2.9-5.4 6.4-5.4h.6C16 6 12.8 4 9 4Z" fill="currentColor"/><path d="M22 14.5c0-2.5-2.5-4.6-5.5-4.6S11 12 11 14.5s2.5 4.6 5.5 4.6c.6 0 1.3-.1 1.9-.3L20.5 20l-.4-1.6c1.1-.8 1.9-2.1 1.9-3.9Z" fill="currentColor"/></svg>`,
wb: `<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M7 13c1.5 1.5 4 1.5 5.5 0M9 9.5c0 .6-.4 1-1 1s-1-.4-1-1 .4-1 1-1 1 .4 1 1Z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
mail: `<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="2.5" stroke="currentColor" stroke-width="1.8"/><path d="m4 7 8 6 8-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`
};
// 分类图标映射(按 icon 字段 / 关键词)
const CAT_ICONS = {
chinese: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 10a8 8 0 0 0 16 0H4Z" fill="currentColor"/><path d="M2 10h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 5c0 1.4.9 1.9.9 3.3M13 4.5c0 1.4.9 1.9.9 3.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
japanese: `<svg viewBox="0 0 24 24" fill="none"><path d="M3 12c0-3.9 4-7 9-7s9 3.1 9 7-4 7-9 7-9-3.1-9-7Z" stroke="currentColor" stroke-width="1.8"/><ellipse cx="9" cy="12" rx="2" ry="3" fill="currentColor"/><ellipse cx="15" cy="12" rx="2" ry="3" fill="currentColor"/></svg>`,
western: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 11c0-3 1.8-5 8-5s8 2 8 5H4Z" fill="currentColor"/><rect x="4" y="12" width="16" height="2.2" rx="1.1" fill="currentColor"/><rect x="5" y="15.5" width="14" height="2.2" rx="1.1" fill="currentColor"/><path d="M6 18.5h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
hotpot: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 11h16l-1 6a2 2 0 0 1-2 1.8H7A2 2 0 0 1 5 17l-1-6Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M2 11h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 6c0-1 .5-1.5.5-2.5M12 5c0-1 .5-1.5.5-2.5M15 6c0-1 .5-1.5.5-2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
dessert: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 10h14l-1.2 8.5a1.5 1.5 0 0 1-1.5 1.3H7.7a1.5 1.5 0 0 1-1.5-1.3L5 10Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 10h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="9" cy="6" r="1.4" fill="currentColor"/><circle cx="12" cy="4.5" r="1.4" fill="currentColor"/><circle cx="15" cy="6" r="1.4" fill="currentColor"/></svg>`,
healthy: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 19c0-8 6-14 14-14 0 8-6 14-14 14Z" fill="currentColor" opacity="0.9"/><path d="M5 19c3-5 7-8 11-10" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>`,
default: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 11a8 8 0 0 0 16 0H4Z" fill="currentColor"/><path d="M2 11h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`
};
// 菜品图标(按标签/名称关键词)
const DISH_ICONS = {
salad: `<svg viewBox="0 0 24 24" fill="none"><path d="M3 12a9 9 0 0 0 18 0H3Z" stroke="currentColor" stroke-width="1.7"/><path d="M2 12h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M8 7c0 1.6 1 2 1 3.5M12 5c0 1.6 1 2 1 3.5M16 7c0 1.6 1 2 1 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
bowl: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 11a8 8 0 0 0 16 0H4Z" fill="currentColor"/><path d="M2 11h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 5c0 1.4.9 1.9.9 3.3M13 4.5c0 1.4.9 1.9.9 3.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
cup: `<svg viewBox="0 0 24 24" fill="none"><path d="M6 8h12l-1 11a1.5 1.5 0 0 1-1.5 1.4h-7A1.5 1.5 0 0 1 7 19L6 8Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M6 11h12" stroke="currentColor" stroke-width="1.6"/><path d="M10 5c0-1 .8-1.3.8-2M14 5c0-1 .8-1.3.8-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
meat: `<svg viewBox="0 0 24 24" fill="none"><path d="M6 14c-2 0-3-1.5-3-3s1-3 3-3c1 0 2 .5 2.5 1.5C10 7.5 12 7 14 8s3 3 3 5-2 4-4 4c-1.5 0-2.8-.7-3.5-2C8.5 16 7 16 6 14Z" fill="currentColor"/><path d="M16 6c1 0 1.5 1 1.5 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
fish: `<svg viewBox="0 0 24 24" fill="none"><path d="M3 12c3-4 7-5 11-5 3 0 6 2 7 5-1 3-4 5-7 5-4 0-8-1-11-5Z" fill="currentColor"/><path d="M3 12 6 9M3 12l3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="16" cy="11" r="1" fill="#fff"/></svg>`,
rice: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 11a7 7 0 0 0 14 0H5Z" fill="currentColor"/><path d="M3 11h18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 6c0 1 .6 1.4.6 2.4M12 5.5c0 1 .6 1.4.6 2.4M15 6c0 1 .6 1.4.6 2.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
cake: `<svg viewBox="0 0 24 24" fill="none"><path d="M5 13c0-2 2-3 7-3s7 1 7 3v6H5v-6Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 16c2 0 3-1 3-2M12 16c2 0 3-1 3-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M12 4.5c0 .8-.5 1-.5 1.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="11.5" cy="4" r="1" fill="currentColor"/></svg>`,
default: `<svg viewBox="0 0 24 24" fill="none"><path d="M4 11a8 8 0 0 0 16 0H4Z" fill="currentColor"/><path d="M2 11h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 5c0 1.4.9 1.9.9 3.3M13 4.5c0 1.4.9 1.9.9 3.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`
};
// 为餐厅/菜品分配渐变色(按 id 取模,保证同一家稳定)
const GRADIENTS = [
{ id: 'g1', css: 'linear-gradient(135deg,#2D9A6C,#66BB6A)' },
{ id: 'g2', css: 'linear-gradient(135deg,#26A69A,#80CBC4)' },
{ id: 'g3', css: 'linear-gradient(135deg,#43A047,#A5D6A7)' },
{ id: 'g4', css: 'linear-gradient(135deg,#558B2F,#AED581)' },
{ id: 'g5', css: 'linear-gradient(135deg,#00897B,#4DB6AC)' },
{ id: 'g6', css: 'linear-gradient(135deg,#388E3C,#81C784)' },
{ id: 'g7', css: 'linear-gradient(135deg,#7CB342,#C5E1A5)' },
{ id: 'g8', css: 'linear-gradient(135deg,#2E7D32,#66BB6A)' }
];
const gradFor = (id) => GRADIENTS[(Number(id) || 0) % GRADIENTS.length];
/* ---------- 工具函数 ---------- */
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const API = '/api/v1/food';
const parseTags = (str) => { try { const v = JSON.parse(str); return Array.isArray(v) ? v : []; } catch (e) { return []; } };
const money = (n) => { n = Number(n) || 0; return n % 1 === 0 ? String(n) : n.toFixed(2).replace(/0$/, ''); };
const money2 = (n) => (Number(n) || 0).toFixed(1).replace(/\.0$/, '');
const escapeHtml = (s) => String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
function catIcon(icon, name) {
const key = (icon || '').toLowerCase();
if (CAT_ICONS[key]) return CAT_ICONS[key];
const n = (name || '');
if (/中|川|粤|京|餐|菜/.test(n)) return CAT_ICONS.chinese;
if (/日|韩|寿司|刺身|料/.test(n)) return CAT_ICONS.japanese;
if (/西|汉堡|披萨|快餐|美/.test(n)) return CAT_ICONS.western;
if (/火锅|烧烤|串|锅/.test(n)) return CAT_ICONS.hotpot;
if (/奶茶|甜品|甜|饮|茶/.test(n)) return CAT_ICONS.dessert;
if (/轻食|健康|沙拉|低卡|绿/.test(n)) return CAT_ICONS.healthy;
return CAT_ICONS.default;
}
function dishIcon(item) {
const n = (item.name || '') + (item.description || '') + (item.tags || '');
if (/沙拉|轻食|蔬|素|绿|藜麦|牛油果/.test(n)) return DISH_ICONS.salad;
if (/鱼|刺身|三文鱼|金枪|鳗|虾/.test(n)) return DISH_ICONS.fish;
if (/肉|牛|鸡|鸭|排|烤|串|腰|肝/.test(n)) return DISH_ICONS.meat;
if (/茶|奶|饮|汁|酒|可乐/.test(n)) return DISH_ICONS.cup;
if (/蛋糕|提拉米苏|马卡龙|甜|熔岩/.test(n)) return DISH_ICONS.cake;
if (/饭|面|河粉|拌饭|粥|馒头/.test(n)) return DISH_ICONS.rice;
if (/汤|锅|豆腐|毛肚|鸭血/.test(n)) return DISH_ICONS.bowl;
return DISH_ICONS.default;
}
async function fetchJSON(url) {
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('网络错误 ' + res.status);
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '接口返回异常');
return json.data;
}
/* ---------- 状态 ---------- */
const state = {
categories: [],
currentCat: 0, // 0 = 全部
keyword: '',
sort: '',
page: 1,
pageSize: 12,
restaurants: [],
total: 0,
loading: false,
cart: [], // {id, name, price, qty, restaurant_id, restaurant_name, icon}
cartRestId: null,
menu: null, // 当前打开餐厅的菜单
activeMenuCat: null
};
/* ---------- 初始化图标 ---------- */
function initStaticIcons() {
$('#logoMark').innerHTML = ICONS.logo;
$('#footerLogo').innerHTML = ICONS.logo;
$('#searchBtn').innerHTML = ICONS.search;
$('#cartBtnIcon').innerHTML = ICONS.cart;
$('#navCtaIcon').innerHTML = ICONS.truck;
$('#heroCtaIcon').innerHTML = ICONS.sparkle;
$('#cartBarIconSvg').innerHTML = ICONS.bag;
$('#cartBtnIcon').innerHTML = ICONS.cart;
$('#clearIcon').innerHTML = ICONS.trash;
$('#drawerTitleIcon').innerHTML = ICONS.cart;
$('#floatIc1').innerHTML = ICONS.leaf;
$('#floatIc2').innerHTML = ICONS.sparkle;
$('#floatIc3').innerHTML = ICONS.truck;
$('#chipClearIcon').innerHTML = ICONS.close;
$('#heroBowl').innerHTML = `<svg viewBox="0 0 200 200" fill="none">
<ellipse cx="100" cy="120" rx="78" ry="20" fill="#C8E6C9"/>
<path d="M30 115c0 28 31 50 70 50s70-22 70-50" stroke="#2D9A6C" stroke-width="6" stroke-linecap="round"/>
<path d="M22 115h156" stroke="#1B5E20" stroke-width="8" stroke-linecap="round"/>
<circle cx="78" cy="92" r="14" fill="#66BB6A"/>
<circle cx="108" cy="86" r="16" fill="#EF5350"/>
<circle cx="128" cy="100" r="11" fill="#FFB74D"/>
<path d="M78 78c0 8 6 12 6 18M108 70c0 8 6 12 6 18" stroke="#2D9A6C" stroke-width="4" stroke-linecap="round"/>
<path d="M100 60c0 6 4 9 4 14" stroke="#81C784" stroke-width="4" stroke-linecap="round"/>
</svg>`;
// hero 叶子
const leafSvg = (rot) => `<svg viewBox="0 0 100 100" fill="none" style="transform:rotate(${rot}deg)">
<path d="M20 80c0-35 25-60 60-60 0 35-25 60-60 60Z" fill="#81C784"/>
<path d="M20 80c12-22 28-36 46-44" stroke="#2D9A6C" stroke-width="2" stroke-linecap="round"/>
</svg>`;
$('#leaf1').innerHTML = leafSvg(-20);
$('#leaf2').innerHTML = leafSvg(35);
$('#leaf3').innerHTML = leafSvg(10);
$('#socialWx').innerHTML = ICONS.wx;
$('#socialWb').innerHTML = ICONS.wb;
$('#socialMail').innerHTML = ICONS.mail;
}
/* ---------- Toast ---------- */
function toast(msg, type) {
const el = document.createElement('div');
el.className = 'toast' + (type === 'warn' ? ' warn' : '');
el.innerHTML = (type === 'warn' ? ICONS.warn : ICONS.check) + `<span>${escapeHtml(msg)}</span>`;
$('#toastWrap').appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 350);
}, 2200);
}
/* ---------- 渲染:分类 ---------- */
function renderCategories() {
const wrap = $('#catGrid');
if (!state.categories.length) {
wrap.innerHTML = '<div class="sk-cat-grid">' + Array(6).fill('<div class="sk-cat skeleton"></div>').join('') + '</div>';
return;
}
const all = [{ id: 0, name: '全部', icon: 'all', sort: 0 }];
const cats = all.concat(state.categories);
wrap.innerHTML = cats.map(c => {
const active = state.currentCat === c.id ? ' active' : '';
const ic = c.id === 0
? `<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="7" height="7" rx="2" fill="currentColor"/><rect x="14" y="3" width="7" height="7" rx="2" fill="currentColor" opacity="0.6"/><rect x="3" y="14" width="7" height="7" rx="2" fill="currentColor" opacity="0.6"/><rect x="14" y="14" width="7" height="7" rx="2" fill="currentColor"/></svg>`
: catIcon(c.icon, c.name);
return `<div class="cat-card${active}" data-id="${c.id}" onclick="window._app.selectCat(${c.id})">
<div class="cat-ic">${ic}</div>
<div class="cat-name">${escapeHtml(c.name)}</div>
</div>`;
}).join('');
}
/* ---------- 渲染:推荐 ---------- */
function renderFeatured(list) {
const wrap = $('#featuredScroller');
if (!list || !list.length) {
wrap.innerHTML = '<div class="sk-scroller">' + Array(4).fill('<div class="sk-feat skeleton"></div>').join('') + '</div>';
return;
}
wrap.innerHTML = list.map((r, i) => {
const g = gradFor(r.id);
const tags = parseTags(r.tags).slice(0, 2);
return `<div class="feat-card" onclick="window._app.openRestaurant(${r.id})">
<div class="feat-cover">
<div class="grad" style="background:${g.css}"></div>
${r.cover?`<img src="${escapeHtml(r.cover)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.parentNode.querySelector('.ic').style.display=''">`:''}
<div class="ic" style="color:${g.css.match(/#[0-9A-Fa-f]+/)[0]};${r.cover?'display:none':''}">${catIcon(r.category_name ? '' : '', r.category_name)}</div>
<div class="feat-rank">${i + 1}</div>
</div>
<div class="feat-body">
<div class="feat-name">${escapeHtml(r.name)}</div>
<div class="feat-desc">${escapeHtml(r.description || '')}</div>
<div class="feat-meta">
<div class="feat-rating">${ICONS.star} ${money2(r.rating)}</div>
<div class="feat-sales">月售 ${r.sales}</div>
</div>
${tags.length ? `<div class="feat-tags">${tags.map(t => `<span class="t">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
</div>
</div>`;
}).join('');
}
/* ---------- 渲染:餐厅列表 ---------- */
function restaurantCard(r) {
const g = gradFor(r.id);
const tags = parseTags(r.tags).slice(0, 3);
const open = r.status === 1;
return `<div class="rest-card" onclick="window._app.openRestaurant(${r.id})">
<div class="rest-logo">
<div class="grad" style="background:${g.css}"></div>
${r.logo?`<img src="${escapeHtml(r.logo)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.parentNode.querySelector('span').style.display=''">`:''}
<span style="${r.logo?'display:none':''}">${catIcon(r.category_name ? '' : '', r.category_name)}</span>
</div>
<div class="rest-info">
<div class="rest-head">
<div class="rest-name">${escapeHtml(r.name)}</div>
<div class="rest-status ${open ? 'open' : 'closed'}">${open ? t('status_open') : t('status_closed')}</div>
</div>
<div class="rest-cat">${escapeHtml(r.category_name || '美食')}</div>
${tags.length ? `<div class="rest-tags">${tags.map(t => `<span class="t">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="rest-meta">
<span class="rest-rating">${ICONS.star} ${money2(r.rating)}</span>
<span class="rest-sales">月售 ${r.sales}</span>
</div>
<div class="rest-delivery">
<span>${ICONS.clock} ${r.delivery_time}分钟</span>
<span class="sep"></span>
<span>${ICONS.pin} ${escapeHtml(r.distance || '-')}</span>
<span class="sep"></span>
<span>起送 <b>¥${money(r.min_order)}</b></span>
<span class="sep"></span>
<span>配送 <b>¥${money(r.delivery_fee)}</b></span>
</div>
</div>
</div>`;
}
function renderRestaurants() {
const wrap = $('#restGrid');
$('#resultCount').textContent = state.total;
if (!state.restaurants.length) {
wrap.innerHTML = `<div class="state-box" style="grid-column:1/-1">
<div class="ic">${ICONS.empty}</div>
<h3>暂无符合条件的餐厅</h3>
<p>试试切换分类或调整搜索关键词</p>
</div>`;
return;
}
wrap.innerHTML = state.restaurants.map(restaurantCard).join('');
}
function skeletonList(n) {
return Array(n).fill(`<div class="sk-card">
<div class="sk-logo skeleton"></div>
<div class="sk-lines">
<div class="sk-line w70"></div>
<div class="sk-line w50"></div>
<div class="sk-line w90"></div>
<div class="sk-line w50"></div>
</div>
</div>`).join('');
}
/* ---------- 数据加载 ---------- */
async function loadHome() {
renderCategories(); // 骨架
renderFeatured(null); // 骨架
try {
const data = await fetchJSON(`${API}/home`);
state.categories = data.categories || [];
renderCategories();
renderFeatured(data.featured || []);
$('#statRest').textContent = (data.featured && data.featured.length ? '' : '') + '12+';
} catch (e) {
console.error(e);
renderFeatured([]);
$('#catGrid').innerHTML = `<div class="state-box" style="grid-column:1/-1">
<div class="ic">${ICONS.error}</div>
<h3>分类加载失败</h3>
<p>${escapeHtml(e.message)}</p>
<div class="retry"><button class="btn-ghost" onclick="window._app.loadHome()">重新加载</button></div>
</div>`;
}
}
async function loadRestaurants(reset) {
if (state.loading) return;
state.loading = true;
if (reset) { state.page = 1; state.restaurants = []; }
$('#loadMoreWrap').style.display = 'none';
if (reset) $('#restGrid').innerHTML = skeletonList(6);
const params = new URLSearchParams();
if (state.currentCat) params.set('category_id', state.currentCat);
if (state.keyword) params.set('keyword', state.keyword);
if (state.sort) params.set('sort', state.sort);
params.set('page', state.page);
params.set('page_size', state.pageSize);
try {
const data = await fetchJSON(`${API}/restaurants?${params.toString()}`);
const list = data.list || [];
state.restaurants = reset ? list : state.restaurants.concat(list);
state.total = data.total || 0;
renderRestaurants();
// 加载更多
if (state.restaurants.length < state.total) {
$('#loadMoreWrap').style.display = 'block';
$('#loadMoreBtn').disabled = false;
$('#loadMoreBtn').innerHTML = '加载更多';
} else {
$('#loadMoreWrap').style.display = 'none';
}
} catch (e) {
console.error(e);
$('#restGrid').innerHTML = `<div class="state-box" style="grid-column:1/-1">
<div class="ic">${ICONS.error}</div>
<h3>餐厅列表加载失败</h3>
<p>${escapeHtml(e.message)}</p>
<div class="retry"><button class="btn-ghost" onclick="window._app.loadRestaurants(true)">重新加载</button></div>
</div>`;
} finally {
state.loading = false;
updateChipClear();
updateSubtitle();
}
}
function updateSubtitle() {
const parts = [];
if (state.currentCat) {
const c = state.categories.find(x => x.id === state.currentCat);
if (c) parts.push('“' + c.name + '”');
}
if (state.keyword) parts.push('“' + state.keyword + '”');
$('#listSubtitle').textContent = parts.length ? `当前筛选:${parts.join(' · ')}` : '为你找到附近优质好店';
}
function updateChipClear() {
const show = state.currentCat || state.keyword;
$('#chipClear').classList.toggle('show', !!show);
const parts = [];
if (state.currentCat) { const c = state.categories.find(x => x.id === state.currentCat); if (c) parts.push(c.name); }
if (state.keyword) parts.push('“' + state.keyword + '”');
$('#chipClearText').textContent = parts.length ? '清除:' + parts.join(' / ') : '清除筛选';
}
/* ---------- 交互:分类/搜索/排序 ---------- */
function selectCat(id) {
if (state.currentCat === id) return;
state.currentCat = id;
renderCategories();
loadRestaurants(true);
scrollToRestaurants();
}
function doSearch() {
const kw = $('#searchInput').value.trim();
if (kw === state.keyword) return;
state.keyword = kw;
loadRestaurants(true);
if (kw) scrollToRestaurants();
}
function setSort(sort) {
if (state.sort === sort) return;
state.sort = sort;
$$('#sortGroup .sort-btn').forEach(b => b.classList.toggle('active', b.dataset.sort === sort));
loadRestaurants(true);
}
function clearFilters() {
state.currentCat = 0;
state.keyword = '';
state.sort = '';
$('#searchInput').value = '';
$$('#sortGroup .sort-btn').forEach(b => b.classList.toggle('active', b.dataset.sort === ''));
renderCategories();
loadRestaurants(true);
}
function loadMore() {
if (state.loading) return;
state.page++;
$('#loadMoreBtn').disabled = true;
$('#loadMoreBtn').innerHTML = '<span class="spinner"></span> 加载中…';
loadRestaurants(false);
}
/* ---------- 餐厅详情 ---------- */
async function openRestaurant(id) {
const mask = $('#modalMask');
mask.classList.add('show');
document.body.style.overflow = 'hidden';
$('#modalHero').innerHTML = `<div style="padding:60px;text-align:center;color:var(--muted)"><div class="spinner" style="width:30px;height:30px;border-width:3px;margin:0 auto 12px"></div>加载中…</div>`;
$('#menuBody').innerHTML = `<div class="menu-loading"><div class="spinner"></div><div>正在准备菜单…</div></div>`;
state.menu = null;
try {
const data = await fetchJSON(`${API}/restaurants/${id}/menu`);
state.menu = data;
renderModal(data);
} catch (e) {
console.error(e);
$('#modalHero').innerHTML = '';
$('#menuBody').innerHTML = `<div class="state-box"><div class="ic">${ICONS.error}</div><h3>菜单加载失败</h3><p>${escapeHtml(e.message)}</p></div>`;
}
}
function renderModal(data) {
const r = data.restaurant || {};
const cats = data.categories || [];
const items = data.menu_items || [];
const g = gradFor(r.id);
const tags = parseTags(r.tags);
$('#modalHero').innerHTML = `
<button class="modal-close" onclick="window._app.closeModal()">${ICONS.close}</button>
<div class="modal-rest">
<div class="modal-logo"><div class="grad" style="background:${g.css}"></div>${r.logo?`<img src="${escapeHtml(r.logo)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.parentNode.querySelector('span').style.display=''">`:''}<span style="${r.logo?'display:none':''}">${catIcon('', r.category_name)}</span></div>
<div class="modal-rest-info">
<div class="modal-rest-name">${escapeHtml(r.name)} <span class="rest-status ${r.status === 1 ? 'open' : 'closed'}">${r.status === 1 ? '营业中' : '休息中'}</span></div>
<div class="modal-rest-cat">${escapeHtml(r.category_name || '美食')} · ${escapeHtml(r.distance || '')}</div>
${tags.length ? `<div class="modal-rest-tags">${tags.map(t => `<span class="t">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="modal-rest-stats">
<div class="item">${ICONS.star} <b>${money2(r.rating)}</b> 评分</div>
<div class="item">月售 <b>${r.sales}</b></div>
<div class="item">${ICONS.clock} <b>${r.delivery_time}</b> 分钟</div>
<div class="item">起送 <b>¥${money(r.min_order)}</b></div>
<div class="item">配送 <b>¥${money(r.delivery_fee)}</b></div>
</div>
</div>
</div>
${r.notice ? `<div class="modal-notice">${ICONS.megaphone}<span>${escapeHtml(r.notice)}</span></div>` : ''}
`;
if (!items.length) {
$('#menuBody').innerHTML = `<div class="state-box"><div class="ic">${ICONS.empty}</div><h3>暂无菜品</h3><p>该餐厅还未上架菜品</p></div>`;
return;
}
// 按菜品分类分组
const grouped = {};
cats.forEach(c => { grouped[c.id] = { cat: c, items: [] }; });
const other = { cat: { id: 0, name: '全部菜品' }, items: [] };
items.forEach(it => {
if (grouped[it.category_id]) grouped[it.category_id].items.push(it);
else other.items.push(it);
});
const sections = Object.values(grouped).filter(s => s.items.length);
if (other.items.length) sections.push(other);
if (!sections.length) sections.push({ cat: { id: 0, name: '全部菜品' }, items });
// 左侧分类
const leftHtml = sections.map((s, i) => `
<div class="menu-cat${i === 0 ? ' active' : ''}" data-cat="${s.cat.id}" onclick="window._app.scrollMenuTo(${s.cat.id})">
${escapeHtml(s.cat.name)} <span class="cnt">${s.items.length}</span>
</div>`).join('');
// 右侧菜品
const rightHtml = sections.map(s => `
<div class="menu-section" id="sec-${s.cat.id}" data-cat="${s.cat.id}">
<div class="menu-section-title">${escapeHtml(s.cat.name)} <span class="cnt">${s.items.length} 道</span></div>
${s.items.map(it => dishCardHtml(it)).join('')}
</div>`).join('');
$('#menuBody').innerHTML = `
<div class="menu-left" id="menuLeft">${leftHtml}</div>
<div class="menu-right" id="menuRight">${rightHtml}</div>
`;
state.activeMenuCat = sections[0].cat.id;
// 渲染各菜品的购物车数量
refreshDishQties();
// 滚动联动
setupMenuScroll();
}
function dishCardHtml(it) {
const g = gradFor(it.id || it.restaurant_id);
const tags = parseTags(it.tags).slice(0, 2);
const hasOld = it.original_price && Number(it.original_price) > Number(it.price);
return `<div class="dish-card" data-dish="${it.id}">
<div class="dish-img"><div class="grad" style="background:${g.css}"></div>${it.image?`<img src="${escapeHtml(it.image)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.parentNode.querySelector('span').style.display=''">`:''}<span style="${it.image?'display:none':''}">${dishIcon(it)}</span></div>
<div class="dish-info">
<div class="dish-name">${escapeHtml(it.name)}</div>
${it.description ? `<div class="dish-desc">${escapeHtml(it.description)}</div>` : ''}
${tags.length ? `<div class="dish-tags">${tags.map(t => `<span class="t">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="dish-foot">
<div class="dish-price">
<span class="now"><small>¥</small>${money(it.price)}</span>
${hasOld ? `<span class="old">¥${money(it.original_price)}</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:6px">
<span class="dish-rating">${ICONS.star} ${money2(it.rating)}</span>
<button class="add-btn" data-add="${it.id}" onclick="window._app.addToCart(${it.id}, event)">
${ICONS.plus} 加入
</button>
<div class="qty-stepper" data-stepper="${it.id}">
<button class="qty-btn minus" onclick="window._app.changeQty(${it.id}, -1)">${ICONS.minus}</button>
<span class="qty-num" data-num="${it.id}">0</span>
<button class="qty-btn plus" onclick="window._app.changeQty(${it.id}, 1)">${ICONS.plus}</button>
</div>
</div>
</div>
</div>
</div>`;
}
function refreshDishQties() {
if (!state.menu) return;
const items = state.menu.menu_items || [];
items.forEach(it => {
const inCart = state.cart.find(c => c.id === it.id);
const addBtn = document.querySelector(`[data-add="${it.id}"]`);
const stepper = document.querySelector(`[data-stepper="${it.id}"]`);
const num = document.querySelector(`[data-num="${it.id}"]`);
if (addBtn) addBtn.style.display = inCart ? 'none' : 'inline-flex';
if (stepper) stepper.classList.toggle('show', !!inCart);
if (num && inCart) num.textContent = inCart.qty;
});
}
function setupMenuScroll() {
const right = $('#menuRight');
if (!right) return;
const cats = $$('#menuLeft .menu-cat');
let ticking = false;
right.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const scrollTop = right.scrollTop;
const sections = $$('#menuRight .menu-section');
let current = sections[0] ? sections[0].dataset.cat : null;
for (const sec of sections) {
if (sec.offsetTop - 40 <= scrollTop) current = sec.dataset.cat;
}
if (current && current !== String(state.activeMenuCat)) {
state.activeMenuCat = current;
cats.forEach(c => c.classList.toggle('active', c.dataset.cat === current));
// 让激活的分类在左侧可见
const activeEl = $('#menuLeft .menu-cat.active');
if (activeEl && activeEl.scrollIntoView) {
const left = $('#menuLeft');
const isHorizontal = window.matchMedia('(max-width:720px)').matches;
if (isHorizontal) {
activeEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
} else {
left.scrollTop = activeEl.offsetTop - left.clientHeight / 2 + activeEl.clientHeight / 2;
}
}
}
ticking = false;
});
});
}
function scrollMenuTo(catId) {
const right = $('#menuRight');
const sec = document.querySelector(`#menuRight .menu-section[data-cat="${catId}"]`);
if (!right || !sec) return;
right.scrollTo({ top: sec.offsetTop - 8, behavior: 'smooth' });
state.activeMenuCat = catId;
$$('#menuLeft .menu-cat').forEach(c => c.classList.toggle('active', c.dataset.cat === String(catId)));
}
function closeModal() {
$('#modalMask').classList.remove('show');
document.body.style.overflow = '';
}
/* ---------- 购物车 ---------- */
function findDish(id) {
if (!state.menu) return null;
return (state.menu.menu_items || []).find(i => i.id === id);
}
function addToCart(id, ev) {
if (ev) ev.stopPropagation();
const dish = findDish(id);
if (!dish) return;
const rest = state.menu.restaurant || {};
// 切换餐厅时清空
if (state.cartRestId && state.cartRestId !== rest.id && state.cart.length) {
state.cart = [];
state.cartRestId = rest.id;
toast('已切换餐厅,购物车已更新', 'warn');
} else {
state.cartRestId = rest.id;
}
const exist = state.cart.find(c => c.id === id);
if (exist) exist.qty++;
else state.cart.push({ id, name: dish.name, price: Number(dish.price), qty: 1, restaurant_id: rest.id, restaurant_name: rest.name });
refreshDishQties();
renderCart();
// 飞入动画
flyToCart(ev);
toast(`已加入「${dish.name}」`);
}
function changeQty(id, delta) {
const item = state.cart.find(c => c.id === id);
if (!item) return;
item.qty += delta;
if (item.qty <= 0) {
state.cart = state.cart.filter(c => c.id !== id);
if (!state.cart.length) state.cartRestId = null;
}
refreshDishQties();
renderCart();
}
function clearCart() {
if (!state.cart.length) return;
state.cart = [];
state.cartRestId = null;
refreshDishQties();
renderCart();
toast('购物车已清空');
}
function cartCount() { return state.cart.reduce((s, c) => s + c.qty, 0); }
function cartTotal() { return state.cart.reduce((s, c) => s + c.price * c.qty, 0); }
function renderCart() {
const count = cartCount();
const total = cartTotal();
// 角标
$('#cartBadge').textContent = count;
$('#cartBadge').classList.toggle('show', count > 0);
$('#cartBarBadge').textContent = count;
$('#cartBarBadge').classList.toggle('show', count > 0);
// 浮层
const bar = $('#cartBar');
if (count > 0) {
bar.classList.add('show');
$('#cartBarTotal').textContent = money(total);
const rest = state.cart[0] ? state.cart[0].restaurant_name : '';
// 起送价提示
const minOrder = state.menu && state.menu.restaurant ? Number(state.menu.restaurant.min_order) || 0 : 0;
const useMin = state.cartRestId === (state.menu && state.menu.restaurant && state.menu.restaurant.id) ? minOrder : 0;
if (useMin && total < useMin) {
$('#cartBarSub').textContent = `还差 ¥${money(useMin - total)} 起送`;
$('#cartBarSub').classList.add('warn');
$('#cartCheckoutBtn').disabled = true;
} else {
$('#cartBarSub').textContent = rest ? `来自「${rest}」· 另需配送费` : '已满起送价';
$('#cartBarSub').classList.remove('warn');
$('#cartCheckoutBtn').disabled = false;
}
} else {
bar.classList.remove('show');
$('#cartBarSub').textContent = '购物车是空的';
$('#cartBarSub').classList.remove('warn');
$('#cartCheckoutBtn').disabled = true;
}
// 抽屉
renderCartDrawer(total, count);
}
function renderCartDrawer(total, count) {
$('#drawerCount').textContent = count ? `· ${count} 件` : '';
const body = $('#cartDrawerBody');
if (!state.cart.length) {
body.innerHTML = `<div class="cart-empty"><div class="ic">${ICONS.empty}</div><p>购物车空空如也<br>快去挑选心仪美食吧</p></div>`;
$('#cartDrawerFoot').innerHTML = `<button class="cart-drawer-checkout" disabled>去结算</button>`;
return;
}
body.innerHTML = state.cart.map(c => {
const g = gradFor(c.id || c.restaurant_id);
const dish = findDish(c.id);
const dishImg = dish ? dish.image : '';
return `<div class="cart-item">
<div class="cart-item-img">
<div class="grad" style="background:${g.css}"></div>
${dishImg?`<img src="${escapeHtml(dishImg)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.parentNode.querySelector('span').style.display=''">`:''}
<span style="${dishImg?'display:none':''}">${DISH_ICONS.default}</span>
</div>
<div class="cart-item-info">
<div class="cart-item-name">${escapeHtml(c.name)}</div>
<div class="cart-item-price">¥${money(c.price)} / 份</div>
</div>
<div class="cart-item-stepper">
<button class="qty-btn minus" onclick="window._app.changeQty(${c.id}, -1)">${ICONS.minus}</button>
<span class="qty-num">${c.qty}</span>
<button class="qty-btn plus" onclick="window._app.changeQty(${c.id}, 1)">${ICONS.plus}</button>
</div>
</div>`;
}).join('');
const minOrder = state.menu && state.menu.restaurant && state.cartRestId === state.menu.restaurant.id ? Number(state.menu.restaurant.min_order) || 0 : 0;
const deliveryFee = state.menu && state.menu.restaurant && state.cartRestId === state.menu.restaurant.id ? Number(state.menu.restaurant.delivery_fee) || 0 : 0;
const meetMin = total >= minOrder;
const footNote = minOrder && !meetMin ? `<div style="font-size:12px;color:#E67E22;margin-bottom:10px;text-align:center">还差 ¥${money(minOrder - total)} 满起送价</div>` : '';
$('#cartDrawerFoot').innerHTML = `
${footNote}
<div class="cart-drawer-total">
<span class="lbl">合计 (含配送费 ¥${money(deliveryFee)})</span>
<span class="val"><small>¥</small>${money(total + deliveryFee)}</span>
</div>
<button class="cart-drawer-checkout" ${meetMin ? '' : 'disabled'} onclick="window._app.checkout()">去结算</button>
`;
}
function checkout() {
if (!state.cart.length) return;
const total = cartTotal();
toast(`下单成功!共 ¥${money(total)},预计 30 分钟送达`);
state.cart = [];
state.cartRestId = null;
refreshDishQties();
renderCart();
closeCartDrawer();
}
/* 飞入购物车动画 */
function flyToCart(ev) {
if (!ev) return;
const from = ev.currentTarget || ev.target;
if (!from.getBoundingClientRect) return;
const cartIcon = $('#cartBtn');
const r1 = from.getBoundingClientRect();
const r2 = cartIcon.getBoundingClientRect();
const dot = document.createElement('div');
dot.style.cssText = `position:fixed;z-index:250;width:18px;height:18px;border-radius:50%;background:${gradFor(0).css};left:${r1.left + r1.width/2}px;top:${r1.top}px;pointer-events:none;transition:all .6s cubic-bezier(.5,-0.3,.7,1);box-shadow:0 4px 10px rgba(45,154,108,0.4)`;
document.body.appendChild(dot);
requestAnimationFrame(() => {
dot.style.left = (r2.left + r2.width/2) + 'px';
dot.style.top = (r2.top + r2.height/2) + 'px';
dot.style.transform = 'scale(0.3)';
dot.style.opacity = '0.2';
});
setTimeout(() => dot.remove(), 620);
}
/* ---------- 购物车抽屉开关 ---------- */
function openCartDrawer() {
if (!state.cart.length) { toast('购物车还是空的', 'warn'); return; }
$('#cartDrawerMask').classList.add('show');
$('#cartDrawer').classList.add('show');
}
function closeCartDrawer() {
$('#cartDrawerMask').classList.remove('show');
$('#cartDrawer').classList.remove('show');
}
/* ---------- 滚动定位 ---------- */
function scrollToTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
function scrollToCategories() { $('#categoriesSection').scrollIntoView({ behavior: 'smooth' }); }
function scrollToRestaurants() {
const el = $('#restaurantsSection');
const y = el.getBoundingClientRect().top + window.scrollY - 70;
window.scrollTo({ top: y, behavior: 'smooth' });
}
/* ---------- 事件绑定 ---------- */
function bindEvents() {
$('#searchBtn').addEventListener('click', doSearch);
$('#searchInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
// 搜索输入实时清空时自动刷新
let searchTimer = null;
$('#searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimer);
const v = e.target.value.trim();
searchTimer = setTimeout(() => {
if (v === '' && state.keyword !== '') { state.keyword = ''; loadRestaurants(true); }
}, 400);
});
$$('#sortGroup .sort-btn').forEach(b => b.addEventListener('click', () => setSort(b.dataset.sort)));
$('#chipClear').addEventListener('click', clearFilters);
$('#loadMoreBtn').addEventListener('click', loadMore);
// 模态
$('#modalMask').addEventListener('click', (e) => { if (e.target === $('#modalMask')) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeModal(); closeCartDrawer(); } });
// 购物车
$('#cartBtn').addEventListener('click', openCartDrawer);
$('#cartBarIcon').addEventListener('click', openCartDrawer);
$('#cartBarText').addEventListener('click', openCartDrawer);
$('#cartCheckoutBtn').addEventListener('click', checkout);
$('#cartClearBtn').addEventListener('click', clearCart);
$('#cartDrawerMask').addEventListener('click', closeCartDrawer);
}
/* ---------- 暴露 ---------- */
window._app = {
selectCat, loadHome, loadRestaurants, openRestaurant, closeModal,
scrollMenuTo, addToCart, changeQty, checkout
};
window.scrollToTop = scrollToTop;
window.scrollToCategories = scrollToCategories;
window.scrollToRestaurants = scrollToRestaurants;
/* ---------- 启动 ---------- */
function init() {
initStaticIcons();
bindEvents();
renderCart();
loadHome();
loadRestaurants(true);
}
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', init)
: init();
})();
</script>
</body>
</html>