8442 lines
477 KiB
HTML
8442 lines
477 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<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="支持多个配方,空行分隔: 头疗,椒样薄荷5,生姜3,迷迭香3 安睡,薰衣草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="扫码申请成为优惠顾客 我的精油小屋" 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="例: 长高 芳香调理8,永久花10,檀香10,乳香15 助眠 薰衣草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,'<') + '</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,'<') + '</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,'<') + '</div>' +
|
||
'<input id="_dlgInput" type="text" value="' + (defaultVal || '').replace(/"/g,'"') + '" 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, '"') + '" 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="粘贴配方文本,支持多行多配方: 长高,芳香调理8,永久花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, '"') + '" 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,'"') + '" 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,'"') + '" 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,'"') + '" 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>
|