Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E

- Replace single-file 8441-line HTML with Vue 3 SPA
- Pinia stores: auth, oils, recipes, diary, ui
- Composables: useApi, useDialog, useSmartPaste, useOilTranslation
- 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc.
- 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc.
- 14 Cypress E2E test specs (113 tests), all passing
- Multi-stage Dockerfile (Node build + Python runtime)
- Demo video generation scripts (TTS + subtitles + screen recording)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 18:35:00 +00:00
parent 0368e85abe
commit ee8ec23dc7
62 changed files with 15035 additions and 8448 deletions

125
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,125 @@
<template>
<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 v-if="auth.isAdmin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top">v2.2.0</span>
</span>
<span
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"
@click="toggleUserMenu"
>
<template v-if="auth.isLoggedIn">
👤 {{ auth.user.display_name || auth.user.username }}
</template>
<template v-else>
<span style="background:rgba(255,255,255,0.2);padding:4px 12px;border-radius:12px">登录</span>
</template>
</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>
<!-- User Menu Popup -->
<UserMenu v-if="showUserMenu" @close="showUserMenu = false" />
<!-- Nav tabs -->
<div class="nav-tabs">
<div class="nav-tab" :class="{ active: ui.currentSection === 'search' }" @click="goSection('search')">🔍 配方查询</div>
<div class="nav-tab" :class="{ active: ui.currentSection === 'manage' }" @click="requireLogin('manage')">📋 管理配方</div>
<div class="nav-tab" :class="{ active: ui.currentSection === 'inventory' }" @click="requireLogin('inventory')">📦 个人库存</div>
<div class="nav-tab" :class="{ active: ui.currentSection === 'oils' }" @click="goSection('oils')">💧 精油价目</div>
<div v-if="auth.isBusiness" class="nav-tab" :class="{ active: ui.currentSection === 'projects' }" @click="goSection('projects')">💼 商业核算</div>
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'audit' }" @click="goSection('audit')">📜 操作日志</div>
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'bugs' }" @click="goSection('bugs')">🐛 Bug</div>
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'users' }" @click="goSection('users')">👥 用户管理</div>
</div>
<!-- Main content -->
<div class="main">
<router-view />
</div>
<!-- Login Modal -->
<LoginModal v-if="ui.showLoginModal" @close="ui.closeLogin()" />
<!-- Custom Dialog -->
<CustomDialog />
<!-- Toast messages -->
<div v-for="(toast, i) in ui.toasts" :key="i" class="toast">{{ toast }}</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useOilsStore } from './stores/oils'
import { useRecipesStore } from './stores/recipes'
import { useUiStore } from './stores/ui'
import LoginModal from './components/LoginModal.vue'
import CustomDialog from './components/CustomDialog.vue'
import UserMenu from './components/UserMenu.vue'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const router = useRouter()
const showUserMenu = ref(false)
function goSection(name) {
ui.showSection(name)
router.push('/' + (name === 'search' ? '' : name))
}
function requireLogin(name) {
if (!auth.isLoggedIn) {
ui.openLogin()
return
}
goSection(name)
}
function toggleUserMenu() {
if (!auth.isLoggedIn) {
ui.openLogin()
return
}
showUserMenu.value = !showUserMenu.value
}
onMounted(async () => {
await auth.initToken()
await Promise.all([
oils.loadOils(),
recipeStore.loadRecipes(),
recipeStore.loadTags(),
])
if (auth.isLoggedIn) {
await recipeStore.loadFavorites()
}
// Periodic refresh
setInterval(async () => {
if (document.visibilityState !== 'visible') return
try {
await auth.loadMe()
} catch {}
}, 15000)
})
</script>

View File

@@ -0,0 +1,501 @@
: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 { 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; }
.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); }
/* 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;
}
/* Category carousel */
.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; }
/* Toast */
.toast {
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;
}
/* Custom dialog overlay */
.dialog-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.dialog-box {
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;
}
.dialog-msg {
font-size: 14px; color: #333; line-height: 1.6;
white-space: pre-line; word-break: break-word;
margin-bottom: 20px; text-align: center;
}
.dialog-btn-row { display: flex; gap: 10px; justify-content: center; }
.dialog-btn-primary {
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;
}
.dialog-btn-outline {
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;
}
/* Responsive */
@media (max-width: 600px) {
.main { padding: 8px; }
.section { max-width: 100%; }
.detail-panel { padding: 12px; }
.recipe-grid { grid-template-columns: 1fr; }
.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; }
}

View File

