Files
oil-formula-calculator/frontend/index.html

8442 lines
477 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="doTERRA配方">
<title>doTERRA 配方计算器</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;600;700&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<style>
:root {
--cream: #faf6f0;
--warm-white: #fffdf9;
--sage: #7a9e7e;
--sage-dark: #5a7d5e;
--sage-light: #c8ddc9;
--sage-mist: #eef4ee;
--gold: #c9a84c;
--gold-light: #f0e4c0;
--brown: #6b4f3a;
--brown-light: #c4a882;
--text-dark: #2c2416;
--text-mid: #5a4a35;
--text-light: #9a8570;
--border: #e0d4c0;
--shadow: 0 4px 20px rgba(90,60,30,0.08);
--shadow-hover: 0 8px 32px rgba(90,60,30,0.15);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Noto Sans SC', sans-serif;
background: var(--cream);
color: var(--text-dark);
min-height: 100vh;
}
/* Header */
.app-header {
background: linear-gradient(135deg, #3d6b41 0%, #5a7d5e 50%, #7a9e7e 100%);
padding: 28px 32px 24px;
position: relative;
overflow: hidden;
}
.app-header::before {
content: '';
position: absolute; inset: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
.header-inner { position: relative; z-index: 1; display: flex; align-items: center; gap: 16px; }
.header-icon { font-size: 40px; }
.header-title { color: white; }
.header-title h1 { font-family: 'Noto Serif SC', serif; font-size: 24px; font-weight: 600; letter-spacing: 2px; }
.header-title p { font-size: 13px; opacity: 0.8; margin-top: 4px; letter-spacing: 1px; }
/* Nav tabs */
.nav-tabs {
display: flex;
background: white;
border-bottom: 1px solid var(--border);
padding: 0 24px;
gap: 0;
overflow-x: auto;
position: sticky;
top: 0;
z-index: 50;
}
.nav-tab {
padding: 14px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-light);
cursor: pointer;
border-bottom: 3px solid transparent;
white-space: nowrap;
transition: all 0.2s;
}
.nav-tab:hover { color: var(--sage-dark); }
.nav-tab.active { color: var(--sage-dark); border-bottom-color: var(--sage); }
/* Main content */
.main { padding: 24px; max-width: 960px; margin: 0 auto; }
/* Section */
.section { display: none; max-width: 800px; margin-left: auto; margin-right: auto; }
.section.active { display: block; }
#section-search { max-width: none; }
#section-search .search-box,
#section-search .recipe-grid,
#section-search #personalSection,
#section-search #publicLabel,
#section-search #fuzzyResults,
#section-search #diarySearchResults,
#section-search #categoryRelated,
#section-search #categoryHeader { max-width: 800px; margin-left: auto; margin-right: auto; }
/* Search box */
.search-box {
background: white;
border-radius: 16px;
padding: 20px 24px;
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.search-label { font-size: 13px; color: var(--text-light); margin-bottom: 10px; letter-spacing: 0.5px; }
.search-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.search-input {
flex: 1; min-width: 200px;
padding: 11px 16px;
border: 1.5px solid var(--border);
border-radius: 10px;
font-size: 15px;
font-family: inherit;
color: var(--text-dark);
background: var(--cream);
transition: border-color 0.2s;
outline: none;
}
.search-input:focus { border-color: var(--sage); background: white; }
.btn {
padding: 11px 22px;
border-radius: 10px;
border: none;
font-size: 14px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-primary { background: var(--sage); color: white; }
.btn-primary:hover { background: var(--sage-dark); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(90,125,94,0.3); }
.btn-gold { background: var(--gold); color: white; }
.btn-gold:hover { background: #b8973e; transform: translateY(-1px); }
.btn-outline { background: transparent; color: var(--sage-dark); border: 1.5px solid var(--sage); }
.btn-outline:hover { background: var(--sage-mist); }
.btn-danger { background: transparent; color: #c0392b; border: 1.5px solid #e8b4b0; }
.btn-danger:hover { background: #fdf0ee; }
.btn-sm { padding: 7px 14px; font-size: 13px; }
/* Recipe grid */
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.recipe-card {
background: white;
border-radius: 14px;
padding: 18px;
cursor: pointer;
box-shadow: var(--shadow);
border: 2px solid transparent;
transition: all 0.2s;
position: relative;
}
.recipe-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-hover); border-color: var(--sage-light); }
.recipe-card.selected { border-color: var(--sage); background: var(--sage-mist); }
.recipe-card-name {
font-family: 'Noto Serif SC', serif;
font-size: 16px;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 8px;
}
.recipe-card-oils { font-size: 12px; color: var(--text-light); line-height: 1.7; }
.recipe-card-price {
margin-top: 12px;
font-size: 13px;
color: var(--sage-dark);
font-weight: 600;
display: flex; align-items: center; gap: 6px;
}
/* Detail panel */
.detail-panel {
background: white;
border-radius: 16px;
padding: 28px;
box-shadow: var(--shadow);
margin-bottom: 24px;
}
.detail-header {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: 24px; flex-wrap: wrap; gap: 12px;
}
.detail-title {
font-family: 'Noto Serif SC', serif;
font-size: 22px; font-weight: 700; color: var(--text-dark);
}
.detail-note {
font-size: 13px; color: var(--text-light);
background: var(--gold-light); border-radius: 8px;
padding: 6px 12px; margin-top: 6px;
display: inline-block;
}
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
/* Ingredients table */
.ingredients-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.ingredients-table th {
text-align: center; padding: 10px 14px;
font-size: 12px; font-weight: 600;
color: var(--text-light); letter-spacing: 0.5px;
border-bottom: 2px solid var(--border);
text-transform: uppercase;
}
.ingredients-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
font-size: 14px; vertical-align: middle;
text-align: center;
}
.ingredients-table tr:last-child td { border-bottom: none; }
.ingredients-table tr:hover td { background: var(--sage-mist); }
.drops-input {
width: 70px; padding: 6px 10px;
border: 1.5px solid var(--border); border-radius: 8px;
font-size: 14px; font-family: inherit; text-align: center;
outline: none; transition: border-color 0.2s;
}
.drops-input:focus { border-color: var(--sage); }
.oil-select {
padding: 6px 10px;
border: 1.5px solid var(--border); border-radius: 8px;
font-size: 13px; font-family: inherit;
background: white; outline: none;
max-width: 160px;
}
.oil-select:focus { border-color: var(--sage); }
.remove-btn {
background: none; border: none; cursor: pointer;
color: #c0392b; font-size: 18px; padding: 4px 8px;
border-radius: 6px; transition: background 0.2s;
}
.remove-btn:hover { background: #fdf0ee; }
.total-row {
background: var(--sage-mist);
border-radius: 12px; padding: 16px 20px;
display: flex; justify-content: space-between; align-items: center;
margin-top: 16px;
}
.total-label { font-size: 14px; color: var(--text-mid); font-weight: 500; }
.total-price { font-size: 22px; font-weight: 700; color: var(--sage-dark); }
/* Add ingredient */
.add-ingredient-row {
display: flex; gap: 10px; align-items: center;
margin-top: 12px; flex-wrap: wrap;
}
.add-ingredient-row select, .add-ingredient-row input {
padding: 8px 12px; border: 1.5px solid var(--border);
border-radius: 8px; font-size: 13px; font-family: inherit;
outline: none;
}
.add-ingredient-row select:focus, .add-ingredient-row input:focus { border-color: var(--sage); }
/* Card preview for export */
.card-preview-wrapper { margin-top: 20px; }
#recipe-card-export {
background: linear-gradient(145deg, #faf7f0 0%, #f5ede0 100%);
border-radius: 20px;
padding: 36px;
font-family: 'Noto Serif SC', serif;
max-width: 480px;
border: 1px solid #e0ccaa;
position: relative;
overflow: hidden;
}
#recipe-card-export::before {
content: '';
position: absolute; top: -40px; right: -40px;
width: 180px; height: 180px;
background: radial-gradient(circle, rgba(122,158,126,0.15) 0%, transparent 70%);
border-radius: 50%;
}
#recipe-card-export::after {
content: '';
position: absolute; bottom: -30px; left: -30px;
width: 140px; height: 140px;
background: radial-gradient(circle, rgba(201,168,76,0.12) 0%, transparent 70%);
border-radius: 50%;
}
.card-brand {
font-size: 11px; letter-spacing: 3px; color: var(--sage);
margin-bottom: 8px;
}
.card-title {
font-size: 26px; font-weight: 700; color: var(--text-dark);
margin-bottom: 6px; line-height: 1.3;
}
.card-divider {
width: 48px; height: 2px;
background: linear-gradient(90deg, var(--sage), var(--gold));
border-radius: 2px; margin: 14px 0;
}
.card-note { font-size: 12px; color: var(--brown-light); margin-bottom: 18px; }
.card-ingredients { list-style: none; margin-bottom: 20px; }
.card-ingredients li {
display: flex; align-items: center;
padding: 9px 0; border-bottom: 1px solid rgba(180,150,100,0.15);
font-size: 14px;
}
.card-ingredients li:last-child { border-bottom: none; }
.card-oil-name { flex: 1; color: var(--text-dark); font-weight: 500; }
.card-oil-drops { width: 60px; text-align: right; color: var(--sage-dark); font-size: 13px; }
.card-oil-cost { width: 70px; text-align: right; color: var(--text-light); font-size: 12px; }
.card-total {
background: linear-gradient(135deg, var(--sage), #5a7d5e);
border-radius: 12px; padding: 14px 20px;
display: flex; justify-content: space-between; align-items: center;
margin-top: 8px;
}
.card-total-label { color: rgba(255,255,255,0.85); font-size: 13px; letter-spacing: 1px; }
.card-total-price { color: white; font-size: 20px; font-weight: 700; }
.card-footer {
margin-top: 16px; text-align: center;
font-size: 11px; color: var(--text-light); letter-spacing: 1px;
}
/* Manage section */
.manage-list { display: flex; flex-direction: column; gap: 12px; }
.manage-item {
background: white; border-radius: 14px; padding: 18px 22px;
box-shadow: var(--shadow); display: flex;
justify-content: space-between; align-items: center;
gap: 12px; flex-wrap: wrap;
}
.manage-item-left { flex: 1; }
.manage-item-name { font-weight: 600; font-size: 16px; color: var(--text-dark); }
.manage-item-oils { font-size: 13px; color: var(--text-light); margin-top: 4px; }
.manage-item-actions { display: flex; gap: 8px; flex-shrink: 0; flex-wrap: wrap; }
/* Add recipe form */
.form-card {
background: white; border-radius: 16px;
padding: 28px; box-shadow: var(--shadow); margin-bottom: 24px;
}
.form-title { font-family: 'Noto Serif SC', serif; font-size: 18px; font-weight: 600; margin-bottom: 20px; color: var(--text-dark); }
.form-group { margin-bottom: 16px; }
.form-label { font-size: 13px; color: var(--text-mid); margin-bottom: 6px; display: block; font-weight: 500; }
.form-control {
width: 100%; padding: 10px 14px;
border: 1.5px solid var(--border); border-radius: 10px;
font-size: 14px; font-family: inherit; outline: none;
transition: border-color 0.2s; background: white;
}
.form-control:focus { border-color: var(--sage); }
.new-ing-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.new-ing-row {
display: flex; gap: 8px; align-items: center;
}
.new-ing-row select { flex: 1; }
.new-ing-row input { width: 80px; }
/* Oils section */
.oils-search { margin-bottom: 16px; }
.oils-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.oil-chip {
background: white; border-radius: 10px; padding: 12px 16px;
box-shadow: 0 2px 8px rgba(90,60,30,0.06);
display: flex; justify-content: space-between; align-items: center;
gap: 8px;
}
.oil-chip-name { font-size: 14px; color: var(--text-dark); font-weight: 500; }
.oil-chip-price { font-size: 13px; color: var(--sage-dark); font-weight: 600; }
.oil-chip-actions { display: flex; gap: 4px; }
.oil-chip-btn {
background: none; border: none; cursor: pointer;
font-size: 13px; padding: 3px 6px; border-radius: 6px;
transition: background 0.2s; color: var(--text-light);
}
.oil-chip-btn:hover { background: var(--sage-mist); color: var(--sage-dark); }
.oil-chip-btn.del:hover { background: #fdf0ee; color: #c0392b; }
.oil-edit-input {
width: 90px; padding: 4px 8px; border: 1.5px solid var(--sage);
border-radius: 6px; font-size: 13px; font-family: inherit;
text-align: center; outline: none;
}
.add-oil-form {
background: white; border-radius: 14px; padding: 16px 20px;
box-shadow: var(--shadow); margin-bottom: 16px;
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
}
.add-oil-form input {
padding: 9px 14px; border: 1.5px solid var(--border);
border-radius: 8px; font-size: 14px; font-family: inherit; outline: none;
}
.add-oil-form input:focus { border-color: var(--sage); }
/* Empty state */
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-light); }
.empty-state-icon { font-size: 48px; margin-bottom: 12px; }
.empty-state-text { font-size: 15px; }
/* Tag */
.tag {
display: inline-block; padding: 3px 10px;
border-radius: 20px; font-size: 12px;
background: var(--sage-mist); color: var(--sage-dark);
margin: 2px;
}
.tag-btn {
display: inline-flex; align-items: center; gap: 3px;
padding: 4px 10px; border-radius: 16px; font-size: 12px;
background: var(--sage-mist); color: var(--sage-dark);
border: 1.5px solid transparent; cursor: pointer;
transition: all 0.2s;
}
.tag-btn:hover { border-color: var(--sage); }
.tag-btn.active { background: var(--sage); color: white; border-color: var(--sage); }
.tag-btn .tag-del {
font-size: 14px; margin-left: 2px; opacity: 0.5;
cursor: pointer; border: none; background: none;
color: inherit; padding: 0 2px;
}
.tag-btn .tag-del:hover { opacity: 1; }
/* Tag picker overlay */
.tag-picker {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3); z-index: 999;
display: flex; align-items: center; justify-content: center;
}
.tag-picker-card {
background: white; border-radius: 16px; padding: 24px;
box-shadow: 0 8px 40px rgba(0,0,0,0.2); max-width: 400px; width: 90%;
}
.tag-picker-title { font-family: 'Noto Serif SC', serif; font-size: 16px; font-weight: 600; margin-bottom: 14px; }
.tag-picker-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.tag-pick {
padding: 6px 14px; border-radius: 20px; font-size: 13px;
border: 1.5px solid var(--border); background: white;
color: var(--text-mid); cursor: pointer; transition: all 0.15s;
}
.tag-pick:hover { border-color: var(--sage); }
.tag-pick.selected { background: var(--sage); color: white; border-color: var(--sage); }
/* Tooltip / hint */
.hint { font-size: 12px; color: var(--text-light); margin-top: 6px; }
.section-title {
font-family: 'Noto Serif SC', serif;
font-size: 18px; font-weight: 600; color: var(--text-dark);
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
}
@media (max-width: 600px) {
.main { padding: 8px; }
.section { max-width: 100%; }
.detail-panel { padding: 12px; }
.recipe-grid { grid-template-columns: 1fr; }
#detailPanel { padding: 6px; max-width: 100vw; }
.ingredients-table { font-size: 12px; }
.ingredients-table td, .ingredients-table th { padding: 6px 4px; }
.oil-select { max-width: 100px; font-size: 11px; }
.drops-input { width: 50px; font-size: 12px; }
.search-input { padding: 8px 10px; font-size: 14px; }
.search-box { padding: 12px; }
.search-label { font-size: 12px; margin-bottom: 6px; }
.form-card { padding: 16px; }
.section-title { font-size: 16px; }
.manage-item { padding: 10px 12px; }
.nav-tab { padding: 10px 12px; font-size: 13px; }
.oil-chip { padding: 10px 12px; }
.oils-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.app-header { padding: 20px 16px 18px; }
.header-title h1 { font-size: 20px; }
}
</style>
</head>
<body>
<div class="app-header" style="position:relative">
<div class="header-inner" style="padding-right:80px">
<div class="header-icon">🌿</div>
<div class="header-title" style="text-align:left;flex:1">
<h1 style="display:flex;justify-content:space-between;align-items:center;gap:8px;white-space:nowrap">
<span style="flex-shrink:0">doTERRA 配方计算器 <span class="requires-admin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top;display:none">v2.2.0</span></span>
<span id="headerUserName" style="cursor:pointer;color:white;font-size:13px;font-weight:500;flex-shrink:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif;letter-spacing:0.3px;opacity:0.95" onclick="showUserMenu()"></span>
</h1>
<p style="display:flex;flex-wrap:wrap;gap:4px 8px;margin:0"><span style="white-space:nowrap">查询配方</span><span style="opacity:0.5">·</span><span style="white-space:nowrap">计算成本</span><span style="opacity:0.5">·</span><span style="white-space:nowrap">自制配方</span><span style="opacity:0.5">·</span><span style="white-space:nowrap">导出卡片</span><span style="opacity:0.5">·</span><span style="white-space:nowrap">精油知识</span></p>
</div>
</div>
</div>
<div class="nav-tabs" id="navTabs">
<div class="nav-tab active" onclick="_resetSearchHome()">🔍 配方查询</div>
<div class="nav-tab" onclick="_requireLogin()?showSection('manage'):0">📋 管理配方</div>
<div class="nav-tab" onclick="_requireLogin()?showSection('inventory'):0">📦 个人库存</div>
<div class="nav-tab" onclick="showSection('oils')">💧 精油价目</div>
<div class="nav-tab requires-business" onclick="showSection('projects')" style="display:none">💼 商业核算</div>
<div class="nav-tab requires-admin" onclick="showSection('audit')">📜 操作日志</div>
<div class="nav-tab requires-admin" id="bugNavTab" onclick="showSection('bugs')">🐛 Bug</div>
<div class="nav-tab requires-admin" onclick="showSection('users')">👥 用户管理</div>
<!-- 通知 tab removed — use popup from user menu -->
</div>
<div class="main" id="mainContent">
<!-- ========== SEARCH SECTION ========== -->
<div class="section active" id="section-search">
<!-- Category carousel -->
<style>
.cat-wrap { position:relative;margin:0 -24px 20px;overflow:hidden; }
.cat-track { display:flex;transition:transform 0.4s ease;will-change:transform; }
.cat-card {
flex:0 0 100%;min-height:200px;position:relative;overflow:hidden;cursor:pointer;
background-size:cover;background-position:center;
}
.cat-card::after {
content:'';position:absolute;inset:0;
background:linear-gradient(135deg,rgba(0,0,0,0.45),rgba(0,0,0,0.25));
}
.cat-inner {
position:relative;z-index:1;height:100%;display:flex;flex-direction:column;
justify-content:center;align-items:center;padding:36px 24px;color:white;text-align:center;
}
.cat-icon { font-size:48px;margin-bottom:10px;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.3)); }
.cat-name { font-family:'Noto Serif SC',serif;font-size:24px;font-weight:700;letter-spacing:3px;text-shadow:0 2px 8px rgba(0,0,0,0.5); }
.cat-sub { font-size:13px;margin-top:6px;opacity:0.9;letter-spacing:1px; }
.cat-arrow {
position:absolute;top:50%;transform:translateY(-50%);z-index:2;
width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,0.25);
border:none;color:white;font-size:18px;cursor:pointer;backdrop-filter:blur(4px);
display:flex;align-items:center;justify-content:center;transition:background 0.2s;
}
.cat-arrow:hover { background:rgba(255,255,255,0.45); }
.cat-arrow.left { left:12px; }
.cat-arrow.right { right:12px; }
.cat-dots { display:flex;justify-content:center;gap:8px;margin-bottom:14px; }
.cat-dot { width:8px;height:8px;border-radius:50%;background:var(--border);cursor:pointer;transition:all 0.25s; }
.cat-dot.active { background:var(--sage);width:22px;border-radius:4px; }
</style>
<div class="cat-wrap" id="categoryWrap" style="display:none">
<div class="cat-track" id="categoryTrack"></div>
<button class="cat-arrow left" onclick="slideCategory(-1)"></button>
<button class="cat-arrow right" onclick="slideCategory(1)"></button>
</div>
<div class="cat-dots" id="categoryDots" style="display:none"></div>
<div class="search-box">
<div class="search-label">搜索配方名称或关键词</div>
<div class="search-row">
<input type="text" class="search-input" id="searchInput" placeholder="搜索配方或精油…" oninput="filterRecipes()" onkeydown="if(event.key==='Enter')confirmSearch()" style="min-width:0;flex:1">
<div style="display:flex;gap:4px;flex-shrink:0">
<button onclick="confirmSearch()" style="background:var(--sage);color:white;border:none;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:16px" title="搜索">🔍</button>
<button onclick="clearSearch()" style="background:none;border:1px solid var(--border);border-radius:8px;padding:8px 8px;cursor:pointer;font-size:13px;color:var(--text-light)" title="清除"></button>
</div>
<!-- fav filter removed, now in personal section -->
</div>
</div>
<!-- Personal recipes + favorites (logged-in only) -->
<div id="personalSection">
<!-- My recipes -->
<div id="myRecipesPreview" style="margin-bottom:16px"></div>
<!-- Favorites -->
<div id="myFavsPreview" style="margin-bottom:16px"></div>
</div>
<div id="publicLabel" style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:8px"></div>
<div id="recipeGrid" class="recipe-grid"></div>
</div>
<!-- ========== MY DIARY SECTION ========== -->
<div class="section" id="section-mydiary">
<div class="section-title">🙋 我的</div>
<!-- Sub-tabs: brand + account only -->
<div style="display:flex;gap:6px;margin-bottom:16px;flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="diaryTabBrand" onclick="showDiaryTab('brand')">🏷 我的品牌</button>
<button class="btn btn-outline btn-sm" id="diaryTabAccount" onclick="showDiaryTab('account')">👤 我的账号</button>
</div>
<!-- My recipes (diary) -->
<div id="diaryRecipesPanel">
<div class="form-card" style="margin-bottom:16px">
<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:10px">✨ 智能粘贴(添加到我的配方)</div>
<div class="form-group">
<textarea class="form-control" id="diarySmartPasteInput" rows="4" placeholder="支持多个配方,空行分隔:&#10;头疗椒样薄荷5生姜3迷迭香3&#10;&#10;安睡薰衣草15雪松10" style="font-size:13px"></textarea>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" onclick="diarySmartPaste()">🪄 识别并添加</button>
<button class="btn btn-outline btn-sm" onclick="createNewDiary()">📝 手动新增</button>
</div>
<div id="diarySmartPasteResult" style="margin-top:10px"></div>
</div>
<div id="diaryList" class="manage-list"></div>
</div>
<!-- Favorites -->
<div id="diaryFavsPanel" style="display:none">
<div id="diaryFavsList" class="recipe-grid"></div>
</div>
<!-- Brand settings -->
<div id="diaryBrandPanel" style="display:none">
<div class="form-card" style="margin-bottom:16px">
<p style="font-size:13px;color:var(--text-light);margin-bottom:16px">分享配方卡片时二维码、背景图、Logo 会自动展示在卡片上</p>
<div style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:16px">
<div>
<label class="form-label">📱 二维码</label>
<p style="font-size:11px;color:var(--text-light);margin-bottom:6px">卡片右上角展示</p>
<div id="qrPreview" style="width:100px;height:100px;border:2px dashed var(--border);border-radius:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;background:white" onclick="document.getElementById('qrUpload').click()">
<span style="font-size:12px;color:var(--text-light)">点击上传</span>
</div>
<input type="file" id="qrUpload" accept="image/*" style="display:none" onchange="uploadBrandImage('qr_code', this)">
<button class="btn btn-outline btn-sm" onclick="clearBrandImage('qr_code')" style="margin-top:6px;font-size:11px">清除</button>
</div>
<div>
<label class="form-label">🖼 背景图</label>
<p style="font-size:11px;color:var(--text-light);margin-bottom:6px">铺满整张卡片,半透明</p>
<div id="bgPreview" style="width:100px;height:100px;border:2px dashed var(--border);border-radius:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;background:white" onclick="document.getElementById('bgUpload').click()">
<span style="font-size:12px;color:var(--text-light)">点击上传</span>
</div>
<input type="file" id="bgUpload" accept="image/*" style="display:none" onchange="uploadBrandImage('brand_bg', this, 1000000)">
<button class="btn btn-outline btn-sm" onclick="clearBrandImage('brand_bg')" style="margin-top:6px;font-size:11px">清除</button>
</div>
<div>
<label class="form-label">🏷 Logo</label>
<p style="font-size:11px;color:var(--text-light);margin-bottom:6px">卡片底部居中水印</p>
<div id="logoPreview" style="width:100px;height:100px;border:2px dashed var(--border);border-radius:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;background:white" onclick="document.getElementById('logoUpload').click()">
<span style="font-size:12px;color:var(--text-light)">点击上传</span>
</div>
<input type="file" id="logoUpload" accept="image/*" style="display:none" onchange="uploadBrandImage('brand_logo', this)">
<button class="btn btn-outline btn-sm" onclick="clearBrandImage('brand_logo')" style="margin-top:6px;font-size:11px">清除</button>
</div>
</div>
<div class="form-group">
<label class="form-label">品牌名称或标语(显示在二维码下方)</label>
<textarea class="form-control" id="brandNameInput" rows="2" placeholder="扫码申请成为优惠顾客&#10;我的精油小屋" style="max-width:350px;font-size:13px" onblur="_autoSaveBrand()"></textarea>
<div style="display:flex;gap:6px;margin-top:6px">
<button class="btn btn-outline btn-sm brand-align-btn" onclick="_setBrandAlign('left')" style="font-size:11px;padding:3px 10px">靠左</button>
<button class="btn btn-outline btn-sm brand-align-btn" onclick="_setBrandAlign('center')" style="font-size:11px;padding:3px 10px">居中</button>
<button class="btn btn-outline btn-sm brand-align-btn" onclick="_setBrandAlign('right')" style="font-size:11px;padding:3px 10px">靠右</button>
</div>
</div>
<!-- Card Preview -->
<div style="margin-bottom:16px">
<label class="form-label">📋 配方卡片预览</label>
<div id="brandCardPreview" style="position:relative;width:280px;height:180px;background:linear-gradient(145deg,#faf7f0,#f5ede0);border-radius:14px;border:1px solid #e0ccaa;overflow:hidden;font-family:'Noto Serif SC',serif;padding:16px">
<div style="font-size:8px;letter-spacing:2px;color:var(--sage);margin-bottom:4px">doTERRA · RECIPE CARD</div>
<div style="font-size:14px;font-weight:700;color:var(--text-dark)">配方名称</div>
<div style="font-size:10px;color:var(--text-light);margin-top:2px">薰衣草 · 乳香 · 茶树</div>
<div style="position:absolute;bottom:12px;left:16px;right:16px;height:24px;background:linear-gradient(135deg,var(--sage),#5a7d5e);border-radius:6px;display:flex;align-items:center;justify-content:center">
<span style="color:white;font-size:10px">💰 ¥12.50</span>
</div>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-primary" onclick="saveBrand()">💾 保存品牌设置</button>
<span id="returnToCardBtn"></span>
</div>
<div id="brandSaveResult" style="margin-top:10px"></div>
</div>
</div>
<!-- Account settings -->
<div id="diaryAccountPanel" style="display:none">
<div class="form-card" style="margin-bottom:16px">
<!-- Display name -->
<div class="form-group">
<label class="form-label">昵称(显示给其他人看)</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" class="form-control" id="myDisplayName" placeholder="你的昵称" style="flex:1;max-width:200px;padding:8px 12px;font-size:14px">
<button class="btn btn-primary btn-sm" onclick="saveMyProfile('display_name')">保存</button>
</div>
</div>
<!-- Username -->
<div class="form-group">
<label class="form-label">用户名(用于登录,唯一)</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" class="form-control" id="myUsername" placeholder="登录用户名" style="flex:1;max-width:200px;padding:8px 12px;font-size:14px">
<button class="btn btn-primary btn-sm" onclick="saveMyProfile('username')">保存</button>
</div>
</div>
<!-- Password -->
<div class="form-group" style="margin-bottom:0">
<label class="form-label">🔑 修改登录密码</label>
<div style="display:flex;flex-direction:column;gap:8px;max-width:240px">
<input type="password" class="form-control" id="myOldPassword" placeholder="当前密码" style="padding:8px 12px;font-size:14px">
<input type="password" class="form-control" id="myNewPassword" placeholder="新密码至少4位" style="padding:8px 12px;font-size:14px">
<input type="password" class="form-control" id="myNewPassword2" placeholder="确认新密码" style="padding:8px 12px;font-size:14px">
<button class="btn btn-primary btn-sm" onclick="saveMyProfile('password')" style="align-self:flex-start">修改密码</button>
</div>
</div>
<div id="accountResult" style="margin-top:10px"></div>
</div>
<!-- Business verification -->
<div class="form-card" style="margin-top:16px" id="businessApplyCard">
<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:8px">🏢 商业用户认证</div>
<div id="businessApplyContent"></div>
</div>
</div>
<!-- Diary detail (shows when clicking a diary recipe) -->
<div id="diaryDetailPanel" style="display:none;margin-top:16px">
<div class="detail-panel">
<div class="detail-header">
<div>
<div class="detail-title" id="diaryDetailTitle"></div>
</div>
<div class="detail-actions">
<button class="btn btn-primary btn-sm" onclick="saveDiaryAll()" style="font-size:12px">💾 保存</button>
<button class="btn btn-outline btn-sm" onclick="previewDiaryCard()" style="font-size:12px">📷 配方卡片</button>
<button class="btn btn-outline btn-sm" onclick="publishDiary()" style="font-size:11px;color:var(--text-light)">📤 共享到公共库</button>
<button class="btn btn-outline btn-sm" onclick="closeDiaryDetail()" style="font-size:12px">← 返回</button>
</div>
</div>
<!-- Ingredients (read-only list) -->
<div id="diaryIngredients" style="margin-bottom:16px"></div>
<!-- Note -->
<div style="margin-bottom:16px">
<div style="font-size:13px;font-weight:600;color:var(--text-mid);margin-bottom:6px">📝 我的备注</div>
<textarea id="diaryNoteEdit" class="form-control" rows="2" placeholder="写点备注…" style="font-size:13px" onchange="saveDiaryNote()"></textarea>
</div>
<!-- Diary entries -->
<div style="font-size:13px;font-weight:600;color:var(--text-mid);margin-bottom:8px">📖 使用日记</div>
<div style="display:flex;gap:8px;margin-bottom:12px">
<textarea id="newDiaryEntry" class="form-control" rows="2" placeholder="记录今天的使用感受…" style="flex:1;font-size:13px"></textarea>
<button class="btn btn-primary btn-sm" onclick="addDiaryEntry()" style="align-self:flex-end">记录</button>
</div>
<div id="diaryEntries"></div>
</div>
</div>
</div>
<!-- ========== INVENTORY SECTION ========== -->
<div class="section" id="section-inventory">
<div class="section-title">📦 我的精油库存</div>
<div class="search-box" style="margin-bottom:16px">
<div class="search-label">添加精油到库存(点击下方精油即可添加)</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:8px">
<input type="text" class="search-input" id="invSearchInput" placeholder="搜索精油名称…" oninput="renderInventoryOilPicker()" style="flex:1">
</div>
<div id="invOilPicker" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;max-height:200px;overflow-y:auto"></div>
</div>
<div class="section-title" style="font-size:16px">已有精油 <span id="invCount" style="font-size:13px;font-weight:400;color:var(--text-light)"></span></div>
<div id="invList" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px"></div>
<div class="section-title" style="font-size:16px;margin-top:24px">可做的配方</div>
<div id="invRecipes" class="manage-list"></div>
</div>
<!-- ========== PROJECTS SECTION ========== -->
<div class="section" id="section-projects">
<div class="section-title">💼 商业核算</div>
<p style="font-size:13px;color:var(--text-light);margin-bottom:20px">商业用户专属功能,包含项目核算、成本分析等工具</p>
<!-- Sub-section: 项目核算 -->
<div class="form-card" style="margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span style="font-size:15px;font-weight:600;color:var(--text-dark)">📊 服务项目成本利润分析</span>
<button class="btn btn-primary btn-sm" onclick="createProject()"> 新增项目</button>
</div>
<!-- Project list -->
<div id="projectList" class="manage-list"></div>
<!-- Project detail -->
<div id="projectDetail" style="display:none;margin-top:16px">
<div class="detail-panel">
<div class="detail-header">
<div class="detail-title" id="projTitle"></div>
<div class="detail-actions">
<button class="btn btn-outline btn-sm" onclick="loadFromRecipe()">从配方导入</button>
<button class="btn btn-primary btn-sm" onclick="saveProject()" style="background:var(--sage-dark)">💾 保存</button>
<button class="btn btn-outline btn-sm" onclick="closeProjectDetail()">← 返回</button>
</div>
</div>
<!-- Ingredients editor -->
<table class="ingredients-table">
<thead><tr><th>精油</th><th>单次用量(滴)</th><th>瓶装容量</th><th>单价/滴</th><th></th></tr></thead>
<tbody id="projIngBody"></tbody>
</table>
<button class="btn btn-outline btn-sm" onclick="addProjIng()" style="margin-top:8px"> 添加精油</button>
<!-- Analysis results -->
<div id="projAnalysis" style="margin-top:20px"></div>
<!-- Pricing -->
<div style="background:var(--cream);border-radius:12px;padding:16px 20px;margin-top:16px">
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:14px;font-weight:600;color:var(--text-mid)">定价 ¥</span>
<input type="number" id="projPricing" class="drops-input" style="width:100px;font-size:16px" min="0" step="1" value="0" oninput="calcProjectProfit()">
<span style="font-size:13px;color:var(--text-light)">/次</span>
</div>
<div id="projProfit" style="font-size:16px;font-weight:700;color:var(--sage-dark)"></div>
</div>
<div id="projProfitDetail" style="font-size:13px;color:var(--text-mid);margin-top:8px"></div>
</div>
<!-- Note -->
<div style="margin-top:16px">
<textarea id="projNote" class="form-control" rows="2" placeholder="项目备注…" style="font-size:13px"></textarea>
</div>
</div>
</div>
</div><!-- /form-card 项目核算 -->
<!-- Future business features go here -->
</div>
<!-- ========== NOTIFICATIONS SECTION (admin) ========== -->
<div class="section requires-notif" id="section-notif">
<div class="section-title" style="justify-content:space-between;flex-wrap:wrap">
<span>🔔 通知 <button class="btn btn-outline btn-sm" onclick="triggerWeeklyReview()" style="margin-left:12px">手动生成周报</button></span>
<button class="btn btn-outline btn-sm" onclick="markAllNotifRead()" style="font-size:12px">✓ 全部已读</button>
</div>
<div id="notifList" class="manage-list"></div>
</div>
<!-- Detail overlay (appears over the grid) -->
<div id="detailOverlay" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:100" onclick="if(event.target===this)closeDetail()">
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.25)" onclick="closeDetail()"></div>
<div id="detailPanel" style="position:relative;z-index:101;max-width:600px;margin:0 auto;padding:12px;max-height:100vh;overflow-y:auto" onclick="event.stopPropagation()">
<!-- Card view (default for everyone) -->
<div id="cardViewMode" style="display:none">
<div class="detail-panel" style="padding:16px;position:relative">
<div style="position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2">
<button id="cardBackToEditBtn" onclick="switchToEditorView()" style="display:none;background:rgba(0,0,0,0.06);border:none;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;color:var(--text-light)" title="返回编辑"></button>
<button onclick="closeDetail()" style="background:rgba(0,0,0,0.06);border:none;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;color:var(--text-light)" title="关闭"></button>
</div>
<div id="cardViewActions" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px"></div>
<div id="cardViewContainer"></div>
</div>
</div>
<!-- Editor view -->
<div id="editorViewMode" style="display:none">
<div class="detail-panel" style="max-width:100%;overflow-x:hidden">
<div class="detail-header" style="flex-wrap:wrap;gap:8px">
<div style="flex:1;min-width:0">
<div class="detail-title" id="detailTitle"></div>
<div id="detailNote"></div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary btn-sm" onclick="saveAndShowCard()" style="background:var(--sage-dark);font-size:12px">💾 保存</button>
<button class="btn btn-outline btn-sm" onclick="_cardViewFromEditor=true;switchToCardView()" style="font-size:12px">👁 预览</button>
<button onclick="closeDetail()" style="background:rgba(0,0,0,0.06);border:none;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;color:var(--text-light)"></button>
</div>
</div>
<!-- Tip -->
<div style="font-size:11px;color:var(--text-light);margin-bottom:10px;line-height:1.5">💡 推荐按照单次用量椰子油10~20滴添加纯精油然后点击下方「容量与稀释」自动生成相应配方</div>
<table class="ingredients-table">
<thead>
<tr>
<th>精油</th>
<th>滴数</th>
<th>单价/滴</th>
<th>小计</th>
<th></th>
</tr>
</thead>
<tbody id="ingredientsBody"></tbody>
</table>
<div class="add-ingredient-row" id="addIngRow" style="display:none;background:var(--sage-mist);border:1.5px dashed var(--sage);border-radius:10px;padding:10px 12px;margin-top:6px">
<select id="newOilSelect" class="form-control" style="max-width:180px;font-size:13px">
<option value="">— 选择精油 —</option>
</select>
<input type="number" id="newOilDrops" placeholder="滴数" style="width:80px;font-size:13px" class="form-control" min="0.5" step="0.5">
<button class="btn btn-primary btn-sm" onclick="confirmAddIngredient()" style="font-size:12px">确认</button>
<button class="btn btn-outline btn-sm" onclick="hideAddRow()" style="font-size:12px">取消</button>
</div>
<button class="btn btn-outline btn-sm" onclick="addIngredientRow()" style="font-size:12px;margin-top:6px;width:100%;border-style:dashed"> 添加精油</button>
<!-- Volume & dilution ratio -->
<div style="background:var(--cream);border-radius:12px;padding:16px 20px;margin-top:16px;border:1px solid var(--border)">
<!-- Line 1: Volume -->
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<span style="font-size:13px;font-weight:600;color:var(--text-mid)">容量:</span>
<button class="btn btn-primary btn-sm vol-btn" onclick="selectVolume('single')" data-vol="single" style="font-size:12px">单次</button>
<button class="btn btn-outline btn-sm vol-btn" onclick="selectVolume('5ml')" data-vol="5ml" style="font-size:12px">5ml</button>
<button class="btn btn-outline btn-sm vol-btn" onclick="selectVolume('10ml')" data-vol="10ml" style="font-size:12px">10ml</button>
<button class="btn btn-outline btn-sm vol-btn" onclick="selectVolume('30ml')" data-vol="30ml" style="font-size:12px">30ml</button>
<button class="btn btn-outline btn-sm vol-btn" onclick="selectVolume('custom')" data-vol="custom" style="font-size:12px">自定义</button>
<input type="number" id="customVolInput" class="drops-input" style="width:60px;font-size:12px" value="20" min="1" step="1" onchange="onCustomVolChange()">
<select id="customVolUnit" class="form-control" style="width:60px;padding:5px 6px;font-size:12px" onchange="onCustomVolChange()">
<option value="drops" selected></option>
<option value="ml">ml</option>
</select>
</div>
<!-- Line 2: Ratio -->
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<span style="font-size:13px;font-weight:600;color:var(--text-mid)">稀释比例1:</span>
<select id="dilutionRatio" class="form-control" style="width:60px;padding:5px 6px;font-size:13px;background:var(--sage-mist);border-color:var(--sage);color:var(--sage-dark);font-weight:600" onchange="onDilutionChange()">
<option value="1">1</option><option value="2">2</option><option value="3">3</option>
<option value="4">4</option><option value="5">5</option><option value="6">6</option>
<option value="7">7</option><option value="8">8</option><option value="9">9</option>
<option value="10" selected>10</option><option value="15">15</option><option value="20">20</option>
</select>
<button class="btn btn-primary btn-sm" onclick="applyVolume()" style="font-size:12px">应用到配方</button>
</div>
<div style="font-size:10px;color:var(--text-light);margin-bottom:6px">5ml = 100滴 · 10ml = 200滴 · 30ml = 600滴</div>
<!-- Line 3: Current status -->
<div id="dilutionHint" style="font-size:13px;color:var(--sage-dark);font-weight:500"></div>
<!-- Notes -->
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border)">
<div style="font-size:13px;font-weight:600;color:var(--text-mid);margin-bottom:6px">📝 备注</div>
<textarea id="detailNoteEdit" class="form-control" rows="2" placeholder="添加备注…" style="font-size:13px" onchange="onNoteChange()"></textarea>
</div>
</div>
<!-- Tags -->
<div style="margin-top:12px">
<div style="font-size:13px;font-weight:600;color:var(--text-mid);margin-bottom:6px">🏷 标签</div>
<div id="editorTags" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px"></div>
<div id="editorCandidateTags" style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px"></div>
<div style="display:flex;gap:6px;align-items:center">
<input type="text" id="editorNewTag" class="form-control" placeholder="添加标签…" style="width:120px;padding:5px 10px;font-size:12px" onkeydown="if(event.key==='Enter'){addEditorTag();event.preventDefault()}">
<button class="btn btn-outline btn-sm" onclick="addEditorTag()" style="font-size:11px">+ 添加</button>
</div>
</div>
<div class="total-row">
<div class="total-label">配方总成本</div>
<div class="total-price" id="totalPrice">¥ 0.00</div>
</div>
</div>
<!-- Card preview removed — use 👁 预览 button instead -->
</div>
</div><!-- /editorViewMode -->
</div><!-- /detailPanel -->
</div><!-- /detailOverlay -->
<!-- ========== MANAGE SECTION ========== -->
<div class="section" id="section-manage">
<!-- Review bar (admin only) -->
<div id="reviewBar" class="search-box requires-admin" style="margin-bottom:16px;display:none">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<div>
<span style="font-size:14px;font-weight:600;color:var(--sage-dark)">📥 待审核</span>
<span id="reviewCount" style="font-size:13px;color:var(--text-light);margin-left:6px"></span>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-outline btn-sm" onclick="filterByReview()">筛选待审核</button>
<button class="btn btn-primary btn-sm" onclick="adoptSelected()">✅ 批量采纳选中</button>
<button class="btn btn-gold btn-sm" onclick="adoptAll()">✅ 全部采纳</button>
</div>
</div>
</div>
<!-- Bug #96: Manage search box -->
<div style="margin-bottom:8px">
<input id="manageSearchInput" type="text" placeholder="🔍 搜索配方名称..." oninput="_manageSearchQuery=this.value.trim().toLowerCase();renderManage()" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;outline:none;box-sizing:border-box" onfocus="this.style.borderColor='var(--sage)'" onblur="this.style.borderColor='var(--border)'">
</div>
<!-- Recipe list header -->
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;margin-bottom:4px">
<span style="font-size:15px;font-weight:600;color:var(--text-dark)">📋 配方列表 <span id="manageFilterLabel" style="font-size:13px;font-weight:400;color:var(--text-light)"></span></span>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary btn-sm" onclick="showAddRecipeOverlay()"> 新增</button>
<button class="btn btn-outline btn-sm" id="selectAllBtn" onclick="toggleSelectAll()">全选/取消</button>
<button class="btn btn-outline btn-sm" id="tagFilterToggleBtn" onclick="_toggleTagFilter()">🏷 标签筛选</button>
<div style="position:relative;display:inline-block">
<button class="btn btn-outline btn-sm" onclick="var m=this.nextElementSibling;m.style.display=m.style.display==='none'?'':'none'">📋 批量操作 ▾</button>
<div style="display:none;position:absolute;right:0;top:100%;margin-top:4px;background:white;border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,0.15);z-index:50;min-width:160px;overflow:hidden">
<div onclick="this.parentElement.style.display='none';batchAddTag()" style="padding:10px 14px;cursor:pointer;font-size:13px;border-bottom:1px solid #f0f0f0" onmouseover="this.style.background='var(--sage-mist)'" onmouseout="this.style.background=''">🏷 批量打标签</div>
<div onclick="this.parentElement.style.display='none';batchShareToPublic()" style="padding:10px 14px;cursor:pointer;font-size:13px;border-bottom:1px solid #f0f0f0" onmouseover="this.style.background='var(--sage-mist)'" onmouseout="this.style.background=''">📤 批量分享到公共库</div>
<div onclick="this.parentElement.style.display='none';batchExportCards()" style="padding:10px 14px;cursor:pointer;font-size:13px;border-bottom:1px solid #f0f0f0" onmouseover="this.style.background='var(--sage-mist)'" onmouseout="this.style.background=''">🖼 批量导出卡片</div>
<div onclick="this.parentElement.style.display='none';batchDelete()" style="padding:10px 14px;cursor:pointer;font-size:13px;color:#c0392b" onmouseover="this.style.background='#fdf0ee'" onmouseout="this.style.background=''">🗑 批量删除</div>
</div>
</div>
<button class="btn btn-gold btn-sm" onclick="exportExcel()">📥 导出 Excel</button>
</div>
</div>
<!-- Tag filter (hidden by default, toggled by button) -->
<div id="tagBar" style="display:none;flex-wrap:wrap;gap:5px;align-items:center;margin-bottom:10px;padding:8px 0"></div>
<!-- Hidden input for new tag (used inside tag editing) -->
<input type="text" id="newTagInput" style="display:none">
<div id="manageList" class="manage-list"></div>
</div>
<!-- ========== ADD SECTION ========== -->
<div class="section" id="section-add">
<div class="form-card">
<div class="form-title">✨ 智能粘贴</div>
<div class="form-group">
<label class="form-label">粘贴一个或多个配方,支持多行输入,空行分隔不同配方</label>
<textarea class="form-control" id="smartPasteInput" rows="6" placeholder="例:&#10;长高&#10;芳香调理8永久花10檀香10乳香15&#10;&#10;助眠&#10;薰衣草15雪松10丝柏5"></textarea>
<div class="hint">支持逗号、顿号、换行分隔,也支持连写。空行或「配方名:」开头自动分隔为多个配方。系统会逐个让你校准后再保存。</div>
</div>
<div style="display:flex; gap:12px;">
<button class="btn btn-primary" onclick="smartPaste()">🪄 识别并生成</button>
<button class="btn btn-outline" onclick="document.getElementById('smartPasteInput').value=''">清除</button>
</div>
<div id="smartPasteResult" style="margin-top:12px"></div>
</div>
<div class="form-card">
<div class="form-title"> 手动新增配方</div>
<div class="form-group">
<label class="form-label">配方名称 *</label>
<input type="text" class="form-control" id="newRecipeName" placeholder="如:淡斑精华、改善睡眠…">
</div>
<div class="form-group">
<label class="form-label">备注说明(可选)</label>
<input type="text" class="form-control" id="newRecipeNote" placeholder="如:适合晚间使用,有艾草时可加入">
</div>
<div class="form-group">
<label class="form-label">精油成分 *</label>
<div class="new-ing-list" id="newIngList"></div>
<button class="btn btn-outline btn-sm" onclick="addNewIngRow()"> 添加精油</button>
</div>
<div style="margin-top: 24px; display:flex; gap:12px;">
<button class="btn btn-primary" onclick="saveNewRecipe()">💾 保存配方</button>
<button class="btn btn-outline" onclick="clearNewForm()">重置</button>
</div>
</div>
</div>
<!-- ========== OILS SECTION ========== -->
<div class="section" id="section-oils">
<div class="section-title">💧 精油价目表 <span style="font-size:14px;font-weight:400;color:var(--text-light)"><span id="oilCount"></span> 种精油</span></div>
<!-- Quick knowledge cards -->
<div style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap">
<div onclick="showDilutionCard()" style="flex:1;min-width:140px;background:linear-gradient(135deg,#e8f5e9,#c8e6c9);border-radius:14px;padding:16px;cursor:pointer;transition:transform 0.2s" onmouseover="this.style.transform='translateY(-2px)'" onmouseout="this.style.transform=''">
<div style="font-size:24px;margin-bottom:6px">💧</div>
<div style="font-size:14px;font-weight:600;color:#2e7d32">稀释比例</div>
<div style="font-size:11px;color:#558b2f;margin-top:4px">不同年龄段的稀释指南</div>
</div>
<div onclick="showCautionCard()" style="flex:1;min-width:140px;background:linear-gradient(135deg,#fff8e1,#ffecb3);border-radius:14px;padding:16px;cursor:pointer;transition:transform 0.2s" onmouseover="this.style.transform='translateY(-2px)'" onmouseout="this.style.transform=''">
<div style="font-size:24px;margin-bottom:6px">⚠️</div>
<div style="font-size:14px;font-weight:600;color:#f57f17">使用禁忌</div>
<div style="font-size:11px;color:#ff8f00;margin-top:4px">安全使用精油的注意事项</div>
</div>
</div>
<div class="add-oil-form requires-oil-edit">
<input type="text" id="newOilName" placeholder="精油名称" style="flex:1;min-width:120px">
<input type="number" id="newOilBottlePrice" placeholder="一瓶价格 (¥)" style="width:130px" step="0.01" min="0">
<select id="newOilVolume" class="form-control" style="width:110px" onchange="onVolumeChange('new')">
<option value="">容量</option>
<option value="2.5">2.5ml (46滴)</option>
<option value="5">5ml (93滴)</option>
<option value="10">10ml (186滴)</option>
<option value="15">15ml (280滴)</option>
<option value="115">115ml (2146滴)</option>
<option value="custom">自定义滴数</option>
</select>
<input type="number" id="newOilDropCount" placeholder="总滴数" style="width:90px;display:none" step="1" min="1">
<button class="btn btn-primary btn-sm" onclick="addNewOil()"> 添加精油</button>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
<div class="search-box oils-search" style="flex:1;min-width:180px;margin-bottom:0">
<input type="text" class="search-input" id="oilSearchInput" placeholder="搜索精油名称…" oninput="filterOils()" style="width:100%">
</div>
<div style="display:flex;border:1.5px solid var(--border);border-radius:10px;overflow:hidden;flex-shrink:0">
<button id="oilViewBottle" class="btn btn-sm" onclick="_setOilView('bottle')" style="border:none;border-radius:0;font-size:12px;padding:6px 12px;background:var(--sage);color:white">单瓶价</button>
<button id="oilViewDrop" class="btn btn-sm" onclick="_setOilView('drop')" style="border:none;border-radius:0;font-size:12px;padding:6px 12px;background:white;color:var(--text-mid)">每滴价</button>
</div>
<button class="btn btn-gold btn-sm requires-admin" onclick="exportOilsPDF()" style="display:none;font-size:12px">📥 导出PDF</button>
</div>
<div id="oilsGrid" class="oils-grid"></div>
</div>
<!-- ========== AUDIT LOG SECTION (admin) ========== -->
<div class="section requires-admin" id="section-audit">
<div class="section-title">📜 操作日志</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
<button class="btn btn-primary btn-sm audit-filter-btn" data-filter="" onclick="filterAuditLog('')">全部</button>
<button class="btn btn-outline btn-sm audit-filter-btn" data-filter="create_recipe" onclick="filterAuditLog('create_recipe')">新增配方</button>
<button class="btn btn-outline btn-sm audit-filter-btn" data-filter="update_recipe" onclick="filterAuditLog('update_recipe')">修改配方</button>
<button class="btn btn-outline btn-sm audit-filter-btn" data-filter="delete_recipe" onclick="filterAuditLog('delete_recipe')">删除配方</button>
<button class="btn btn-outline btn-sm audit-filter-btn" data-filter="adopt_recipe" onclick="filterAuditLog('adopt_recipe')">采纳配方</button>
<button class="btn btn-outline btn-sm audit-filter-btn" data-filter="upsert_oil" onclick="filterAuditLog('upsert_oil')">精油变动</button>
<button class="btn btn-outline btn-sm audit-filter-btn" data-filter="user" onclick="filterAuditLog('user')">用户管理</button>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px;align-items:center">
<span style="font-size:12px;color:var(--text-light)">按用户:</span>
<div id="auditUserFilters" style="display:flex;gap:6px;flex-wrap:wrap"></div>
</div>
<div id="auditList" class="manage-list"></div>
<div style="text-align:center;margin-top:16px">
<button class="btn btn-outline btn-sm" onclick="loadMoreAudit()">加载更多</button>
</div>
</div>
<!-- ========== USER MANAGEMENT SECTION (admin) ========== -->
<!-- ========== BUG TRACKER ========== -->
<div class="section requires-admin" id="section-bugs">
<div class="section-title" style="justify-content:space-between;flex-wrap:wrap">
<span>🐛 Bug 追踪</span>
<button class="btn btn-primary btn-sm" onclick="addNewBug()"> 新增 Bug</button>
</div>
<div id="bugListActive" class="manage-list" style="margin-bottom:24px"></div>
<div id="bugListResolved"></div>
</div>
<div class="section requires-admin" id="section-users">
<div class="section-title">👥 用户管理</div>
<!-- Business applications -->
<!-- Translation suggestions -->
<div id="translationSection" class="form-card" style="margin-bottom:16px;display:none">
<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:8px">📝 翻译建议审核</div>
<div id="translationList"></div>
</div>
<div id="bizApprovalSection" class="form-card" style="margin-bottom:16px;display:none">
<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:8px">🏢 商业认证审批</div>
<div id="bizApprovalList"></div>
</div>
<div class="form-card" style="margin-bottom:16px">
<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:8px">新增用户</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<input type="text" class="form-control" id="newUserName" placeholder="用户名" style="width:100px;padding:6px 10px;font-size:13px">
<input type="text" class="form-control" id="newUserDisplayName" placeholder="显示名" style="width:100px;padding:6px 10px;font-size:13px">
<select class="form-control" id="newUserRole" style="width:100px;padding:6px 8px;font-size:13px">
<option value="viewer">查看者</option>
<option value="editor">编辑者</option>
<option value="senior_editor">高级编辑者</option>
<option value="admin">管理员</option>
</select>
<button class="btn btn-primary btn-sm" onclick="createUser()">创建</button>
</div>
<div id="newUserResult" style="margin-top:10px"></div>
</div>
<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
<input type="text" class="form-control" id="userSearchInput" placeholder="搜索用户…" oninput="renderUsers()" style="flex:1;min-width:120px;max-width:200px;padding:6px 10px;font-size:13px">
<select class="form-control" id="userRoleFilter" onchange="renderUsers()" style="width:100px;padding:6px 8px;font-size:12px">
<option value="">全部</option>
<option value="admin">管理员</option>
<option value="senior_editor">高级编辑者</option>
<option value="editor">编辑者</option>
<option value="viewer">查看者</option>
<option value="business">🏢 企业客户</option>
</select>
</div>
<div id="usersList" class="manage-list"></div>
</div>
</div>
<script>
// ============ CUSTOM DIALOGS (replace native alert/confirm/prompt) ============
(function() {
const _dlgStyle = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:20px';
const _boxStyle = 'background:white;border-radius:16px;padding:28px 24px 20px;max-width:340px;width:100%;box-shadow:0 12px 40px rgba(0,0,0,0.2);font-family:inherit';
const _msgStyle = 'font-size:14px;color:#333;line-height:1.6;white-space:pre-line;word-break:break-word;margin-bottom:20px;text-align:center';
const _btnRow = 'display:flex;gap:10px;justify-content:center';
const _btnPrimary = 'flex:1;max-width:140px;padding:10px 0;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;background:linear-gradient(135deg,#7a9e7e,#5a7d5e);color:white';
const _btnOutline = 'flex:1;max-width:140px;padding:10px 0;border:1.5px solid #d4cfc7;border-radius:10px;font-size:14px;cursor:pointer;background:white;color:#666';
function _createOverlay() {
const o = document.createElement('div');
o.style.cssText = _dlgStyle;
return o;
}
window._nativeAlert = window.alert;
window.alert = function(msg) {
return new Promise(resolve => {
const o = _createOverlay();
o.innerHTML = '<div style="' + _boxStyle + '">' +
'<div style="' + _msgStyle + '">' + String(msg || '').replace(/</g,'&lt;') + '</div>' +
'<div style="' + _btnRow + '"><button id="_dlgOk" style="' + _btnPrimary + '">确定</button></div></div>';
document.body.appendChild(o);
o.querySelector('#_dlgOk').focus();
o.querySelector('#_dlgOk').onclick = () => { o.remove(); resolve(); };
});
};
window._nativeConfirm = window.confirm;
window.confirm = function(msg) {
// Must be synchronous for existing code — use native but can't avoid URL
// So we build a BLOCKING approach with a sync loop... not possible in JS
// Best approach: keep it native but wrap in custom UI via a trick
// Actually: we replace with a custom modal and return a value synchronously
// by using a blocking technique (not possible without freezing)
//
// Practical solution: use native confirm but we'll migrate critical ones.
// For now, override to show our custom dialog for the MOST COMMON case.
//
// Since JS doesn't support sync custom UI, we use the approach of
// making all confirm calls use _confirm() which returns a Promise.
// We keep window.confirm as native fallback for inline onclick handlers.
return window._nativeConfirm(msg);
};
// Async versions for use in our code
window._confirm = function(msg) {
return new Promise(resolve => {
const o = _createOverlay();
o.innerHTML = '<div style="' + _boxStyle + '">' +
'<div style="' + _msgStyle + '">' + String(msg || '').replace(/</g,'&lt;') + '</div>' +
'<div style="' + _btnRow + '">' +
'<button id="_dlgCancel" style="' + _btnOutline + '">取消</button>' +
'<button id="_dlgOk" style="' + _btnPrimary + '">确定</button>' +
'</div></div>';
document.body.appendChild(o);
o.querySelector('#_dlgOk').focus();
o.querySelector('#_dlgOk').onclick = () => { o.remove(); resolve(true); };
o.querySelector('#_dlgCancel').onclick = () => { o.remove(); resolve(false); };
});
};
window._prompt = function(msg, defaultVal) {
return new Promise(resolve => {
const o = _createOverlay();
o.innerHTML = '<div style="' + _boxStyle + '">' +
'<div style="' + _msgStyle + '">' + String(msg || '').replace(/</g,'&lt;') + '</div>' +
'<input id="_dlgInput" type="text" value="' + (defaultVal || '').replace(/"/g,'&quot;') + '" style="width:100%;padding:10px 14px;border:1.5px solid #d4cfc7;border-radius:10px;font-size:14px;margin-bottom:16px;outline:none;font-family:inherit;box-sizing:border-box">' +
'<div style="' + _btnRow + '">' +
'<button id="_dlgCancel" style="' + _btnOutline + '">取消</button>' +
'<button id="_dlgOk" style="' + _btnPrimary + '">确定</button>' +
'</div></div>';
document.body.appendChild(o);
const inp = o.querySelector('#_dlgInput');
inp.focus();
inp.select();
inp.onkeydown = e => { if (e.key === 'Enter') { o.remove(); resolve(inp.value); } };
o.querySelector('#_dlgOk').onclick = () => { o.remove(); resolve(inp.value); };
o.querySelector('#_dlgCancel').onclick = () => { o.remove(); resolve(null); };
});
};
})();
// ============ DATA SAFETY & AUTO-REFRESH ============
// 1. Offline detection + pending write queue
let _isOnline = navigator.onLine;
const _pendingWrites = [];
function _updatePendingBanner() {
// Restore from localStorage to check
let count = _pendingWrites.length;
try { const saved = JSON.parse(localStorage.getItem('oil_pending_writes') || '[]'); count = Math.max(count, saved.length); } catch(e) {}
const existing = document.getElementById('pendingBanner');
if (count > 0) {
if (!existing) {
const b = document.createElement('div');
b.id = 'pendingBanner';
b.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#e65100;color:white;text-align:center;padding:8px 16px;font-size:13px;z-index:9998;display:flex;justify-content:center;align-items:center;gap:10px';
document.body.appendChild(b);
}
const banner = document.getElementById('pendingBanner');
banner.innerHTML = _isOnline
? '⏳ 有 ' + count + ' 条数据正在重新发送...'
: '⚠️ 网络断开,' + count + ' 条数据等待发送 · 请勿清除浏览器数据';
} else if (existing) {
existing.remove();
}
}
window.addEventListener('online', () => {
_isOnline = true;
_updatePendingBanner();
_flushPendingWrites();
});
window.addEventListener('offline', () => {
_isOnline = false;
_updatePendingBanner();
});
// Check pending queue on page load
setTimeout(_updatePendingBanner, 1000);
async function _flushPendingWrites() {
// Also restore from localStorage
try {
const saved = JSON.parse(localStorage.getItem('oil_pending_writes') || '[]');
saved.forEach(w => { if (!_pendingWrites.find(p => p.url === w.url && p.time === w.time)) _pendingWrites.push(w); });
} catch(e) {}
if (!_pendingWrites.length) return;
_showToast('正在保存离线数据...');
let ok = 0;
while (_pendingWrites.length > 0) {
const w = _pendingWrites[0];
try {
// Refresh auth token in case it changed
if (w.options && w.options.headers && _authToken) {
w.options.headers['Authorization'] = 'Bearer ' + _authToken;
}
const r = await fetch(w.url, w.options);
if (r.ok || r.status === 400 || r.status === 404) {
// Success or client error (won't succeed on retry) — remove from queue
_pendingWrites.shift();
ok++;
} else {
// Server error — stop, will retry next time
break;
}
} catch(e) { break; } // Network error — stop
}
try { localStorage.setItem('oil_pending_writes', JSON.stringify(_pendingWrites)); } catch(x) {}
if (ok > 0) _showToast('✅ 已保存 ' + ok + ' 条离线数据');
_updatePendingBanner();
}
// 2. Auto-refresh after deployment
let _serverVersion = null;
function _checkVersion() {
fetch('/api/version').then(r => r.json()).then(d => {
if (_serverVersion && d.version !== _serverVersion) {
_showToast('系统已更新,正在刷新...');
setTimeout(() => location.reload(), 1500);
}
_serverVersion = d.version;
}).catch(() => {});
}
_checkVersion();
// ============ DATA ============
const DEFAULT_OILS = {"小豆蔻": 2.69, "芹菜籽": 1.11, "芫荽": 0.79, "小茴香": 0.52, "生姜": 1.48, "姜黄": 0.98, "缬草": 1.73, "岩兰草": 1.86, "侧柏": 2.1, "桦木": 5.11, "雪松": 0.46, "斯里兰卡肉桂皮": 2.96, "夏威夷檀香": 6.61, "檀香": 7.69, "古巴香脂": 1.11, "乳香": 2.25, "枫香": 0.86, "没药": 2.09, "罗勒": 0.96, "黑云杉": 1.83, "芫荽叶": 0.82, "香茅": 0.61, "丝柏": 0.55, "道格拉斯冷杉": 2.1, "尤加利": 0.66, "扁柏": 2.47, "柠檬尤加利": 0.43, "柠檬草": 0.41, "马郁兰": 0.7, "香蜂草": 8.71, "麦卢卡": 4.62, "西班牙牛至": 0.8, "广藿香": 0.96, "椒样薄荷": 0.75, "苦橙叶": 0.79, "迷迭香": 0.63, "西伯利亚冷杉": 0.61, "西班牙鼠尾草": 0.82, "绿薄荷": 0.89, "茶树": 0.7, "百里香": 1.0, "冬青": 0.84, "蓝艾菊": 7.53, "快乐鼠尾草": 1.16, "丁香花蕾": 0.61, "天竺葵": 1.38, "永久花": 7.15, "茉莉": 26.3, "薰衣草": 0.82, "罗马洋甘菊": 4.52, "依兰依兰": 1.25, "佛手柑": 1.23, "黑胡椒": 2.04, "圆柚": 0.59, "杜松浆果": 2.04, "柠檬": 0.43, "莱姆": 0.48, "山鸡椒": 0.68, "加州胡椒": 2.04, "红橘": 0.46, "野橘": 0.38, "乐释": 1.25, "赋活呼吸": 0.95, "芳香调理": 0.98, "安定情绪": 0.73, "柑橘清新": 0.68, "柑橘绚烂": 0.82, "温柔呵护": 1.59, "完美修护": 1.14, "舒缓": 3.28, "乐活": 1.09, "顺畅呼吸": 0.8, "愈创木": 0.57, "恬家": 0.79, "安宁神气": 1.2, "椰风香草": 3.33, "清醇薄荷": 0.91, "新瑞活力": 0.86, "保卫": 1.13, "净化清新": 0.73, "花样年华焕肤油": 3.66, "天然防护": 0.39, "西洋蓍草": 1.61, "元气": 0.82, "欢欣": 2.31, "抚慰": 3.6, "宽容": 2.1, "鼓舞": 2.2, "热情": 3.82, "静谧": 3.01, "椰子油": 0.05, "植物空胶囊": 0.2, "玫瑰": 28.82, "玫瑰呵护": 2.53, "茉莉呵护": 2.74, "橙花呵护": 2.31, "桂花呵护": 2.58, "木兰呵护": 2.04, "茶树呵护": 0.67, "特瑞活力": 1.25, "全神贯注": 1.72, "新清肌呵护": 1.4, "新清肌调理": 0.93, "当归": 4.84, "月桂叶": 1.0, "橙花": 12.37, "桂花": 10.54, "穗甘松": 4.84, "玫瑰草": 0.64, "罗文莎叶": 0.89, "甜茴香": 0.64, "五味子": 1.0, "印蒿": 1.14, "柠檬香桃木": 0.89, "蓝睡莲呵护": 2.88, "丝柏呵护": 0.54, "乐活呵护": 1.05, "顺畅呼吸呵护": 0.73, "乳香呵护": 2.15, "永久花呵护": 2.47, "全神贯注呵护": 1.08, "薰衣草呵护": 0.78, "保卫呵护": 1.05, "牛至呵护": 0.83, "舒压呵护": 0.67, "薄荷呵护": 0.75, "仕女呵护": 1.29, "山苍子花": 3.39, "白兰叶": 1.3, "舒缓呵护": 1.85, "麦卢卡呵护": 2.58, "温悦舒释": 1.08, "小野菊呵护": 1.96, "鹅掌柴呵护": 1.96, "栀子花呵护": 1.91, "忍冬花呵护": 2.42};
const DEFAULT_OILS_META = {"小豆蔻": {"bottlePrice": 250, "dropCount": 93}, "芹菜籽": {"bottlePrice": 310, "dropCount": 280}, "芫荽": {"bottlePrice": 220, "dropCount": 280}, "小茴香": {"bottlePrice": 145, "dropCount": 280}, "生姜": {"bottlePrice": 415, "dropCount": 280}, "姜黄": {"bottlePrice": 275, "dropCount": 280}, "缬草": {"bottlePrice": 485, "dropCount": 280}, "岩兰草": {"bottlePrice": 520, "dropCount": 280}, "侧柏": {"bottlePrice": 195, "dropCount": 93}, "桦木": {"bottlePrice": 475, "dropCount": 93}, "雪松": {"bottlePrice": 130, "dropCount": 280}, "斯里兰卡肉桂皮": {"bottlePrice": 275, "dropCount": 93}, "夏威夷檀香": {"bottlePrice": 615, "dropCount": 93}, "檀香": {"bottlePrice": 715, "dropCount": 93}, "古巴香脂": {"bottlePrice": 310, "dropCount": 280}, "乳香": {"bottlePrice": 630, "dropCount": 280}, "枫香": {"bottlePrice": 240, "dropCount": 280}, "没药": {"bottlePrice": 585, "dropCount": 280}, "罗勒": {"bottlePrice": 270, "dropCount": 280}, "黑云杉": {"bottlePrice": 170, "dropCount": 93}, "芫荽叶": {"bottlePrice": 230, "dropCount": 280}, "香茅": {"bottlePrice": 170, "dropCount": 280}, "丝柏": {"bottlePrice": 155, "dropCount": 280}, "道格拉斯冷杉": {"bottlePrice": 195, "dropCount": 93}, "尤加利": {"bottlePrice": 185, "dropCount": 280}, "扁柏": {"bottlePrice": 230, "dropCount": 93}, "柠檬尤加利": {"bottlePrice": 120, "dropCount": 280}, "柠檬草": {"bottlePrice": 115, "dropCount": 280}, "马郁兰": {"bottlePrice": 195, "dropCount": 280}, "香蜂草": {"bottlePrice": 810, "dropCount": 93}, "麦卢卡": {"bottlePrice": 430, "dropCount": 93}, "西班牙牛至": {"bottlePrice": 225, "dropCount": 280}, "广藿香": {"bottlePrice": 270, "dropCount": 280}, "椒样薄荷": {"bottlePrice": 210, "dropCount": 280}, "苦橙叶": {"bottlePrice": 220, "dropCount": 280}, "迷迭香": {"bottlePrice": 175, "dropCount": 280}, "西伯利亚冷杉": {"bottlePrice": 170, "dropCount": 280}, "西班牙鼠尾草": {"bottlePrice": 230, "dropCount": 280}, "绿薄荷": {"bottlePrice": 250, "dropCount": 280}, "茶树": {"bottlePrice": 195, "dropCount": 280}, "百里香": {"bottlePrice": 280, "dropCount": 280}, "冬青": {"bottlePrice": 235, "dropCount": 280}, "蓝艾菊": {"bottlePrice": 700, "dropCount": 93}, "快乐鼠尾草": {"bottlePrice": 325, "dropCount": 280}, "丁香花蕾": {"bottlePrice": 170, "dropCount": 280}, "天竺葵": {"bottlePrice": 385, "dropCount": 280}, "永久花": {"bottlePrice": 665, "dropCount": 93}, "茉莉": {"bottlePrice": 1210, "dropCount": 46}, "薰衣草": {"bottlePrice": 230, "dropCount": 280}, "罗马洋甘菊": {"bottlePrice": 420, "dropCount": 93}, "依兰依兰": {"bottlePrice": 350, "dropCount": 280}, "佛手柑": {"bottlePrice": 345, "dropCount": 280}, "黑胡椒": {"bottlePrice": 190, "dropCount": 93}, "圆柚": {"bottlePrice": 165, "dropCount": 280}, "杜松浆果": {"bottlePrice": 190, "dropCount": 93}, "柠檬": {"bottlePrice": 120, "dropCount": 280}, "莱姆": {"bottlePrice": 135, "dropCount": 280}, "山鸡椒": {"bottlePrice": 190, "dropCount": 280}, "加州胡椒": {"bottlePrice": 190, "dropCount": 93}, "红橘": {"bottlePrice": 130, "dropCount": 280}, "野橘": {"bottlePrice": 105, "dropCount": 280}, "乐释": {"bottlePrice": 350, "dropCount": 280}, "赋活呼吸": {"bottlePrice": 265, "dropCount": 280}, "芳香调理": {"bottlePrice": 275, "dropCount": 280}, "安定情绪": {"bottlePrice": 205, "dropCount": 280}, "柑橘清新": {"bottlePrice": 190, "dropCount": 280}, "柑橘绚烂": {"bottlePrice": 230, "dropCount": 280}, "温柔呵护": {"bottlePrice": 445, "dropCount": 280}, "完美修护": {"bottlePrice": 320, "dropCount": 280}, "舒缓": {"bottlePrice": 305, "dropCount": 93}, "乐活": {"bottlePrice": 305, "dropCount": 280}, "顺畅呼吸": {"bottlePrice": 225, "dropCount": 280}, "愈创木": {"bottlePrice": 160, "dropCount": 280}, "恬家": {"bottlePrice": 220, "dropCount": 280}, "安宁神气": {"bottlePrice": 335, "dropCount": 280}, "椰风香草": {"bottlePrice": 310, "dropCount": 93}, "清醇薄荷": {"bottlePrice": 255, "dropCount": 280}, "新瑞活力": {"bottlePrice": 240, "dropCount": 280}, "保卫": {"bottlePrice": 315, "dropCount": 280}, "净化清新": {"bottlePrice": 205, "dropCount": 280}, "花样年华焕肤油": {"bottlePrice": 680, "dropCount": 186}, "天然防护": {"bottlePrice": 110, "dropCount": 280}, "西洋蓍草": {"bottlePrice": 450, "dropCount": 280}, "元气": {"bottlePrice": 230, "dropCount": 280}, "欢欣": {"bottlePrice": 215, "dropCount": 93}, "抚慰": {"bottlePrice": 335, "dropCount": 93}, "宽容": {"bottlePrice": 195, "dropCount": 93}, "鼓舞": {"bottlePrice": 205, "dropCount": 93}, "热情": {"bottlePrice": 355, "dropCount": 93}, "静谧": {"bottlePrice": 280, "dropCount": 93}, "椰子油": {"bottlePrice": 115, "dropCount": 2146}, "植物空胶囊": {"bottlePrice": 32.73, "dropCount": 160}, "玫瑰": {"bottlePrice": 2680, "dropCount": 93}, "玫瑰呵护": {"bottlePrice": 470, "dropCount": 186}, "茉莉呵护": {"bottlePrice": 510, "dropCount": 186}, "橙花呵护": {"bottlePrice": 430, "dropCount": 186}, "桂花呵护": {"bottlePrice": 480, "dropCount": 186}, "木兰呵护": {"bottlePrice": 380, "dropCount": 186}, "茶树呵护": {"bottlePrice": 125, "dropCount": 186}, "特瑞活力": {"bottlePrice": 350, "dropCount": 280}, "全神贯注": {"bottlePrice": 320, "dropCount": 186}, "新清肌呵护": {"bottlePrice": 260, "dropCount": 186}, "新清肌调理": {"bottlePrice": 260, "dropCount": 280}, "当归": {"bottlePrice": 450, "dropCount": 93}, "月桂叶": {"bottlePrice": 280, "dropCount": 280}, "橙花": {"bottlePrice": 1150, "dropCount": 93}, "桂花": {"bottlePrice": 980, "dropCount": 93}, "穗甘松": {"bottlePrice": 450, "dropCount": 93}, "玫瑰草": {"bottlePrice": 180, "dropCount": 280}, "罗文莎叶": {"bottlePrice": 250, "dropCount": 280}, "甜茴香": {"bottlePrice": 180, "dropCount": 280}, "五味子": {"bottlePrice": 280, "dropCount": 280}, "印蒿": {"bottlePrice": 320, "dropCount": 280}, "柠檬香桃木": {"bottlePrice": 250, "dropCount": 280}, "蓝睡莲呵护": {"bottlePrice": 535, "dropCount": 186}, "丝柏呵护": {"bottlePrice": 100, "dropCount": 186}, "乐活呵护": {"bottlePrice": 195, "dropCount": 186}, "顺畅呼吸呵护": {"bottlePrice": 135, "dropCount": 186}, "乳香呵护": {"bottlePrice": 400, "dropCount": 186}, "永久花呵护": {"bottlePrice": 460, "dropCount": 186}, "全神贯注呵护": {"bottlePrice": 200, "dropCount": 186}, "薰衣草呵护": {"bottlePrice": 145, "dropCount": 186}, "保卫呵护": {"bottlePrice": 195, "dropCount": 186}, "牛至呵护": {"bottlePrice": 155, "dropCount": 186}, "舒压呵护": {"bottlePrice": 125, "dropCount": 186}, "薄荷呵护": {"bottlePrice": 140, "dropCount": 186}, "仕女呵护": {"bottlePrice": 240, "dropCount": 186}, "山苍子花": {"bottlePrice": 315, "dropCount": 93}, "白兰叶": {"bottlePrice": 365, "dropCount": 280}, "舒缓呵护": {"bottlePrice": 345, "dropCount": 186}, "麦卢卡呵护": {"bottlePrice": 480, "dropCount": 186}, "温悦舒释": {"bottlePrice": 200, "dropCount": 186}, "小野菊呵护": {"bottlePrice": 365, "dropCount": 186}, "鹅掌柴呵护": {"bottlePrice": 365, "dropCount": 186}, "栀子花呵护": {"bottlePrice": 355, "dropCount": 186}, "忍冬花呵护": {"bottlePrice": 450, "dropCount": 186}};
// OILS stores per-drop price for calculations
// OILS_META stores {bottlePrice, dropCount} for editing
let OILS = {};
let OILS_META = {};
function loadOils() {}
function saveOils() {}
const DEFAULT_RECIPES = [
{name:"酸痛包",note:"",ingredients:[{oil:"椒样薄荷",drops:1},{oil:"舒缓",drops:2},{oil:"芳香调理",drops:1},{oil:"冬青",drops:1},{oil:"柠檬草",drops:1},{oil:"生姜",drops:2},{oil:"茶树",drops:1},{oil:"乳香",drops:1},{oil:"椰子油",drops:10}]},
{name:"小v脸",note:"",ingredients:[{oil:"丝柏",drops:2},{oil:"乳香",drops:1},{oil:"西洋蓍草",drops:1},{oil:"永久花",drops:1},{oil:"椰子油",drops:10}]},
{name:"健脾化湿精油浴",note:"",ingredients:[{oil:"西伯利亚冷杉",drops:3},{oil:"芫荽",drops:3},{oil:"红橘",drops:2},{oil:"椰子油",drops:20}]},
{name:"一夜好眠精油浴",note:"",ingredients:[{oil:"安宁神气",drops:1},{oil:"岩兰草",drops:1},{oil:"乐释",drops:1},{oil:"薰衣草",drops:1},{oil:"乳香",drops:1},{oil:"安定情绪",drops:1},{oil:"椰子油",drops:10}]},
{name:"生发",note:"",ingredients:[{oil:"椒样薄荷",drops:1},{oil:"茶树",drops:1},{oil:"迷迭香",drops:1},{oil:"丝柏",drops:2},{oil:"生姜",drops:1},{oil:"雪松",drops:2},{oil:"薰衣草",drops:1},{oil:"乳香",drops:2},{oil:"安定情绪",drops:1},{oil:"椰子油",drops:15}]},
{name:"湿疹舒缓",note:"",ingredients:[{oil:"广藿香",drops:1},{oil:"绿薄荷",drops:1},{oil:"麦卢卡",drops:1},{oil:"永久花",drops:1},{oil:"蓝艾菊",drops:1},{oil:"椰子油",drops:10}]},
{name:"乳腺疏通",note:"",ingredients:[{oil:"乳香",drops:1},{oil:"薰衣草",drops:1},{oil:"丁香花蕾",drops:1},{oil:"柑橘清新",drops:1},{oil:"椰子油",drops:10}]},
{name:"缓解酸痛精油刮痧",note:"",ingredients:[{oil:"椒样薄荷",drops:1},{oil:"舒缓",drops:2},{oil:"芳香调理",drops:1},{oil:"冬青",drops:1},{oil:"柠檬草",drops:1},{oil:"生姜",drops:2},{oil:"茶树",drops:1},{oil:"乳香",drops:1},{oil:"椰子油",drops:10}]},
{name:"灰指甲",note:"",ingredients:[{oil:"西班牙牛至",drops:1},{oil:"椰子油",drops:6}]},
{name:"白发转黑",note:"",ingredients:[{oil:"乳香",drops:2},{oil:"快乐鼠尾草",drops:1},{oil:"依兰依兰",drops:1},{oil:"生姜",drops:1},{oil:"薰衣草",drops:1},{oil:"扁柏",drops:1},{oil:"雪松",drops:1},{oil:"椰子油",drops:10}]},
{name:"紫外线修复",note:"有艾草时可加入艾草",ingredients:[{oil:"乳香",drops:1},{oil:"西洋蓍草",drops:1},{oil:"蓝艾菊",drops:1},{oil:"麦卢卡",drops:1},{oil:"侧柏",drops:1},{oil:"椰子油",drops:15}]},
{name:"瘦身带脉",note:"",ingredients:[{oil:"丝柏",drops:1},{oil:"圆柚",drops:1},{oil:"新瑞活力",drops:1},{oil:"永久花",drops:1},{oil:"黑胡椒",drops:1},{oil:"姜黄",drops:1},{oil:"柠檬",drops:1},{oil:"乳香",drops:1},{oil:"椰子油",drops:15}]},
{name:"私密护理",note:"",ingredients:[{oil:"西洋蓍草",drops:2},{oil:"没药",drops:2},{oil:"快乐鼠尾草",drops:1},{oil:"依兰依兰",drops:1},{oil:"茶树",drops:0.5},{oil:"丝柏",drops:0.5},{oil:"椰子油",drops:8},{oil:"植物空胶囊",drops:1}]},
{name:"脚气/头皮屑",note:"",ingredients:[{oil:"茶树",drops:1},{oil:"椰子油",drops:6}]},
{name:"痘痘",note:"",ingredients:[{oil:"广藿香",drops:1},{oil:"姜黄",drops:1},{oil:"西洋蓍草",drops:2},{oil:"茶树",drops:1},{oil:"麦卢卡",drops:1},{oil:"椰子油",drops:20}]},
{name:"淋巴排毒",note:"",ingredients:[{oil:"乳香",drops:1},{oil:"丝柏",drops:1},{oil:"圆柚",drops:1},{oil:"迷迭香",drops:1},{oil:"杜松浆果",drops:1},{oil:"椰子油",drops:10}]},
{name:"驱寒/祛湿精油浴",note:"有艾草时可加入",ingredients:[{oil:"杜松浆果",drops:3},{oil:"广藿香",drops:3},{oil:"生姜",drops:2},{oil:"椰子油",drops:20}]},
{name:"荷尔蒙调节/更年期护理精油浴",note:"",ingredients:[{oil:"依兰依兰",drops:2},{oil:"温柔呵护",drops:2},{oil:"天竺葵",drops:2},{oil:"快乐鼠尾草",drops:2},{oil:"岩兰草",drops:1},{oil:"椰子油",drops:10}]},
{name:"情绪管理",note:"",ingredients:[{oil:"抚慰",drops:1},{oil:"热情",drops:1},{oil:"欢欣",drops:1},{oil:"鼓舞",drops:1},{oil:"宽容",drops:1},{oil:"静谧",drops:1},{oil:"椰子油",drops:10}]},
{name:"发膜",note:"",ingredients:[{oil:"乳香",drops:2},{oil:"茶树",drops:2},{oil:"生姜",drops:2}]},
{name:"脾胃养护1",note:"",tags:[],ingredients:[{oil:"生姜",drops:10},{oil:"红橘",drops:20},{oil:"乐活",drops:20},{oil:"广藿香",drops:15},{oil:"岩兰草",drops:10}]},
{name:"脾胃养护2",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"红橘",drops:10},{oil:"生姜",drops:10},{oil:"乐活",drops:15}]},
{name:"招财开运油",note:"",tags:[],ingredients:[{oil:"夏威夷檀香",drops:1},{oil:"岩兰草",drops:1},{oil:"天竺葵",drops:2},{oil:"佛手柑",drops:10},{oil:"野橘",drops:20}]},
{name:"植物热玛吉(玫瑰纯油版)",note:"",tags:[],ingredients:[{oil:"西洋蓍草",drops:20},{oil:"玫瑰",drops:5},{oil:"丝柏",drops:10},{oil:"永久花",drops:5},{oil:"乳香",drops:10}]},
{name:"植物热玛吉(玫瑰呵护版)",note:"",tags:[],ingredients:[{oil:"西洋蓍草",drops:20},{oil:"玫瑰",drops:5},{oil:"丝柏",drops:10},{oil:"永久花",drops:5},{oil:"乳香",drops:10}]},
{name:"十全大补",note:"",tags:[],ingredients:[{oil:"快乐鼠尾草",drops:15},{oil:"花样年华焕肤油",drops:10},{oil:"温柔呵护",drops:8},{oil:"玫瑰呵护",drops:15},{oil:"茉莉呵护",drops:15},{oil:"温柔呵护",drops:10}]},
{name:"豪华头疗",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:25},{oil:"丝柏",drops:15},{oil:"夏威夷檀香",drops:10},{oil:"完美修护",drops:20},{oil:"乳香",drops:20},{oil:"生姜",drops:15},{oil:"雪松",drops:15},{oil:"马郁兰",drops:10},{oil:"安定情绪",drops:20},{oil:"依兰依兰",drops:10}]},
{name:"酸痛包1",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"舒缓",drops:10},{oil:"芳香调理",drops:5},{oil:"冬青",drops:10},{oil:"柠檬草",drops:5},{oil:"生姜",drops:5},{oil:"西班牙牛至",drops:5},{oil:"乳香",drops:5}]},
{name:"酸痛包2",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"舒缓",drops:5},{oil:"芳香调理",drops:5},{oil:"柠檬草",drops:5},{oil:"生姜",drops:5},{oil:"茶树",drops:5},{oil:"乳香",drops:5}]},
{name:"明目青睐1",note:"",tags:[],ingredients:[{oil:"柠檬草",drops:6},{oil:"乳香",drops:10},{oil:"永久花",drops:9},{oil:"花样年华焕肤油",drops:10},{oil:"快乐鼠尾草",drops:10}]},
{name:"明目青睐2",note:"",tags:[],ingredients:[{oil:"芳香调理",drops:3},{oil:"柠檬草",drops:3},{oil:"快乐鼠尾草",drops:7},{oil:"乳香",drops:7}]},
{name:"带脉排毒瘦腰1",note:"",tags:[],ingredients:[{oil:"广藿香",drops:5},{oil:"黑胡椒",drops:10},{oil:"天竺葵",drops:20},{oil:"新瑞活力",drops:30},{oil:"丝柏",drops:30},{oil:"乳香",drops:20},{oil:"杜松浆果",drops:20}]},
{name:"带脉瘦腰2",note:"",tags:[],ingredients:[{oil:"杜松浆果",drops:5},{oil:"生姜",drops:5},{oil:"圆柚",drops:10},{oil:"丝柏",drops:10},{oil:"乳香",drops:7},{oil:"天竺葵",drops:5},{oil:"新瑞活力",drops:10}]},
{name:"缓解头痛(强)",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:10},{oil:"薰衣草",drops:10},{oil:"罗勒",drops:15},{oil:"马郁兰",drops:15},{oil:"舒缓",drops:50}]},
{name:"1清咽止咳",note:"",tags:[],ingredients:[{oil:"没药",drops:10},{oil:"乳香",drops:15},{oil:"尤加利",drops:15},{oil:"小豆蔻",drops:10},{oil:"顺畅呼吸",drops:20},{oil:"西伯利亚冷杉",drops:10}]},
{name:"清咽止咳2",note:"",tags:[],ingredients:[{oil:"茶树",drops:5},{oil:"乳香",drops:10},{oil:"保卫",drops:5},{oil:"顺畅呼吸",drops:10}]},
{name:"提升免疫(强)",note:"",tags:[],ingredients:[{oil:"西班牙牛至",drops:5},{oil:"侧柏",drops:10},{oil:"百里香",drops:10},{oil:"柠檬草",drops:10},{oil:"乳香",drops:20},{oil:"茶树",drops:10},{oil:"保卫",drops:10}]},
{name:"护肝排毒(强)",note:"",tags:[],ingredients:[{oil:"柠檬",drops:20},{oil:"元气",drops:20},{oil:"当归",drops:1},{oil:"芹菜籽",drops:10},{oil:"天竺葵",drops:9},{oil:"迷迭香",drops:15}]},
{name:"强心护心(强)",note:"",tags:[],ingredients:[{oil:"百里香",drops:10},{oil:"香蜂草",drops:15},{oil:"古巴香脂",drops:20},{oil:"依兰依兰",drops:15},{oil:"快乐鼠尾草",drops:15}]},
{name:"通鼻消炎1",note:"",tags:[],ingredients:[{oil:"茶树",drops:5},{oil:"椒样薄荷",drops:5},{oil:"乳香",drops:10},{oil:"尤加利",drops:10},{oil:"蓝艾菊",drops:5},{oil:"迷迭香",drops:10},{oil:"顺畅呼吸",drops:20}]},
{name:"通鼻消炎2",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"茶树",drops:5},{oil:"尤加利",drops:10},{oil:"顺畅呼吸",drops:15}]},
{name:"过敏湿疹(强)",note:"",tags:[],ingredients:[{oil:"罗马洋甘菊",drops:10},{oil:"蓝艾菊",drops:20},{oil:"薰衣草",drops:10},{oil:"香蜂草",drops:15},{oil:"绿薄荷",drops:10},{oil:"永久花",drops:10},{oil:"广藿香",drops:15},{oil:"乳香",drops:10}]},
{name:"强肾化水(强)",note:"",tags:[],ingredients:[{oil:"茉莉呵护",drops:10},{oil:"檀香",drops:15},{oil:"黑胡椒",drops:10},{oil:"依兰依兰",drops:10},{oil:"古巴香脂",drops:10},{oil:"杜松浆果",drops:20}]},
{name:"防疫病毒(强)",note:"",tags:[],ingredients:[{oil:"尤加利",drops:5},{oil:"侧柏",drops:5},{oil:"百里香",drops:5},{oil:"茶树",drops:10},{oil:"保卫",drops:10}]},
{name:"消富贵包(强)",note:"",tags:[],ingredients:[{oil:"芳香调理",drops:10},{oil:"永久花",drops:10},{oil:"生姜",drops:10},{oil:"舒缓",drops:5},{oil:"柠檬草",drops:10},{oil:"完美修护",drops:10},{oil:"乳香",drops:10}]},
{name:"淋巴排毒(强)",note:"",tags:[],ingredients:[{oil:"柑橘清新",drops:5},{oil:"丁香花蕾",drops:10},{oil:"柠檬草",drops:5},{oil:"没药",drops:20},{oil:"乳香",drops:20}]},
{name:"祛湿化滞(强)",note:"",tags:[],ingredients:[{oil:"黑胡椒",drops:8},{oil:"斯里兰卡肉桂皮",drops:6},{oil:"圆柚",drops:9},{oil:"岩兰草",drops:8},{oil:"广藿香",drops:9},{oil:"姜黄",drops:10},{oil:"西洋蓍草",drops:10}]},
{name:"肺部结节",note:"",tags:[],ingredients:[{oil:"完美修护",drops:10},{oil:"乳香",drops:10},{oil:"椒样薄荷",drops:5},{oil:"柑橘清新",drops:5},{oil:"赋活呼吸",drops:5},{oil:"天竺葵",drops:10}]},
{name:"尿路感染1",note:"",tags:[],ingredients:[{oil:"天竺葵",drops:10},{oil:"杜松浆果",drops:5},{oil:"西班牙牛至",drops:5},{oil:"柠檬草",drops:5},{oil:"罗勒",drops:5},{oil:"保卫",drops:5},{oil:"柠檬",drops:5}]},
{name:"尿路感染2",note:"",tags:[],ingredients:[{oil:"西班牙牛至",drops:1},{oil:"柠檬草",drops:1}]},
{name:"养前列腺",note:"",tags:[],ingredients:[{oil:"檀香",drops:3},{oil:"杜松浆果",drops:8},{oil:"斯里兰卡肉桂皮",drops:2},{oil:"芳香调理",drops:6},{oil:"茉莉",drops:1},{oil:"快乐鼠尾草",drops:3},{oil:"五味子",drops:15},{oil:"百里香",drops:2}]},
{name:"皮外损伤",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"茶树",drops:10},{oil:"薰衣草",drops:8},{oil:"没药",drops:4},{oil:"永久花",drops:4}]},
{name:"消脂肪肝",note:"",tags:[],ingredients:[{oil:"杜松浆果",drops:5},{oil:"元气",drops:5},{oil:"乳香",drops:2},{oil:"柠檬",drops:3},{oil:"柠檬草",drops:5},{oil:"新瑞活力",drops:10}]},
{name:"平衡血糖",note:"",tags:[],ingredients:[{oil:"新瑞活力",drops:10},{oil:"薰衣草",drops:5},{oil:"斯里兰卡肉桂皮",drops:3},{oil:"迷迭香",drops:3},{oil:"芫荽",drops:3},{oil:"山鸡椒",drops:3},{oil:"天竺葵",drops:3}]},
{name:"肚痛腹泻",note:"",tags:[],ingredients:[{oil:"乐活",drops:16},{oil:"柠檬草",drops:8},{oil:"生姜",drops:6},{oil:"西班牙牛至",drops:6},{oil:"罗勒",drops:4}]},
{name:"韧带扭伤",note:"",tags:[],ingredients:[{oil:"芳香调理",drops:8},{oil:"姜黄",drops:5},{oil:"柠檬草",drops:8},{oil:"西班牙牛至",drops:3},{oil:"永久花",drops:5},{oil:"罗勒",drops:2},{oil:"古巴香脂",drops:6}]},
{name:"血脂稳定",note:"",tags:[],ingredients:[{oil:"新瑞活力",drops:10},{oil:"柠檬草",drops:5},{oil:"永久花",drops:5},{oil:"小茴香",drops:5},{oil:"罗勒",drops:5},{oil:"元气",drops:5}]},
{name:"尿床尿频",note:"",tags:[],ingredients:[{oil:"杜松浆果",drops:16},{oil:"柠檬草",drops:12},{oil:"侧柏",drops:8},{oil:"檀香",drops:4}]},
{name:"冻疮修复",note:"",tags:[],ingredients:[{oil:"乳香",drops:5},{oil:"薰衣草",drops:5},{oil:"黑胡椒",drops:3},{oil:"没药",drops:3},{oil:"迷迭香",drops:5},{oil:"生姜",drops:5},{oil:"天竺葵",drops:10}]},
{name:"皮下囊肿",note:"",tags:[],ingredients:[{oil:"西洋蓍草",drops:5},{oil:"乳香",drops:10},{oil:"百里香",drops:5},{oil:"西班牙牛至",drops:5},{oil:"广藿香",drops:10},{oil:"丁香花蕾",drops:5}]},
{name:"疏肝解郁",note:"",tags:[],ingredients:[{oil:"天竺葵",drops:5},{oil:"芹菜籽",drops:2},{oil:"岩兰草",drops:5},{oil:"元气",drops:8},{oil:"佛手柑",drops:5},{oil:"罗马洋甘菊",drops:5}]},
{name:"大脑抗衰",note:"",tags:[],ingredients:[{oil:"特瑞活力",drops:10},{oil:"完美修护",drops:10},{oil:"乳香",drops:10},{oil:"迷迭香",drops:5},{oil:"乐释",drops:5},{oil:"广藿香",drops:5},{oil:"古巴香脂",drops:5}]},
{name:"神经麻木",note:"",tags:[],ingredients:[{oil:"特瑞活力",drops:10},{oil:"完美修护",drops:10},{oil:"永久花",drops:5},{oil:"百里香",drops:5},{oil:"古巴香脂",drops:10},{oil:"马郁兰",drops:5},{oil:"乳香",drops:5}]},
{name:"口唇疱疹1",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:5},{oil:"罗马洋甘菊",drops:7},{oil:"茶树",drops:5},{oil:"乳香",drops:5},{oil:"玫瑰草",drops:3},{oil:"没药",drops:5}]},
{name:"口唇疱疹2",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:6},{oil:"罗马洋甘菊",drops:6},{oil:"罗文莎叶",drops:6}]},
{name:"春季用油",note:"",tags:[],ingredients:[{oil:"黑云杉",drops:3},{oil:"柠檬草",drops:5},{oil:"杜松浆果",drops:5},{oil:"芹菜籽",drops:5},{oil:"天竺葵",drops:5},{oil:"莱姆",drops:10},{oil:"元气",drops:10}]},
{name:"夏季用油",note:"",tags:[],ingredients:[{oil:"生姜",drops:5},{oil:"杜松浆果",drops:5},{oil:"丝柏",drops:5},{oil:"圆柚",drops:5},{oil:"斯里兰卡肉桂皮",drops:3},{oil:"西伯利亚冷杉",drops:5},{oil:"广藿香",drops:7},{oil:"乐活",drops:10}]},
{name:"秋季用油",note:"",tags:[],ingredients:[{oil:"西伯利亚冷杉",drops:5},{oil:"茶树",drops:5},{oil:"小豆蔻",drops:3},{oil:"丁香花蕾",drops:2},{oil:"迷迭香",drops:3},{oil:"乳香",drops:7},{oil:"顺畅呼吸",drops:10}]},
{name:"冬季用油",note:"",tags:[],ingredients:[{oil:"茉莉呵护",drops:10},{oil:"生姜",drops:5},{oil:"雪松",drops:5},{oil:"黑胡椒",drops:2},{oil:"檀香",drops:3},{oil:"五味子",drops:3},{oil:"杜松浆果",drops:5},{oil:"天竺葵",drops:5},{oil:"乳香",drops:5}]},
{name:"祛湿丸子",note:"",tags:[],ingredients:[{oil:"斯里兰卡肉桂皮",drops:1},{oil:"圆柚",drops:2},{oil:"姜黄",drops:2},{oil:"黑胡椒",drops:1},{oil:"岩兰草",drops:2},{oil:"广藿香",drops:2},{oil:"西洋蓍草",drops:2}]},
{name:"三伏排湿",note:"",tags:[],ingredients:[{oil:"杜松浆果",drops:5},{oil:"芳香调理",drops:5},{oil:"圆柚",drops:5},{oil:"元气",drops:5},{oil:"新瑞活力",drops:5},{oil:"生姜",drops:5},{oil:"广藿香",drops:5}]},
{name:"三伏晒背",note:"",tags:[],ingredients:[{oil:"乳香",drops:5},{oil:"薰衣草",drops:5},{oil:"罗马洋甘菊",drops:5},{oil:"永久花",drops:5},{oil:"蓝艾菊",drops:3},{oil:"西洋蓍草",drops:10}]},
{name:"三伏百会",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"特瑞活力",drops:10},{oil:"古巴香脂",drops:10}]},
{name:"三伏八髎",note:"",tags:[],ingredients:[{oil:"生姜",drops:10},{oil:"乳香",drops:10},{oil:"印蒿",drops:3},{oil:"温柔呵护",drops:4},{oil:"完美修护",drops:6},{oil:"花样年华焕肤油",drops:4}]},
{name:"三伏大椎",note:"",tags:[],ingredients:[{oil:"斯里兰卡肉桂皮",drops:5},{oil:"檀香",drops:5},{oil:"西洋蓍草",drops:10},{oil:"生姜",drops:5},{oil:"乳香",drops:10},{oil:"广藿香",drops:10}]},
{name:"三伏檀中",note:"",tags:[],ingredients:[{oil:"夏威夷檀香",drops:2},{oil:"香蜂草",drops:30}]},
{name:"太伏太溪",note:"",tags:[],ingredients:[{oil:"元气",drops:15},{oil:"特瑞活力",drops:15}]},
{name:"三伏扶阳",note:"",tags:[],ingredients:[{oil:"乳香",drops:8},{oil:"广藿香",drops:3},{oil:"依兰依兰",drops:3},{oil:"生姜",drops:8},{oil:"檀香",drops:5},{oil:"斯里兰卡肉桂皮",drops:4},{oil:"当归",drops:2},{oil:"黑胡椒",drops:8},{oil:"柠檬草",drops:5},{oil:"杜松浆果",drops:8}]},
{name:"三阴交油",note:"",tags:[],ingredients:[{oil:"生姜",drops:6},{oil:"乳香",drops:10},{oil:"杜松浆果",drops:10},{oil:"快乐鼠尾草",drops:4}]},
{name:"八虚用油",note:"",tags:[],ingredients:[{oil:"元气",drops:5},{oil:"圆柚",drops:5},{oil:"新瑞活力",drops:5},{oil:"乳香",drops:5},{oil:"柠檬草",drops:5},{oil:"芳香调理",drops:5}]},
{name:"冬日小火炉",note:"",tags:[],ingredients:[{oil:"山鸡椒",drops:6},{oil:"生姜",drops:6},{oil:"黑胡椒",drops:6},{oil:"茉莉",drops:2},{oil:"柠檬草",drops:6},{oil:"天竺葵",drops:6},{oil:"温柔呵护",drops:10},{oil:"小茴香",drops:4}]},
{name:"女性内分泌",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:5},{oil:"乳香",drops:5},{oil:"依兰依兰",drops:5},{oil:"快乐鼠尾草",drops:5},{oil:"温柔呵护",drops:10},{oil:"温柔呵护",drops:5}]},
{name:"提升免疫力",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"茶树",drops:20},{oil:"保卫",drops:20}]},
{name:"面部精华",note:"",tags:[],ingredients:[{oil:"天竺葵",drops:5},{oil:"丝柏",drops:5},{oil:"雪松",drops:5},{oil:"薰衣草",drops:5},{oil:"乳香",drops:5},{oil:"西洋蓍草",drops:10}]},
{name:"日常头疗",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"茶树",drops:5},{oil:"野橘",drops:5},{oil:"丝柏",drops:10},{oil:"生姜",drops:5},{oil:"雪松",drops:10},{oil:"薰衣草",drops:5},{oil:"乳香",drops:7},{oil:"安定情绪",drops:7}]},
{name:"肝肾保护",note:"",tags:[],ingredients:[{oil:"天竺葵",drops:5},{oil:"生姜",drops:5},{oil:"杜松浆果",drops:5},{oil:"柠檬",drops:10},{oil:"元气",drops:5},{oil:"柠檬草",drops:5}]},
{name:"情绪能量油",note:"",tags:[],ingredients:[{oil:"乳香",drops:1},{oil:"柑橘绚烂",drops:10},{oil:"柑橘清新",drops:10},{oil:"安宁神气",drops:5},{oil:"安定情绪",drops:5}]},
{name:"养心宁神",note:"",tags:[],ingredients:[{oil:"依兰依兰",drops:5},{oil:"古巴香脂",drops:10},{oil:"乳香",drops:15}]},
{name:"安稳睡眠",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"薰衣草",drops:10},{oil:"安定情绪",drops:10}]},
{name:"过敏体质",note:"",tags:[],ingredients:[{oil:"罗马洋甘菊",drops:20},{oil:"椒样薄荷",drops:10},{oil:"薰衣草",drops:10},{oil:"柠檬",drops:10}]},
{name:"淋巴结节",note:"",tags:[],ingredients:[{oil:"完美修护",drops:7},{oil:"丝柏",drops:5},{oil:"柠檬草",drops:7},{oil:"乳香",drops:10},{oil:"芳香调理",drops:5}]},
{name:"甲状腺结节",note:"",tags:[],ingredients:[{oil:"芳香调理",drops:5},{oil:"椒样薄荷",drops:5},{oil:"丁香花蕾",drops:5},{oil:"柠檬草",drops:5},{oil:"完美修护",drops:10},{oil:"乳香",drops:10}]},
{name:"皮肤过敏",note:"",tags:[],ingredients:[{oil:"罗马洋甘菊",drops:8},{oil:"西洋蓍草",drops:10},{oil:"雪松",drops:5},{oil:"侧柏",drops:2},{oil:"茶树",drops:5},{oil:"乳香",drops:5}]},
{name:"止鼾安睡",note:"",tags:[],ingredients:[{oil:"安宁神气",drops:10},{oil:"罗勒",drops:5},{oil:"丝柏",drops:5},{oil:"道格拉斯冷杉",drops:5},{oil:"百里香",drops:5},{oil:"顺畅呼吸",drops:10}]},
{name:"哮喘缓解",note:"",tags:[],ingredients:[{oil:"顺畅呼吸",drops:10},{oil:"蓝艾菊",drops:5},{oil:"柠檬",drops:5},{oil:"薰衣草",drops:5},{oil:"椒样薄荷",drops:5},{oil:"赋活呼吸",drops:10}]},
{name:"带状疱疹",note:"",tags:[],ingredients:[{oil:"广藿香",drops:3},{oil:"佛手柑",drops:5},{oil:"天竺葵",drops:5},{oil:"丁香花蕾",drops:5},{oil:"百里香",drops:5},{oil:"香蜂草",drops:3},{oil:"完美修护",drops:7},{oil:"没药",drops:5},{oil:"乳香",drops:7}]},
{name:"夏日社痱",note:"",tags:[],ingredients:[{oil:"罗马洋甘菊",drops:8},{oil:"椒样薄荷",drops:8},{oil:"薰衣草",drops:6},{oil:"广藿香",drops:8}]},
{name:"耳聋耳鸣",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:5},{oil:"香蜂草",drops:3},{oil:"天竺葵",drops:5},{oil:"罗勒",drops:10},{oil:"永久花",drops:5},{oil:"乳香",drops:10}]},
{name:"减脂瘦身",note:"",tags:[],ingredients:[{oil:"天竺葵",drops:5},{oil:"乳香",drops:8},{oil:"杜松浆果",drops:10},{oil:"圆柚",drops:10},{oil:"迷迭香",drops:7},{oil:"丝柏",drops:15},{oil:"新瑞活力",drops:20}]},
{name:"痔疮用油",note:"",tags:[],ingredients:[{oil:"没药",drops:10},{oil:"乳香",drops:10},{oil:"天竺葵",drops:10},{oil:"永久花",drops:10},{oil:"丝柏",drops:15},{oil:"茶树",drops:20}]},
{name:"静脉曲张1",note:"",tags:[],ingredients:[{oil:"甜茴香",drops:7},{oil:"丝柏",drops:15},{oil:"薰衣草",drops:10},{oil:"柠檬草",drops:8},{oil:"永久花",drops:5},{oil:"芳香调理",drops:5}]},
{name:"静脉曲张2",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:3},{oil:"黑胡椒",drops:5},{oil:"永久花",drops:8},{oil:"丝柏",drops:10}]},
{name:"痛风缓解",note:"",tags:[],ingredients:[{oil:"柠檬草",drops:8},{oil:"西伯利亚冷杉",drops:5},{oil:"芳香调理",drops:10},{oil:"舒缓",drops:5},{oil:"西班牙牛至",drops:3},{oil:"乳香",drops:5},{oil:"香蜂草",drops:3}]},
{name:"滑膜炎症",note:"",tags:[],ingredients:[{oil:"丝柏",drops:5},{oil:"生姜",drops:5},{oil:"椒样薄荷",drops:5},{oil:"罗勒",drops:5},{oil:"冬青",drops:3},{oil:"乳香",drops:10},{oil:"道格拉斯冷杉",drops:5}]},
{name:"腱鞘炎症",note:"",tags:[],ingredients:[{oil:"罗勒",drops:5},{oil:"丝柏",drops:5},{oil:"乳香",drops:5},{oil:"薰衣草",drops:5},{oil:"圆柚",drops:5},{oil:"柠檬草",drops:10}]},
{name:"腰椎滑脱",note:"",tags:[],ingredients:[{oil:"丝柏",drops:5},{oil:"舒缓",drops:5},{oil:"椒样薄荷",drops:5},{oil:"檀香",drops:5},{oil:"冬青",drops:5},{oil:"马郁兰",drops:5},{oil:"乳香",drops:5}]},
{name:"暖宫调经1",note:"",tags:[],ingredients:[{oil:"温柔呵护",drops:10},{oil:"生姜",drops:10},{oil:"黑胡椒",drops:10},{oil:"天竺葵",drops:5},{oil:"温柔呵护",drops:25},{oil:"快乐鼠尾草",drops:15}]},
{name:"暖宫调经2",note:"",tags:[],ingredients:[{oil:"温柔呵护",drops:10},{oil:"生姜",drops:6},{oil:"黑胡椒",drops:6},{oil:"天竺葵",drops:3},{oil:"温柔呵护",drops:17},{oil:"快乐鼠尾草",drops:10}]},
{name:"经期止痛",note:"",tags:[],ingredients:[{oil:"黑胡椒",drops:5},{oil:"舒缓",drops:10},{oil:"生姜",drops:5},{oil:"乳香",drops:10},{oil:"温柔呵护",drops:10},{oil:"温柔呵护",drops:20},{oil:"薰衣草",drops:10}]},
{name:"乳腺增生",note:"",tags:[],ingredients:[{oil:"完美修护",drops:10},{oil:"薰衣草",drops:5},{oil:"迷迭香",drops:5},{oil:"百里香",drops:5},{oil:"丁香花蕾",drops:5},{oil:"柑橘清新",drops:10}]},
{name:"丰胸挺拨",note:"",tags:[],ingredients:[{oil:"小茴香",drops:20},{oil:"依兰依兰",drops:10},{oil:"温柔呵护",drops:30},{oil:"西洋蓍草",drops:30},{oil:"快乐鼠尾草",drops:10}]},
{name:"私密紧致",note:"",tags:[],ingredients:[{oil:"玫瑰呵护",drops:10},{oil:"茉莉呵护",drops:10},{oil:"丝柏",drops:15},{oil:"依兰依兰",drops:10},{oil:"快乐鼠尾草",drops:15}]},
{name:"乳腺结节1",note:"",tags:[],ingredients:[{oil:"完美修护",drops:10},{oil:"柑橘清新",drops:5},{oil:"没药",drops:10},{oil:"姜黄",drops:10},{oil:"丁香花蕾",drops:5},{oil:"乳香",drops:20}]},
{name:"乳腺结节2",note:"",tags:[],ingredients:[{oil:"丁香花蕾",drops:10},{oil:"乳香",drops:5},{oil:"柑橘清新",drops:10},{oil:"完美修护",drops:10},{oil:"柠檬草",drops:5}]},
{name:"手脚冰凉",note:"",tags:[],ingredients:[{oil:"生姜",drops:20},{oil:"天竺葵",drops:10},{oil:"岩兰草",drops:10},{oil:"柠檬草",drops:10},{oil:"黑胡椒",drops:15},{oil:"野橘",drops:10}]},
{name:"修复腹直肌",note:"",tags:[],ingredients:[{oil:"丝柏",drops:10},{oil:"乳香",drops:5},{oil:"永久花",drops:5},{oil:"圆柚",drops:10},{oil:"广藿香",drops:10},{oil:"柠檬草",drops:10}]},
{name:"子宫肌瘤1",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:5},{oil:"完美修护",drops:10},{oil:"安定情绪",drops:5},{oil:"西班牙牛至",drops:3},{oil:"姜黄",drops:10},{oil:"天竺葵",drops:5},{oil:"永久花",drops:5},{oil:"乳香",drops:10}]},
{name:"子宫肌瘤2",note:"",tags:[],ingredients:[{oil:"乳香",drops:5},{oil:"罗勒",drops:6},{oil:"天竺葵",drops:10}]},
{name:"卵巢囊肿1",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:8},{oil:"完美修护",drops:5},{oil:"温柔呵护",drops:50},{oil:"斯里兰卡肉桂皮",drops:5},{oil:"丝柏",drops:8},{oil:"迷迭香",drops:5},{oil:"安定情绪",drops:8},{oil:"乳香",drops:6}]},
{name:"卵巢囊肿2",note:"",tags:[],ingredients:[{oil:"乳香",drops:2},{oil:"温柔呵护",drops:2},{oil:"西班牙牛至",drops:1},{oil:"快乐鼠尾草",drops:1}]},
{name:"美背",note:"",tags:[],ingredients:[{oil:"夏威夷檀香",drops:10},{oil:"黑云杉",drops:15},{oil:"雪松",drops:10},{oil:"依兰依兰",drops:10},{oil:"佛手柑",drops:15},{oil:"圆柚",drops:10},{oil:"玫瑰呵护",drops:15},{oil:"当归",drops:5},{oil:"乳香",drops:10}]},
{name:"减轻妊娠纹",note:"",tags:[],ingredients:[{oil:"永久花",drops:7},{oil:"广藿香",drops:5},{oil:"没药",drops:4},{oil:"乳香",drops:7},{oil:"薰衣草",drops:7},{oil:"完美修护",drops:10},{oil:"花样年华焕肤油",drops:10}]},
{name:"更年期症",note:"",tags:[],ingredients:[{oil:"柠檬",drops:10},{oil:"椒样薄荷",drops:8},{oil:"薰衣草",drops:10},{oil:"天竺葵",drops:10},{oil:"温柔呵护",drops:30},{oil:"快乐鼠尾草",drops:20},{oil:"罗马洋甘菊",drops:12}]},
{name:"妇科炎症",note:"",tags:[],ingredients:[{oil:"没药",drops:10},{oil:"斯里兰卡肉桂皮",drops:10},{oil:"迷迭香",drops:20},{oil:"完美修护",drops:10},{oil:"杜松浆果",drops:10}]},
{name:"备孕调理",note:"",tags:[],ingredients:[{oil:"天竺葵",drops:5},{oil:"生姜",drops:5},{oil:"温柔呵护",drops:10},{oil:"快乐鼠尾草",drops:5},{oil:"温柔呵护",drops:10}]},
{name:"抗衰紧致",note:"",tags:[],ingredients:[{oil:"丝柏",drops:5},{oil:"乳香",drops:5},{oil:"夏威夷檀香",drops:5},{oil:"玫瑰",drops:3},{oil:"永久花",drops:5},{oil:"西洋蓍草",drops:20}]},
{name:"美白淡斑1",note:"",tags:[],ingredients:[{oil:"玫瑰呵护",drops:5},{oil:"丁香花蕾",drops:2},{oil:"夏威夷檀香",drops:5},{oil:"扁柏",drops:5},{oil:"永久花",drops:5},{oil:"岩兰草",drops:5},{oil:"黑云杉",drops:5},{oil:"芹菜籽",drops:8},{oil:"花样年华焕肤油",drops:10}]},
{name:"美白淡斑2",note:"",tags:[],ingredients:[{oil:"广藿香",drops:3},{oil:"芹菜籽",drops:3},{oil:"黑云杉",drops:3},{oil:"西洋蓍草",drops:3},{oil:"橙花呵护",drops:4},{oil:"花样年华焕肤油",drops:3}]},
{name:"水油平衡",note:"",tags:[],ingredients:[{oil:"新清肌调理",drops:15},{oil:"天竺葵",drops:3},{oil:"雪松",drops:5},{oil:"椒样薄荷",drops:5},{oil:"永久花",drops:3},{oil:"麦卢卡",drops:3},{oil:"薰衣草",drops:5},{oil:"茶树",drops:5}]},
{name:"敏感肌",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"夏威夷檀香",drops:2},{oil:"岩兰草",drops:3},{oil:"广藿香",drops:5},{oil:"罗马洋甘菊",drops:10}]},
{name:"防晒修复",note:"",tags:[],ingredients:[{oil:"罗马洋甘菊",drops:10},{oil:"乳香",drops:10},{oil:"椒样薄荷",drops:4},{oil:"永久花",drops:8},{oil:"柠檬草",drops:3},{oil:"薰衣草",drops:10},{oil:"夏威夷檀香",drops:5}]},
{name:"深层净化排毒",note:"",tags:[],ingredients:[{oil:"茶树呵护",drops:20},{oil:"杜松浆果",drops:10},{oil:"薰衣草",drops:20},{oil:"圆柚",drops:5},{oil:"蓝艾菊",drops:3},{oil:"丝柏",drops:3},{oil:"侧柏",drops:5},{oil:"雪松",drops:10}]},
{name:"蓝月光贵妇面霜",note:"",tags:[],ingredients:[{oil:"玫瑰呵护",drops:10},{oil:"芹菜籽",drops:2},{oil:"永久花",drops:2},{oil:"西洋蓍草",drops:10},{oil:"蓝艾菊",drops:5}]},
{name:"唇部保养",note:"",tags:[],ingredients:[{oil:"夏威夷檀香",drops:3},{oil:"没药",drops:3},{oil:"薰衣草",drops:5},{oil:"永久花",drops:3},{oil:"麦卢卡",drops:1},{oil:"乳香",drops:5}]},
{name:"皱纹推土机",note:"",tags:[],ingredients:[{oil:"花样年华焕肤油",drops:10},{oil:"桂花呵护",drops:6},{oil:"麦卢卡",drops:4},{oil:"永久花",drops:4},{oil:"黑云杉",drops:5},{oil:"夏威夷檀香",drops:5},{oil:"乳香",drops:10},{oil:"穗甘松",drops:6},{oil:"依兰依兰",drops:10}]},
{name:"祛除颈纹",note:"",tags:[],ingredients:[{oil:"夏威夷檀香",drops:5},{oil:"薰衣草",drops:5},{oil:"乳香",drops:10},{oil:"依兰依兰",drops:10},{oil:"穗甘松",drops:10}]},
{name:"蓝带神仙水",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"夏威夷檀香",drops:5},{oil:"蓝艾菊",drops:5},{oil:"永久花",drops:5},{oil:"西洋蓍草",drops:10}]},
{name:"全效紧致乳",note:"",tags:[],ingredients:[{oil:"花样年华焕肤油",drops:50},{oil:"橙花呵护",drops:50},{oil:"茉莉呵护",drops:50},{oil:"古巴香脂",drops:5},{oil:"西洋蓍草",drops:5},{oil:"雪松",drops:10},{oil:"玫瑰",drops:5}]},
{name:"眼袋黑眼圈",note:"",tags:[],ingredients:[{oil:"快乐鼠尾草",drops:5},{oil:"永久花",drops:2},{oil:"丝柏",drops:15},{oil:"乳香",drops:5},{oil:"西洋蓍草",drops:5}]},
{name:"清痘无痕",note:"",tags:[],ingredients:[{oil:"新清肌呵护",drops:20},{oil:"净化清新",drops:10},{oil:"薰衣草",drops:10},{oil:"花样年华焕肤油",drops:10},{oil:"茶树",drops:10}]},
{name:"脂肪粒",note:"",tags:[],ingredients:[{oil:"乳香",drops:5},{oil:"永久花",drops:3},{oil:"西班牙牛至",drops:3},{oil:"芫荽",drops:3},{oil:"雪松",drops:3},{oil:"茶树",drops:3},{oil:"罗马洋甘菊",drops:5}]},
{name:"植物蜂皮",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"橙花呵护",drops:10},{oil:"桂花呵护",drops:10},{oil:"永久花",drops:5},{oil:"没药",drops:3},{oil:"麦卢卡",drops:3},{oil:"花样年华焕肤油",drops:10}]},
{name:"植物水光针",note:"",tags:[],ingredients:[{oil:"西洋蓍草",drops:10},{oil:"芹菜籽",drops:5},{oil:"玫瑰",drops:2},{oil:"乳香",drops:5},{oil:"木兰呵护",drops:20}]},
{name:"早C精华",note:"",tags:[],ingredients:[{oil:"西洋蓍草",drops:20},{oil:"永久花",drops:5},{oil:"夏威夷檀香",drops:3},{oil:"穗甘松",drops:3},{oil:"乳香",drops:5},{oil:"玫瑰呵护",drops:10}]},
{name:"晚A精华",note:"",tags:[],ingredients:[{oil:"花样年华焕肤油",drops:10},{oil:"麦卢卡",drops:3},{oil:"芹菜籽",drops:5},{oil:"天竺葵",drops:5},{oil:"茉莉呵护",drops:10},{oil:"橙花呵护",drops:10},{oil:"雪松",drops:5}]},
{name:"消鸡皮肤",note:"",tags:[],ingredients:[{oil:"圆柚",drops:5},{oil:"薰衣草",drops:5},{oil:"枫香",drops:5},{oil:"柠檬香桃木",drops:5},{oil:"安定情绪",drops:5},{oil:"天竺葵",drops:6}]},
{name:"解荨麻疹",note:"",tags:[],ingredients:[{oil:"没药",drops:2},{oil:"乳香",drops:4},{oil:"椒样薄荷",drops:4},{oil:"罗马洋甘菊",drops:6},{oil:"薰衣草",drops:4},{oil:"蓝艾菊",drops:6}]},
{name:"保湿焕肤",note:"",tags:[],ingredients:[{oil:"永久花",drops:5},{oil:"没药",drops:5},{oil:"薰衣草",drops:5},{oil:"夏威夷檀香",drops:5},{oil:"玫瑰",drops:3},{oil:"天竺葵",drops:5},{oil:"玫瑰草",drops:5}]},
{name:"奶油桂花手霜",note:"",tags:[],ingredients:[{oil:"西洋蓍草",drops:10},{oil:"乳香",drops:10},{oil:"桂花",drops:8},{oil:"薰衣草",drops:8},{oil:"天竺葵",drops:8},{oil:"椰风香草",drops:8}]},
{name:"养护指甲",note:"",tags:[],ingredients:[{oil:"夏威夷檀香",drops:10},{oil:"罗马洋甘菊",drops:10},{oil:"没药",drops:10}]},
{name:"皮肤皲裂/脚后跟干裂",note:"",tags:[],ingredients:[{oil:"天竺葵",drops:10},{oil:"乳香",drops:10},{oil:"薰衣草",drops:8},{oil:"没药",drops:8}]},
{name:"黑绷带",note:"",tags:[],ingredients:[{oil:"广藿香",drops:6},{oil:"岩兰草",drops:6},{oil:"穗甘松",drops:6},{oil:"夏威夷檀香",drops:10},{oil:"永久花",drops:10},{oil:"没药",drops:10},{oil:"蓝艾菊",drops:10}]},
{name:"眉毛睫毛增长滋养液",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"迷迭香",drops:8},{oil:"雪松",drops:8},{oil:"依兰依兰",drops:6},{oil:"薰衣草",drops:8}]},
{name:"天鹅颈",note:"",tags:[],ingredients:[{oil:"穗甘松",drops:5},{oil:"柠檬草",drops:5},{oil:"完美修护",drops:10},{oil:"舒缓",drops:10},{oil:"依兰依兰",drops:10},{oil:"丝柏",drops:10},{oil:"芳香调理",drops:10},{oil:"乳香",drops:10}]},
{name:"收缩毛孔",note:"",tags:[],ingredients:[{oil:"迷迭香",drops:5},{oil:"依兰依兰",drops:7},{oil:"天竺葵",drops:10},{oil:"丝柏",drops:10}]},
{name:"酒糟鼻",note:"",tags:[],ingredients:[{oil:"乳香",drops:7},{oil:"薰衣草",drops:5},{oil:"岩兰草",drops:8},{oil:"罗马洋甘菊",drops:5},{oil:"茶树",drops:8}]},
{name:"疤痕修复",note:"",tags:[],ingredients:[{oil:"没药",drops:4},{oil:"乳香",drops:7},{oil:"薰衣草",drops:7},{oil:"永久花",drops:7},{oil:"花样年华焕肤油",drops:10}]},
{name:"太阳油",note:"",tags:[],ingredients:[{oil:"依兰依兰",drops:15},{oil:"广藿香",drops:15},{oil:"雪松",drops:15},{oil:"檀香",drops:15},{oil:"杜松浆果",drops:15}]},
{name:"月亮油",note:"",tags:[],ingredients:[{oil:"温柔呵护",drops:15},{oil:"茉莉呵护",drops:10},{oil:"玫瑰呵护",drops:10},{oil:"佛手柑",drops:5},{oil:"小茴香",drops:5},{oil:"依兰依兰",drops:7},{oil:"快乐鼠尾草",drops:10}]},
{name:"阿育吠陀",note:"",tags:[],ingredients:[{oil:"姜黄",drops:5},{oil:"生姜",drops:5},{oil:"杜松浆果",drops:10},{oil:"苦橙叶",drops:10},{oil:"迷迭香",drops:5},{oil:"柠檬",drops:10},{oil:"圆柚",drops:5}]},
{name:"缓解头痛",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"薰衣草",drops:5},{oil:"生姜",drops:5},{oil:"马郁兰",drops:7},{oil:"舒缓",drops:15}]},
{name:"白发变黑(女士)",note:"",tags:[],ingredients:[{oil:"乳香",drops:7},{oil:"快乐鼠尾草",drops:10},{oil:"依兰依兰",drops:5},{oil:"生姜",drops:7},{oil:"薰衣草",drops:5}]},
{name:"白发变黑(男士)",note:"",tags:[],ingredients:[{oil:"迷迭香",drops:10},{oil:"薰衣草",drops:10},{oil:"雪松",drops:10},{oil:"永久花",drops:5}]},
{name:"头屑清爽",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:10},{oil:"雪松",drops:20},{oil:"迷迭香",drops:10},{oil:"丝柏",drops:15},{oil:"茶树",drops:20}]},
{name:"口腔溃疡1",note:"",tags:[],ingredients:[{oil:"丁香花蕾",drops:5},{oil:"茶树",drops:10},{oil:"薰衣草",drops:5},{oil:"没药",drops:5},{oil:"乳香",drops:5}]},
{name:"口腔溃疡2",note:"",tags:[],ingredients:[{oil:"永久花",drops:5},{oil:"茶树",drops:10},{oil:"麦卢卡",drops:10},{oil:"月桂叶",drops:10},{oil:"佛手柑",drops:20},{oil:"薰衣草",drops:20}]},
{name:"扁桃腺炎",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"百里香",drops:5},{oil:"小豆蔻",drops:5},{oil:"尤加利",drops:5},{oil:"保卫",drops:5},{oil:"顺畅呼吸",drops:5}]},
{name:"慢性阑尾炎",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"柠檬草",drops:5},{oil:"西班牙牛至",drops:3},{oil:"保卫",drops:3},{oil:"乐活",drops:10},{oil:"新瑞活力",drops:5},{oil:"完美修护",drops:5},{oil:"乳香",drops:10}]},
{name:"结石、胆囊炎",note:"",tags:[],ingredients:[{oil:"罗勒",drops:5},{oil:"冬青",drops:5},{oil:"柠檬",drops:10},{oil:"天竺葵",drops:5},{oil:"迷迭香",drops:5},{oil:"圆柚",drops:10},{oil:"莱姆",drops:10},{oil:"椒样薄荷",drops:5},{oil:"乳香",drops:10}]},
{name:"脚气、灰指甲",note:"",tags:[],ingredients:[{oil:"香蜂草",drops:3},{oil:"保卫",drops:7},{oil:"茶树",drops:10},{oil:"西班牙牛至",drops:10},{oil:"广藿香",drops:5}]},
{name:"甲亢、甲减",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:9},{oil:"丁香花蕾",drops:12},{oil:"柠檬草",drops:15},{oil:"没药",drops:12},{oil:"乳香",drops:12}]},
{name:"降压仪式",note:"",tags:[],ingredients:[{oil:"依兰依兰",drops:10},{oil:"香蜂草",drops:8},{oil:"马郁兰",drops:15},{oil:"乳香",drops:15},{oil:"薰衣草",drops:12}]},
{name:"伤筋动骨",note:"",tags:[],ingredients:[{oil:"完美修护",drops:10},{oil:"乳香",drops:10},{oil:"生姜",drops:10},{oil:"西班牙牛至",drops:10},{oil:"冬青",drops:15},{oil:"柠檬草",drops:15},{oil:"舒缓",drops:5}]},
{name:"干眼症",note:"",tags:[],ingredients:[{oil:"薰衣草",drops:5},{oil:"乳香",drops:5},{oil:"罗马洋甘菊",drops:5},{oil:"快乐鼠尾草",drops:10},{oil:"古巴香脂",drops:5},{oil:"完美修护",drops:5}]},
{name:"儿童抚触",note:"",tags:[],ingredients:[{oil:"生姜",drops:3},{oil:"保卫",drops:3},{oil:"西伯利亚冷杉",drops:5},{oil:"乐活",drops:5},{oil:"没药",drops:5},{oil:"乳香",drops:5}]},
{name:"儿童脾胃",note:"",tags:[],ingredients:[{oil:"红橘",drops:10},{oil:"小茴香",drops:2},{oil:"生姜",drops:3},{oil:"乐活",drops:5}]},
{name:"视力养护",note:"",tags:[],ingredients:[{oil:"乳香",drops:3},{oil:"柠檬草",drops:3},{oil:"永久花",drops:2},{oil:"快乐鼠尾草",drops:5}]},
{name:"驱蚊喷雾",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"柠檬尤加利",drops:5},{oil:"尤加利",drops:5},{oil:"香茅",drops:5},{oil:"天竺葵",drops:3},{oil:"薰衣草",drops:10},{oil:"天然防护",drops:20}]},
{name:"个子高高",note:"",tags:[],ingredients:[{oil:"檀香",drops:3},{oil:"永久花",drops:3},{oil:"芳香调理",drops:2},{oil:"乳香",drops:5},{oil:"西伯利亚冷杉",drops:5}]},
{name:"清咽止咳",note:"",tags:[],ingredients:[{oil:"乳香",drops:5},{oil:"西伯利亚冷杉",drops:3},{oil:"尤加利",drops:3},{oil:"保卫",drops:2},{oil:"顺畅呼吸",drops:5}]},
{name:"通鼻消炎",note:"",tags:[],ingredients:[{oil:"茶树",drops:2},{oil:"椒样薄荷",drops:3},{oil:"乳香",drops:3},{oil:"蓝艾菊",drops:1},{oil:"尤加利",drops:3},{oil:"迷迭香",drops:2},{oil:"顺畅呼吸",drops:5}]},
{name:"蚊虫叮咬1",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"茶树",drops:3},{oil:"薰衣草",drops:3},{oil:"乳香",drops:2},{oil:"罗勒",drops:3},{oil:"天然防护",drops:5}]},
{name:"蚊虫叮咬2",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:5},{oil:"香茅",drops:5},{oil:"柠檬草",drops:5},{oil:"天然防护",drops:10}]},
{name:"学霸神助",note:"",tags:[],ingredients:[{oil:"檀香",drops:2},{oil:"完美修护",drops:2},{oil:"椒样薄荷",drops:4},{oil:"全神贯注",drops:8},{oil:"罗勒",drops:2},{oil:"迷迭香",drops:4},{oil:"乳香",drops:4}]},
{name:"磨牙安抚",note:"",tags:[],ingredients:[{oil:"佛手柑",drops:5},{oil:"罗勒",drops:5},{oil:"芳香调理",drops:5},{oil:"古巴香脂",drops:5},{oil:"安定情绪",drops:5}]},
{name:"免疫助力",note:"",tags:[],ingredients:[{oil:"野橘",drops:5},{oil:"茶树",drops:5},{oil:"侧柏",drops:3},{oil:"姜黄",drops:3},{oil:"保卫",drops:5},{oil:"乳香",drops:5}]},
{name:"腺样体肥大",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:3},{oil:"百里香",drops:2},{oil:"茶树",drops:3},{oil:"尤加利",drops:2},{oil:"雪松",drops:3},{oil:"顺畅呼吸",drops:5}]},
{name:"退烧方案",note:"",tags:[],ingredients:[{oil:"顺畅呼吸",drops:3},{oil:"保卫",drops:5},{oil:"椒样薄荷",drops:5},{oil:"茶树",drops:3},{oil:"薰衣草",drops:2},{oil:"乳香",drops:5}]},
{name:"手足口症",note:"",tags:[],ingredients:[{oil:"茶树",drops:3},{oil:"香蜂草",drops:3},{oil:"丁香花蕾",drops:3},{oil:"月桂叶",drops:3},{oil:"保卫",drops:3},{oil:"古巴香脂",drops:3}]},
{name:"湿疹修复",note:"",tags:[],ingredients:[{oil:"蓝艾菊",drops:2},{oil:"茶树",drops:2},{oil:"乳香",drops:3},{oil:"天竺葵",drops:3},{oil:"广藿香",drops:3},{oil:"罗马洋甘菊",drops:5}]},
{name:"抽动症",note:"",tags:[],ingredients:[{oil:"罗马洋甘菊",drops:3},{oil:"安定情绪",drops:5},{oil:"雪松",drops:3},{oil:"岩兰草",drops:3},{oil:"乳香",drops:5}]},
{name:"头疗生发",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:10},{oil:"茶树",drops:10},{oil:"迷迭香",drops:10},{oil:"丝柏",drops:20},{oil:"生姜",drops:10},{oil:"雪松",drops:20},{oil:"薰衣草",drops:10},{oil:"乳香",drops:14},{oil:"安定情绪",drops:14}]},
{name:"1、呼吸系统细胞律动含香蜂草",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"茶树",drops:4},{oil:"芳香调理",drops:4},{oil:"顺畅呼吸",drops:4},{oil:"迷迭香",drops:4},{oil:"尤加利",drops:4},{oil:"香蜂草",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"1、呼吸系统细胞律动不含香蜂草",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"茶树",drops:4},{oil:"芳香调理",drops:4},{oil:"顺畅呼吸",drops:4},{oil:"迷迭香",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"2、神经系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"百里香",drops:4},{oil:"丁香花蕾",drops:4},{oil:"芳香调理",drops:4},{oil:"柠檬草",drops:4},{oil:"香蜂草",drops:4},{oil:"广藿香",drops:4},{oil:"佛手柑",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"3、消化系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"百里香",drops:4},{oil:"芳香调理",drops:4},{oil:"佛手柑",drops:4},{oil:"芫荽",drops:4},{oil:"乐活",drops:4},{oil:"生姜",drops:4},{oil:"天竺葵",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"4、骨骼系统细胞律动炎症控制",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"茶树",drops:4},{oil:"冬青",drops:4},{oil:"芳香调理",drops:4},{oil:"柠檬草",drops:4},{oil:"西伯利亚冷杉",drops:4},{oil:"舒缓",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"5、淋巴系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"迷迭香",drops:4},{oil:"芳香调理",drops:4},{oil:"柠檬草",drops:4},{oil:"新瑞活力",drops:4},{oil:"元气",drops:4},{oil:"柠檬",drops:4},{oil:"圆柚",drops:4},{oil:"生姜",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"6、生殖系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"西班牙牛至",drops:4},{oil:"茶树",drops:4},{oil:"芳香调理",drops:4},{oil:"薰衣草",drops:4},{oil:"广藿香",drops:4},{oil:"芫荽",drops:4},{oil:"快乐鼠尾草",drops:4},{oil:"檀香",drops:4},{oil:"丁香花蕾",drops:4},{oil:"依兰依兰",drops:4},{oil:"天竺葵",drops:4},{oil:"温柔呵护",drops:4},{oil:"元气",drops:4},{oil:"温柔呵护",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"7、免疫系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"西班牙牛至",drops:4},{oil:"茶树",drops:4},{oil:"芳香调理",drops:4},{oil:"西伯利亚冷杉",drops:4},{oil:"丝柏",drops:4},{oil:"柠檬",drops:4},{oil:"莱姆",drops:4},{oil:"圆柚",drops:4},{oil:"保卫",drops:4},{oil:"丁香花蕾",drops:4},{oil:"百里香",drops:4},{oil:"元气",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"8、循环系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"百里香",drops:4},{oil:"芳香调理",drops:4},{oil:"柠檬草",drops:4},{oil:"保卫",drops:4},{oil:"马郁兰",drops:4},{oil:"罗勒",drops:4},{oil:"薰衣草",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"9、内分泌系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"快乐鼠尾草",drops:4},{oil:"芳香调理",drops:4},{oil:"天竺葵",drops:4},{oil:"保卫",drops:4},{oil:"依兰依兰",drops:4},{oil:"小茴香",drops:4},{oil:"薰衣草",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"10、感冒发烧系统细胞律动",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"西班牙牛至",drops:4},{oil:"百里香",drops:4},{oil:"保卫",drops:4},{oil:"芳香调理",drops:4},{oil:"柠檬草",drops:4},{oil:"尤加利",drops:4},{oil:"茶树",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"11、肌肉系统细胞律动缓解疼痛",note:"",tags:[],ingredients:[{oil:"乳香",drops:4},{oil:"生姜",drops:4},{oil:"芳香调理",drops:4},{oil:"冬青",drops:4},{oil:"柠檬草",drops:4},{oil:"西伯利亚冷杉",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"12、芳香调理技术",note:"",tags:[],ingredients:[{oil:"安定情绪",drops:4},{oil:"薰衣草",drops:4},{oil:"茶树",drops:4},{oil:"保卫",drops:4},{oil:"芳香调理",drops:4},{oil:"舒缓",drops:4},{oil:"野橘",drops:4},{oil:"椒样薄荷",drops:4}]},
{name:"芳心调理技术(单瓶购买)",note:"",tags:[],ingredients:[{oil:"静谧",drops:4},{oil:"抚慰",drops:4},{oil:"宽容",drops:4},{oil:"热情",drops:4},{oil:"欢欣",drops:4},{oil:"鼓舞",drops:4}]},
{name:"芳心调理技术(套装购买)",note:"",tags:[],ingredients:[{oil:"静谧",drops:4},{oil:"抚慰",drops:4},{oil:"宽容",drops:4},{oil:"热情",drops:4},{oil:"欢欣",drops:4},{oil:"鼓舞",drops:4}]},
{name:"头疼",note:"",tags:[],ingredients:[{oil:"尤加利",drops:1},{oil:"薰衣草",drops:1},{oil:"冬青",drops:2},{oil:"椒样薄荷",drops:2}]},
{name:"痤疮粉刺",note:"",tags:[],ingredients:[{oil:"茶树",drops:1}]},
{name:"植物热玛吉",note:"",tags:[],ingredients:[{oil:"玫瑰",drops:5},{oil:"丝柏",drops:10},{oil:"西洋蓍草",drops:20},{oil:"永久花",drops:5},{oil:"乳香",drops:10}]},
{name:"近视老花",note:"",tags:[],ingredients:[{oil:"乳香",drops:10},{oil:"快乐鼠尾草",drops:10},{oil:"花样年华焕肤油",drops:10}]},
{name:"记忆力",note:"",tags:[],ingredients:[{oil:"罗勒",drops:1},{oil:"迷迭香",drops:2},{oil:"佛手柑",drops:2}]},
{name:"皮肤老化",note:"",tags:[],ingredients:[{oil:"花样年华焕肤油",drops:3},{oil:"西洋蓍草",drops:3}]},
{name:"生发配方",note:"",tags:[],ingredients:[{oil:"生姜",drops:1},{oil:"迷迭香",drops:1},{oil:"乳香",drops:1},{oil:"雪松",drops:1},{oil:"薰衣草",drops:1},{oil:"扁柏",drops:1}]},
{name:"头皮屑",note:"",tags:[],ingredients:[{oil:"茶树",drops:1},{oil:"丝柏",drops:1},{oil:"迷迭香",drops:1}]},
{name:"生发2",note:"",tags:[],ingredients:[{oil:"生姜",drops:1},{oil:"薰衣草",drops:2},{oil:"迷迭香",drops:3},{oil:"雪松",drops:4}]},
{name:"白发转黑发",note:"",tags:[],ingredients:[{oil:"完美修护",drops:16},{oil:"乳香",drops:16},{oil:"雪松",drops:16},{oil:"薰衣草",drops:16},{oil:"丝柏",drops:16},{oil:"快乐鼠尾草",drops:16},{oil:"生姜",drops:16},{oil:"侧柏",drops:16},{oil:"扁柏",drops:16},{oil:"依兰依兰",drops:16},{oil:"迷迭香",drops:16},{oil:"檀香",drops:10},{oil:"西伯利亚冷杉",drops:10}]},
{name:"鼻炎用油",note:"",tags:[],ingredients:[{oil:"香蜂草",drops:4},{oil:"顺畅呼吸",drops:20},{oil:"椒样薄荷",drops:4},{oil:"罗勒",drops:6},{oil:"尤加利",drops:6}]},
{name:"中耳炎",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:1},{oil:"罗勒",drops:2},{oil:"茶树",drops:2},{oil:"薰衣草",drops:2}]},
{name:"口臭",note:"",tags:[],ingredients:[{oil:"绿薄荷",drops:1}]},
{name:"牙龈牙周",note:"",tags:[],ingredients:[{oil:"茶树",drops:1},{oil:"丁香花蕾",drops:1}]},
{name:"烧烫伤",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:2},{oil:"茶树",drops:2},{oil:"薰衣草",drops:2}]},
{name:"儿童长高",note:"",tags:[],ingredients:[{oil:"西伯利亚冷杉",drops:6},{oil:"乳香",drops:5},{oil:"檀香",drops:3},{oil:"永久花",drops:3},{oil:"芳香调理",drops:2}]},
{name:"湿疹",note:"",tags:[],ingredients:[{oil:"绿薄荷",drops:1},{oil:"侧柏",drops:5},{oil:"蓝艾菊",drops:5},{oil:"广藿香",drops:10},{oil:"乳香",drops:10}]},
{name:"退烧神器",note:"",tags:[],ingredients:[{oil:"杜松浆果",drops:3},{oil:"椒样薄荷",drops:4},{oil:"永久花",drops:3},{oil:"山鸡椒",drops:3},{oil:"保卫",drops:4},{oil:"柠檬",drops:3},{oil:"乳香",drops:4}]},
{name:"感冒咳嗽",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:1},{oil:"柠檬",drops:2},{oil:"乳香",drops:3},{oil:"西班牙牛至",drops:4},{oil:"保卫",drops:5}]},
{name:"腹泻",note:"",tags:[],ingredients:[{oil:"西班牙牛至",drops:1},{oil:"乐活",drops:1},{oil:"生姜",drops:1},{oil:"罗勒",drops:1}]},
{name:"晕车恶心",note:"",tags:[],ingredients:[{oil:"生姜",drops:1},{oil:"尤加利",drops:1},{oil:"佛手柑",drops:1},{oil:"椒样薄荷",drops:1},{oil:"乐活",drops:1}]},
{name:"醉酒",note:"",tags:[],ingredients:[{oil:"乐活",drops:2}]},
{name:"鸡眼/疣/痣",note:"",tags:[],ingredients:[{oil:"丁香花蕾",drops:1},{oil:"西班牙牛至",drops:1}]},
{name:"脚气",note:"",tags:[],ingredients:[{oil:"茶树",drops:1},{oil:"西班牙牛至",drops:1}]},
{name:"灰指甲2",note:"",tags:[],ingredients:[{oil:"茶树",drops:8},{oil:"西班牙牛至",drops:8},{oil:"百里香",drops:8}]},
{name:"丰胸挺拔",note:"",tags:[],ingredients:[{oil:"小茴香",drops:6},{oil:"依兰依兰",drops:4},{oil:"温柔呵护",drops:10},{oil:"西洋蓍草",drops:10},{oil:"快乐鼠尾草",drops:3}]},
{name:"痛经",note:"",tags:[],ingredients:[{oil:"斯里兰卡肉桂皮",drops:1},{oil:"玫瑰",drops:1},{oil:"丁香花蕾",drops:1},{oil:"薰衣草",drops:2}]},
{name:"亲密关系",note:"",tags:[],ingredients:[{oil:"快乐鼠尾草",drops:6},{oil:"茉莉呵护",drops:12},{oil:"檀香",drops:7},{oil:"依兰依兰",drops:6},{oil:"花样年华焕肤油",drops:5},{oil:"玫瑰呵护",drops:12}]},
{name:"前列腺养护",note:"",tags:[],ingredients:[{oil:"西班牙牛至",drops:2},{oil:"檀香",drops:3},{oil:"茉莉呵护",drops:4},{oil:"丝柏",drops:3},{oil:"乳香",drops:4},{oil:"永久花",drops:3},{oil:"迷迭香",drops:3},{oil:"快乐鼠尾草",drops:4}]},
{name:"手脚冰冷",note:"",tags:[],ingredients:[{oil:"生姜",drops:2},{oil:"圆柚",drops:2}]},
{name:"外阴瘙痒",note:"",tags:[],ingredients:[{oil:"茶树",drops:1},{oil:"没药",drops:1},{oil:"玫瑰草",drops:1},{oil:"佛手柑",drops:1}]},
{name:"淋巴排毒2",note:"",tags:[],ingredients:[{oil:"乳香",drops:1},{oil:"丝柏",drops:1},{oil:"迷迭香",drops:1},{oil:"柠檬草",drops:1},{oil:"永久花",drops:1},{oil:"新瑞活力",drops:1}]},
{name:"痔疮",note:"",tags:[],ingredients:[{oil:"没药",drops:1},{oil:"茶树",drops:1},{oil:"丝柏",drops:2},{oil:"永久花",drops:2}]},
{name:"便秘积食",note:"",tags:[],ingredients:[{oil:"乐活",drops:1},{oil:"柠檬",drops:1},{oil:"马郁兰",drops:1},{oil:"椒样薄荷",drops:1}]},
{name:"护肝排毒",note:"",tags:[],ingredients:[{oil:"当归",drops:1},{oil:"芫荽",drops:2},{oil:"元气",drops:3},{oil:"天竺葵",drops:3},{oil:"迷迭香",drops:7},{oil:"柠檬",drops:13}]},
{name:"痛风",note:"",tags:[],ingredients:[{oil:"百里香",drops:6},{oil:"杜松浆果",drops:8},{oil:"天竺葵",drops:10},{oil:"冬青",drops:10}]},
{name:"消除富贵包",note:"",tags:[],ingredients:[{oil:"柠檬草",drops:2},{oil:"乳香",drops:5},{oil:"芳香调理",drops:5},{oil:"生姜",drops:2},{oil:"舒缓",drops:3},{oil:"古巴香脂",drops:2}]},
{name:"焦虑",note:"",tags:[],ingredients:[{oil:"野橘",drops:1},{oil:"薰衣草",drops:2},{oil:"天竺葵",drops:2}]},
{name:"更年期",note:"",tags:[],ingredients:[{oil:"椒样薄荷",drops:2},{oil:"乳香",drops:3},{oil:"薰衣草",drops:3},{oil:"天竺葵",drops:4},{oil:"温柔呵护",drops:8},{oil:"快乐鼠尾草",drops:10},{oil:"罗马洋甘菊",drops:8}]},
{name:"失眠多梦",note:"",tags:[],ingredients:[{oil:"雪松",drops:1},{oil:"野橘",drops:1},{oil:"乳香",drops:1},{oil:"檀香",drops:1},{oil:"橙花",drops:1},{oil:"乐释",drops:1},{oil:"苦橙叶",drops:1},{oil:"薰衣草",drops:1},{oil:"马郁兰",drops:1},{oil:"佛手柑",drops:1},{oil:"岩兰草",drops:1},{oil:"罗马洋甘菊",drops:1}]},
{name:"稳定血糖",note:"",tags:[],ingredients:[{oil:"新瑞活力",drops:2},{oil:"斯里兰卡肉桂皮",drops:2},{oil:"芫荽",drops:2},{oil:"迷迭香",drops:2}]},
{name:"心脑血管护理",note:"",tags:[],ingredients:[{oil:"乳香",drops:2},{oil:"香蜂草",drops:1}]},
{name:"关节疼痛",note:"",tags:[],ingredients:[{oil:"乳香",drops:3},{oil:"舒缓",drops:2},{oil:"道格拉斯冷杉",drops:2},{oil:"柠檬草",drops:1}]},
{name:"高血压保健",note:"",tags:[],ingredients:[{oil:"依兰依兰",drops:10},{oil:"薰衣草",drops:10},{oil:"马郁兰",drops:10},{oil:"乳香",drops:10},{oil:"香蜂草",drops:5}]}
];
// ============ STATE ============
let recipes = [];
let currentRecipe = null;
let currentEditing = [];
function loadRecipes() {}
function saveRecipes() {}
// ============ UTILS ============
function calcCost(ingredients) {
return ingredients.reduce((sum, ing) => {
const ppd = OILS[ing.oil] || 0;
return sum + ppd * ing.drops;
}, 0);
}
function fmtPrice(n) { return '¥ ' + n.toFixed(2); }
function showSection(name) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.getElementById('section-' + name).classList.add('active');
const tabs = document.querySelectorAll('.nav-tab');
const map = {search:0, manage:1, add:2, oils:3};
tabs[map[name]].classList.add('active');
if (name === 'oils') renderOils();
if (name === 'add') renderNewIngList();
}
// ============ SEARCH SECTION ============
function filterRecipes() {
const q = document.getElementById('searchInput').value.trim().toLowerCase();
const filtered = q ? recipes.filter(r =>
r.name.toLowerCase().includes(q) ||
r.ingredients.some(i => i.oil.toLowerCase().includes(q) || (oilEn(i.oil)||'').toLowerCase().includes(q)) ||
(r.tags || []).some(t => t.toLowerCase().includes(q))
) : recipes;
renderGrid(filtered);
}
function clearSearch() {
document.getElementById('searchInput').value = '';
_favFilterActive = false;
const btn = document.getElementById('favFilterBtn');
if (btn) { btn.classList.remove('btn-primary'); btn.classList.add('btn-outline'); btn.style.color = ''; }
filterRecipes();
closeDetail();
}
function renderGrid(list) {
const grid = document.getElementById('recipeGrid');
if (!list.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">🔍</div><div class="empty-state-text">没有找到相关配方</div></div>';
return;
}
grid.innerHTML = list.map((r, idx) => {
const realIdx = recipes.indexOf(r);
const cost = calcCost(r.ingredients);
const oilNames = r.ingredients.map(i => i.oil).join('、');
const tags = (r.tags || []).map(t => `<span class="tag">${t}</span>`).join(' ');
return `<div class="recipe-card" onclick="selectRecipe(${realIdx})">
<div class="recipe-card-name">${r.name}</div>
${tags ? '<div style="margin:4px 0">'+tags+'</div>' : ''}
<div class="recipe-card-oils">${oilNames}</div>
<div style="margin-top:8px">
<div class="recipe-card-price" style="margin:0">💰 ${fmtCostWithRetail(r.ingredients)}</div>
</div>
</div>`;
}).join('');
}
function selectRecipe(idx) {
currentRecipe = idx;
currentEditing = JSON.parse(JSON.stringify(recipes[idx].ingredients));
document.getElementById('detailOverlay').style.display = 'block';
document.body.style.overflow = 'hidden';
_cardViewFromEditor = false;
switchToCardView();
document.getElementById('detailPanel').scrollTop = 0;
// First-time QR prompt
if (currentUser.id && !userBrand.qr_code && !localStorage.getItem('qr_prompted')) {
localStorage.setItem('qr_prompted', '1');
setTimeout(async () => {
if (await _confirm('还没有上传你的二维码,上传后配方卡片会自动带上。现在去设置?')) {
closeDetail();
showSection('mydiary');
showDiaryTab('brand');
}
}, 500);
}
}
let _cardViewFromEditor = false;
function _closeCardView() {
if (_cardViewFromEditor) {
switchToEditorView();
} else {
closeDetail();
}
}
function switchToCardView() {
document.getElementById('cardViewMode').style.display = '';
document.getElementById('editorViewMode').style.display = 'none';
const backBtn = document.getElementById('cardBackToEditBtn');
if (backBtn) backBtn.style.display = _cardViewFromEditor ? '' : 'none';
renderCardView();
}
function switchToEditorView() {
document.getElementById('cardViewMode').style.display = 'none';
document.getElementById('editorViewMode').style.display = '';
renderDetail();
}
function saveAndShowCard() {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
r.name = document.getElementById('detailNameInput')?.value || r.name;
r.ingredients = JSON.parse(JSON.stringify(currentEditing));
r.note = document.getElementById('detailNoteEdit')?.value ?? r.note;
r._dirty = true;
_editSnapshot = null; // Clear snapshot — changes are intentional
// Save diary recipe to diary API, public recipe to recipes API
if (r._diary_id) {
_apiFetch('/api/diary/' + r._diary_id, { method: 'PUT', body: JSON.stringify({
name: r.name, note: r.note, ingredients: r.ingredients.map(i => ({ oil: i.oil, drops: i.drops }))
})}).then(() => { if (!window._writeQueued) _showToast('✅ 已保存'); loadDiary(); }).catch(() => _showToast('保存失败'));
} else {
if (!_canEdit(r)) { alert('没有权限修改此配方'); return; }
_apiSaveRecipes();
if (!window._writeQueued) _showToast('✅ 已保存');
}
_cardViewFromEditor = true;
switchToCardView();
filterRecipes();
renderManage();
}
let _cardLang = 'cn';
function renderCardView() {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
const canEdit = _canEdit(r);
const loggedIn = currentUser.id !== null;
if (_cardViewFromEditor) {
// Preview mode from editor — no action buttons
document.getElementById('cardViewActions').innerHTML = '';
} else {
// Opened from search page — show favorite + save-as-mine buttons
let btns = '';
if (loggedIn && r._id) {
const fav = isFavorite(r);
btns += '<button class="btn btn-outline btn-sm" onclick="toggleViewerFav(this)" style="font-size:12px">' + (fav ? '★ 已收藏' : '☆ 收藏') + '</button>';
// Only show "存为我的" for public library recipes (has _id, not a diary temp recipe)
if (!r._diary_id) {
btns += '<button class="btn btn-outline btn-sm" onclick="saveAsMine()" style="font-size:12px">📔 存为我的</button>';
}
}
document.getElementById('cardViewActions').innerHTML = btns;
}
_cardLang = 'cn';
_generateCardImage();
}
function _generateCardImage() {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
const container = document.getElementById('cardViewContainer');
container.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-light)">生成中...</div>';
// Render card in selected language — both render into cardViewContainer for same width
if (_cardLang === 'en') {
_renderEnViewerCard(r);
} else {
renderViewerCard(currentRecipe);
}
const cardEl = document.querySelector('#cardViewContainer > div');
if (!cardEl) return;
html2canvas(cardEl, { scale: 2, backgroundColor: '#faf7f0', useCORS: true }).then(canvas => {
const imgUrl = canvas.toDataURL('image/png');
const recipeName = r.name;
const isEn = _cardLang === 'en';
container.innerHTML =
// Language toggle
'<div style="display:flex;justify-content:center;margin-bottom:12px">' +
'<div style="display:flex;border:1.5px solid var(--border);border-radius:10px;overflow:hidden">' +
'<button class="btn btn-sm" onclick="_cardLang=\'cn\';_generateCardImage()" style="border:none;border-radius:0;font-size:12px;padding:6px 16px;' + (!isEn ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)') + '">中文</button>' +
'<button class="btn btn-sm" onclick="_cardLang=\'en\';_generateCardImage()" style="border:none;border-radius:0;font-size:12px;padding:6px 16px;' + (isEn ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)') + '">English</button>' +
'</div>' +
'</div>' +
// Card image
'<img id="cardImage" src="' + imgUrl + '" style="width:100%;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1)">' +
// QR code prompt if not set
(!userBrand.qr_code && currentUser.id ? '<div onclick="_goAddQrFromCard()" style="text-align:center;margin-top:8px;padding:6px 12px;background:var(--cream);border:1px dashed var(--border);border-radius:8px;cursor:pointer;font-size:12px;color:var(--text-mid)">' +
'📱 添加二维码,让配方卡片带上你的联系方式</div>' : '') +
// Actions
'<div style="display:flex;gap:8px;justify-content:center;margin-top:12px;flex-wrap:wrap">' +
'<button class="btn btn-primary btn-sm" onclick="_saveCurrentCardImage()" style="font-size:12px">💾 保存图片</button>' +
'<button class="btn btn-outline btn-sm" onclick="copyRecipeText()" style="font-size:12px">📋 复制文字</button>' +
(isEn && currentUser.id ? '<button class="btn btn-outline btn-sm" onclick="_editTranslation()" style="font-size:12px">✏️ 修改翻译</button>' : '') +
'</div>';
}).catch(() => { if (tempDiv) tempDiv.remove(); });
}
function toggleViewerFav(btn) {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
if (!r._id) return;
toggleFavorite(r._id, btn);
}
function renderViewerCard(idx) {
const r = recipes[idx];
const eos = r.ingredients.filter(i => i.oil !== '椰子油');
const coconut = r.ingredients.find(i => i.oil === '椰子油');
const totalEO = eos.reduce((s, i) => s + i.drops, 0);
const cDrops = coconut ? coconut.drops : 0;
let html = '<div id="recipe-card-export-viewer" style="' +
'background:linear-gradient(145deg,#faf7f0 0%,#f5ede0 100%);border-radius:20px;padding:36px;' +
'font-family:Noto Serif SC,serif;max-width:480px;border:1px solid #e0ccaa;position:relative;overflow:hidden">';
html += '<div style="font-size:11px;letter-spacing:3px;color:var(--sage);margin-bottom:8px">doTERRA · 来自大地的礼物</div>';
html += '<div style="font-size:26px;font-weight:700;color:var(--text-dark);margin-bottom:6px;line-height:1.3">' + r.name + '</div>';
html += '<div style="width:80px;height:2px;background:linear-gradient(90deg,var(--sage),var(--gold));border-radius:2px;margin:14px 0"></div>';
html += '<ul style="list-style:none;margin-bottom:20px;padding:0">';
eos.forEach(ing => {
const ppd = OILS[ing.oil] || 0;
const rpd = oilRetailPpd(ing.oil);
const sub = ppd * ing.drops;
const rsub = rpd * ing.drops;
html += '<li style="display:flex;align-items:center;padding:9px 0;border-bottom:1px solid rgba(180,150,100,0.15);font-size:14px">' +
'<span style="flex:1;color:var(--text-dark);font-weight:500">' + ing.oil + '</span>' +
'<span style="width:50px;text-align:right;color:var(--sage-dark);font-size:13px">' + ing.drops + ' 滴</span>' +
'<span style="width:60px;text-align:right;color:var(--text-light);font-size:12px">' + (sub > 0 ? fmtPrice(sub) : '') + '</span>' +
(rsub > 0 && rsub !== sub ? '<span style="width:55px;text-align:right;color:var(--text-light);font-size:10px;text-decoration:line-through">' + fmtPrice(rsub) + '</span>' : '') +
'</li>';
});
html += '</ul>';
if (totalEO > 0) {
const totalAll = totalEO + cDrops;
const vol = Math.round(totalAll / DROPS_PER_ML);
const ratio = cDrops > 0 ? Math.round(cDrops / totalEO) : 0;
let desc = '';
if (cDrops > 0 && vol >= 5) {
desc = '该配方适用于 ' + vol + 'ml 瓶,其中纯精油 ' + totalEO + ' 滴,其余用椰子油填满,稀释比例为 1:' + ratio;
} else if (cDrops > 0) {
desc = '该配方适用于单次用量(共' + totalAll + '滴),其中纯精油 ' + totalEO + ' 滴,椰子油 ' + cDrops + ' 滴,稀释比例为 1:' + ratio;
}
if (desc) html += '<div style="padding:10px 14px;background:rgba(180,150,100,0.08);border-radius:10px;font-size:12px;color:var(--text-mid);margin-bottom:12px">' + desc + '</div>';
}
if (r.note) html += '<div style="font-size:12px;color:var(--brown-light);margin-bottom:12px;font-style:italic">📝 ' + r.note + '</div>';
const total = calcCost(r.ingredients);
const retailTotal = calcRetailCost(r.ingredients);
html += '<div style="background:linear-gradient(135deg,var(--sage),#5a7d5e);border-radius:12px;padding:14px 20px;display:flex;justify-content:space-between;align-items:center">' +
'<span style="color:rgba(255,255,255,0.85);font-size:13px;letter-spacing:1px">配方总成本</span>' +
'<span style="color:white;font-size:20px;font-weight:700">' + fmtPrice(total) +
(retailTotal > 0 && retailTotal !== total ? ' <span style="text-decoration:line-through;opacity:0.6;font-size:13px">' + fmtPrice(retailTotal) + '</span>' : '') +
'</span></div>';
html += '<div style="margin-top:16px;text-align:center;font-size:11px;color:var(--text-light);letter-spacing:1px">制作日期:' + new Date().toLocaleDateString('zh-CN') + '</div>';
// Brand
html += _buildBrandHtml();
html += '</div>';
document.getElementById('cardViewContainer').innerHTML = html;
}
function _renderEnViewerCard(r) {
const eos = r.ingredients.filter(i => i.oil !== '椰子油');
const coconut = r.ingredients.find(i => i.oil === '椰子油');
const totalEO = eos.reduce((s, i) => s + i.drops, 0);
const cDrops = coconut ? coconut.drops : 0;
const total = calcCost(r.ingredients);
const retailTotal = calcRetailCost(r.ingredients);
let html = '<div style="background:linear-gradient(145deg,#faf7f0 0%,#f5ede0 100%);border-radius:20px;padding:36px;font-family:\'Noto Serif SC\',serif;max-width:480px;border:1px solid #e0ccaa;position:relative;overflow:hidden">';
html += '<div style="font-size:11px;letter-spacing:3px;color:var(--sage);margin-bottom:8px">doTERRA · Gifts of the Earth</div>';
html += '<div style="font-size:26px;font-weight:700;color:var(--text-dark);margin-bottom:6px;line-height:1.3">' + recipeNameEn(r.name) + '</div>';
html += '<div style="width:80px;height:2px;background:linear-gradient(90deg,var(--sage),var(--gold));border-radius:2px;margin:14px 0"></div>';
html += '<ul style="list-style:none;margin-bottom:20px;padding:0">';
eos.forEach(ing => {
const en = oilEn(ing.oil) || ing.oil;
const ppd = OILS[ing.oil] || 0;
const rpd = oilRetailPpd(ing.oil);
const sub = ppd * ing.drops;
const rsub = rpd * ing.drops;
html += '<li style="display:flex;align-items:center;padding:9px 0;border-bottom:1px solid rgba(180,150,100,0.15);font-size:14px">' +
'<span style="flex:1;color:var(--text-dark);font-weight:500">' + en + '</span>' +
'<span style="width:50px;text-align:right;color:var(--sage-dark);font-size:13px">' + ing.drops + ' drops</span>' +
'<span style="width:60px;text-align:right;color:var(--text-light);font-size:12px">' + (sub > 0 ? fmtPrice(sub) : '') + '</span>' +
(rsub > 0 && rsub !== sub ? '<span style="width:55px;text-align:right;color:var(--text-light);font-size:10px;text-decoration:line-through">' + fmtPrice(rsub) + '</span>' : '') +
'</li>';
});
html += '</ul>';
if (totalEO > 0) {
const _totalAll = totalEO + cDrops;
const _vol = Math.round(_totalAll / DROPS_PER_ML);
const _ratio = cDrops > 0 ? Math.round(cDrops / totalEO) : 0;
let _desc = '';
if (cDrops > 0 && _vol >= 5) {
_desc = 'For ' + _vol + 'ml bottle: ' + totalEO + ' drops essential oils, fill with coconut oil. Dilution 1:' + _ratio;
} else if (cDrops > 0) {
_desc = 'Single use (' + _totalAll + ' drops): ' + totalEO + ' drops essential oils + ' + cDrops + ' drops coconut oil. Dilution 1:' + _ratio;
}
if (_desc) html += '<div style="padding:10px 14px;background:rgba(180,150,100,0.08);border-radius:10px;font-size:12px;color:var(--text-mid);margin-bottom:12px">' + _desc + '</div>';
}
if (r.note) html += '<div style="font-size:12px;color:var(--brown-light);margin-bottom:12px;font-style:italic">📝 ' + r.note + '</div>';
html += '<div style="background:linear-gradient(135deg,var(--sage),#5a7d5e);border-radius:12px;padding:14px 20px;display:flex;justify-content:space-between;align-items:center">' +
'<span style="color:rgba(255,255,255,0.85);font-size:13px;letter-spacing:1px">Total Cost</span>' +
'<span style="color:white;font-size:20px;font-weight:700">' + fmtPrice(total) +
(retailTotal > 0 && retailTotal !== total ? ' <span style="text-decoration:line-through;opacity:0.6;font-size:13px">' + fmtPrice(retailTotal) + '</span>' : '') +
'</span></div>';
html += '<div style="margin-top:16px;text-align:center;font-size:11px;color:var(--text-light);letter-spacing:1px">' + new Date().toLocaleDateString('en-US', {year:'numeric',month:'long',day:'numeric'}) + '</div>';
html += _buildBrandHtml();
html += '</div>';
document.getElementById('cardViewContainer').innerHTML = html;
}
function _buildEnCard(r) {
const eos = r.ingredients.filter(i => i.oil !== '椰子油');
const coconut = r.ingredients.find(i => i.oil === '椰子油');
const totalEO = eos.reduce((s, i) => s + i.drops, 0);
const cDrops = coconut ? coconut.drops : 0;
const total = calcCost(r.ingredients);
const retailTotal = calcRetailCost(r.ingredients);
// Match Chinese card's exact same structure and spacing
let html = '<div style="background:linear-gradient(145deg,#faf7f0 0%,#f5ede0 100%);border-radius:20px;padding:36px;font-family:\'Noto Serif SC\',serif;max-width:480px;border:1px solid #e0ccaa;overflow:hidden;position:relative">';
html += '<div style="font-size:11px;letter-spacing:3px;color:var(--sage);margin-bottom:8px">doTERRA · Gifts of the Earth</div>';
html += '<div style="font-size:26px;font-weight:700;color:var(--text-dark);margin-bottom:6px;line-height:1.3">' + recipeNameEn(r.name) + '</div>';
html += '<div style="width:80px;height:2px;background:linear-gradient(90deg,var(--sage),var(--gold));border-radius:2px;margin:14px 0"></div>';
html += '<ul style="list-style:none;margin-bottom:20px;padding:0">';
eos.forEach(ing => {
const en = oilEn(ing.oil) || ing.oil;
const ppd = OILS[ing.oil] || 0;
const rpd = oilRetailPpd(ing.oil);
const sub = ppd * ing.drops;
const rsub = rpd * ing.drops;
html += '<li style="display:flex;align-items:center;padding:9px 0;border-bottom:1px solid rgba(180,150,100,0.15);font-size:14px">' +
'<span style="flex:1;color:var(--text-dark);font-weight:500">' + en + '</span>' +
'<span style="width:60px;text-align:right;color:var(--sage-dark);font-size:13px">' + ing.drops + ' drops</span>' +
'<span style="width:60px;text-align:right;color:var(--text-light);font-size:12px">' + (sub > 0 ? fmtPrice(sub) : '') + '</span>' +
(rsub > 0 && rsub !== sub ? '<span style="width:55px;text-align:right;color:var(--text-light);font-size:10px;text-decoration:line-through">' + fmtPrice(rsub) + '</span>' : '') +
'</li>';
});
html += '</ul>';
if (totalEO > 0) {
const _totalAll = totalEO + cDrops;
const _vol = Math.round(_totalAll / DROPS_PER_ML);
const _ratio = cDrops > 0 ? Math.round(cDrops / totalEO) : 0;
let _desc = '';
if (cDrops > 0 && _vol >= 5) {
_desc = 'For ' + _vol + 'ml bottle: ' + totalEO + ' drops essential oils, fill with coconut oil. Dilution 1:' + _ratio;
} else if (cDrops > 0) {
_desc = 'Single use (' + _totalAll + ' drops): ' + totalEO + ' drops essential oils + ' + cDrops + ' drops coconut oil. Dilution 1:' + _ratio;
}
if (_desc) html += '<div style="padding:10px 14px;background:rgba(180,150,100,0.08);border-radius:10px;font-size:12px;color:var(--text-mid);margin-bottom:12px">' + _desc + '</div>';
}
if (r.note) html += '<div style="font-size:12px;color:var(--brown-light);margin-bottom:12px;font-style:italic">📝 ' + r.note + '</div>';
html += '<div style="background:linear-gradient(135deg,var(--sage),#5a7d5e);border-radius:12px;padding:14px 20px;display:flex;justify-content:space-between;align-items:center">' +
'<span style="color:rgba(255,255,255,0.85);font-size:13px;letter-spacing:1px">Total Cost</span>' +
'<span style="color:white;font-size:20px;font-weight:700">' + fmtPrice(total) +
(retailTotal > 0 && retailTotal !== total ? ' <span style="text-decoration:line-through;opacity:0.6;font-size:13px">' + fmtPrice(retailTotal) + '</span>' : '') +
'</span></div>';
html += '<div style="margin-top:16px;text-align:center;font-size:11px;color:var(--text-light);letter-spacing:1px">' + new Date().toLocaleDateString('en-US', {year:'numeric',month:'long',day:'numeric'}) + '</div>';
html += _buildBrandHtml();
html += '</div>';
const div = document.createElement('div');
div.style.cssText = 'position:absolute;left:-9999px;top:0';
div.innerHTML = html;
document.body.appendChild(div);
return div;
}
let _shareLang = 'cn';
function shareRecipe() {
if (currentRecipe === null) return;
_shareLang = 'cn';
_generateShareOverlay();
}
function _generateShareOverlay() {
const r = recipes[currentRecipe];
const isEn = _shareLang === 'en';
// Remove old overlay
document.querySelectorAll('[data-share-overlay]').forEach(el => el.remove());
let cardEl;
let tempDiv = null;
if (isEn) {
tempDiv = _buildEnCard(r);
cardEl = tempDiv.firstElementChild;
} else {
cardEl = document.querySelector('#cardViewContainer > div') || document.getElementById('recipe-card-export');
}
if (!cardEl) return;
const overlay = document.createElement('div');
overlay.setAttribute('data-share-overlay', '1');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.6);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px';
overlay.innerHTML = '<div style="color:white;font-size:15px">生成' + (isEn ? 'English' : '中文') + '卡片中...</div>';
document.body.appendChild(overlay);
html2canvas(cardEl, { scale: 2, backgroundColor: '#faf7f0', useCORS: true }).then(canvas => {
if (tempDiv) tempDiv.remove();
const imgUrl = canvas.toDataURL('image/png');
const fileName = (isEn ? (r.ingredients.map(i => oilEn(i.oil)||i.oil).join('_').substring(0,30)) : r.name);
overlay.innerHTML = `
<div style="background:white;border-radius:16px;max-width:420px;width:100%;max-height:85vh;overflow-y:auto;padding:20px;text-align:center" onclick="event.stopPropagation()">
<div style="display:flex;justify-content:center;gap:6px;margin-bottom:12px">
<button class="btn ${!isEn?'btn-primary':'btn-outline'} btn-sm" onclick="_shareLang='cn';_generateShareOverlay()">中文</button>
<button class="btn ${isEn?'btn-primary':'btn-outline'} btn-sm" onclick="_shareLang='en';_generateShareOverlay()">English</button>
</div>
<img src="${imgUrl}" style="width:100%;border-radius:12px;border:1px solid #eee;margin-bottom:16px">
<div style="font-size:13px;color:var(--text-light);margin-bottom:12px">📱 长按图片 → 保存到相册 → 分享到微信</div>
<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap" id="shareBtnRow">
<button class="btn btn-primary btn-sm" onclick="downloadShareImage('${imgUrl}','${fileName.replace(/'/g,"\\'")}')">💾 保存图片</button>
<button class="btn btn-outline btn-sm" onclick="copyShareText(${isEn})">📋 复制文字</button>
<button class="btn btn-outline btn-sm" onclick="this.closest('[data-share-overlay]').remove()">关闭</button>
</div>
</div>`;
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
// Native share
canvas.toBlob(blob => {
if (blob && navigator.share && navigator.canShare) {
const file = new File([blob], fileName + '.png', { type: 'image/png' });
if (navigator.canShare({ files: [file] })) {
const shareBtn = document.createElement('button');
shareBtn.className = 'btn btn-gold btn-sm';
shareBtn.textContent = '📤 分享到...';
shareBtn.onclick = (e) => { e.stopPropagation(); navigator.share({ title: r.name, files: [file] }).catch(() => {}); };
document.getElementById('shareBtnRow')?.prepend(shareBtn);
}
}
}, 'image/png');
}).catch(() => {
if (tempDiv) tempDiv.remove();
overlay.innerHTML = '<div style="color:white">生成失败</div>';
setTimeout(() => overlay.remove(), 1500);
});
}
function downloadShareImage(imgUrl, name) {
const a = document.createElement('a');
a.href = imgUrl;
a.download = name + '_配方卡.png';
a.click();
}
function _showToast(msg) {
const t = document.createElement('div');
t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.8);color:white;padding:10px 24px;border-radius:20px;font-size:14px;z-index:999;pointer-events:none;transition:opacity 0.3s';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 1800);
}
async function copyRecipeText() {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
const eos = r.ingredients.filter(i => i.oil !== '椰子油');
const coconut = r.ingredients.find(i => i.oil === '椰子油');
const cost = calcCost(r.ingredients);
const retail = calcRetailCost(r.ingredients);
let text = '【' + r.name + '】\n';
text += eos.map(i => {
const ppd = OILS[i.oil] || 0;
return i.oil + ' ' + i.drops + '滴 ¥' + (ppd * i.drops).toFixed(2);
}).join('\n');
if (coconut) {
const cml = (coconut.drops / DROPS_PER_ML).toFixed(1);
text += '\n椰子油 ' + coconut.drops + '滴(' + cml + 'ml';
}
text += '\n\n成本合计¥' + cost.toFixed(2);
if (retail > 0 && retail !== cost) text += '(零售 ¥' + retail.toFixed(2) + '';
if (r.note) text += '\n备注' + r.note;
text += '\n\n—— doTERRA · 来自大地的礼物';
navigator.clipboard.writeText(text).then(() => _showToast('✅ 已复制到剪贴板')).catch(async () => await _prompt('复制以下内容:', text));
}
async function copyShareText(isEn) {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
const eos = r.ingredients.filter(i => i.oil !== '椰子油');
const coconut = r.ingredients.find(i => i.oil === '椰子油');
const cost = calcCost(r.ingredients);
const retail = calcRetailCost(r.ingredients);
let text;
if (isEn) {
text = '【' + r.name + '】\n';
text += eos.map(i => (oilEn(i.oil) || i.oil) + ' ' + i.drops + ' drops').join(', ');
if (coconut) text += '\nCoconut Oil ' + coconut.drops + ' drops';
text += '\nTotal: ¥' + cost.toFixed(2);
if (r.note) text += '\nNote: ' + r.note;
text += '\n\n—— doTERRA · Gifts of the Earth';
} else {
text = '【' + r.name + '】\n';
text += eos.map(i => i.oil + ' ' + i.drops + '滴').join('');
if (coconut) text += '\n椰子油 ' + coconut.drops + '滴';
text += '\n成本¥' + cost.toFixed(2);
if (retail > 0 && retail !== cost) text += '(零售 ¥' + retail.toFixed(2) + '';
if (r.note) text += '\n备注' + r.note;
text += '\n\n—— doTERRA · 来自大地的礼物';
}
navigator.clipboard.writeText(text).then(() => _showToast('✅ 已复制到剪贴板')).catch(async () => await _prompt('复制以下内容:', text));
}
function closeDetail() {
// Restore unsaved changes
if (_editSnapshot && currentRecipe !== null && recipes[currentRecipe]) {
recipes[currentRecipe].name = _editSnapshot.name;
recipes[currentRecipe].note = _editSnapshot.note;
recipes[currentRecipe].ingredients = _editSnapshot.ingredients;
}
_editSnapshot = null;
document.getElementById('detailOverlay').style.display = 'none';
document.body.style.overflow = '';
currentRecipe = null;
}
const DROPS_PER_ML = 18.6;
function _getEOs() { return currentEditing.filter(i => i.oil !== '椰子油'); }
function _getCoconut() { return currentEditing.find(i => i.oil === '椰子油'); }
function toggleEditorFav(btn) {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
if (!r._id) return;
toggleFavorite(r._id, null);
const fav = isFavorite(r);
btn.textContent = fav ? '★ 已收藏' : '☆ 收藏';
btn.style.color = fav ? 'var(--gold)' : '';
}
function renderDetail() {
const r = recipes[currentRecipe];
document.getElementById('detailTitle').innerHTML = '<input type="text" id="detailNameInput" value="' + (r.name || '').replace(/"/g, '&quot;') + '" class="form-control" style="font-size:20px;font-weight:700;font-family:Noto Serif SC,serif;border:1.5px solid var(--border);border-radius:10px;padding:8px 14px;width:100%" placeholder="配方名称" onchange="if(currentRecipe!==null){recipes[currentRecipe].name=this.value;recipes[currentRecipe]._dirty=true}">';
const noteEl = document.getElementById('detailNote');
noteEl.innerHTML = '';
// Set fav button
const favBtn = document.getElementById('editorFavBtn');
if (favBtn && currentUser.id) {
const fav = isFavorite(r);
favBtn.textContent = fav ? '★ 已收藏' : '☆ 收藏';
favBtn.style.color = fav ? 'var(--gold)' : '';
}
// Move coconut oil to end
const cIdx = currentEditing.findIndex(i => i.oil === '椰子油');
if (cIdx >= 0 && cIdx < currentEditing.length - 1) {
const c = currentEditing.splice(cIdx, 1)[0];
currentEditing.push(c);
}
const eos = _getEOs();
const coconut = _getCoconut();
const tbody = document.getElementById('ingredientsBody');
tbody.innerHTML = currentEditing.map((ing, i) => {
const ppd = OILS[ing.oil] || 0;
const sub = ppd * ing.drops;
const isCoconut = ing.oil === '椰子油';
const oilOptions = Object.keys(OILS).sort((a,b) => a.localeCompare(b,'zh')).map(o =>
`<option value="${o}" ${o === ing.oil ? 'selected' : ''}>${o}</option>`
).join('');
const dropsCell = `<input type="number" class="drops-input" value="${ing.drops}" min="0.5" step="0.5" onchange="changeDrops(${i}, this.value)">`;
return `<tr${isCoconut ? ' style="background:var(--sage-mist)"' : ''}>
<td><select class="oil-select" onchange="changeOil(${i}, this.value)">${oilOptions}</select></td>
<td>${dropsCell}</td>
<td style="color:var(--text-light);font-size:13px">${ppd > 0 ? '¥'+ppd.toFixed(2) : '—'}</td>
<td style="font-weight:600;color:var(--sage-dark)">${sub > 0 ? fmtPrice(sub) : '—'}</td>
<td><button class="remove-btn" onclick="removeIng(${i})">×</button></td>
</tr>`;
}).join('');
// Volume controls — detect from actual recipe data
const cDrops = coconut ? coconut.drops : 0;
const totalEO = eos.reduce((s, i) => s + i.drops, 0);
const totalAllDrops = totalEO + cDrops;
// Detect volume mode from recipe
if (totalAllDrops === 100) currentVolMode = '5ml';
else if (totalAllDrops === 200) currentVolMode = '10ml';
else if (totalAllDrops === 600) currentVolMode = '30ml';
else if (cDrops > 0 && cDrops <= 20 && totalAllDrops <= 40) currentVolMode = 'single';
else if (cDrops > 0) currentVolMode = 'custom';
else currentVolMode = 'single';
_highlightVolBtn(currentVolMode);
// Set dilution ratio selector from actual recipe
if (totalEO > 0 && cDrops > 0) {
const actualRatio = Math.round(cDrops / totalEO);
const ratioSel = document.getElementById('dilutionRatio');
if (ratioSel) {
const closest = Array.from(ratioSel.options).reduce((best, opt) => Math.abs(parseInt(opt.value) - actualRatio) < Math.abs(parseInt(best.value) - actualRatio) ? opt : best);
ratioSel.value = closest.value;
}
}
// Update hint line — delegate to shared function
updateDilutionHint();
// Notes
document.getElementById('detailNoteEdit').value = r.note || '';
renderEditorTags();
const total = calcCost(currentEditing);
const retailTotal = calcRetailCost(currentEditing);
document.getElementById('totalPrice').innerHTML = fmtPrice(total) +
(retailTotal > 0 && retailTotal !== total ? ' <span style="text-decoration:line-through;color:var(--text-light);font-size:14px">' + fmtPrice(retailTotal) + '</span>' : '');
// Sync edits back to recipe and refresh grid card
if (currentRecipe !== null) {
recipes[currentRecipe].ingredients = JSON.parse(JSON.stringify(currentEditing));
filterRecipes();
// Re-highlight the selected card
const cards = document.querySelectorAll('.recipe-card');
cards.forEach(c => c.classList.remove('selected'));
}
renderCard();
hideAddRow();
}
// Volume presets in total drops
const VOL_PRESETS = { single: null, '5ml': 100, '10ml': 200, '30ml': 600 };
let currentVolMode = '30ml';
function _getTotalDropsForMode(mode) {
if (mode === 'single') return null; // special: coconut=10
if (mode === 'custom') {
const val = parseFloat(document.getElementById('customVolInput').value) || 0;
const unit = document.getElementById('customVolUnit').value;
return unit === 'ml' ? Math.round(val * 20) : Math.round(val);
}
return VOL_PRESETS[mode] || 100;
}
function _highlightVolBtn(mode) {
currentVolMode = mode;
document.querySelectorAll('.vol-btn').forEach(b => {
const match = b.dataset.vol === mode;
b.classList.toggle('btn-primary', match);
b.classList.toggle('btn-outline', !match);
b.style.color = match ? 'white' : '';
});
const customInput = document.getElementById('customVolInput');
const customUnit = document.getElementById('customVolUnit');
customInput.style.display = mode === 'custom' ? '' : 'none';
customUnit.style.display = mode === 'custom' ? '' : 'none';
}
function selectVolume(mode) {
_highlightVolBtn(mode);
if (mode === 'custom') document.getElementById('customVolInput').focus();
updateDilutionHint();
}
function onCustomVolChange() {
currentVolMode = 'custom';
_highlightVolBtn('custom');
updateDilutionHint();
}
function onDilutionChange() {
updateDilutionHint();
}
function updateDilutionHint() {
const eos = _getEOs();
const coconut = _getCoconut();
const totalEO = eos.reduce((s, i) => s + i.drops, 0);
const cDrops = coconut ? coconut.drops : 0;
const el = document.getElementById('dilutionHint');
if (!el) return;
if (totalEO > 0 && cDrops > 0) {
const ratio = Math.round(cDrops / totalEO);
const isSingle = currentVolMode === 'single';
if (isSingle) {
const totalAll = totalEO + cDrops;
el.textContent = '该配方适用于单次用量(共' + totalAll + '滴),其中纯精油 ' + totalEO + ' 滴,椰子油 ' + cDrops + ' 滴,稀释比例为 1:' + ratio;
} else {
const volLabel = {
'5ml': '5ml', '10ml': '10ml', '30ml': '30ml',
'custom': Math.round((totalEO + cDrops) / DROPS_PER_ML) + 'ml'
}[currentVolMode] || Math.round((totalEO + cDrops) / DROPS_PER_ML) + 'ml';
el.textContent = '该配方适用于 ' + volLabel + ' 瓶,其中纯精油 ' + totalEO + ' 滴,其余用椰子油填满,稀释比例为 1:' + ratio;
}
} else if (totalEO > 0) {
el.innerHTML = '<span style="color:#e65100">配方中无椰子油</span>,选择容量和稀释比例,点「应用到配方」自动添加';
} else {
el.textContent = '';
}
}
function applyVolume() {
const ratio = parseInt(document.getElementById('dilutionRatio').value) || 10;
let targetEO, targetCoconut;
if (currentVolMode === 'single') {
// Single use: coconut=10, EO based on ratio
targetCoconut = 10;
targetEO = Math.round(targetCoconut / ratio);
} else {
// ml modes: fill to total capacity
const totalDrops = _getTotalDropsForMode(currentVolMode);
if (!totalDrops || totalDrops <= 0) { alert('请先选择容量'); return; }
targetEO = Math.round(totalDrops / (1 + ratio));
targetCoconut = totalDrops - targetEO;
}
const eos = _getEOs();
const currentTotalEO = eos.reduce((s, i) => s + i.drops, 0);
if (currentTotalEO === 0) { alert('请先添加精油'); return; }
// Scale each EO proportionally
const factor = targetEO / currentTotalEO;
eos.forEach(ing => {
ing.drops = Math.max(0.5, Math.round(ing.drops * factor * 2) / 2);
});
// Set coconut oil and move to end
let coconut = _getCoconut();
if (!coconut) {
currentEditing.push({ oil: '椰子油', drops: targetCoconut });
} else {
coconut.drops = targetCoconut;
// Move coconut to end of list
const idx = currentEditing.indexOf(coconut);
if (idx >= 0 && idx < currentEditing.length - 1) {
currentEditing.splice(idx, 1);
currentEditing.push(coconut);
}
}
renderDetail();
}
function onNoteChange() {
if (currentRecipe === null) return;
recipes[currentRecipe].note = document.getElementById('detailNoteEdit').value;
}
function renderEditorTags() {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
const tags = r.tags || [];
const container = document.getElementById('editorTags');
if (!container) return;
container.innerHTML = tags.map(t =>
'<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:16px;font-size:12px;background:var(--sage-mist);color:var(--sage-dark)">' +
t +
'<button onclick="removeEditorTag(\'' + t.replace(/'/g,"\\'") + '\')" style="background:none;border:none;cursor:pointer;font-size:12px;color:var(--sage-dark);padding:0 2px;opacity:0.6">×</button>' +
'</span>'
).join('') || '<span style="font-size:12px;color:var(--text-light)">暂无标签</span>';
// Show candidate tags (not yet added)
const candidates = document.getElementById('editorCandidateTags');
if (candidates) {
const unused = allTags.filter(t => !tags.includes(t));
candidates.innerHTML = unused.map(t =>
'<span onclick="addEditorTagDirect(\'' + t.replace(/'/g,"\\'") + '\')" style="display:inline-block;padding:3px 8px;border-radius:12px;font-size:11px;background:#f5f5f5;color:var(--text-light);cursor:pointer;border:1px dashed var(--border)" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'#f5f5f5\'">' +
'+ ' + t + '</span>'
).join('');
}
}
function addEditorTagDirect(tag) {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
if (!r.tags) r.tags = [];
if (!r.tags.includes(tag)) { r.tags.push(tag); r._dirty = true; }
renderEditorTags();
}
function addEditorTag() {
if (currentRecipe === null) return;
const input = document.getElementById('editorNewTag');
const val = input.value.trim();
if (!val) return;
const tags = val.split(/[,,、]/).map(s => s.trim()).filter(Boolean);
const r = recipes[currentRecipe];
if (!r.tags) r.tags = [];
tags.forEach(t => {
if (!r.tags.includes(t)) r.tags.push(t);
if (!allTags.includes(t)) { allTags.push(t); _apiFetch('/api/tags', {method:'POST', body:JSON.stringify({name:t})}).catch(()=>{}); }
});
input.value = '';
renderEditorTags();
}
function removeEditorTag(tag) {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
if (r.tags) r.tags = r.tags.filter(t => t !== tag);
renderEditorTags();
}
function changeOil(i, val) {
currentEditing[i].oil = val;
renderDetail();
}
function changeDrops(i, val) {
currentEditing[i].drops = parseFloat(val) || 0;
renderDetail();
}
function removeIng(i) {
currentEditing.splice(i, 1);
renderDetail();
}
function addIngredientRow() {
const row = document.getElementById('addIngRow');
row.style.display = 'flex';
const sel = document.getElementById('newOilSelect');
if (sel.options.length <= 1) {
Object.keys(OILS).sort((a,b) => a.localeCompare(b,'zh')).forEach(o => {
const opt = document.createElement('option');
opt.value = o; opt.textContent = o;
sel.appendChild(opt);
});
}
document.getElementById('newOilDrops').value = '1';
}
function hideAddRow() { document.getElementById('addIngRow').style.display = 'none'; }
function confirmAddIngredient() {
const oil = document.getElementById('newOilSelect').value;
const drops = parseFloat(document.getElementById('newOilDrops').value) || 1;
if (!oil) { alert('请选择精油'); return; }
if (currentEditing.some(i => i.oil === oil)) { alert('已有「' + oil + '」,请直接修改滴数'); return; }
currentEditing.push({oil, drops});
renderDetail();
}
function saveCurrentEditing() {
if (currentRecipe === null) return;
recipes[currentRecipe].ingredients = JSON.parse(JSON.stringify(currentEditing));
saveRecipes();
alert('✅ 配方已保存!');
}
// ============ CARD ============
function renderCard() {
const r = recipes[currentRecipe];
document.getElementById('cardTitle').textContent = r.name;
document.getElementById('cardNote').textContent = '';
document.getElementById('cardDate').textContent = '制作日期:' + new Date().toLocaleDateString('zh-CN');
const eos = currentEditing.filter(i => i.oil !== '椰子油');
const coconut = currentEditing.find(i => i.oil === '椰子油');
// EO list (exclude coconut)
const ul = document.getElementById('cardIngredients');
ul.innerHTML = eos.map(ing => {
const ppd = OILS[ing.oil] || 0;
const sub = ppd * ing.drops;
return `<li>
<span class="card-oil-name">${ing.oil}</span>
<span class="card-oil-drops">${ing.drops} 滴</span>
<span class="card-oil-cost">${sub > 0 ? fmtPrice(sub) : ''}</span>
</li>`;
}).join('');
// Coconut oil + ratio
const cardCoconut = document.getElementById('cardCoconut');
if (coconut && coconut.drops > 0) {
const totalEO = eos.reduce((s, i) => s + i.drops, 0);
const ml = (coconut.drops / DROPS_PER_ML).toFixed(1);
const ratioStr = totalEO > 0 ? `稀释比例 1:${(coconut.drops / totalEO).toFixed(1)}` : '';
const cCost = (OILS['椰子油'] || 0) * coconut.drops;
cardCoconut.innerHTML = `🥥 椰子油 ${coconut.drops} 滴(${ml}ml${ratioStr ? ' · ' + ratioStr : ''}${cCost > 0 ? ' · ' + fmtPrice(cCost) : ''}`;
} else {
cardCoconut.innerHTML = '';
}
// Remarks
const note = document.getElementById('detailNoteEdit') ? document.getElementById('detailNoteEdit').value : (r.note || '');
document.getElementById('cardRemarks').textContent = note ? '📝 ' + note : '';
const total = calcCost(currentEditing);
const cardRetail = calcRetailCost(currentEditing);
document.getElementById('cardTotal').innerHTML = fmtPrice(total) +
(cardRetail > 0 && cardRetail !== total ? ' <span style="text-decoration:line-through;opacity:0.6;font-size:14px">' + fmtPrice(cardRetail) + '</span>' : '');
// Brand
const brandEl = document.getElementById('cardBrand');
if (brandEl) {
brandEl.innerHTML = _buildBrandHtml();
}
}
function _dataURLtoBlob(dataURL) {
const parts = dataURL.split(',');
const mime = parts[0].match(/:(.*?);/)[1];
const raw = atob(parts[1]);
const arr = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return new Blob([arr], { type: mime });
}
let _returnToRecipeIdx = null;
function _goAddQrFromCard() {
_returnToRecipeIdx = currentRecipe;
closeDetail();
_prevShowSection2('mydiary');
showDiaryTab('brand');
setTimeout(() => {
const btn = document.getElementById('returnToCardBtn');
if (btn) btn.innerHTML = '<button class="btn btn-outline" onclick="_returnToCard()" style="font-size:13px">← 返回配方卡片</button>';
}, 100);
}
function _returnToCard() {
const btn = document.getElementById('returnToCardBtn');
if (btn) btn.innerHTML = '';
if (_returnToRecipeIdx !== null && recipes[_returnToRecipeIdx]) {
showSection('search');
selectRecipe(_returnToRecipeIdx);
}
_returnToRecipeIdx = null;
}
async function _editTranslation() {
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
const currentEn = recipeNameEn(r.name);
const newEn = await _prompt('修改英文翻译:', currentEn || r.name);
if (!newEn || newEn === currentEn) return;
if (currentUser.role === 'admin') {
// Admin: directly update the translation map
window._customTranslations = window._customTranslations || {};
window._customTranslations[r.name] = newEn;
// Override recipeNameEn to check custom translations first
const origRecipeNameEn = recipeNameEn;
recipeNameEn = function(name) {
if (window._customTranslations && window._customTranslations[name]) return window._customTranslations[name];
return origRecipeNameEn(name);
};
_showToast('✅ 翻译已更新');
_generateCardImage(); // Regenerate card with new translation
} else {
// Non-admin: submit suggestion for review
try {
await _apiFetch('/api/translation-suggest', { method: 'POST', body: JSON.stringify({
recipe_id: r._id || null, recipe_name: r.name, suggested_en: newEn
})});
_showToast('✅ 翻译建议已提交,等待管理员审核');
} catch(e) { _showToast('提交失败'); }
}
}
function _saveCurrentCardImage() {
const img = document.getElementById('cardImage');
if (!img || !img.src) return;
const name = (currentRecipe !== null ? recipes[currentRecipe].name : 'recipe') + (_cardLang === 'en' ? '_EN' : '');
const filename = (name || 'recipe') + '_配方卡.png';
const blob = _dataURLtoBlob(img.src);
// Must stay synchronous from user click — no async/await before a.click()
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile && navigator.canShare) {
const file = new File([blob], filename, { type: 'image/png' });
if (navigator.canShare({ files: [file] })) {
navigator.share({ files: [file] }).then(() => _showToast('✅ 图片已保存')).catch(() => {});
return;
}
}
// Direct download — fully synchronous from click
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 300);
}
function exportCard() {
const el = document.querySelector('#cardViewContainer > div') || document.getElementById('recipe-card-export');
if (!el) return;
html2canvas(el, {scale: 2, backgroundColor: '#faf7f0', useCORS: true}).then(canvas => {
const name = currentRecipe !== null ? recipes[currentRecipe].name : 'recipe';
_showSaveImagePopup(canvas, name + '_配方卡');
});
}
function _showSaveImagePopup(canvas, filename) {
const imgUrl = canvas.toDataURL('image/png');
// Try direct download first
const a = document.createElement('a');
a.href = imgUrl;
a.download = filename + '.png';
a.click();
// Also show image for long-press (mobile fallback)
const pop = document.createElement('div');
pop.style.cssText = 'position:fixed;inset:0;z-index:250;background:rgba(0,0,0,0.7);display:flex;flex-direction:column;align-items:center;padding:16px;overflow-y:auto';
pop.onclick = () => pop.remove();
pop.innerHTML =
'<div style="flex-shrink:0;text-align:center;margin:8px 0" onclick="event.stopPropagation()">' +
'<div style="font-size:13px;color:rgba(255,255,255,0.8);margin-bottom:8px">📱 图片已下载 · 也可长按下方图片保存到相册</div>' +
'</div>' +
'<img src="' + imgUrl + '" style="max-width:100%;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.3)" onclick="event.stopPropagation()">' +
'<div style="flex-shrink:0;text-align:center;margin-top:12px">' +
'<button class="btn btn-outline btn-sm" onclick="this.closest(\'[style*=fixed]\').remove()" style="color:white;border-color:rgba(255,255,255,0.3)">关闭</button>' +
'</div>';
document.body.appendChild(pop);
}
// ============ TAG SYSTEM ============
let allTags = [];
let manageFilterTag = null; // null = show all
let selectedRecipes = new Set();
function loadTags() {}
function saveTags() {}
function addGlobalTag() {
const input = document.getElementById('newTagInput');
const name = input.value.trim();
if (!name) return;
// Support multiple separated by comma
const newTags = name.split(/[,,、]/).map(s => s.trim()).filter(Boolean);
newTags.forEach(t => {
if (!allTags.includes(t)) allTags.push(t);
});
saveTags();
input.value = '';
renderTagBar();
}
async function deleteGlobalTag(tag) {
if (!await _confirm(`删除标签「${tag}」?\n(已标记此标签的配方不会被删除,只是移除标签)`)) return;
allTags = allTags.filter(t => t !== tag);
// Remove from all recipes too
recipes.forEach(r => {
if (r.tags) r.tags = r.tags.filter(t => t !== tag);
});
saveTags();
saveRecipes();
renderTagBar();
renderManage();
}
function _toggleTagFilter() {
const bar = document.getElementById('tagBar');
const btn = document.getElementById('tagFilterToggleBtn');
const shown = bar.style.display !== 'none';
bar.style.display = shown ? 'none' : 'flex';
btn.classList.toggle('btn-primary', !shown);
btn.classList.toggle('btn-outline', shown);
window._tagFilterVisible = !shown;
_saveFoldStates();
}
function renderTagBar() {
const bar = document.getElementById('tagBar');
if (!allTags.length) {
bar.innerHTML = '<span style="font-size:12px;color:var(--text-light)">暂无标签,添加一个吧</span>';
return;
}
// "All" button
let html = `<div class="tag-btn ${manageFilterTag === null ? 'active' : ''}" onclick="filterByTag(null)">全部</div>`;
// "Untagged" button
html += `<div class="tag-btn ${manageFilterTag === '__other__' ? 'active' : ''}" onclick="filterByTag('__other__')">其他</div>`;
allTags.forEach(t => {
const count = recipes.filter(r => (r.tags || []).includes(t)).length;
html += `<div class="tag-btn ${manageFilterTag === t ? 'active' : ''}" onclick="filterByTag('${t.replace(/'/g, "\\'")}')">
${t} <span style="opacity:0.6;font-size:11px">${count}</span>
<button class="tag-del" onclick="event.stopPropagation();deleteGlobalTag('${t.replace(/'/g, "\\'")}')" title="删除标签">×</button>
</div>`;
});
bar.innerHTML = html;
// Restore visible/hidden state
bar.style.display = window._tagFilterVisible ? 'flex' : 'none';
const btn = document.getElementById('tagFilterToggleBtn');
if (btn) { btn.classList.toggle('btn-primary', !!window._tagFilterVisible); btn.classList.toggle('btn-outline', !window._tagFilterVisible); }
}
function filterByTag(tag) {
manageFilterTag = tag;
reviewFilterActive = false;
mineFilterActive = false;
const btn = document.getElementById('filterMineBtn');
if (btn) { btn.classList.remove('btn-primary'); btn.classList.add('btn-outline'); }
selectedRecipes.clear();
renderTagBar();
renderManage();
}
// ============ MANAGE SECTION ============
function renderManage() {
const list = document.getElementById('manageList');
const label = document.getElementById('manageFilterLabel');
// Filter by tag
let filtered = recipes.map((r, i) => ({ r, i }));
if (manageFilterTag === '__other__') {
filtered = filtered.filter(({ r }) => !r.tags || r.tags.length === 0);
label.textContent = '— 其他';
} else if (manageFilterTag) {
filtered = filtered.filter(({ r }) => (r.tags || []).includes(manageFilterTag));
label.textContent = '— ' + manageFilterTag;
} else {
label.textContent = '';
}
if (!filtered.length) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📋</div><div class="empty-state-text">暂无配方</div></div>';
return;
}
list.innerHTML = filtered.map(({ r, i }) => {
const cost = calcCost(r.ingredients);
const oilNames = r.ingredients.map(ing => ing.oil).join('、');
const tags = (r.tags || []).map(t => `<span class="tag">${t}</span>`).join(' ');
const checked = selectedRecipes.has(i) ? 'checked' : '';
return `<div class="manage-item">
<input type="checkbox" ${checked} onchange="toggleSelect(${i}, this.checked)" style="width:18px;height:18px;accent-color:var(--sage);cursor:pointer">
<div class="manage-item-left">
<div class="manage-item-name">${r.name} ${tags}</div>
<div class="manage-item-oils">${oilNames}</div>
<div style="margin-top:6px;font-size:13px;color:var(--sage-dark);font-weight:600">${fmtCostWithRetail(r.ingredients)}</div>
</div>
<div class="manage-item-actions">
<button class="btn btn-outline btn-sm" onclick="editTags(${i})">🏷 标签</button>
<button class="btn btn-outline btn-sm" onclick="editFromManage(${i})">✏️ 编辑</button>
<button class="btn btn-danger btn-sm" onclick="deleteRecipe(${i})">🗑 删除</button>
</div>
</div>`;
}).join('');
}
function toggleSelect(i, checked) {
if (checked) selectedRecipes.add(i);
else selectedRecipes.delete(i);
_updateSelectAllBtn();
}
function toggleSelectAll() {
// Get all visible IDs (public recipes + diary)
const visibleIds = [];
// Public recipes
let pubVisible = recipes.map((r, i) => ({ r, i }));
if (manageFilterTag === '__other__') pubVisible = pubVisible.filter(({ r }) => !r.tags || r.tags.length === 0);
else if (manageFilterTag) pubVisible = pubVisible.filter(({ r }) => (r.tags || []).includes(manageFilterTag));
pubVisible.forEach(({ i }) => visibleIds.push(i));
// Diary recipes
if (currentUser.role === 'admin') {
let diaryVisible = userDiary;
if (manageFilterTag === '__other__') diaryVisible = diaryVisible.filter(d => !d.tags || d.tags.length === 0);
else if (manageFilterTag) diaryVisible = diaryVisible.filter(d => (d.tags || []).includes(manageFilterTag));
diaryVisible.forEach(d => visibleIds.push('d' + d.id));
}
const allSelected = visibleIds.length > 0 && visibleIds.every(id => selectedRecipes.has(id));
if (allSelected) {
visibleIds.forEach(id => selectedRecipes.delete(id));
} else {
visibleIds.forEach(id => selectedRecipes.add(id));
}
_updateSelectAllBtn();
renderManage();
}
function _openTagPicker(name, currentTags, onSave) {
const overlay = document.createElement('div');
overlay.className = 'tag-picker';
window._tagPickerSet = new Set(currentTags);
window._tagPickerSaveFn = onSave;
overlay.innerHTML = '<div class="tag-picker-card">' +
'<div class="tag-picker-title">🏷 为「' + name + '」选择标签</div>' +
'<div class="tag-picker-tags" id="tagPickerTags"></div>' +
'<div style="display:flex;gap:8px;margin-bottom:16px;align-items:center">' +
'<input type="text" id="tagPickerNew" class="form-control" placeholder="新标签…" style="flex:1;font-size:13px;padding:8px 12px" onkeydown="if(event.key===\'Enter\'){addTagFromPicker();event.preventDefault()}">' +
'<button class="btn btn-outline btn-sm" onclick="addTagFromPicker()">添加</button>' +
'</div>' +
'<div style="display:flex;gap:10px;justify-content:flex-end">' +
'<button class="btn btn-outline btn-sm" onclick="closeTagPicker()">取消</button>' +
'<button class="btn btn-primary btn-sm" onclick="_commitTagPicker()">确认</button>' +
'</div></div>';
document.body.appendChild(overlay);
renderTagPicker();
}
function _commitTagPicker() {
if (window._tagPickerSaveFn) window._tagPickerSaveFn([...window._tagPickerSet]);
closeTagPicker();
renderTagBar();
renderManage();
}
function editTags(i) {
const r = recipes[i];
if (!r) { _showToast('配方不存在'); return; }
_openTagPicker(r.name, r.tags || [], function(tags) {
recipes[i].tags = tags;
recipes[i]._dirty = true;
tags.forEach(t => { if (!allTags.includes(t)) allTags.push(t); });
_apiSaveRecipes();
saveTags();
});
}
function renderTagPicker() {
const container = document.getElementById('tagPickerTags');
if (!container) return;
const selected = window._tagPickerSet;
container.innerHTML = allTags.map(t =>
`<div class="tag-pick ${selected.has(t) ? 'selected' : ''}" onclick="toggleTagPick('${t.replace(/'/g, "\\'")}')">${t}</div>`
).join('');
}
function toggleTagPick(tag) {
const s = window._tagPickerSet;
if (s.has(tag)) s.delete(tag);
else s.add(tag);
renderTagPicker();
}
function addTagFromPicker() {
const input = document.getElementById('tagPickerNew');
const name = input.value.trim();
if (!name) return;
const newTags = name.split(/[,,、]/).map(s => s.trim()).filter(Boolean);
newTags.forEach(t => {
if (!allTags.includes(t)) allTags.push(t);
window._tagPickerSet.add(t);
});
saveTags();
input.value = '';
renderTagPicker();
renderTagBar();
}
// saveTagPicker removed — use _commitTagPicker via _openTagPicker
function closeTagPicker() {
const overlay = document.querySelector('.tag-picker');
if (overlay) overlay.remove();
window._tagPickerSet = null;
}
function addRecipeToSheet(ws, r, isFirst, fontTitle, fontHeader, fontBody, fontTotal) {
if (!isFirst) ws.addRow([]); // blank row between recipes
// Title row - merge A:D and add background color
const titleRow = ws.addRow([r.name]);
const rowNum = titleRow.number;
ws.mergeCells(rowNum, 1, rowNum, 4);
titleRow.getCell(1).font = fontTitle;
titleRow.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC8DDC9' } };
titleRow.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
titleRow.height = 36;
const centerAlign = { horizontal: 'center', vertical: 'middle' };
// Note row
if (r.note) {
const noteRow = ws.addRow(['备注:' + r.note]);
noteRow.eachCell(c => { c.font = fontBody; c.alignment = centerAlign; });
}
// Header row
const headerRow = ws.addRow(['精油', '滴数', '单价/滴', '小计']);
headerRow.eachCell(c => { c.font = fontHeader; c.alignment = centerAlign; });
// Separate EOs and coconut oil
const eos = r.ingredients.filter(i => i.oil !== '椰子油');
const coconut = r.ingredients.find(i => i.oil === '椰子油');
// Ingredient rows (EOs only)
let total = 0;
eos.forEach(ing => {
const ppd = OILS[ing.oil] || 0;
const sub = ppd * ing.drops;
total += sub;
const row = ws.addRow([ing.oil, ing.drops, '¥' + ppd.toFixed(2), '¥' + sub.toFixed(2)]);
row.eachCell(c => { c.font = fontBody; c.alignment = centerAlign; });
});
// Coconut oil row with ml and ratio
if (coconut && coconut.drops > 0) {
const totalEO = eos.reduce((s, i) => s + i.drops, 0);
const ml = (coconut.drops / 18.6).toFixed(1);
const ratioStr = totalEO > 0 ? '1:' + (coconut.drops / totalEO).toFixed(1) + '' : '';
const cPpd = OILS['椰子油'] || 0;
const cSub = cPpd * coconut.drops;
total += cSub;
const cRow = ws.addRow(['🥥 椰子油 ' + ratioStr, coconut.drops + '滴 / ' + ml + 'ml', '¥' + cPpd.toFixed(2), '¥' + cSub.toFixed(2)]);
cRow.eachCell(c => { c.font = fontBody; c.alignment = centerAlign; });
}
// Total row
const totalRow = ws.addRow(['合计', '', '', '¥' + total.toFixed(2)]);
totalRow.eachCell(c => { c.font = fontTotal; c.alignment = centerAlign; });
}
async function exportExcel() {
const indices = selectedRecipes.size > 0 ? [...selectedRecipes].sort((a,b) => a-b) : recipes.map((_, i) => i);
if (!indices.length) { alert('没有配方可导出'); return; }
const wb = new ExcelJS.Workbook();
const fontTitle = { size: 20 };
const fontHeader = { size: 14 };
const fontBody = { size: 12 };
const fontTotal = { size: 12, bold: true };
const colWidths = [{ width: 20 }, { width: 10 }, { width: 14 }, { width: 14 }];
// Group recipes by tag
const tagGroups = {}; // tag -> [recipe]
const untagged = [];
indices.forEach(idx => {
const r = recipes[idx];
const tags = r.tags && r.tags.length > 0 ? r.tags : null;
if (!tags) {
untagged.push(r);
} else {
tags.forEach(t => {
if (!tagGroups[t]) tagGroups[t] = [];
tagGroups[t].push(r);
});
}
});
// Helper: add a summary table sheet with columns: 配方名称, 精油成分, 滴数, 成本, 备注
const summaryColWidths = [{ width: 22 }, { width: 36 }, { width: 12 }, { width: 14 }, { width: 28 }];
const summaryHeaders = ['配方名称', '精油成分', '滴数', '成本', '备注'];
const centerAlign = { horizontal: 'center', vertical: 'middle' };
const leftAlign = { horizontal: 'left', vertical: 'middle', wrapText: true };
function addSummarySheet(sheetName, recs) {
const safeName = sheetName.slice(0, 31).replace(/[\\\/\*\?\[\]:]/g, '_');
const ws = wb.addWorksheet(safeName);
ws.columns = summaryColWidths;
const hRow = ws.addRow(summaryHeaders);
hRow.eachCell(c => { c.font = { size: 13, bold: true }; c.alignment = centerAlign; c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC8DDC9' } }; });
recs.forEach(r => {
const ings = r.ingredients || [];
const oilNames = ings.map(i => i.oil).join('、');
const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0);
const totalCost = ings.reduce((s, i) => s + (i.drops || 0) * (OILS[i.oil] || 0), 0);
const row = ws.addRow([r.name, oilNames, totalDrops, '¥' + totalCost.toFixed(2), r.note || '']);
row.eachCell((c, colNum) => { c.font = { size: 12 }; c.alignment = colNum === 2 || colNum === 5 ? leftAlign : centerAlign; });
});
}
// "全部" sheet with detailed per-recipe format (existing style)
const wsAll = wb.addWorksheet('全部');
wsAll.columns = colWidths;
indices.forEach((idx, n) => {
addRecipeToSheet(wsAll, recipes[idx], n === 0, fontTitle, fontHeader, fontBody, fontTotal);
});
// Then one summary sheet per tag
for (const [tag, recs] of Object.entries(tagGroups)) {
addSummarySheet(tag, recs);
}
// Untagged → "其他" sheet (always create if there are untagged recipes)
if (untagged.length) {
addSummarySheet('其他', untagged);
}
// Generate and download
const buf = await wb.xlsx.writeBuffer();
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '精油配方导出_' + new Date().toLocaleDateString('zh-CN').replace(/\//g, '-') + '.xlsx';
a.click();
URL.revokeObjectURL(url);
}
function _updateSelectAllBtn() {
const btn = document.getElementById('selectAllBtn');
if (!btn) return;
// Count all visible items
let totalVisible = 0;
let pubVisible = recipes.map((r,i)=>({r,i}));
if (manageFilterTag==='__other__') pubVisible=pubVisible.filter(({r})=>!r.tags||r.tags.length===0);
else if (manageFilterTag) pubVisible=pubVisible.filter(({r})=>(r.tags||[]).includes(manageFilterTag));
totalVisible += pubVisible.length;
if (currentUser.role==='admin') {
let dv = userDiary;
if (manageFilterTag==='__other__') dv=dv.filter(d=>!d.tags||d.tags.length===0);
else if (manageFilterTag) dv=dv.filter(d=>(d.tags||[]).includes(manageFilterTag));
totalVisible += dv.length;
}
const allSelected = totalVisible > 0 && selectedRecipes.size >= totalVisible;
btn.classList.toggle('btn-primary', allSelected);
btn.classList.toggle('btn-outline', !allSelected);
}
async function batchShareToPublic() {
const diaryIds = [...selectedRecipes].filter(id => typeof id === 'string' && id.startsWith('d')).map(id => parseInt(id.slice(1)));
if (!diaryIds.length) { alert('请先勾选个人配方'); return; }
if (!await _confirm('将 ' + diaryIds.length + ' 个个人配方分享到公共配方库?')) return;
let count = 0;
let failed = [];
for (const did of diaryIds) {
const d = userDiary.find(x => x.id === did);
if (!d) continue;
try {
await _apiFetch('/api/recipes', { method: 'POST', body: JSON.stringify({
name: d.name, note: d.note || '',
ingredients: (d.ingredients || []).map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: d.tags || []
})});
count++;
} catch(e) { failed.push(d.name); }
}
if (count > 0) { await _apiLoadRecipes(); _showToast(currentUser.role === 'admin' ? '✅ 已分享 ' + count + ' 个配方到公共库' : '✅ 已提交 ' + count + ' 个配方,等待管理员审核'); }
if (failed.length > 0) { _showToast('⚠️ ' + failed.length + ' 个配方分享失败:' + failed.join('、')); }
selectedRecipes.clear(); _updateSelectAllBtn(); renderManage();
}
async function batchExportCards() {
if (selectedRecipes.size === 0) { alert('请先勾选配方'); return; }
const allSelected = [];
for (const id of selectedRecipes) {
if (typeof id === 'string' && id.startsWith('d')) {
const did = parseInt(id.slice(1));
const d = userDiary.find(x => x.id === did);
if (d) allSelected.push({ name: d.name, ingredients: d.ingredients || [], note: d.note || '', tags: d.tags || [] });
} else {
const r = recipes[id];
if (r) allSelected.push(r);
}
}
if (!allSelected.length) { alert('没有选中的配方'); return; }
_showToast('正在生成 ' + allSelected.length + ' 张卡片,请稍候...');
const zip = new JSZip();
const container = document.getElementById('cardViewContainer');
for (let i = 0; i < allSelected.length; i++) {
const r = allSelected[i];
// Temporarily render the card
const tmpIdx = recipes.indexOf(r);
if (tmpIdx >= 0) {
renderViewerCard(tmpIdx);
} else {
// Diary recipe — render manually
const eos = (r.ingredients || []).filter(x => x.oil !== '椰子油');
const coconut = (r.ingredients || []).find(x => x.oil === '椰子油');
const cDrops = coconut ? coconut.drops : 0;
const totalEO = eos.reduce((s, x) => s + x.drops, 0);
let html = '<div style="background:linear-gradient(145deg,#faf7f0,#f5ede0);border-radius:20px;padding:36px;font-family:Noto Serif SC,serif;max-width:480px;border:1px solid #e0ccaa;position:relative;overflow:hidden">';
html += '<div style="font-size:11px;letter-spacing:3px;color:var(--sage);margin-bottom:8px">doTERRA · 来自大地的礼物</div>';
html += '<div style="font-size:26px;font-weight:700;color:var(--text-dark);margin-bottom:6px;line-height:1.3">' + r.name + '</div>';
html += '<div style="width:80px;height:2px;background:linear-gradient(90deg,var(--sage),var(--gold));border-radius:2px;margin:14px 0"></div>';
html += '<ul style="list-style:none;margin-bottom:20px;padding:0">';
eos.forEach(ing => {
const ppd = OILS[ing.oil] || 0;
html += '<li style="display:flex;align-items:center;padding:9px 0;border-bottom:1px solid rgba(180,150,100,0.15);font-size:14px"><span style="flex:1;color:var(--text-dark);font-weight:500">' + ing.oil + '</span><span style="width:50px;text-align:right;color:var(--sage-dark);font-size:13px">' + ing.drops + ' 滴</span><span style="width:60px;text-align:right;color:var(--text-light);font-size:12px">' + (ppd > 0 ? fmtPrice(ppd * ing.drops) : '') + '</span></li>';
});
html += '</ul>';
if (totalEO > 0 && cDrops > 0) {
const vol = Math.round((totalEO + cDrops) / DROPS_PER_ML);
const ratio = Math.round(cDrops / totalEO);
html += '<div style="padding:10px 14px;background:rgba(180,150,100,0.08);border-radius:10px;font-size:12px;color:var(--text-mid);margin-bottom:12px">该配方适用于 ' + vol + 'ml 瓶,其中纯精油 ' + totalEO + ' 滴,其余用椰子油填满,稀释比例为 1:' + ratio + '</div>';
}
if (r.note) html += '<div style="font-size:12px;color:var(--brown-light);margin-bottom:12px;font-style:italic">📝 ' + r.note + '</div>';
const total = calcCost(r.ingredients || []);
html += '<div style="background:linear-gradient(135deg,var(--sage),#5a7d5e);border-radius:12px;padding:14px 20px;display:flex;justify-content:space-between;align-items:center"><span style="color:rgba(255,255,255,0.85);font-size:13px">配方总成本</span><span style="color:white;font-size:20px;font-weight:700">' + fmtPrice(total) + '</span></div>';
html += _buildBrandHtml();
html += '</div>';
container.innerHTML = html;
}
const cardEl = container.querySelector('div');
if (!cardEl) continue;
try {
const canvas = await html2canvas(cardEl, { scale: 2, backgroundColor: '#faf7f0', useCORS: true });
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
zip.file(r.name.replace(/[\/\\:*?"<>|]/g, '_') + '_配方卡.png', blob);
} catch(e) { console.error('Export failed for', r.name, e); }
// Update progress
if (i % 3 === 0) _showToast('正在生成... (' + (i+1) + '/' + allSelected.length + ')');
}
container.innerHTML = '';
_showToast('正在打包下载...');
const content = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = url; a.download = '配方卡片_' + allSelected.length + '张.zip';
a.style.display = 'none';
document.body.appendChild(a); a.click();
setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 300);
_showToast('✅ 已导出 ' + allSelected.length + ' 张卡片');
}
async function batchDelete() {
if (selectedRecipes.size === 0) { alert('请先勾选配方'); return; }
if (!await _confirm('确认删除选中的 ' + selectedRecipes.size + ' 个配方?此操作不可撤回。')) return;
for (const id of [...selectedRecipes]) {
if (typeof id === 'string' && id.startsWith('d')) {
const did = parseInt(id.slice(1));
await _apiFetch('/api/diary/' + did, { method: 'DELETE' }).catch(() => {});
} else {
const r = recipes[id];
if (r && r._id) await _apiFetch('/api/recipes/' + r._id, { method: 'DELETE' }).catch(() => {});
}
}
await _apiLoadRecipes();
await loadDiary();
selectedRecipes.clear(); _updateSelectAllBtn();
_showToast('✅ 已删除');
renderManage();
}
async function shareDiaryToPublic(diaryId) {
const d = userDiary.find(x => x.id === diaryId);
if (!d) return;
if (!await _confirm('将「' + d.name + '」分享到公共配方库?\n分享后所有用户都能看到这个配方。')) return;
try {
const res = await _apiFetch('/api/recipes', { method: 'POST', body: JSON.stringify({
name: d.name, note: d.note || '',
ingredients: (d.ingredients || []).map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: d.tags || []
})});
if (res.ok) {
if (currentUser.role === 'admin') {
_showToast('✅ 已分享到公共配方库');
} else {
_showToast('✅ 已提交,等待管理员审核');
}
await _apiLoadRecipes();
renderManage();
} else _showToast('分享失败');
} catch(e) { _showToast('分享失败'); }
}
function showAddRecipeOverlay() {
const overlay = document.createElement('div');
overlay.id = 'addRecipeOverlay';
overlay.style.cssText = 'position:fixed;inset:0;z-index:100;background:rgba(0,0,0,0.25);display:flex;justify-content:center;overflow-y:auto;padding:12px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div style="position:relative;max-width:600px;width:100%;margin:auto" onclick="event.stopPropagation()">' +
'<div class="detail-panel" style="max-width:100%;overflow-x:hidden">' +
// Close button
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
'<span style="font-size:18px;font-weight:700;font-family:Noto Serif SC,serif;color:var(--text-dark)"> 新增配方</span>' +
'<button onclick="document.getElementById(\'addRecipeOverlay\').remove()" style="background:rgba(0,0,0,0.06);border:none;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;color:var(--text-light)">✕</button>' +
'</div>' +
// Smart paste section
'<div class="form-card" style="margin-bottom:16px">' +
'<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:8px">✨ 智能粘贴</div>' +
'<div class="form-group">' +
'<textarea class="form-control" id="addSmartPasteInput" rows="4" placeholder="粘贴配方文本,支持多行多配方:&#10;长高芳香调理8永久花10&#10;&#10;助眠薰衣草15雪松10" style="font-size:13px"></textarea>' +
'</div>' +
'<button class="btn btn-primary btn-sm" onclick="smartPasteFromAdd()">🪄 识别并生成</button>' +
'<div id="addSmartPasteResult" style="margin-top:10px"></div>' +
'</div>' +
// Manual add section
'<div class="form-card">' +
'<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:8px">📝 手动新增</div>' +
'<div class="form-group">' +
'<label class="form-label">配方名称</label>' +
'<input type="text" class="form-control" id="addRecipeName" placeholder="如:淡斑精华" style="font-size:14px">' +
'</div>' +
'<button class="btn btn-primary btn-sm" onclick="createEmptyRecipeAndEdit()">创建并编辑</button>' +
'</div>' +
'</div></div>';
document.body.appendChild(overlay);
}
function smartPasteFromAdd() {
const addInput = document.getElementById('addSmartPasteInput');
const origInput = document.getElementById('smartPasteInput');
if (origInput) origInput.value = addInput.value;
smartPaste();
// smartPaste now renders into addSmartPasteResult (preferred) or smartPasteResult
if (true) {
}
}
async function createEmptyRecipeAndEdit() {
const name = document.getElementById('addRecipeName')?.value.trim();
if (!name) { alert('请输入配方名称'); return; }
// Create in diary (personal) by default
try {
const res = await _apiFetch('/api/diary', { method: 'POST', body: JSON.stringify({ name, ingredients: [], note: '' }) });
if (!res.ok) { alert('创建失败'); return; }
const data = await res.json();
await loadDiary();
document.getElementById('addRecipeOverlay')?.remove();
editDiaryFromManage(data.id);
} catch(e) { alert('创建失败'); }
}
let _editSnapshot = null;
function editFromManage(i) {
currentRecipe = i;
// Save snapshot to restore on cancel
_editSnapshot = { name: recipes[i].name, note: recipes[i].note, ingredients: JSON.parse(JSON.stringify(recipes[i].ingredients)) };
currentEditing = JSON.parse(JSON.stringify(recipes[i].ingredients));
document.getElementById('detailOverlay').style.display = 'block';
document.body.style.overflow = 'hidden';
switchToEditorView();
document.getElementById('detailPanel').scrollTop = 0;
}
async function deleteRecipe(i) {
if (!await _confirm(`确认删除配方「${recipes[i].name}」?`)) return;
recipes.splice(i, 1);
saveRecipes();
renderManage();
}
async function deleteRecipeFromSearch(i) {
if (!await _confirm(`确认删除配方「${recipes[i].name}」?`)) return;
recipes.splice(i, 1);
saveRecipes();
// Hide detail panel if the deleted recipe was selected
if (currentRecipe === i) {
closeDetail();
} else if (currentRecipe > i) {
currentRecipe--;
}
filterRecipes();
}
// ============ ADD SECTION ============
let newIngredients = [{oil:'', drops:1}];
function renderNewIngList() {
const list = document.getElementById('newIngList');
list.innerHTML = newIngredients.map((ing, i) => {
const oilOptions = Object.keys(OILS).sort((a,b) => a.localeCompare(b,'zh')).map(o =>
`<option value="${o}" ${o === ing.oil ? 'selected' : ''}>${o}</option>`
).join('');
return `<div class="new-ing-row">
<select class="form-control" style="flex:1" onchange="newIngChange(${i},'oil',this.value)">
<option value="">— 选择精油 —</option>${oilOptions}
</select>
<input type="number" class="form-control" style="width:90px" value="${ing.drops}" min="0.5" step="0.5" placeholder="滴数" onchange="newIngChange(${i},'drops',this.value)">
<button class="remove-btn" onclick="removeNewIng(${i})">×</button>
</div>`;
}).join('');
}
function newIngChange(i, field, val) {
if (field === 'drops') newIngredients[i].drops = parseFloat(val) || 0;
else newIngredients[i].oil = val;
}
function addNewIngRow() {
newIngredients.push({oil:'', drops:1});
renderNewIngList();
}
function removeNewIng(i) {
if (newIngredients.length <= 1) return;
newIngredients.splice(i, 1);
renderNewIngList();
}
// Check if two ingredient lists are the same
function isSameIngredients(a, b) {
if (a.length !== b.length) return false;
const sortA = [...a].sort((x, y) => x.oil.localeCompare(y.oil));
const sortB = [...b].sort((x, y) => x.oil.localeCompare(y.oil));
return sortA.every((ing, i) => ing.oil === sortB[i].oil && ing.drops === sortB[i].drops);
}
// Check duplicate before saving. Returns true if save should proceed.
async function checkDuplicateAndSave(name, ingredients, note) {
const existing = recipes.filter(r => r.name === name);
if (existing.length > 0) {
const same = existing.some(r => isSameIngredients(r.ingredients, ingredients));
if (same) {
alert(`配方「${name}」已存在且内容一致,无需重复保存。`);
return false;
} else {
// Different content, suggest new name
let newName = name;
let n = 2;
while (recipes.some(r => r.name === newName)) {
newName = name + n;
n++;
}
const ok = await _confirm(`已有同名配方「${name}」但内容不一致。\n\n是否保存为「${newName}」?`);
if (!ok) return false;
recipes.push({ name: newName, note: note || '', ingredients: JSON.parse(JSON.stringify(ingredients)) });
saveRecipes();
alert('✅ 配方「' + newName + '」已保存!');
return true;
}
}
// No duplicate
recipes.push({ name, note: note || '', ingredients: JSON.parse(JSON.stringify(ingredients)) });
saveRecipes();
alert('✅ 配方「' + name + '」已保存!');
return true;
}
function saveNewRecipe() {
const name = document.getElementById('newRecipeName').value.trim();
if (!name) { alert('请输入配方名称'); return; }
const ings = newIngredients.filter(i => i.oil && i.drops > 0);
if (!ings.length) { alert('请至少添加一种精油'); return; }
const note = document.getElementById('newRecipeNote').value.trim();
if (checkDuplicateAndSave(name, ings, note)) {
clearNewForm();
}
}
function clearNewForm() {
document.getElementById('newRecipeName').value = '';
document.getElementById('newRecipeNote').value = '';
newIngredients = [{oil:'', drops:1}];
renderNewIngList();
}
// ============ SMART PASTE ============
// Parse a text chunk into [(oilName, drops), ...] pairs
// Handles: "芳香调理8永久花10檀香10" → [("芳香调理",8), ("永久花",10), ("檀香",10)]
function _convertToDrops(oil, value, unit) {
// If unit is ml/毫升, convert: 5ml = 100 drops (20 drops per ml)
if (unit && /^(ml|毫升|ML|mL)$/.test(unit.trim())) {
return Math.round(value * 20);
}
return value;
}
function parseOilChunk(text, oilNames) {
const results = []; // {name, drops, found}
const notFound = [];
// First try: split by numbers + optional unit → [(text, number, unit), ...]
const pairs = [];
const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g;
let m;
let lastEnd = 0;
while ((m = regex.exec(text)) !== null) {
pairs.push({ name: m[1].trim(), value: parseFloat(m[2]), unit: m[3] || '' });
lastEnd = regex.lastIndex;
}
// Remaining text after last number (oil with no drops)
const remainder = text.slice(lastEnd).trim();
if (pairs.length > 0) {
// Got number-delimited pairs
pairs.forEach(p => {
if (!p.name) return;
// The name might contain multiple oils concatenated: try greedy match from oil list
const parsed = greedyMatchOils(p.name, oilNames);
if (parsed.length === 0) {
// Whole chunk is one oil name
const oil = findOil(p.name, oilNames);
if (oil) results.push({ oil, drops: _convertToDrops(oil, p.value, p.unit) });
else notFound.push(p.name);
} else if (parsed.length === 1) {
results.push({ oil: parsed[0], drops: _convertToDrops(parsed[0], p.value, p.unit) });
} else {
// Multiple oils found before this number - last one gets the drops, others get 1
parsed.forEach((oil, idx) => {
results.push({ oil, drops: idx === parsed.length - 1 ? _convertToDrops(oil, p.value, p.unit) : 1 });
});
}
});
if (remainder) {
const oil = findOil(remainder, oilNames);
if (oil) results.push({ oil, drops: 1 });
else notFound.push(remainder);
}
} else {
// No numbers at all, try to find oil names in the text
const oil = findOil(text, oilNames);
if (oil) results.push({ oil, drops: 1 });
else notFound.push(text);
}
return { results, notFound };
}
// Try to greedily match known oil names from a concatenated string
// "芳香调理永久花" → ["芳香调理", "永久花"]
function greedyMatchOils(text, oilNames) {
if (!text) return [];
// Sort oil names by length descending (prefer longer matches)
const sorted = [...oilNames].sort((a, b) => b.length - a.length);
const matched = [];
let pos = 0;
while (pos < text.length) {
let found = false;
// Try exact match at current position
for (const name of sorted) {
if (text.startsWith(name, pos)) {
matched.push(name);
pos += name.length;
found = true;
break;
}
}
if (!found) {
// Try fuzzy match: collect chars until we find a match
let end = pos + 1;
let fuzzyFound = false;
while (end <= text.length) {
const segment = text.slice(pos, end);
const oil = findOil(segment, oilNames);
// Check if this segment closely matches an oil (not just substring)
if (oil && editDistance(segment, oil) <= Math.max(1, Math.floor(oil.length / 3))) {
matched.push(oil);
pos = end;
fuzzyFound = true;
break;
}
end++;
}
if (!fuzzyFound) {
// Skip one character and try again
pos++;
}
}
}
return matched;
}
// ── Smart Paste: split multi-recipe input ───────────────
function _splitRawIntoBlocks(raw) {
// Step 1: split by blank lines or semicolons
let blocks = raw.split(/\n\s*\n|[;]/).map(b => b.trim()).filter(Boolean);
// Step 2: for each block, split all parts and detect recipe boundaries
// A non-oil, non-number word AFTER we've seen at least one oil = new recipe
if (blocks.length <= 1) {
const oilNames = Object.keys(OILS);
const allParts = raw.split(/[,,、;\n\t。. ]+/).map(s => s.replace(/[:]$/, '').trim()).filter(Boolean);
if (allParts.length >= 2) {
const result = [];
let current = [];
let hasOilInCurrent = false;
for (const part of allParts) {
const hasNum = /\d/.test(part);
const textOnly = part.replace(/\d+\.?\d*/g, '').replace(/\s*(ml|毫升|ML|mL|滴)\s*/gi, '').trim();
const isOil = textOnly && findOil(textOnly, oilNames);
if (!hasNum && !isOil && textOnly.length >= 1 && hasOilInCurrent) {
// This is a new recipe name — boundary
result.push(current.join(''));
current = [part];
hasOilInCurrent = false;
} else {
current.push(part);
if (isOil || (hasNum && textOnly && findOil(textOnly, oilNames))) hasOilInCurrent = true;
}
}
if (current.length > 0) result.push(current.join(''));
if (result.length > 1) blocks = result;
}
}
return blocks;
}
function _cleanRecipeName(name) {
// Strip surrounding quotes, brackets, parens, colons
return name.replace(/^[\s"'"'「」【】《》\[\]()]+/, '').replace(/[\s"'"'「」【】《》\[\]():]+$/, '').trim();
}
function _parseSingleBlock(raw) {
const parts = raw.split(/[,,、;\n\t ]+/).map(s => s.trim()).filter(Boolean);
if (parts.length < 1) return null;
const oilNames = Object.keys(OILS);
const oilsSorted = [...oilNames].sort((a, b) => b.length - a.length);
let recipeName = '';
let startIdx = 0;
const firstClean = parts[0].replace(/[:]$/, '').trim();
const firstHasNum = /\d/.test(firstClean);
const firstIsOil = findOil(firstClean, oilNames);
if (!firstHasNum && !firstIsOil) {
recipeName = _cleanRecipeName(firstClean);
startIdx = 1;
} else {
const text = parts[0];
let firstOilPos = text.length;
for (const name of oilsSorted) {
const idx = text.indexOf(name);
if (idx !== -1 && idx < firstOilPos) firstOilPos = idx;
}
if (firstOilPos > 0) {
recipeName = _cleanRecipeName(text.slice(0, firstOilPos));
parts[0] = text.slice(firstOilPos);
}
if (!recipeName) recipeName = '未命名配方';
startIdx = 0;
}
const ingredients = [];
const notFound = [];
for (let i = startIdx; i < parts.length; i++) {
const part = parts[i].trim();
if (!part) continue;
const { results, notFound: nf } = parseOilChunk(part, oilNames);
results.forEach(r => ingredients.push(r));
nf.forEach(n => notFound.push(n));
}
// Deduplicate: keep the first occurrence of each oil
const seen = new Set();
const deduped = [];
for (const ing of ingredients) {
if (!seen.has(ing.oil)) { seen.add(ing.oil); deduped.push(ing); }
}
const ingredients2 = deduped;
if (!ingredients2.length) return null;
return { name: recipeName, ingredients: ingredients2, notFound };
}
function smartPaste() {
const raw = document.getElementById('smartPasteInput').value.trim();
if (!raw) { alert('请输入配方内容'); return; }
const blocks = _splitRawIntoBlocks(raw);
const parsed = blocks.map(b => _parseSingleBlock(b)).filter(Boolean);
if (parsed.length === 0) {
alert('没有识别到任何精油配方');
return;
}
// Store all parsed recipes and show wizard
window._smartPasteQueue = parsed;
window._smartPasteIdx = 0;
window._smartPasteSaved = 0;
_renderSmartWizard();
}
function _renderSmartWizard() {
const queue = window._smartPasteQueue;
const idx = window._smartPasteIdx;
const resultDiv = document.getElementById('addSmartPasteResult') || document.getElementById('smartPasteResult');
if (!queue || idx >= queue.length) {
// All done
const saved = window._smartPasteSaved || 0;
resultDiv.innerHTML = '<div style="background:var(--sage-mist);border-radius:12px;padding:20px;text-align:center">' +
'<div style="font-size:24px;margin-bottom:8px">✅</div>' +
'<div style="font-size:15px;font-weight:600;color:var(--sage-dark)">全部完成!共保存 ' + saved + ' 个配方</div>' +
'</div>';
document.getElementById('smartPasteInput').value = '';
window._smartPasteQueue = null;
// Expand personal recipes on search page
window._myRecipesCollapsed = false;
_saveFoldStates();
filterRecipes();
renderPersonalSection();
// If only 1 recipe saved, show its card; otherwise close
setTimeout(() => {
document.getElementById('addRecipeOverlay')?.remove();
if (saved === 1 && userDiary.length > 0) {
// Show card preview, then navigate to manage after close
const newId = userDiary[0].id;
viewDiaryAsCard(newId);
// Override closeDetail to go to manage page after
const origClose = closeDetail;
closeDetail = function() {
origClose();
closeDetail = origClose;
_goToNewRecipes();
};
} else {
_goToNewRecipes();
}
}, 1000);
return;
}
const recipe = queue[idx];
const total = queue.length;
// Build editable wizard card
let html = '<div style="background:var(--sage-mist);border-radius:14px;padding:20px;margin-top:8px">';
// Progress header
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">' +
'<div style="font-size:15px;font-weight:700;color:var(--sage-dark)">📋 校准配方 (' + (idx+1) + '/' + total + ')</div>' +
'<div style="font-size:12px;color:var(--text-light)">已保存 ' + (window._smartPasteSaved||0) + ' 个</div>' +
'</div>';
// Progress bar
html += '<div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:16px;overflow:hidden">' +
'<div style="height:100%;background:var(--sage);width:' + ((idx/total)*100) + '%;transition:width 0.3s"></div></div>';
// Recipe name (editable)
html += '<div class="form-group" style="margin-bottom:12px">' +
'<label class="form-label" style="font-size:13px">配方名称</label>' +
'<input type="text" class="form-control" id="wizardName" value="' + (recipe.name||'').replace(/"/g, '&quot;') + '" style="font-size:14px;font-weight:600">' +
'</div>';
// Ingredients (editable rows, like add recipe page)
html += '<div class="form-group" style="margin-bottom:12px">' +
'<label class="form-label" style="font-size:13px">精油成分</label>' +
'<div id="wizardIngList">';
recipe.ingredients.forEach((ing, i) => {
const cost = (OILS[ing.oil] || 0) * ing.drops;
html += '<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px" data-idx="' + i + '">' +
'<select class="form-control wiz-oil" style="flex:2;font-size:13px;padding:6px 8px">' +
'<option value="">选择精油</option>' +
Object.keys(OILS).sort().map(n => '<option value="' + n + '"' + (n === ing.oil ? ' selected' : '') + '>' + n + '</option>').join('') +
'</select>' +
'<input type="number" class="form-control wiz-drops" value="' + ing.drops + '" min="0.5" step="0.5" style="width:65px;font-size:13px;padding:6px 8px;text-align:center">' +
'<span style="font-size:11px;color:var(--text-light);width:50px;text-align:right">¥' + cost.toFixed(2) + '</span>' +
'<button onclick="this.parentElement.remove();_updateWizardCost()" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--text-light);padding:2px">✕</button>' +
'</div>';
});
html += '</div>' +
'<button class="btn btn-outline btn-sm" onclick="_addWizardIngRow()" style="font-size:12px"> 添加精油</button>' +
'</div>';
// Not found warnings
if (recipe.notFound && recipe.notFound.length) {
html += '<div style="font-size:12px;color:#c0392b;margin-bottom:12px">⚠️ 未识别:' + recipe.notFound.join('、') + '</div>';
}
// Cost display
const totalCost = recipe.ingredients.reduce((s, ing) => s + (OILS[ing.oil] || 0) * ing.drops, 0);
html += '<div id="wizardCostLine" style="font-size:14px;font-weight:600;color:var(--sage-dark);margin-bottom:14px">总成本:¥' + totalCost.toFixed(2) + '</div>';
// Action buttons
html += '<div style="display:flex;gap:10px;flex-wrap:wrap">' +
'<button class="btn btn-primary" onclick="_wizardSaveAndNext()">✅ 保存并' + (idx < total - 1 ? '下一个' : '完成') + '</button>' +
(idx < total - 1 ? '<button class="btn btn-outline" onclick="_wizardSkip()">放弃</button>' : '') +
'<button class="btn btn-outline" onclick="_wizardCancel()" style="color:var(--text-light)">取消</button>' +
'</div>';
html += '</div>';
resultDiv.innerHTML = html;
// Add live cost updates
resultDiv.querySelectorAll('.wiz-oil, .wiz-drops').forEach(el => {
el.addEventListener('change', _updateWizardCost);
el.addEventListener('input', _updateWizardCost);
});
}
function _addWizardIngRow() {
const list = document.getElementById('wizardIngList');
if (!list) return;
const div = document.createElement('div');
div.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:6px';
div.innerHTML =
'<select class="form-control wiz-oil" style="flex:2;font-size:13px;padding:6px 8px" onchange="_updateWizardCost()">' +
'<option value="">选择精油</option>' +
Object.keys(OILS).sort().map(n => '<option value="' + n + '">' + n + '</option>').join('') +
'</select>' +
'<input type="number" class="form-control wiz-drops" value="1" min="0.5" step="0.5" style="width:65px;font-size:13px;padding:6px 8px;text-align:center" oninput="_updateWizardCost()">' +
'<span style="font-size:11px;color:var(--text-light);width:50px;text-align:right">¥0.00</span>' +
'<button onclick="this.parentElement.remove();_updateWizardCost()" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--text-light);padding:2px">✕</button>';
list.appendChild(div);
}
function _updateWizardCost() {
const list = document.getElementById('wizardIngList');
if (!list) return;
let total = 0;
list.querySelectorAll('[style*="display:flex"]').forEach(row => {
const oil = row.querySelector('.wiz-oil')?.value;
const drops = parseFloat(row.querySelector('.wiz-drops')?.value) || 0;
const cost = (OILS[oil] || 0) * drops;
total += cost;
const costSpan = row.querySelector('span');
if (costSpan) costSpan.textContent = '¥' + cost.toFixed(2);
});
const costLine = document.getElementById('wizardCostLine');
if (costLine) costLine.textContent = '总成本:¥' + total.toFixed(2);
}
function _getWizardIngredients() {
const list = document.getElementById('wizardIngList');
if (!list) return [];
const ings = [];
list.querySelectorAll('[style*="display:flex"]').forEach(row => {
const oil = row.querySelector('.wiz-oil')?.value;
const drops = parseFloat(row.querySelector('.wiz-drops')?.value) || 0;
if (oil && drops > 0) ings.push({ oil, drops });
});
return ings;
}
async function _saveToDiary(name, ingredients, note) {
// Check for duplicate — same name in diary
const existing = userDiary.find(d => d.name === name);
if (existing) {
// Compare ingredients
const oldIngs = (existing.ingredients || []).filter(i => i.oil !== '椰子油').map(i => i.oil + ' ' + i.drops + '滴').join('、');
const newIngs = ingredients.filter(i => i.oil !== '椰子油').map(i => i.oil + ' ' + i.drops + '滴').join('、');
const isSame = oldIngs === newIngs;
if (isSame) {
_showToast('「' + name + '」已存在且内容相同,跳过');
return true; // treat as success to continue wizard
}
const msg = '个人配方中已有「' + name + '」\n\n原有配方' + (oldIngs || '空') + '\n新配方' + (newIngs || '空') + '\n\n是否覆盖';
if (!await _confirm(msg)) return 'cancel';
try {
const res = await _apiFetch('/api/diary/' + existing.id, { method: 'PUT', body: JSON.stringify({
name, note: note || '', ingredients: ingredients.map(i => ({ oil: i.oil, drops: i.drops }))
})});
if (res.ok) { await loadDiary(); return true; }
} catch(e) {}
return false;
}
try {
const res = await _apiFetch('/api/diary', { method: 'POST', body: JSON.stringify({
name, ingredients: ingredients.map(i => ({ oil: i.oil, drops: i.drops })), note: note || ''
})});
if (res.ok) { await loadDiary(); return true; }
} catch(e) {}
return false;
}
async function _wizardSaveAndNext() {
const name = document.getElementById('wizardName')?.value.trim();
if (!name) { alert('请输入配方名称'); return; }
const ings = _getWizardIngredients();
if (!ings.length) { alert('请至少添加一种精油'); return; }
const result = await _saveToDiary(name, ings, '');
if (result === 'cancel') {
// User chose not to overwrite — stay on current step, let them edit name
_showToast('可以修改配方名称后重新保存');
document.getElementById('wizardName')?.focus();
document.getElementById('wizardName')?.select();
return;
}
if (result) {
window._smartPasteSaved = (window._smartPasteSaved || 0) + 1;
}
window._smartPasteIdx++;
_renderSmartWizard();
}
function _wizardSkip() {
window._smartPasteIdx++;
_renderSmartWizard();
}
function _wizardCancel() {
const addResult = document.getElementById('addSmartPasteResult');
if (addResult) addResult.innerHTML = '';
const origResult = document.getElementById('smartPasteResult');
if (origResult) origResult.innerHTML = '';
window._smartPasteQueue = null;
// Close the add overlay if open
document.getElementById('addRecipeOverlay')?.remove();
}
function scaleRecipe(ingredients, coconutDrops, ratio) {
ratio = ratio || 8;
const totalEO = ingredients.reduce((s, ing) => s + ing.drops, 0);
const targetEO = coconutDrops / ratio;
const factor = targetEO / totalEO;
let scaled = ingredients.map(ing => ({
oil: ing.oil,
drops: Math.round(ing.drops * factor * 2) / 2
}));
scaled = scaled.map(ing => ({ ...ing, drops: Math.max(0.5, ing.drops) }));
scaled.push({ oil: '椰子油', drops: coconutDrops });
return scaled;
}
function getScaleParams() {
const coconut = parseFloat(document.getElementById('scaleCoconut')?.value) || 10;
const ratio = parseInt(document.getElementById('scaleRatio')?.value) || 8;
return { coconut, ratio };
}
function rescalePreview() {
const data = window._smartPasteData;
if (!data) return;
const { coconut, ratio } = getScaleParams();
data.scaledIngredients = scaleRecipe(data.ingredients, coconut, ratio);
const scaledDiv = document.getElementById('scaledPreviewContent');
if (scaledDiv) {
scaledDiv.innerHTML = renderRecipePreview('单次用量', data.name, data.scaledIngredients);
}
}
function renderRecipePreview(label, name, ingredients, notFound) {
let html = '<div style="font-weight:600;margin-bottom:8px">' + label + ':「' + name + '」</div><div style="font-size:13px;line-height:2">';
ingredients.forEach(ing => {
const cost = (OILS[ing.oil] || 0) * ing.drops;
html += '<span class="tag">' + ing.oil + ' ' + ing.drops + '滴 (¥' + cost.toFixed(2) + ')</span> ';
});
const total = ingredients.reduce((s, ing) => s + (OILS[ing.oil] || 0) * ing.drops, 0);
html += '</div><div style="margin-top:6px;font-size:13px;font-weight:600;color:var(--sage-dark)">总成本:¥' + total.toFixed(2) + '</div>';
if (notFound && notFound.length) {
html += '<div style="margin-top:6px;font-size:12px;color:#c0392b">⚠️ 未识别:' + notFound.join('、') + '</div>';
}
return html;
}
async function confirmSmartPaste(type) {
const data = window._smartPasteData;
if (!data) return;
const ings = type === 'scaled' ? data.scaledIngredients : data.ingredients;
const note = type === 'scaled' ? '单次用量(已换算)' : '';
const result = await _saveToDiary(data.name, ings, note);
if (result === 'cancel') {
_showToast('可以修改配方名称后重新保存');
return;
}
if (result) {
document.getElementById('smartPasteInput').value = '';
document.getElementById('smartPasteResult').innerHTML = '';
window._smartPasteData = null;
_showToast('✅ 已保存到我的配方');
}
}
function editDistance(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({length: m+1}, () => Array(n+1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = Math.min(
dp[i-1][j] + 1,
dp[i][j-1] + 1,
dp[i-1][j-1] + (a[i-1] === b[j-1] ? 0 : 1)
);
return dp[m][n];
}
// Homophone/pinyin alias map for common misspellings
const OIL_HOMOPHONES = {
'相貌':'香茅','香矛':'香茅','向茅':'香茅','像茅':'香茅',
'如香':'乳香','儒香':'乳香',
'古巴想':'古巴香脂','古巴香':'古巴香脂','古巴相脂':'古巴香脂',
'博荷':'薄荷','薄河':'薄荷',
'尤佳利':'尤加利','优加利':'尤加利',
'依兰':'依兰依兰',
'雪松木':'雪松',
'桧木':'扁柏','桧柏':'扁柏',
'永久化':'永久花','永久华':'永久花',
'罗马洋柑菊':'罗马洋甘菊','洋甘菊':'罗马洋甘菊',
'天竹葵':'天竺葵','天竺癸':'天竺葵',
'没要':'没药','莫药':'没药',
'快乐鼠尾':'快乐鼠尾草',
'椒样博荷':'椒样薄荷','椒样薄和':'椒样薄荷',
'丝柏木':'丝柏',
'柠檬草油':'柠檬草',
'茶树油':'茶树',
'薰衣草油':'薰衣草',
'玫瑰花':'玫瑰',
};
function findOil(input, oilNames) {
if (!input || input.length < 1) return null;
// Check homophone alias first
if (OIL_HOMOPHONES[input]) return OIL_HOMOPHONES[input];
// Exact match
if (OILS[input] !== undefined) return input;
// Substring match (prefer longest): input⊂name or name⊂input
let best = null;
let bestLen = 0;
for (const name of oilNames) {
if (name === input) return name;
if (name.includes(input) || input.includes(name)) {
if (name.length > bestLen) { best = name; bestLen = name.length; }
}
}
if (best) return best;
// "Missing one char" match: for oils with 3+ chars,
// check if input matches the oil name with any single char removed (or vice versa)
// e.g. 古巴想 vs 古巴香脂: 古巴香脂 remove '脂' = 古巴香 ≠ 古巴想, but 古巴想 has same length...
// Better: check if input and name share all-but-one characters in order
if (input.length >= 2) {
let bestMissing = null;
let bestMissingScore = 0;
for (const name of oilNames) {
if (name.length < 3) continue;
// Input is name minus one char
if (input.length === name.length - 1) {
for (let i = 0; i < name.length; i++) {
if (name.slice(0, i) + name.slice(i + 1) === input) {
if (name.length > bestMissingScore) { bestMissing = name; bestMissingScore = name.length; }
break;
}
}
}
// Name is input minus one char (input has extra char)
if (input.length === name.length + 1 && name.length >= 3) {
for (let i = 0; i < input.length; i++) {
if (input.slice(0, i) + input.slice(i + 1) === name) {
if (name.length > bestMissingScore) { bestMissing = name; bestMissingScore = name.length; }
break;
}
}
}
}
if (bestMissing) return bestMissing;
}
// Edit distance fuzzy: for 3+ char names, allow 1 substitution (same-length homophone)
// e.g. 古巴想脂 vs 古巴香脂 (one char different, same length)
let bestDist = Infinity;
let bestFuzzy = null;
for (const name of oilNames) {
const dist = editDistance(input, name);
let maxDist;
if (name.length === 2 && input.length === 2) {
maxDist = 1; // 2-char: allow one substitution (同音: 如祥→乳香)
} else if (name.length < 3) {
continue; // 2-char name vs different-length input: skip
} else {
maxDist = name.length <= 3 ? 1 : Math.ceil(name.length / 3);
}
if (dist <= maxDist && dist < bestDist) {
bestDist = dist;
bestFuzzy = name;
}
}
return bestFuzzy;
}
// ============ OILS SECTION ============
let editingOil = null;
let _oilViewMode = 'bottle'; // 'bottle' or 'drop'
function exportOilsPDF() {
const today = new Date();
const dateStr = today.getFullYear() + String(today.getMonth()+1).padStart(2,'0') + String(today.getDate()).padStart(2,'0');
const title = '国内多特瑞精油单价' + dateStr;
// Build table data sorted by name
const entries = Object.entries(OILS_META).sort((a, b) => a[0].localeCompare(b[0], 'zh'));
let html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>' + title + '</title>' +
'<style>' +
'body{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;padding:20px;font-size:11px;color:#333}' +
'h1{font-size:18px;text-align:center;margin-bottom:4px}' +
'p.sub{text-align:center;font-size:11px;color:#888;margin-bottom:16px}' +
'table{width:100%;border-collapse:collapse;margin-bottom:20px}' +
'th{background:#7a9e7e;color:white;padding:6px 8px;text-align:center;font-size:11px;font-weight:600}' +
'td{padding:5px 8px;border-bottom:1px solid #e0e0e0;text-align:center;font-size:11px}' +
'tr:nth-child(even){background:#f9f9f9}' +
'tr:hover{background:#e8f5e9}' +
'td:first-child{text-align:left;font-weight:500}' +
'@media print{body{padding:10px}h1{font-size:16px}}' +
'</style></head><body>' +
'<h1>doTERRA 精油单价表</h1>' +
'<p class="sub">' + title + ' · 会员优惠价</p>' +
'<table><thead><tr>' +
'<th>精油</th><th>每瓶价格(元)</th><th>零售价(元)</th><th>容量</th><th>滴数</th><th>单价(元/滴)</th>' +
'</tr></thead><tbody>';
const volMap = {46:'2.5ml',93:'5ml',160:'160颗',186:'10ml',280:'15ml',2146:'115ml'};
entries.forEach(([name, meta]) => {
const ppd = OILS[name] || 0;
const vol = volMap[meta.dropCount] || (meta.dropCount + '滴');
const unit = name === '植物空胶囊' ? '/颗' : '/滴';
html += '<tr>' +
'<td>' + name + '</td>' +
'<td>¥' + (meta.bottlePrice || 0) + '</td>' +
'<td>' + (meta.retailPrice ? '¥' + meta.retailPrice : '-') + '</td>' +
'<td>' + vol + '</td>' +
'<td>' + (meta.dropCount || '-') + '</td>' +
'<td>¥' + ppd.toFixed(2) + unit + '</td>' +
'</tr>';
});
html += '</tbody></table>' +
'<p style="text-align:center;font-size:10px;color:#aaa">共 ' + entries.length + ' 种精油 · doTERRA 配方计算器导出</p>' +
'</body></html>';
// Open in new window for print/save as PDF
const win = window.open('', '_blank');
win.document.write(html);
win.document.close();
win.document.title = title;
setTimeout(() => { win.print(); }, 500);
}
function _setOilView(mode) {
_oilViewMode = mode;
document.getElementById('oilViewBottle').style.cssText = 'border:none;border-radius:0;font-size:12px;padding:6px 12px;' + (mode==='bottle' ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)');
document.getElementById('oilViewDrop').style.cssText = 'border:none;border-radius:0;font-size:12px;padding:6px 12px;' + (mode==='drop' ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)');
renderOils(document.getElementById('oilSearchInput')?.value || '');
}
function renderOils(filter='') {
document.getElementById('oilCount').textContent = Object.keys(OILS).length;
const grid = document.getElementById('oilsGrid');
const entries = Object.entries(OILS).filter(([name]) =>
!filter || name.includes(filter)
).sort((a, b) => a[0].localeCompare(b[0], 'zh'));
grid.innerHTML = entries.map(([name, ppd]) => {
const meta = OILS_META[name] || {};
if (editingOil === name) {
const dc = meta.dropCount || '';
const volMap = {46:'2.5', 93:'5', 186:'10', 280:'15', 2146:'115'};
const vol = volMap[dc] || 'custom';
return `<div class="oil-chip" style="flex-wrap:wrap">
<span class="oil-chip-name" style="width:100%;margin-bottom:6px">${name}<br><span style="font-size:11px;color:var(--text-light);font-weight:400">${oilEn(name)}</span></span>
<input type="number" class="oil-edit-input" id="editBottlePrice" value="${meta.bottlePrice || ''}" step="0.01" min="0" placeholder="瓶价"
onkeydown="if(event.key==='Enter')saveOilEdit('${name}')">
<select class="oil-edit-input" id="editVolume" onchange="onVolumeChange('edit')" style="width:110px">
<option value="2.5" ${vol==='2.5'?'selected':''}>2.5ml</option>
<option value="5" ${vol==='5'?'selected':''}>5ml</option>
<option value="10" ${vol==='10'?'selected':''}>10ml</option>
<option value="15" ${vol==='15'?'selected':''}>15ml</option>
<option value="115" ${vol==='115'?'selected':''}>115ml</option>
<option value="custom" ${vol==='custom'?'selected':''}>自定义</option>
</select>
<input type="number" class="oil-edit-input" id="editDropCount" value="${dc}" step="1" min="1" placeholder="滴数"
style="display:${vol==='custom'?'inline-block':'none'}"
onkeydown="if(event.key==='Enter')saveOilEdit('${name}')">
<div class="oil-chip-actions">
<button class="oil-chip-btn" onclick="saveOilEdit('${name}')" title="保存">✓</button>
<button class="oil-chip-btn" onclick="cancelOilEdit()" title="取消">✕</button>
</div>
</div>`;
}
const canEditOil = currentUser.role === 'admin' || currentUser.role === 'senior_editor';
const adminActions = canEditOil ? `
<div style="display:flex;flex-direction:column;gap:1px">
<button onclick="event.stopPropagation();startOilEdit('${name}')" title="编辑" style="background:none;border:none;cursor:pointer;font-size:11px;padding:2px 4px;border-radius:4px;color:var(--text-light)" onmouseover="this.style.background='var(--sage-mist)'" onmouseout="this.style.background='none'">✏️</button>
<button onclick="event.stopPropagation();deleteOil('${name}')" title="删除" style="background:none;border:none;cursor:pointer;font-size:11px;padding:2px 4px;border-radius:4px;color:var(--text-light)" onmouseover="this.style.background='#fdf0ee'" onmouseout="this.style.background='none'">🗑</button>
</div>` : '';
const hasCard = _getOilCard(name);
const isActive = meta.isActive !== false;
const chipStyle = !isActive
? 'opacity:0.7;background:#f5f5f5'
: hasCard
? 'cursor:pointer;border-left:3px solid var(--sage);background:linear-gradient(90deg,var(--sage-mist),white)'
: '';
return `<div class="oil-chip" style="${chipStyle}" ${hasCard ? 'onclick="showOilCard(\''+name.replace(/'/g,"\\'")+'\')"' : ''}>
<div style="flex:1;min-width:0">
<span class="oil-chip-name">${name}${hasCard ? ' <span style="font-size:9px;color:var(--sage);background:var(--sage-mist);padding:1px 5px;border-radius:6px;vertical-align:middle">📖</span>' : ''}<br><span style="font-size:10px;color:var(--text-light);font-weight:400">${oilEn(name)}</span></span>
</div>
<div style="text-align:right;flex-shrink:0">
${_oilViewMode === 'bottle'
? ('<div style="font-size:13px;color:var(--sage-dark);font-weight:600">¥' + (meta.bottlePrice || (ppd * (meta.dropCount || 280))).toFixed(0) +
'<span style="font-size:10px;font-weight:400;color:var(--text-light)">/瓶</span>' +
(meta.dropCount ? '<span style="font-size:10px;font-weight:400;color:var(--text-light)"> ' + ({46:'2.5ml',93:'5ml',160:'160颗',186:'10ml',280:'15ml',2146:'115ml'}[meta.dropCount] || '') + '</span>' : '') +
'</div>' +
(meta.retailPrice ? '<div style="font-size:11px;color:var(--text-light);text-decoration:line-through">¥' + meta.retailPrice + '</div>' : ''))
: ('<div style="font-size:13px;color:var(--sage-dark);font-weight:600">¥' + ppd.toFixed(2) + (name==='植物空胶囊'?'/颗':'/滴') + '</div>' +
(meta.retailPrice ? '<div style="font-size:11px;color:var(--text-light);text-decoration:line-through">¥' + (meta.retailPrice/meta.dropCount).toFixed(2) + (name==='植物空胶囊'?'/颗':'/滴') + '</div>' : ''))
}
</div>
${adminActions || ''}
</div>`;
}).join('');
}
function filterOils() {
renderOils(document.getElementById('oilSearchInput').value.trim());
}
const VOLUME_DROPS = {'2.5': 46, '5': 93, '10': 186, '15': 280, '115': 2146};
function onVolumeChange(prefix) {
const volSelect = document.getElementById(prefix === 'new' ? 'newOilVolume' : 'editVolume');
const dropInput = document.getElementById(prefix === 'new' ? 'newOilDropCount' : 'editDropCount');
const vol = volSelect.value;
if (VOLUME_DROPS[vol]) {
dropInput.value = VOLUME_DROPS[vol];
dropInput.style.display = 'none';
} else if (vol === 'custom') {
dropInput.value = '';
dropInput.style.display = 'inline-block';
dropInput.focus();
} else {
dropInput.value = '';
dropInput.style.display = 'none';
}
}
function getDropCount(prefix) {
const volSelect = document.getElementById(prefix === 'new' ? 'newOilVolume' : 'editVolume');
const dropInput = document.getElementById(prefix === 'new' ? 'newOilDropCount' : 'editDropCount');
const vol = volSelect.value;
if (VOLUME_DROPS[vol]) return VOLUME_DROPS[vol];
return parseInt(dropInput.value);
}
async function addNewOil() {
const nameInput = document.getElementById('newOilName');
const bottlePriceInput = document.getElementById('newOilBottlePrice');
const name = nameInput.value.trim();
const bottlePrice = parseFloat(bottlePriceInput.value);
const dropCount = getDropCount('new');
if (!name) { alert('请输入精油名称'); return; }
if (isNaN(bottlePrice) || bottlePrice < 0) { alert('请输入有效的瓶价'); return; }
if (isNaN(dropCount) || dropCount <= 0) { alert('请选择容量或输入滴数'); return; }
if (OILS[name] !== undefined) {
const meta = OILS_META[name];
if (!await _confirm(`${name}」已存在(¥${meta.bottlePrice}/${meta.dropCount}滴),要更新吗?`)) return;
}
OILS[name] = bottlePrice / dropCount;
OILS_META[name] = { bottlePrice, dropCount };
saveOils();
nameInput.value = '';
bottlePriceInput.value = '';
document.getElementById('newOilVolume').value = '';
document.getElementById('newOilDropCount').value = '';
document.getElementById('newOilDropCount').style.display = 'none';
filterOils();
}
function startOilEdit(name) {
editingOil = name;
filterOils();
setTimeout(() => {
const input = document.getElementById('editOilPrice');
if (input) { input.focus(); input.select(); }
}, 50);
}
function saveOilEdit(name) {
const bottlePrice = parseFloat(document.getElementById('editBottlePrice').value);
const dropCount = getDropCount('edit');
if (isNaN(bottlePrice) || bottlePrice < 0) { alert('请输入有效的瓶价'); return; }
if (isNaN(dropCount) || dropCount <= 0) { alert('请选择容量或输入滴数'); return; }
OILS[name] = bottlePrice / dropCount;
OILS_META[name] = { bottlePrice, dropCount };
saveOils();
editingOil = null;
filterOils();
}
function cancelOilEdit() {
editingOil = null;
filterOils();
}
async function deleteOil(name) {
if (!await _confirm(`确认删除「${name}」?\n\n注意:已有配方中如果用到此精油,成本将显示为 ¥0`)) return;
delete OILS[name];
delete OILS_META[name];
saveOils();
filterOils();
}
// ============ OIL ENGLISH NAMES ============
const OIL_EN = {
"丁香花蕾":"Clove Bud","丝柏":"Cypress","乐活":"DigestZen","乐释":"Adaptiv",
"乳香":"Frankincense","五味子":"Schisandra","佛手柑":"Bergamot","依兰依兰":"Ylang Ylang",
"侧柏":"Arborvitae","保卫":"On Guard","元气":"Zendocrine","全神贯注":"Thinker",
"冬青":"Wintergreen","净化清新":"Purify","加州胡椒":"Pink Pepper","印蒿":"Manuka",
"古巴香脂":"Copaiba","圆柚":"Grapefruit","夏威夷檀香":"Hawaiian Sandalwood",
"天然防护":"TerraShield","天竺葵":"Geranium","姜黄":"Turmeric","安宁神气":"Serenity",
"安定情绪":"Balance","完美修护":"DDR Prime","宽容":"Forgive","小茴香":"Fennel",
"小豆蔻":"Cardamom","尤加利":"Eucalyptus","山鸡椒":"Litsea","岩兰草":"Vetiver",
"广藿香":"Patchouli","当归":"Angelica","快乐鼠尾草":"Clary Sage","恬家":"Tamer",
"愈创木":"Guaiacwood","扁柏":"Hinoki","抚慰":"Console","斯里兰卡肉桂皮":"Cinnamon Bark",
"新清肌呵护":"HD Clear Touch","新清肌调理":"HD Clear","新瑞活力":"MetaPWR",
"月桂叶":"Bay Leaf","木兰呵护":"Magnolia Touch","杜松浆果":"Juniper Berry",
"枫香":"Liquidambar","柑橘清新":"Citrus Bliss","柑橘绚烂":"Sunny Citrus",
"柠檬":"Lemon","柠檬尤加利":"Lemon Eucalyptus","柠檬草":"Lemongrass",
"柠檬香桃木":"Lemon Myrtle","桂花":"Osmanthus","桂花呵护":"Osmanthus Touch",
"丝柏呵护":"Cypress Touch","乐活呵护":"DigestZen Touch","乳香呵护":"Frankincense Touch",
"仕女呵护":"Soft Talk","保卫呵护":"On Guard Touch","元气焕能":"MetaPWR Advantage",
"全神贯注呵护":"Thinker Touch","永久花呵护":"Helichrysum Touch","牛至呵护":"Oregano Touch",
"舒压呵护":"PastTense Touch","艾草":"Mugwort","蓝睡莲呵护":"Blue Lotus Touch",
"薄荷呵护":"Peppermint Touch","薰衣草呵护":"Lavender Touch","顺畅呼吸呵护":"Breathe Touch",
"桦木":"Birch","植物空胶囊":"Veggie Cap","椒样薄荷":"Peppermint","椰子油":"Coconut Oil",
"椰风香草":"Vanilla & Coconut","橙花":"Neroli","橙花呵护":"Neroli Touch",
"檀香":"Sandalwood","欢欣":"Cheer","永久花":"Helichrysum","没药":"Myrrh",
"清醇薄荷":"SuperMint","温柔呵护":"ClaryCalm","热情":"Passion","特瑞活力":"DDR Prime",
"玫瑰":"Rose","玫瑰呵护":"Rose Touch","玫瑰草":"Palmarosa","甜茴香":"Sweet Fennel",
"生姜":"Ginger","百里香":"Thyme","穗甘松":"Spikenard","红橘":"Tangerine",
"绿薄荷":"Spearmint","缬草":"Valerian","罗勒":"Basil","罗文莎叶":"Ravensara",
"罗马洋甘菊":"Roman Chamomile","舒缓":"Deep Blue","芫荽":"Coriander",
"芫荽叶":"Cilantro","花样年华焕肤油":"Immortelle","芳香调理":"AromaTouch",
"芹菜籽":"Celery Seed","苦橙叶":"Petitgrain","茉莉":"Jasmine",
"茉莉呵护":"Jasmine Touch","茶树":"Tea Tree","茶树呵护":"Tea Tree Touch",
"莱姆":"Lime","蓝艾菊":"Blue Tansy","薰衣草":"Lavender",
"西伯利亚冷杉":"Siberian Fir","西洋蓍草":"Yarrow|Pom","西班牙牛至":"Oregano",
"西班牙鼠尾草":"Spanish Sage","赋活呼吸":"Air-X","迷迭香":"Rosemary",
"道格拉斯冷杉":"Douglas Fir","野橘":"Wild Orange","雪松":"Cedarwood",
"静谧":"Peace","顺畅呼吸":"Easy Air","香茅":"Citronella","香蜂草":"Melissa",
"马郁兰":"Marjoram","麦卢卡":"Manuka","黑云杉":"Black Spruce",
"黑胡椒":"Black Pepper","鼓舞":"Motivate"
};
function oilEn(name) { return OIL_EN[name] || ''; }
// Synonym groups for fuzzy search - any word in a group matches the others
const SEARCH_SYNONYMS = [
['胸','乳腺','丰胸','乳房'],
['头','头疼','头痛','头疗','头皮','头发','脱发','生发','白发','发膜'],
['睡','失眠','安眠','安睡','睡眠','多梦','助眠'],
['肚','腹','胃','消化','便秘','积食','腹泻','拉肚','脾胃','胀气'],
['瘦','减肥','减脂','瘦身','瘦腰','带脉'],
['痘','痤疮','粉刺','清痘','痘痘'],
['斑','淡斑','美白','祛斑','色斑'],
['皱','抗衰','紧致','皱纹','抗老','焕肤'],
['鼻','鼻炎','通鼻','鼻塞','鼻子'],
['咳','咳嗽','止咳','清咽','嗓子'],
['肩','颈','肩颈','落枕','富贵包','天鹅颈'],
['腰','腰椎','腰痛','腰背'],
['膝','关节','骨','滑膜','腱鞘','扭伤','韧带'],
['经','痛经','月经','调经','暖宫','子宫','卵巢'],
['眼','眼袋','黑眼圈','近视','老花','视力','干眼','明目'],
['湿','祛湿','湿疹','排湿','化湿'],
['寒','驱寒','手脚冰凉','冰冷','暖'],
['敏','过敏','敏感肌','荨麻疹','皮肤过敏'],
['免疫','感冒','发烧','退烧','防疫','保卫'],
['肝','护肝','排毒','肝脏','脂肪肝'],
['肾','强肾','前列腺','尿','泌尿'],
['心','心脑','血管','血压','高血压','心脏'],
['情绪','焦虑','抑郁','安定','放松','压力','失眠'],
['美容','护肤','面部','脸','毛孔','保湿'],
['呼吸','顺畅','哮喘','止鼾','打鼾'],
['糖','血糖','稳定血糖','降糖'],
['疤','疤痕','修复','伤口','烧烫伤'],
];
function getExpandedQuery(q) {
const terms = [q];
for (const group of SEARCH_SYNONYMS) {
if (group.some(w => q.includes(w) || w.includes(q))) {
for (const w of group) { if (!terms.includes(w)) terms.push(w); }
}
}
return terms;
}
function recipeNameEn(name) {
const MAP = {
'酸痛包':'Pain Relief','小v脸':'V-Face Lift','健脾化湿精油浴':'Spleen & Dampness Bath',
'一夜好眠精油浴':'Deep Sleep Bath','生发':'Hair Growth','湿疹舒缓':'Eczema Relief',
'乳腺疏通':'Breast Care','缓解酸痛精油刮痧':'Pain Relief Gua Sha','灰指甲':'Nail Fungus',
'白发转黑':'Gray Hair Reversal','紫外线修复':'UV Repair','瘦身带脉':'Slimming Belt',
'私密护理':'Intimate Care','痘痘':'Acne Care','淋巴排毒':'Lymph Detox',
'情绪管理':'Emotional Balance','发膜':'Hair Mask','脾胃养护':'Digestive Care',
'招财开运油':'Prosperity Oil','植物热玛吉':'Plant Thermage','十全大补':'Ultimate Tonic',
'豪华头疗':'Luxury Scalp Treatment','明目青睐':'Eye Care','缓解头痛':'Headache Relief',
'护肝排毒':'Liver Detox','强心护心':'Heart Support','通鼻消炎':'Sinus Relief',
'过敏湿疹':'Allergy & Eczema','肺部结节':'Lung Nodule Care','尿路感染':'UTI Care',
'皮外损伤':'Wound Healing','平衡血糖':'Blood Sugar Balance','肚痛腹泻':'Stomach Relief',
'韧带扭伤':'Ligament Sprain','血脂稳定':'Cholesterol Balance','冻疮修复':'Frostbite Repair',
'皮下囊肿':'Subcutaneous Cyst','疏肝解郁':'Liver Qi Relief','大脑抗衰':'Brain Anti-Aging',
'神经麻木':'Nerve Numbness','春季用油':'Spring Blend','夏季用油':'Summer Blend',
'秋季用油':'Autumn Blend','冬季用油':'Winter Blend','祛湿丸子':'Dampness Ball',
'三伏排湿':'San Fu Dampness','三伏晒背':'San Fu Back Sun','三伏百会':'San Fu Bai Hui',
'三伏八髎':'San Fu Ba Liao','三伏大椎':'San Fu Da Zhui','三伏檀中':'San Fu Dan Zhong',
'三伏扶阳':'San Fu Yang Support','女性内分泌':'Female Hormones','提升免疫力':'Immune Boost',
'面部精华':'Facial Serum','日常头疗':'Daily Scalp Care','肝肾保护':'Liver & Kidney',
'情绪能量油':'Emotional Energy','养心宁神':'Heart & Spirit','安稳睡眠':'Steady Sleep',
'过敏体质':'Allergy Constitution','淋巴结节':'Lymph Nodule','甲状腺结节':'Thyroid Nodule',
'皮肤过敏':'Skin Allergy','止鼾安睡':'Anti-Snore Sleep','哮喘缓解':'Asthma Relief',
'带状疱疹':'Shingles Care','耳聋耳鸣':'Tinnitus Care','减脂瘦身':'Fat Burning',
'痔疮用油':'Hemorrhoid Care','静脉曲张':'Varicose Veins','痛风缓解':'Gout Relief',
'滑膜炎症':'Synovitis Care','腱鞘炎症':'Tendinitis Care','腰椎滑脱':'Lumbar Slip',
'暖宫调经':'Warming Womb','经期止痛':'Period Pain Relief','乳腺增生':'Breast Hyperplasia',
'丰胸挺拔':'Breast Enhancement','私密紧致':'Intimate Tightening','乳腺结节':'Breast Nodule',
'手脚冰凉':'Cold Hands & Feet','修复腹直肌':'Diastasis Recti','子宫肌瘤':'Uterine Fibroid',
'卵巢囊肿':'Ovarian Cyst','美背':'Beautiful Back','减轻妊娠纹':'Stretch Mark',
'更年期症':'Menopause Support','妇科炎症':'Gynecological Care','备孕调理':'Fertility',
'抗衰紧致':'Anti-Aging Firming','美白淡斑':'Whitening & Brightening',
'水油平衡':'Oil-Water Balance','敏感肌':'Sensitive Skin','防晒修复':'Sun Repair',
'深层净化排毒':'Deep Purifying Detox','唇部保养':'Lip Care','皱纹推土机':'Wrinkle Bulldozer',
'祛除颈纹':'Neck Line Removal','植物水光针':'Plant Water Glow',
'早C精华':'Morning C Serum','晚A精华':'Night A Serum','消鸡皮肤':'Keratosis Care',
'保湿焕肤':'Moisturizing Renewal','养护指甲':'Nail Care','疤痕修复':'Scar Repair',
'收缩毛孔':'Pore Minimizer','酒糟鼻':'Rosacea Care','眼袋黑眼圈':'Dark Circle Care',
'清痘无痕':'Clear Acne','脂肪粒':'Milia Care','头疼':'Headache','痤疮粉刺':'Acne',
'近视老花':'Vision Care','记忆力':'Memory Boost','皮肤老化':'Skin Aging',
'头皮屑':'Dandruff','鼻炎用油':'Rhinitis Care','中耳炎':'Otitis Media',
'口腔溃疡':'Mouth Ulcer','口臭':'Bad Breath','牙龈牙周':'Gum Care',
'烧烫伤':'Burn Care','蚊虫叮咬':'Insect Bite','退烧神器':'Fever Reducer',
'感冒咳嗽':'Cold & Cough','腹泻':'Diarrhea','晕车恶心':'Motion Sickness',
'醉酒':'Hangover','脚气':'Athlete\'s Foot','痛经':'Period Cramp',
'亲密关系':'Intimacy','前列腺养护':'Prostate Care','手脚冰冷':'Cold Extremities',
'口唇疱疹':'Cold Sore','外阴瘙痒':'Feminine Itch','便秘积食':'Constipation',
'痛风':'Gout','消除富贵包':'Buffalo Hump','焦虑':'Anxiety',
'更年期':'Menopause','失眠多梦':'Insomnia','稳定血糖':'Blood Sugar',
'心脑血管护理':'Cardiovascular','关节疼痛':'Joint Pain','高血压保健':'Blood Pressure',
'儿童长高':'Child Growth','湿疹':'Eczema','驱蚊喷雾':'Mosquito Spray',
'学霸神助':'Study Focus','儿童抚触':'Child Touch','儿童脾胃':'Child Digestion',
'视力养护':'Vision Support','退烧方案':'Fever Protocol','白发转黑发':'Gray to Black Hair',
'生发配方':'Hair Growth Formula','消脂肪肝':'Fatty Liver','尿床尿频':'Bedwetting',
};
return MAP[name] || name;
}
function oilRetailPpd(name) { const m = OILS_META[name]; return m && m.retailPrice ? m.retailPrice / m.dropCount : 0; }
function calcRetailCost(ingredients) { return ingredients.reduce((s, i) => s + oilRetailPpd(i.oil) * i.drops, 0); }
function fmtCostWithRetail(ingredients) {
const cost = calcCost(ingredients);
const retail = calcRetailCost(ingredients);
if (retail > 0 && retail !== cost) {
return fmtPrice(cost) + ' <span style="text-decoration:line-through;color:var(--text-light);font-size:12px">' + fmtPrice(retail) + '</span>';
}
return fmtPrice(cost);
}
// ============ OIL KNOWLEDGE CARDS ============
const OIL_CARDS = {
'野橘':{emoji:'🍊',en:'Wild Orange',effects:'安抚镇静、驱散负能量、紧张情绪和压力\n抗氧化、消炎、抗病毒、增强免疫\n提振食欲刺激胆汁分泌促进消化\n促进循环',usage:'日常香薰,提振情绪、舒压,营造愉悦氛围\n饮水加入 1至2 滴,护肝抗病毒\n泡澡时滴 3至4 滴,消除疲劳,放松身心\n扫地、拖地时加入 3至5 滴,清新空气\n涂抹腹部促进消化\n涂抹肝区帮助肝脏排毒\n口腔溃疡时加入水中漱口',method:'🔹香薰 🔸内用 🔺涂抹',caution:'轻微光敏,白天涂抹注意防晒'},
'冬青':{emoji:'🌿',en:'Wintergreen',effects:'强效镇痛(肌肉、关节)\n抗炎、促进循环\n舒缓紧绷肌肉抗痉挛',usage:'牙疼时加 1 滴到水中漱口\n扭伤、落枕、酸痛如肩颈酸痛处稀释涂抹\n运动前后按摩',method:'🔹香薰 |🔺涂抹(需 6 倍稀释)',caution:'不可内用、孕期慎用、避免儿童误食'},
'生姜':{emoji:'🫚',en:'Ginger',effects:'促进消化、暖胃\n活血、改善循环、祛湿\n抗炎、抗氧化、强健免疫\n缓解恶心、晕车\n促进骨骼、肌肉和关节的健康',usage:'胀气、腹冷时,稀释涂抹腹部或喝 1 滴\n手脚冰凉时稀释涂抹脚底或将1滴加入热饮中\n晕车时吸闻或滴在手心嗅吸\n祛除风寒可将 2 滴加入热水中泡脚\n痛经时稀释涂抹于小腹并按摩\n做菜时可加入 1 滴帮助增添风味',method:'🔹香薰 🔸内用 🔺涂抹(需稀释)',caution:''},
'柠檬草':{emoji:'🍃',en:'Lemongrass',effects:'强效抗菌、抗炎\n驱虫、净化空气\n扩张血管促进循环缓解肌肉疼痛',usage:'筋膜紧绷、腿麻或肌肉酸痛时稀释涂抹\n肩周炎时6 倍稀释后涂抹于肩颈部位并按摩\n做菜时加入 1 滴,增加泰式风味\n加入椰子油中制成家居喷雾涂抹在裸露肌肤上驱蚊虫\n洗衣时加 3至5 滴祛味杀菌\n日常香薰平衡情绪',method:'🔹香薰 🔸内用 🔺涂抹(需 6 倍稀释)',caution:''},
'柑橘清新':{emoji:'🍬',en:'Citrus Bliss',effects:'提振精神,改善负面情绪\n净化空间\n降低压力',usage:'日常香薰提升愉悦感,提振精神,净化空间\n拖地时加几滴清新空气\n加入到护手霜中滋润手部肌肤享受清新香气',method:'🔹香薰 🔺涂抹',caution:'含柑橘类,光敏注意白天涂抹'},
'芳香调理':{emoji:'🤲',en:'AromaTouch',effects:'放松紧绷肌肉,放松关节\n促进血液循环\n促进淋巴排毒\n提升免疫\n舒缓放松减少紧张',usage:'稀释涂抹于太阳穴,缓解头痛,改善紧张情绪\n稀释涂抹于僵硬的身体部位如肩颈处并按摩促进肌肉放松\n日常香薰或加入热水中泡澡释放压力',method:'🔹香薰 🔺涂抹',caution:''},
'西洋蓍草':{emoji:'🔵',en:'Yarrow | Pom',effects:'改善肌肤老化症状\n美白肌肤改善瑕疵\n呵护敏感肌肤对抗炎症\n提升整体免疫',usage:'早晚护肤时涂抹3至4滴于面部改善皱纹和细纹美白肌肤\n每天早晚舌下含服1滴促进细胞健康提升免疫',method:'🔸内用 🔺涂抹',caution:''},
'新瑞活力':{emoji:'🌿',en:'MetaPWR',effects:'促进新陈代谢,减肥\n抑制食欲减少对甜食的渴望\n稳定血糖波动\n提振情绪激励身心',usage:'饭前喝1至2滴控制食欲稳定血糖提升代谢\n日常香薰可以帮助恢复能量消除疲乏感\n稀释涂抹与身体需紧致的部位帮助紧致塑形\n加入饮品中帮助增添风味\n\n\n\n刺激等级需稀释',method:'🔹香薰 🔸内用 🔺涂抹(需稀释)',caution:''},
'安定情绪':{emoji:'🌳',en:'Balance',effects:'促进全身的放松\n减轻焦虑缓解紧张情绪\n带来宁静和安定感',usage:'日常香薰稳定情绪,放松\n夜间香薰促进睡眠\n涂抹脚底或脊椎放松情绪放松肌肉\n冥想、瑜伽前涂抹',method:'🔹香薰 🔺涂抹',caution:''},
'安宁神气':{emoji:'😴',en:'Serenity',effects:'促进深度睡眠\n放松身体缓解焦虑\n平衡情绪\n平衡自律神经系统',usage:'夜间香薰或稀释涂抹脚底促进深度睡眠,释放压力\n稀释涂抹太阳穴或脚底舒缓压力\n吸闻缓解焦虑和紧张情绪',method:'🔹香薰 🔺涂抹',caution:''},
'元气':{emoji:'🔥',en:'Zendocrine',effects:'帮助身体净化,排毒\n维持肝脏和肾脏健康\n平衡情徐',usage:'饭前内用1至2滴帮助代谢\n稀释涂抹肝区或内服3滴帮助养护肝脏\n稀释涂抹后腰脊椎出帮助养护肾脏排除毒素\n日常香薰消除压力',method:'🔹香薰 🔸内用 🔺涂抹',caution:''},
'温柔呵护':{emoji:'🌸',en:'Soft Talk',effects:'平衡荷尔蒙\n抚平情绪波动\n调理经期不适\n舒缓压力\n提升女性魅力',usage:'稀释涂抹下腹部帮助平衡荷尔蒙,或进行经期调理\n手心嗅吸帮助舒缓压力平衡情绪\n2滴直接涂抹于脖颈后侧或手腕动脉处提升女性魅力',method:'🔹香薰 🔺涂抹',caution:''},
'柠檬':{emoji:'🍋',en:'Lemon',effects:'清洁身体与环境\n强健免疫系统\n帮助肝脏代谢、排毒\n抗氧化\n净化空气、去异味\n蔬果清洗、保鲜减少果蝇\n促进循环、提振精神',usage:'添加至护肤品中晚上使用\n添加至牙膏里美白牙齿\n滴入口中或水里喝下一天三次每次3至5滴净化身体\n洗水果和蔬菜时添加 1至2 滴浸泡\n嗓子疼或感冒初期时含服柠檬1至2滴做阻截病毒的第一道防线\n日常香薰提振情绪护肝',method:'🔹香薰 🔸内用 🔺涂抹(夜间)',caution:'光敏性,白天避免涂抹'},
'薰衣草':{emoji:'💜',en:'Lavender',effects:'镇静安神、改善睡眠、缓解头痛\n舒缓压力、平衡情绪、抗抑郁\n烧烫伤修复、疤痕、痘印\n促进伤口修复、止血\n促进细胞再生修复结缔组织\n抗炎、抗过敏、止痛\n抗痉挛\n皮肤舒缓止痒如蚊虫叮咬',usage:'烧伤、烫伤、割伤及任何伤口处涂抹,止血防疤\n夜间香薰助眠白天香薰舒缓情绪\n鱼刺卡嗓子时滴入口中\n加入护肤品中平衡油脂、改善痘痘、去疤痕',method:'🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)',caution:''},
'椒样薄荷':{emoji:'🌿',en:'Peppermint',effects:'促进健康的呼吸系统\n祛痰、抗粘膜发炎、打开呼吸道\n强肝利胆促进消化舒缓各种肠胃疾病\n退热、缓解中暑\n清凉止痒\n提神醒脑、提升专注、缓解头痛',usage:'白天香薰提神醒脑,清新空气\n按摩头部缓解头疼、提神醒脑\n蚊虫叮咬后涂抹止痒\n混入水中进行漱口清新口气\n在喷瓶中和水混合夏天时每次晃匀喷洒于身上预防中暑\n发烧时2小时将稀释后的薄荷涂抹于额头、腋下、腹股沟等帮助身体降温\n打嗝、咳嗽、鼻塞时吸闻\n晕车时吸闻\n消化不良时稀释涂抹于腹部或内用 2 滴\n过敏时使用吸闻、内用、涂抹均可',method:'🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)',caution:'孕期/高血压慎用,晚上少用'},
'茶树':{emoji:'🌱',en:'Tea Tree',effects:'抗菌、抗病毒、抗真菌\n提升免疫力\n头皮屑护理\n预防化脓\n居家杀菌净化',usage:'各种痤疮处点涂\n加入护肤品中清洁皮肤促进皮肤健康\n湿疹、痱子时稀释涂抹\n洗头时加 1 滴到洗头膏,去头皮屑\n洗衣服时加入 3至5 滴,杀菌祛味\n脚气时用茶树泡脚并和水混合后喷撒于鞋内\n中耳炎时在睡前滴 1 滴到棉球上,塞入耳道\n眼睛发炎时稀释涂抹于大眼眶\n阴道炎时在热水中加入2至3滴坐浴或滴于内裤上\n感冒时涂抹杀菌抗病毒',method:'🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)',caution:''},
'西班牙牛至':{emoji:'🔥',en:'Oregano',effects:'强抗菌、抗病毒、抗顽固性真菌\n成人炎症辅助\n促进消化\n强抗氧化、抗衰老\n免疫力提升',usage:'洗衣服或拖地时加入 3至5 滴,消炎杀菌,自然清香\n吃坏肚子或消化不良时稀释涂抹于腹部或灌于胶囊中内用\n灰指甲时稀释涂抹于患处每日 1至2 次\n疣、痣、鸡眼等不健康的皮肤组织上稀释涂抹\n关节炎等肌肉骨骼酸痛问题配合稀释涂抹消炎止痛\n流感季节时香薰杀灭空气中微生物\n炒牛肉时用牙签蘸取少许增添风味',method:'🔹香薰 🔸内用(胶囊) 🔺涂抹(需高倍稀释)',caution:''},
'保卫':{emoji:'🛡',en:'On Guard',effects:'强化免疫力\n抗氧化\n天然杀菌、净化空气\n维护口腔健康',usage:'日常香熏净化空气,强化免疫力\n流感季节或换季时香薰阻断病毒传播\n混入水中漱口保持口气清新\n牙龈炎、牙周炎、拔牙后混入椰子油中漱口油漱法\n日常稀释涂抹于脊椎或脚底强化免疫力\n感冒时涂抹抗菌抗病毒',method:'🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)',caution:'含肉桂丁香,不宜频繁涂抹'},
'顺畅呼吸':{emoji:'🌬',en:'Breathe',effects:'帮助缓解鼻炎、感冒等呼吸道不适\n促进呼吸系统健康\n净化空气\n换季时保护呼吸系统',usage:'日常香薰,强健呼吸系统,净化空气\n咳嗽、鼻塞时香薰、吸闻可滴在口罩内、涂抹于鼻翼、喉咙或肺部\n乘坐公共交通工具时可滴在口罩内清新空气\n打鼾、哮喘、鼻炎可日常吸闻夜间滴在口罩内\n运动前吸闻扩张呼吸道',method:'🔹香薰 🔺涂抹(儿童/敏感肌需稀释)',caution:''},
'乐活':{emoji:'🍃',en:'DigestZen',effects:'促进消化\n缓解胀气、消化不良、便秘等胃肠不适\n胃肠型感冒改善',usage:'便秘时,稀释涂抹肚脐周围并顺时针揉腹\n喝酒前后各喝2滴解酒护肝\n晕车、晕船时吸闻或稀释涂抹于肚脐周围可缓解恶心舒缓情绪\n胃肠型感冒时稀释涂抹肚脐周围并揉腹\n至拉肚子时喝2滴或稀释涂抹肚脐周围并逆时针揉腹\n可滴入水中泡制卤蛋',method:'🔹熏香 🔸内用 🔺涂抹(儿童/敏感肌需稀释)',caution:''},
'舒缓':{emoji:'🌿',en:'Deep Blue',effects:'缓解肌肉酸痛\n抗痉挛抗炎',usage:'肌肉酸痛、扭伤、挫伤、肩颈紧绷、落枕、关节疼痛、腰背部酸痛、运动损伤时,稀释涂抹于患处',method:'🔺涂抹(需稀释)',caution:''},
'乳香':{emoji:'👑',en:'Frankincense',effects:'促进伤口愈合,促进细胞健康\n抗氧化、抗发炎、抗衰老、淡纹\n活血行气\n疏通血管\n滋养大脑神经提高血氧含量',usage:'加入护肤品中,淡斑,抗衰\n稀释后涂抹大眼眶改善视力\n早晚舌下含服 2 滴,提高血氧含量,缓解头疼,疏通血管\n夜间香薰滋养大脑安眠\n任何情况下想不起来用什么就用乳香',method:'🔹香薰 🔸内用 🔺涂抹',caution:''}
};
const OIL_CARD_ALIAS = {
'仕女呵护': '温柔呵护',
'薄荷呵护': '椒样薄荷',
'牛至呵护': '西班牙牛至',
};
function _getOilCard(name) {
if (OIL_CARDS[name]) return OIL_CARDS[name];
// Check alias
if (OIL_CARD_ALIAS[name] && OIL_CARDS[OIL_CARD_ALIAS[name]]) return OIL_CARDS[OIL_CARD_ALIAS[name]];
// Fallback: 呵护 variant → base oil card
const base = name.replace(/呵护$/, '');
if (base !== name && OIL_CARDS[base]) return OIL_CARDS[base];
return null;
}
function showOilCard(name) {
const card = _getOilCard(name);
if (!card) return;
const meta = OILS_META[name] || {};
const ppd = OILS[name] || 0;
const rpd = meta.retailPrice ? (meta.retailPrice / meta.dropCount) : 0;
const en = card.en || oilEn(name);
const fmtList = (text) => {
if (!text) return '';
return text.replace(/\\n/g, '\n').split('\n').filter(l => l.trim()).map(l =>
'<div style="display:flex;gap:8px;margin-bottom:8px;align-items:flex-start">' +
'<span style="color:var(--sage);flex-shrink:0;margin-top:3px;font-size:8px">●</span>' +
'<span>' + l.trim() + '</span></div>'
).join('');
};
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:16px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
let html = '<div style="position:absolute;inset:0;background:rgba(0,0,0,0.4)" onclick="this.parentElement.remove()"></div>';
html += '<div style="position:relative;z-index:1;background:white;border-radius:20px;max-width:460px;width:100%;max-height:88vh;overflow-y:auto;box-shadow:0 16px 56px rgba(0,0,0,0.25)" onclick="event.stopPropagation()">';
// Header
html += '<div style="background:linear-gradient(135deg,#3d6b41 0%,#5a7d5e 50%,#7a9e7e 100%);border-radius:20px 20px 0 0;padding:28px 24px;color:white;text-align:center;position:relative">';
html += '<button onclick="this.closest(\'[style*=fixed]\').remove()" style="position:absolute;top:12px;right:16px;background:rgba(255,255,255,0.2);border:none;color:white;width:30px;height:30px;border-radius:50%;cursor:pointer;font-size:16px">×</button>';
html += '<div style="font-size:48px;margin-bottom:6px;filter:drop-shadow(0 2px 8px rgba(0,0,0,0.2))">' + card.emoji + '</div>';
html += '<div style="font-family:Noto Serif SC,serif;font-size:22px;font-weight:700;letter-spacing:2px">' + name + '</div>';
html += '<div style="font-size:13px;opacity:0.85;margin-top:4px;letter-spacing:1px">' + en + '</div>';
// Price info
html += '<div style="margin-top:12px;display:flex;justify-content:center;gap:12px;font-size:12px;opacity:0.9">';
html += '<span>¥' + ppd.toFixed(2) + '/滴</span>';
html += '<span>' + (meta.dropCount || '?') + '滴/瓶</span>';
html += '<span>¥' + (meta.bottlePrice || '?') + '/瓶</span>';
html += '</div>';
if (rpd) {
html += '<div style="font-size:11px;opacity:0.7;margin-top:4px;text-decoration:line-through">零售 ¥' + rpd.toFixed(2) + '/滴 · ¥' + meta.retailPrice + '/瓶</div>';
}
html += '</div>';
// Body
html += '<div style="padding:24px">';
// Method badges
if (card.method) {
html += '<div style="text-align:center;margin-bottom:16px">';
const methods = card.method.split('').map(m => m.trim());
methods.forEach(m => {
let bg = 'var(--sage-mist)'; let color = 'var(--sage-dark)';
if (m.includes('内用')) { bg = '#fff3e0'; color = '#e65100'; }
if (m.includes('涂抹')) { bg = '#e8f5e9'; color = '#2e7d32'; }
if (m.includes('香薰') || m.includes('熏香')) { bg = '#e3f2fd'; color = '#1565c0'; }
html += '<span style="display:inline-block;padding:4px 12px;border-radius:16px;font-size:12px;margin:2px;background:' + bg + ';color:' + color + '">' + m + '</span>';
});
html += '</div>';
}
// Effects
html += '<div style="margin-bottom:20px">';
html += '<div style="font-size:16px;font-weight:700;color:var(--sage-dark);margin-bottom:12px;letter-spacing:1px;display:flex;align-items:center;gap:6px"><span style="font-size:18px">✨</span> 主要功效</div>';
html += '<div style="font-size:13px;color:var(--text-dark);line-height:1.9">' + fmtList(card.effects) + '</div>';
html += '</div>';
// Usage
html += '<div style="margin-bottom:20px">';
html += '<div style="font-size:16px;font-weight:700;color:var(--sage-dark);margin-bottom:12px;letter-spacing:1px;display:flex;align-items:center;gap:6px"><span style="font-size:18px">📖</span> 使用方法</div>';
html += '<div style="font-size:13px;color:var(--text-mid);line-height:1.9">' + fmtList(card.usage) + '</div>';
html += '</div>';
// Caution
if (card.caution) {
html += '<div style="background:#fff8e1;border-radius:10px;padding:12px 16px;border-left:3px solid #ffc107">';
html += '<div style="font-size:12px;font-weight:600;color:#f57f17;margin-bottom:4px">⚠️ 注意事项</div>';
html += '<div style="font-size:13px;color:#795548">' + card.caution.replace(/\\n/g, '<br>') + '</div>';
html += '</div>';
}
// Brand footer
if (userBrand.qr_code || userBrand.brand_logo || userBrand.brand_name) {
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding-top:16px;margin-top:16px;border-top:1px solid rgba(180,150,100,0.15);gap:10px">';
html += '<div style="display:flex;align-items:center;gap:8px;flex:1">';
if (userBrand.brand_logo) html += '<img src="' + userBrand.brand_logo + '" style="height:28px;width:auto;object-fit:contain">';
if (userBrand.brand_name) html += '<span style="font-size:11px;color:#8a7a6a">' + userBrand.brand_name + '</span>';
html += '</div>';
if (userBrand.qr_code) html += '<img src="' + userBrand.qr_code + '" style="width:56px;height:56px;object-fit:cover;border-radius:4px">';
html += '</div>';
}
html += '</div>'; // close body padding div
html += '</div>'; // close card container
overlay.innerHTML = html;
document.body.appendChild(overlay);
_autoConvertCardToImage(overlay);
}
function _autoConvertCardToImage(overlay) {
const cardDiv = overlay.querySelector('[style*="max-width"]');
if (!cardDiv) return;
// Hide buttons during capture
const btns = cardDiv.querySelectorAll('button');
btns.forEach(b => b.style.display = 'none');
setTimeout(() => {
html2canvas(cardDiv, { scale: 2, backgroundColor: '#ffffff', useCORS: true }).then(canvas => {
const imgUrl = canvas.toDataURL('image/png');
overlay.innerHTML = '';
overlay.style.cssText = 'position:fixed;inset:0;z-index:200;background:rgba(0,0,0,0.7);display:flex;flex-direction:column;align-items:center;padding:16px;overflow-y:auto';
overlay.onclick = () => overlay.remove();
overlay.innerHTML =
'<img src="' + imgUrl + '" style="max-width:100%;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.3);margin-top:16px" onclick="event.stopPropagation()">' +
'<div style="text-align:center;margin-top:10px;font-size:13px;color:rgba(255,255,255,0.7)">📱 长按图片保存到相册</div>' +
'<button class="btn btn-outline btn-sm" onclick="this.closest(\'[style*=fixed]\').remove()" style="color:white;border-color:rgba(255,255,255,0.3);margin-top:8px">关闭</button>';
}).catch(() => { btns.forEach(b => b.style.display = ''); });
}, 100);
}
function showDilutionCard() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:16px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.4)" onclick="this.parentElement.remove()"></div>
<div style="position:relative;z-index:1;background:white;border-radius:20px;max-width:420px;width:100%;max-height:88vh;overflow-y:auto;box-shadow:0 16px 56px rgba(0,0,0,0.25)" onclick="event.stopPropagation()">
<div style="background:linear-gradient(135deg,#2e7d32,#66bb6a);border-radius:20px 20px 0 0;padding:28px 24px;color:white;text-align:center">
<div style="font-size:48px;margin-bottom:8px">💧</div>
<div style="font-family:Noto Serif SC,serif;font-size:22px;font-weight:700">精油稀释比例指南</div>
<div style="font-size:13px;opacity:0.85;margin-top:4px">安全使用,科学稀释</div>
</div>
<div style="padding:24px">
<table style="width:100%;border-collapse:collapse;font-size:14px">
<tr style="border-bottom:2px solid #e8f5e9"><th style="text-align:left;padding:10px 8px;color:#2e7d32">适用人群</th><th style="text-align:right;padding:10px 8px;color:#2e7d32">精油 : 椰子油</th></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">👶 1岁以下</td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 200</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">🧒 1 至 2 岁</td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 100</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">👦 2 至 6 岁</td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 50</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">🧑 6 至 12 岁</td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 10</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">🧴 成人敏感肌</td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 5~10</td></tr>
<tr><td style="padding:10px 8px">🔥 强刺激精油<br><span style="font-size:11px;color:var(--text-light)">牛至/肉桂/丁香/桂皮等</span></td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 6~10</td></tr>
</table>
<div style="margin-top:16px;padding:12px;background:#e8f5e9;border-radius:10px;font-size:12px;color:#2e7d32;text-align:center">
💡 稀释比例 = 1滴精油 : N滴椰子油<br>比例越大越温和,新手建议从高稀释比例开始
</div>
</div>
<div style="padding:0 24px 20px;display:flex;gap:8px;justify-content:center">
</div>
</div>`;
document.body.appendChild(overlay);
_autoConvertCardToImage(overlay);
}
function showCautionCard() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:16px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.4)" onclick="this.parentElement.remove()"></div>
<div style="position:relative;z-index:1;background:white;border-radius:20px;max-width:420px;width:100%;max-height:88vh;overflow-y:auto;box-shadow:0 16px 56px rgba(0,0,0,0.25)" onclick="event.stopPropagation()">
<div style="background:linear-gradient(135deg,#e65100,#ff9800);border-radius:20px 20px 0 0;padding:28px 24px;color:white;text-align:center">
<div style="font-size:48px;margin-bottom:8px">⚠️</div>
<div style="font-family:Noto Serif SC,serif;font-size:22px;font-weight:700">精油使用禁忌</div>
<div style="font-size:13px;opacity:0.85;margin-top:4px">安全第一,正确使用</div>
</div>
<div style="padding:24px">
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🚫</span>
<div><div style="font-weight:600;color:var(--text-dark)">不得入眼、耳、鼻腔</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">精油不可直接接触眼睛、耳道和鼻腔内部</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🥥</span>
<div><div style="font-weight:600;color:var(--text-dark)">误触或刺激 → 用椰子油稀释</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">不可用水冲洗,水会加剧刺激,用椰子油涂抹稀释</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🌙</span>
<div><div style="font-weight:600;color:var(--text-dark)">柠檬等光敏性精油仅夜间涂抹</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">涂抹后 12 小时内避免阳光直射</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🌡️</span>
<div><div style="font-weight:600;color:var(--text-dark)">阴凉避光保存,远离儿童</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">避免高温和阳光直射,放在儿童够不到的地方</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🧴</span>
<div><div style="font-weight:600;color:var(--text-dark)">避免和塑料制品接触</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">精油会腐蚀塑料,请使用玻璃或不锈钢容器</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">💧</span>
<div><div style="font-weight:600;color:var(--text-dark)">少量多次,多喝水</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">使用精油后多补充水分,帮助身体代谢</div></div>
</div>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
_autoConvertCardToImage(overlay);
}
function saveTipCardImage(overlay, name) {
const cardDiv = overlay.querySelector('[style*="max-width:420px"]');
if (!cardDiv) return;
const btns = cardDiv.querySelectorAll('button');
btns.forEach(b => b.style.display = 'none');
html2canvas(cardDiv, { scale: 2, backgroundColor: '#ffffff', useCORS: true }).then(canvas => {
btns.forEach(b => b.style.display = '');
_showSaveImagePopup(canvas, name);
}).catch(() => { btns.forEach(b => b.style.display = ''); });
}
function saveOilCardImage(name) {
const overlay = document.querySelector('[style*="z-index: 200"], [style*="z-index:200"]');
if (!overlay) { alert('找不到卡片'); return; }
// The card is the white rounded div inside the overlay
const allDivs = overlay.querySelectorAll('div');
let cardDiv = null;
for (const d of allDivs) {
if (d.style.borderRadius && d.style.borderRadius.includes('20px') && d.style.background === 'white') {
cardDiv = d; break;
}
}
// Fallback: find the div with the green gradient header
if (!cardDiv) {
for (const d of allDivs) {
if (d.style.maxWidth && d.style.maxWidth.includes('460px')) {
cardDiv = d; break;
}
}
}
if (!cardDiv) { alert('找不到卡片内容'); return; }
// Hide buttons and close button during capture
const btns = cardDiv.querySelectorAll('button');
btns.forEach(b => b.style.display = 'none');
html2canvas(cardDiv, { scale: 2, backgroundColor: '#ffffff', useCORS: true }).then(canvas => {
btns.forEach(b => b.style.display = '');
_showSaveImagePopup(canvas, name + '_精油知识卡');
}).catch((e) => {
btns.forEach(b => b.style.display = '');
alert('保存失败:' + e.message);
});
}
// ============ AUTH + API ADAPTER LAYER ============
// Token management
let _authToken = '';
let currentUser = { role: 'viewer', username: 'anonymous', display_name: '匿名', id: null };
function _initToken() {
const params = new URLSearchParams(window.location.search);
const t = params.get('token');
if (t) {
localStorage.setItem('oil_auth_token', t);
_authToken = t;
history.replaceState(null, '', window.location.pathname);
} else {
_authToken = localStorage.getItem('oil_auth_token') || '';
}
}
function _apiFetch(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (_authToken) headers['Authorization'] = 'Bearer ' + _authToken;
const method = (opts.method || 'GET').toUpperCase();
const isWrite = method === 'POST' || method === 'PUT' || method === 'DELETE';
if (!isWrite) {
// Read requests — just fetch, no queue
return fetch(path, { ...opts, headers });
}
// Reset queued flag at start of each write call
window._writeQueued = false;
// Write requests — retry on failure, queue if offline
const fullOpts = { ...opts, headers };
if (!_isOnline) {
_pendingWrites.push({ url: path, options: fullOpts, time: Date.now() });
try { localStorage.setItem('oil_pending_writes', JSON.stringify(_pendingWrites)); } catch(x) {}
window._writeQueued = true;
_showToast('⚠️ 离线中,数据已暂存,恢复后自动保存');
_updatePendingBanner();
// Return a fake ok response so callers don't crash
return Promise.resolve(new Response('{"ok":true}', { status: 200, headers: { 'Content-Type': 'application/json' } }));
}
return fetch(path, fullOpts).then(r => {
if (!r.ok && r.status >= 500) throw new Error('Server error');
return r;
}).catch(e => {
// Network or server error — queue for retry
_pendingWrites.push({ url: path, options: fullOpts, time: Date.now() });
try { localStorage.setItem('oil_pending_writes', JSON.stringify(_pendingWrites)); } catch(x) {}
window._writeQueued = true;
_showToast('⚠️ 保存失败,已暂存,将自动重试');
_updatePendingBanner();
return new Response('{"ok":true,"queued":true}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
}
async function _apiLoadMe() {
try {
const res = await _apiFetch('/api/me');
currentUser = await res.json();
} catch(e) { currentUser = { role: 'viewer', username: 'anonymous', display_name: '匿名', id: null }; }
}
function _canEdit(recipe) {
if (currentUser.role === 'admin') return true;
if (currentUser.role === 'senior_editor') return true;
if (currentUser.role === 'editor' && recipe._owner_id === currentUser.id) return true;
return false;
}
function _applyPermissions() {
const role = currentUser.role;
const canEdit = role === 'editor' || role === 'senior_editor' || role === 'admin';
document.querySelectorAll('.requires-editor').forEach(el => {
el.style.display = canEdit ? '' : 'none';
});
document.querySelectorAll('.requires-oil-edit').forEach(el => {
el.style.display = (role === 'admin' || role === 'senior_editor') ? '' : 'none';
});
document.querySelectorAll('.requires-admin').forEach(el => {
el.style.display = role === 'admin' ? '' : 'none';
});
document.querySelectorAll('.requires-notif').forEach(el => {
el.style.display = (role === 'admin' || role === 'senior_editor') ? '' : 'none';
});
// Show user name or login button in header
const nameEl = document.getElementById('headerUserName');
if (nameEl) {
const isImpersonating = !!localStorage.getItem('oil_admin_token_backup');
if (currentUser.username !== 'anonymous') {
nameEl.innerHTML = '👤 ' + (currentUser.display_name || currentUser.username) +
(isImpersonating ? ' <span style="font-size:10px;background:rgba(255,255,255,0.2);padding:1px 6px;border-radius:8px">模拟中 · 点击切回</span>' : ' ▾');
} else {
nameEl.innerHTML = '<span style="background:rgba(255,255,255,0.2);padding:4px 12px;border-radius:12px">登录</span>';
}
}
}
// API data loading
async function _apiLoadOils() {
try {
const res = await _apiFetch('/api/oils');
const data = await res.json();
OILS = {}; OILS_META = {};
for (const o of data) {
OILS[o.name] = o.bottle_price / o.drop_count;
OILS_META[o.name] = { bottlePrice: o.bottle_price, dropCount: o.drop_count, retailPrice: o.retail_price, isActive: o.is_active !== 0 };
}
} catch(e) { console.error('loadOils failed:', e); }
}
async function _apiLoadRecipes() {
try {
const res = await _apiFetch('/api/recipes');
const data = await res.json();
recipes = data.map(r => ({
_id: r.id,
_owner_id: r.owner_id,
_owner_name: r.owner_name,
_version: r.version || 1,
name: r.name,
note: r.note,
tags: r.tags || [],
ingredients: r.ingredients.map(i => ({ oil: i.oil_name, drops: i.drops }))
}));
} catch(e) { console.error('loadRecipes failed:', e); recipes = []; }
}
async function _apiLoadTags() {
try {
const res = await _apiFetch('/api/tags');
allTags = await res.json();
} catch(e) { allTags = []; }
recipes.forEach(r => (r.tags || []).forEach(t => { if (!allTags.includes(t)) allTags.push(t); }));
}
function _apiSaveRecipes() {
for (const r of recipes) {
if (r._dirty || !r._id) {
const body = {
name: r.name, note: r.note || '',
ingredients: r.ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: r.tags || [],
version: r._version || null
};
if (r._id) {
_apiFetch('/api/recipes/' + r._id, { method: 'PUT', body: JSON.stringify(body) })
.then(res => {
if (res.status === 409) { _showToast('⚠️ 此配方已被其他人修改,请刷新'); return; }
if (!res.ok) { _showToast('⚠️ 保存失败,请重试'); return; }
r._dirty = false;
r._version = (r._version || 1) + 1;
}).catch(() => { _showToast('⚠️ 网络错误,保存失败'); });
} else {
_apiFetch('/api/recipes', { method: 'POST', body: JSON.stringify(body) })
.then(res => { if (!res.ok) throw new Error(); return res.json(); })
.then(d => { r._id = d.id; r._owner_id = currentUser.id; r._version = 1; r._dirty = false; })
.catch(() => { _showToast('⚠️ 新增配方失败,请重试'); });
}
}
}
}
// Override original stubs
saveOils = function() {
for (const [name, meta] of Object.entries(OILS_META)) {
_apiFetch('/api/oils', {
method: 'POST',
body: JSON.stringify({ name, bottle_price: meta.bottlePrice, drop_count: meta.dropCount })
}).catch(() => _showToast('精油保存失败'));
}
};
saveRecipes = function() { _apiSaveRecipes(); };
saveTags = function() {
// Tags are saved individually when created, no need to batch
};
// Override deleteOil
deleteOil = async function(name) {
if (!await _confirm(`确认删除「${name}」?\n\n注意:已有配方中如果用到此精油,成本将显示为 ¥0`)) return;
_apiFetch('/api/oils/' + encodeURIComponent(name), { method: 'DELETE' }).catch(() => _showToast('删除失败'));
delete OILS[name]; delete OILS_META[name];
filterOils();
};
// Override deleteGlobalTag
deleteGlobalTag = async function(tag) {
if (!await _confirm(`删除标签「${tag}」?\n(已标记此标签的配方不会被删除,只是移除标签)`)) return;
_apiFetch('/api/tags/' + encodeURIComponent(tag), { method: 'DELETE' }).catch(() => _showToast('删除失败'));
allTags = allTags.filter(t => t !== tag);
recipes.forEach(r => { if (r.tags) r.tags = r.tags.filter(t => t !== tag); });
renderTagBar(); renderManage();
};
// Override deleteRecipe
deleteRecipe = async function(i) {
if (!await _confirm(`确认删除配方「${recipes[i].name}」?`)) return;
if (!_canEdit(recipes[i])) { alert('只能删除自己创建的配方'); return; }
const rid = recipes[i]._id;
if (rid) _apiFetch('/api/recipes/' + rid, { method: 'DELETE' }).catch(() => _showToast('删除失败'));
recipes.splice(i, 1); renderManage();
};
deleteRecipeFromSearch = async function(i) {
if (!await _confirm(`确认删除配方「${recipes[i].name}」?`)) return;
if (!_canEdit(recipes[i])) { alert('只能删除自己创建的配方'); return; }
const rid = recipes[i]._id;
if (rid) _apiFetch('/api/recipes/' + rid, { method: 'DELETE' }).catch(() => _showToast('删除失败'));
recipes.splice(i, 1);
if (currentRecipe === i) { closeDetail(); }
else if (currentRecipe > i) { currentRecipe--; }
filterRecipes();
};
saveCurrentEditing = function() {
if (currentRecipe === null) return;
if (!_canEdit(recipes[currentRecipe])) { alert('没有权限修改此配方'); return; }
recipes[currentRecipe].ingredients = JSON.parse(JSON.stringify(currentEditing));
recipes[currentRecipe]._dirty = true;
_apiSaveRecipes();
alert('✅ 配方已保存!');
};
// saveTagPicker override removed — handled by _commitTagPicker
checkDuplicateAndSave = async function(name, ingredients, note) {
const existing = recipes.filter(r => r.name === name);
if (existing.length > 0) {
const same = existing.some(r => isSameIngredients(r.ingredients, ingredients));
if (same) { alert(`配方「${name}」已存在且内容一致,无需重复保存。`); return false; }
let newName = name, n = 2;
while (recipes.some(r => r.name === newName)) { newName = name + n; n++; }
if (!await _confirm(`已有同名配方「${name}」但内容不一致。\n\n是否保存为「${newName}」?`)) return false;
const nr = { name: newName, note: note || '', tags: [], ingredients: JSON.parse(JSON.stringify(ingredients)) };
recipes.push(nr); _apiSaveRecipes();
alert('✅ 配方「' + newName + '」已保存!'); return true;
}
const nr = { name, note: note || '', tags: [], ingredients: JSON.parse(JSON.stringify(ingredients)) };
recipes.push(nr); _apiSaveRecipes();
alert('✅ 配方「' + name + '」已保存!'); return true;
};
// Override renderGrid to respect permissions
const _origRenderGrid = renderGrid;
renderGrid = function(list) {
const grid = document.getElementById('recipeGrid');
if (!list.length) {
// Try symptom fuzzy match
_showSymptomSearch(grid);
return;
}
const loggedIn = currentUser.id !== null;
grid.innerHTML = list.map((r, idx) => {
const realIdx = recipes.indexOf(r);
const cost = calcCost(r.ingredients);
const oilNames = r.ingredients.map(i => i.oil).join('、');
const tags = (r.tags || []).map(t => '<span class="tag">' + t + '</span>').join(' ');
const canDel = _canEdit(r);
const fav = isFavorite(r);
const favBtn = '<button onclick="event.stopPropagation();toggleFavorite('+r._id+',this)" title="'+(fav?'取消收藏':'收藏')+'" style="background:none;border:none;font-size:20px;cursor:pointer;padding:2px 4px;color:'+(fav?'var(--gold)':'var(--border)')+'">'+(fav?'★':'☆')+'</button>';
return '<div class="recipe-card" onclick="selectRecipe(' + realIdx + ')">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:4px">' +
'<div class="recipe-card-name" style="flex:1;min-width:0;word-break:break-word;margin-bottom:0">' + r.name + '</div>' + favBtn +
'</div>' +
(tags ? '<div style="margin:4px 0">' + tags + '</div>' : '') +
'<div class="recipe-card-oils">' + oilNames + '</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">' +
'<div class="recipe-card-price" style="margin:0">💰 ' + fmtCostWithRetail(r.ingredients) + '</div>' +
'</div></div>';
}).join('');
};
// Override renderManage to split into my recipes + public recipes
const _origRenderManage = renderManage;
function _renderManageItem(r, i) {
const oilNames = r.ingredients.map(ing => ing.oil).join('、');
const tags = (r.tags || []).map(t => '<span class="tag">' + t + '</span>').join(' ');
const checked = selectedRecipes.has(i) ? 'checked' : '';
const canMod = _canEdit(r);
return '<div class="manage-item" style="gap:10px;cursor:pointer" onclick="editFromManage(' + i + ')">' +
'<input type="checkbox" ' + checked + ' onclick="event.stopPropagation()" onchange="toggleSelect(' + i + ', this.checked)" style="width:18px;height:18px;accent-color:var(--sage);cursor:pointer;flex-shrink:0">' +
'<div class="manage-item-left" style="min-width:0">' +
'<div class="manage-item-name" style="word-break:break-word">' + r.name + ' ' + tags + '</div>' +
'<div class="manage-item-oils">' + oilNames + '</div>' +
'<div style="margin-top:4px;font-size:13px;color:var(--sage-dark);font-weight:600">' + fmtCostWithRetail(r.ingredients) + '</div>' +
'</div>' +
(canMod ? '<div style="display:flex;gap:2px;flex-shrink:0;align-items:center">' +
'<button onclick="event.stopPropagation();editTags(' + i + ')" title="标签" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'none\'">🏷</button>' +
'<button onclick="event.stopPropagation();deleteRecipe(' + i + ')" title="删除" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'#fdf0ee\'" onmouseout="this.style.background=\'none\'">🗑</button>' +
'</div>' : '') +
'</div>';
}
let _renderManageLock = false;
let _manageSearchQuery = ''; // Bug #96: manage page search
renderManage = async function() {
if (_renderManageLock) return;
_renderManageLock = true;
try { await _doRenderManage(); } finally { _renderManageLock = false; }
};
async function _doRenderManage() {
if (currentUser.id) await loadDiary();
if (typeof _updateReviewBar === 'function') _updateReviewBar();
const list = document.getElementById('manageList');
if (!list) return;
const label = document.getElementById('manageFilterLabel');
let filtered = recipes.map((r, i) => ({ r, i }));
// Bug #96: apply manage search query
if (_manageSearchQuery) {
filtered = filtered.filter(({ r }) => r.name.toLowerCase().includes(_manageSearchQuery));
}
// Review/mine filters
if (mineFilterActive && currentUser.id) {
filtered = filtered.filter(({ r }) => r._owner_id === currentUser.id);
label.textContent = '— 我的';
} else if (reviewFilterActive && currentUser.role === 'admin') {
filtered = filtered.filter(({ r }) => r._owner_id && r._owner_id !== currentUser.id);
label.textContent = '— 待审核';
} else if (manageFilterTag === '__other__') { filtered = filtered.filter(({ r }) => !r.tags || r.tags.length === 0); label.textContent = '— 其他'; }
else if (manageFilterTag) { filtered = filtered.filter(({ r }) => (r.tags || []).includes(manageFilterTag)); label.textContent = '— ' + manageFilterTag; }
else { label.textContent = ''; }
if (!filtered.length) { list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📋</div><div class="empty-state-text">暂无配方</div></div>'; return; }
const isAdmin = currentUser.role === 'admin' || currentUser.role === 'senior_editor';
if (isAdmin) {
// Admin: show diary recipes as "我的配方" + recipes table as "公共配方库"
let html = '';
// My recipes from diary
if (userDiary.length > 0) {
let diaryFiltered = userDiary;
if (_manageSearchQuery) diaryFiltered = diaryFiltered.filter(d => d.name.toLowerCase().includes(_manageSearchQuery));
if (manageFilterTag === '__other__') diaryFiltered = diaryFiltered.filter(d => !d.tags || d.tags.length === 0);
else if (manageFilterTag) diaryFiltered = diaryFiltered.filter(d => (d.tags || []).includes(manageFilterTag));
if (diaryFiltered.length > 0) {
const mc = window._manageMyCollapsed;
html += '<div style="margin-bottom:12px">' +
'<div onclick="window._manageMyCollapsed=!window._manageMyCollapsed;_saveFoldStates();var b=this.nextElementSibling;b.style.display=window._manageMyCollapsed?\'none\':\'\';this.querySelector(\'span:last-child\').textContent=window._manageMyCollapsed?\'▸\':\'▾\'" style="display:flex;align-items:center;gap:6px;padding:6px 0;cursor:pointer;user-select:none">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-dark)">📔 我的配方 (' + diaryFiltered.length + ')</span>' +
'<span style="font-size:11px;color:var(--text-light)">' + (mc ? '▸' : '▾') + '</span>' +
'</div>' +
'<div' + (mc ? ' style="display:none"' : '') + '>' +
diaryFiltered.map(d => {
const oilNames = (d.ingredients||[]).map(ing => ing.oil).join('、');
const dTags = (d.tags||[]).map(t => '<span class="tag">' + t + '</span>').join(' ');
const dChecked = selectedRecipes.has('d'+d.id) ? 'checked' : '';
const matchedRecipe = recipes.find(r => r.name === d.name);
const isShared = !!matchedRecipe;
let sharedBadge = '';
if (isShared) {
// Admin-owned = fully shared; user-owned = pending review
const isAdopted = matchedRecipe._owner_id === currentUser.id && currentUser.role === 'admin';
const isAdminOwned = matchedRecipe._owner_id !== currentUser.id && currentUser.role === 'admin';
if (isAdopted || isAdminOwned) {
sharedBadge = '<span style="font-size:9px;background:#e8f5e9;color:#2e7d32;padding:1px 5px;border-radius:4px;margin-left:4px">已共享</span>';
} else {
sharedBadge = '<span style="font-size:9px;background:#fff3e0;color:#e65100;padding:1px 5px;border-radius:4px;margin-left:4px">待审核</span>';
}
}
return '<div class="manage-item" style="gap:10px;cursor:pointer" onclick="editDiaryFromManage('+d.id+')">' +
'<input type="checkbox" ' + dChecked + ' onclick="event.stopPropagation()" onchange="toggleSelect(\'d'+d.id+'\', this.checked)" style="width:18px;height:18px;accent-color:var(--sage);cursor:pointer;flex-shrink:0">' +
'<div class="manage-item-left" style="min-width:0">' +
'<div class="manage-item-name" style="word-break:break-word">' + d.name + sharedBadge + ' ' + dTags + '</div>' +
'<div class="manage-item-oils">' + oilNames + '</div>' +
'<div style="margin-top:4px;font-size:13px;color:var(--sage-dark);font-weight:600">' + fmtCostWithRetail(d.ingredients || []) + '</div>' +
'</div>' +
'<div style="display:flex;gap:2px;flex-shrink:0;align-items:center">' +
'<button onclick="event.stopPropagation();editDiaryTags('+d.id+')" title="标签" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'none\'">🏷</button>' +
'<button onclick="event.stopPropagation();shareDiaryToPublic('+d.id+')" title="分享到公共库" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'none\'">📤</button>' +
'<button onclick="event.stopPropagation();deleteDiaryRecipe(' + d.id + ')" title="删除" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'#fdf0ee\'" onmouseout="this.style.background=\'none\'">🗑</button>' +
'</div>' +
'</div>';
}).join('') +
'</div></div>';
}
}
// Public recipes
if (filtered.length > 0) {
const pc = window._managePubCollapsed;
html += '<div style="margin-bottom:12px">' +
'<div onclick="window._managePubCollapsed=!window._managePubCollapsed;_saveFoldStates();var b=this.nextElementSibling;b.style.display=window._managePubCollapsed?\'none\':\'\';this.querySelector(\'span:last-child\').textContent=window._managePubCollapsed?\'▸\':\'▾\'" style="display:flex;align-items:center;gap:6px;padding:6px 0;cursor:pointer;user-select:none">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-dark)">📚 公共配方库 (' + filtered.length + ')</span>' +
'<span style="font-size:11px;color:var(--text-light)">' + (pc ? '▸' : '▾') + '</span>' +
'</div>' +
'<div' + (pc ? ' style="display:none"' : '') + '>' +
filtered.map(({ r, i }) => _renderManageItem(r, i)).join('') +
'</div></div>';
}
list.innerHTML = html || '<div class="empty-state"><div class="empty-state-icon">📋</div><div class="empty-state-text">暂无配方</div></div>';
} else {
// Non-admin: show diary recipes + public recipes they own
await loadDiary();
let html = '';
// Diary recipes
if (userDiary.length > 0) {
let diaryFiltered = userDiary;
if (_manageSearchQuery) diaryFiltered = diaryFiltered.filter(d => d.name.toLowerCase().includes(_manageSearchQuery));
if (manageFilterTag === '__other__') diaryFiltered = diaryFiltered.filter(d => !d.tags || d.tags.length === 0);
else if (manageFilterTag) diaryFiltered = diaryFiltered.filter(d => (d.tags || []).includes(manageFilterTag));
if (diaryFiltered.length > 0) {
const mc = window._manageMyCollapsed;
html += '<div style="margin-bottom:12px">' +
'<div onclick="window._manageMyCollapsed=!window._manageMyCollapsed;_saveFoldStates();var b=this.nextElementSibling;b.style.display=window._manageMyCollapsed?\'none\':\'\';this.querySelector(\'span:last-child\').textContent=window._manageMyCollapsed?\'▸\':\'▾\'" style="display:flex;align-items:center;gap:6px;padding:6px 0;cursor:pointer;user-select:none">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-dark)">📔 我的配方 (' + diaryFiltered.length + ')</span>' +
'<span style="font-size:11px;color:var(--text-light)">' + (mc ? '▸' : '▾') + '</span>' +
'</div>' +
'<div' + (mc ? ' style="display:none"' : '') + '>' +
diaryFiltered.map(d => {
const oilNames = (d.ingredients||[]).map(ing => ing.oil).join('、');
const dTags = (d.tags||[]).map(t => '<span class="tag">' + t + '</span>').join(' ');
const dChecked = selectedRecipes.has('d'+d.id) ? 'checked' : '';
const matchedRecipe = recipes.find(r => r.name === d.name);
const isShared = !!matchedRecipe;
let sharedBadge = '';
if (isShared) {
// For non-admin: recipe owned by themselves = pending; owned by admin = adopted/shared
const isPending = matchedRecipe._owner_id === currentUser.id && currentUser.role !== 'admin';
if (isPending) {
sharedBadge = '<span style="font-size:9px;background:#fff3e0;color:#e65100;padding:1px 5px;border-radius:4px;margin-left:4px">待审核</span>';
} else {
sharedBadge = '<span style="font-size:9px;background:#e8f5e9;color:#2e7d32;padding:1px 5px;border-radius:4px;margin-left:4px">已共享</span>';
}
}
return '<div class="manage-item" style="gap:10px;cursor:pointer" onclick="editDiaryFromManage('+d.id+')">' +
'<input type="checkbox" ' + dChecked + ' onclick="event.stopPropagation()" onchange="toggleSelect(\'d'+d.id+'\', this.checked)" style="width:18px;height:18px;accent-color:var(--sage);cursor:pointer;flex-shrink:0">' +
'<div class="manage-item-left" style="min-width:0">' +
'<div class="manage-item-name" style="word-break:break-word">' + d.name + sharedBadge + ' ' + dTags + '</div>' +
'<div class="manage-item-oils">' + oilNames + '</div>' +
'<div style="margin-top:4px;font-size:13px;color:var(--sage-dark);font-weight:600">' + fmtCostWithRetail(d.ingredients || []) + '</div>' +
'</div>' +
'<div style="display:flex;gap:2px;flex-shrink:0;align-items:center">' +
'<button onclick="event.stopPropagation();editDiaryTags('+d.id+')" title="标签" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'none\'">🏷</button>' +
'<button onclick="event.stopPropagation();shareDiaryToPublic('+d.id+')" title="分享到公共库" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'none\'">📤</button>' +
'<button onclick="event.stopPropagation();deleteDiaryRecipe(' + d.id + ')" title="删除" style="background:none;border:none;cursor:pointer;font-size:16px;padding:6px;border-radius:6px;color:var(--text-mid)" onmouseover="this.style.background=\'#fdf0ee\'" onmouseout="this.style.background=\'none\'">🗑</button>' +
'</div></div>';
}).join('') +
'</div></div>';
}
}
// Public recipes they own (editors only)
const mine = filtered.filter(({ r }) => r._owner_id === currentUser.id);
if (mine.length > 0) {
const pc = window._managePubCollapsed;
html += '<div style="margin-bottom:12px">' +
'<div onclick="window._managePubCollapsed=!window._managePubCollapsed;_saveFoldStates();var b=this.nextElementSibling;b.style.display=window._managePubCollapsed?\'none\':\'\';this.querySelector(\'span:last-child\').textContent=window._managePubCollapsed?\'▸\':\'▾\'" style="display:flex;align-items:center;gap:6px;padding:6px 0;cursor:pointer;user-select:none">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-dark)">📚 我创建的公共配方 (' + mine.length + ')</span>' +
'<span style="font-size:11px;color:var(--text-light)">' + (pc ? '▸' : '▾') + '</span>' +
'</div>' +
'<div' + (pc ? ' style="display:none"' : '') + '>' +
mine.map(({ r, i }) => _renderManageItem(r, i)).join('') +
'</div></div>';
}
list.innerHTML = html || '<div class="empty-state"><div class="empty-state-icon">📔</div><div class="empty-state-text">还没有个人配方</div></div>';
}
}
// Update showSection to handle new sections
const _origShowSection = showSection;
showSection = function(name) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
const sec = document.getElementById('section-' + name);
if (sec) sec.classList.add('active');
const tabs = document.querySelectorAll('.nav-tab');
const tabTexts = Array.from(tabs).map(t => t.textContent);
// Bug #94: search tab onclick is _resetSearchHome(), not showSection('search'),
// so we need special handling. Also match tabs whose onclick contains the section name.
let clickedTab;
if (name === 'search') {
clickedTab = tabs[0]; // first tab is always search
} else {
clickedTab = Array.from(tabs).find(t => t.getAttribute('onclick') && t.getAttribute('onclick').includes("'" + name + "'"));
}
if (clickedTab) clickedTab.classList.add('active');
if (name === 'manage') { loadDiary().then(() => { renderTagBar(); renderManage(); }); }
if (name === 'oils') renderOils();
if (name === 'add') renderNewIngList();
if (name === 'audit') renderAuditLog();
if (name === 'users') renderUsers();
};
// ── AUDIT LOG ──────────────────────────────────────────
let auditOffset = 0;
async function renderAuditLog() {
auditOffset = 0;
_auditFilter = '';
document.querySelectorAll('.audit-filter-btn').forEach(b => {
b.classList.toggle('btn-primary', b.dataset.filter === '');
b.classList.toggle('btn-outline', b.dataset.filter !== '');
});
const list = document.getElementById('auditList');
list.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-light)">加载中...</div>';
try {
const res = await _apiFetch('/api/audit-log?limit=500&offset=0');
_auditData = await res.json();
auditOffset = _auditData.length;
_buildAuditUserButtons();
list.innerHTML = _renderAuditRows(_auditData);
} catch(e) { list.innerHTML = '<div style="color:red;padding:20px">加载失败</div>'; }
}
async function loadMoreAudit() {
try {
const res = await _apiFetch('/api/audit-log?limit=500&offset=' + auditOffset);
const data = await res.json();
_auditData = _auditData.concat(data);
auditOffset += data.length;
filterAuditLog(_auditFilter);
} catch(e) {}
}
let _auditData = [];
let _auditFilter = '';
let _auditUserFilter = '';
function filterAuditLog(filter) {
_auditFilter = filter;
document.querySelectorAll('.audit-filter-btn').forEach(b => {
const match = b.dataset.filter === filter;
b.classList.toggle('btn-primary', match);
b.classList.toggle('btn-outline', !match);
});
_applyAuditFilters();
}
function filterAuditByUser(username) {
_auditUserFilter = _auditUserFilter === username ? '' : username;
document.querySelectorAll('.audit-user-btn').forEach(b => {
const match = b.dataset.user === _auditUserFilter;
b.classList.toggle('btn-primary', match);
b.classList.toggle('btn-outline', !match);
});
_applyAuditFilters();
}
function _applyAuditFilters() {
let filtered = _auditData;
if (_auditFilter) {
filtered = filtered.filter(r => {
if (_auditFilter === 'user') return r.action.includes('user');
return r.action === _auditFilter || r.action === 'undo_' + _auditFilter;
});
}
if (_auditUserFilter) {
filtered = filtered.filter(r => (r.user_name || r.username) === _auditUserFilter);
}
document.getElementById('auditList').innerHTML = _renderAuditRows(filtered);
}
function viewAuditRecipe(recipeId) {
const idx = recipes.findIndex(r => r._id === recipeId);
if (idx < 0) { alert('该配方可能已被删除'); return; }
// Show card overlay without leaving audit page
currentRecipe = idx;
currentEditing = JSON.parse(JSON.stringify(recipes[idx].ingredients));
document.getElementById('detailOverlay').style.display = 'block';
document.body.style.overflow = 'hidden';
document.getElementById('cardViewMode').style.display = '';
document.getElementById('editorViewMode').style.display = 'none';
renderCardView();
document.getElementById('detailPanel').scrollTop = 0;
}
function _buildAuditUserButtons() {
const users = {};
_auditData.forEach(r => {
const name = r.user_name || r.username || '系统';
users[name] = (users[name] || 0) + 1;
});
const container = document.getElementById('auditUserFilters');
if (!container) return;
container.innerHTML = Object.entries(users).sort((a,b) => b[1] - a[1]).map(([name, count]) =>
'<button class="btn btn-outline btn-sm audit-user-btn" data-user="' + name + '" onclick="filterAuditByUser(\'' + name.replace(/'/g,"\\'") + '\')" style="font-size:11px;padding:3px 8px">' + name + ' (' + count + ')</button>'
).join('');
}
function _renderAuditRows(data) {
const actionNames = {
create_recipe: '📝 新增配方', update_recipe: '✏️ 修改配方', delete_recipe: '🗑 删除配方',
upsert_oil: '💧 更新精油', delete_oil: '🗑 删除精油',
create_tag: '🏷 新增标签', delete_tag: '🏷 删除标签',
create_user: '👤 创建用户', delete_user: '🗑 删除用户', update_user: '👤 修改用户',
adopt_recipe: '✅ 采纳配方',
undo_delete_recipe: '↩ 恢复配方', undo_delete_oil: '↩ 恢复精油', undo_delete_user: '↩ 恢复用户'
};
const undoable = ['delete_recipe', 'delete_oil', 'delete_user'];
if (!data.length) return '<div class="empty-state"><div class="empty-state-text">暂无记录</div></div>';
return data.map(r => {
const who = r.user_name || r.username || '系统';
const what = actionNames[r.action] || r.action;
const target = r.target_name || r.target_id || '';
const time = r.created_at || '';
const canUndo = undoable.includes(r.action) && r.detail;
const undoBtn = canUndo ? '<button class="btn btn-outline btn-sm" style="margin-left:6px;font-size:11px" onclick="undoAudit(' + r.id + ', this)">↩ 撤销</button>' : '';
const isDelete = r.action.startsWith('delete_');
const isCreate = r.action === 'create_recipe';
const borderColor = isDelete ? '#e8b4b0' : isCreate ? 'var(--sage)' : 'transparent';
// Detail preview
let detailHtml = '';
if (r.detail) {
try {
const d = JSON.parse(r.detail);
if (d.ingredients) {
const ings = d.ingredients.map(i => (i.oil_name || i.oil) + ' ' + i.drops + '滴').join('');
detailHtml = '<div style="font-size:11px;color:var(--text-light);margin-top:4px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + ings + '</div>';
}
if (d.from_user) detailHtml = '<div style="font-size:11px;color:var(--text-light);margin-top:4px">来自:' + d.from_user + '</div>';
if (d.role) detailHtml = '<div style="font-size:11px;color:var(--text-light);margin-top:4px">权限:' + d.role + '</div>';
} catch(e) {}
}
const isRecipeAction = r.action.includes('recipe') && r.target_id && !isDelete;
const clickAttr = isRecipeAction ? 'style="padding:10px 14px;border-left:3px solid ' + borderColor + ';flex-wrap:wrap;cursor:pointer" onclick="viewAuditRecipe(' + r.target_id + ')"' : 'style="padding:10px 14px;border-left:3px solid ' + borderColor + ';flex-wrap:wrap"';
return '<div class="manage-item" ' + clickAttr + '>' +
'<div style="flex:1;min-width:0">' +
'<div><span style="font-weight:600;color:var(--sage-dark);font-size:13px">' + who + '</span> ' +
'<span style="color:' + (isDelete ? '#c0392b' : 'var(--text-mid)') + ';font-size:13px">' + what + '</span> ' +
'<span style="font-weight:500;font-size:13px">' + target + '</span></div>' +
detailHtml +
'</div>' +
'<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">' +
'<span style="font-size:11px;color:var(--text-light);white-space:nowrap">' + time + '</span>' +
undoBtn +
'</div>' +
'</div>';
}).join('');
}
async function undoAudit(logId, btn) {
if (!await _confirm('确认撤销此操作?')) return;
btn.disabled = true;
btn.textContent = '撤销中...';
try {
const res = await _apiFetch('/api/audit-log/' + logId + '/undo', { method: 'POST', body: '{}' });
const text = await res.text();
if (!res.ok) {
let msg = text;
try { msg = JSON.parse(text).detail || text; } catch(e) {}
alert('撤销失败:' + msg);
btn.disabled = false; btn.textContent = '↩ 撤销';
return;
}
btn.textContent = '✅ 已撤销';
btn.classList.remove('btn-outline');
btn.style.color = 'var(--sage-dark)';
await _apiLoadRecipes();
await _apiLoadOils();
await _apiLoadTags();
alert('✅ 撤销成功,数据已恢复');
} catch(e) {
alert('撤销失败:' + e.message);
btn.disabled = false; btn.textContent = '↩ 撤销';
}
}
// ── USER MANAGEMENT ────────────────────────────────────
let _usersCache = null;
async function renderUsers() {
const list = document.getElementById('usersList');
try {
if (!_usersCache) {
const res = await _apiFetch('/api/users');
_usersCache = await res.json();
}
let data = _usersCache;
// Apply search filter
const search = (document.getElementById('userSearchInput')?.value || '').trim().toLowerCase();
if (search) data = data.filter(u => (u.display_name||'').toLowerCase().includes(search) || u.username.toLowerCase().includes(search));
// Apply role filter
const roleFilter = document.getElementById('userRoleFilter')?.value || '';
if (roleFilter === 'business') data = data.filter(u => u.business_verified);
else if (roleFilter) data = data.filter(u => u.role === roleFilter);
const roleNames = {admin:'管理员',editor:'编辑者',senior_editor:'高级编辑者',viewer:'查看者'};
list.innerHTML = data.map(u => {
const url = window.location.origin + '/?token=' + u.token;
const isSelf = u.id === currentUser.id;
const isAdmin = u.role === 'admin';
const bizBadge = u.business_verified ? '<span style="font-size:9px;background:#e3f2fd;color:#1565c0;padding:1px 5px;border-radius:4px;margin-left:4px">🏢 企业</span>' : '';
const bizBtn = (!isAdmin && u.business_verified) ? '<button onclick="revokeBiz(' + u.id + ')" title="取消企业资格" style="background:none;border:none;cursor:pointer;font-size:11px;padding:2px 4px;color:var(--text-light)">🏢✕</button>' : '';
return '<div class="manage-item" style="padding:10px 14px;gap:8px">' +
'<div style="flex:1;min-width:0;cursor:pointer" onclick="editUserName(' + u.id + ',\'' + (u.display_name||u.username).replace(/'/g,"\\'") + '\')" title="点击修改昵称">' +
'<span style="font-weight:600;font-size:14px">' + (u.display_name || u.username) + '</span>' + bizBadge +
'<div style="font-size:11px;color:var(--text-light)">@' + u.username + '</div>' +
'</div>' +
'<select id="role-select-' + u.id + '" class="form-control" style="width:90px;padding:4px 6px;font-size:11px" ' + (isSelf ? 'disabled' : '') + ' onchange="saveUserRole(' + u.id + ')">' +
'<option value="admin"' + (u.role === 'admin' ? ' selected' : '') + '>管理员</option>' +
'<option value="senior_editor"' + (u.role === 'senior_editor' ? ' selected' : '') + '>高级编辑者</option>' +
'<option value="editor"' + (u.role === 'editor' ? ' selected' : '') + '>编辑者</option>' +
'<option value="viewer"' + (u.role === 'viewer' ? ' selected' : '') + '>查看者</option>' +
'</select>' +
'<input type="hidden" id="url-' + u.id + '" value="' + url + '">' +
bizBtn +
(isAdmin ? '' : '<button onclick="copyUserUrl(' + u.id + ')" title="复制链接" style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;color:var(--text-light)">📋</button>') +
(isSelf ? '' : '<button onclick="deleteUser(' + u.id + ',\'' + u.username.replace(/'/g, "\\'") + '\')" title="删除" style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;color:var(--text-light)">🗑</button>') +
'</div>';
}).join('');
} catch(e) { list.innerHTML = '<div style="color:red;padding:20px">加载失败</div>'; }
// Load translation suggestions
try {
const tRes = await _apiFetch('/api/translation-suggestions');
const suggestions = await tRes.json();
const tSection = document.getElementById('translationSection');
const tList = document.getElementById('translationList');
if (suggestions.length > 0) {
tSection.style.display = '';
tList.innerHTML = suggestions.map(s =>
'<div class="manage-item" style="padding:10px;gap:8px">' +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:13px"><span style="font-weight:600">' + s.recipe_name + '</span> → <span style="color:var(--sage-dark)">' + s.suggested_en + '</span></div>' +
'<div style="font-size:11px;color:var(--text-light)">' + (s.display_name || s.username || '') + ' · ' + (s.created_at || '') + '</div>' +
'</div>' +
'<div style="display:flex;gap:4px;flex-shrink:0">' +
'<button class="btn btn-primary btn-sm" onclick="approveTranslation(' + s.id + ',\'' + s.recipe_name.replace(/'/g,"\\'") + '\',\'' + s.suggested_en.replace(/'/g,"\\'") + '\')">✅ 采纳</button>' +
'<button class="btn btn-outline btn-sm" onclick="rejectTranslation(' + s.id + ')" style="color:#c0392b">拒绝</button>' +
'</div></div>'
).join('');
} else {
tSection.style.display = 'none';
}
} catch(e) {}
// Load business applications
try {
const bRes = await _apiFetch('/api/business-applications');
const apps = await bRes.json();
const pending = apps.filter(a => a.status === 'pending');
const section = document.getElementById('bizApprovalSection');
const bList = document.getElementById('bizApprovalList');
if (pending.length > 0) {
section.style.display = '';
bList.innerHTML = pending.map(a =>
'<div class="manage-item" style="padding:12px;gap:10px">' +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:14px;font-weight:600;color:var(--text-dark)">' + (a.display_name || a.username) + '</div>' +
'<div style="font-size:13px;color:var(--text-mid)">商户名:' + a.business_name + '</div>' +
(a.document ? '<img src="' + a.document + '" style="max-width:200px;max-height:150px;border-radius:8px;margin-top:6px;border:1px solid var(--border)">' : '<div style="font-size:12px;color:var(--text-light);margin-top:4px">未上传证明文件</div>') +
'<div style="font-size:11px;color:var(--text-light);margin-top:4px">' + (a.created_at || '') + '</div>' +
'</div>' +
'<div style="display:flex;gap:6px;flex-shrink:0">' +
'<button class="btn btn-primary btn-sm" onclick="approveBiz(' + a.id + ')">✅ 通过</button>' +
'<button class="btn btn-outline btn-sm" onclick="rejectBiz(' + a.id + ')" style="color:#c0392b">拒绝</button>' +
'</div>' +
'</div>'
).join('');
} else {
section.style.display = 'none';
}
} catch(e) {}
}
async function approveBiz(appId) {
try {
await _apiFetch('/api/business-applications/' + appId + '/approve', { method: 'POST', body: '{}' });
// Mark related notifications as read
await _apiFetch('/api/notifications/read-all', { method: 'POST', body: '{}' }).catch(() => {});
loadNotifications();
_showToast('✅ 已通过');
renderUsers();
} catch(e) { _showToast('操作失败,请重试'); }
}
async function rejectBiz(appId) {
const reason = await _prompt('拒绝理由(对方会看到):');
if (reason === null) return;
try {
await _apiFetch('/api/business-applications/' + appId + '/reject', { method: 'POST', body: JSON.stringify({ reason: reason || '' }) });
_showToast('已拒绝');
renderUsers();
} catch(e) { _showToast('操作失败,请重试'); }
}
async function approveTranslation(sid, recipeName, suggestedEn) {
try {
await _apiFetch('/api/translation-suggestions/' + sid + '/approve', { method: 'POST', body: '{}' });
// Apply the translation locally
window._customTranslations = window._customTranslations || {};
window._customTranslations[recipeName] = suggestedEn;
const origRNE = recipeNameEn;
recipeNameEn = function(name) {
if (window._customTranslations && window._customTranslations[name]) return window._customTranslations[name];
return origRNE(name);
};
_showToast('✅ 已采纳翻译:' + recipeName + ' → ' + suggestedEn);
renderUsers();
} catch(e) { _showToast('操作失败,请重试'); }
}
async function rejectTranslation(sid) {
try {
await _apiFetch('/api/translation-suggestions/' + sid + '/reject', { method: 'POST', body: '{}' });
_showToast('已拒绝');
renderUsers();
} catch(e) { _showToast('操作失败,请重试'); }
}
async function revokeBiz(userId) {
const reason = await _prompt('取消原因(对方会看到):');
if (reason === null) return;
try {
await _apiFetch('/api/business-revoke/' + userId, { method: 'POST', body: JSON.stringify({ reason: reason || '' }) });
_usersCache = null;
_showToast('已取消企业资格');
renderUsers();
} catch(e) { _showToast('操作失败,请重试'); }
}
async function editUserName(id, current) {
const name = await _prompt('修改昵称:', current);
if (!name || name === current) return;
try {
const res = await _apiFetch('/api/users/' + id, { method: 'PUT', body: JSON.stringify({ display_name: name }) });
if (res.ok) { _showToast('✅ 已修改'); renderUsers(); }
else _showToast('修改失败');
} catch(e) { _showToast('网络错误'); }
}
async function saveUserRole(id) {
const sel = document.getElementById('role-select-' + id);
if (!sel) return;
try {
const res = await _apiFetch('/api/users/' + id, { method: 'PUT', body: JSON.stringify({ role: sel.value }) });
if (!res.ok) { alert('保存失败:' + await res.text()); return; }
alert('✅ 权限已更新');
} catch(e) { alert('保存失败'); }
}
function copyUserUrl(id) {
const input = document.getElementById('url-' + id);
if (!input) return;
navigator.clipboard.writeText(input.value).then(() => {
alert('✅ 链接已复制');
}).catch(async () => { await _prompt('复制链接:', input.value); });
}
async function createUser() {
const username = document.getElementById('newUserName').value.trim();
const displayName = document.getElementById('newUserDisplayName').value.trim();
const role = document.getElementById('newUserRole').value;
if (!username) { alert('请输入用户名'); return; }
try {
const res = await _apiFetch('/api/users', {
method: 'POST', body: JSON.stringify({ username, role, display_name: displayName })
});
if (!res.ok) { alert('创建失败:' + await res.text()); return; }
const data = await res.json();
const url = window.location.origin + '/?token=' + data.token;
document.getElementById('newUserResult').innerHTML =
'<div style="background:var(--sage-mist);padding:12px;border-radius:8px;font-size:13px">' +
'✅ 用户 <b>' + username + '</b> 已创建<br>' +
'分享链接:<input type="text" value="' + url + '" style="width:100%;margin-top:6px;padding:6px;border:1px solid var(--border);border-radius:6px;font-size:12px" onclick="this.select()" readonly>' +
'</div>';
document.getElementById('newUserName').value = '';
document.getElementById('newUserDisplayName').value = '';
renderUsers();
} catch(e) { alert('创建失败'); }
}
async function deleteUser(id, username) {
if (!await _confirm('确认删除用户「' + username + '」?')) return;
try {
await _apiFetch('/api/users/' + id, { method: 'DELETE' });
renderUsers();
} catch(e) { alert('删除失败'); }
}
// ── RECIPE REVIEW & ADOPT (admin) ──────────────────────
let reviewFilterActive = false;
function _getPendingRecipes() {
if (currentUser.role !== 'admin') return [];
return recipes.filter(r => r._owner_id && r._owner_id !== currentUser.id);
}
function _updateReviewBar() {
const bar = document.getElementById('reviewBar');
if (!bar || currentUser.role !== 'admin') return;
const pending = _getPendingRecipes();
document.getElementById('reviewCount').textContent = pending.length > 0 ? pending.length + ' 条待审核' : '无待审核配方';
}
let mineFilterActive = false;
function filterByMine() {
mineFilterActive = !mineFilterActive;
reviewFilterActive = false;
manageFilterTag = null;
const btn = document.getElementById('filterMineBtn');
if (btn) { btn.classList.toggle('btn-primary', mineFilterActive); btn.classList.toggle('btn-outline', !mineFilterActive); }
renderTagBar();
renderManage();
}
function filterByReview() {
reviewFilterActive = !reviewFilterActive;
mineFilterActive = false;
const btn = document.getElementById('filterMineBtn');
if (btn) { btn.classList.remove('btn-primary'); btn.classList.add('btn-outline'); }
manageFilterTag = null;
renderTagBar();
renderManage();
}
async function adoptRecipe(i) {
const r = recipes[i];
if (!r._id) return;
try {
await _apiFetch('/api/recipes/' + r._id + '/adopt', { method: 'POST' });
r._owner_id = currentUser.id;
r._owner_name = currentUser.display_name || currentUser.username;
// If no more pending, exit review filter
if (!_getPendingRecipes().length) reviewFilterActive = false;
_updateReviewBar();
renderManage();
} catch(e) { alert('采纳失败'); }
}
async function adoptSelected() {
const ids = [...selectedRecipes].map(i => recipes[i]._id).filter(Boolean);
const pending = ids.filter(id => {
const r = recipes.find(r => r._id === id);
return r && r._owner_id !== currentUser.id;
});
if (!pending.length) { alert('没有选中待审核的配方'); return; }
if (!await _confirm('确认采纳选中的 ' + pending.length + ' 条配方?')) return;
try {
await _apiFetch('/api/recipes/adopt-batch', { method: 'POST', body: JSON.stringify({ ids: pending }) });
pending.forEach(id => {
const r = recipes.find(r => r._id === id);
if (r) { r._owner_id = currentUser.id; r._owner_name = currentUser.display_name; }
});
selectedRecipes.clear();
reviewFilterActive = false;
_updateReviewBar();
renderManage();
alert('✅ 已采纳 ' + pending.length + ' 条配方');
} catch(e) { alert('采纳失败'); }
}
async function adoptAll() {
const pending = _getPendingRecipes();
if (!pending.length) { alert('没有待审核的配方'); return; }
if (!await _confirm('确认采纳全部 ' + pending.length + ' 条待审核配方?')) return;
const ids = pending.map(r => r._id).filter(Boolean);
try {
await _apiFetch('/api/recipes/adopt-batch', { method: 'POST', body: JSON.stringify({ ids }) });
pending.forEach(r => { r._owner_id = currentUser.id; r._owner_name = currentUser.display_name; });
reviewFilterActive = false;
_updateReviewBar();
renderManage();
alert('✅ 已采纳 ' + pending.length + ' 条配方');
} catch(e) { alert('采纳失败'); }
}
// Patch: _updateReviewBar is called inside _doRenderManage now
// (old third override removed — merged into _doRenderManage)
// ── FAVORITES ──────────────────────────────────────────
let userFavorites = []; // array of recipe _id
async function loadFavorites() {
if (!currentUser.id) return;
try {
const res = await _apiFetch('/api/favorites');
userFavorites = await res.json();
} catch(e) { userFavorites = []; }
}
function isFavorite(recipe) {
return recipe._id && userFavorites.includes(recipe._id);
}
async function toggleFavorite(recipeId, btn) {
if (!currentUser.id) { showLoginModal(); return; }
const isFav = userFavorites.includes(recipeId);
if (isFav) {
await _apiFetch('/api/favorites/' + recipeId, {method:'DELETE'}).catch(()=>{});
userFavorites = userFavorites.filter(id => id !== recipeId);
} else {
await _apiFetch('/api/favorites/' + recipeId, {method:'POST', body:'{}'}).catch(()=>{});
userFavorites.push(recipeId);
}
if (btn) {
btn.textContent = isFav ? '☆' : '★';
btn.title = isFav ? '收藏' : '取消收藏';
btn.style.color = isFav ? 'var(--text-light)' : 'var(--gold)';
}
// Refresh grid if we're in favorites filter
if (_favFilterActive) filterRecipes();
}
let _favFilterActive = false;
function toggleFavFilter() {
_favFilterActive = !_favFilterActive;
const btn = document.getElementById('favFilterBtn');
if (btn) {
btn.classList.toggle('btn-primary', _favFilterActive);
btn.classList.toggle('btn-outline', !_favFilterActive);
btn.style.color = _favFilterActive ? 'white' : '';
}
filterRecipes();
}
// ── DIARY (personal recipes + journal) ─────────────────
let userDiary = [];
let currentDiaryId = null;
async function loadDiary() {
if (!currentUser.id) return;
try {
const res = await _apiFetch('/api/diary');
userDiary = await res.json();
} catch(e) { userDiary = []; }
}
async function saveAsMine() {
if (!currentUser.id) { showLoginModal(); return; }
if (currentRecipe === null) return;
const r = recipes[currentRecipe];
const name = await _prompt('保存为我的配方,名称:', r.name);
if (!name) return;
try {
const res = await _apiFetch('/api/diary', { method: 'POST', body: JSON.stringify({
name,
source_recipe_id: r._id || null,
ingredients: r.ingredients.map(i => ({ oil: i.oil, drops: i.drops })),
note: r.note || ''
})});
if (!res.ok) { alert('保存失败'); return; }
await loadDiary();
alert('✅ 已保存到「我的配方日记」');
} catch(e) { alert('保存失败'); }
}
async function diarySmartPaste() {
if (!currentUser.id) { showLoginModal(); return; }
const raw = document.getElementById('diarySmartPasteInput')?.value.trim();
if (!raw) { _showToast('请输入配方内容'); return; }
const blocks = _splitRawIntoBlocks(raw);
const parsed = blocks.map(b => _parseSingleBlock(b)).filter(Boolean);
if (parsed.length === 0) { _showToast('没有识别到精油配方'); return; }
// Store queue and render wizard in diary area
window._diarySmartQueue = parsed;
window._diarySmartIdx = 0;
window._diarySmartSaved = 0;
_renderDiarySmartWizard();
}
function _renderDiarySmartWizard() {
const queue = window._diarySmartQueue;
const idx = window._diarySmartIdx;
const resultDiv = document.getElementById('diarySmartPasteResult');
if (!queue || idx >= queue.length) {
const saved = window._diarySmartSaved || 0;
resultDiv.innerHTML = '<div style="background:var(--sage-mist);border-radius:12px;padding:16px;text-align:center">' +
'<div style="font-size:20px;margin-bottom:6px">✅</div>' +
'<div style="font-size:14px;font-weight:600;color:var(--sage-dark)">完成!共保存 ' + saved + ' 个配方</div></div>';
document.getElementById('diarySmartPasteInput').value = '';
window._diarySmartQueue = null;
loadDiary().then(() => { renderDiaryList(); renderPersonalSection(); });
return;
}
const recipe = queue[idx];
const total = queue.length;
let html = '<div style="background:var(--sage-mist);border-radius:12px;padding:16px;margin-top:6px">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">' +
'<div style="font-size:14px;font-weight:600;color:var(--sage-dark)">校准配方 (' + (idx+1) + '/' + total + ')</div>' +
'<div style="font-size:11px;color:var(--text-light)">已保存 ' + (window._diarySmartSaved||0) + '</div></div>';
html += '<div style="height:3px;background:var(--border);border-radius:2px;margin-bottom:12px;overflow:hidden">' +
'<div style="height:100%;background:var(--sage);width:' + ((idx/total)*100) + '%"></div></div>';
html += '<div style="margin-bottom:10px">' +
'<label style="font-size:12px;color:var(--text-mid)">配方名称</label>' +
'<input type="text" class="form-control" id="diaryWizName" value="' + (recipe.name||'').replace(/"/g,'&quot;') + '" style="font-size:13px;font-weight:600;margin-top:4px">' +
'</div>';
html += '<div style="margin-bottom:10px"><label style="font-size:12px;color:var(--text-mid)">精油成分</label><div id="diaryWizIngList" style="margin-top:4px">';
recipe.ingredients.forEach(ing => {
const cost = (OILS[ing.oil] || 0) * ing.drops;
html += '<div style="display:flex;gap:5px;align-items:center;margin-bottom:5px">' +
'<select class="form-control dwiz-oil" style="flex:2;font-size:12px;padding:5px 6px" onchange="_updateDiaryWizCost()">' +
'<option value="">选择</option>' +
Object.keys(OILS).sort().map(n => '<option value="' + n + '"' + (n===ing.oil?' selected':'') + '>' + n + '</option>').join('') +
'</select>' +
'<input type="number" class="form-control dwiz-drops" value="' + ing.drops + '" min="0.5" step="0.5" style="width:55px;font-size:12px;padding:5px;text-align:center" oninput="_updateDiaryWizCost()">' +
'<span style="font-size:10px;color:var(--text-light);width:44px;text-align:right">¥' + cost.toFixed(2) + '</span>' +
'<button onclick="this.parentElement.remove();_updateDiaryWizCost()" style="background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-light);padding:1px">✕</button>' +
'</div>';
});
html += '</div><button class="btn btn-outline btn-sm" onclick="_addDiaryWizIngRow()" style="font-size:11px"> 添加</button></div>';
if (recipe.notFound && recipe.notFound.length) {
html += '<div style="font-size:11px;color:#c0392b;margin-bottom:8px">⚠️ 未识别:' + recipe.notFound.join('、') + '</div>';
}
const totalCost = recipe.ingredients.reduce((s, ing) => s + (OILS[ing.oil]||0) * ing.drops, 0);
html += '<div id="diaryWizCostLine" style="font-size:13px;font-weight:600;color:var(--sage-dark);margin-bottom:10px">总成本:¥' + totalCost.toFixed(2) + '</div>';
html += '<div style="display:flex;gap:8px">' +
'<button class="btn btn-primary btn-sm" onclick="_diaryWizSave()">✅ 保存' + (idx < total-1 ? '并下一个' : '') + '</button>' +
(idx < total-1 ? '<button class="btn btn-outline btn-sm" onclick="_diaryWizSkip()">放弃</button>' : '') +
'<button class="btn btn-outline btn-sm" onclick="document.getElementById(\'diarySmartPasteResult\').innerHTML=\'\';window._diarySmartQueue=null" style="color:var(--text-light)">取消</button>' +
'</div></div>';
resultDiv.innerHTML = html;
}
function _addDiaryWizIngRow() {
const list = document.getElementById('diaryWizIngList');
if (!list) return;
const div = document.createElement('div');
div.style.cssText = 'display:flex;gap:5px;align-items:center;margin-bottom:5px';
div.innerHTML =
'<select class="form-control dwiz-oil" style="flex:2;font-size:12px;padding:5px 6px" onchange="_updateDiaryWizCost()"><option value="">选择</option>' +
Object.keys(OILS).sort().map(n => '<option value="' + n + '">' + n + '</option>').join('') +
'</select>' +
'<input type="number" class="form-control dwiz-drops" value="1" min="0.5" step="0.5" style="width:55px;font-size:12px;padding:5px;text-align:center" oninput="_updateDiaryWizCost()">' +
'<span style="font-size:10px;color:var(--text-light);width:44px;text-align:right">¥0.00</span>' +
'<button onclick="this.parentElement.remove();_updateDiaryWizCost()" style="background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-light);padding:1px">✕</button>';
list.appendChild(div);
}
function _updateDiaryWizCost() {
const list = document.getElementById('diaryWizIngList');
if (!list) return;
let total = 0;
list.querySelectorAll('[style*="display:flex"]').forEach(row => {
const oil = row.querySelector('.dwiz-oil')?.value;
const drops = parseFloat(row.querySelector('.dwiz-drops')?.value) || 0;
const cost = (OILS[oil]||0) * drops;
total += cost;
const span = row.querySelector('span');
if (span) span.textContent = '¥' + cost.toFixed(2);
});
const cl = document.getElementById('diaryWizCostLine');
if (cl) cl.textContent = '总成本:¥' + total.toFixed(2);
}
async function _diaryWizSave() {
const name = document.getElementById('diaryWizName')?.value.trim();
if (!name) { _showToast('请输入配方名称'); return; }
const list = document.getElementById('diaryWizIngList');
const ings = [];
list?.querySelectorAll('[style*="display:flex"]').forEach(row => {
const oil = row.querySelector('.dwiz-oil')?.value;
const drops = parseFloat(row.querySelector('.dwiz-drops')?.value) || 0;
if (oil && drops > 0) ings.push({ oil, drops });
});
if (!ings.length) { _showToast('请至少添加一种精油'); return; }
try {
const res = await _apiFetch('/api/diary', { method: 'POST', body: JSON.stringify({ name, ingredients: ings, note: '' }) });
if (!res.ok) { _showToast('保存失败'); return; }
window._diarySmartSaved = (window._diarySmartSaved||0) + 1;
_showToast('✅ 已保存「' + name + '」');
} catch(e) { _showToast('保存失败'); return; }
window._diarySmartIdx++;
_renderDiarySmartWizard();
}
function _diaryWizSkip() {
window._diarySmartIdx++;
_renderDiarySmartWizard();
}
async function createNewDiary() {
if (!currentUser.id) { showLoginModal(); return; }
try {
const res = await _apiFetch('/api/diary', { method: 'POST', body: JSON.stringify({
name: '未命名配方', ingredients: [], note: ''
})});
if (!res.ok) { alert('创建失败'); return; }
const data = await res.json();
await loadDiary();
openDiaryDetail(data.id);
} catch(e) { alert('创建失败'); }
}
function showDiaryTab(tab) {
// Ensure title and tabs are always visible
const title = document.querySelector('#section-mydiary > .section-title');
const tabsRow = document.querySelector('#section-mydiary > div:nth-child(2)');
if (title) title.style.display = '';
if (tabsRow) tabsRow.style.display = '';
['Recipes','Favs','Brand','Account'].forEach(t => {
const btn = document.getElementById('diaryTab' + t);
const panel = document.getElementById('diary' + t + 'Panel');
if (btn) { btn.classList.toggle('btn-primary', tab === t.toLowerCase()); btn.classList.toggle('btn-outline', tab !== t.toLowerCase()); }
if (panel) panel.style.display = tab === t.toLowerCase() ? '' : 'none';
});
document.getElementById('diaryDetailPanel').style.display = 'none';
if (tab === 'recipes') renderDiaryList();
if (tab === 'favs') renderDiaryFavs();
if (tab === 'brand') renderBrandUI();
if (tab === 'account') renderAccountUI();
}
function renderDiaryList() {
const list = document.getElementById('diaryList');
if (!userDiary.length) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📔</div><div class="empty-state-text">还没有个人配方<br><span style="font-size:13px;color:var(--text-light)">在配方详情点「📔 存为我的」开始</span></div></div>';
return;
}
// Use recipe-grid card format
list.className = 'recipe-grid';
list.innerHTML = userDiary.map(d => {
const entryCount = d.entries ? d.entries.length : 0;
const ings = (d.ingredients || []).filter(i => i.oil !== '椰子油').map(i => i.oil).join('、');
const cost = (d.ingredients || []).reduce((s, i) => s + (OILS[i.oil] || 0) * i.drops, 0);
return '<div class="recipe-card" onclick="openDiaryDetail(' + d.id + ')">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:6px">' +
'<div class="recipe-card-name" style="flex:1;min-width:0;word-break:break-word;margin-bottom:0">' + d.name + '</div>' +
'<button onclick="event.stopPropagation();deleteDiaryRecipe(' + d.id + ')" title="删除" style="background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-light);padding:2px;flex-shrink:0">🗑</button>' +
'</div>' +
(entryCount ? '<div style="margin:4px 0"><span class="tag">' + entryCount + '篇日记</span></div>' : '') +
'<div class="recipe-card-oils">' + ings + '</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">' +
'<div class="recipe-card-price" style="margin:0">💰 ' + fmtCostWithRetail(d.ingredients || []) + '</div>' +
'</div>' +
'</div>';
}).join('');
}
function renderDiaryFavs() {
const container = document.getElementById('diaryFavsList');
const favRecipes = recipes.filter(r => r._id && userFavorites.includes(r._id));
if (!favRecipes.length) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">★</div><div class="empty-state-text">还没有收藏配方<br><span style="font-size:13px;color:var(--text-light)">在配方卡片上点 ★ 收藏</span></div></div>';
return;
}
container.innerHTML = favRecipes.map(r => {
const realIdx = recipes.indexOf(r);
const oilNames = r.ingredients.filter(i => i.oil !== '椰子油').map(i => i.oil).join('、');
const tags = (r.tags || []).map(t => '<span class="tag">' + t + '</span>').join(' ');
const fav = isFavorite(r);
return '<div class="recipe-card" onclick="showSection(\'search\');selectRecipe(' + realIdx + ')">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:4px">' +
'<div class="recipe-card-name" style="flex:1;min-width:0;word-break:break-word;margin-bottom:0">' + r.name + '</div>' +
'<button onclick="event.stopPropagation();toggleFavorite(' + r._id + ',this)" style="background:none;border:none;font-size:20px;cursor:pointer;padding:2px 4px;color:var(--gold);flex-shrink:0">★</button>' +
'</div>' +
(tags ? '<div style="margin:4px 0">' + tags + '</div>' : '') +
'<div class="recipe-card-oils">' + oilNames + '</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">' +
'<div class="recipe-card-price" style="margin:0">💰 ' + fmtCostWithRetail(r.ingredients) + '</div>' +
'</div>' +
'</div>';
}).join('');
}
function editDiaryFromManage(diaryId) {
const d = userDiary.find(x => x.id === diaryId);
if (!d) return;
const tmpRecipe = { _id: null, _diary_id: diaryId, name: d.name, note: d.note || '', tags: [], ingredients: d.ingredients || [], _owner_id: currentUser.id };
recipes.push(tmpRecipe);
const tmpIdx = recipes.length - 1;
currentRecipe = tmpIdx;
_editSnapshot = null; // temp recipe, just remove on close
currentEditing = JSON.parse(JSON.stringify(d.ingredients || []));
document.getElementById('detailOverlay').style.display = 'block';
document.body.style.overflow = 'hidden';
switchToEditorView();
document.getElementById('detailPanel').scrollTop = 0;
const origClose = closeDetail;
closeDetail = function() { recipes.splice(tmpIdx, 1); currentRecipe = null; origClose(); closeDetail = origClose; };
}
function editDiaryTags(diaryId) {
const d = userDiary.find(x => x.id === diaryId);
if (!d) return;
_openTagPicker(d.name, d.tags || [], function(tags) {
d.tags = tags; // Update immediately so renderManage sees it
tags.forEach(t => { if (!allTags.includes(t)) allTags.push(t); });
_apiFetch('/api/diary/' + diaryId, { method: 'PUT', body: JSON.stringify({ tags }) })
.catch(() => _showToast('保存失败'));
});
}
function viewDiaryAsCard(diaryId) {
const d = userDiary.find(x => x.id === diaryId);
if (!d) return;
// Create a temporary recipe object and use the standard card view
const tmpRecipe = { _id: null, name: d.name, note: d.note || '', tags: [], ingredients: d.ingredients || [], _owner_id: currentUser.id };
recipes.push(tmpRecipe);
const tmpIdx = recipes.length - 1;
currentRecipe = tmpIdx;
currentEditing = JSON.parse(JSON.stringify(d.ingredients || []));
document.getElementById('detailOverlay').style.display = 'block';
document.body.style.overflow = 'hidden';
switchToCardView();
document.getElementById('detailPanel').scrollTop = 0;
// Override closeDetail to clean up temp recipe
const origClose = closeDetail;
closeDetail = function() { recipes.splice(tmpIdx, 1); currentRecipe = null; origClose(); closeDetail = origClose; };
}
function openDiaryDetail(diaryId) {
currentDiaryId = diaryId;
const d = userDiary.find(x => x.id === diaryId);
if (!d) return;
document.getElementById('diaryRecipesPanel').style.display = 'none';
document.getElementById('diaryDetailPanel').style.display = '';
document.getElementById('diaryDetailTitle').innerHTML =
'<input type="text" value="' + (d.name || '').replace(/"/g,'&quot;') + '" id="diaryNameInput" class="form-control" style="font-size:18px;font-weight:700;font-family:Noto Serif SC,serif;border:none;border-bottom:2px solid var(--border);border-radius:0;padding:4px 0;max-width:300px" placeholder="配方名称">';
document.getElementById('diaryNoteEdit').value = d.note || '';
// Ingredients (editable)
const ings = d.ingredients || [];
const oilOpts = Object.keys(OILS).sort((a,b) => a.localeCompare(b,'zh')).map(o => '<option value="' + o + '">' + o + '</option>').join('');
document.getElementById('diaryIngredients').innerHTML =
'<table class="ingredients-table"><thead><tr><th>精油</th><th>滴数</th><th>成本</th><th></th></tr></thead><tbody>' +
ings.map((i, idx) => {
const ppd = OILS[i.oil] || 0;
return '<tr><td><select class="oil-select" onchange="updateDiaryIng(' + idx + ',\'oil\',this.value)" style="font-size:12px">' +
Object.keys(OILS).sort((a,b) => a.localeCompare(b,'zh')).map(o => '<option value="' + o + '"' + (o === i.oil ? ' selected' : '') + '>' + o + '</option>').join('') +
'</select></td>' +
'<td><input type="number" class="drops-input" value="' + i.drops + '" min="0.5" step="0.5" onchange="updateDiaryIng(' + idx + ',\'drops\',this.value)" style="width:55px"></td>' +
'<td style="color:var(--sage-dark);font-size:12px">' + (ppd > 0 ? fmtPrice(ppd * i.drops) : '—') + '</td>' +
'<td><button onclick="removeDiaryIng(' + idx + ')" style="background:none;border:none;cursor:pointer;color:var(--text-light);font-size:14px">×</button></td></tr>';
}).join('') +
'</tbody></table>' +
'<button class="btn btn-outline btn-sm" onclick="addDiaryIng()" style="margin-top:6px;font-size:12px"> 加精油</button>' +
'';
// Entries
renderDiaryEntries(d);
}
function updateDiaryIng(idx, field, val) {
const d = userDiary.find(x => x.id === currentDiaryId);
if (!d) return;
if (field === 'oil') d.ingredients[idx].oil = val;
if (field === 'drops') d.ingredients[idx].drops = parseFloat(val) || 0;
openDiaryDetail(currentDiaryId);
}
function removeDiaryIng(idx) {
const d = userDiary.find(x => x.id === currentDiaryId);
if (!d) return;
d.ingredients.splice(idx, 1);
openDiaryDetail(currentDiaryId);
}
function addDiaryIng() {
const d = userDiary.find(x => x.id === currentDiaryId);
if (!d) return;
if (!d.ingredients) d.ingredients = [];
d.ingredients.push({ oil: Object.keys(OILS).sort((a,b) => a.localeCompare(b,'zh'))[0], drops: 1 });
openDiaryDetail(currentDiaryId);
}
async function saveDiaryAll() {
if (!currentDiaryId) { alert('无法保存:未选择配方'); return; }
const d = userDiary.find(x => x.id === currentDiaryId);
if (!d) { alert('无法保存:找不到配方数据'); return; }
const nameInput = document.getElementById('diaryNameInput');
const noteInput = document.getElementById('diaryNoteEdit');
const name = nameInput ? nameInput.value.trim() : d.name;
const note = noteInput ? noteInput.value : d.note;
const ingredients = d.ingredients || [];
try {
const res = await _apiFetch('/api/diary/' + currentDiaryId, { method: 'PUT', body: JSON.stringify({
name: name || '未命名配方', note, ingredients
})});
if (!res.ok) {
const err = await res.text();
alert('保存失败:' + err);
return;
}
d.name = name || '未命名配方';
d.note = note;
alert('✅ 已保存');
await loadDiary();
} catch(e) { alert('保存失败:' + e.message); }
}
function previewDiaryCard() {
if (!currentDiaryId) return;
const d = userDiary.find(x => x.id === currentDiaryId);
if (!d) return;
const nameInput = document.getElementById('diaryNameInput');
const name = nameInput ? nameInput.value.trim() : d.name;
if (!d.ingredients || !d.ingredients.length) { alert('请先添加精油'); return; }
// Build a temporary recipe-like object and reuse renderViewerCard
const tmpIdx = recipes.length;
const tmpRecipe = {
_id: null, name: name || '未命名配方', note: d.note || '',
tags: [], ingredients: d.ingredients, _owner_id: currentUser.id
};
recipes.push(tmpRecipe);
const prevRecipe = currentRecipe;
currentRecipe = tmpIdx;
// Show overlay with card
document.getElementById('detailOverlay').style.display = 'block';
document.body.style.overflow = 'hidden';
document.getElementById('cardViewMode').style.display = '';
document.getElementById('editorViewMode').style.display = 'none';
// Actions: just share + download + close
document.getElementById('cardViewActions').innerHTML =
'<button class="btn btn-outline btn-sm" onclick="shareRecipe()" style="font-size:12px">🔗 分享</button>' +
'<button class="btn btn-outline btn-sm" onclick="closeDetail()" style="font-size:12px">✕ 关闭</button>';
renderViewerCard(tmpIdx);
document.getElementById('detailPanel').scrollTop = 0;
// Cleanup: remove temp recipe when overlay closes
const origClose = closeDetail;
closeDetail = function() {
recipes.splice(tmpIdx, 1);
currentRecipe = prevRecipe;
origClose();
closeDetail = origClose;
};
}
async function publishDiary() {
if (!currentDiaryId) return;
const d = userDiary.find(x => x.id === currentDiaryId);
if (!d) return;
const nameInput = document.getElementById('diaryNameInput');
const name = nameInput ? nameInput.value.trim() : d.name;
if (!name || name === '未命名配方') { alert('请先填写配方名称'); return; }
if (!d.ingredients || !d.ingredients.length) { alert('请先添加精油'); return; }
if (!await _confirm('确认共享「' + name + '」到共享配方库吗?')) return;
try {
const res = await _apiFetch('/api/recipes', { method: 'POST', body: JSON.stringify({
name, note: d.note || '',
ingredients: d.ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: []
})});
if (!res.ok) { alert('共享失败'); return; }
await _apiLoadRecipes();
alert('✅ 已共享到公共配方库');
} catch(e) { alert('共享失败'); }
}
async function saveDiaryIngredients() {
if (!currentDiaryId) return;
const d = userDiary.find(x => x.id === currentDiaryId);
if (!d) return;
try {
await _apiFetch('/api/diary/' + currentDiaryId, { method: 'PUT', body: JSON.stringify({ ingredients: d.ingredients }) });
alert('✅ 配方已保存');
} catch(e) { alert('保存失败'); }
}
function renderDiaryEntries(d) {
const entries = d.entries || [];
document.getElementById('diaryEntries').innerHTML = entries.length
? entries.map(e =>
'<div style="background:white;border-radius:10px;padding:14px 16px;margin-bottom:8px;box-shadow:0 2px 8px rgba(90,60,30,0.06)">' +
'<div style="display:flex;justify-content:space-between;align-items:center">' +
'<span style="font-size:11px;color:var(--text-light)">' + (e.created_at || '') + '</span>' +
'<button style="background:none;border:none;color:var(--text-light);cursor:pointer;font-size:14px" onclick="deleteDiaryEntry(' + e.id + ')">×</button>' +
'</div>' +
'<div style="font-size:14px;color:var(--text-dark);margin-top:6px;white-space:pre-wrap">' + e.content + '</div>' +
'</div>'
).join('')
: '<div style="text-align:center;color:var(--text-light);padding:20px;font-size:13px">还没有日记,写下你的第一条感悟吧</div>';
}
function closeDiaryDetail() {
document.getElementById('diaryDetailPanel').style.display = 'none';
document.getElementById('diaryRecipesPanel').style.display = '';
currentDiaryId = null;
}
async function saveDiaryNote() {
if (!currentDiaryId) return;
const note = document.getElementById('diaryNoteEdit').value;
try {
await _apiFetch('/api/diary/' + currentDiaryId, { method: 'PUT', body: JSON.stringify({ note }) });
if (!window._writeQueued) _showToast('✅ 已保存');
} catch(e) { _showToast('⚠️ 备注保存失败'); }
const d = userDiary.find(x => x.id === currentDiaryId);
if (d) d.note = note;
}
async function addDiaryEntry() {
if (!currentDiaryId) return;
const textarea = document.getElementById('newDiaryEntry');
const content = textarea.value.trim();
if (!content) { alert('请写点内容'); return; }
try {
await _apiFetch('/api/diary/' + currentDiaryId + '/entries', { method: 'POST', body: JSON.stringify({ content }) });
textarea.value = '';
await loadDiary();
const d = userDiary.find(x => x.id === currentDiaryId);
if (d) renderDiaryEntries(d);
} catch(e) { alert('保存失败'); }
}
async function deleteDiaryEntry(entryId) {
if (!await _confirm('删除这条日记?')) return;
await _apiFetch('/api/diary/entries/' + entryId, { method: 'DELETE' }).catch(() => _showToast('删除失败'));
await loadDiary();
const d = userDiary.find(x => x.id === currentDiaryId);
if (d) renderDiaryEntries(d);
}
async function deleteDiaryRecipe(diaryId) {
if (!await _confirm('删除这个个人配方及所有日记?')) return;
await _apiFetch('/api/diary/' + diaryId, { method: 'DELETE' }).catch(() => _showToast('删除失败'));
await loadDiary();
if (currentDiaryId === diaryId) closeDiaryDetail();
renderDiaryList();
}
// ── PROFIT PROJECTS ─────────────────────────────────────
let allProjects = [];
let currentProject = null;
let projIngredients = [];
async function loadProjects() {
try {
const res = await _apiFetch('/api/projects');
allProjects = await res.json();
} catch(e) { allProjects = []; }
}
function renderProjectList() {
const list = document.getElementById('projectList');
if (!allProjects.length) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">💼</div><div class="empty-state-text">还没有项目,点击右上角新增</div></div>';
return;
}
list.innerHTML = allProjects.map(p => {
const ings = (p.ingredients || []).map(i => i.oil).join('、');
return '<div class="manage-item" style="cursor:pointer" onclick="openProjectDetail(' + p.id + ')">' +
'<div class="manage-item-left">' +
'<div class="manage-item-name">' + p.name + '</div>' +
'<div class="manage-item-oils">' + ings + '</div>' +
(p.pricing ? '<div style="font-size:13px;color:var(--sage-dark);font-weight:600;margin-top:4px">定价 ¥' + p.pricing + '/次</div>' : '') +
'</div>' +
'<div class="manage-item-actions">' +
(currentUser.role === 'admin' || currentUser.role === 'senior_editor' ? '<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteProject(' + p.id + ')">删除</button>' : '') +
'</div></div>';
}).join('');
}
async function createProject() {
const name = await _prompt('项目名称:');
if (!name) return;
try {
const res = await _apiFetch('/api/projects', { method:'POST', body: JSON.stringify({ name, ingredients: [] }) });
const data = await res.json();
await loadProjects();
renderProjectList();
openProjectDetail(data.id);
} catch(e) { alert('创建失败'); }
}
async function deleteProject(id) {
if (!await _confirm('确认删除此项目?')) return;
await _apiFetch('/api/projects/' + id, { method:'DELETE' }).catch(() => _showToast('删除失败'));
await loadProjects();
if (currentProject === id) closeProjectDetail();
renderProjectList();
}
function openProjectDetail(id) {
const p = allProjects.find(x => x.id === id);
if (!p) return;
currentProject = id;
projIngredients = JSON.parse(JSON.stringify(p.ingredients || []));
document.getElementById('projectList').style.display = 'none';
document.getElementById('projectDetail').style.display = '';
document.getElementById('projTitle').textContent = p.name;
document.getElementById('projPricing').value = p.pricing || 0;
document.getElementById('projNote').value = p.note || '';
renderProjIngredients();
calcProjectAnalysis();
}
function closeProjectDetail() {
document.getElementById('projectDetail').style.display = 'none';
document.getElementById('projectList').style.display = '';
currentProject = null;
}
function renderProjIngredients() {
const tbody = document.getElementById('projIngBody');
tbody.innerHTML = projIngredients.map((ing, i) => {
const meta = OILS_META[ing.oil] || {};
const ppd = OILS[ing.oil] || 0;
const bottleInfo = meta.dropCount ? meta.dropCount + '滴/' + (meta.bottlePrice || 0) + '元' : '—';
const oilOptions = Object.keys(OILS).sort((a,b) => a.localeCompare(b,'zh')).map(o =>
'<option value="' + o + '"' + (o === ing.oil ? ' selected' : '') + '>' + o + '</option>'
).join('');
return '<tr>' +
'<td><select class="oil-select" onchange="changeProjOil(' + i + ',this.value)">' + oilOptions + '</select></td>' +
'<td><input type="number" class="drops-input" value="' + ing.drops + '" min="0.5" step="0.5" onchange="changeProjDrops(' + i + ',this.value)"></td>' +
'<td style="font-size:12px;color:var(--text-light)">' + bottleInfo + '</td>' +
'<td style="font-size:13px;color:var(--text-light)">¥' + ppd.toFixed(2) + '</td>' +
'<td><button class="remove-btn" onclick="removeProjIng(' + i + ')">×</button></td></tr>';
}).join('');
}
function changeProjOil(i, val) { projIngredients[i].oil = val; renderProjIngredients(); calcProjectAnalysis(); }
function changeProjDrops(i, val) { projIngredients[i].drops = parseFloat(val) || 0; calcProjectAnalysis(); }
function removeProjIng(i) { projIngredients.splice(i, 1); renderProjIngredients(); calcProjectAnalysis(); }
function addProjIng() { projIngredients.push({ oil: Object.keys(OILS)[0], drops: 4 }); renderProjIngredients(); calcProjectAnalysis(); }
async function loadFromRecipe() {
const name = await _prompt('输入配方名称(精确匹配):');
if (!name) return;
const r = recipes.find(x => x.name === name.trim());
if (!r) { alert('未找到配方「' + name + '」'); return; }
projIngredients = JSON.parse(JSON.stringify(r.ingredients));
renderProjIngredients();
calcProjectAnalysis();
}
function calcProjectAnalysis() {
const container = document.getElementById('projAnalysis');
if (!projIngredients.length) { container.innerHTML = ''; calcProjectProfit(); return; }
// For each oil: how many services can one bottle provide?
const analysis = projIngredients.map(ing => {
const meta = OILS_META[ing.oil] || {};
const bottleDrops = meta.dropCount || 280;
const bottlePrice = meta.bottlePrice || 0;
const servicesFromBottle = ing.drops > 0 ? Math.floor(bottleDrops / ing.drops) : 999999;
return {
oil: ing.oil,
dropsPerService: ing.drops,
bottleDrops,
bottlePrice,
servicesFromBottle,
ppdCost: (OILS[ing.oil] || 0) * ing.drops
};
});
// Bottleneck: oil that runs out first
const bottleneck = analysis.reduce((a, b) => a.servicesFromBottle < b.servicesFromBottle ? a : b);
const totalServices = bottleneck.servicesFromBottle;
// For each oil: how many drops & cost used over totalServices
const details = analysis.map(a => {
const usedDrops = a.dropsPerService * totalServices;
const usedBottles = usedDrops / a.bottleDrops;
const usedCost = usedBottles * a.bottlePrice;
return { ...a, usedDrops, usedBottles, usedCost };
});
const totalCost = details.reduce((s, d) => s + d.usedCost, 0);
const costPerService = totalServices > 0 ? totalCost / totalServices : 0;
// Store for profit calc
window._projTotalServices = totalServices;
window._projCostPerService = costPerService;
window._projTotalCost = totalCost;
let html = '<div style="font-size:14px;font-weight:600;color:var(--text-mid);margin-bottom:10px">📊 成本分析</div>';
html += '<table class="ingredients-table"><thead><tr>' +
'<th>精油</th><th>单次用量</th><th>瓶装</th><th>瓶可做次数</th><th>做' + totalServices + '次用量</th><th>消耗瓶数</th><th>成本</th></tr></thead><tbody>';
details.forEach(d => {
const isBottleneck = d.oil === bottleneck.oil;
html += '<tr' + (isBottleneck ? ' style="background:var(--gold-light)"' : '') + '>' +
'<td>' + d.oil + (isBottleneck ? ' <span style="font-size:11px;color:var(--brown)">⚡先用完</span>' : '') + '</td>' +
'<td>' + d.dropsPerService + '滴</td>' +
'<td style="font-size:12px">' + d.bottleDrops + '滴/¥' + d.bottlePrice + '</td>' +
'<td style="font-weight:600">' + d.servicesFromBottle + '次</td>' +
'<td>' + d.usedDrops + '滴</td>' +
'<td>' + d.usedBottles.toFixed(2) + '瓶</td>' +
'<td style="font-weight:600;color:var(--sage-dark)">¥' + d.usedCost.toFixed(2) + '</td></tr>';
});
html += '</tbody></table>';
html += '<div class="total-row" style="margin-top:12px">' +
'<div><span class="total-label">可做服务次数:</span><span style="font-size:18px;font-weight:700;color:var(--sage-dark)">' + totalServices + ' 次</span></div>' +
'<div><span class="total-label">总原料成本:</span><span class="total-price">¥' + totalCost.toFixed(2) + '</span></div>' +
'<div><span class="total-label">单次成本:</span><span style="font-size:18px;font-weight:700;color:var(--brown)">¥' + costPerService.toFixed(2) + '/次</span></div>' +
'</div>';
container.innerHTML = html;
calcProjectProfit();
}
function calcProjectProfit() {
const pricing = parseFloat(document.getElementById('projPricing').value) || 0;
const totalServices = window._projTotalServices || 0;
const costPerService = window._projCostPerService || 0;
const totalCost = window._projTotalCost || 0;
if (!totalServices || !pricing) {
document.getElementById('projProfit').textContent = '';
document.getElementById('projProfitDetail').textContent = pricing ? '' : '填写定价后自动计算利润';
return;
}
const profitPerService = pricing - costPerService;
const totalRevenue = pricing * totalServices;
const totalProfit = totalRevenue - totalCost;
const margin = (profitPerService / pricing * 100).toFixed(1);
document.getElementById('projProfit').innerHTML =
'单次利润 <span style="color:' + (profitPerService > 0 ? 'var(--sage-dark)' : '#c0392b') + ';font-size:22px">¥' +
profitPerService.toFixed(2) + '</span> · 利润率 ' + margin + '%';
document.getElementById('projProfitDetail').textContent =
totalServices + '次总收入 ¥' + totalRevenue.toFixed(2) + ' 总成本 ¥' + totalCost.toFixed(2) +
' = 总利润 ¥' + totalProfit.toFixed(2);
}
async function saveProject() {
if (!currentProject) return;
const pricing = parseFloat(document.getElementById('projPricing').value) || 0;
const note = document.getElementById('projNote').value;
try {
await _apiFetch('/api/projects/' + currentProject, {
method: 'PUT',
body: JSON.stringify({ ingredients: projIngredients, pricing, note })
});
await loadProjects();
alert('✅ 已保存');
} catch(e) { alert('保存失败'); }
}
// ── BRAND SETTINGS ─────────────────────────────────────
let userBrand = { qr_code: null, brand_logo: null, brand_name: '' };
async function loadBrand() {
if (!currentUser.id) return;
try {
const res = await _apiFetch('/api/brand');
userBrand = await res.json();
} catch(e) {}
}
function renderAccountUI() {
const dnEl = document.getElementById('myDisplayName');
const unEl = document.getElementById('myUsername');
if (dnEl) dnEl.value = currentUser.display_name || '';
if (unEl) unEl.value = currentUser.username || '';
// Business verification section
const bc = document.getElementById('businessApplyContent');
if (!bc) return;
if (currentUser.business_verified) {
bc.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:10px 14px;background:#e8f5e9;border-radius:10px"><span style="font-size:18px">✅</span><span style="font-size:14px;color:#2e7d32;font-weight:600">已认证商业用户</span></div>';
} else {
// Load last application to pre-fill
_apiFetch('/api/my-business-application').then(r => r.json()).then(app => {
const prevName = app.business_name || '';
const prevDoc = app.document || '';
const isPending = app.status === 'pending';
const statusMsg = isPending
? '<div style="padding:8px 12px;background:#fff8e1;border-radius:8px;margin-bottom:12px;font-size:13px;color:#f57f17">⏳ 申请审核中,请耐心等待</div>'
: (app.status === 'rejected'
? '<div style="padding:8px 12px;background:#fdf0ee;border-radius:8px;margin-bottom:12px;font-size:13px;color:#c62828">上次申请未通过,可修改后重新提交' + (app.reject_reason ? '<div style="margin-top:4px;color:var(--text-mid)">原因:' + app.reject_reason + '</div>' : '') + '</div>'
: '');
bc.innerHTML =
'<p style="font-size:12px;color:var(--text-light);margin-bottom:12px">认证后可使用项目核算等商业功能,请填写商户信息并上传证明文件</p>' +
statusMsg +
'<div class="form-group"><label class="form-label">商户名称</label>' +
'<input type="text" class="form-control" id="bizName" value="' + prevName.replace(/"/g,'&quot;') + '" placeholder="如XX美容院" style="max-width:250px;font-size:14px"' + (isPending ? ' disabled' : '') + '></div>' +
(prevDoc ? '<div class="form-group"><label class="form-label">已上传的证明文件</label><img src="' + prevDoc + '" style="max-width:200px;max-height:150px;border-radius:8px;border:1px solid var(--border);display:block;margin-bottom:8px"></div>' : '') +
'<div class="form-group"><label class="form-label">' + (prevDoc ? '更换证明文件' : '证明文件(营业执照/店铺照片等)') + '</label>' +
'<input type="file" id="bizDoc" accept="image/*" style="font-size:13px"' + (isPending ? ' disabled' : '') + '></div>' +
(isPending ? '' : '<button class="btn btn-primary btn-sm" onclick="submitBusinessApply()">' + (app.status === 'rejected' ? '重新提交' : '提交认证申请') + '</button>') +
'<div id="bizApplyResult" style="margin-top:8px"></div>';
}).catch(() => {
bc.innerHTML =
'<p style="font-size:12px;color:var(--text-light);margin-bottom:12px">认证后可使用项目核算等商业功能,请填写商户信息并上传证明文件</p>' +
'<div class="form-group"><label class="form-label">商户名称</label>' +
'<input type="text" class="form-control" id="bizName" placeholder="如XX美容院" style="max-width:250px;font-size:14px"></div>' +
'<div class="form-group"><label class="form-label">证明文件(营业执照/店铺照片等)</label>' +
'<input type="file" id="bizDoc" accept="image/*" style="font-size:13px"></div>' +
'<button class="btn btn-primary btn-sm" onclick="submitBusinessApply()">提交认证申请</button>' +
'<div id="bizApplyResult" style="margin-top:8px"></div>';
});
}
}
async function submitBusinessApply() {
const name = document.getElementById('bizName')?.value.trim();
if (!name) { document.getElementById('bizApplyResult').innerHTML = '<span style="color:#c0392b">请填写商户名称</span>'; return; }
let doc = '';
const file = document.getElementById('bizDoc')?.files[0];
if (file) {
doc = await _compressImage(file, 1500000);
} else {
const prevImg = document.querySelector('#businessApplyContent img');
if (prevImg) doc = prevImg.src;
}
if (!doc) { document.getElementById('bizApplyResult').innerHTML = '<span style="color:#c0392b">请上传证明文件</span>'; return; }
try {
const res = await _apiFetch('/api/business-apply', { method: 'POST', body: JSON.stringify({ business_name: name, document: doc }) });
if (res.ok) {
if (!window._writeQueued) document.getElementById('bizApplyResult').innerHTML = '<span style="color:var(--sage-dark)">✅ 申请已提交,等待管理员审核</span>';
} else {
const err = await res.json().catch(() => ({}));
document.getElementById('bizApplyResult').innerHTML = '<span style="color:#c0392b">' + (err.detail || '提交失败') + '</span>';
}
} catch(e) { document.getElementById('bizApplyResult').innerHTML = '<span style="color:#c0392b">网络错误</span>'; }
}
function renderBrandUI() {
const qrPrev = document.getElementById('qrPreview');
const logoPrev = document.getElementById('logoPreview');
const bgPrev = document.getElementById('bgPreview');
const nameInput = document.getElementById('brandNameInput');
if (!qrPrev) return;
function setPreview(el, src, fit) {
if (src) el.innerHTML = '<img src="' + src + '" style="width:100%;height:100%;object-fit:' + fit + '">';
else el.innerHTML = '<span style="font-size:12px;color:var(--text-light)">点击上传</span>';
}
setPreview(qrPrev, userBrand.qr_code, 'cover');
setPreview(bgPrev, userBrand.brand_bg, 'cover');
setPreview(logoPrev, userBrand.brand_logo, 'contain;padding:4px');
if (nameInput) nameInput.value = userBrand.brand_name || '';
// Highlight current alignment button
const align = userBrand.brand_align || 'center';
document.querySelectorAll('.brand-align-btn').forEach(b => {
const isActive = b.textContent.includes(align === 'left' ? '靠左' : align === 'right' ? '靠右' : '居中');
b.classList.toggle('btn-primary', isActive);
b.classList.toggle('btn-outline', !isActive);
});
// Update card preview
_updateBrandPreview();
}
function _updateBrandPreview() {
const prev = document.getElementById('brandCardPreview');
if (!prev) return;
// Remove old overlays
prev.querySelectorAll('.brand-overlay').forEach(el => el.remove());
// Background image: cover entire card, low opacity
if (userBrand.brand_bg) {
const bg = document.createElement('div');
bg.className = 'brand-overlay';
bg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;background:url(\'' + userBrand.brand_bg + '\') center/cover no-repeat;opacity:0.12;pointer-events:none;z-index:0';
prev.insertBefore(bg, prev.firstChild);
}
// Logo: bottom center watermark
if (userBrand.brand_logo) {
const logo = document.createElement('img');
logo.className = 'brand-overlay';
logo.src = userBrand.brand_logo;
logo.style.cssText = 'position:absolute;bottom:36px;left:50%;transform:translateX(-50%);height:28px;opacity:0.25;pointer-events:none;z-index:1';
prev.appendChild(logo);
}
// QR code: top-right
if (userBrand.qr_code) {
const qr = document.createElement('div');
qr.className = 'brand-overlay';
qr.style.cssText = 'position:absolute;top:10px;right:10px;display:flex;flex-direction:column;align-items:center;gap:1px;z-index:2';
qr.innerHTML = '<img src="' + userBrand.qr_code + '" style="width:32px;height:32px;object-fit:cover;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,0.1)">' +
(userBrand.brand_name ? '<div style="font-size:5px;color:var(--text-light);text-align:center;line-height:1.2;max-width:44px;white-space:pre-line">' + userBrand.brand_name + '</div>' : '');
prev.appendChild(qr);
}
}
function _compressImage(file, maxBytes) {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => {
if (file.size <= maxBytes) { resolve(reader.result); return; }
// Compress via canvas
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let w = img.width, h = img.height;
// Scale down if very large
const maxDim = 1200;
if (w > maxDim || h > maxDim) {
const ratio = Math.min(maxDim / w, maxDim / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
let quality = 0.8;
let result = canvas.toDataURL('image/jpeg', quality);
// Reduce quality until under limit
while (result.length > maxBytes * 1.37 && quality > 0.1) {
quality -= 0.1;
result = canvas.toDataURL('image/jpeg', quality);
}
_showToast('图片已自动压缩');
resolve(result);
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
function uploadBrandImage(field, input, maxSize) {
const file = input.files[0];
if (!file) return;
const limit = maxSize || 500000;
_compressImage(file, limit).then(dataUrl => {
userBrand[field] = dataUrl;
renderBrandUI();
_autoSaveBrand();
});
}
function clearBrandImage(field) {
userBrand[field] = null;
renderBrandUI();
_autoSaveBrand();
}
function _setBrandAlign(align) {
userBrand.brand_align = align;
document.querySelectorAll('.brand-align-btn').forEach(b => {
b.classList.toggle('btn-primary', b.textContent.includes(align === 'left' ? '靠左' : align === 'right' ? '靠右' : '居中'));
b.classList.toggle('btn-outline', !b.textContent.includes(align === 'left' ? '靠左' : align === 'right' ? '靠右' : '居中'));
});
_updateBrandPreview();
_autoSaveBrand();
}
function _autoSaveBrand() {
userBrand.brand_name = document.getElementById('brandNameInput')?.value || userBrand.brand_name || '';
_apiFetch('/api/brand', { method: 'PUT', body: JSON.stringify(userBrand) })
.then(res => { if (res.ok && !window._writeQueued) _showToast('✅ 已自动保存'); })
.catch(() => _showToast('自动保存失败'));
}
function _buildBrandHtml() {
let bh = '';
const align = userBrand.brand_align || 'center';
// Background: full cover, low opacity
if (userBrand.brand_bg) {
bh += '<div style="position:absolute;inset:0;width:100%;height:100%;background:url(\'' + userBrand.brand_bg + '\') center/cover no-repeat;opacity:0.12;pointer-events:none;z-index:0"></div>';
}
// Logo: bottom center watermark
if (userBrand.brand_logo) {
bh += '<img src="' + userBrand.brand_logo + '" style="position:absolute;bottom:60px;left:50%;transform:translateX(-50%);height:60px;opacity:0.2;pointer-events:none;z-index:1">';
}
// QR: top-right corner
if (userBrand.qr_code) {
bh += '<div style="position:absolute;top:36px;right:24px;display:flex;flex-direction:column;align-items:center;gap:3px;z-index:2">';
bh += '<img src="' + userBrand.qr_code + '" style="width:54px;height:54px;object-fit:cover;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,0.1)">';
if (userBrand.brand_name) bh += '<div style="font-size:7px;color:var(--text-light);text-align:' + align + ';line-height:1.3;max-width:68px;white-space:pre-line">' + userBrand.brand_name + '</div>';
bh += '</div>';
}
return bh;
}
async function saveBrand() {
userBrand.brand_name = document.getElementById('brandNameInput')?.value || '';
try {
const res = await _apiFetch('/api/brand', { method: 'PUT', body: JSON.stringify(userBrand) });
if (!res.ok) { alert('保存失败:' + await res.text()); return; }
if (!window._writeQueued) {
document.getElementById('brandSaveResult').innerHTML = '<span style="color:var(--sage-dark)">✅ 已保存</span>';
setTimeout(() => { const el = document.getElementById('brandSaveResult'); if (el) el.innerHTML = ''; }, 2000);
}
} catch(e) { alert('保存失败'); }
}
// ── BATCH TAG ──────────────────────────────────────────
async function batchAddTag() {
if (selectedRecipes.size === 0) { alert('请先勾选配方'); return; }
// Collect current tags from all selected
const allSelected = [];
for (const idx of selectedRecipes) {
if (typeof idx === 'string' && idx.startsWith('d')) {
const did = parseInt(idx.slice(1));
const d = userDiary.find(x => x.id === did);
if (d) allSelected.push({ type: 'diary', id: did, obj: d });
} else {
const r = recipes[idx];
if (r) allSelected.push({ type: 'recipe', idx, obj: r });
}
}
const commonTags = allSelected.length > 0 ? (allSelected[0].obj.tags || []).filter(t => allSelected.every(s => (s.obj.tags || []).includes(t))) : [];
_openTagPicker('已选 ' + allSelected.length + ' 个配方', commonTags, function(tags) {
let count = 0;
for (const s of allSelected) {
const oldTags = s.obj.tags || [];
const merged = [...new Set([...oldTags, ...tags])];
if (merged.length !== oldTags.length) {
s.obj.tags = merged;
if (s.type === 'recipe') { s.obj._dirty = true; count++; }
else { _apiFetch('/api/diary/' + s.id, { method: 'PUT', body: JSON.stringify({ tags: merged }) }).catch(() => {}); count++; }
}
}
tags.forEach(t => { if (!allTags.includes(t)) allTags.push(t); });
_apiSaveRecipes();
saveTags();
_showToast('✅ 已为 ' + count + ' 条配方添加标签');
});
}
// ── CATEGORY MODULES ───────────────────────────────────
let _catIdx = 0;
let _catCount = 0;
async function renderCategories() {
const wrap = document.getElementById('categoryWrap');
const track = document.getElementById('categoryTrack');
const dots = document.getElementById('categoryDots');
try {
const res = await _apiFetch('/api/categories');
const cats = await res.json();
if (!cats.length) { wrap.style.display = 'none'; dots.style.display = 'none'; return; }
_catCount = cats.length;
_catIdx = 0;
wrap.style.display = '';
dots.style.display = '';
track.innerHTML = cats.map(c => {
const bg = c.bg_image
? `background-image:url('${c.bg_image}')`
: `background:linear-gradient(135deg,${c.color_from},${c.color_to})`;
return `<div class="cat-card" style="${bg}" onclick="filterByCategory('${c.tag_name.replace(/'/g, "\\'")}','${c.name.replace(/'/g, "\\'")}')">
<div class="cat-inner">
<div class="cat-icon">${c.icon}</div>
<div class="cat-name">${c.name}</div>
${c.subtitle ? '<div class="cat-sub">' + c.subtitle + '</div>' : ''}
</div>
</div>`;
}).join('');
dots.innerHTML = cats.map((_, i) =>
`<div class="cat-dot${i === 0 ? ' active' : ''}" onclick="goToCategory(${i})"></div>`
).join('');
// Touch swipe
let startX = 0;
wrap.addEventListener('touchstart', e => { startX = e.touches[0].clientX; }, {passive:true});
wrap.addEventListener('touchend', e => {
const diff = startX - e.changedTouches[0].clientX;
if (Math.abs(diff) > 40) slideCategory(diff > 0 ? 1 : -1);
}, {passive:true});
// Auto-play
// Auto-play with pause on hover/touch
let _catPaused = false;
wrap.addEventListener('mouseenter', () => _catPaused = true);
wrap.addEventListener('mouseleave', () => _catPaused = false);
wrap.addEventListener('touchstart', () => _catPaused = true, {passive:true});
wrap.addEventListener('touchend', () => { setTimeout(() => _catPaused = false, 3000); }, {passive:true});
setInterval(() => { if (document.visibilityState === 'visible' && !_catPaused) slideCategory(1); }, 5000);
} catch(e) { wrap.style.display = 'none'; dots.style.display = 'none'; }
}
function slideCategory(dir) {
const track = document.getElementById('categoryTrack');
const oldIdx = _catIdx;
_catIdx = (_catIdx + dir + _catCount) % _catCount;
// When wrapping around (last→first or first→last), briefly disable transition for seamless feel
if (dir > 0 && _catIdx === 0 && oldIdx === _catCount - 1) {
// Going forward past last → fade transition instead of slide
track.style.transition = 'opacity 0.3s';
track.style.opacity = '0';
setTimeout(() => {
track.style.transition = 'none';
track.style.transform = `translateX(0)`;
setTimeout(() => {
track.style.transition = 'transform 0.4s ease, opacity 0.3s';
track.style.opacity = '1';
}, 20);
}, 300);
} else if (dir < 0 && _catIdx === _catCount - 1 && oldIdx === 0) {
track.style.transition = 'opacity 0.3s';
track.style.opacity = '0';
setTimeout(() => {
track.style.transition = 'none';
track.style.transform = `translateX(-${_catIdx * 100}%)`;
setTimeout(() => {
track.style.transition = 'transform 0.4s ease, opacity 0.3s';
track.style.opacity = '1';
}, 20);
}, 300);
} else {
track.style.transition = 'transform 0.4s ease';
track.style.transform = `translateX(-${_catIdx * 100}%)`;
}
document.querySelectorAll('.cat-dot').forEach((d, i) => d.classList.toggle('active', i === _catIdx));
}
function goToCategory(idx) {
const track = document.getElementById('categoryTrack');
_catIdx = idx;
track.style.transition = 'transform 0.4s ease';
track.style.transform = `translateX(-${idx * 100}%)`;
document.querySelectorAll('.cat-dot').forEach((d, i) => d.classList.toggle('active', i === idx));
}
// Related tag mapping for category suggestions
const _relatedTagMap = {
'美容院': ['护肤','美白','淡斑','抗皱','保湿','祛痘','紧致','瘦脸','小v脸','美容','焕肤'],
'美发店': ['头疗','头皮','头发','脱发','生发','白发','发膜','头疗','护发'],
'艾灸馆': ['疼痛','酸痛','肩颈','腰椎','关节','温经','祛湿','艾灸','理疗','刮痧'],
'瑜伽': ['普拉提','瑜伽','拉伸','放松','呼吸','冥想','肌肉','关节','脊柱','骨盆'],
'养生馆': ['养生','调理','脾胃','肝胆','肾','排毒','免疫','睡眠','化湿'],
'养生': ['养生','调理','脾胃','肝胆','肾','排毒','免疫','睡眠','化湿','精油浴'],
'母婴': ['宝宝','儿童','孕妇','哺乳','感冒','发烧','咳嗽','湿疹'],
'情绪': ['情绪','焦虑','压力','睡眠','安眠','放松','抑郁','冥想'],
};
let _activeCategoryTag = null;
function filterByCategory(tag, displayName) {
_activeCategoryTag = tag;
window._activeCategoryName = displayName || tag;
document.getElementById('searchInput').value = '';
_hideCategoryCarousel();
// Hide personal section and public label
const ps = document.getElementById('personalSection'); if (ps) ps.style.display = 'none';
const pl = document.getElementById('publicLabel'); if (pl) pl.style.display = 'none';
// Clean up old
const oldRel = document.getElementById('categoryRelated'); if (oldRel) oldRel.remove();
const oldHdr = document.getElementById('categoryHeader'); if (oldHdr) oldHdr.remove();
// Find tagged recipes (both public + diary)
const exact = recipes.filter(r => (r.tags || []).includes(tag));
const diaryExact = userDiary.filter(d => (d.tags || []).includes(tag));
// Build header
const grid = document.getElementById('recipeGrid');
const headerHtml = '<div id="categoryHeader" style="margin-bottom:16px">' +
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">' +
'<button onclick="_exitCategoryView()" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-mid);padding:4px">←</button>' +
'<div style="font-family:Noto Serif SC,serif;font-size:22px;font-weight:700;color:var(--text-dark)">' + (window._activeCategoryName || tag) + '</div>' +
'</div>' +
'<div style="font-size:13px;color:var(--text-light);margin-bottom:4px">🌿 适合' + (window._activeCategoryName || tag) + '的精油配方 (' + (exact.length + diaryExact.length) + ')</div>' +
'</div>';
grid.insertAdjacentHTML('beforebegin', headerHtml);
// Render tagged recipes (use original renderGrid to avoid symptom search override)
if (exact.length > 0) {
_origRenderGrid(exact);
} else {
grid.innerHTML = '';
}
// Add diary matches if any
if (diaryExact.length > 0) {
const diaryHtml = diaryExact.map(d => {
const ings = (d.ingredients||[]).filter(i=>i.oil!=='椰子油').map(i=>i.oil).join('、');
return '<div class="recipe-card" onclick="viewDiaryAsCard('+d.id+')">' +
'<div class="recipe-card-name">' + d.name + '</div>' +
'<div class="recipe-card-oils">' + ings + '</div>' +
'<div style="margin-top:8px"><div class="recipe-card-price">💰 ' + fmtCostWithRetail(d.ingredients || []) + '</div></div></div>';
}).join('');
grid.insertAdjacentHTML('beforeend', diaryHtml);
}
// Related recipes by keywords
const keywords = _relatedTagMap[tag] || [];
if (keywords.length > 0) {
const exactIds = new Set(exact.map(r => recipes.indexOf(r)));
const related = recipes.filter((r, idx) => {
if (exactIds.has(idx)) return false;
const haystack = (r.name + ' ' + (r.note || '') + ' ' + (r.tags || []).join(' ') + ' ' + r.ingredients.map(i => i.oil).join(' ')).toLowerCase();
return keywords.some(k => haystack.includes(k));
});
if (related.length > 0) {
const hasExact = exact.length + diaryExact.length > 0;
const relLabel = hasExact ? '💡 类似配方' : '🌿 推荐配方';
const relLimit = hasExact ? 12 : 24;
const relHtml = '<div id="categoryRelated" style="margin-top:16px;' + (hasExact ? 'border-top:2px solid var(--border);padding-top:12px' : '') + '">' +
'<div style="font-size:14px;color:var(--text-mid);margin-bottom:12px">' + relLabel + '</div>' +
'<div class="recipe-grid">' + related.slice(0, relLimit).map(r => {
const realIdx = recipes.indexOf(r);
const oilNames = r.ingredients.filter(i => i.oil !== '椰子油').map(i => i.oil).join('、');
return '<div class="recipe-card" onclick="selectRecipe('+realIdx+')">' +
'<div class="recipe-card-name">' + r.name + '</div>' +
'<div class="recipe-card-oils">' + oilNames + '</div>' +
'<div style="margin-top:8px"><div class="recipe-card-price">💰 ' + fmtCostWithRetail(r.ingredients) + '</div></div></div>';
}).join('') + '</div></div>';
grid.insertAdjacentHTML('afterend', relHtml);
}
}
}
function _goToNewRecipes() {
window._myRecipesCollapsed = false;
window._manageMyCollapsed = false;
_saveFoldStates();
showSection('manage');
renderManage().then(() => {
setTimeout(() => {
const firstItem = document.querySelector('#manageList .manage-item');
if (firstItem) firstItem.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 300);
});
}
function _resetSearchHome() {
_activeCategoryTag = null;
document.getElementById('searchInput').value = '';
const oldHdr = document.getElementById('categoryHeader'); if (oldHdr) oldHdr.remove();
const oldRel = document.getElementById('categoryRelated'); if (oldRel) oldRel.remove();
const oldFuzzy = document.getElementById('fuzzyResults'); if (oldFuzzy) oldFuzzy.remove();
const oldDiary = document.getElementById('diarySearchResults'); if (oldDiary) oldDiary.remove();
const ps = document.getElementById('personalSection'); if (ps) ps.style.display = '';
const pl = document.getElementById('publicLabel'); if (pl) pl.style.display = '';
showSection('search');
_showCategoryCarousel();
renderGrid(recipes);
renderPersonalSection();
}
function _exitCategoryView() {
_activeCategoryTag = null;
const oldHdr = document.getElementById('categoryHeader'); if (oldHdr) oldHdr.remove();
const oldRel = document.getElementById('categoryRelated'); if (oldRel) oldRel.remove();
const ps = document.getElementById('personalSection'); if (ps) ps.style.display = '';
const pl = document.getElementById('publicLabel'); if (pl) pl.style.display = '';
_showCategoryCarousel();
filterRecipes();
}
function _hideCategoryCarousel() {
const w = document.getElementById('categoryWrap');
const d = document.getElementById('categoryDots');
if (w) w.style.display = 'none';
if (d) d.style.display = 'none';
}
function _showCategoryCarousel() {
const w = document.getElementById('categoryWrap');
const d = document.getElementById('categoryDots');
if (w && _catCount > 0) w.style.display = '';
if (d && _catCount > 0) d.style.display = '';
}
// ── INVENTORY ──────────────────────────────────────────
let userInventory = [];
async function loadInventory() {
if (!currentUser.id) return;
try {
const res = await _apiFetch('/api/inventory');
userInventory = await res.json();
} catch(e) { userInventory = []; }
}
function renderInventoryOilPicker() {
const q = (document.getElementById('invSearchInput')?.value || '').trim().toLowerCase();
const picker = document.getElementById('invOilPicker');
const oils = Object.keys(OILS).filter(name => !q || name.toLowerCase().includes(q)).sort((a,b) => a.localeCompare(b,'zh'));
picker.innerHTML = oils.map(name => {
const has = userInventory.includes(name);
return `<div onclick="toggleInventory('${name.replace(/'/g, "\\'")}')" style="
padding:6px 12px;border-radius:20px;font-size:13px;cursor:pointer;
background:${has ? 'var(--sage)' : 'white'};color:${has ? 'white' : 'var(--text-mid)'};
border:1.5px solid ${has ? 'var(--sage)' : 'var(--border)'};transition:all 0.15s;
">${name}</div>`;
}).join('');
}
async function toggleInventory(name) {
try {
if (userInventory.includes(name)) {
await _apiFetch('/api/inventory/' + encodeURIComponent(name), {method:'DELETE'});
userInventory = userInventory.filter(n => n !== name);
} else {
await _apiFetch('/api/inventory', {method:'POST', body: JSON.stringify({oil_name: name})});
userInventory.push(name);
}
renderInventoryUI();
} catch(e) { _showToast('操作失败,请重试'); }
}
function renderInventoryUI() {
renderInventoryOilPicker();
const list = document.getElementById('invList');
document.getElementById('invCount').textContent = userInventory.length ? `${userInventory.length} 种)` : '';
if (!userInventory.length) {
list.innerHTML = '<span style="color:var(--text-light);font-size:13px">还没有添加精油,在上方搜索并点击添加</span>';
document.getElementById('invRecipes').innerHTML = '';
return;
}
list.innerHTML = userInventory.sort((a,b) => a.localeCompare(b,'zh')).map(name =>
`<div style="padding:6px 14px;border-radius:20px;font-size:13px;background:var(--sage);color:white;cursor:pointer;display:flex;align-items:center;gap:4px"
onclick="showRecipesForOil('${name.replace(/'/g, "\\'")}')">${name}
<span onclick="event.stopPropagation();toggleInventory('${name.replace(/'/g, "\\'")}')" style="opacity:0.7;cursor:pointer;margin-left:2px">×</span>
</div>`
).join('');
loadInventoryRecipes();
}
async function loadInventoryRecipes() {
const container = document.getElementById('invRecipes');
try {
const res = await _apiFetch('/api/inventory/recipes');
const data = await res.json();
if (!data.length) { container.innerHTML = '<div class="empty-state"><div class="empty-state-text">暂无匹配配方</div></div>'; return; }
container.innerHTML = data.slice(0, 50).map(r => {
const matchPct = Math.round(r.inventory_match / r.inventory_total * 100);
const missing = r.inventory_missing || [];
return `<div class="manage-item" style="cursor:pointer" onclick="showSection('search');document.getElementById('searchInput').value='${r.name.replace(/'/g,"\\'")}';filterRecipes()">
<div class="manage-item-left">
<div class="manage-item-name">${r.name}
<span style="font-size:12px;padding:2px 8px;border-radius:10px;margin-left:6px;
background:${matchPct===100?'var(--sage)':'var(--gold-light)'};color:${matchPct===100?'white':'var(--brown)'}">
${r.inventory_match}/${r.inventory_total} 匹配${matchPct===100?' ✓':''}
</span>
</div>
${missing.length ? '<div style="font-size:12px;color:var(--text-light);margin-top:4px">缺少:' + missing.join('、') + '</div>' : ''}
</div>
</div>`;
}).join('');
} catch(e) { container.innerHTML = '<div style="color:red">加载失败</div>'; }
}
function showRecipesForOil(name) {
showSection('search');
document.getElementById('searchInput').value = name;
filterRecipes();
}
// ── SEARCH LOGGING ─────────────────────────────────────
let _searchLogTimer = null;
let _recentSearchLogs = {}; // { query: timestamp } for dedup
function logSearchQuery(query, matchedCount) {
// Only called explicitly by search button / Enter, not on every keystroke
const q = query.trim();
if (!q) return;
// Dedup: skip if same query was logged in the last 5 minutes
const now = Date.now();
const key = q.toLowerCase() + '|' + matchedCount;
if (_recentSearchLogs[key] && (now - _recentSearchLogs[key]) < 300000) return;
_recentSearchLogs[key] = now;
_apiFetch('/api/search-log', {method:'POST', body: JSON.stringify({query: q, matched_count: matchedCount})}).catch(()=>{});
}
function confirmSearch() {
const q = document.getElementById('searchInput').value.trim();
filterRecipes();
if (q) {
const matched = document.querySelectorAll('#recipeGrid .recipe-card').length;
logSearchQuery(q, matched);
}
}
// ── NOTIFICATIONS ──────────────────────────────────────
let _notifUnread = 0;
async function loadNotifications() {
if (!currentUser.id) return;
try {
const res = await _apiFetch('/api/notifications');
const data = await res.json();
_notifUnread = data.filter(n => !n.is_read).length;
// Admin/senior_editor: show notification tab
const tab = document.getElementById('notifTab');
if (tab && (currentUser.role === 'admin' || currentUser.role === 'senior_editor')) {
tab.textContent = _notifUnread > 0 ? '🔔 通知 (' + _notifUnread + ')' : '🔔 通知';
tab.style.display = '';
}
// Update header badge for ALL users
const nameEl = document.getElementById('headerUserName');
if (nameEl) {
const badge = document.getElementById('notifBadge');
if (_notifUnread > 0) {
if (badge) badge.textContent = _notifUnread;
else {
const b = document.createElement('span');
b.id = 'notifBadge';
b.style.cssText = 'background:#c0392b;color:white;font-size:10px;padding:1px 5px;border-radius:8px;margin-left:4px';
b.textContent = _notifUnread;
nameEl.appendChild(b);
}
} else if (badge) {
badge.remove();
}
}
} catch(e) {}
}
async function renderNotifications() {
const list = document.getElementById('notifList');
try {
const res = await _apiFetch('/api/notifications');
const data = await res.json();
if (!data.length) { list.innerHTML = '<div class="empty-state"><div class="empty-state-text">暂无通知</div></div>'; return; }
const unread = data.filter(n => !n.is_read);
const read = data.filter(n => n.is_read);
const renderItem = n => {
const isRead = n.is_read;
const isBugTest = n.title && (n.title.includes('待测试') || n.title.includes('已修复,请测试'));
let buttons = '';
const isBugRelated = n.title && (n.title.includes('Bug') || n.title.includes('bug'));
const isBizApply = n.title && (n.title.includes('商业认证申请') || n.title.includes('商业认证未通过'));
const bugIdM = (n.body||'').match(/\[bug_id:(\d+)\]/);
const bugLink = bugIdM ? '<button class="btn btn-outline btn-sm" onclick="_apiFetch(\'/api/notifications/'+n.id+'/read\',{method:\'POST\',body:\'{}\'});_jumpToBug('+bugIdM[1]+')" style="font-size:11px;margin-left:4px">跳转 Bug</button>' : '';
const bizLink = isBizApply ? (currentUser.role === 'admin'
? '<button class="btn btn-outline btn-sm" onclick="_apiFetch(\'/api/notifications/'+n.id+'/read\',{method:\'POST\',body:\'{}\'});showSection(\'users\');setTimeout(loadNotifications,500)" style="font-size:11px;margin-left:4px">去审批</button>'
: '<button class="btn btn-outline btn-sm" onclick="_apiFetch(\'/api/notifications/'+n.id+'/read\',{method:\'POST\',body:\'{}\'});_prevShowSection2(\'mydiary\');showDiaryTab(\'account\');setTimeout(function(){var el=document.getElementById(\'businessApplyCard\');if(el)el.scrollIntoView({behavior:\'smooth\',block:\'center\'})},300);setTimeout(loadNotifications,500)" style="font-size:11px;margin-left:4px">再次申请</button>'
) : '';
if (!isRead) {
if (isBugTest) {
var bid = bugIdM ? bugIdM[1] : '0';
buttons = '<button class="btn btn-primary btn-sm" onclick="confirmBugFixedTab('+n.id+',\''+bid+'\')">✅ 已测试</button>';
} else {
buttons = '<button class="btn btn-outline btn-sm" onclick="markNotifRead('+n.id+',this)">已读</button>';
if (isBugRelated) buttons += bugLink;
if (isBizApply) buttons += bizLink;
}
} else {
buttons = '<button class="btn btn-outline btn-sm" onclick="markNotifUnreadTab('+n.id+')" style="font-size:10px;color:var(--text-light)">标为未读</button>';
if (isBugRelated && bugIdM) buttons += bugLink;
}
return `<div class="manage-item" style="padding:14px 18px;${isRead?'opacity:0.5;background:#fafafa':'border-left:3px solid var(--sage)'}">
<div class="manage-item-left">
<div class="manage-item-name">${n.title}</div>
<div style="font-size:13px;color:var(--text-mid);margin-top:4px">${(n.body || '').replace(/\n?\[bug_id:\d+\]/g,'')}</div>
<div style="font-size:11px;color:var(--text-light);margin-top:4px">${n.created_at}</div>
</div>
${buttons}
</div>`;
};
let html = unread.map(renderItem).join('');
if (unread.length > 0 && read.length > 0) {
html += '<div style="padding:8px 18px;font-size:12px;color:var(--text-light);border-top:2px solid var(--border)">── 已读 ──</div>';
}
html += read.map(renderItem).join('');
list.innerHTML = html;
} catch(e) { list.innerHTML = '<div style="color:red">加载失败</div>'; }
}
async function confirmBugFixedTab(notifId, bugIdStr) {
const bugId = bugIdStr && bugIdStr !== '0' ? bugIdStr : null;
const note = await _prompt('测试备注(通过/未通过/说明)');
if (note === null) return;
try {
if (bugId) {
await _apiFetch('/api/bug-reports/' + bugId, { method: 'PUT', body: JSON.stringify({ status: 3, note: note || '' }) });
} else {
const res = await _apiFetch('/api/bug-reports');
const bugs = await res.json();
const testingBugs = bugs.filter(b => b.is_resolved === 1);
if (testingBugs.length > 0) {
await _apiFetch('/api/bug-reports/' + testingBugs[0].id, { method: 'PUT', body: JSON.stringify({ status: 3, note: note || '' }) });
}
}
await _apiFetch('/api/notifications/' + notifId + '/read', { method: 'POST', body: JSON.stringify({ force: true }) });
_showToast('✅ 已提交测试反馈');
renderNotifications();
loadNotifications();
} catch(e) { _showToast('操作失败'); }
}
async function markNotifUnreadTab(id) {
await _apiFetch('/api/notifications/' + id + '/unread', { method: 'POST', body: '{}' });
renderNotifications();
loadNotifications();
}
async function markNotifRead(id, btn) {
await _apiFetch('/api/notifications/' + id + '/read', {method:'POST', body:'{}'});
renderNotifications();
loadNotifications();
}
async function _checkBugTestPending() {
try {
const res = await _apiFetch('/api/notifications');
const data = await res.json();
return data.some(n => !n.is_read && n.title && n.title.includes('待测试'));
} catch(e) { return false; }
}
async function markAllNotifRead() {
if (await _checkBugTestPending()) { alert('还有待测试的 Bug 通知,请先完成测试再全部已读,感谢配合!'); return; }
await _apiFetch('/api/notifications/read-all', {method:'POST', body:'{}'});
renderNotifications();
loadNotifications();
}
async function markAllNotifReadPopup() {
if (await _checkBugTestPending()) { alert('还有待测试的 Bug 通知,请先完成测试再全部已读,感谢配合!'); return; }
await _apiFetch('/api/notifications/read-all', {method:'POST', body:'{}'});
loadNotifications();
const overlay = document.querySelector('[style*="z-index:300"], [style*="z-index: 300"]');
if (overlay) { overlay.remove(); showMyNotifications(); }
}
async function triggerWeeklyReview() {
try {
const res = await _apiFetch('/api/cron/weekly-review', {method:'POST', body:'{}'});
const data = await res.json();
alert(`周报已生成:${data.pending_recipes} 条待审核配方,${data.unmatched_queries} 个未匹配搜索`);
renderNotifications();
loadNotifications();
} catch(e) { alert('生成失败'); }
}
// ── PATCH: add search logging to filterRecipes ─────────
function _matchesQuery(r, terms) {
return terms.some(t =>
r.name.toLowerCase().includes(t) ||
r.ingredients.some(i => i.oil.toLowerCase().includes(t) || (oilEn(i.oil)||'').toLowerCase().includes(t)) ||
(r.tags || []).some(tag => tag.toLowerCase().includes(t))
);
}
const _origFilterRecipes = filterRecipes;
filterRecipes = function() {
const q = document.getElementById('searchInput').value.trim().toLowerCase();
const grid = document.getElementById('recipeGrid');
// Clean up previous fuzzy results
const oldFuzzy = document.getElementById('fuzzyResults'); if (oldFuzzy) oldFuzzy.remove();
if (_favFilterActive && userFavorites.length > 0) {
let filtered = recipes.filter(r => r._id && userFavorites.includes(r._id));
if (q) { const terms = getExpandedQuery(q); filtered = filtered.filter(r => _matchesQuery(r, terms)); }
renderGrid(filtered);
} else if (_favFilterActive) {
renderGrid([]);
} else if (q) {
const terms = getExpandedQuery(q);
if (_activeCategoryTag) {
// Search within category first, then show all matches below
const catRecipes = recipes.filter(r => (r.tags || []).includes(_activeCategoryTag));
const catMatch = catRecipes.filter(r => _matchesQuery(r, terms));
const catMatchIds = new Set(catMatch.map(r => recipes.indexOf(r)));
const allMatch = recipes.filter(r => _matchesQuery(r, terms) && !catMatchIds.has(recipes.indexOf(r)));
if (catMatch.length > 0) {
renderGrid(catMatch);
} else {
grid.innerHTML = '<div style="text-align:center;padding:20px 0;grid-column:1/-1"><div style="font-size:28px;margin-bottom:8px">🔍</div><div style="font-size:15px;color:var(--text-mid)">在「' + _activeCategoryTag + '」中没有找到「' + q + '」</div></div>';
}
if (allMatch.length > 0) {
const allHtml = '<div id="fuzzyResults" style="padding:12px 0;margin-top:16px;border-top:2px solid var(--border)">' +
'<div style="font-size:14px;color:var(--text-mid);margin-bottom:12px">📚 全部配方库结果</div>' +
'<div class="recipe-grid">' + allMatch.map(r => {
const realIdx = recipes.indexOf(r);
const oilNames = r.ingredients.filter(i=>i.oil!=='椰子油').map(i=>i.oil).join('、');
return '<div class="recipe-card" onclick="selectRecipe('+realIdx+')">' +
'<div class="recipe-card-name">' + r.name + '</div>' +
'<div class="recipe-card-oils">' + oilNames + '</div>' +
'<div style="margin-top:8px"><div class="recipe-card-price">💰 ' + fmtCostWithRetail(r.ingredients) + '</div></div></div>';
}).join('') + '</div>' +
'<div style="text-align:center;padding:16px 0"><div style="font-size:13px;color:var(--text-light);margin-bottom:8px">没有找到想要的?</div>' +
'<button class="btn btn-outline btn-sm" onclick="reportMissingRecipe(\'' + q.replace(/'/g,"\\'") + '\')">📢 告诉我们,尽快添加</button></div>' +
'</div>';
grid.insertAdjacentHTML('afterend', allHtml);
} else if (catMatch.length === 0) {
// No matches anywhere — show report
grid.innerHTML += '<div style="text-align:center;padding:16px 0;grid-column:1/-1"><div style="font-size:13px;color:var(--text-light);margin-bottom:8px">没有找到想要的?</div>' +
'<button class="btn btn-outline btn-sm" onclick="reportMissingRecipe(\'' + q.replace(/'/g,"\\'") + '\')">📢 告诉我们,尽快添加</button></div>';
}
} else {
// Normal search
const exact = recipes.filter(r => _matchesQuery(r, [q]));
const fuzzy = terms.length > 1 ? recipes.filter(r => !_matchesQuery(r, [q]) && _matchesQuery(r, terms)) : [];
if (exact.length > 0 && fuzzy.length > 0) {
renderGrid(exact);
const fuzzyHtml = '<div style="padding:12px 0;margin-top:16px;border-top:2px solid var(--border)">' +
'<div style="font-size:14px;color:var(--text-mid);margin-bottom:12px">💡 相关配方</div>' +
'<div class="recipe-grid">' + fuzzy.map(r => {
const realIdx = recipes.indexOf(r);
const oilNames = r.ingredients.filter(i=>i.oil!=='椰子油').map(i=>i.oil).join('、');
return '<div class="recipe-card" onclick="selectRecipe('+realIdx+')">' +
'<div class="recipe-card-name">' + r.name + '</div>' +
'<div class="recipe-card-oils">' + oilNames + '</div>' +
'<div style="margin-top:8px"><div class="recipe-card-price">💰 ' + fmtCostWithRetail(r.ingredients) + '</div></div></div>';
}).join('') + '</div></div>';
grid.insertAdjacentHTML('afterend', '<div id="fuzzyResults">' + fuzzyHtml + '</div>');
} else if (exact.length > 0) {
renderGrid(exact);
} else if (fuzzy.length > 0) {
renderGrid(fuzzy);
} else {
renderGrid([]);
}
}
} else {
renderGrid(recipes);
}
// Clean up old fuzzy/diary results
if (!q) { const old = document.getElementById('fuzzyResults'); if (old) old.remove(); }
const oldDiary = document.getElementById('diarySearchResults'); if (oldDiary) oldDiary.remove();
const oldCatRel = document.getElementById('categoryRelated'); if (oldCatRel) oldCatRel.remove();
if (!_activeCategoryTag) {
const oldCatHdr = document.getElementById('categoryHeader'); if (oldCatHdr) { oldCatHdr.remove(); const ps=document.getElementById('personalSection'); if(ps) ps.style.display=''; const pl=document.getElementById('publicLabel'); if(pl) pl.style.display=''; }
}
// Show matching diary recipes when searching
if (q && currentUser.id && userDiary.length > 0) {
const terms = getExpandedQuery(q);
const diaryMatches = userDiary.filter(d => {
const name = d.name.toLowerCase();
const oils = (d.ingredients || []).map(i => i.oil.toLowerCase()).join(' ');
const haystack = name + ' ' + oils + ' ' + (d.note || '').toLowerCase();
return terms.some(t => haystack.includes(t));
});
if (diaryMatches.length > 0) {
const diaryHtml = '<div id="diarySearchResults" style="margin-top:16px;border-top:2px solid var(--border);padding-top:12px">' +
'<div style="font-size:14px;color:var(--text-mid);margin-bottom:12px">📔 我的配方 (' + diaryMatches.length + ')</div>' +
'<div class="recipe-grid">' + diaryMatches.map(d => {
const ings = (d.ingredients||[]).filter(i=>i.oil!=='椰子油').map(i=>i.oil).join('、');
return '<div class="recipe-card" onclick="viewDiaryAsCard('+d.id+')">' +
'<div class="recipe-card-name">' + d.name + '</div>' +
'<div class="recipe-card-oils">' + ings + '</div>' +
'<div style="margin-top:8px"><div class="recipe-card-price">💰 ' + fmtCostWithRetail(d.ingredients || []) + '</div></div></div>';
}).join('') + '</div></div>';
// Insert after fuzzy results if present, or after grid
const fuzzyEl = document.getElementById('fuzzyResults');
(fuzzyEl || grid).insertAdjacentHTML('afterend', diaryHtml);
}
}
if (q || _favFilterActive) _hideCategoryCarousel(); else _showCategoryCarousel();
renderPersonalSection();
};
// ── PATCH: showSection to handle new sections ──────────
const _prevShowSection2 = showSection;
showSection = function(name) {
_prevShowSection2(name);
if (name === 'inventory') { renderInventoryUI(); }
if (name === 'notif') { renderNotifications(); }
if (name === 'bugs') { renderBugList(); }
if (name === 'mydiary') { showDiaryTab('brand'); }
if (name === 'projects') { renderProjectList(); }
// Remember current section for page refresh
try { sessionStorage.setItem('oil_current_section', name); } catch(e) {}
};
// ── PATCH: _applyPermissions to handle logged-in ───────
const _origApplyPerms = _applyPermissions;
_applyPermissions = function() {
_origApplyPerms();
// Business features
document.querySelectorAll('.requires-business').forEach(el => {
el.style.display = (currentUser.role === 'admin' || currentUser.role === 'senior_editor' || currentUser.business_verified) ? '' : 'none';
});
};
function _requireLogin() {
if (currentUser.id) return true;
showLoginModal();
return false;
}
// ── LOGIN / USER MENU / IMPERSONATE ─────────────────────
function showLoginModal() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div style="background:white;border-radius:16px;padding:28px;max-width:320px;width:100%;box-shadow:0 12px 40px rgba(0,0,0,0.2)" onclick="event.stopPropagation()">
<div style="text-align:center;margin-bottom:20px">
<div style="font-size:32px;margin-bottom:8px">🌿</div>
<div style="font-family:'Noto Serif SC',serif;font-size:18px;font-weight:600" id="authTitle">登录</div>
</div>
<div style="margin-bottom:12px">
<input type="text" id="loginUsername" class="form-control" placeholder="用户名" style="width:100%;padding:10px 14px;font-size:14px">
</div>
<div id="regDisplayNameRow" style="margin-bottom:12px;display:none">
<input type="text" id="regDisplayName" class="form-control" placeholder="昵称(可选)" style="width:100%;padding:10px 14px;font-size:14px">
</div>
<div style="margin-bottom:16px">
<input type="password" id="loginPassword" class="form-control" placeholder="密码" style="width:100%;padding:10px 14px;font-size:14px" onkeydown="if(event.key==='Enter'){document.getElementById('regDisplayNameRow').style.display==='none'?doLogin():doRegister()}">
</div>
<button class="btn btn-primary" id="authSubmitBtn" onclick="doLogin()" style="width:100%;padding:10px;font-size:15px">登录</button>
<div style="text-align:center;margin-top:12px">
<span id="authToggle" style="font-size:13px;color:var(--sage-dark);cursor:pointer" onclick="toggleAuthMode()">没有账号?注册</span>
</div>
<div id="loginError" style="margin-top:10px;text-align:center;font-size:13px;color:#c0392b"></div>
</div>`;
document.body.appendChild(overlay);
setTimeout(() => document.getElementById('loginUsername')?.focus(), 100);
}
let _authIsRegister = false;
function toggleAuthMode() {
_authIsRegister = !_authIsRegister;
document.getElementById('authTitle').textContent = _authIsRegister ? '注册' : '登录';
document.getElementById('regDisplayNameRow').style.display = _authIsRegister ? '' : 'none';
document.getElementById('authSubmitBtn').textContent = _authIsRegister ? '注册' : '登录';
document.getElementById('authSubmitBtn').onclick = _authIsRegister ? doRegister : doLogin;
document.getElementById('authToggle').textContent = _authIsRegister ? '已有账号?登录' : '没有账号?注册';
document.getElementById('loginError').textContent = '';
}
async function doRegister() {
const username = document.getElementById('loginUsername')?.value.trim();
const password = document.getElementById('loginPassword')?.value.trim();
const displayName = document.getElementById('regDisplayName')?.value.trim();
if (!username || username.length < 2) { document.getElementById('loginError').textContent = '用户名至少2个字符'; return; }
if (!password || password.length < 4) { document.getElementById('loginError').textContent = '密码至少4位'; return; }
try {
const res = await fetch('/api/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ username, password, display_name: displayName }) });
if (!res.ok) {
const err = await res.json();
document.getElementById('loginError').textContent = err.detail || '注册失败';
return;
}
const data = await res.json();
localStorage.setItem('oil_auth_token', data.token);
_authToken = data.token;
document.querySelector('[style*="z-index:300"]')?.remove();
location.reload();
} catch(e) { document.getElementById('loginError').textContent = '网络错误'; }
}
async function doLogin() {
const username = document.getElementById('loginUsername')?.value.trim();
const password = document.getElementById('loginPassword')?.value.trim();
if (!username || !password) { document.getElementById('loginError').textContent = '请输入用户名和密码'; return; }
try {
const res = await fetch('/api/login', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ username, password }) });
if (!res.ok) {
const err = await res.json();
document.getElementById('loginError').textContent = err.detail || '登录失败';
return;
}
const data = await res.json();
localStorage.setItem('oil_auth_token', data.token);
_authToken = data.token;
document.querySelector('[style*="z-index:300"]')?.remove();
location.reload();
} catch(e) { document.getElementById('loginError').textContent = '网络错误'; }
}
function showUserMenu() {
const hasBackup = !!localStorage.getItem('oil_admin_token_backup');
if (currentUser.username === 'anonymous' && !hasBackup) {
// Show mini menu with login + bug report
const existing = document.getElementById('userMenuPopup');
if (existing) { existing.remove(); return; }
const el = document.getElementById('headerUserName');
const rect = el.getBoundingClientRect();
const popup = document.createElement('div');
popup.id = 'userMenuPopup';
popup.style.cssText = 'position:fixed;top:' + (rect.bottom + 8) + 'px;right:16px;background:white;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.15);z-index:100;min-width:180px;padding:8px 0';
popup.innerHTML =
'<div onclick="document.getElementById(\'userMenuPopup\')?.remove();showLoginModal()" style="padding:10px 16px;cursor:pointer;font-size:14px">🔑 登录 / 注册</div>' +
'<div onclick="document.getElementById(\'userMenuPopup\')?.remove();showBugReport()" style="padding:10px 16px;cursor:pointer;font-size:14px;border-top:1px solid #eee">🐛 反馈问题</div>';
document.body.appendChild(popup);
setTimeout(() => { document.addEventListener('click', function _c(e) { if (!popup.contains(e.target) && e.target !== el) { popup.remove(); document.removeEventListener('click', _c); } }); }, 10);
return;
}
const existing = document.getElementById('userMenuPopup');
if (existing) { existing.remove(); return; }
const el = document.getElementById('headerUserName');
const rect = el.getBoundingClientRect();
const isImpersonating = !!localStorage.getItem('oil_admin_token_backup');
let menuHtml = '<div style="padding:8px 0">';
menuHtml += '<div style="padding:8px 16px;font-size:13px;color:var(--text-light);border-bottom:1px solid #eee">' + (currentUser.display_name || currentUser.username) + '</div>';
// Bug #95: Impersonate list in collapsible section
if (currentUser.role === 'admin' || isImpersonating) {
menuHtml += '<div onclick="var body=this.nextElementSibling;var arrow=this.querySelector(\'._roleArrow\');if(body.style.display===\'none\'){body.style.display=\'\';arrow.textContent=\'▾\'}else{body.style.display=\'none\';arrow.textContent=\'▸\'}" style="padding:10px 16px;cursor:pointer;font-size:14px;border-top:1px solid #eee;margin-top:4px;display:flex;justify-content:space-between;align-items:center;user-select:none" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'\'">' +
'<span>🔄 切换角色</span><span class="_roleArrow" style="font-size:11px;color:var(--text-light)">▸</span></div>';
menuHtml += '<div style="display:none">';
// Admin (self) option when impersonating
if (isImpersonating) {
menuHtml += '<div onclick="doLogout()" style="padding:8px 16px;cursor:pointer;font-size:13px;display:flex;justify-content:space-between;align-items:center;font-weight:600;color:var(--sage-dark)" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'\'">' +
'<span>👤 管理员(我)</span><span style="font-size:11px;color:var(--text-light)">管理员</span></div>';
}
menuHtml += '<div id="impersonateList" style="max-height:200px;overflow-y:auto"></div>';
menuHtml += '</div>'; // end collapsible body
}
menuHtml += '<div onclick="document.getElementById(\'userMenuPopup\')?.remove();showBrandPage()" style="padding:10px 16px;cursor:pointer;font-size:14px;border-top:1px solid #eee;margin-top:4px">🏷 我的品牌 / 二维码</div>';
menuHtml += '<div onclick="document.getElementById(\'userMenuPopup\')?.remove();_prevShowSection2(\'mydiary\');showDiaryTab(\'account\')" style="padding:10px 16px;cursor:pointer;font-size:14px;border-top:1px solid #eee">🔑 修改密码</div>';
menuHtml += '<div onclick="document.getElementById(\'userMenuPopup\')?.remove();showMyNotifications()" style="padding:10px 16px;cursor:pointer;font-size:14px;border-top:1px solid #eee">🔔 我的通知' + (_notifUnread > 0 ? ' <span style="background:#c0392b;color:white;font-size:10px;padding:1px 6px;border-radius:8px">' + _notifUnread + '</span>' : '') + '</div>';
if (!currentUser.business_verified && currentUser.role !== 'admin') {
menuHtml += '<div onclick="document.getElementById(\'userMenuPopup\')?.remove();_prevShowSection2(\'mydiary\');showDiaryTab(\'account\');setTimeout(function(){var el=document.getElementById(\'businessApplyCard\');if(el)el.scrollIntoView({behavior:\'smooth\',block:\'center\'})},300)" style="padding:10px 16px;cursor:pointer;font-size:14px;border-top:1px solid #eee">🏢 商业客户认证</div>';
}
menuHtml += '<div onclick="document.getElementById(\'userMenuPopup\')?.remove();showBugReport()" style="padding:10px 16px;cursor:pointer;font-size:14px;border-top:1px solid #eee">🐛 反馈问题</div>';
menuHtml += '<div onclick="doLogout()" style="padding:10px 16px;cursor:pointer;font-size:14px;color:#c0392b;border-top:1px solid #eee">退出登录</div>';
menuHtml += '</div>';
const popup = document.createElement('div');
popup.id = 'userMenuPopup';
popup.style.cssText = 'position:fixed;top:' + (rect.bottom + 8) + 'px;right:16px;background:white;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.15);z-index:100;min-width:200px';
popup.innerHTML = menuHtml;
document.body.appendChild(popup);
// Load impersonate list
if (currentUser.role === 'admin' || isImpersonating) {
// Use admin token to fetch user list
const adminToken = localStorage.getItem('oil_admin_token_backup') || _authToken;
fetch('/api/users', { headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + adminToken } })
.then(r => r.json()).then(users => {
const list = document.getElementById('impersonateList');
if (!list) return;
const roleNames = {admin:'管理员',senior_editor:'高级编辑者',editor:'编辑者',viewer:'普通用户'};
const testUsers = users.filter(u => u.username.startsWith('test_'));
// Add anonymous visitor option
list.innerHTML = '<div onclick="doImpersonateAnonymous()" style="padding:8px 16px;cursor:pointer;font-size:13px;display:flex;justify-content:space-between;align-items:center" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'\'">' +
'<span>👻 匿名访客</span><span style="font-size:11px;color:var(--text-light)">未登录</span></div>' +
testUsers.map(u =>
'<div style="padding:6px 16px;display:flex;justify-content:space-between;align-items:center;gap:8px">' +
'<span style="font-size:13px;flex:1">' + (u.display_name || u.username) + '</span>' +
'<span style="font-size:10px;color:var(--text-light)">' + (roleNames[u.role] || u.role) + '</span>' +
'<button onclick="doImpersonate(' + u.id + ')" style="background:none;border:1px solid var(--border);border-radius:6px;padding:2px 8px;cursor:pointer;font-size:11px;color:var(--text-mid)" title="直接切换">切换</button>' +
'<button onclick="doSimulateLogin(\'' + u.username + '\')" style="background:none;border:1px solid var(--sage);border-radius:6px;padding:2px 8px;cursor:pointer;font-size:11px;color:var(--sage-dark)" title="模拟完整登录流程">登录</button>' +
'</div>'
).join('');
}).catch(() => {});
}
// Close on click outside
setTimeout(() => {
document.addEventListener('click', function _close(e) {
if (!popup.contains(e.target) && e.target !== el) {
popup.remove();
document.removeEventListener('click', _close);
}
});
}, 10);
}
function _cleanSwitchTo(token) {
// Save admin token if not already saved
const adminToken = localStorage.getItem('oil_admin_token_backup') || _authToken;
// Clear ALL local state for a true clean switch
const keysToKeep = ['oil_admin_token_backup'];
const backup = adminToken;
// Clear everything
localStorage.clear();
// Restore admin backup
localStorage.setItem('oil_admin_token_backup', backup);
// Set new token (or nothing for anonymous)
if (token) localStorage.setItem('oil_auth_token', token);
location.reload();
}
async function doImpersonate(userId) {
try {
const adminToken = localStorage.getItem('oil_admin_token_backup') || _authToken;
const res = await fetch('/api/impersonate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + adminToken },
body: JSON.stringify({ user_id: userId })
});
const data = await res.json();
_cleanSwitchTo(data.token);
} catch(e) { alert('切换失败'); }
}
function doSimulateLogin(username) {
// Save admin token, clear everything, then show login modal pre-filled
const adminToken = localStorage.getItem('oil_admin_token_backup') || _authToken;
localStorage.clear();
localStorage.setItem('oil_admin_token_backup', adminToken);
_authToken = '';
// Close menu
document.getElementById('userMenuPopup')?.remove();
// Show login modal with username pre-filled
showLoginModal();
setTimeout(() => {
const input = document.getElementById('loginUsername');
if (input) { input.value = username; input.readOnly = true; input.style.background = '#f5f5f5'; }
document.getElementById('loginPassword')?.focus();
}, 200);
}
function doImpersonateAnonymous() {
// Switch to fully anonymous (no token at all)
_cleanSwitchTo(null);
}
function doLogout() {
// If impersonating, switch back to admin (clean)
const backup = localStorage.getItem('oil_admin_token_backup');
if (backup) {
localStorage.clear();
localStorage.setItem('oil_auth_token', backup);
location.reload();
return;
}
localStorage.clear();
_authToken = '';
location.reload();
}
function showPasswordDialog() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div style="background:white;border-radius:16px;padding:24px;max-width:320px;width:100%" onclick="event.stopPropagation()">
<div style="font-size:16px;font-weight:600;margin-bottom:4px">🔑 修改密码</div>
<div style="font-size:12px;color:var(--text-light);margin-bottom:16px">用户名:<b>${currentUser.username}</b></div>
<input type="password" id="dialogPassword" class="form-control" placeholder="新密码至少4位" style="width:100%;padding:10px 14px;font-size:14px;margin-bottom:12px">
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" onclick="saveDialogPassword()" style="flex:1">保存</button>
<button class="btn btn-outline btn-sm" onclick="this.closest('[style*=fixed]').remove()">取消</button>
</div>
<div id="dialogPwResult" style="margin-top:8px;text-align:center"></div>
</div>`;
document.body.appendChild(overlay);
setTimeout(() => document.getElementById('dialogPassword')?.focus(), 100);
}
async function saveDialogPassword() {
const pw = document.getElementById('dialogPassword')?.value.trim();
if (!pw || pw.length < 4) { document.getElementById('dialogPwResult').innerHTML = '<span style="color:#c0392b;font-size:13px">密码至少4位</span>'; return; }
try {
const res = await _apiFetch('/api/me/password', { method: 'PUT', body: JSON.stringify({ password: pw }) });
if (res.ok) {
document.getElementById('dialogPwResult').innerHTML = '<span style="color:var(--sage-dark);font-size:13px">✅ 密码已设置</span>';
setTimeout(() => document.querySelector('[style*="z-index: 300"], [style*="z-index:300"]')?.remove(), 1200);
} else {
document.getElementById('dialogPwResult').innerHTML = '<span style="color:#c0392b;font-size:13px">设置失败</span>';
}
} catch(e) { document.getElementById('dialogPwResult').innerHTML = '<span style="color:#c0392b;font-size:13px">网络错误</span>'; }
}
async function saveMyProfile(field) {
const resultEl = document.getElementById('accountResult');
const body = {};
if (field === 'display_name') {
const val = document.getElementById('myDisplayName')?.value.trim();
if (!val) { resultEl.innerHTML = '<span style="color:#c0392b">昵称不能为空</span>'; return; }
body.display_name = val;
} else if (field === 'username') {
const val = document.getElementById('myUsername')?.value.trim();
if (!val || val.length < 2) { resultEl.innerHTML = '<span style="color:#c0392b">用户名至少2个字符</span>'; return; }
body.username = val;
} else if (field === 'password') {
const oldPw = document.getElementById('myOldPassword')?.value.trim();
const newPw = document.getElementById('myNewPassword')?.value.trim();
const newPw2 = document.getElementById('myNewPassword2')?.value.trim();
if (!oldPw) { resultEl.innerHTML = '<span style="color:#c0392b">请输入当前密码</span>'; return; }
if (!newPw || newPw.length < 4) { resultEl.innerHTML = '<span style="color:#c0392b">新密码至少4位</span>'; return; }
if (newPw !== newPw2) { resultEl.innerHTML = '<span style="color:#c0392b">两次新密码不一致</span>'; return; }
body.old_password = oldPw;
body.password = newPw;
}
try {
const res = await _apiFetch('/api/me', { method: 'PUT', body: JSON.stringify(body) });
if (res.ok) {
if (!window._writeQueued) {
resultEl.innerHTML = '<span style="color:var(--sage-dark)">✅ 已保存</span>';
setTimeout(() => { if (resultEl) resultEl.innerHTML = ''; }, 2000);
}
if (field === 'password') { document.getElementById('myOldPassword').value = ''; document.getElementById('myNewPassword').value = ''; document.getElementById('myNewPassword2').value = ''; }
if (field === 'display_name') currentUser.display_name = body.display_name;
if (field === 'username') currentUser.username = body.username;
} else {
const err = await res.json().catch(() => ({}));
resultEl.innerHTML = '<span style="color:#c0392b">' + (err.detail || '保存失败') + '</span>';
}
} catch(e) { resultEl.innerHTML = '<span style="color:#c0392b">网络错误</span>'; }
}
// Legacy alias
async function setMyPassword() { return saveMyProfile('password'); }
// ── PERSONAL SECTION ON SEARCH PAGE ─────────────────────
function _togglePersonalFold(which) {
if (which === 'my') window._myRecipesCollapsed = !window._myRecipesCollapsed;
else window._myFavsCollapsed = !window._myFavsCollapsed;
_saveFoldStates();
renderPersonalSection();
}
// Persist and restore ALL fold states
function _saveFoldStates() {
try { localStorage.setItem('oil_fold_state', JSON.stringify({
my: !!window._myRecipesCollapsed, fav: !!window._myFavsCollapsed,
manMy: !!window._manageMyCollapsed, manPub: !!window._managePubCollapsed,
tagFilter: !!window._tagFilterVisible
})); } catch(e) {}
}
try {
const fs = JSON.parse(localStorage.getItem('oil_fold_state') || '{}');
window._myRecipesCollapsed = !!fs.my;
window._myFavsCollapsed = !!fs.fav;
window._manageMyCollapsed = !!fs.manMy;
window._managePubCollapsed = !!fs.manPub;
window._tagFilterVisible = !!fs.tagFilter;
} catch(e) {}
function renderPersonalSection() {
const q = document.getElementById('searchInput')?.value.trim().toLowerCase() || '';
const section = document.getElementById('personalSection');
if (!section) return;
if (q) { section.style.display = 'none'; return; }
section.style.display = '';
const myDiv = document.getElementById('myRecipesPreview');
const favDiv = document.getElementById('myFavsPreview');
// Anonymous: show login prompts
if (!currentUser.id) {
myDiv.innerHTML = '<div onclick="showLoginModal()" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;cursor:pointer">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-mid)">📔 我的配方</span>' +
'<span style="font-size:12px;color:var(--sage-dark)">登录查看 →</span>' +
'</div>';
favDiv.innerHTML = '<div onclick="showLoginModal()" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;cursor:pointer">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-mid)">★ 我的收藏</span>' +
'<span style="font-size:12px;color:var(--sage-dark)">登录查看 →</span>' +
'</div>';
const pubLabel = document.getElementById('publicLabel');
if (pubLabel) pubLabel.textContent = '📚 全部配方库';
return;
}
// Logged in: show actual data
// My recipes (diary) — collapsible (header always visible)
if (userDiary.length > 0) {
const c = window._myRecipesCollapsed;
const headerHtml = '<div onclick="_togglePersonalFold(\'my\')" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:' + (c ? '0' : '8px') + ';cursor:pointer;user-select:none">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-mid)">📔 我的配方 (' + userDiary.length + ') <span style="font-size:11px;color:var(--text-light)">' + (c ? '▸' : '▾') + '</span></span>' +
'</div>';
const bodyHtml = '<div class="fold-body"' + (c ? ' style="display:none"' : '') + '>' +
'<div class="recipe-grid">' + userDiary.slice(0, 4).map(d => {
const ings = (d.ingredients||[]).filter(i=>i.oil!=='椰子油').map(i=>i.oil).join('、');
return '<div class="recipe-card" onclick="viewDiaryAsCard('+d.id+')">' +
'<div class="recipe-card-name">' + d.name + '</div>' +
'<div class="recipe-card-oils">' + ings + '</div>' +
'<div style="margin-top:8px"><div class="recipe-card-price" style="margin:0">💰 ' + fmtCostWithRetail(d.ingredients || []) + '</div></div></div>';
}).join('') + '</div></div>';
myDiv.innerHTML = headerHtml + bodyHtml;
} else {
myDiv.innerHTML = '';
}
// Favorites — collapsible
const favRecipes = recipes.filter(r => r._id && userFavorites.includes(r._id));
if (favRecipes.length > 0) {
const c = window._myFavsCollapsed;
favDiv.innerHTML = '<div onclick="_togglePersonalFold(\'fav\')" style="display:flex;align-items:center;margin-bottom:' + (c ? '0' : '8px') + ';cursor:pointer;user-select:none">' +
'<span style="font-size:14px;font-weight:600;color:var(--text-mid)">★ 我的收藏 (' + favRecipes.length + ') <span style="font-size:11px;color:var(--text-light)">' + (c ? '▸' : '▾') + '</span></span>' +
'</div>' +
'<div class="fold-body"' + (c ? ' style="display:none"' : '') + '>' +
'<div class="recipe-grid">' + favRecipes.slice(0, 4).map(r => {
const realIdx = recipes.indexOf(r);
const oilNames = r.ingredients.filter(i=>i.oil!=='椰子油').map(i=>i.oil).join('、');
return '<div class="recipe-card" onclick="selectRecipe('+realIdx+')">' +
'<div class="recipe-card-name">' + r.name + '</div>' +
'<div class="recipe-card-oils">' + oilNames + '</div>' +
'<div style="margin-top:6px"><div class="recipe-card-price">💰 ' + fmtCostWithRetail(r.ingredients) + '</div></div></div>';
}).join('') + '</div></div>';
} else {
favDiv.innerHTML = '';
}
// Public label
const pubLabel = document.getElementById('publicLabel');
if (pubLabel) pubLabel.textContent = currentUser.id ? '📚 全部配方库' : '';
}
// ── SYMPTOM SEARCH ──────────────────────────────────────
async function _showSymptomSearch(grid) {
const q = document.getElementById('searchInput').value.trim();
if (!q) { grid.innerHTML = ''; return; }
// Try fuzzy match via API
try {
const res = await _apiFetch('/api/symptom-search', { method: 'POST', body: JSON.stringify({ query: q }) });
const data = await res.json();
if (data.exact && data.exact.length > 0) {
// Exact matches found - render normally
const mapped = data.exact.map(r => {
const local = recipes.find(lr => lr._id === r.id);
return local || r;
}).filter(Boolean);
if (mapped.length) { renderGrid(mapped); return; }
}
// Build result outside grid to avoid grid layout issues
const catCtx = _activeCategoryTag ? '在「' + _activeCategoryTag + '」中' : '';
let html = '';
// Top: centered no-match message (full width)
html += '<div style="text-align:center;padding:20px 0 12px;grid-column:1/-1">' +
'<div style="font-size:28px;margin-bottom:8px">🔍</div>' +
'<div style="font-size:15px;color:var(--text-mid)">' + catCtx + '没有找到「' + q + '」的配方</div>' +
'</div>';
if (data.related && data.related.length > 0) {
const mapped = data.related.map(r => {
const local = recipes.find(lr => lr._id === r.id);
return local || r;
}).filter(Boolean);
html += '<div style="font-size:14px;color:var(--text-mid);margin-bottom:4px;text-align:center;grid-column:1/-1">以下是一些相关配方:</div>';
mapped.forEach((r, idx) => {
const realIdx = recipes.indexOf(r);
if (realIdx < 0) return;
const oilNames = r.ingredients.filter(i=>i.oil!=='椰子油').map(i => i.oil).join('、');
html += '<div class="recipe-card" onclick="selectRecipe(' + realIdx + ')">' +
'<div class="recipe-card-name">' + r.name + '</div>' +
'<div class="recipe-card-oils">' + oilNames + '</div>' +
'<div style="margin-top:8px"><div class="recipe-card-price">💰 ' + fmtCostWithRetail(r.ingredients) + '</div></div></div>';
});
}
// Bottom: report button (full width)
html += '<div style="text-align:center;padding:20px 0;grid-column:1/-1">' +
'<div style="font-size:13px;color:var(--text-light);margin-bottom:8px">没有找到想要的?</div>' +
'<button class="btn btn-outline btn-sm" onclick="reportMissingRecipe(\'' + q.replace(/'/g,"\\'") + '\')">📢 告诉我们,尽快添加</button>' +
'</div>';
grid.innerHTML = html;
} catch(e) {
grid.innerHTML = '<div style="text-align:center;padding:20px;grid-column:1/-1"><div style="font-size:28px;margin-bottom:8px">🔍</div><div style="font-size:15px;color:var(--text-mid)">没有找到相关配方</div></div>';
}
}
async function reportMissingRecipe(query) {
try {
const ctx = _activeCategoryTag ? '(在' + _activeCategoryTag + '中搜索)' : '';
await _apiFetch('/api/symptom-search', { method: 'POST', body: JSON.stringify({ query: query + ctx, report_missing: true }) });
_showToast('✅ 已上报,我们会尽快添加相关配方');
} catch(e) { _showToast('上报失败'); }
}
async function showMyNotifications() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:16px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div style="background:white;border-radius:16px;padding:24px;max-width:420px;width:100%;max-height:80vh;overflow-y:auto" onclick="event.stopPropagation()"><div style="text-align:center;color:var(--text-light)">加载中...</div></div>';
document.body.appendChild(overlay);
try {
const res = await _apiFetch('/api/notifications');
const data = await res.json();
const card = overlay.querySelector('div > div') || overlay.querySelector('div');
const container = overlay.querySelector('[style*="max-width"]');
const hasUnread = data.some(n => !n.is_read);
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">' +
'<span style="font-size:16px;font-weight:600">🔔 我的通知</span>' +
'<div style="display:flex;gap:8px;align-items:center">' +
(hasUnread ? '<button onclick="markAllNotifReadPopup()" style="background:none;border:1px solid var(--border);border-radius:8px;padding:3px 10px;font-size:11px;cursor:pointer;color:var(--text-mid)">全部已读</button>' : '') +
'<button onclick="this.closest(\'[style*=fixed]\').remove()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--text-light)">✕</button>' +
'</div></div>';
if (!data.length) {
html += '<div style="text-align:center;padding:20px;color:var(--text-light)">暂无通知</div>';
} else {
const unread = data.filter(n => !n.is_read);
const read = data.filter(n => n.is_read);
const renderPopupItem = n => {
const isRead = n.is_read;
const isBugTest = n.title && n.title.includes('待测试');
const isBugRelated = n.title && (n.title.includes('Bug') || n.title.includes('bug'));
const isBizApply = n.title && (n.title.includes('商业认证申请') || n.title.includes('商业认证未通过'));
const popupBugIdM = (n.body||'').match(/\[bug_id:(\d+)\]/);
const popupBugLink = (isBugRelated && popupBugIdM) ? '<button class="btn btn-outline btn-sm" onclick="_apiFetch(\'/api/notifications/'+n.id+'/read\',{method:\'POST\',body:\'{}\'});this.closest(\'[style*=fixed]\').remove();_jumpToBug('+popupBugIdM[1]+');loadNotifications()" style="font-size:10px">跳转</button>' : '';
const popupBizLink = isBizApply ? (currentUser.role === 'admin'
? '<button class="btn btn-outline btn-sm" onclick="_apiFetch(\'/api/notifications/'+n.id+'/read\',{method:\'POST\',body:\'{}\'});this.closest(\'[style*=fixed]\').remove();showSection(\'users\');setTimeout(loadNotifications,500)" style="font-size:10px">去审批</button>'
: '<button class="btn btn-outline btn-sm" onclick="_apiFetch(\'/api/notifications/'+n.id+'/read\',{method:\'POST\',body:\'{}\'});this.closest(\'[style*=fixed]\').remove();_prevShowSection2(\'mydiary\');showDiaryTab(\'account\');setTimeout(function(){var el=document.getElementById(\'businessApplyCard\');if(el)el.scrollIntoView({behavior:\'smooth\',block:\'center\'})},300);setTimeout(loadNotifications,500)" style="font-size:10px">再次申请</button>'
) : '';
let buttons = '';
if (!isRead) {
if (isBugTest) {
var embeddedBugId = popupBugIdM ? popupBugIdM[1] : '0';
buttons = '<button class="btn btn-primary btn-sm" onclick="confirmBugFixedInline(' + n.id + ',this,\'' + embeddedBugId + '\')" style="font-size:11px">✅ 已测试</button>';
} else {
buttons = '<button class="btn btn-outline btn-sm" onclick="markNotifReadInline(' + n.id + ',this)" style="font-size:11px">已读</button>';
if (isBugRelated) buttons += popupBugLink;
if (isBizApply) buttons += popupBizLink;
}
} else {
buttons = '<button class="btn btn-outline btn-sm" onclick="markNotifUnreadInline(' + n.id + ',this)" style="font-size:10px;color:var(--text-light)">标为未读</button>';
if (isBugRelated && popupBugIdM) buttons += popupBugLink;
}
return '<div style="padding:10px 0;border-bottom:1px solid #f0f0f0;' + (isRead ? 'opacity:0.45' : '') + '">' +
'<div style="font-size:14px;font-weight:' + (isRead ? '400' : '600') + ';color:var(--text-dark)">' + n.title + '</div>' +
(n.body ? '<div style="font-size:13px;color:var(--text-mid);margin-top:4px;white-space:pre-line">' + (n.body||'').replace(/\n?\[bug_id:\d+\]/g,'') + '</div>' : '') +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">' +
'<span style="font-size:11px;color:var(--text-light)">' + (n.created_at || '') + '</span>' +
'<div style="display:flex;gap:6px">' + buttons + '</div>' +
'</div></div>';
};
html += unread.map(renderPopupItem).join('');
if (unread.length > 0 && read.length > 0) {
html += '<div style="padding:10px 0;font-size:12px;color:var(--text-light);text-align:center;border-bottom:1px solid #f0f0f0">── 已读 ──</div>';
}
html += read.map(renderPopupItem).join('');
}
container.innerHTML = html;
} catch(e) {
overlay.querySelector('[style*="max-width"]').innerHTML = '<div style="color:red;text-align:center">加载失败</div>';
}
}
async function markNotifReadInline(id, btn) {
await _apiFetch('/api/notifications/' + id + '/read', { method: 'POST', body: '{}' });
const overlay = btn.closest('[style*="z-index:300"], [style*="z-index: 300"]');
if (overlay) { overlay.remove(); showMyNotifications(); }
loadNotifications();
}
async function markNotifUnreadInline(id, btn) {
await _apiFetch('/api/notifications/' + id + '/unread', { method: 'POST', body: '{}' });
const overlay = btn.closest('[style*="z-index:300"], [style*="z-index: 300"]');
if (overlay) { overlay.remove(); showMyNotifications(); }
loadNotifications();
}
async function confirmBugFixedInline(notifId, btn, bugIdStr) {
const bugId = bugIdStr && bugIdStr !== '0' ? bugIdStr : null;
const note = await _prompt('测试备注(通过/未通过/说明)');
if (note === null) return;
try {
if (bugId) {
await _apiFetch('/api/bug-reports/' + bugId, { method: 'PUT', body: JSON.stringify({ status: 3, note: note || '' }) });
} else {
// Fallback: find any testing bug assigned to me
const res = await _apiFetch('/api/bug-reports');
const bugs = await res.json();
const testingBugs = bugs.filter(b => b.is_resolved === 1);
if (testingBugs.length > 0) {
await _apiFetch('/api/bug-reports/' + testingBugs[0].id, { method: 'PUT', body: JSON.stringify({ status: 3, note: note || '' }) });
}
}
await _apiFetch('/api/notifications/' + notifId + '/read', { method: 'POST', body: JSON.stringify({ force: true }) });
_showToast('✅ 已提交测试反馈,已通知管理员');
const overlay = btn.closest('[style*="z-index:300"], [style*="z-index: 300"]');
if (overlay) { overlay.remove(); showMyNotifications(); }
loadNotifications();
} catch(e) { _showToast('操作失败'); }
}
function testerConfirm(bugId) {
// Show note input then submit status 3
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div style="background:white;border-radius:16px;padding:24px;max-width:360px;width:100%" onclick="event.stopPropagation()">' +
'<div style="font-size:15px;font-weight:600;margin-bottom:12px">✅ 提交测试结果</div>' +
'<textarea id="testerNote" class="form-control" rows="3" placeholder="测试结果(通过/未通过/具体说明…)" style="width:100%;font-size:13px;margin-bottom:12px"></textarea>' +
'<div style="display:flex;gap:8px">' +
'<button class="btn btn-primary btn-sm" onclick="_submitTesterConfirm(' + bugId + ')" style="flex:1">提交</button>' +
'<button class="btn btn-outline btn-sm" onclick="this.closest(\'[style*=fixed]\').remove()">取消</button>' +
'</div></div>';
document.body.appendChild(overlay);
setTimeout(() => document.getElementById('testerNote')?.focus(), 100);
}
async function _submitTesterConfirm(bugId) {
const note = document.getElementById('testerNote')?.value.trim() || '';
try {
await _apiFetch('/api/bug-reports/' + bugId, { method: 'PUT', body: JSON.stringify({ status: 3, note }) });
document.querySelector('[style*="z-index: 300"], [style*="z-index:300"]')?.remove();
_showToast('✅ 已提交测试反馈');
renderBugList();
} catch(e) { _showToast('提交失败'); }
}
function showBrandPage() {
// Show mydiary section without triggering the default 'recipes' tab
_prevShowSection2('mydiary');
showDiaryTab('brand');
}
// ── BUG REPORT ──────────────────────────────────────────
function showBugReport() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div style="background:white;border-radius:16px;padding:24px;max-width:360px;width:100%" onclick="event.stopPropagation()">
<div style="font-size:16px;font-weight:600;margin-bottom:12px">🐛 反馈问题</div>
<textarea id="bugContent" class="form-control" rows="4" placeholder="描述你遇到的问题或建议…" style="width:100%;font-size:14px;margin-bottom:12px"></textarea>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" onclick="submitBugReport()" style="flex:1">提交</button>
<button class="btn btn-outline btn-sm" onclick="this.closest('[style*=fixed]').remove()">取消</button>
</div>
</div>`;
document.body.appendChild(overlay);
}
async function submitBugReport() {
const content = document.getElementById('bugContent')?.value.trim();
if (!content) { _showToast('请输入内容'); return; }
try {
const res = await _apiFetch('/api/bug-report', { method: 'POST', body: JSON.stringify({ content }) });
if (res.ok) {
if (!window._writeQueued) _showToast('✅ 感谢反馈,已通知管理员');
document.querySelector('[style*="z-index: 300"], [style*="z-index:300"]')?.remove();
} else { _showToast('提交失败'); }
} catch(e) { _showToast('网络错误'); }
}
// ── BUG TRACKER ─────────────────────────────────────────
function _fmtBugTime(t) {
if (!t) return '';
// Show only MM-DD HH:MM
const m = t.match(/(\d{4})-(\d{2})-(\d{2})\s*(\d{2}:\d{2})/);
return m ? m[2]+'-'+m[3]+' '+m[4] : t;
}
const _priColors = ['#c62828','#e65100','#2e7d32','#1565c0'];
const _priNames = ['紧急','重要','一般','长期待办'];
function _renderBugRow(b) {
const statusNames = {0:'待处理', 1:'待测试', 2:'已修复', 3:'已测试'};
const statusColors = {0:'#e65100', 1:'var(--gold)', 2:'var(--sage-dark)', 3:'#1565c0'};
const status = b.is_resolved || 0;
const pri = b.priority ?? 2;
const isAdmin = currentUser.role === 'admin';
const comments = b.comments || [];
const lastComment = comments.slice(-1)[0];
const lastNote = lastComment && lastComment.action !== '创建' ? (lastComment.display_name || lastComment.username || '') + ' ' + lastComment.action + (lastComment.content ? '' + lastComment.content : '') : '';
// Build action buttons
let btns = '';
if (isAdmin) {
if (status === 0 || status === 1) {
btns = '<button class="btn btn-gold btn-sm" onclick="event.stopPropagation();chooseTester(' + b.id + ',' + (b.user_id||'null') + ')" style="font-size:10px;padding:2px 8px">测试</button>' +
'<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();setBugStatus(' + b.id + ',2)" style="font-size:10px;padding:2px 8px">修复</button>';
} else if (status === 3) {
btns = '<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();setBugStatus(' + b.id + ',2)" style="font-size:10px;padding:2px 8px">确认</button>' +
'<button class="btn btn-gold btn-sm" onclick="event.stopPropagation();chooseTester(' + b.id + ',' + (b.user_id||'null') + ')" style="font-size:10px;padding:2px 8px">重测</button>';
}
} else if (status === 1) {
btns = '<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();testerConfirm(' + b.id + ')" style="font-size:10px;padding:2px 8px">已测试</button>';
}
return '<div class="bug-row" data-bug-id="' + b.id + '" style="background:white;border-radius:8px;border:1px solid var(--border);margin-bottom:4px;overflow:hidden;border-left:3px solid ' + statusColors[status] + '">' +
// Compact header — click to expand
'<div onclick="var d=this.nextElementSibling;d.style.display=d.style.display===\'none\'?\'block\':\'none\'" style="padding:8px 10px;cursor:pointer;display:flex;align-items:center;gap:8px">' +
// Priority dot
(isAdmin ? '<span onclick="event.stopPropagation();cycleBugPriority('+b.id+','+pri+',this)" style="width:9px;height:9px;border-radius:50%;background:'+_priColors[pri]+';flex-shrink:0;cursor:pointer" title="'+_priNames[pri]+'"></span>'
: '<span style="width:9px;height:9px;border-radius:50%;background:'+_priColors[pri]+';flex-shrink:0" title="'+_priNames[pri]+'"></span>') +
// User feedback badge + content
'<div style="flex:1;min-width:0;font-size:13px;color:var(--text-dark);line-height:1.5">' +
(isAdmin && b.user_id && b.user_id !== currentUser.id ? '<span style="font-size:9px;background:#e8f5e9;color:#2e7d32;padding:1px 5px;border-radius:4px;margin-right:4px;vertical-align:middle">' + (b.display_name || b.username || '用户') + ' 反馈</span>' : '') +
b.content + '</div>' +
// Last comment (right side)
(lastNote ? '<div style="flex-shrink:0;max-width:40%;font-size:10px;color:var(--text-light);line-height:1.4">' + lastNote + '</div>' : '') +
// Status badge + assignee
'<span style="font-size:10px;padding:1px 6px;border-radius:6px;white-space:nowrap;flex-shrink:0;background:' + statusColors[status] + ';color:white">' + statusNames[status] + (b.assigned_name && status === 1 ? ' → ' + b.assigned_name : '') + '</span>' +
'</div>' +
// Expandable detail
'<div style="display:none;padding:0 10px 6px;border-top:1px solid var(--border)">' +
// Meta line
'<div style="font-size:10px;color:var(--text-light);padding:4px 0">' + (b.display_name || b.username || '') + ' · ' + _fmtBugTime(b.created_at) + '</div>' +
// Timeline
_renderBugTimeline(b) +
// Actions
'<div style="display:flex;gap:4px;justify-content:flex-end;padding:4px 0">' +
btns +
'<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();addBugComment(' + b.id + ')" style="font-size:10px;padding:2px 8px">💬</button>' +
(isAdmin ? '<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();deleteBug(' + b.id + ')" style="font-size:10px;padding:2px 8px;color:#c0392b">🗑</button>' : '') +
'</div>' +
'</div>' +
'</div>';
}
function _renderBugTimeline(b) {
const comments = b.comments || [];
const logEntries = comments.filter((c, i) => i > 0 || c.action !== '创建');
if (!logEntries.length) return '';
let html = '<div style="padding:2px 0 4px">';
logEntries.forEach(c => {
const cWho = c.display_name || c.username || '系统';
const actionColor = c.action.includes('已测试') ? '#1565c0' : c.action.includes('待测试') ? 'var(--gold)' : c.action.includes('已修复') ? 'var(--sage-dark)' : 'var(--text-mid)';
html += '<div style="display:flex;gap:6px;padding:3px 0;font-size:11px;line-height:1.4">' +
'<span style="width:6px;height:6px;border-radius:50%;background:' + actionColor + ';margin-top:4px;flex-shrink:0"></span>' +
'<div style="flex:1">' +
'<span style="font-weight:600;color:var(--text-dark)">' + cWho + '</span> ' +
'<span style="color:' + actionColor + '">' + c.action + '</span>' +
(c.content ? ' <span style="color:var(--text-mid)">' + c.content + '</span>' : '') +
' <span style="color:var(--text-light);font-size:9px">' + _fmtBugTime(c.created_at) + '</span>' +
'</div></div>';
});
html += '</div>';
return html;
}
async function _jumpToBug(bugId) {
showSection('bugs');
await renderBugList();
setTimeout(() => {
const row = document.querySelector('.bug-row[data-bug-id="' + bugId + '"]');
if (!row) return;
// Expand the detail panel
const detail = row.children[1]; // second child is the expandable detail
if (detail && detail.style.display === 'none') detail.style.display = 'block';
// Also expand parent priority group if collapsed
const parentBody = row.closest('div[style*="display:none"]');
if (parentBody) parentBody.style.display = '';
// Scroll and highlight
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.style.outline = '2px solid var(--sage)';
row.style.background = 'var(--sage-mist)';
setTimeout(() => { row.style.outline = ''; row.style.background = ''; }, 3000);
}, 300);
}
async function renderBugList() {
try {
const res = await _apiFetch('/api/bug-reports');
const bugs = await res.json();
const active = bugs.filter(b => b.is_resolved !== 2);
// Update bug tab badge
const bugTab = document.getElementById('bugNavTab');
if (bugTab) bugTab.textContent = active.length > 0 ? '🐛 Bug (' + active.length + ')' : '🐛 Bug';
const resolved = bugs.filter(b => b.is_resolved === 2);
const activeList = document.getElementById('bugListActive');
if (active.length) {
// Group by priority
const groups = [
{ pri: 0, name: '紧急', color: _priColors[0], items: active.filter(b => (b.priority ?? 2) === 0) },
{ pri: 1, name: '重要', color: _priColors[1], items: active.filter(b => (b.priority ?? 2) === 1) },
{ pri: 2, name: '一般', color: _priColors[2], items: active.filter(b => (b.priority ?? 2) === 2) },
{ pri: 3, name: '长期待办', color: _priColors[3], items: active.filter(b => (b.priority ?? 2) === 3) },
].filter(g => g.items.length > 0);
activeList.innerHTML = groups.map(g =>
'<div class="bug-pri-group" style="margin-bottom:10px">' +
'<div onclick="var c=this.nextElementSibling;c.style.display=c.style.display===\'none\'?\'block\':\'none\';this.querySelector(\'span:last-child\').textContent=c.style.display===\'none\'?\'▸\':\'▾\'" style="display:flex;align-items:center;gap:6px;padding:4px 0;cursor:pointer;user-select:none">' +
'<span style="width:8px;height:8px;border-radius:50%;background:' + g.color + '"></span>' +
'<span style="font-size:12px;font-weight:600;color:var(--text-mid)">' + g.name + ' (' + g.items.length + ')</span>' +
'<span style="font-size:11px;color:var(--text-light)">▾</span>' +
'</div>' +
'<div>' + g.items.map(b => _renderBugRow(b)).join('') + '</div>' +
'</div>'
).join('');
} else {
activeList.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-light)">🎉 没有待处理的 Bug</div>';
}
const resolvedList = document.getElementById('bugListResolved');
if (resolved.length) {
resolvedList.innerHTML =
'<div onclick="var c=this.nextElementSibling;c.style.display=c.style.display===\'none\'?\'block\':\'none\';this.querySelector(\'span:last-child\').textContent=c.style.display===\'none\'?\'▸\':\'▾\'" style="display:flex;align-items:center;gap:6px;padding:6px 0;cursor:pointer;user-select:none">' +
'<span style="width:8px;height:8px;border-radius:50%;background:var(--sage-dark)"></span>' +
'<span style="font-size:12px;font-weight:600;color:var(--text-light)">已修复 (' + resolved.length + ')</span>' +
'<span style="font-size:11px;color:var(--text-light)">▸</span>' +
'</div>' +
'<div style="display:none">' + resolved.map(b => _renderBugRow(b)).join('') + '</div>';
} else {
resolvedList.innerHTML = '';
}
} catch(e) { document.getElementById('bugListActive').innerHTML = '<div style="color:red">加载失败</div>'; }
}
let _priDebounce = null;
async function cycleBugPriority(bugId, current, dot) {
const next = (current + 1) % 4;
dot.style.background = _priColors[next];
dot.title = _priNames[next] + '(点击切换)';
dot.setAttribute('onclick', 'event.stopPropagation();cycleBugPriority('+bugId+','+next+',this)');
_apiFetch('/api/bug-reports/' + bugId, { method: 'PUT', body: JSON.stringify({ priority: next }) }).catch(() => _showToast('更新失败'));
// Debounce: re-sort 2s after last click
if (_priDebounce) clearTimeout(_priDebounce);
_priDebounce = setTimeout(() => { _priDebounce = null; renderBugList(); }, 2000);
}
async function deleteBug(bugId) {
if (!await _confirm('确认删除这条 Bug')) return;
try {
await _apiFetch('/api/bug-reports/' + bugId, { method: 'DELETE' });
_showToast('已删除');
renderBugList();
} catch(e) { _showToast('删除失败'); }
}
function addBugComment(bugId) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div style="background:white;border-radius:16px;padding:24px;max-width:360px;width:100%" onclick="event.stopPropagation()">' +
'<div style="font-size:15px;font-weight:600;margin-bottom:12px">💬 添加备注</div>' +
'<textarea id="bugCommentInput" class="form-control" rows="3" placeholder="写点备注…" style="width:100%;font-size:14px;margin-bottom:12px"></textarea>' +
'<div style="display:flex;gap:8px">' +
'<button class="btn btn-primary btn-sm" onclick="submitBugComment(' + bugId + ')" style="flex:1">提交</button>' +
'<button class="btn btn-outline btn-sm" onclick="this.closest(\'[style*=fixed]\').remove()">取消</button>' +
'</div></div>';
document.body.appendChild(overlay);
setTimeout(() => document.getElementById('bugCommentInput')?.focus(), 100);
}
async function submitBugComment(bugId) {
const content = document.getElementById('bugCommentInput')?.value.trim();
if (!content) { _showToast('请输入内容'); return; }
try {
const res = await _apiFetch('/api/bug-reports/' + bugId + '/comment', { method: 'POST', body: JSON.stringify({ content }) });
if (res.ok) {
document.querySelector('[style*="z-index: 300"], [style*="z-index:300"]')?.remove();
_showToast('✅ 已添加备注');
// Save expanded bug IDs before re-render
const expandedIds = new Set();
document.querySelectorAll('.bug-row').forEach(row => {
const detail = row.querySelector('div:nth-child(2)');
if (detail && detail.style.display !== 'none') {
expandedIds.add(row.getAttribute('data-bug-id'));
}
});
await renderBugList();
// Restore expanded state and scroll to the commented bug
expandedIds.forEach(id => {
const row = document.querySelector('.bug-row[data-bug-id="' + id + '"]');
if (row) {
const detail = row.querySelector('div:nth-child(2)');
if (detail) detail.style.display = 'block';
}
});
const targetRow = document.querySelector('.bug-row[data-bug-id="' + bugId + '"]');
if (targetRow) {
const detail = targetRow.querySelector('div:nth-child(2)');
if (detail) detail.style.display = 'block';
targetRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else { _showToast('添加失败'); }
} catch(e) { _showToast('添加失败'); }
}
async function chooseTester(bugId, reporterUserId) {
try {
const res = await _apiFetch('/api/users');
const users = await res.json();
const others = users.filter(u => u.role !== 'admin');
const roleNames = {admin:'管理员',senior_editor:'高级编辑者',editor:'编辑者',viewer:'普通用户'};
// Find Baolian's id
const baolian = users.find(u => u.display_name === 'Baolian' || u.username === '宝莲');
const baolianId = baolian ? baolian.id : null;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div style="background:white;border-radius:16px;padding:24px;max-width:320px;width:100%" onclick="event.stopPropagation()">' +
'<div style="font-size:15px;font-weight:600;margin-bottom:12px">发送待测试通知</div>' +
'<textarea id="testNote" class="form-control" rows="2" placeholder="备注(可选)" style="width:100%;font-size:13px;margin-bottom:12px"></textarea>' +
'<div style="font-size:13px;color:var(--text-mid);margin-bottom:8px">发送给:</div>' +
others.map(u => {
const isReporter = u.id === reporterUserId;
const isBaolian = u.id === baolianId;
const highlight = isReporter || isBaolian;
const badges = [];
if (isReporter) badges.push('<span style="font-size:10px;background:var(--gold-light);color:var(--brown);padding:1px 6px;border-radius:8px">反馈人</span>');
if (isBaolian && !isReporter) badges.push('<span style="font-size:10px;background:var(--sage-mist);color:var(--sage-dark);padding:1px 6px;border-radius:8px">推荐</span>');
return '<div onclick="sendTestNotify(' + bugId + ',' + u.id + ',document.getElementById(\'testNote\').value);this.closest(\'[style*=fixed]\').remove()" style="padding:8px 14px;cursor:pointer;border-radius:8px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center' + (highlight ? ';background:var(--sage-mist)' : '') + '" onmouseover="this.style.background=\'var(--sage-mist)\'" onmouseout="this.style.background=\'' + (highlight ? 'var(--sage-mist)' : '') + '\'">' +
'<span>' + (u.display_name || u.username) + ' ' + badges.join(' ') + '</span>' +
'<span style="font-size:11px;color:var(--text-light)">' + (roleNames[u.role]||u.role) + '</span>' +
'</div>';
}).join('') +
'</div>';
document.body.appendChild(overlay);
} catch(e) { setBugStatus(bugId, 1); }
}
async function sendTestNotify(bugId, userId, note) {
try {
await _apiFetch('/api/bug-reports/' + bugId, { method: 'PUT', body: JSON.stringify({ status: 1, notify_user_id: userId, note: note || '' }) });
_showToast('已标为待测试,已通知');
renderBugList();
} catch(e) { _showToast('操作失败'); }
}
async function setBugStatus(bugId, status) {
if (status === 2) {
// Ask for optional note when marking as resolved
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div style="background:white;border-radius:16px;padding:24px;max-width:360px;width:100%" onclick="event.stopPropagation()">' +
'<div style="font-size:15px;font-weight:600;margin-bottom:12px">✅ 标记为已修复</div>' +
'<textarea id="resolveNote" class="form-control" rows="2" placeholder="修复备注(可选)" style="width:100%;font-size:13px;margin-bottom:12px"></textarea>' +
'<div style="display:flex;gap:8px">' +
'<button class="btn btn-primary btn-sm" onclick="_doSetBugStatus(' + bugId + ',2,document.getElementById(\'resolveNote\').value);this.closest(\'[style*=fixed]\').remove()" style="flex:1">确认</button>' +
'<button class="btn btn-outline btn-sm" onclick="this.closest(\'[style*=fixed]\').remove()">取消</button>' +
'</div></div>';
document.body.appendChild(overlay);
return;
}
_doSetBugStatus(bugId, status, '');
}
async function _doSetBugStatus(bugId, status, note) {
// Optimistic: immediately move the bug row
const row = document.querySelector('.bug-row[data-bug-id="' + bugId + '"]');
if (row && status === 2) {
row.style.transition = 'opacity 0.3s';
row.style.opacity = '0';
setTimeout(() => row.remove(), 300);
}
_showToast(status === 1 ? '已标为待测试' : '✅ 已修复');
// Fire API in background, refresh list after
_apiFetch('/api/bug-reports/' + bugId, { method: 'PUT', body: JSON.stringify({ status, note: note || '' }) })
.then(() => renderBugList())
.catch(() => { _showToast('操作失败'); renderBugList(); });
}
async function confirmBugFixed(notifId) {
// This is called from admin notification tab — for "已测试" notifications
// Admin marks as read; actual status change happens on bug page
await _apiFetch('/api/notifications/' + notifId + '/read', { method: 'POST', body: '{}' });
_showToast('已读,请到 Bug 页面确认修复');
renderNotifications();
loadNotifications();
}
function addNewBug() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:20px';
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div style="background:white;border-radius:16px;padding:28px;max-width:400px;width:100%;box-shadow:0 12px 40px rgba(0,0,0,0.2)" onclick="event.stopPropagation()">
<div style="text-align:center;margin-bottom:20px">
<div style="font-size:28px;margin-bottom:8px">🐛</div>
<div style="font-family:'Noto Serif SC',serif;font-size:17px;font-weight:600;color:var(--text-dark)">记录一个问题或待办</div>
<div style="font-size:12px;color:var(--text-light);margin-top:4px">描述清楚问题或需要改进的地方</div>
</div>
<textarea id="newBugContent" class="form-control" rows="4" placeholder="例如:手机版配方卡片显示不全…(回车直接添加)" style="width:100%;font-size:14px;margin-bottom:12px" onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!event.isComposing){event.preventDefault();submitNewBug()}"></textarea>
<div style="display:flex;gap:12px;align-items:center;margin-bottom:16px">
<span style="font-size:13px;color:var(--text-mid)">优先级:</span>
<label class="pri-opt" data-pri="0" style="cursor:pointer;font-size:13px"><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#c62828;vertical-align:middle;margin-right:3px;border:2px solid transparent"></span>紧急</label>
<label class="pri-opt" data-pri="1" style="cursor:pointer;font-size:13px"><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#e65100;vertical-align:middle;margin-right:3px;border:2px solid transparent"></span>重要</label>
<label class="pri-opt" data-pri="2" style="cursor:pointer;font-size:13px"><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#2e7d32;vertical-align:middle;margin-right:3px;border:2px solid transparent"></span>一般</label>
<label class="pri-opt" data-pri="3" style="cursor:pointer;font-size:13px"><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#1565c0;vertical-align:middle;margin-right:3px;border:2px solid transparent"></span>长期待办</label>
<input type="hidden" id="newBugPriority" value="2">
</div>
<div style="display:flex;gap:10px;justify-content:center">
<button class="btn btn-primary" onclick="submitNewBug()" style="flex:1;max-width:160px">添加</button>
<button class="btn btn-outline" onclick="this.closest('[style*=fixed]').remove()" style="flex:1;max-width:120px">取消</button>
</div>
</div>`;
document.body.appendChild(overlay);
// Wire up priority selector
overlay.querySelectorAll('.pri-opt').forEach(label => {
if (label.dataset.pri === '2') label.querySelector('span').style.border = '2px solid var(--text-dark)';
label.onclick = () => {
document.getElementById('newBugPriority').value = label.dataset.pri;
overlay.querySelectorAll('.pri-opt span').forEach(s => s.style.border = '2px solid transparent');
label.querySelector('span').style.border = '2px solid var(--text-dark)';
};
});
setTimeout(() => document.getElementById('newBugContent')?.focus(), 100);
}
async function submitNewBug() {
const content = document.getElementById('newBugContent')?.value.trim();
if (!content) { _showToast('请输入内容'); return; }
const priVal = document.getElementById('newBugPriority')?.value;
const priority = priVal !== undefined && priVal !== '' ? parseInt(priVal) : 2;
try {
const res = await _apiFetch('/api/bug-report', { method: 'POST', body: JSON.stringify({ content, priority }) });
if (res.ok) {
document.querySelector('[style*="z-index: 300"], [style*="z-index:300"]')?.remove();
if (!window._writeQueued) _showToast('✅ 已添加');
renderBugList();
} else { _showToast('添加失败'); }
} catch(e) { _showToast('添加失败'); }
}
// ── UNSAVED DATA PROTECTION ─────────────────────────────
window.addEventListener('beforeunload', e => {
const hasDirty = recipes.some(r => r._dirty);
if (hasDirty) {
e.preventDefault();
e.returnValue = '你有未保存的修改,确定要离开吗?';
}
});
// ── ONLINE/OFFLINE DETECTION ────────────────────────────
window.addEventListener('offline', () => _showToast('⚠️ 网络已断开,操作可能无法保存'));
window.addEventListener('online', () => _showToast('✅ 网络已恢复'));
// ── SWIPE TO SWITCH TABS ────────────────────────────────
(function() {
let startX = 0, startY = 0;
function getVisibleTabs() {
return Array.from(document.querySelectorAll('.nav-tab')).filter(t => t.offsetParent !== null && t.style.display !== 'none');
}
function getCurrentTabIndex(tabs) {
return tabs.findIndex(t => t.classList.contains('active'));
}
// Listen on document body to catch swipes on any page
let _swipeStartTarget = null;
document.addEventListener('touchstart', e => {
// Don't intercept if overlay is open
if (document.getElementById('detailOverlay')?.style.display === 'block') return;
_swipeStartTarget = e.target;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener('touchend', e => {
if (document.getElementById('detailOverlay')?.style.display === 'block') return;
// Don't switch tabs if swipe started inside category carousel
if (_swipeStartTarget && _swipeStartTarget.closest('.cat-wrap')) return;
const dx = startX - e.changedTouches[0].clientX;
const dy = startY - e.changedTouches[0].clientY;
if (Math.abs(dx) < 80 || Math.abs(dy) > Math.abs(dx)) return;
const tabs = getVisibleTabs();
const idx = getCurrentTabIndex(tabs);
if (idx < 0) return;
if (dx > 0 && idx < tabs.length - 1) {
tabs[idx + 1].click();
window.scrollTo(0, 0);
} else if (dx < 0 && idx > 0) {
tabs[idx - 1].click();
window.scrollTo(0, 0);
}
}, { passive: true });
})();
// ── ASYNC INIT ──────────────────────────────────────────
_initToken();
(async function() {
try {
await _apiLoadMe();
_applyPermissions();
await _apiLoadOils();
await _apiLoadRecipes();
await _apiLoadTags();
filterRecipes();
renderCategories();
await loadDiary();
loadFavorites().then(() => { filterRecipes(); renderPersonalSection(); });
loadBrand();
loadProjects();
loadInventory();
loadNotifications();
// Restore last viewed section on refresh
try {
const saved = sessionStorage.getItem('oil_current_section');
if (saved && document.getElementById('section-' + saved)) {
showSection(saved);
}
} catch(e) {}
// Poll for new notifications every 30s, and bug list if on bug page
function _pollBugs() {
if (currentUser.role !== 'admin') return;
_apiFetch('/api/bug-reports').then(r => r.json()).then(bugs => {
const active = bugs.filter(b => b.is_resolved !== 2);
const bugTab = document.getElementById('bugNavTab');
if (bugTab) bugTab.textContent = active.length > 0 ? '🐛 Bug (' + active.length + ')' : '🐛 Bug';
const bugSection = document.getElementById('section-bugs');
// Don't re-render bug list while viewing (it collapses expanded items)
// Bug tab count still updates via _pollBugs
}).catch(() => {});
}
_pollBugs();
setInterval(() => {
if (document.visibilityState !== 'visible') return;
// Refresh user permissions
_apiFetch('/api/me').then(r => r.json()).then(me => {
const changed = me.role !== currentUser.role || me.business_verified !== currentUser.business_verified;
Object.assign(currentUser, me);
if (changed) _applyPermissions();
}).catch(() => {});
loadNotifications();
_pollBugs();
_checkVersion();
_flushPendingWrites();
// Refresh account page if viewing it
const accPanel = document.getElementById('diaryAccountPanel');
if (accPanel && accPanel.style.display !== 'none') {
_apiFetch('/api/me').then(r => r.json()).then(me => {
currentUser.business_verified = me.business_verified;
renderAccountUI();
}).catch(() => {});
}
// Refresh user management page if viewing it
const usersSection = document.getElementById('section-users');
if (usersSection && usersSection.classList.contains('active')) {
_usersCache = null;
renderUsers();
}
}, 15000);
} catch(e) {
console.error('Init error:', e);
document.querySelector('.main').innerHTML = '<div style="padding:40px;color:red;text-align:center">加载出错: ' + e.message + '<br><pre>' + e.stack + '</pre></div>';
}
})();
</script>
</body>
</html>