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:
125
frontend/src/App.vue
Normal file
125
frontend/src/App.vue
Normal 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>
|
||||
501
frontend/src/assets/styles.css
Normal file
501
frontend/src/assets/styles.css
Normal 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; }
|
||||
}
|
||||
120
frontend/src/components/CustomDialog.vue
Normal file
120
frontend/src/components/CustomDialog.vue
Normal 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>
|
||||
196
frontend/src/components/LoginModal.vue
Normal file
196
frontend/src/components/LoginModal.vue
Normal 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>
|
||||
144
frontend/src/components/RecipeCard.vue
Normal file
144
frontend/src/components/RecipeCard.vue
Normal 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>
|
||||
636
frontend/src/components/RecipeDetailOverlay.vue
Normal file
636
frontend/src/components/RecipeDetailOverlay.vue
Normal 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>
|
||||
206
frontend/src/components/TagPicker.vue
Normal file
206
frontend/src/components/TagPicker.vue
Normal 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>
|
||||
156
frontend/src/components/UserMenu.vue
Normal file
156
frontend/src/components/UserMenu.vue
Normal 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>
|
||||
44
frontend/src/composables/useApi.js
Normal file
44
frontend/src/composables/useApi.js
Normal 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
|
||||
43
frontend/src/composables/useDialog.js
Normal file
43
frontend/src/composables/useDialog.js
Normal 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
|
||||
}
|
||||
42
frontend/src/composables/useOilTranslation.js
Normal file
42
frontend/src/composables/useOilTranslation.js
Normal 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]
|
||||
}
|
||||
262
frontend/src/composables/useSmartPaste.js
Normal file
262
frontend/src/composables/useSmartPaste.js
Normal 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
10
frontend/src/main.js
Normal 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')
|
||||
56
frontend/src/router/index.js
Normal file
56
frontend/src/router/index.js
Normal 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
103
frontend/src/stores/auth.js
Normal 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,
|
||||
}
|
||||
})
|
||||
56
frontend/src/stores/diary.js
Normal file
56
frontend/src/stores/diary.js
Normal 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
106
frontend/src/stores/oils.js
Normal 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,
|
||||
}
|
||||
})
|
||||
105
frontend/src/stores/recipes.js
Normal file
105
frontend/src/stores/recipes.js
Normal 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
40
frontend/src/stores/ui.js
Normal 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,
|
||||
}
|
||||
})
|
||||
380
frontend/src/views/AuditLog.vue
Normal file
380
frontend/src/views/AuditLog.vue
Normal 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>
|
||||
644
frontend/src/views/BugTracker.vue
Normal file
644
frontend/src/views/BugTracker.vue
Normal 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>
|
||||
383
frontend/src/views/Inventory.vue
Normal file
383
frontend/src/views/Inventory.vue
Normal 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>
|
||||
872
frontend/src/views/MyDiary.vue
Normal file
872
frontend/src/views/MyDiary.vue
Normal 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="粘贴配方文本,智能识别... 例如: 舒缓配方,薰衣草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>
|
||||
788
frontend/src/views/OilReference.vue
Normal file
788
frontend/src/views/OilReference.vue
Normal 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>婴儿(<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>
|
||||
759
frontend/src/views/Projects.vue
Normal file
759
frontend/src/views/Projects.vue
Normal 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">← 返回列表</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>
|
||||
967
frontend/src/views/RecipeManager.vue
Normal file
967
frontend/src/views/RecipeManager.vue
Normal 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="粘贴配方文本,支持智能识别... 例如: 薰衣草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>
|
||||
398
frontend/src/views/RecipeSearch.vue
Normal file
398
frontend/src/views/RecipeSearch.vue
Normal 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">‹</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)">›</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>
|
||||
741
frontend/src/views/UserManagement.vue
Normal file
741
frontend/src/views/UserManagement.vue
Normal 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">→</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>
|
||||
Reference in New Issue
Block a user