@@ -0,0 +1,120 @@
<template>
<div v-if="dialogState.visible" class="dialog-overlay">
<div class="dialog-box">
<div class="dialog-msg">{{ dialogState.message }}</div>
<input
v-if="dialogState.type === 'prompt'"
v-model="inputValue"
type="text"
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"
@keydown.enter="submitPrompt"
ref="promptInput"
/>
<div class="dialog-btn-row">
<button v-if="dialogState.type !== 'alert'" class="dialog-btn-outline" @click="cancel">取消</button>
<button class="dialog-btn-primary" @click="ok">确定</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { dialogState, closeDialog } from '../composables/useDialog'
const inputValue = ref('')
const promptInput = ref(null)
watch(() => dialogState.visible, (v) => {
if (v && dialogState.type === 'prompt') {
inputValue.value = dialogState.defaultValue || ''
nextTick(() => {
promptInput.value?.focus()
promptInput.value?.select()
})
}
})
function ok() {
if (dialogState.type === 'alert') closeDialog()
else if (dialogState.type === 'confirm') closeDialog(true)
else closeDialog(inputValue.value)
}
function cancel() {
if (dialogState.type === 'confirm') closeDialog(false)
else closeDialog(null)
}
function submitPrompt() {
closeDialog(inputValue.value)
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-box {
background: #fff;
border-radius: 16px;
padding: 28px 24px 20px;
min-width: 280px;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
text-align: center;
}
.dialog-msg {
font-size: 15px;
color: #3e3a44;
margin-bottom: 18px;
line-height: 1.6;
white-space: pre-wrap;
}
.dialog-btn-row {
display: flex;
gap: 10px;
justify-content: center;
}
.dialog-btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 28px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.dialog-btn-primary:hover {
opacity: 0.9;
}
.dialog-btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 28px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.dialog-btn-outline:hover {
background: #f8f7f5;
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="login-overlay" @click.self="$emit('close')">
<div class="login-card">
<div class="login-header">
<span
class="login-tab"
:class="{ active: mode === 'login' }"
@click="mode = 'login'"
>登录</span>
<span
class="login-tab"
:class="{ active: mode === 'register' }"
@click="mode = 'register'"
>注册</span>
</div>
<div class="login-body">
<input
v-model="username"
type="text"
placeholder="用户名"
class="login-input"
@keydown.enter="submit"
/>
<input
v-model="password"
type="password"
placeholder="密码"
class="login-input"
@keydown.enter="submit"
/>
<input
v-if="mode === 'register'"
v-model="displayName"
type="text"
placeholder="显示名称(可选)"
class="login-input"
@keydown.enter="submit"
/>
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
<button class="login-submit" :disabled="loading" @click="submit">
{{ loading ? '请稍候...' : (mode === 'login' ? '登录' : '注册') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
const emit = defineEmits(['close'])
const auth = useAuthStore()
const ui = useUiStore()
const mode = ref('login')
const username = ref('')
const password = ref('')
const displayName = ref('')
const errorMsg = ref('')
const loading = ref(false)
async function submit() {
errorMsg.value = ''
if (!username.value.trim()) {
errorMsg.value = '请输入用户名'
return
}
if (!password.value) {
errorMsg.value = '请输入密码'
return
}
loading.value = true
try {
if (mode.value === 'login') {
await auth.login(username.value.trim(), password.value)
ui.showToast('登录成功')
} else {
await auth.register(
username.value.trim(),
password.value,
displayName.value.trim() || username.value.trim()
)
ui.showToast('注册成功')
}
emit('close')
// Reload page data after auth change
window.location.reload()
} catch (e) {
errorMsg.value = e?.message || (mode.value === 'login' ? '登录失败,请检查用户名和密码' : '注册失败,请重试')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 5000;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: #fff;
border-radius: 18px;
width: 340px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.login-header {
display: flex;
border-bottom: 1px solid #eee;
}
.login-tab {
flex: 1;
text-align: center;
padding: 14px 0;
font-size: 15px;
font-weight: 500;
color: #999;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
border-bottom: 2px solid transparent;
}
.login-tab.active {
color: #4a9d7e;
border-bottom-color: #4a9d7e;
}
.login-body {
padding: 24px 24px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.login-input {
width: 100%;
padding: 11px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
transition: border-color 0.2s;
}
.login-input:focus {
border-color: #4a9d7e;
}
.login-error {
color: #d9534f;
font-size: 13px;
text-align: center;
}
.login-submit {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 11px 0;
font-size: 15px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: opacity 0.2s;
}
.login-submit:hover {
opacity: 0.9;
}
.login-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div class="recipe-card" @click="$emit('click', index)">
<div class="card-name">{{ recipe.name }}</div>
<div v-if="recipe.tags && recipe.tags.length" class="card-tags">
<span v-for="tag in recipe.tags" :key="tag" class="card-tag">{{ tag }}</span>
</div>
<div class="card-oils">
<span v-for="(ing, i) in recipe.ingredients" :key="i" class="card-oil">
{{ ing.oil }}
</span>
</div>
<div class="card-bottom">
<span class="card-price">
{{ priceInfo.cost }}
<span v-if="priceInfo.hasRetail" class="card-retail">零售 {{ priceInfo.retail }}</span>
</span>
<button
class="card-star"
:class="{ favorited: isFav }"
@click.stop="$emit('toggle-fav', recipe._id)"
:title="isFav ? '取消收藏' : '收藏'"
>
{{ isFav ? '★' : '☆' }}
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
const props = defineProps({
recipe: { type: Object, required: true },
index: { type: Number, required: true },
})
defineEmits(['click', 'toggle-fav'])
const oilsStore = useOilsStore()
const recipesStore = useRecipesStore()
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(props.recipe.ingredients))
const isFav = computed(() => recipesStore.isFavorite(props.recipe))
</script>
<style scoped>
.recipe-card {
background: #fff;
border-radius: 14px;
padding: 18px 16px 14px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: box-shadow 0.2s, transform 0.15s;
display: flex;
flex-direction: column;
gap: 8px;
}
.recipe-card:hover {
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card-name {
font-family: 'Noto Serif SC', serif;
font-size: 16px;
font-weight: 600;
color: #3e3a44;
line-height: 1.3;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.card-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
background: #f0ece4;
color: #8a7e6b;
}
.card-oils {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.card-oil {
font-size: 12px;
color: #6b6375;
background: #f8f7f5;
padding: 2px 7px;
border-radius: 6px;
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 6px;
}
.card-price {
font-size: 14px;
font-weight: 600;
color: #4a9d7e;
}
.card-retail {
font-size: 11px;
font-weight: 400;
color: #999;
margin-left: 6px;
}
.card-star {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #ccc;
padding: 2px 4px;
line-height: 1;
transition: color 0.2s;
}
.card-star.favorited {
color: #f5a623;
}
.card-star:hover {
color: #f5a623;
}
</style>

View File

@@ -0,0 +1,636 @@
<template>
<div class="detail-overlay" @click.self="$emit('close')">
<div class="detail-panel">
<!-- Mode toggle -->
<div class="detail-mode-tabs">
<button
class="mode-tab"
:class="{ active: viewMode === 'card' }"
@click="viewMode = 'card'"
>卡片预览</button>
<button
class="mode-tab"
:class="{ active: viewMode === 'editor' }"
@click="viewMode = 'editor'"
>编辑</button>
<button class="detail-close-btn" @click="$emit('close')"></button>
</div>
<!-- Card View -->
<div v-if="viewMode === 'card'" class="detail-card-view">
<div ref="cardRef" class="export-card">
<div class="export-card-name">{{ recipe.name }}</div>
<div v-if="recipe.tags && recipe.tags.length" class="export-card-tags">
<span v-for="tag in recipe.tags" :key="tag" class="export-card-tag">{{ tag }}</span>
</div>
<table class="export-card-table">
<thead>
<tr>
<th>精油</th>
<th>滴数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
<tr v-for="(ing, i) in recipe.ingredients" :key="i">
<td>{{ ing.oil }}</td>
<td>{{ ing.drops }}</td>
<td>{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" style="text-align:right;font-weight:600">总计</td>
<td style="font-weight:600">{{ priceInfo.cost }}</td>
</tr>
<tr v-if="priceInfo.hasRetail">
<td colspan="2" style="text-align:right;color:#999">零售价</td>
<td style="color:#999">{{ priceInfo.retail }}</td>
</tr>
</tfoot>
</table>
<div v-if="recipe.note" class="export-card-note">{{ recipe.note }}</div>
</div>
<div class="detail-card-actions">
<button class="action-btn" @click="exportImage">📤 导出图片</button>
<button class="action-btn" @click="$emit('close')">关闭</button>
</div>
</div>
<!-- Editor View -->
<div v-if="viewMode === 'editor'" class="detail-editor-view">
<div class="editor-section">
<label class="editor-label">配方名称</label>
<input v-model="editName" type="text" class="editor-input" />
</div>
<div class="editor-section">
<label class="editor-label">备注</label>
<textarea v-model="editNote" class="editor-textarea" rows="2"></textarea>
</div>
<div class="editor-section">
<label class="editor-label">标签</label>
<div class="editor-tags">
<span v-for="tag in editTags" :key="tag" class="editor-tag">
{{ tag }}
<span class="tag-remove" @click="removeTag(tag)">×</span>
</span>
<button class="tag-add-btn" @click="showTagPicker = true">+ 标签</button>
</div>
</div>
<div class="editor-section">
<label class="editor-label">成分</label>
<table class="editor-table">
<thead>
<tr>
<th>精油</th>
<th>滴数</th>
<th>成本</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(ing, i) in editIngredients" :key="i">
<td>
<select v-model="ing.oil" class="editor-select">
<option value="">选择精油</option>
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
</td>
<td>
<input v-model.number="ing.drops" type="number" min="1" class="editor-drops" />
</td>
<td class="ing-cost">
{{ ing.oil ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * (ing.drops || 0)) : '-' }}
</td>
<td>
<button class="remove-row-btn" @click="removeIngredient(i)"></button>
</td>
</tr>
</tbody>
</table>
<button class="add-row-btn" @click="addIngredient">+ 添加精油</button>
</div>
<div class="editor-section">
<label class="editor-label">容量</label>
<div class="volume-controls">
<button
v-for="(drops, ml) in volumeOptions"
:key="ml"
class="volume-btn"
:class="{ active: selectedVolume === ml }"
@click="selectedVolume = ml"
>{{ ml }}ml</button>
</div>
</div>
<div class="editor-total">
总计: {{ editPriceInfo.cost }}
<span v-if="editPriceInfo.hasRetail" style="color:#999;font-size:13px;margin-left:8px">
零售 {{ editPriceInfo.retail }}
</span>
</div>
<div class="editor-actions">
<button class="action-btn" @click="$emit('close')">取消</button>
<button class="action-btn action-btn-primary" @click="saveRecipe">保存</button>
</div>
</div>
<!-- Tag Picker -->
<TagPicker
v-if="showTagPicker"
:name="editName"
:current-tags="editTags"
:all-tags="recipesStore.allTags"
@save="onTagsSaved"
@close="showTagPicker = false"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import html2canvas from 'html2canvas'
import { useOilsStore, VOLUME_DROPS } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import TagPicker from './TagPicker.vue'
const props = defineProps({
recipeIndex: { type: Number, required: true },
})
const emit = defineEmits(['close'])
const oilsStore = useOilsStore()
const recipesStore = useRecipesStore()
const ui = useUiStore()
const viewMode = ref('card')
const cardRef = ref(null)
const showTagPicker = ref(false)
const selectedVolume = ref('5')
const volumeOptions = VOLUME_DROPS
// Source recipe
const recipe = computed(() => recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' })
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(recipe.value.ingredients))
// Editable copies
const editName = ref('')
const editNote = ref('')
const editTags = ref([])
const editIngredients = ref([])
const editPriceInfo = computed(() => oilsStore.fmtCostWithRetail(editIngredients.value.filter(i => i.oil)))
onMounted(() => {
const r = recipe.value
editName.value = r.name
editNote.value = r.note || ''
editTags.value = [...(r.tags || [])]
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
})
function addIngredient() {
editIngredients.value.push({ oil: '', drops: 1 })
}
function removeIngredient(index) {
editIngredients.value.splice(index, 1)
}
function removeTag(tag) {
editTags.value = editTags.value.filter(t => t !== tag)
}
function onTagsSaved(tags) {
editTags.value = tags
showTagPicker.value = false
}
async function saveRecipe() {
const ingredients = editIngredients.value.filter(i => i.oil && i.drops > 0)
if (!editName.value.trim()) {
ui.showToast('请输入配方名称')
return
}
if (ingredients.length === 0) {
ui.showToast('请至少添加一种精油')
return
}
try {
const payload = {
...recipe.value,
name: editName.value.trim(),
note: editNote.value.trim(),
tags: editTags.value,
ingredients,
}
await recipesStore.saveRecipe(payload)
ui.showToast('保存成功')
emit('close')
} catch (e) {
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
}
}
async function exportImage() {
if (!cardRef.value) return
try {
const canvas = await html2canvas(cardRef.value, {
backgroundColor: '#fff',
scale: 2,
})
const link = document.createElement('a')
link.download = `${recipe.value.name || '配方'}.png`
link.href = canvas.toDataURL('image/png')
link.click()
ui.showToast('已导出图片')
} catch (e) {
ui.showToast('导出失败')
}
}
</script>
<style scoped>
.detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 5500;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.detail-panel {
background: #fff;
border-radius: 18px;
width: 520px;
max-width: 100%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
position: relative;
}
.detail-mode-tabs {
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
padding: 0 16px;
gap: 4px;
}
.mode-tab {
padding: 14px 16px;
font-size: 14px;
font-weight: 500;
color: #999;
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
font-family: inherit;
transition: color 0.2s;
}
.mode-tab.active {
color: #4a9d7e;
border-bottom-color: #4a9d7e;
}
.detail-close-btn {
margin-left: auto;
background: none;
border: none;
font-size: 18px;
color: #999;
cursor: pointer;
padding: 8px;
}
.detail-close-btn:hover {
color: #333;
}
/* Card View */
.detail-card-view {
padding: 20px;
}
.export-card {
background: #fefdfb;
border: 1px solid #eee;
border-radius: 12px;
padding: 24px 20px;
}
.export-card-name {
font-family: 'Noto Serif SC', serif;
font-size: 20px;
font-weight: 700;
color: #3e3a44;
margin-bottom: 10px;
}
.export-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.export-card-tag {
font-size: 11px;
padding: 2px 10px;
border-radius: 8px;
background: #f0ece4;
color: #8a7e6b;
}
.export-card-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
margin-bottom: 12px;
}
.export-card-table th {
text-align: left;
font-weight: 500;
color: #999;
border-bottom: 1px solid #eee;
padding: 8px 6px;
font-size: 12px;
}
.export-card-table td {
padding: 7px 6px;
color: #3e3a44;
border-bottom: 1px solid #f5f5f3;
}
.export-card-table tfoot td {
border-bottom: none;
padding-top: 10px;
}
.export-card-note {
font-size: 13px;
color: #999;
margin-top: 8px;
line-height: 1.5;
white-space: pre-wrap;
}
.detail-card-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
}
/* Editor View */
.detail-editor-view {
padding: 20px;
}
.editor-section {
margin-bottom: 18px;
}
.editor-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #999;
margin-bottom: 6px;
}
.editor-input {
width: 100%;
padding: 10px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.editor-input:focus {
border-color: #4a9d7e;
}
.editor-textarea {
width: 100%;
padding: 10px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
resize: vertical;
}
.editor-textarea:focus {
border-color: #4a9d7e;
}
.editor-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.editor-tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 10px;
background: #4a9d7e;
color: #fff;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tag-remove {
cursor: pointer;
font-size: 14px;
opacity: 0.7;
}
.tag-remove:hover {
opacity: 1;
}
.tag-add-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 10px;
border: 1.5px dashed #ccc;
background: none;
color: #999;
cursor: pointer;
font-family: inherit;
}
.tag-add-btn:hover {
border-color: #4a9d7e;
color: #4a9d7e;
}
.editor-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.editor-table th {
text-align: left;
font-weight: 500;
color: #999;
padding: 6px 4px;
font-size: 12px;
border-bottom: 1px solid #eee;
}
.editor-table td {
padding: 5px 4px;
}
.editor-select {
width: 100%;
padding: 7px 8px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.editor-drops {
width: 60px;
padding: 7px 8px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 13px;
text-align: center;
font-family: inherit;
}
.ing-cost {
font-size: 12px;
color: #4a9d7e;
white-space: nowrap;
}
.remove-row-btn {
background: none;
border: none;
color: #ccc;
font-size: 14px;
cursor: pointer;
padding: 4px;
}
.remove-row-btn:hover {
color: #d9534f;
}
.add-row-btn {
margin-top: 8px;
background: none;
border: 1.5px dashed #ccc;
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
color: #999;
cursor: pointer;
font-family: inherit;
width: 100%;
}
.add-row-btn:hover {
border-color: #4a9d7e;
color: #4a9d7e;
}
.volume-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.volume-btn {
padding: 6px 16px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
background: #fff;
font-size: 13px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.volume-btn.active {
background: #4a9d7e;
color: #fff;
border-color: #4a9d7e;
}
.editor-total {
font-size: 16px;
font-weight: 600;
color: #4a9d7e;
padding: 12px 0;
border-top: 1px solid #eee;
margin-bottom: 4px;
}
.editor-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Shared action buttons */
.action-btn {
padding: 9px 22px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
background: #fff;
font-size: 14px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
}
.action-btn:hover {
background: #f8f7f5;
}
.action-btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border-color: transparent;
}
.action-btn-primary:hover {
opacity: 0.9;
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="tagpicker-overlay" @click.self="$emit('close')">
<div class="tagpicker-card">
<div class="tagpicker-title">{{ name }}选择标签</div>
<div class="tagpicker-pills">
<span
v-for="tag in allTags"
:key="tag"
class="tagpicker-pill"
:class="{ selected: selectedTags.has(tag) }"
@click="toggleTag(tag)"
>
{{ tag }}
</span>
</div>
<div class="tagpicker-new">
<input
v-model="newTag"
type="text"
placeholder="添加新标签..."
class="tagpicker-input"
@keydown.enter="addNewTag"
/>
<button class="tagpicker-add-btn" @click="addNewTag" :disabled="!newTag.trim()">+</button>
</div>
<div class="tagpicker-actions">
<button class="tagpicker-btn-cancel" @click="$emit('close')">取消</button>
<button class="tagpicker-btn-confirm" @click="save">确定</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const props = defineProps({
name: { type: String, default: '' },
currentTags: { type: Array, default: () => [] },
allTags: { type: Array, default: () => [] },
})
const emit = defineEmits(['save', 'close'])
const selectedTags = reactive(new Set(props.currentTags))
const newTag = ref('')
function toggleTag(tag) {
if (selectedTags.has(tag)) {
selectedTags.delete(tag)
} else {
selectedTags.add(tag)
}
}
function addNewTag() {
const tag = newTag.value.trim()
if (!tag) return
if (!selectedTags.has(tag)) {
selectedTags.add(tag)
}
newTag.value = ''
}
function save() {
emit('save', [...selectedTags])
}
</script>
<style scoped>
.tagpicker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 6000;
display: flex;
align-items: center;
justify-content: center;
}
.tagpicker-card {
background: #fff;
border-radius: 16px;
padding: 24px;
width: 380px;
max-width: 90vw;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.tagpicker-title {
font-size: 16px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 16px;
}
.tagpicker-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
min-height: 32px;
}
.tagpicker-pill {
font-size: 13px;
padding: 5px 14px;
border-radius: 14px;
background: #f0ece4;
color: #8a7e6b;
cursor: pointer;
transition: background 0.15s, color 0.15s;
user-select: none;
}
.tagpicker-pill.selected {
background: #4a9d7e;
color: #fff;
}
.tagpicker-pill:hover {
opacity: 0.85;
}
.tagpicker-new {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.tagpicker-input {
flex: 1;
padding: 9px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 13px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.tagpicker-input:focus {
border-color: #4a9d7e;
}
.tagpicker-add-btn {
width: 36px;
height: 36px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
background: #fff;
font-size: 18px;
color: #4a9d7e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.tagpicker-add-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tagpicker-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.tagpicker-btn-cancel {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 24px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
}
.tagpicker-btn-cancel:hover {
background: #f8f7f5;
}
.tagpicker-btn-confirm {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 24px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.tagpicker-btn-confirm:hover {
opacity: 0.9;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<div class="usermenu-overlay" @click.self="$emit('close')">
<div class="usermenu-card">
<div class="usermenu-name">{{ auth.user.display_name || auth.user.username }}</div>
<div class="usermenu-role">
<span class="role-badge">{{ roleLabel }}</span>
</div>
<div class="usermenu-actions">
<button class="usermenu-btn" @click="goMyDiary">
📖 我的
</button>
<button class="usermenu-btn" @click="goNotifications">
🔔 通知
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
</button>
<button class="usermenu-btn usermenu-btn-logout" @click="handleLogout">
🚪 退出登录
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
const emit = defineEmits(['close'])
const auth = useAuthStore()
const ui = useUiStore()
const router = useRouter()
const unreadCount = ref(0)
const roleLabel = computed(() => {
const map = {
admin: '管理员',
senior_editor: '高级编辑',
editor: '编辑',
viewer: '查看者',
}
return map[auth.user.role] || auth.user.role
})
function goMyDiary() {
emit('close')
router.push('/mydiary')
}
function goNotifications() {
emit('close')
router.push('/notifications')
}
function handleLogout() {
auth.logout()
ui.showToast('已退出登录')
emit('close')
window.location.reload()
}
</script>
<style scoped>
.usermenu-overlay {
position: fixed;
inset: 0;
z-index: 4000;
}
.usermenu-card {
position: absolute;
top: 56px;
right: 16px;
background: #fff;
border-radius: 14px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
padding: 18px 20px 14px;
min-width: 180px;
z-index: 4001;
}
.usermenu-name {
font-size: 16px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 4px;
}
.usermenu-role {
margin-bottom: 14px;
}
.role-badge {
display: inline-block;
font-size: 11px;
padding: 2px 10px;
border-radius: 8px;
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
color: #4a9d7e;
font-weight: 500;
}
.usermenu-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.usermenu-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 9px 10px;
border: none;
background: none;
border-radius: 8px;
font-size: 14px;
color: #3e3a44;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background 0.15s;
position: relative;
}
.usermenu-btn:hover {
background: #f5f3f0;
}
.usermenu-btn-logout {
color: #d9534f;
margin-top: 6px;
border-top: 1px solid #eee;
padding-top: 12px;
border-radius: 0 0 8px 8px;
}
.unread-badge {
background: #d9534f;
color: #fff;
font-size: 11px;
font-weight: 600;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
padding: 0 5px;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,44 @@
const API_BASE = '' // same origin, uses vite proxy in dev
export function getToken() {
return localStorage.getItem('oil_auth_token') || ''
}
export function setToken(token) {
if (token) localStorage.setItem('oil_auth_token', token)
else localStorage.removeItem('oil_auth_token')
}
function buildHeaders(extra = {}) {
const headers = { 'Content-Type': 'application/json', ...extra }
const token = getToken()
if (token) headers['Authorization'] = 'Bearer ' + token
return headers
}
async function request(path, opts = {}) {
const headers = buildHeaders(opts.headers)
const res = await fetch(API_BASE + path, { ...opts, headers })
return res
}
async function requestJSON(path, opts = {}) {
const res = await request(path, opts)
if (!res.ok) throw res
return res.json()
}
// api is callable as api(path, opts) → raw Response
// AND has convenience methods: api.get(), api.post(), api.put(), api.delete()
function apiFn(path, opts = {}) {
return request(path, opts)
}
apiFn.raw = request
apiFn.get = (path) => requestJSON(path)
apiFn.post = (path, body) => requestJSON(path, { method: 'POST', body: JSON.stringify(body) })
apiFn.put = (path, body) => requestJSON(path, { method: 'PUT', body: JSON.stringify(body) })
apiFn.del = (path) => requestJSON(path, { method: 'DELETE' })
apiFn.delete = (path) => requestJSON(path, { method: 'DELETE' })
export const api = apiFn

View File

@@ -0,0 +1,43 @@
import { reactive } from 'vue'
export const dialogState = reactive({
visible: false,
type: 'alert', // 'alert', 'confirm', 'prompt'
message: '',
defaultValue: '',
resolve: null
})
export function showAlert(msg) {
return new Promise(resolve => {
dialogState.visible = true
dialogState.type = 'alert'
dialogState.message = msg
dialogState.resolve = resolve
})
}
export function showConfirm(msg) {
return new Promise(resolve => {
dialogState.visible = true
dialogState.type = 'confirm'
dialogState.message = msg
dialogState.resolve = resolve
})
}
export function showPrompt(msg, defaultVal = '') {
return new Promise(resolve => {
dialogState.visible = true
dialogState.type = 'prompt'
dialogState.message = msg
dialogState.defaultValue = defaultVal
dialogState.resolve = resolve
})
}
export function closeDialog(result) {
dialogState.visible = false
if (dialogState.resolve) dialogState.resolve(result)
dialogState.resolve = null
}

View File

@@ -0,0 +1,42 @@
// Oil English names map
const OIL_EN = {
'薰衣草': 'Lavender', '茶树': 'Tea Tree', '乳香': 'Frankincense',
'柠檬': 'Lemon', '椒样薄荷': 'Peppermint', '丝柏': 'Cypress',
'尤加利': 'Eucalyptus', '迷迭香': 'Rosemary', '天竺葵': 'Geranium',
'依兰依兰': 'Ylang Ylang', '佛手柑': 'Bergamot', '生姜': 'Ginger',
'没药': 'Myrrh', '檀香': 'Sandalwood', '雪松': 'Cedarwood',
'罗马洋甘菊': 'Roman Chamomile', '永久花': 'Helichrysum',
'快乐鼠尾草': 'Clary Sage', '广藿香': 'Patchouli',
'百里香': 'Thyme', '牛至': 'Oregano', '冬青': 'Wintergreen',
'肉桂': 'Cinnamon', '丁香': 'Clove', '黑胡椒': 'Black Pepper',
'葡萄柚': 'Grapefruit', '橙花': 'Neroli', '玫瑰': 'Rose',
'岩兰草': 'Vetiver', '马郁兰': 'Marjoram', '芫荽': 'Coriander',
'柠檬草': 'Lemongrass', '杜松浆果': 'Juniper Berry', '甜橙': 'Wild Orange',
'香茅': 'Citronella', '薄荷': 'Peppermint', '扁柏': 'Arborvitae',
'古巴香脂': 'Copaiba', '椰子油': 'Coconut Oil',
'芳香调理': 'AromaTouch', '保卫复方': 'On Guard',
'乐活复方': 'Balance', '舒缓复方': 'Past Tense',
'净化复方': 'Purify', '呼吸复方': 'Breathe',
'舒压复方': 'Adaptiv', '多特瑞': 'doTERRA',
}
export function oilEn(name) {
return OIL_EN[name] || ''
}
export function recipeNameEn(name) {
// Try to translate known keywords
// Simple approach: return original name for now, user can customize
return name
}
// Custom translations (can be set by admin)
const customTranslations = {}
export function setCustomTranslation(zhName, enName) {
customTranslations[zhName] = enName
}
export function getCustomTranslation(zhName) {
return customTranslations[zhName]
}

View File

@@ -0,0 +1,262 @@
export const DROPS_PER_ML = 18.6
export const OIL_HOMOPHONES = {
'相貌':'香茅','香矛':'香茅','向茅':'香茅','像茅':'香茅',
'如香':'乳香','儒香':'乳香',
'古巴想':'古巴香脂','古巴香':'古巴香脂','古巴相脂':'古巴香脂',
'博荷':'薄荷','薄河':'薄荷',
'尤佳利':'尤加利','优加利':'尤加利',
'依兰':'依兰依兰',
'雪松木':'雪松',
'桧木':'扁柏','桧柏':'扁柏',
'永久化':'永久花','永久华':'永久花',
'罗马洋柑菊':'罗马洋甘菊','洋甘菊':'罗马洋甘菊',
'天竹葵':'天竺葵','天竺癸':'天竺葵',
'没要':'没药','莫药':'没药',
'快乐鼠尾':'快乐鼠尾草',
'椒样博荷':'椒样薄荷','椒样薄和':'椒样薄荷',
'丝柏木':'丝柏',
'柠檬草油':'柠檬草',
'茶树油':'茶树',
'薰衣草油':'薰衣草',
'玫瑰花':'玫瑰',
}
/**
* Levenshtein edit distance between two strings
*/
export 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++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
}
return dp[m][n]
}
/**
* Fuzzy match oil name against known list.
* Priority: homophone -> exact -> substring -> missing-char -> edit distance
* Returns matched oil name or null.
*/
export function findOil(input, oilNames) {
if (!input || input.length === 0) return null
const trimmed = input.trim()
if (!trimmed) return null
// 1. Homophone alias check
if (OIL_HOMOPHONES[trimmed]) {
const alias = OIL_HOMOPHONES[trimmed]
if (oilNames.includes(alias)) return alias
}
// 2. Exact match
if (oilNames.includes(trimmed)) return trimmed
// 3. Substring match (input ⊂ name or name ⊂ input), prefer longest
let substringMatches = []
for (const name of oilNames) {
if (name.includes(trimmed) || trimmed.includes(name)) {
substringMatches.push(name)
}
}
if (substringMatches.length > 0) {
substringMatches.sort((a, b) => b.length - a.length)
return substringMatches[0]
}
// 4. "Missing one char" match - input is one char shorter than an oil name
for (const name of oilNames) {
if (Math.abs(name.length - trimmed.length) === 1) {
const longer = name.length > trimmed.length ? name : trimmed
const shorter = name.length > trimmed.length ? trimmed : name
// Check if shorter can be formed by removing one char from longer
for (let i = 0; i < longer.length; i++) {
const candidate = longer.slice(0, i) + longer.slice(i + 1)
if (candidate === shorter) return name
}
}
}
// 5. Edit distance fuzzy match
let bestMatch = null
let bestDist = Infinity
for (const name of oilNames) {
const dist = editDistance(trimmed, name)
const maxLen = Math.max(trimmed.length, name.length)
// Only accept if edit distance is reasonable (less than half the length)
if (dist < bestDist && dist <= Math.floor(maxLen / 2)) {
bestDist = dist
bestMatch = name
}
}
return bestMatch
}
/**
* Greedy longest-match from concatenated string against oil names.
* Returns array of matched oil names in order.
*/
export function greedyMatchOils(text, oilNames) {
const results = []
let i = 0
while (i < text.length) {
let bestMatch = null
let bestLen = 0
// Try all oil names sorted by length (longest first)
const sorted = [...oilNames].sort((a, b) => b.length - a.length)
for (const name of sorted) {
if (text.substring(i, i + name.length) === name) {
bestMatch = name
bestLen = name.length
break
}
}
// Also check homophones
if (!bestMatch) {
for (const [alias, canonical] of Object.entries(OIL_HOMOPHONES)) {
if (text.substring(i, i + alias.length) === alias) {
if (!bestMatch || alias.length > bestLen) {
bestMatch = canonical
bestLen = alias.length
}
}
}
}
if (bestMatch) {
results.push(bestMatch)
i += bestLen
} else {
i++
}
}
return results
}
/**
* Parse text chunk into [{oil, drops}] pairs.
* Handles formats like "芳香调理8永久花10" or "薰衣草 3滴 茶树 2ml"
*/
export function parseOilChunk(text, oilNames) {
const results = []
const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g
let match
while ((match = regex.exec(text)) !== null) {
const namePart = match[1].trim()
let amount = parseFloat(match[2])
const unit = match[3] || ''
// Convert ml to drops
if (unit && (unit.toLowerCase() === 'ml' || unit === '毫升')) {
amount = Math.round(amount * 20)
}
// Try greedy match on the name part
const matched = greedyMatchOils(namePart, oilNames)
if (matched.length > 0) {
// Last matched oil gets the drops
for (let i = 0; i < matched.length - 1; i++) {
results.push({ oil: matched[i], drops: 0 })
}
results.push({ oil: matched[matched.length - 1], drops: amount })
} else {
// Try findOil as fallback
const found = findOil(namePart, oilNames)
if (found) {
results.push({ oil: found, drops: amount })
} else if (namePart) {
results.push({ oil: namePart, drops: amount, notFound: true })
}
}
}
return results
}
/**
* Split multi-recipe input by blank lines or semicolons.
* Detects recipe boundaries (non-oil text after seeing oils = new recipe).
*/
export function splitRawIntoBlocks(raw, oilNames) {
// First split by semicolons
let parts = raw.split(/[;]/)
// Then split each part by blank lines
let blocks = []
for (const part of parts) {
const subBlocks = part.split(/\n\s*\n/)
blocks.push(...subBlocks)
}
// Filter empty blocks
blocks = blocks.map(b => b.trim()).filter(b => b.length > 0)
return blocks
}
/**
* Parse one recipe block into {name, ingredients, notFound}.
* 1. Split by commas/newlines/etc
* 2. First non-oil, non-number part = recipe name
* 3. Rest parsed through parseOilChunk
* 4. Deduplicate ingredients
*/
export function parseSingleBlock(raw, oilNames) {
// Split by commas, Chinese commas, newlines, spaces
const parts = raw.split(/[,\n\r]+/).map(s => s.trim()).filter(s => s)
let name = ''
let ingredientParts = []
let foundFirstOil = false
for (const part of parts) {
// Check if this part contains oil references
const hasNumber = /\d/.test(part)
const hasOil = oilNames.some(oil => part.includes(oil)) ||
Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias))
if (!foundFirstOil && !hasOil && !hasNumber && !name) {
// This is the recipe name
name = part
} else {
foundFirstOil = true
ingredientParts.push(part)
}
}
// Parse all ingredient parts
const allIngredients = []
const notFound = []
for (const part of ingredientParts) {
const parsed = parseOilChunk(part, oilNames)
for (const item of parsed) {
if (item.notFound) {
notFound.push(item.oil)
} else {
allIngredients.push(item)
}
}
}
// Deduplicate: merge same oil, sum drops
const deduped = []
const seen = {}
for (const item of allIngredients) {
if (seen[item.oil] !== undefined) {
deduped[seen[item.oil]].drops += item.drops
} else {
seen[item.oil] = deduped.length
deduped.push({ ...item })
}
}
return {
name: name || '未命名配方',
ingredients: deduped,
notFound
}
}

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,56 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'RecipeSearch',
component: () => import('../views/RecipeSearch.vue'),
},
{
path: '/manage',
name: 'RecipeManager',
component: () => import('../views/RecipeManager.vue'),
},
{
path: '/inventory',
name: 'Inventory',
component: () => import('../views/Inventory.vue'),
},
{
path: '/oils',
name: 'OilReference',
component: () => import('../views/OilReference.vue'),
},
{
path: '/projects',
name: 'Projects',
component: () => import('../views/Projects.vue'),
},
{
path: '/mydiary',
name: 'MyDiary',
component: () => import('../views/MyDiary.vue'),
},
{
path: '/audit',
name: 'AuditLog',
component: () => import('../views/AuditLog.vue'),
},
{
path: '/bugs',
name: 'BugTracker',
component: () => import('../views/BugTracker.vue'),
},
{
path: '/users',
name: 'UserManagement',
component: () => import('../views/UserManagement.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

103
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,103 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../composables/useApi'
const DEFAULT_USER = {
id: null,
role: 'viewer',
username: 'anonymous',
display_name: '匿名',
has_password: false,
business_verified: false,
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('oil_auth_token') || '')
const user = ref({ ...DEFAULT_USER })
// Getters
const isLoggedIn = computed(() => user.value.id !== null)
const isAdmin = computed(() => user.value.role === 'admin')
const canEdit = computed(() =>
['editor', 'senior_editor', 'admin'].includes(user.value.role)
)
const isBusiness = computed(() => user.value.business_verified)
// Actions
async function initToken() {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
token.value = urlToken
localStorage.setItem('oil_auth_token', urlToken)
// Clean URL
const url = new URL(window.location)
url.searchParams.delete('token')
window.history.replaceState({}, '', url)
}
if (token.value) {
await loadMe()
}
}
async function loadMe() {
try {
const data = await api.get('/api/me')
user.value = {
id: data.id,
role: data.role,
username: data.username,
display_name: data.display_name,
has_password: data.has_password ?? false,
business_verified: data.business_verified ?? false,
}
} catch {
logout()
}
}
async function login(username, password) {
const data = await api.post('/api/login', { username, password })
token.value = data.token
localStorage.setItem('oil_auth_token', data.token)
await loadMe()
}
async function register(username, password, displayName) {
const data = await api.post('/api/register', {
username,
password,
display_name: displayName,
})
token.value = data.token
localStorage.setItem('oil_auth_token', data.token)
await loadMe()
}
function logout() {
token.value = ''
localStorage.removeItem('oil_auth_token')
user.value = { ...DEFAULT_USER }
}
function canEditRecipe(recipe) {
if (isAdmin.value || user.value.role === 'senior_editor') return true
if (user.value.role === 'editor' && recipe._owner_id === user.value.id) return true
return false
}
return {
token,
user,
isLoggedIn,
isAdmin,
canEdit,
isBusiness,
initToken,
loadMe,
login,
register,
logout,
canEditRecipe,
}
})

View File

@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../composables/useApi'
export const useDiaryStore = defineStore('diary', () => {
const userDiary = ref([])
const currentDiaryId = ref(null)
// Actions
async function loadDiary() {
const data = await api.get('/api/diary')
userDiary.value = data
}
async function createDiary(data) {
const result = await api.post('/api/diary', data)
await loadDiary()
return result
}
async function updateDiary(id, data) {
const result = await api.put(`/api/diary/${id}`, data)
await loadDiary()
return result
}
async function deleteDiary(id) {
await api.delete(`/api/diary/${id}`)
userDiary.value = userDiary.value.filter((d) => (d._id ?? d.id) !== id)
if (currentDiaryId.value === id) {
currentDiaryId.value = null
}
}
async function addEntry(diaryId, content) {
const result = await api.post(`/api/diary/${diaryId}/entries`, content)
await loadDiary()
return result
}
async function deleteEntry(entryId) {
await api.delete(`/api/diary/entries/${entryId}`)
await loadDiary()
}
return {
userDiary,
currentDiaryId,
loadDiary,
createDiary,
updateDiary,
deleteDiary,
addEntry,
deleteEntry,
}
})

106
frontend/src/stores/oils.js Normal file
View File

@@ -0,0 +1,106 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../composables/useApi'
export const DROPS_PER_ML = 18.6
export const VOLUME_DROPS = {
'2.5': 46,
'5': 93,
'10': 186,
'15': 280,
'115': 2146,
}
export const useOilsStore = defineStore('oils', () => {
const oils = ref(new Map())
const oilsMeta = ref(new Map())
// Getters
const oilNames = computed(() =>
[...oils.value.keys()].sort((a, b) => a.localeCompare(b, 'zh'))
)
function pricePerDrop(name) {
return oils.value.get(name) || 0
}
function calcCost(ingredients) {
return ingredients.reduce((sum, ing) => {
return sum + pricePerDrop(ing.oil) * ing.drops
}, 0)
}
function calcRetailCost(ingredients) {
return ingredients.reduce((sum, ing) => {
const meta = oilsMeta.value.get(ing.oil)
if (meta && meta.retailPrice && meta.dropCount) {
return sum + (meta.retailPrice / meta.dropCount) * ing.drops
}
return sum + pricePerDrop(ing.oil) * ing.drops
}, 0)
}
function fmtPrice(n) {
return '¥ ' + n.toFixed(2)
}
function fmtCostWithRetail(ingredients) {
const cost = calcCost(ingredients)
const retail = calcRetailCost(ingredients)
const costStr = fmtPrice(cost)
if (retail > cost) {
return { cost: costStr, retail: fmtPrice(retail), hasRetail: true }
}
return { cost: costStr, retail: null, hasRetail: false }
}
// Actions
async function loadOils() {
const data = await api.get('/api/oils')
const newOils = new Map()
const newMeta = new Map()
for (const oil of data) {
const ppd = oil.drop_count ? oil.bottle_price / oil.drop_count : 0
newOils.set(oil.name, ppd)
newMeta.set(oil.name, {
bottlePrice: oil.bottle_price,
dropCount: oil.drop_count,
retailPrice: oil.retail_price ?? null,
isActive: oil.is_active ?? true,
})
}
oils.value = newOils
oilsMeta.value = newMeta
}
async function saveOil(name, bottlePrice, dropCount, retailPrice) {
await api.post('/api/oils', {
name,
bottle_price: bottlePrice,
drop_count: dropCount,
retail_price: retailPrice,
})
await loadOils()
}
async function deleteOil(name) {
await api.delete(`/api/oils/${encodeURIComponent(name)}`)
oils.value.delete(name)
oilsMeta.value.delete(name)
}
return {
oils,
oilsMeta,
oilNames,
pricePerDrop,
calcCost,
calcRetailCost,
fmtPrice,
fmtCostWithRetail,
loadOils,
saveOil,
deleteOil,
}
})

View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../composables/useApi'
export const useRecipesStore = defineStore('recipes', () => {
const recipes = ref([])
const allTags = ref([])
const userFavorites = ref([])
// Actions
async function loadRecipes() {
const data = await api.get('/api/recipes')
recipes.value = data.map((r) => ({
_id: r._id ?? r.id,
_owner_id: r._owner_id ?? r.owner_id,
_owner_name: r._owner_name ?? r.owner_name ?? '',
_version: r._version ?? r.version ?? 1,
name: r.name,
note: r.note ?? '',
tags: r.tags ?? [],
ingredients: (r.ingredients ?? []).map((ing) => ({
oil: ing.oil ?? ing.name,
drops: ing.drops,
})),
}))
}
async function loadTags() {
const data = await api.get('/api/tags')
const apiTags = data.map((t) => (typeof t === 'string' ? t : t.name))
const recipeTags = recipes.value.flatMap((r) => r.tags)
const tagSet = new Set([...apiTags, ...recipeTags])
allTags.value = [...tagSet].sort((a, b) => a.localeCompare(b, 'zh'))
}
async function loadFavorites() {
try {
const data = await api.get('/api/favorites')
userFavorites.value = data.map((f) => f._id ?? f.id ?? f.recipe_id ?? f)
} catch {
userFavorites.value = []
}
}
async function saveRecipe(recipe) {
if (recipe._id) {
const data = await api.put(`/api/recipes/${recipe._id}`, recipe)
const idx = recipes.value.findIndex((r) => r._id === recipe._id)
if (idx !== -1) {
recipes.value[idx] = { ...recipes.value[idx], ...recipe, _version: data._version ?? data.version ?? recipe._version }
}
return data
} else {
const data = await api.post('/api/recipes', recipe)
await loadRecipes()
return data
}
}
async function deleteRecipe(id) {
await api.delete(`/api/recipes/${id}`)
recipes.value = recipes.value.filter((r) => r._id !== id)
}
async function toggleFavorite(recipeId) {
if (userFavorites.value.includes(recipeId)) {
await api.delete(`/api/favorites/${recipeId}`)
userFavorites.value = userFavorites.value.filter((id) => id !== recipeId)
} else {
await api.post(`/api/favorites/${recipeId}`)
userFavorites.value.push(recipeId)
}
}
function isFavorite(recipe) {
return userFavorites.value.includes(recipe._id)
}
async function createTag(name) {
await api.post('/api/tags', { name })
if (!allTags.value.includes(name)) {
allTags.value = [...allTags.value, name].sort((a, b) => a.localeCompare(b, 'zh'))
}
}
async function deleteTag(name) {
await api.delete(`/api/tags/${encodeURIComponent(name)}`)
allTags.value = allTags.value.filter((t) => t !== name)
}
return {
recipes,
allTags,
userFavorites,
loadRecipes,
loadTags,
loadFavorites,
saveRecipe,
deleteRecipe,
toggleFavorite,
isFavorite,
createTag,
deleteTag,
}
})

40
frontend/src/stores/ui.js Normal file
View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const currentSection = ref('search')
const showLoginModal = ref(false)
const toasts = ref([])
let toastId = 0
function showSection(name) {
currentSection.value = name
}
function showToast(msg, duration = 1800) {
const id = ++toastId
toasts.value.push({ id, msg })
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, duration)
}
function openLogin() {
showLoginModal.value = true
}
function closeLogin() {
showLoginModal.value = false
}
return {
currentSection,
showLoginModal,
toasts,
showSection,
showToast,
openLogin,
closeLogin,
}
})

View File

@@ -0,0 +1,380 @@
<template>
<div class="audit-log">
<h3 class="page-title">📜 操作日志</h3>
<!-- Action Type Filters -->
<div class="filter-row">
<span class="filter-label">操作类型:</span>
<button
v-for="action in actionTypes"
:key="action.value"
class="filter-btn"
:class="{ active: selectedAction === action.value }"
@click="selectedAction = selectedAction === action.value ? '' : action.value"
>{{ action.label }}</button>
</div>
<!-- User Filters -->
<div class="filter-row" v-if="uniqueUsers.length > 0">
<span class="filter-label">用户:</span>
<button
v-for="u in uniqueUsers"
:key="u"
class="filter-btn"
:class="{ active: selectedUser === u }"
@click="selectedUser = selectedUser === u ? '' : u"
>{{ u }}</button>
</div>
<!-- Log List -->
<div class="log-list">
<div v-for="log in filteredLogs" :key="log._id || log.id" class="log-item">
<div class="log-header">
<span class="log-action" :class="actionClass(log.action)">{{ actionLabel(log.action) }}</span>
<span class="log-user">{{ log.user_name || log.username || '系统' }}</span>
<span class="log-time">{{ formatTime(log.created_at) }}</span>
</div>
<div class="log-detail">
<span v-if="log.target_type" class="log-target">{{ log.target_type }}: </span>
<span class="log-desc">{{ log.description || log.detail || formatDetail(log) }}</span>
</div>
<div v-if="log.changes" class="log-changes">
<pre class="changes-pre">{{ typeof log.changes === 'string' ? log.changes : JSON.stringify(log.changes, null, 2) }}</pre>
</div>
<button
v-if="log.undoable"
class="btn-undo"
@click="undoLog(log)"
> 撤销</button>
</div>
<div v-if="filteredLogs.length === 0" class="empty-hint">暂无日志记录</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="load-more">
<button class="btn-outline" @click="loadMore" :disabled="loading">
{{ loading ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
const logs = ref([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(0)
const pageSize = 50
const selectedAction = ref('')
const selectedUser = ref('')
const actionTypes = [
{ value: 'create', label: '创建' },
{ value: 'update', label: '更新' },
{ value: 'delete', label: '删除' },
{ value: 'login', label: '登录' },
{ value: 'approve', label: '审核' },
{ value: 'export', label: '导出' },
]
const uniqueUsers = computed(() => {
const names = new Set()
for (const log of logs.value) {
const name = log.user_name || log.username
if (name) names.add(name)
}
return [...names].sort()
})
const filteredLogs = computed(() => {
let result = logs.value
if (selectedAction.value) {
result = result.filter(l => l.action === selectedAction.value)
}
if (selectedUser.value) {
result = result.filter(l =>
(l.user_name || l.username) === selectedUser.value
)
}
return result
})
function actionLabel(action) {
const map = {
create: '创建',
update: '更新',
delete: '删除',
login: '登录',
approve: '审核',
reject: '拒绝',
export: '导出',
undo: '撤销',
}
return map[action] || action
}
function actionClass(action) {
return {
'action-create': action === 'create',
'action-update': action === 'update',
'action-delete': action === 'delete' || action === 'reject',
'action-login': action === 'login',
'action-approve': action === 'approve',
}
}
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function formatDetail(log) {
if (log.target_name) return log.target_name
if (log.recipe_name) return log.recipe_name
if (log.oil_name) return log.oil_name
return ''
}
async function fetchLogs() {
loading.value = true
try {
const res = await api(`/api/audit-logs?offset=${page.value * pageSize}&limit=${pageSize}`)
if (res.ok) {
const data = await res.json()
const items = Array.isArray(data) ? data : data.logs || data.items || []
if (items.length < pageSize) {
hasMore.value = false
}
logs.value.push(...items)
}
} catch {
hasMore.value = false
}
loading.value = false
}
function loadMore() {
page.value++
fetchLogs()
}
async function undoLog(log) {
const ok = await showConfirm('确定撤销此操作?')
if (!ok) return
try {
const id = log._id || log.id
const res = await api(`/api/audit-logs/${id}/undo`, { method: 'POST' })
if (res.ok) {
ui.showToast('已撤销')
// Refresh
logs.value = []
page.value = 0
hasMore.value = true
await fetchLogs()
} else {
ui.showToast('撤销失败')
}
} catch {
ui.showToast('撤销失败')
}
}
onMounted(() => {
fetchLogs()
})
</script>
<style scoped>
.audit-log {
padding: 0 12px 24px;
}
.page-title {
margin: 0 0 16px;
font-size: 16px;
color: #3e3a44;
}
.filter-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.filter-label {
font-size: 13px;
color: #6b6375;
font-weight: 500;
white-space: nowrap;
}
.filter-btn {
padding: 5px 14px;
border-radius: 16px;
border: 1.5px solid #e5e4e7;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
transition: all 0.15s;
}
.filter-btn.active {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
.filter-btn:hover {
border-color: #d4cfc7;
}
.log-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.log-item {
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
transition: border-color 0.15s;
}
.log-item:hover {
border-color: #d4cfc7;
}
.log-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.log-action {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #f0eeeb;
color: #6b6375;
}
.action-create { background: #e8f5e9; color: #2e7d5a; }
.action-update { background: #e3f2fd; color: #1565c0; }
.action-delete { background: #ffebee; color: #c62828; }
.action-login { background: #fff3e0; color: #e65100; }
.action-approve { background: #f3e5f5; color: #7b1fa2; }
.log-user {
font-size: 13px;
font-weight: 500;
color: #3e3a44;
}
.log-time {
font-size: 11px;
color: #b0aab5;
margin-left: auto;
}
.log-detail {
font-size: 13px;
color: #6b6375;
margin-top: 2px;
}
.log-target {
font-weight: 500;
color: #3e3a44;
}
.log-changes {
margin-top: 6px;
}
.changes-pre {
font-size: 11px;
background: #f8f7f5;
padding: 8px 10px;
border-radius: 6px;
overflow-x: auto;
margin: 0;
color: #6b6375;
font-family: ui-monospace, Consolas, monospace;
line-height: 1.5;
max-height: 120px;
}
.btn-undo {
margin-top: 8px;
padding: 4px 12px;
border: 1.5px solid #e5e4e7;
border-radius: 8px;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
}
.btn-undo:hover {
border-color: #7ec6a4;
color: #4a9d7e;
}
.load-more {
text-align: center;
margin-top: 16px;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 28px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-outline:disabled {
opacity: 0.5;
cursor: default;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 32px 0;
}
</style>

View File

@@ -0,0 +1,644 @@
<template>
<div class="bug-tracker">
<div class="toolbar">
<h3 class="page-title">🐛 Bug Tracker</h3>
<button class="btn-primary" @click="showAddBug = true">+ 新增Bug</button>
</div>
<!-- Active Bugs -->
<div class="section-header">
<span>🔴 活跃 ({{ activeBugs.length }})</span>
</div>
<div class="bug-list">
<div v-for="bug in activeBugs" :key="bug._id || bug.id" class="bug-card" :class="'priority-' + (bug.priority || 'normal')">
<div class="bug-header">
<span class="bug-priority" :class="'p-' + (bug.priority || 'normal')">{{ priorityLabel(bug.priority) }}</span>
<span class="bug-status" :class="'s-' + (bug.status || 'open')">{{ statusLabel(bug.status) }}</span>
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
</div>
<div class="bug-title">{{ bug.title }}</div>
<div v-if="bug.description" class="bug-desc">{{ bug.description }}</div>
<div v-if="bug.reporter" class="bug-reporter">报告者: {{ bug.reporter }}</div>
<!-- Status workflow -->
<div class="bug-actions">
<template v-if="bug.status === 'open'">
<button class="btn-sm btn-status" @click="updateStatus(bug, 'testing')">开始测试</button>
</template>
<template v-else-if="bug.status === 'testing'">
<button class="btn-sm btn-status" @click="updateStatus(bug, 'fixed')">标记修复</button>
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
</template>
<template v-else-if="bug.status === 'fixed'">
<button class="btn-sm btn-status" @click="updateStatus(bug, 'tested')">验证通过</button>
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
</template>
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
</div>
<!-- Comments -->
<div class="comments-section" v-if="expandedBugId === (bug._id || bug.id)">
<div v-for="comment in (bug.comments || [])" :key="comment._id || comment.id" class="comment-item">
<div class="comment-meta">
<span class="comment-author">{{ comment.author || comment.user_name || '匿名' }}</span>
<span class="comment-time">{{ formatDate(comment.created_at) }}</span>
</div>
<div class="comment-text">{{ comment.text || comment.content }}</div>
</div>
<div class="comment-add">
<input
v-model="newComment"
class="form-input"
placeholder="添加备注..."
@keydown.enter="addComment(bug)"
/>
<button class="btn-primary btn-sm" @click="addComment(bug)" :disabled="!newComment.trim()">发送</button>
</div>
</div>
<button class="btn-toggle-comments" @click="toggleComments(bug)">
💬 {{ (bug.comments || []).length }} 条备注
{{ expandedBugId === (bug._id || bug.id) ? '' : '' }}
</button>
</div>
<div v-if="activeBugs.length === 0" class="empty-hint">暂无活跃Bug</div>
</div>
<!-- Resolved Bugs -->
<div class="section-header" style="margin-top:20px" @click="showResolved = !showResolved">
<span> 已解决 ({{ resolvedBugs.length }})</span>
<span class="toggle-icon">{{ showResolved ? '▾' : '▸' }}</span>
</div>
<div v-if="showResolved" class="bug-list">
<div v-for="bug in resolvedBugs" :key="bug._id || bug.id" class="bug-card resolved">
<div class="bug-header">
<span class="bug-priority" :class="'p-' + (bug.priority || 'normal')">{{ priorityLabel(bug.priority) }}</span>
<span class="bug-status s-tested">已解决</span>
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
</div>
<div class="bug-title">{{ bug.title }}</div>
<div class="bug-actions">
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 'open')">重新打开</button>
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
</div>
</div>
<div v-if="resolvedBugs.length === 0" class="empty-hint">暂无已解决Bug</div>
</div>
<!-- Add Bug Modal -->
<div v-if="showAddBug" class="overlay" @click.self="showAddBug = false">
<div class="overlay-panel">
<div class="overlay-header">
<h3>新增Bug</h3>
<button class="btn-close" @click="showAddBug = false"></button>
</div>
<div class="form-group">
<label>标题</label>
<input v-model="bugForm.title" class="form-input" placeholder="Bug标题" />
</div>
<div class="form-group">
<label>描述</label>
<textarea v-model="bugForm.description" class="form-textarea" rows="4" placeholder="Bug描述复现步骤等..."></textarea>
</div>
<div class="form-group">
<label>优先级</label>
<div class="priority-btns">
<button
v-for="p in priorities"
:key="p.value"
class="priority-btn"
:class="{ active: bugForm.priority === p.value, ['p-' + p.value]: true }"
@click="bugForm.priority = p.value"
>{{ p.label }}</button>
</div>
</div>
<div class="overlay-footer">
<button class="btn-outline" @click="showAddBug = false">取消</button>
<button class="btn-primary" @click="createBug" :disabled="!bugForm.title.trim()">提交</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
const bugs = ref([])
const showAddBug = ref(false)
const showResolved = ref(false)
const expandedBugId = ref(null)
const newComment = ref('')
const bugForm = reactive({
title: '',
description: '',
priority: 'normal',
})
const priorities = [
{ value: 'low', label: '低' },
{ value: 'normal', label: '中' },
{ value: 'high', label: '高' },
{ value: 'critical', label: '紧急' },
]
const activeBugs = computed(() =>
bugs.value.filter(b => b.status !== 'tested' && b.status !== 'closed')
.sort((a, b) => {
const order = { critical: 0, high: 1, normal: 2, low: 3 }
return (order[a.priority] ?? 2) - (order[b.priority] ?? 2)
})
)
const resolvedBugs = computed(() =>
bugs.value.filter(b => b.status === 'tested' || b.status === 'closed')
)
function priorityLabel(p) {
const map = { low: '低', normal: '中', high: '高', critical: '紧急' }
return map[p] || '中'
}
function statusLabel(s) {
const map = { open: '待处理', testing: '测试中', fixed: '已修复', tested: '已验证', closed: '已关闭' }
return map[s] || s
}
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function toggleComments(bug) {
const id = bug._id || bug.id
expandedBugId.value = expandedBugId.value === id ? null : id
}
async function loadBugs() {
try {
const res = await api('/api/bugs')
if (res.ok) {
bugs.value = await res.json()
}
} catch {
bugs.value = []
}
}
async function createBug() {
if (!bugForm.title.trim()) return
try {
const res = await api('/api/bugs', {
method: 'POST',
body: JSON.stringify({
title: bugForm.title.trim(),
description: bugForm.description.trim(),
priority: bugForm.priority,
status: 'open',
reporter: auth.user.display_name || auth.user.username,
}),
})
if (res.ok) {
showAddBug.value = false
bugForm.title = ''
bugForm.description = ''
bugForm.priority = 'normal'
await loadBugs()
ui.showToast('Bug已提交')
}
} catch {
ui.showToast('提交失败')
}
}
async function updateStatus(bug, newStatus) {
const id = bug._id || bug.id
try {
const res = await api(`/api/bugs/${id}`, {
method: 'PUT',
body: JSON.stringify({ status: newStatus }),
})
if (res.ok) {
bug.status = newStatus
ui.showToast(`状态已更新: ${statusLabel(newStatus)}`)
}
} catch {
ui.showToast('更新失败')
}
}
async function removeBug(bug) {
const ok = await showConfirm(`确定删除 "${bug.title}"`)
if (!ok) return
const id = bug._id || bug.id
try {
const res = await api(`/api/bugs/${id}`, { method: 'DELETE' })
if (res.ok) {
bugs.value = bugs.value.filter(b => (b._id || b.id) !== id)
ui.showToast('已删除')
}
} catch {
ui.showToast('删除失败')
}
}
async function addComment(bug) {
if (!newComment.value.trim()) return
const id = bug._id || bug.id
try {
const res = await api(`/api/bugs/${id}/comments`, {
method: 'POST',
body: JSON.stringify({
text: newComment.value.trim(),
author: auth.user.display_name || auth.user.username,
}),
})
if (res.ok) {
newComment.value = ''
await loadBugs()
ui.showToast('备注已添加')
}
} catch {
ui.showToast('添加失败')
}
}
onMounted(() => {
loadBugs()
})
</script>
<style scoped>
.bug-tracker {
padding: 0 12px 24px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 4px;
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 8px;
cursor: pointer;
}
.toggle-icon {
font-size: 12px;
color: #999;
}
.bug-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bug-card {
padding: 14px 16px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
border-left: 4px solid #e5e4e7;
}
.bug-card.priority-critical { border-left-color: #d32f2f; }
.bug-card.priority-high { border-left-color: #f57c00; }
.bug-card.priority-normal { border-left-color: #1976d2; }
.bug-card.priority-low { border-left-color: #9e9e9e; }
.bug-card.resolved { opacity: 0.7; }
.bug-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.bug-priority {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.p-critical { background: #ffebee; color: #c62828; }
.p-high { background: #fff3e0; color: #e65100; }
.p-normal { background: #e3f2fd; color: #1565c0; }
.p-low { background: #f5f5f5; color: #757575; }
.bug-status {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.s-open { background: #ffebee; color: #c62828; }
.s-testing { background: #fff3e0; color: #e65100; }
.s-fixed { background: #e3f2fd; color: #1565c0; }
.s-tested { background: #e8f5e9; color: #2e7d5a; }
.bug-date {
font-size: 11px;
color: #b0aab5;
margin-left: auto;
}
.bug-title {
font-weight: 600;
font-size: 15px;
color: #3e3a44;
margin-bottom: 4px;
}
.bug-desc {
font-size: 13px;
color: #6b6375;
line-height: 1.6;
margin-bottom: 4px;
}
.bug-reporter {
font-size: 12px;
color: #b0aab5;
margin-bottom: 6px;
}
.bug-actions {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.btn-sm {
padding: 5px 14px;
font-size: 12px;
border-radius: 8px;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-status {
background: linear-gradient(135deg, #7ec6a4, #4a9d7e);
color: #fff;
}
.btn-reopen {
background: #fff3e0;
color: #e65100;
border: 1.5px solid #ffe0b2;
}
.btn-delete {
background: #fff;
color: #ef5350;
border: 1.5px solid #ffcdd2;
}
.btn-toggle-comments {
border: none;
background: transparent;
color: #6b6375;
font-size: 12px;
cursor: pointer;
padding: 6px 0;
font-family: inherit;
margin-top: 4px;
}
.btn-toggle-comments:hover {
color: #3e3a44;
}
/* Comments */
.comments-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f0eeeb;
}
.comment-item {
padding: 8px 10px;
background: #f8f7f5;
border-radius: 8px;
margin-bottom: 6px;
}
.comment-meta {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.comment-author {
font-size: 12px;
font-weight: 600;
color: #3e3a44;
}
.comment-time {
font-size: 11px;
color: #b0aab5;
}
.comment-text {
font-size: 13px;
color: #6b6375;
line-height: 1.5;
}
.comment-add {
display: flex;
gap: 6px;
margin-top: 8px;
}
.form-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.overlay-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 480px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.overlay-header h3 {
margin: 0;
font-size: 17px;
color: #3e3a44;
}
.overlay-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 6px;
}
.form-textarea {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.form-textarea:focus {
border-color: #7ec6a4;
}
.priority-btns {
display: flex;
gap: 6px;
}
.priority-btn {
padding: 6px 16px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.priority-btn.active {
font-weight: 600;
}
.priority-btn.active.p-low { background: #f5f5f5; border-color: #9e9e9e; color: #616161; }
.priority-btn.active.p-normal { background: #e3f2fd; border-color: #64b5f6; color: #1565c0; }
.priority-btn.active.p-high { background: #fff3e0; border-color: #ffb74d; color: #e65100; }
.priority-btn.active.p-critical { background: #ffebee; border-color: #ef9a9a; color: #c62828; }
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<div class="inventory-page">
<!-- Search -->
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索精油..."
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<!-- Oil Picker Grid -->
<div class="section-label">点击添加到库存</div>
<div class="oil-picker-grid">
<div
v-for="name in filteredOilNames"
:key="name"
class="oil-pick-chip"
:class="{ owned: ownedSet.has(name) }"
@click="toggleOil(name)"
>
<span class="pick-dot" :class="{ active: ownedSet.has(name) }">{{ ownedSet.has(name) ? '✓' : '+' }}</span>
<span class="pick-name">{{ name }}</span>
</div>
<div v-if="filteredOilNames.length === 0" class="empty-hint">未找到精油</div>
</div>
<!-- Owned Oils Section -->
<div class="section-header">
<span>🧴 已有精油 ({{ ownedOils.length }})</span>
<button v-if="ownedOils.length" class="btn-sm btn-outline" @click="clearAll">清空</button>
</div>
<div v-if="ownedOils.length" class="owned-grid">
<div v-for="name in ownedOils" :key="name" class="owned-chip" @click="toggleOil(name)">
{{ name }}
</div>
</div>
<div v-else class="empty-hint">暂未添加精油点击上方精油添加到库存</div>
<!-- Matching Recipes Section -->
<div class="section-header" style="margin-top:20px">
<span>📋 可做的配方 ({{ matchingRecipes.length }})</span>
</div>
<div v-if="matchingRecipes.length" class="matching-list">
<div v-for="r in matchingRecipes" :key="r._id" class="match-card">
<div class="match-name">{{ r.name }}</div>
<div class="match-ings">
<span
v-for="ing in r.ingredients"
:key="ing.oil"
class="match-ing"
:class="{ missing: !ownedSet.has(ing.oil) }"
>
{{ ing.oil }} {{ ing.drops }}
</span>
</div>
<div class="match-meta">
<span class="match-coverage">覆盖 {{ coveragePercent(r) }}%</span>
<span v-if="missingOils(r).length" class="match-missing">
缺少: {{ missingOils(r).join(', ') }}
</span>
</div>
</div>
</div>
<div v-else class="empty-hint">
{{ ownedOils.length ? '暂无完全匹配的配方' : '添加精油后自动推荐配方' }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const searchQuery = ref('')
const ownedOils = ref([])
const loading = ref(false)
const ownedSet = computed(() => new Set(ownedOils.value))
const filteredOilNames = computed(() => {
if (!searchQuery.value.trim()) return oils.oilNames
const q = searchQuery.value.trim().toLowerCase()
return oils.oilNames.filter(n => n.toLowerCase().includes(q))
})
const matchingRecipes = computed(() => {
if (ownedOils.value.length === 0) return []
return recipeStore.recipes
.filter(r => {
const needed = r.ingredients.map(i => i.oil)
const coverage = needed.filter(o => ownedSet.value.has(o)).length
return coverage >= Math.ceil(needed.length * 0.5)
})
.sort((a, b) => {
const aCov = coverageRatio(a)
const bCov = coverageRatio(b)
return bCov - aCov
})
})
function coverageRatio(recipe) {
const needed = recipe.ingredients.map(i => i.oil)
if (needed.length === 0) return 0
return needed.filter(o => ownedSet.value.has(o)).length / needed.length
}
function coveragePercent(recipe) {
return Math.round(coverageRatio(recipe) * 100)
}
function missingOils(recipe) {
return recipe.ingredients
.map(i => i.oil)
.filter(o => !ownedSet.value.has(o))
}
async function loadInventory() {
loading.value = true
try {
const res = await api('/api/inventory')
if (res.ok) {
const data = await res.json()
ownedOils.value = data.oils || data || []
}
} catch {
// inventory may not exist yet
}
loading.value = false
}
async function saveInventory() {
try {
await api('/api/inventory', {
method: 'PUT',
body: JSON.stringify({ oils: ownedOils.value }),
})
} catch {
// silent save
}
}
async function toggleOil(name) {
const idx = ownedOils.value.indexOf(name)
if (idx >= 0) {
ownedOils.value.splice(idx, 1)
} else {
ownedOils.value.push(name)
ownedOils.value.sort((a, b) => a.localeCompare(b, 'zh'))
}
await saveInventory()
}
async function clearAll() {
ownedOils.value = []
await saveInventory()
ui.showToast('已清空库存')
}
onMounted(() => {
loadInventory()
})
</script>
<style scoped>
.inventory-page {
padding: 0 12px 24px;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
margin-bottom: 14px;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.section-label {
font-size: 12px;
color: #b0aab5;
margin-bottom: 8px;
padding: 0 4px;
}
.oil-picker-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
}
.oil-pick-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 20px;
background: #f8f7f5;
border: 1.5px solid #e5e4e7;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
color: #3e3a44;
}
.oil-pick-chip:hover {
border-color: #d4cfc7;
background: #f0eeeb;
}
.oil-pick-chip.owned {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
}
.pick-dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e5e4e7;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #999;
}
.pick-dot.active {
background: #4a9d7e;
color: #fff;
}
.pick-name {
font-size: 13px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 4px;
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 8px;
}
.owned-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.owned-chip {
padding: 6px 12px;
border-radius: 20px;
background: #e8f5e9;
border: 1.5px solid #7ec6a4;
font-size: 13px;
color: #2e7d5a;
cursor: pointer;
transition: all 0.15s;
}
.owned-chip:hover {
background: #ffebee;
border-color: #ef9a9a;
color: #c62828;
}
.matching-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.match-card {
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
}
.match-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 6px;
}
.match-ings {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.match-ing {
padding: 2px 8px;
border-radius: 10px;
background: #e8f5e9;
font-size: 12px;
color: #2e7d5a;
}
.match-ing.missing {
background: #fff3e0;
color: #e65100;
}
.match-meta {
display: flex;
gap: 10px;
align-items: center;
font-size: 12px;
}
.match-coverage {
color: #4a9d7e;
font-weight: 600;
}
.match-missing {
color: #999;
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
border-radius: 8px;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,872 @@
<template>
<div class="my-diary">
<!-- Sub Tabs -->
<div class="sub-tabs">
<button class="sub-tab" :class="{ active: activeTab === 'diary' }" @click="activeTab = 'diary'">📖 配方日记</button>
<button class="sub-tab" :class="{ active: activeTab === 'brand' }" @click="activeTab = 'brand'">🏷 Brand</button>
<button class="sub-tab" :class="{ active: activeTab === 'account' }" @click="activeTab = 'account'">👤 Account</button>
</div>
<!-- Diary Tab -->
<div v-if="activeTab === 'diary'" class="tab-content">
<!-- Smart Paste -->
<div class="paste-section">
<textarea
v-model="pasteText"
class="paste-input"
placeholder="粘贴配方文本,智能识别...&#10;例如: 舒缓配方薰衣草3滴茶树2滴"
rows="3"
></textarea>
<button class="btn-primary" @click="handleSmartPaste" :disabled="!pasteText.trim()">智能添加</button>
</div>
<!-- Diary Recipe Grid -->
<div class="diary-grid">
<div
v-for="d in diaryStore.userDiary"
:key="d._id || d.id"
class="diary-card"
:class="{ selected: selectedDiaryId === (d._id || d.id) }"
@click="selectDiary(d)"
>
<div class="diary-name">{{ d.name || '未命名' }}</div>
<div class="diary-ings">
<span v-for="ing in (d.ingredients || []).slice(0, 3)" :key="ing.oil" class="diary-ing">
{{ ing.oil }} {{ ing.drops }}
</span>
<span v-if="(d.ingredients || []).length > 3" class="diary-more">+{{ (d.ingredients || []).length - 3 }}</span>
</div>
<div class="diary-meta">
<span class="diary-cost" v-if="d.ingredients">{{ oils.fmtPrice(oils.calcCost(d.ingredients)) }}</span>
<span class="diary-entries">{{ (d.entries || []).length }} 条日志</span>
</div>
</div>
<div v-if="diaryStore.userDiary.length === 0" class="empty-hint">暂无配方日记</div>
</div>
<!-- Diary Detail Panel -->
<div v-if="selectedDiary" class="diary-detail">
<div class="detail-header">
<input v-model="selectedDiary.name" class="detail-name-input" @blur="updateCurrentDiary" />
<button class="btn-icon" @click="deleteDiary(selectedDiary)" title="删除">🗑</button>
</div>
<!-- Ingredients Editor -->
<div class="section-card">
<h4>成分</h4>
<div v-for="(ing, i) in selectedDiary.ingredients" :key="i" class="ing-row">
<select v-model="ing.oil" class="form-select" @change="updateCurrentDiary">
<option value="">选择精油</option>
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<input
v-model.number="ing.drops"
type="number"
min="0"
class="form-input-sm"
@change="updateCurrentDiary"
/>
<button class="btn-icon-sm" @click="selectedDiary.ingredients.splice(i, 1); updateCurrentDiary()"></button>
</div>
<button class="btn-outline btn-sm" @click="selectedDiary.ingredients.push({ oil: '', drops: 1 })">+ 添加</button>
</div>
<!-- Notes -->
<div class="section-card">
<h4>备注</h4>
<textarea
v-model="selectedDiary.note"
class="form-textarea"
rows="2"
placeholder="配方备注..."
@blur="updateCurrentDiary"
></textarea>
</div>
<!-- Journal Entries -->
<div class="section-card">
<h4>使用日志</h4>
<div class="entry-list">
<div v-for="entry in (selectedDiary.entries || [])" :key="entry._id || entry.id" class="entry-item">
<div class="entry-date">{{ formatDate(entry.created_at) }}</div>
<div class="entry-content">{{ entry.content || entry.text }}</div>
<button class="btn-icon-sm" @click="removeEntry(entry)"></button>
</div>
</div>
<div class="entry-add">
<input
v-model="newEntryText"
class="form-input"
placeholder="记录使用感受..."
@keydown.enter="addNewEntry"
/>
<button class="btn-primary btn-sm" @click="addNewEntry" :disabled="!newEntryText.trim()">添加</button>
</div>
</div>
</div>
</div>
<!-- Brand Tab -->
<div v-if="activeTab === 'brand'" class="tab-content">
<div class="section-card">
<h4>🏷 品牌设置</h4>
<div class="form-group">
<label>品牌名称</label>
<input v-model="brandName" class="form-input" placeholder="您的品牌名称" @blur="saveBrandSettings" />
</div>
<div class="form-group">
<label>二维码链接</label>
<input v-model="brandQrUrl" class="form-input" placeholder="https://..." @blur="saveBrandSettings" />
<div v-if="brandQrUrl" class="qr-preview">
<img :src="'https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=' + encodeURIComponent(brandQrUrl)" alt="QR" class="qr-img" />
</div>
</div>
<div class="form-group">
<label>品牌Logo</label>
<div class="upload-area" @click="triggerUpload('logo')">
<img v-if="brandLogo" :src="brandLogo" class="upload-preview" />
<span v-else class="upload-hint">点击上传Logo</span>
</div>
<input ref="logoInput" type="file" accept="image/*" style="display:none" @change="handleUpload('logo', $event)" />
</div>
<div class="form-group">
<label>卡片背景</label>
<div class="upload-area" @click="triggerUpload('bg')">
<img v-if="brandBg" :src="brandBg" class="upload-preview wide" />
<span v-else class="upload-hint">点击上传背景图</span>
</div>
<input ref="bgInput" type="file" accept="image/*" style="display:none" @change="handleUpload('bg', $event)" />
</div>
</div>
</div>
<!-- Account Tab -->
<div v-if="activeTab === 'account'" class="tab-content">
<div class="section-card">
<h4>👤 账号设置</h4>
<div class="form-group">
<label>显示名称</label>
<input v-model="displayName" class="form-input" />
<button class="btn-primary btn-sm" style="margin-top:6px" @click="updateDisplayName">保存</button>
</div>
<div class="form-group">
<label>用户名</label>
<div class="form-static">{{ auth.user.username }}</div>
</div>
<div class="form-group">
<label>角色</label>
<div class="form-static role-badge">{{ roleLabel }}</div>
</div>
</div>
<div class="section-card">
<h4>🔑 修改密码</h4>
<div class="form-group">
<label>{{ auth.user.has_password ? '当前密码' : '(首次设置密码)' }}</label>
<input v-if="auth.user.has_password" v-model="oldPassword" type="password" class="form-input" placeholder="当前密码" />
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" class="form-input" placeholder="新密码" />
</div>
<div class="form-group">
<label>确认密码</label>
<input v-model="confirmPassword" type="password" class="form-input" placeholder="确认新密码" />
</div>
<button class="btn-primary" @click="changePassword">修改密码</button>
</div>
<!-- Business Verification -->
<div v-if="!auth.isBusiness" class="section-card">
<h4>💼 商业认证</h4>
<p class="hint-text">申请商业认证后可使用商业核算功能</p>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的申请理由..."></textarea>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessReason.trim()">提交申请</button>
</div>
<div v-else class="section-card">
<h4>💼 商业认证</h4>
<div class="verified-badge"> 已认证商业用户</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useDiaryStore } from '../stores/diary'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showAlert } from '../composables/useDialog'
import { parseSingleBlock } from '../composables/useSmartPaste'
const auth = useAuthStore()
const oils = useOilsStore()
const diaryStore = useDiaryStore()
const ui = useUiStore()
const activeTab = ref('diary')
const pasteText = ref('')
const selectedDiaryId = ref(null)
const selectedDiary = ref(null)
const newEntryText = ref('')
// Brand settings
const brandName = ref('')
const brandQrUrl = ref('')
const brandLogo = ref('')
const brandBg = ref('')
const logoInput = ref(null)
const bgInput = ref(null)
// Account settings
const displayName = ref('')
const oldPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const businessReason = ref('')
const roleLabel = computed(() => {
const roles = {
admin: '管理员',
senior_editor: '高级编辑',
editor: '编辑',
viewer: '查看者',
}
return roles[auth.user.role] || auth.user.role
})
onMounted(async () => {
await diaryStore.loadDiary()
displayName.value = auth.user.display_name || ''
await loadBrandSettings()
})
function selectDiary(d) {
const id = d._id || d.id
selectedDiaryId.value = id
selectedDiary.value = {
...d,
_id: id,
ingredients: (d.ingredients || []).map(i => ({ ...i })),
entries: d.entries || [],
note: d.note || '',
}
}
async function handleSmartPaste() {
const result = parseSingleBlock(pasteText.value, oils.oilNames)
try {
await diaryStore.createDiary({
name: result.name,
ingredients: result.ingredients,
note: '',
})
pasteText.value = ''
ui.showToast('已添加配方日记')
if (result.notFound.length > 0) {
ui.showToast(`未识别: ${result.notFound.join(', ')}`)
}
} catch (e) {
ui.showToast('添加失败')
}
}
async function updateCurrentDiary() {
if (!selectedDiary.value) return
try {
await diaryStore.updateDiary(selectedDiary.value._id, {
name: selectedDiary.value.name,
ingredients: selectedDiary.value.ingredients.filter(i => i.oil),
note: selectedDiary.value.note,
})
} catch {
// silent
}
}
async function deleteDiary(d) {
const ok = await showConfirm(`确定删除 "${d.name}"`)
if (!ok) return
await diaryStore.deleteDiary(d._id)
selectedDiary.value = null
selectedDiaryId.value = null
ui.showToast('已删除')
}
async function addNewEntry() {
if (!newEntryText.value.trim() || !selectedDiary.value) return
try {
await diaryStore.addEntry(selectedDiary.value._id, {
text: newEntryText.value.trim(),
})
newEntryText.value = ''
// Refresh diary to get new entries
const updated = diaryStore.userDiary.find(d => (d._id || d.id) === selectedDiary.value._id)
if (updated) selectDiary(updated)
ui.showToast('已添加日志')
} catch {
ui.showToast('添加失败')
}
}
async function removeEntry(entry) {
const ok = await showConfirm('确定删除此日志?')
if (!ok) return
try {
await diaryStore.deleteEntry(entry._id || entry.id)
const updated = diaryStore.userDiary.find(d => (d._id || d.id) === selectedDiary.value._id)
if (updated) selectDiary(updated)
} catch {
ui.showToast('删除失败')
}
}
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
// Brand settings
async function loadBrandSettings() {
try {
const res = await api('/api/brand-settings')
if (res.ok) {
const data = await res.json()
brandName.value = data.brand_name || ''
brandQrUrl.value = data.qr_url || ''
brandLogo.value = data.logo_url || ''
brandBg.value = data.bg_url || ''
}
} catch {
// no brand settings yet
}
}
async function saveBrandSettings() {
try {
await api('/api/brand-settings', {
method: 'PUT',
body: JSON.stringify({
brand_name: brandName.value,
qr_url: brandQrUrl.value,
}),
})
} catch {
// silent
}
}
function triggerUpload(type) {
if (type === 'logo') logoInput.value?.click()
else bgInput.value?.click()
}
async function handleUpload(type, event) {
const file = event.target.files[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
formData.append('type', type)
try {
const token = localStorage.getItem('oil_auth_token') || ''
const res = await fetch('/api/brand-upload', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: formData,
})
if (res.ok) {
const data = await res.json()
if (type === 'logo') brandLogo.value = data.url
else brandBg.value = data.url
ui.showToast('上传成功')
}
} catch {
ui.showToast('上传失败')
}
}
// Account
async function updateDisplayName() {
try {
await api('/api/me/display-name', {
method: 'PUT',
body: JSON.stringify({ display_name: displayName.value }),
})
auth.user.display_name = displayName.value
ui.showToast('已更新')
} catch {
ui.showToast('更新失败')
}
}
async function changePassword() {
if (newPassword.value !== confirmPassword.value) {
await showAlert('两次密码输入不一致')
return
}
if (newPassword.value.length < 4) {
await showAlert('密码至少4个字符')
return
}
try {
await api('/api/me/password', {
method: 'PUT',
body: JSON.stringify({
old_password: oldPassword.value,
new_password: newPassword.value,
}),
})
oldPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
ui.showToast('密码已修改')
await auth.loadMe()
} catch {
ui.showToast('修改失败')
}
}
async function applyBusiness() {
try {
await api('/api/business-apply', {
method: 'POST',
body: JSON.stringify({ reason: businessReason.value }),
})
businessReason.value = ''
ui.showToast('申请已提交,请等待审核')
} catch {
ui.showToast('提交失败')
}
}
</script>
<style scoped>
.my-diary {
padding: 0 12px 24px;
}
.sub-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
background: #f8f7f5;
border-radius: 12px;
padding: 4px;
}
.sub-tab {
flex: 1;
border: none;
background: transparent;
padding: 10px 8px;
border-radius: 10px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
color: #6b6375;
transition: all 0.15s;
font-weight: 500;
}
.sub-tab.active {
background: #fff;
color: #3e3a44;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.tab-content {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.paste-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.paste-input {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.paste-input:focus {
border-color: #7ec6a4;
}
.diary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.diary-card {
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.diary-card:hover {
border-color: #d4cfc7;
}
.diary-card.selected {
border-color: #7ec6a4;
background: #f0faf5;
}
.diary-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 6px;
}
.diary-ings {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.diary-ing {
padding: 2px 8px;
background: #f0eeeb;
border-radius: 10px;
font-size: 11px;
color: #6b6375;
}
.diary-more {
font-size: 11px;
color: #b0aab5;
}
.diary-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.diary-cost {
color: #4a9d7e;
font-weight: 500;
}
.diary-entries {
color: #b0aab5;
}
/* Detail panel */
.diary-detail {
margin-top: 16px;
padding: 16px;
background: #f8f7f5;
border-radius: 14px;
border: 1.5px solid #e5e4e7;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.detail-name-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
outline: none;
}
.detail-name-input:focus {
border-color: #7ec6a4;
}
.section-card {
margin-bottom: 14px;
padding: 12px;
background: #fff;
border-radius: 10px;
border: 1.5px solid #e5e4e7;
}
.section-card h4 {
margin: 0 0 10px;
font-size: 13px;
color: #3e3a44;
}
.ing-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.form-select {
flex: 1;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.form-input-sm {
width: 60px;
padding: 8px 8px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.form-textarea {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.entry-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.entry-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: #f8f7f5;
border-radius: 8px;
}
.entry-date {
font-size: 11px;
color: #b0aab5;
white-space: nowrap;
margin-top: 2px;
}
.entry-content {
flex: 1;
font-size: 13px;
color: #3e3a44;
line-height: 1.5;
}
.entry-add {
display: flex;
gap: 6px;
}
.form-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
/* Brand */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 6px;
}
.form-static {
padding: 8px 12px;
background: #f0eeeb;
border-radius: 8px;
font-size: 14px;
color: #6b6375;
}
.role-badge {
display: inline-block;
}
.qr-preview {
margin-top: 10px;
text-align: center;
}
.qr-img {
width: 120px;
height: 120px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
}
.upload-area {
width: 100%;
min-height: 80px;
border: 2px dashed #d4cfc7;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.15s;
overflow: hidden;
}
.upload-area:hover {
border-color: #7ec6a4;
}
.upload-preview {
max-width: 80px;
max-height: 80px;
object-fit: contain;
}
.upload-preview.wide {
max-width: 200px;
max-height: 100px;
}
.upload-hint {
font-size: 13px;
color: #b0aab5;
}
.hint-text {
font-size: 13px;
color: #6b6375;
margin-bottom: 12px;
}
.verified-badge {
padding: 12px;
background: #e8f5e9;
border-radius: 10px;
color: #2e7d5a;
font-weight: 500;
text-align: center;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-sm {
padding: 6px 14px;
font-size: 12px;
border-radius: 8px;
}
.btn-icon {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
padding: 4px;
}
.btn-icon-sm {
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
padding: 2px;
color: #999;
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.diary-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,788 @@
<template>
<div class="oil-reference">
<!-- Knowledge Cards -->
<div class="knowledge-cards">
<div class="kcard" @click="showDilution = true">
<span class="kcard-icon">💧</span>
<span class="kcard-title">稀释比例</span>
<span class="kcard-arrow"></span>
</div>
<div class="kcard" @click="showContra = true">
<span class="kcard-icon"></span>
<span class="kcard-title">使用禁忌</span>
<span class="kcard-arrow"></span>
</div>
</div>
<!-- Info Overlays -->
<div v-if="showDilution" class="info-overlay" @click.self="showDilution = false">
<div class="info-panel">
<div class="info-header">
<h3>💧 稀释比例参考</h3>
<button class="btn-close" @click="showDilution = false"></button>
</div>
<div class="info-body">
<table class="info-table">
<thead>
<tr><th>用途</th><th>比例</th><th>每10ml基底油</th></tr>
</thead>
<tbody>
<tr><td>面部护肤</td><td>1%</td><td>2滴精油</td></tr>
<tr><td>身体按摩</td><td>2-3%</td><td>4-6滴精油</td></tr>
<tr><td>局部疼痛</td><td>3-5%</td><td>6-10滴精油</td></tr>
<tr><td>急救用途</td><td>5-10%</td><td>10-20滴精油</td></tr>
<tr><td>儿童(2-6)</td><td>0.5-1%</td><td>1-2滴精油</td></tr>
<tr><td>婴儿(&lt;2)</td><td>0.25%</td><td>0.5滴精油</td></tr>
</tbody>
</table>
<p class="info-note">* 1ml 约等于 {{ DROPS_PER_ML }} </p>
</div>
</div>
</div>
<div v-if="showContra" class="info-overlay" @click.self="showContra = false">
<div class="info-panel">
<div class="info-header">
<h3> 使用禁忌</h3>
<button class="btn-close" @click="showContra = false"></button>
</div>
<div class="info-body">
<div class="contra-section">
<h4>光敏性精油涂抹后12小时内避免阳光直射</h4>
<p>柠檬佛手柑葡萄柚莱姆甜橙野橘</p>
</div>
<div class="contra-section">
<h4>孕妇慎用</h4>
<p>快乐鼠尾草迷迭香肉桂丁香百里香牛至冬青</p>
</div>
<div class="contra-section">
<h4>儿童慎用</h4>
<p>椒样薄荷6岁以下避免尤加利10岁以下慎用冬青肉桂</p>
</div>
<div class="contra-section">
<h4>宠物禁用</h4>
<p>茶树尤加利肉桂丁香百里香冬青对猫有毒</p>
</div>
</div>
</div>
</div>
<!-- Add Oil Form (admin/senior_editor) -->
<div v-if="auth.canEdit" class="add-oil-form">
<h3 class="section-title">添加精油</h3>
<div class="form-row">
<input v-model="newOilName" class="form-input" placeholder="精油名称" />
<input v-model.number="newBottlePrice" class="form-input-sm" type="number" placeholder="瓶价 ¥" />
<input v-model.number="newDropCount" class="form-input-sm" type="number" placeholder="滴数" />
<input v-model.number="newRetailPrice" class="form-input-sm" type="number" placeholder="零售价 ¥" />
<button class="btn-primary" @click="addOil" :disabled="!newOilName.trim()">添加</button>
</div>
</div>
<!-- Search & View Toggle -->
<div class="toolbar">
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索精油..."
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<div class="view-toggle">
<button
class="toggle-btn"
:class="{ active: viewMode === 'bottle' }"
@click="viewMode = 'bottle'"
>🧴 瓶价</button>
<button
class="toggle-btn"
:class="{ active: viewMode === 'drop' }"
@click="viewMode = 'drop'"
>💧 滴价</button>
</div>
</div>
<!-- Oil Grid -->
<div class="oil-grid">
<div
v-for="name in filteredOilNames"
:key="name"
class="oil-card"
@click="selectOil(name)"
>
<div class="oil-name">{{ name }}</div>
<div class="oil-price" v-if="viewMode === 'bottle'">
{{ getMeta(name)?.bottlePrice != null ? ('¥ ' + getMeta(name).bottlePrice.toFixed(2)) : '--' }}
<span class="oil-count" v-if="getMeta(name)?.dropCount">({{ getMeta(name).dropCount }})</span>
</div>
<div class="oil-price" v-else>
{{ oils.pricePerDrop(name) ? ('¥ ' + oils.pricePerDrop(name).toFixed(4)) : '--' }}
<span class="oil-unit">/</span>
</div>
<div v-if="getMeta(name)?.retailPrice" class="oil-retail">
零售 ¥ {{ getMeta(name).retailPrice.toFixed(2) }}
</div>
<div class="oil-actions" v-if="auth.isAdmin" @click.stop>
<button class="btn-icon-sm" @click="editOil(name)" title="编辑"></button>
<button class="btn-icon-sm" @click="removeOil(name)" title="删除">🗑</button>
</div>
</div>
<div v-if="filteredOilNames.length === 0" class="empty-hint">未找到精油</div>
</div>
<!-- Oil Detail Card -->
<div v-if="selectedOilName" class="oil-detail-overlay" @click.self="selectedOilName = null">
<div class="oil-detail-panel">
<div class="detail-header">
<h3>{{ selectedOilName }}</h3>
<button class="btn-close" @click="selectedOilName = null"></button>
</div>
<div class="detail-body">
<div class="detail-row">
<span class="detail-label">瓶价</span>
<span class="detail-value">{{ getMeta(selectedOilName)?.bottlePrice != null ? ('¥ ' + getMeta(selectedOilName).bottlePrice.toFixed(2)) : '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">总滴数</span>
<span class="detail-value">{{ getMeta(selectedOilName)?.dropCount || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">每滴价格</span>
<span class="detail-value">{{ oils.pricePerDrop(selectedOilName) ? ('¥ ' + oils.pricePerDrop(selectedOilName).toFixed(4)) : '--' }}</span>
</div>
<div class="detail-row" v-if="getMeta(selectedOilName)?.retailPrice">
<span class="detail-label">零售价</span>
<span class="detail-value">¥ {{ getMeta(selectedOilName).retailPrice.toFixed(2) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">每ml价格</span>
<span class="detail-value">{{ oils.pricePerDrop(selectedOilName) ? ('¥ ' + (oils.pricePerDrop(selectedOilName) * DROPS_PER_ML).toFixed(2)) : '--' }}</span>
</div>
<h4 style="margin:16px 0 8px">含此精油的配方</h4>
<div v-if="recipesWithOil.length" class="detail-recipes">
<div v-for="r in recipesWithOil" :key="r._id" class="detail-recipe-item">
<span class="dr-name">{{ r.name }}</span>
<span class="dr-drops">{{ getDropsForOil(r, selectedOilName) }}</span>
</div>
</div>
<div v-else class="empty-hint">暂无使用此精油的配方</div>
</div>
</div>
</div>
<!-- Edit Oil Overlay -->
<div v-if="editingOilName" class="info-overlay" @click.self="editingOilName = null">
<div class="info-panel" style="max-width:400px">
<div class="info-header">
<h3>编辑精油: {{ editingOilName }}</h3>
<button class="btn-close" @click="editingOilName = null"></button>
</div>
<div class="info-body">
<div class="form-group">
<label>瓶价 (¥)</label>
<input v-model.number="editBottlePrice" class="form-input" type="number" />
</div>
<div class="form-group">
<label>滴数</label>
<input v-model.number="editDropCount" class="form-input" type="number" />
</div>
<div class="form-group">
<label>零售价 (¥)</label>
<input v-model.number="editRetailPrice" class="form-input" type="number" />
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px">
<button class="btn-outline" @click="editingOilName = null">取消</button>
<button class="btn-primary" @click="saveEditOil">保存</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore, DROPS_PER_ML } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const searchQuery = ref('')
const viewMode = ref('bottle')
const selectedOilName = ref(null)
const showDilution = ref(false)
const showContra = ref(false)
// Add oil form
const newOilName = ref('')
const newBottlePrice = ref(null)
const newDropCount = ref(null)
const newRetailPrice = ref(null)
// Edit oil
const editingOilName = ref(null)
const editBottlePrice = ref(0)
const editDropCount = ref(0)
const editRetailPrice = ref(null)
const filteredOilNames = computed(() => {
if (!searchQuery.value.trim()) return oils.oilNames
const q = searchQuery.value.trim().toLowerCase()
return oils.oilNames.filter(n => n.toLowerCase().includes(q))
})
const recipesWithOil = computed(() => {
if (!selectedOilName.value) return []
return recipeStore.recipes.filter(r =>
r.ingredients.some(i => i.oil === selectedOilName.value)
)
})
function getMeta(name) {
return oils.oilsMeta.get(name)
}
function getDropsForOil(recipe, oilName) {
const ing = recipe.ingredients.find(i => i.oil === oilName)
return ing ? ing.drops : 0
}
function selectOil(name) {
selectedOilName.value = name
}
async function addOil() {
if (!newOilName.value.trim()) return
try {
await oils.saveOil(
newOilName.value.trim(),
newBottlePrice.value || 0,
newDropCount.value || 0,
newRetailPrice.value || null
)
ui.showToast(`已添加: ${newOilName.value}`)
newOilName.value = ''
newBottlePrice.value = null
newDropCount.value = null
newRetailPrice.value = null
} catch (e) {
ui.showToast('添加失败: ' + (e.message || ''))
}
}
function editOil(name) {
editingOilName.value = name
const meta = oils.oilsMeta.get(name)
editBottlePrice.value = meta?.bottlePrice || 0
editDropCount.value = meta?.dropCount || 0
editRetailPrice.value = meta?.retailPrice || null
}
async function saveEditOil() {
try {
await oils.saveOil(
editingOilName.value,
editBottlePrice.value,
editDropCount.value,
editRetailPrice.value
)
ui.showToast('已更新')
editingOilName.value = null
} catch (e) {
ui.showToast('更新失败')
}
}
async function removeOil(name) {
const ok = await showConfirm(`确定删除精油 "${name}"`)
if (!ok) return
try {
await oils.deleteOil(name)
ui.showToast('已删除')
} catch {
ui.showToast('删除失败')
}
}
</script>
<style scoped>
.oil-reference {
padding: 0 12px 24px;
}
.knowledge-cards {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.kcard {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: linear-gradient(135deg, #f8f7f5, #f0eeeb);
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.kcard:hover {
border-color: #7ec6a4;
background: linear-gradient(135deg, #f0faf5, #e8f5e9);
}
.kcard-icon {
font-size: 22px;
}
.kcard-title {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
flex: 1;
}
.kcard-arrow {
color: #b0aab5;
font-size: 18px;
}
/* Info overlay */
.info-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.info-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 520px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.info-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.info-header h3 {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.info-body {
font-size: 14px;
color: #3e3a44;
line-height: 1.7;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
}
.info-table th,
.info-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #e5e4e7;
font-size: 13px;
}
.info-table th {
font-weight: 600;
color: #6b6375;
background: #f8f7f5;
}
.info-note {
font-size: 12px;
color: #b0aab5;
margin-top: 8px;
}
.contra-section {
margin-bottom: 14px;
}
.contra-section h4 {
font-size: 14px;
margin: 0 0 4px;
color: #e65100;
}
.contra-section p {
font-size: 13px;
color: #6b6375;
margin: 0;
}
/* Add oil form */
.add-oil-form {
margin-bottom: 16px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin: 0 0 10px;
}
.form-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.form-input {
flex: 1;
min-width: 100px;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
.form-input-sm {
width: 80px;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 4px;
}
/* Toolbar */
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
align-items: center;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
flex: 1;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.view-toggle {
display: flex;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
overflow: hidden;
}
.toggle-btn {
border: none;
background: #fff;
padding: 8px 14px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
transition: all 0.15s;
white-space: nowrap;
}
.toggle-btn.active {
background: linear-gradient(135deg, #7ec6a4, #4a9d7e);
color: #fff;
}
/* Oil grid */
.oil-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.oil-card {
padding: 12px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
position: relative;
}
.oil-card:hover {
border-color: #7ec6a4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.oil-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 4px;
}
.oil-price {
font-size: 14px;
color: #4a9d7e;
font-weight: 500;
}
.oil-count {
font-size: 11px;
color: #b0aab5;
font-weight: 400;
}
.oil-unit {
font-size: 11px;
color: #b0aab5;
}
.oil-retail {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.oil-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.oil-card:hover .oil-actions {
opacity: 1;
}
.btn-icon-sm {
border: none;
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 6px;
}
.btn-icon-sm:hover {
background: #f0eeeb;
}
/* Detail overlay */
.oil-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.oil-detail-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 440px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.detail-header h3 {
margin: 0;
font-size: 18px;
color: #3e3a44;
}
.detail-body h4 {
font-size: 14px;
color: #3e3a44;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0eeeb;
font-size: 14px;
}
.detail-label {
color: #6b6375;
}
.detail-value {
font-weight: 600;
color: #3e3a44;
}
.detail-recipes {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-recipe-item {
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: #f8f7f5;
border-radius: 8px;
font-size: 13px;
}
.dr-name {
color: #3e3a44;
}
.dr-drops {
color: #4a9d7e;
font-weight: 500;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.oil-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.form-row {
flex-direction: column;
}
.form-input-sm {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,759 @@
<template>
<div class="projects-page">
<!-- Project List -->
<div class="toolbar">
<h3 class="page-title">💼 商业核算</h3>
<button class="btn-primary" @click="createProject">+ 新建项目</button>
</div>
<div v-if="!selectedProject" class="project-list">
<div
v-for="p in projects"
:key="p._id || p.id"
class="project-card"
@click="selectProject(p)"
>
<div class="proj-header">
<span class="proj-name">{{ p.name }}</span>
<span class="proj-date">{{ formatDate(p.updated_at || p.created_at) }}</span>
</div>
<div class="proj-summary">
<span>成分: {{ (p.ingredients || []).length }} </span>
<span class="proj-cost" v-if="p.ingredients && p.ingredients.length">
成本 {{ oils.fmtPrice(oils.calcCost(p.ingredients)) }}
</span>
</div>
<div class="proj-actions" @click.stop>
<button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑</button>
</div>
</div>
<div v-if="projects.length === 0" class="empty-hint">暂无项目点击上方创建</div>
</div>
<!-- Project Detail -->
<div v-if="selectedProject" class="project-detail">
<div class="detail-toolbar">
<button class="btn-back" @click="selectedProject = null">&larr; 返回列表</button>
<input
v-model="selectedProject.name"
class="proj-name-input"
@blur="saveProject"
/>
<button class="btn-outline btn-sm" @click="importFromRecipe">📋 从配方导入</button>
</div>
<!-- Ingredients Editor -->
<div class="ingredients-section">
<h4>🧴 配方成分</h4>
<div v-for="(ing, i) in selectedProject.ingredients" :key="i" class="ing-row">
<select v-model="ing.oil" class="form-select" @change="saveProject">
<option value="">选择精油</option>
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<input
v-model.number="ing.drops"
type="number"
min="0"
class="form-input-sm"
placeholder="滴数"
@change="saveProject"
/>
<span class="ing-cost">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * (ing.drops || 0)) : '--' }}</span>
<button class="btn-icon-sm" @click="removeIngredient(i)"></button>
</div>
<button class="btn-outline btn-sm" @click="addIngredient">+ 添加成分</button>
</div>
<!-- Pricing Section -->
<div class="pricing-section">
<h4>💰 价格计算</h4>
<div class="price-row">
<span class="price-label">原料成本</span>
<span class="price-value cost">{{ oils.fmtPrice(materialCost) }}</span>
</div>
<div class="price-row">
<span class="price-label">包装费用</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.packaging_cost" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row">
<span class="price-label">人工费用</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.labor_cost" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row">
<span class="price-label">其他成本</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.other_cost" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row total">
<span class="price-label">总成本</span>
<span class="price-value cost">{{ oils.fmtPrice(totalCost) }}</span>
</div>
<div class="price-row">
<span class="price-label">售价</span>
<div class="price-input-wrap">
<span>¥</span>
<input v-model.number="selectedProject.selling_price" type="number" class="form-input-inline" @change="saveProject" />
</div>
</div>
<div class="price-row">
<span class="price-label">批量数量</span>
<input v-model.number="selectedProject.quantity" type="number" min="1" class="form-input-inline" @change="saveProject" />
</div>
</div>
<!-- Profit Analysis -->
<div class="profit-section">
<h4>📊 利润分析</h4>
<div class="profit-grid">
<div class="profit-card">
<div class="profit-label">单件利润</div>
<div class="profit-value" :class="{ negative: unitProfit < 0 }">{{ oils.fmtPrice(unitProfit) }}</div>
</div>
<div class="profit-card">
<div class="profit-label">利润率</div>
<div class="profit-value" :class="{ negative: profitMargin < 0 }">{{ profitMargin.toFixed(1) }}%</div>
</div>
<div class="profit-card">
<div class="profit-label">批量总利润</div>
<div class="profit-value" :class="{ negative: batchProfit < 0 }">{{ oils.fmtPrice(batchProfit) }}</div>
</div>
<div class="profit-card">
<div class="profit-label">批量总收入</div>
<div class="profit-value">{{ oils.fmtPrice(batchRevenue) }}</div>
</div>
</div>
</div>
<!-- Notes -->
<div class="notes-section">
<h4>📝 备注</h4>
<textarea
v-model="selectedProject.notes"
class="notes-textarea"
rows="3"
placeholder="项目备注..."
@blur="saveProject"
></textarea>
</div>
</div>
<!-- Import From Recipe Modal -->
<div v-if="showImportModal" class="overlay" @click.self="showImportModal = false">
<div class="overlay-panel">
<div class="overlay-header">
<h3>从配方导入</h3>
<button class="btn-close" @click="showImportModal = false"></button>
</div>
<div class="recipe-import-list">
<div
v-for="r in recipeStore.recipes"
:key="r._id"
class="import-item"
@click="doImport(r)"
>
<span class="import-name">{{ r.name }}</span>
<span class="import-count">{{ r.ingredients.length }} 种精油</span>
<span class="import-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const projects = ref([])
const selectedProject = ref(null)
const showImportModal = ref(false)
onMounted(async () => {
await loadProjects()
})
async function loadProjects() {
try {
const res = await api('/api/projects')
if (res.ok) {
projects.value = await res.json()
}
} catch {
projects.value = []
}
}
async function createProject() {
const name = await showPrompt('项目名称:', '新项目')
if (!name) return
try {
const res = await api('/api/projects', {
method: 'POST',
body: JSON.stringify({
name,
ingredients: [],
packaging_cost: 0,
labor_cost: 0,
other_cost: 0,
selling_price: 0,
quantity: 1,
notes: '',
}),
})
if (res.ok) {
await loadProjects()
const data = await res.json()
selectedProject.value = projects.value.find(p => (p._id || p.id) === (data._id || data.id)) || null
ui.showToast('项目已创建')
}
} catch {
ui.showToast('创建失败')
}
}
function selectProject(p) {
selectedProject.value = {
...p,
ingredients: (p.ingredients || []).map(i => ({ ...i })),
packaging_cost: p.packaging_cost || 0,
labor_cost: p.labor_cost || 0,
other_cost: p.other_cost || 0,
selling_price: p.selling_price || 0,
quantity: p.quantity || 1,
notes: p.notes || '',
}
}
async function saveProject() {
if (!selectedProject.value) return
const id = selectedProject.value._id || selectedProject.value.id
try {
await api(`/api/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(selectedProject.value),
})
await loadProjects()
} catch {
// silent save
}
}
async function deleteProject(p) {
const ok = await showConfirm(`确定删除项目 "${p.name}"`)
if (!ok) return
const id = p._id || p.id
try {
await api(`/api/projects/${id}`, { method: 'DELETE' })
projects.value = projects.value.filter(proj => (proj._id || proj.id) !== id)
if (selectedProject.value && (selectedProject.value._id || selectedProject.value.id) === id) {
selectedProject.value = null
}
ui.showToast('已删除')
} catch {
ui.showToast('删除失败')
}
}
function addIngredient() {
if (!selectedProject.value) return
selectedProject.value.ingredients.push({ oil: '', drops: 1 })
}
function removeIngredient(index) {
selectedProject.value.ingredients.splice(index, 1)
saveProject()
}
function importFromRecipe() {
showImportModal.value = true
}
function doImport(recipe) {
if (!selectedProject.value) return
selectedProject.value.ingredients = recipe.ingredients.map(i => ({ ...i }))
showImportModal.value = false
saveProject()
ui.showToast(`已导入 "${recipe.name}" 的配方`)
}
const materialCost = computed(() => {
if (!selectedProject.value) return 0
return oils.calcCost(selectedProject.value.ingredients.filter(i => i.oil))
})
const totalCost = computed(() => {
if (!selectedProject.value) return 0
return materialCost.value +
(selectedProject.value.packaging_cost || 0) +
(selectedProject.value.labor_cost || 0) +
(selectedProject.value.other_cost || 0)
})
const unitProfit = computed(() => {
if (!selectedProject.value) return 0
return (selectedProject.value.selling_price || 0) - totalCost.value
})
const profitMargin = computed(() => {
if (!selectedProject.value || !selectedProject.value.selling_price) return 0
return (unitProfit.value / selectedProject.value.selling_price) * 100
})
const batchProfit = computed(() => {
return unitProfit.value * (selectedProject.value?.quantity || 1)
})
const batchRevenue = computed(() => {
return (selectedProject.value?.selling_price || 0) * (selectedProject.value?.quantity || 1)
})
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleDateString('zh-CN')
}
</script>
<style scoped>
.projects-page {
padding: 0 12px 24px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.project-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.project-card {
padding: 14px 16px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
position: relative;
}
.project-card:hover {
border-color: #7ec6a4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.proj-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.proj-name {
font-weight: 600;
font-size: 15px;
color: #3e3a44;
}
.proj-date {
font-size: 12px;
color: #b0aab5;
}
.proj-summary {
display: flex;
gap: 12px;
font-size: 13px;
color: #6b6375;
}
.proj-cost {
color: #4a9d7e;
font-weight: 500;
}
.proj-actions {
position: absolute;
top: 12px;
right: 12px;
opacity: 0;
transition: opacity 0.15s;
}
.project-card:hover .proj-actions {
opacity: 1;
}
/* Detail */
.detail-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.btn-back {
border: none;
background: #f0eeeb;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
color: #6b6375;
}
.btn-back:hover {
background: #e5e4e7;
}
.proj-name-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
outline: none;
min-width: 120px;
}
.proj-name-input:focus {
border-color: #7ec6a4;
}
.ingredients-section,
.pricing-section,
.profit-section,
.notes-section {
margin-bottom: 20px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.ingredients-section h4,
.pricing-section h4,
.profit-section h4,
.notes-section h4 {
margin: 0 0 12px;
font-size: 14px;
color: #3e3a44;
}
.ing-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.form-select {
flex: 1;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.form-input-sm {
width: 70px;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.ing-cost {
font-size: 13px;
color: #4a9d7e;
font-weight: 500;
min-width: 60px;
text-align: right;
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #eae8e5;
font-size: 14px;
}
.price-row.total {
border-top: 2px solid #d4cfc7;
border-bottom: 2px solid #d4cfc7;
font-weight: 600;
padding: 10px 0;
}
.price-label {
color: #6b6375;
}
.price-value {
font-weight: 600;
color: #3e3a44;
}
.price-value.cost {
color: #4a9d7e;
}
.price-input-wrap {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #3e3a44;
}
.form-input-inline {
width: 80px;
padding: 6px 8px;
border: 1.5px solid #d4cfc7;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
outline: none;
text-align: right;
}
.form-input-inline:focus {
border-color: #7ec6a4;
}
.profit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.profit-card {
padding: 12px;
background: #fff;
border-radius: 10px;
text-align: center;
border: 1.5px solid #e5e4e7;
}
.profit-label {
font-size: 12px;
color: #6b6375;
margin-bottom: 4px;
}
.profit-value {
font-size: 18px;
font-weight: 700;
color: #4a9d7e;
}
.profit-value.negative {
color: #ef5350;
}
.notes-textarea {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.notes-textarea:focus {
border-color: #7ec6a4;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.overlay-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 480px;
width: 100%;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.overlay-header h3 {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.recipe-import-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.import-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
.import-item:hover {
background: #f0faf5;
}
.import-name {
flex: 1;
font-weight: 500;
font-size: 14px;
color: #3e3a44;
}
.import-count {
font-size: 12px;
color: #b0aab5;
}
.import-cost {
font-size: 13px;
color: #4a9d7e;
font-weight: 500;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-sm {
padding: 6px 14px;
font-size: 12px;
border-radius: 8px;
}
.btn-icon-sm {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 6px;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.profit-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,967 @@
<template>
<div class="recipe-manager">
<!-- Review Bar (admin only) -->
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending">
📝 待审核配方: {{ pendingCount }}
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
</div>
<div v-if="showPending && pendingRecipes.length" class="pending-list">
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
<span class="pending-name">{{ r.name }}</span>
<span class="pending-owner">{{ r._owner_name }}</span>
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
</div>
</div>
<!-- Search & Actions Bar -->
<div class="manage-toolbar">
<div class="search-box">
<input
class="search-input"
v-model="manageSearch"
placeholder="搜索配方..."
/>
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''"></button>
</div>
<button class="btn-primary" @click="showAddOverlay = true">+ 添加配方</button>
<button class="btn-outline" @click="exportExcel">📊 导出Excel</button>
</div>
<!-- Tag Filter Bar -->
<div class="tag-filter-bar">
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
🏷 标签筛选 {{ showTagFilter ? '' : '' }}
</button>
<div v-if="showTagFilter" class="tag-list">
<span
v-for="tag in recipeStore.allTags"
:key="tag"
class="tag-chip"
:class="{ active: selectedTags.includes(tag) }"
@click="toggleTag(tag)"
>{{ tag }}</span>
</div>
</div>
<!-- Batch Operations -->
<div v-if="selectedIds.size > 0" class="batch-bar">
<span>已选 {{ selectedIds.size }} </span>
<select v-model="batchAction" class="batch-select">
<option value="">批量操作...</option>
<option value="tag">添加标签</option>
<option value="share">分享</option>
<option value="export">导出卡片</option>
<option value="delete">删除</option>
</select>
<button class="btn-sm btn-primary" @click="executeBatch" :disabled="!batchAction">执行</button>
<button class="btn-sm btn-outline" @click="clearSelection">取消选择</button>
</div>
<!-- My Recipes Section -->
<div class="recipe-section">
<h3 class="section-title">📖 我的配方 ({{ myRecipes.length }})</h3>
<div class="recipe-list">
<div
v-for="r in myFilteredRecipes"
:key="r._id"
class="recipe-row"
:class="{ selected: selectedIds.has(r._id) }"
>
<input
type="checkbox"
:checked="selectedIds.has(r._id)"
@change="toggleSelect(r._id)"
class="row-check"
/>
<div class="row-info" @click="editRecipe(r)">
<span class="row-name">{{ r.name }}</span>
<span class="row-tags">
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span>
</span>
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
</div>
<div class="row-actions">
<button class="btn-icon" @click="editRecipe(r)" title="编辑"></button>
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑</button>
</div>
</div>
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
</div>
</div>
<!-- Public Recipes Section -->
<div class="recipe-section">
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
<div class="recipe-list">
<div
v-for="r in publicFilteredRecipes"
:key="r._id"
class="recipe-row"
:class="{ selected: selectedIds.has(r._id) }"
>
<input
type="checkbox"
:checked="selectedIds.has(r._id)"
@change="toggleSelect(r._id)"
class="row-check"
/>
<div class="row-info" @click="editRecipe(r)">
<span class="row-name">{{ r.name }}</span>
<span class="row-owner">{{ r._owner_name }}</span>
<span class="row-tags">
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span>
</span>
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
</div>
<div class="row-actions" v-if="auth.canEditRecipe(r)">
<button class="btn-icon" @click="editRecipe(r)" title="编辑"></button>
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑</button>
</div>
</div>
<div v-if="publicFilteredRecipes.length === 0" class="empty-hint">暂无公共配方</div>
</div>
</div>
<!-- Add/Edit Recipe Overlay -->
<div v-if="showAddOverlay" class="overlay" @click.self="closeOverlay">
<div class="overlay-panel">
<div class="overlay-header">
<h3>{{ editingRecipe ? '编辑配方' : '添加配方' }}</h3>
<button class="btn-close" @click="closeOverlay"></button>
</div>
<!-- Smart Paste Section -->
<div class="paste-section">
<textarea
v-model="smartPasteText"
class="paste-input"
placeholder="粘贴配方文本,支持智能识别...&#10;例如: 薰衣草3滴 茶树2滴"
rows="4"
></textarea>
<button class="btn-primary" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
智能识别
</button>
</div>
<div class="divider-text">或手动输入</div>
<!-- Manual Form -->
<div class="form-group">
<label>配方名称</label>
<input v-model="formName" class="form-input" placeholder="配方名称" />
</div>
<div class="form-group">
<label>成分</label>
<div v-for="(ing, i) in formIngredients" :key="i" class="ing-row">
<select v-model="ing.oil" class="form-select">
<option value="">选择精油</option>
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<input v-model.number="ing.drops" type="number" min="0" class="form-input-sm" placeholder="滴数" />
<button class="btn-icon-sm" @click="formIngredients.splice(i, 1)"></button>
</div>
<button class="btn-outline btn-sm" @click="formIngredients.push({ oil: '', drops: 1 })">+ 添加成分</button>
</div>
<div class="form-group">
<label>备注</label>
<textarea v-model="formNote" class="form-input" rows="2" placeholder="配方备注"></textarea>
</div>
<div class="form-group">
<label>标签</label>
<div class="tag-list">
<span
v-for="tag in recipeStore.allTags"
:key="tag"
class="tag-chip"
:class="{ active: formTags.includes(tag) }"
@click="toggleFormTag(tag)"
>{{ tag }}</span>
</div>
</div>
<div class="overlay-footer">
<button class="btn-outline" @click="closeOverlay">取消</button>
<button class="btn-primary" @click="saveCurrentRecipe">保存</button>
</div>
</div>
</div>
<!-- Tag Picker Overlay -->
<TagPicker
v-if="showTagPicker"
:name="tagPickerName"
:currentTags="tagPickerTags"
:allTags="recipeStore.allTags"
@save="onTagPickerSave"
@close="showTagPicker = false"
/>
</div>
</template>
<script setup>
import { ref, computed, reactive } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog'
import { parseSingleBlock } from '../composables/useSmartPaste'
import RecipeCard from '../components/RecipeCard.vue'
import TagPicker from '../components/TagPicker.vue'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const manageSearch = ref('')
const selectedTags = ref([])
const showTagFilter = ref(false)
const selectedIds = reactive(new Set())
const batchAction = ref('')
const showAddOverlay = ref(false)
const editingRecipe = ref(null)
const showPending = ref(false)
const pendingRecipes = ref([])
const pendingCount = ref(0)
// Form state
const formName = ref('')
const formIngredients = ref([{ oil: '', drops: 1 }])
const formNote = ref('')
const formTags = ref([])
const smartPasteText = ref('')
// Tag picker state
const showTagPicker = ref(false)
const tagPickerName = ref('')
const tagPickerTags = ref([])
// Computed lists
const myRecipes = computed(() =>
recipeStore.recipes.filter(r => r._owner_id === auth.user.id)
)
const publicRecipes = computed(() =>
recipeStore.recipes.filter(r => r._owner_id !== auth.user.id)
)
function filterBySearchAndTags(list) {
let result = list
const q = manageSearch.value.trim().toLowerCase()
if (q) {
result = result.filter(r =>
r.name.toLowerCase().includes(q) ||
r.ingredients.some(ing => ing.oil.toLowerCase().includes(q)) ||
(r.tags && r.tags.some(t => t.toLowerCase().includes(q)))
)
}
if (selectedTags.value.length > 0) {
result = result.filter(r =>
r.tags && selectedTags.value.every(t => r.tags.includes(t))
)
}
return result
}
const myFilteredRecipes = computed(() => filterBySearchAndTags(myRecipes.value))
const publicFilteredRecipes = computed(() => filterBySearchAndTags(publicRecipes.value))
function toggleTag(tag) {
const idx = selectedTags.value.indexOf(tag)
if (idx >= 0) selectedTags.value.splice(idx, 1)
else selectedTags.value.push(tag)
}
function toggleSelect(id) {
if (selectedIds.has(id)) selectedIds.delete(id)
else selectedIds.add(id)
}
function clearSelection() {
selectedIds.clear()
batchAction.value = ''
}
async function executeBatch() {
const ids = [...selectedIds]
if (!ids.length || !batchAction.value) return
if (batchAction.value === 'delete') {
const ok = await showConfirm(`确定删除 ${ids.length} 个配方?`)
if (!ok) return
for (const id of ids) {
await recipeStore.deleteRecipe(id)
}
ui.showToast(`已删除 ${ids.length} 个配方`)
} else if (batchAction.value === 'tag') {
const tagName = await showPrompt('输入要添加的标签:')
if (!tagName) return
for (const id of ids) {
const recipe = recipeStore.recipes.find(r => r._id === id)
if (recipe && !recipe.tags.includes(tagName)) {
recipe.tags.push(tagName)
await recipeStore.saveRecipe(recipe)
}
}
ui.showToast(`已为 ${ids.length} 个配方添加标签`)
} else if (batchAction.value === 'share') {
const text = ids.map(id => {
const r = recipeStore.recipes.find(rec => rec._id === id)
if (!r) return ''
const ings = r.ingredients.map(ing => `${ing.oil} ${ing.drops}`).join('')
return `${r.name}${ings}`
}).filter(Boolean).join('\n\n')
try {
await navigator.clipboard.writeText(text)
ui.showToast('已复制到剪贴板')
} catch {
ui.showToast('复制失败')
}
} else if (batchAction.value === 'export') {
ui.showToast('导出卡片功能开发中')
}
clearSelection()
}
function editRecipe(recipe) {
editingRecipe.value = recipe
formName.value = recipe.name
formIngredients.value = recipe.ingredients.map(i => ({ ...i }))
formNote.value = recipe.note || ''
formTags.value = [...(recipe.tags || [])]
showAddOverlay.value = true
}
function closeOverlay() {
showAddOverlay.value = false
editingRecipe.value = null
resetForm()
}
function resetForm() {
formName.value = ''
formIngredients.value = [{ oil: '', drops: 1 }]
formNote.value = ''
formTags.value = []
smartPasteText.value = ''
}
function handleSmartPaste() {
const result = parseSingleBlock(smartPasteText.value, oils.oilNames)
formName.value = result.name
formIngredients.value = result.ingredients.length > 0
? result.ingredients
: [{ oil: '', drops: 1 }]
if (result.notFound.length > 0) {
ui.showToast(`未识别: ${result.notFound.join(', ')}`)
}
}
function toggleFormTag(tag) {
const idx = formTags.value.indexOf(tag)
if (idx >= 0) formTags.value.splice(idx, 1)
else formTags.value.push(tag)
}
async function saveCurrentRecipe() {
const validIngs = formIngredients.value.filter(i => i.oil && i.drops > 0)
if (!formName.value.trim()) {
ui.showToast('请输入配方名称')
return
}
if (validIngs.length === 0) {
ui.showToast('请至少添加一个成分')
return
}
const payload = {
name: formName.value.trim(),
ingredients: validIngs,
note: formNote.value,
tags: formTags.value,
}
if (editingRecipe.value) {
payload._id = editingRecipe.value._id
payload._version = editingRecipe.value._version
}
try {
await recipeStore.saveRecipe(payload)
ui.showToast(editingRecipe.value ? '配方已更新' : '配方已添加')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
}
async function removeRecipe(recipe) {
const ok = await showConfirm(`确定删除配方 "${recipe.name}"`)
if (!ok) return
try {
await recipeStore.deleteRecipe(recipe._id)
ui.showToast('已删除')
} catch (e) {
ui.showToast('删除失败')
}
}
async function approveRecipe(recipe) {
try {
await api('/api/recipes/' + recipe._id + '/approve', { method: 'POST' })
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
pendingCount.value--
ui.showToast('已通过')
await recipeStore.loadRecipes()
} catch {
ui.showToast('操作失败')
}
}
async function rejectRecipe(recipe) {
try {
await api('/api/recipes/' + recipe._id + '/reject', { method: 'POST' })
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
pendingCount.value--
ui.showToast('已拒绝')
} catch {
ui.showToast('操作失败')
}
}
async function exportExcel() {
try {
const res = await api('/api/recipes/export-excel')
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '配方导出.xlsx'
a.click()
URL.revokeObjectURL(url)
ui.showToast('导出成功')
} catch {
ui.showToast('导出失败')
}
}
function onTagPickerSave(tags) {
formTags.value = tags
showTagPicker.value = false
}
// Load pending if admin
if (auth.isAdmin) {
api('/api/recipes/pending').then(async res => {
if (res.ok) {
const data = await res.json()
pendingRecipes.value = data
pendingCount.value = data.length
}
}).catch(() => {})
}
</script>
<style scoped>
.recipe-manager {
padding: 0 12px 24px;
}
.review-bar {
background: linear-gradient(135deg, #fff3e0, #ffe0b2);
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
color: #e65100;
}
.pending-list {
margin-bottom: 12px;
}
.pending-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #fffde7;
border-radius: 8px;
margin-bottom: 6px;
font-size: 13px;
}
.pending-name {
font-weight: 600;
flex: 1;
}
.pending-owner {
color: #999;
font-size: 12px;
}
.manage-toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
flex: 1;
min-width: 160px;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.tag-filter-bar {
margin-bottom: 12px;
}
.tag-toggle-btn {
background: #f8f7f5;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
color: #3e3a44;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tag-chip {
padding: 4px 12px;
border-radius: 16px;
background: #f0eeeb;
font-size: 12px;
cursor: pointer;
color: #6b6375;
border: 1.5px solid transparent;
transition: all 0.15s;
}
.tag-chip.active {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
.batch-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #e8f5e9;
border-radius: 10px;
margin-bottom: 12px;
font-size: 13px;
flex-wrap: wrap;
}
.batch-select {
padding: 6px 10px;
border-radius: 8px;
border: 1.5px solid #d4cfc7;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.recipe-section {
margin-bottom: 20px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #3e3a44;
margin: 0 0 10px;
}
.recipe-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.recipe-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
transition: all 0.15s;
}
.recipe-row:hover {
border-color: #d4cfc7;
background: #fafaf8;
}
.recipe-row.selected {
border-color: #7ec6a4;
background: #f0faf5;
}
.row-check {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4a9d7e;
}
.row-info {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
min-width: 0;
flex-wrap: wrap;
}
.row-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
}
.row-owner {
font-size: 11px;
color: #b0aab5;
}
.row-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.mini-tag {
padding: 2px 8px;
border-radius: 10px;
background: #f0eeeb;
font-size: 11px;
color: #6b6375;
}
.row-cost {
font-size: 13px;
color: #4a9d7e;
font-weight: 500;
margin-left: auto;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 4px;
}
.btn-icon {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
padding: 4px;
border-radius: 6px;
}
.btn-icon:hover {
background: #f0eeeb;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.overlay-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 520px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.overlay-header h3 {
margin: 0;
font-size: 17px;
color: #3e3a44;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.paste-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.paste-input {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.paste-input:focus {
border-color: #7ec6a4;
}
.divider-text {
text-align: center;
color: #b0aab5;
font-size: 12px;
margin: 12px 0;
position: relative;
}
.divider-text::before,
.divider-text::after {
content: '';
position: absolute;
top: 50%;
width: 35%;
height: 1px;
background: #e5e4e7;
}
.divider-text::before {
left: 0;
}
.divider-text::after {
right: 0;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 10px 14px;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.form-input:focus {
border-color: #7ec6a4;
}
.ing-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.form-select {
flex: 1;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.form-input-sm {
width: 70px;
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
}
.btn-icon-sm {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #999;
padding: 4px;
}
.overlay-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
white-space: nowrap;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
white-space: nowrap;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-sm {
padding: 6px 14px;
font-size: 12px;
border-radius: 8px;
}
.btn-approve {
background: #4a9d7e;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
}
.btn-reject {
background: #ef5350;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
.toggle-icon {
font-size: 12px;
}
@media (max-width: 600px) {
.manage-toolbar {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,398 @@
<template>
<div class="recipe-search">
<!-- Category Carousel -->
<div class="cat-wrap" v-if="categories.length">
<button class="cat-arrow cat-arrow-left" @click="scrollCat(-1)" :disabled="catScrollPos <= 0">&lsaquo;</button>
<div class="cat-track" ref="catTrack">
<div
v-for="cat in categories"
:key="cat.name"
class="cat-card"
:class="{ active: selectedCategory === cat.name }"
@click="toggleCategory(cat.name)"
>
<span class="cat-icon">{{ cat.icon || '📁' }}</span>
<span class="cat-label">{{ cat.name }}</span>
</div>
</div>
<button class="cat-arrow cat-arrow-right" @click="scrollCat(1)">&rsaquo;</button>
</div>
<!-- Search Box -->
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索配方名、精油、标签..."
@input="onSearch"
/>
<button v-if="searchQuery" class="search-clear-btn" @click="clearSearch"></button>
<button class="search-btn" @click="onSearch">🔍</button>
</div>
<!-- Personal Section (logged in) -->
<div v-if="auth.isLoggedIn" class="personal-section">
<div class="section-header" @click="showMyRecipes = !showMyRecipes">
<span>📖 我的配方</span>
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
</div>
<div v-if="showMyRecipes" class="recipe-grid">
<RecipeCard
v-for="(r, i) in myRecipesPreview"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
<div v-if="myRecipesPreview.length === 0" class="empty-hint">暂无个人配方</div>
</div>
<div class="section-header" @click="showFavorites = !showFavorites">
<span> 收藏配方</span>
<span class="toggle-icon">{{ showFavorites ? '▾' : '▸' }}</span>
</div>
<div v-if="showFavorites" class="recipe-grid">
<RecipeCard
v-for="(r, i) in favoritesPreview"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
<div v-if="favoritesPreview.length === 0" class="empty-hint">暂无收藏配方</div>
</div>
</div>
<!-- Fuzzy Search Results -->
<div v-if="searchQuery && fuzzyResults.length" class="search-results-section">
<div class="section-label">🔍 搜索结果 ({{ fuzzyResults.length }})</div>
<div class="recipe-grid">
<RecipeCard
v-for="(r, i) in fuzzyResults"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
</div>
</div>
<!-- Public Recipe Grid -->
<div v-if="!searchQuery || fuzzyResults.length === 0">
<div class="section-label">🌿 公共配方库 ({{ filteredRecipes.length }})</div>
<div class="recipe-grid">
<RecipeCard
v-for="(r, i) in filteredRecipes"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
<div v-if="filteredRecipes.length === 0" class="empty-hint">暂无配方</div>
</div>
</div>
<!-- Recipe Detail Overlay -->
<RecipeDetailOverlay
v-if="selectedRecipeIndex !== null"
:recipeIndex="selectedRecipeIndex"
@close="selectedRecipeIndex = null"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import RecipeCard from '../components/RecipeCard.vue'
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const searchQuery = ref('')
const selectedCategory = ref(null)
const categories = ref([])
const selectedRecipeIndex = ref(null)
const showMyRecipes = ref(true)
const showFavorites = ref(true)
const catScrollPos = ref(0)
const catTrack = ref(null)
onMounted(async () => {
try {
const res = await api('/api/category-modules')
if (res.ok) {
categories.value = await res.json()
}
} catch {
// category modules are optional
}
})
function toggleCategory(name) {
selectedCategory.value = selectedCategory.value === name ? null : name
}
function scrollCat(dir) {
if (!catTrack.value) return
const scrollAmount = 200
catTrack.value.scrollLeft += dir * scrollAmount
catScrollPos.value = catTrack.value.scrollLeft + dir * scrollAmount
}
const filteredRecipes = computed(() => {
let list = recipeStore.recipes
if (selectedCategory.value) {
list = list.filter(r => r.tags && r.tags.includes(selectedCategory.value))
}
return list
})
const fuzzyResults = computed(() => {
if (!searchQuery.value.trim()) return []
const q = searchQuery.value.trim().toLowerCase()
return recipeStore.recipes.filter(r => {
const nameMatch = r.name.toLowerCase().includes(q)
const oilMatch = r.ingredients.some(ing => ing.oil.toLowerCase().includes(q))
const tagMatch = r.tags && r.tags.some(t => t.toLowerCase().includes(q))
return nameMatch || oilMatch || tagMatch
})
})
const myRecipesPreview = computed(() => {
if (!auth.isLoggedIn) return []
return recipeStore.recipes
.filter(r => r._owner_id === auth.user.id)
.slice(0, 6)
})
const favoritesPreview = computed(() => {
if (!auth.isLoggedIn) return []
return recipeStore.recipes
.filter(r => recipeStore.isFavorite(r))
.slice(0, 6)
})
function findGlobalIndex(recipe) {
return recipeStore.recipes.findIndex(r => r._id === recipe._id)
}
function openDetail(index) {
if (index >= 0) {
selectedRecipeIndex.value = index
}
}
async function handleToggleFav(recipe) {
if (!auth.isLoggedIn) {
ui.openLogin()
return
}
await recipeStore.toggleFavorite(recipe._id)
}
function onSearch() {
// fuzzyResults computed handles the filtering reactively
}
function clearSearch() {
searchQuery.value = ''
selectedCategory.value = null
}
</script>
<style scoped>
.recipe-search {
padding: 0 12px 24px;
}
.cat-wrap {
position: relative;
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 4px;
}
.cat-track {
display: flex;
gap: 10px;
overflow-x: auto;
scroll-behavior: smooth;
flex: 1;
padding: 8px 0;
scrollbar-width: none;
}
.cat-track::-webkit-scrollbar {
display: none;
}
.cat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 16px;
border-radius: 12px;
background: #f8f7f5;
cursor: pointer;
white-space: nowrap;
font-size: 13px;
transition: all 0.2s;
min-width: 64px;
border: 1.5px solid transparent;
}
.cat-card:hover {
background: #f0eeeb;
}
.cat-card.active {
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
.cat-icon {
font-size: 20px;
}
.cat-label {
font-size: 12px;
}
.cat-arrow {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1.5px solid #d4cfc7;
background: #fff;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #6b6375;
}
.cat-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
background: #f8f7f5;
border-radius: 12px;
padding: 4px 8px;
border: 1.5px solid #e5e4e7;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 10px 8px;
font-size: 14px;
outline: none;
font-family: inherit;
color: #3e3a44;
}
.search-input::placeholder {
color: #b0aab5;
}
.search-btn,
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
padding: 6px 8px;
border-radius: 8px;
color: #6b6375;
}
.search-clear-btn:hover,
.search-btn:hover {
background: #eae8e5;
}
.personal-section {
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #f8f7f5;
border-radius: 10px;
cursor: pointer;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #3e3a44;
}
.section-header:hover {
background: #f0eeeb;
}
.toggle-icon {
font-size: 12px;
color: #999;
}
.section-label {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
padding: 8px 4px;
margin-bottom: 8px;
}
.search-results-section {
margin-bottom: 20px;
}
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.recipe-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,741 @@
<template>
<div class="user-management">
<h3 class="page-title">👥 用户管理</h3>
<!-- Translation Suggestions Review -->
<div v-if="translations.length > 0" class="review-section">
<h4 class="section-title">🌐 翻译建议</h4>
<div class="review-list">
<div v-for="t in translations" :key="t._id || t.id" class="review-item">
<div class="review-info">
<span class="review-original">{{ t.original }}</span>
<span class="review-arrow">&rarr;</span>
<span class="review-suggested">{{ t.suggested }}</span>
<span class="review-user">{{ t.user_name || '匿名' }}</span>
</div>
<div class="review-actions">
<button class="btn-sm btn-approve" @click="approveTranslation(t)">采纳</button>
<button class="btn-sm btn-reject" @click="rejectTranslation(t)">拒绝</button>
</div>
</div>
</div>
</div>
<!-- Business Application Approval -->
<div v-if="businessApps.length > 0" class="review-section">
<h4 class="section-title">💼 商业认证申请</h4>
<div class="review-list">
<div v-for="app in businessApps" :key="app._id || app.id" class="review-item">
<div class="review-info">
<span class="review-name">{{ app.user_name || app.display_name }}</span>
<span class="review-reason">{{ app.reason }}</span>
</div>
<div class="review-actions">
<button class="btn-sm btn-approve" @click="approveBusiness(app)">通过</button>
<button class="btn-sm btn-reject" @click="rejectBusiness(app)">拒绝</button>
</div>
</div>
</div>
</div>
<!-- New User Creation -->
<div class="create-section">
<h4 class="section-title"> 创建新用户</h4>
<div class="create-form">
<input v-model="newUser.username" class="form-input" placeholder="用户名" />
<input v-model="newUser.display_name" class="form-input" placeholder="显示名称" />
<input v-model="newUser.password" class="form-input" type="password" placeholder="密码 (留空自动生成)" />
<select v-model="newUser.role" class="form-select">
<option value="viewer">查看者</option>
<option value="editor">编辑</option>
<option value="senior_editor">高级编辑</option>
<option value="admin">管理员</option>
</select>
<button class="btn-primary" @click="createUser" :disabled="!newUser.username.trim()">创建</button>
</div>
<div v-if="createdLink" class="created-link">
<span>登录链接:</span>
<code>{{ createdLink }}</code>
<button class="btn-sm btn-outline" @click="copyLink(createdLink)">复制</button>
</div>
</div>
<!-- Search & Filter -->
<div class="filter-toolbar">
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索用户..."
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<div class="role-filters">
<button
v-for="r in roles"
:key="r.value"
class="filter-btn"
:class="{ active: filterRole === r.value }"
@click="filterRole = filterRole === r.value ? '' : r.value"
>{{ r.label }}</button>
</div>
</div>
<!-- User List -->
<div class="user-list">
<div v-for="u in filteredUsers" :key="u._id || u.id" class="user-card">
<div class="user-info">
<div class="user-name">
{{ u.display_name || u.username }}
<span class="user-username" v-if="u.display_name">@{{ u.username }}</span>
</div>
<div class="user-meta">
<span class="user-role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
<span v-if="u.business_verified" class="biz-badge">💼 商业认证</span>
<span class="user-date">注册: {{ formatDate(u.created_at) }}</span>
</div>
</div>
<div class="user-actions">
<select
:value="u.role"
class="role-select"
@change="changeRole(u, $event.target.value)"
>
<option value="viewer">查看者</option>
<option value="editor">编辑</option>
<option value="senior_editor">高级编辑</option>
<option value="admin">管理员</option>
</select>
<button class="btn-sm btn-outline" @click="copyUserLink(u)" title="复制登录链接">🔗</button>
<button class="btn-sm btn-delete" @click="removeUser(u)" title="删除用户">🗑</button>
</div>
</div>
<div v-if="filteredUsers.length === 0" class="empty-hint">未找到用户</div>
</div>
<div class="user-count"> {{ users.length }} 个用户</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
const users = ref([])
const searchQuery = ref('')
const filterRole = ref('')
const translations = ref([])
const businessApps = ref([])
const createdLink = ref('')
const newUser = reactive({
username: '',
display_name: '',
password: '',
role: 'viewer',
})
const roles = [
{ value: 'admin', label: '管理员' },
{ value: 'senior_editor', label: '高级编辑' },
{ value: 'editor', label: '编辑' },
{ value: 'viewer', label: '查看者' },
]
const filteredUsers = computed(() => {
let list = users.value
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase()
list = list.filter(u =>
(u.username || '').toLowerCase().includes(q) ||
(u.display_name || '').toLowerCase().includes(q)
)
}
if (filterRole.value) {
list = list.filter(u => u.role === filterRole.value)
}
return list
})
function roleLabel(role) {
const map = { admin: '管理员', senior_editor: '高级编辑', editor: '编辑', viewer: '查看者' }
return map[role] || role
}
function formatDate(d) {
if (!d) return '--'
return new Date(d).toLocaleDateString('zh-CN')
}
async function loadUsers() {
try {
const res = await api('/api/users')
if (res.ok) {
users.value = await res.json()
}
} catch {
users.value = []
}
}
async function loadTranslations() {
try {
const res = await api('/api/translation-suggestions')
if (res.ok) {
translations.value = await res.json()
}
} catch {
translations.value = []
}
}
async function loadBusinessApps() {
try {
const res = await api('/api/business-applications')
if (res.ok) {
businessApps.value = await res.json()
}
} catch {
businessApps.value = []
}
}
async function createUser() {
if (!newUser.username.trim()) return
try {
const res = await api('/api/users', {
method: 'POST',
body: JSON.stringify({
username: newUser.username.trim(),
display_name: newUser.display_name.trim() || newUser.username.trim(),
password: newUser.password || undefined,
role: newUser.role,
}),
})
if (res.ok) {
const data = await res.json()
if (data.token) {
const baseUrl = window.location.origin
createdLink.value = `${baseUrl}/?token=${data.token}`
}
newUser.username = ''
newUser.display_name = ''
newUser.password = ''
newUser.role = 'viewer'
await loadUsers()
ui.showToast('用户已创建')
} else {
const err = await res.json().catch(() => ({}))
ui.showToast('创建失败: ' + (err.error || err.message || ''))
}
} catch {
ui.showToast('创建失败')
}
}
async function changeRole(user, newRole) {
const id = user._id || user.id
try {
const res = await api(`/api/users/${id}/role`, {
method: 'PUT',
body: JSON.stringify({ role: newRole }),
})
if (res.ok) {
user.role = newRole
ui.showToast(`已更新 ${user.display_name || user.username} 的角色`)
}
} catch {
ui.showToast('更新失败')
}
}
async function removeUser(user) {
const ok = await showConfirm(`确定删除用户 "${user.display_name || user.username}"?此操作不可撤销。`)
if (!ok) return
const id = user._id || user.id
try {
const res = await api(`/api/users/${id}`, { method: 'DELETE' })
if (res.ok) {
users.value = users.value.filter(u => (u._id || u.id) !== id)
ui.showToast('已删除')
}
} catch {
ui.showToast('删除失败')
}
}
async function copyUserLink(user) {
try {
const id = user._id || user.id
const res = await api(`/api/users/${id}/token`)
if (res.ok) {
const data = await res.json()
const link = `${window.location.origin}/?token=${data.token}`
await navigator.clipboard.writeText(link)
ui.showToast('链接已复制')
}
} catch {
ui.showToast('获取链接失败')
}
}
async function copyLink(link) {
try {
await navigator.clipboard.writeText(link)
ui.showToast('已复制')
} catch {
ui.showToast('复制失败')
}
}
async function approveTranslation(t) {
const id = t._id || t.id
try {
const res = await api(`/api/translation-suggestions/${id}/approve`, { method: 'POST' })
if (res.ok) {
translations.value = translations.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已采纳')
}
} catch {
ui.showToast('操作失败')
}
}
async function rejectTranslation(t) {
const id = t._id || t.id
try {
const res = await api(`/api/translation-suggestions/${id}/reject`, { method: 'POST' })
if (res.ok) {
translations.value = translations.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已拒绝')
}
} catch {
ui.showToast('操作失败')
}
}
async function approveBusiness(app) {
const id = app._id || app.id
try {
const res = await api(`/api/business-applications/${id}/approve`, { method: 'POST' })
if (res.ok) {
businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已通过')
}
} catch {
ui.showToast('操作失败')
}
}
async function rejectBusiness(app) {
const id = app._id || app.id
try {
const res = await api(`/api/business-applications/${id}/reject`, { method: 'POST' })
if (res.ok) {
businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已拒绝')
}
} catch {
ui.showToast('操作失败')
}
}
onMounted(() => {
loadUsers()
loadTranslations()
loadBusinessApps()
})
</script>
<style scoped>
.user-management {
padding: 0 12px 24px;
}
.page-title {
margin: 0 0 16px;
font-size: 16px;
color: #3e3a44;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin: 0 0 10px;
}
/* Review sections */
.review-section {
margin-bottom: 18px;
padding: 14px;
background: #fff8e1;
border-radius: 12px;
border: 1.5px solid #ffe082;
}
.review-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.review-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #fff;
border-radius: 8px;
gap: 8px;
flex-wrap: wrap;
}
.review-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
font-size: 13px;
}
.review-original {
font-weight: 500;
color: #3e3a44;
}
.review-arrow {
color: #b0aab5;
}
.review-suggested {
font-weight: 600;
color: #4a9d7e;
}
.review-user,
.review-reason {
font-size: 12px;
color: #999;
}
.review-name {
font-weight: 600;
color: #3e3a44;
}
.review-actions {
display: flex;
gap: 6px;
}
.btn-approve {
background: #4a9d7e;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
border-radius: 8px;
}
.btn-reject {
background: #ef5350;
color: #fff;
border: none;
cursor: pointer;
font-family: inherit;
border-radius: 8px;
}
/* Create user */
.create-section {
margin-bottom: 18px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.create-form {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.form-input {
flex: 1;
min-width: 120px;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
.form-select {
padding: 8px 10px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.created-link {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 10px 12px;
background: #e8f5e9;
border-radius: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.created-link code {
flex: 1;
word-break: break-all;
font-size: 11px;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
}
/* Filter toolbar */
.filter-toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
align-items: center;
}
.search-box {
display: flex;
align-items: center;
background: #f8f7f5;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid #e5e4e7;
flex: 1;
min-width: 150px;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: #999;
padding: 4px;
}
.role-filters {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.filter-btn {
padding: 5px 14px;
border-radius: 16px;
border: 1.5px solid #e5e4e7;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: #6b6375;
transition: all 0.15s;
}
.filter-btn.active {
background: #e8f5e9;
border-color: #7ec6a4;
color: #2e7d5a;
font-weight: 600;
}
/* User list */
.user-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 10px;
gap: 10px;
flex-wrap: wrap;
}
.user-card:hover {
border-color: #d4cfc7;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-weight: 600;
font-size: 14px;
color: #3e3a44;
margin-bottom: 4px;
}
.user-username {
font-weight: 400;
font-size: 12px;
color: #b0aab5;
margin-left: 4px;
}
.user-meta {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.user-role-badge {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.role-admin { background: #f3e5f5; color: #7b1fa2; }
.role-senior_editor { background: #e3f2fd; color: #1565c0; }
.role-editor { background: #e8f5e9; color: #2e7d5a; }
.role-viewer { background: #f5f5f5; color: #757575; }
.biz-badge {
font-size: 11px;
color: #e65100;
}
.user-date {
font-size: 11px;
color: #b0aab5;
}
.user-actions {
display: flex;
gap: 6px;
align-items: center;
}
.role-select {
padding: 5px 8px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 12px;
font-family: inherit;
background: #fff;
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
border-radius: 8px;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
background: #f8f7f5;
}
.btn-delete {
background: #fff;
color: #ef5350;
border: 1.5px solid #ffcdd2;
cursor: pointer;
font-family: inherit;
}
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.user-count {
text-align: center;
font-size: 12px;
color: #b0aab5;
margin-top: 16px;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
@media (max-width: 600px) {
.create-form {
flex-direction: column;
}
.create-form .form-input,
.create-form .form-select {
width: 100%;
}
.user-card {
flex-direction: column;
align-items: flex-start;
}
.user-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>