Files
oil-formula-calculator/frontend/src/views/RecipeManager.vue
Hera Zhao 0f7ae6ecc7
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 22s
Test / e2e-test (push) Successful in 51s
fix: 管理配方tab所有人可见,未登录时显示登录引导
撤回之前的editor-only限制,改为tab可见但页面内容需登录。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:20:45 +00:00

2440 lines
83 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="recipe-manager">
<!-- Login prompt for non-logged-in users -->
<div v-if="!auth.isLoggedIn" class="login-prompt">
<p>登录后可管理配方创建个人配方集</p>
<button class="btn-primary" @click="ui.openLogin()">登录 / 注册</button>
</div>
<template v-else>
<!-- Review Bar (admin + senior_editor with assigned reviews) -->
<div v-if="(auth.isAdmin || auth.canManage) && 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 clickable" @click="openRecipeDetail(r)">{{ r.name }}</span>
<span class="pending-owner">{{ r._owner_name }}</span>
<template v-if="auth.isAdmin">
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
<button class="btn-sm btn-outline" @click="r._showAssign = !r._showAssign">指派</button>
<div v-if="r._showAssign" class="assign-row">
<select v-model="r._assignTo" class="assign-select">
<option value="">选择审核人...</option>
<option v-for="u in seniorEditors" :key="u.id" :value="u.id">{{ u.display_name || u.username }}</option>
</select>
<button class="btn-sm btn-primary" @click="assignReview(r)" :disabled="!r._assignTo">发送</button>
</div>
</template>
<template v-else>
<button class="btn-sm btn-approve" @click="recommendApprove(r)">推荐通过</button>
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
</template>
</div>
</div>
<!-- Search bar -->
<div class="search-bar">
<div class="search-box">
<input class="search-input" v-model="manageSearch" placeholder="搜索配方名、精油、标签..." />
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''"></button>
</div>
</div>
<!-- Action buttons -->
<div class="action-bar">
<button class="action-chip" @click="showAddOverlay = true">新增</button>
<button class="action-chip" :class="{ active: isAllSelected }" @click="toggleSelectAll">
全选<span v-if="totalSelected > 0" class="chip-count">{{ totalSelected }}</span>
</button>
<button class="action-chip" :class="{ active: showTagFilter }" @click="toggleTagFilter">标签</button>
<button v-if="totalSelected > 0" class="action-chip" :class="{ active: showBatchMenu }" @click="showBatchMenu = !showBatchMenu">批量</button>
<button v-if="totalSelected > 0" class="action-chip cancel" @click="clearSelection">取消</button>
<button v-if="auth.isAdmin" class="export-btn" @click="exportExcel" title="导出Excel">📥</button>
</div>
<div v-if="showTagFilter" class="tag-list-bar">
<span
v-for="tag in visibleAllTags"
:key="tag"
class="tag-chip"
:class="{ active: selectedTags.includes(tag) }"
@click="toggleTag(tag)"
>{{ tag }}<span v-if="auth.isAdmin" class="tag-delete" @click.stop="deleteGlobalTag(tag)">×</span></span>
<div v-if="auth.canEdit" class="tag-add-row">
<input v-model="globalNewTag" class="tag-add-input" placeholder="新标签..." @keydown.enter="addGlobalTag" />
<button class="tag-add-btn" @click="addGlobalTag" :disabled="!globalNewTag.trim()">+</button>
</div>
</div>
<div v-if="showBatchMenu && totalSelected > 0" class="batch-menu">
<button class="batch-menu-btn" @click="doBatch('tag')">🏷 批量打标签</button>
<button class="batch-menu-btn" @click="doBatch('export')">📷 批量导出卡片</button>
<button v-if="selectedDiaryIds.size > 0 && selectedIds.size === 0" class="batch-menu-btn" @click="doBatch('share_public')">📤 批量共享到公共库</button>
<button class="batch-menu-btn batch-delete" @click="doBatch('delete')">🗑 批量删除</button>
</div>
<!-- Batch Tag Picker -->
<div v-if="showBatchTagPicker" class="batch-tag-picker">
<div class="editor-tags">
<span v-for="tag in batchTagsSelected" :key="tag" class="editor-tag">
{{ tag }}
<span class="tag-remove" @click="batchTagsSelected = batchTagsSelected.filter(t => t !== tag)">×</span>
</span>
</div>
<div class="candidate-tags">
<span
v-for="tag in recipeStore.allTags.filter(t => !batchTagsSelected.includes(t))"
:key="tag"
class="candidate-tag"
@click="batchTagsSelected.push(tag)"
>+ {{ tag }}</span>
</div>
<div class="tag-input-row">
<input v-model="batchNewTag" class="editor-input" placeholder="新标签..." @keydown.enter="addBatchTag" style="flex:1;max-width:120px" />
<button class="action-btn action-btn-sm" @click="addBatchTag" :disabled="!batchNewTag.trim()">+</button>
</div>
<div v-if="batchExistingTags.length" style="margin-top:8px">
<div style="font-size:12px;color:#999;margin-bottom:4px">点击移除已有标签</div>
<div class="editor-tags">
<span v-for="tag in batchExistingTags" :key="'rm-'+tag" class="editor-tag tag-removable" @click="batchTagsToRemove.includes(tag) ? batchTagsToRemove.splice(batchTagsToRemove.indexOf(tag),1) : batchTagsToRemove.push(tag)" :class="{ 'tag-marked-remove': batchTagsToRemove.includes(tag) }">
{{ tag }} <span style="margin-left:2px">{{ batchTagsToRemove.includes(tag) ? '✓移除' : '×' }}</span>
</span>
</div>
</div>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="action-btn action-btn-primary action-btn-sm" @click="applyBatchTags">确认</button>
<button class="action-btn action-btn-sm" @click="showBatchTagPicker = false">取消</button>
</div>
</div>
<!-- My Recipes Section (from diary) -->
<div class="recipe-section">
<h3 class="section-title clickable" @click="showMyRecipes = !showMyRecipes">
<button class="mini-select" :class="{ active: isMyAllSelected }" @click.stop="toggleMySelect" title="全选我的配方"></button>
<span>📖 我的配方 ({{ myRecipes.length }})</span>
<span v-if="!auth.isAdmin" class="contrib-tag">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} </span>
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
</h3>
<template v-if="showMyRecipes || manageSearch || selectedTags.length">
<div class="recipe-list">
<div
v-for="d in myFilteredRecipes"
:key="'diary-' + d.id"
class="recipe-row"
:class="{ selected: selectedDiaryIds.has(d.id) }"
>
<input
type="checkbox"
:checked="selectedDiaryIds.has(d.id)"
@change="toggleDiarySelect(d.id)"
class="row-check"
/>
<div class="row-info" @click="editDiaryRecipe(d)">
<span class="row-name">{{ d.name }}</span>
<span v-if="getVolumeLabel(d.ingredients)" class="row-volume">{{ getVolumeLabel(d.ingredients) }}</span>
<span class="row-tags">
<span v-for="t in [...(d.tags || [])].sort((a,b)=>a.localeCompare(b,'zh'))" :key="t" class="mini-tag">{{ t }}</span>
</span>
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
<span v-if="getDiaryShareStatus(d) === 'shared'" class="share-tag shared">已共享</span>
<span v-else-if="getDiaryShareStatus(d) === 'pending'" class="share-tag pending">等待审核</span>
</div>
<div class="row-actions">
<button class="btn-icon" @click="handleShare(d)" title="共享到公共配方库">📤</button>
<button class="btn-icon" @click="removeDiaryRecipe(d)" title="删除">🗑</button>
</div>
</div>
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
</div>
</template>
</div>
<!-- Public Recipes Section (editor+) -->
<div v-if="auth.canEdit" class="recipe-section">
<h3 class="section-title clickable" @click="showPublicRecipes = !showPublicRecipes">
<button class="mini-select" :class="{ active: isPubAllSelected }" @click.stop="togglePubSelect" title="全选公共配方"></button>
<span>🌿 公共配方库 ({{ publicRecipes.length }})</span>
<span class="toggle-icon">{{ showPublicRecipes ? '▾' : '▸' }}</span>
</h3>
<div v-if="showPublicRecipes || manageSearch || selectedTags.length" 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 v-if="getVolumeLabel(r.ingredients)" class="row-volume">{{ getVolumeLabel(r.ingredients) }}</span>
<span class="row-tags">
<span v-for="t in [...(r.tags || [])].sort((a,b)=>a.localeCompare(b,'zh'))" :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="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">
<div class="overlay-panel">
<div class="overlay-header">
<h3>{{ editingRecipe ? '编辑配方' : '添加配方' }}</h3>
<button class="btn-close" @click="closeOverlay"></button>
</div>
<!-- Smart Paste Section (only for new recipes) -->
<template v-if="!editingRecipe">
<div class="paste-section">
<textarea
v-model="smartPasteText"
class="paste-input"
placeholder="直接粘贴配方文本,支持多条配方同时识别&#10;例如: 舒缓放松薰衣草3茶树2&#10;提神醒脑柠檬5椒样薄荷3&#10;不写数字默认1滴: 薰衣草,茶树,乳香"
rows="4"
></textarea>
<button class="btn-primary btn-sm" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
🪄 智能识别
</button>
</div>
<!-- Multi-recipe queue indicator -->
<div v-if="parsedRecipes.length > 0" class="parsed-queue">
<div class="parsed-queue-header">
<span class="parsed-queue-label">批量识别 ({{ parsedCurrentIndex + 1 }}/{{ parsedRecipes.length }})</span>
<button class="btn-outline btn-sm" @click="saveAllParsed">全部保存</button>
<button class="btn-outline btn-sm" @click="parsedRecipes = []; parsedCurrentIndex = -1">取消全部</button>
</div>
<div class="parsed-queue-list">
<button
v-for="(pr, pi) in parsedRecipes" :key="pi"
class="parsed-queue-item"
:class="{ active: pi === parsedCurrentIndex }"
@click="loadParsedIntoForm(pi)"
>
<span class="parsed-queue-name">{{ pr.name || '未命名' }}</span>
<span class="parsed-queue-count">{{ pr.ingredients.length }}</span>
<span class="btn-icon-sm" @click.stop="parsedRecipes.splice(pi, 1); if (parsedRecipes.length === 0) parsedCurrentIndex = -1; else if (pi <= parsedCurrentIndex) loadParsedIntoForm(Math.min(parsedCurrentIndex, parsedRecipes.length - 1))"></span>
</button>
</div>
</div>
<div class="divider-text">或手动输入</div>
</template>
<!-- Manual Form (matches RecipeDetailOverlay editor) -->
<div class="editor-header">
<div style="flex:1;min-width:0">
<input v-model="formName" type="text" class="editor-name-input" placeholder="配方名称" />
</div>
<div class="editor-header-actions">
<button class="action-btn action-btn-primary action-btn-sm" @click="saveCurrentRecipe">💾 保存</button>
<button class="action-btn action-btn-sm" @click="previewRecipe">👁 预览</button>
</div>
</div>
<!-- Volume selector -->
<div class="editor-section">
<label class="editor-label">容量</label>
<div class="volume-controls">
<button class="volume-btn" :class="{ active: formVolume === 'single' }" @click="formVolume = 'single'">单次</button>
<button class="volume-btn" :class="{ active: formVolume === '5' }" @click="formVolume = '5'">5ml</button>
<button class="volume-btn" :class="{ active: formVolume === '10' }" @click="formVolume = '10'">10ml</button>
<button class="volume-btn" :class="{ active: formVolume === '15' }" @click="formVolume = '15'">15ml</button>
<button class="volume-btn" :class="{ active: formVolume === '20' }" @click="formVolume = '20'">20ml</button>
<button class="volume-btn" :class="{ active: formVolume === '30' }" @click="formVolume = '30'">30ml</button>
<button class="volume-btn" :class="{ active: formVolume === 'custom' }" @click="formVolume = 'custom'">自定义</button>
</div>
<div v-if="formVolume === 'custom'" class="custom-volume-row">
<input v-model.number="formCustomVolume" type="number" min="1" class="drops-sm" placeholder="ml" />
<span style="font-size:12px;color:#999">ml</span>
</div>
<div class="ratio-row">
<span class="ratio-label">参考比例 1:</span>
<select v-model.number="formDilution" class="select-sm">
<option v-for="n in [3,4,5,6,7,8,9,10,12,15,20]" :key="n" :value="n">{{ n }}</option>
</select>
<span class="ratio-hint">纯精油总数约为 {{ suggestedEoDrops }} 现在为 {{ eoTotalDrops }} </span>
</div>
</div>
<!-- Ingredients table (essential oils only, coconut at bottom) -->
<div class="editor-section">
<table class="editor-table">
<thead>
<tr><th>精油</th><th>滴数</th><th>单价/</th><th>小计</th><th></th></tr>
</thead>
<tbody>
<tr v-for="(ing, i) in formEoIngredients" :key="'eo-'+i">
<td>
<div class="oil-search-wrap">
<input
v-model="ing._search"
class="form-select"
placeholder="搜索精油..."
@focus="ing._open = true"
@input="ing._open = true"
@blur="onOilBlur(ing)"
/>
<div v-if="ing._open" class="oil-dropdown">
<div
v-for="name in filteredOilNames(ing._search || '')"
:key="name"
class="oil-option"
@mousedown.prevent="selectOil(ing, name)"
>{{ name }}</div>
<div v-if="filteredOilNames(ing._search || '').length === 0" class="oil-option oil-empty">无匹配</div>
</div>
</div>
</td>
<td><input v-model.number="ing.drops" type="number" min="0.5" step="0.5" class="editor-drops" /></td>
<td class="ing-ppd">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }}</td>
<td class="ing-cost">{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '-' }}</td>
<td><button class="remove-row-btn" @click="removeEoRow(i)"></button></td>
</tr>
<!-- Coconut oil row -->
<tr v-if="formCocoRow" class="coco-row">
<td><span class="coco-label">椰子油</span></td>
<td>
<template v-if="formVolume === 'single'">
<input v-model.number="formCocoRow.drops" type="number" min="0" class="editor-drops" />
</template>
<template v-else>
<span class="coco-fill">填满 ({{ cocoFillMl }}ml)</span>
</template>
</td>
<td class="ing-ppd">{{ oils.fmtPrice(oils.pricePerDrop('椰子油')) }}</td>
<td class="ing-cost">{{ oils.fmtPrice(oils.pricePerDrop('椰子油') * cocoActualDrops) }}</td>
<td><button class="remove-row-btn" @click="formCocoRow = null"></button></td>
</tr>
</tbody>
</table>
<button class="add-row-btn" @click="addOilRow">+ 添加精油</button>
</div>
<!-- Real-time summary (only when volume selected) -->
<div v-if="formVolume" class="recipe-summary">
{{ recipeSummaryText }}
</div>
<!-- Notes -->
<div class="editor-section">
<label class="editor-label">备注</label>
<textarea v-model="formNote" class="editor-textarea" rows="2" placeholder="配方备注..."></textarea>
</div>
<!-- Tags -->
<div class="editor-section">
<label class="editor-label">标签</label>
<div class="editor-tags">
<span v-for="tag in formTags" :key="tag" class="editor-tag">
{{ tag }}
<span class="tag-remove" @click="toggleFormTag(tag)">×</span>
</span>
</div>
<div class="candidate-tags" v-if="formCandidateTags.length">
<span v-for="tag in formCandidateTags" :key="tag" class="candidate-tag" @click="toggleFormTag(tag)">+ {{ tag }}</span>
</div>
<div class="tag-input-row">
<input v-model="newTagInput" type="text" class="editor-input" placeholder="添加新标签..." @keydown.enter="addNewFormTag" style="flex:1" />
<button class="action-btn action-btn-sm" @click="addNewFormTag" :disabled="!newTagInput.trim()">+</button>
</div>
</div>
<!-- Total cost -->
<div class="editor-total">
总计: {{ formTotalCost }}
</div>
</div>
</div>
<!-- Review History (admin only) -->
<div v-if="auth.isAdmin" class="recipe-section">
<h3 class="section-title clickable" @click="showReviewHistory = !showReviewHistory">
<span>📋 审核记录</span>
<span class="toggle-icon">{{ showReviewHistory ? '▾' : '▸' }}</span>
</h3>
<div v-if="showReviewHistory" class="review-history">
<div v-for="r in reviewHistory" :key="r.id" class="review-log-item">
<span :class="r.action === 'adopt_recipe' ? 'log-approve' : 'log-reject'">
{{ r.action === 'adopt_recipe' ? '✅ 采纳' : '❌ 拒绝' }}
</span>
<span class="log-recipe">{{ r.target_name }}</span>
<span class="log-from" v-if="r.detail">{{ parseReviewDetail(r.detail) }}</span>
<span class="log-time">{{ formatDate(r.created_at) }}</span>
</div>
<div v-if="reviewHistory.length === 0" class="empty-hint">暂无审核记录</div>
</div>
</div>
<!-- Recipe Detail Overlay -->
<RecipeDetailOverlay
v-if="previewRecipeIndex !== null || previewRecipeData !== null"
:recipeIndex="previewRecipeIndex"
:recipeData="previewRecipeData"
:isDiary="true"
@close="previewRecipeIndex = null; previewRecipeData = null"
/>
<!-- Tag Picker Overlay -->
<TagPicker
v-if="showTagPicker"
:name="tagPickerName"
:currentTags="tagPickerTags"
:allTags="recipeStore.allTags"
@save="onTagPickerSave"
@close="showTagPicker = false"
/>
</template>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore, EDITOR_ONLY_TAGS } from '../stores/recipes'
import { useDiaryStore } from '../stores/diary'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog'
import { parseSingleBlock, parseMultiRecipes } from '../composables/useSmartPaste'
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
import RecipeCard from '../components/RecipeCard.vue'
import TagPicker from '../components/TagPicker.vue'
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const diaryStore = useDiaryStore()
const ui = useUiStore()
const manageSearch = ref('')
const selectedTags = ref([])
const showTagFilter = ref(false)
const selectedIds = reactive(new Set())
const selectedDiaryIds = reactive(new Set())
const showAddOverlay = ref(false)
const editingRecipe = ref(null)
const showPending = ref(false)
const pendingRecipes = ref([])
const pendingCount = ref(0)
const seniorEditors = ref([])
// Form state
const formName = ref('')
const formIngredients = ref([{ oil: '', drops: 1, _search: '', _open: false }])
const formNote = ref('')
const formTags = ref([])
const smartPasteText = ref('')
const parsedRecipes = ref([])
const parsedCurrentIndex = ref(-1)
const showAddIngRow = ref(false)
const newIngOil = ref('')
const newIngSearch = ref('')
const newIngDrops = ref(1)
const newIngDropdownOpen = ref(false)
const formVolume = ref('')
const formCustomVolume = ref(null)
const formCustomUnit = ref('drops')
const formDilution = ref(6)
const formCocoRow = ref(null)
watch(() => formVolume.value, (vol) => {
if (vol && !formCocoRow.value && parsedCurrentIndex.value < 0) {
formCocoRow.value = { oil: '椰子油', drops: vol === 'single' ? 10 : 0, _search: '椰子油', _open: false }
}
})
// EO ingredients (everything except coconut)
const formEoIngredients = computed(() =>
formIngredients.value.filter(i => i.oil !== '椰子油')
)
const eoTotalDrops = computed(() =>
formEoIngredients.value.filter(i => i.oil && i.drops > 0).reduce((s, i) => s + i.drops, 0)
)
const targetTotalDrops = computed(() => {
if (formVolume.value === 'single') return null
if (formVolume.value === 'custom') return Math.round((formCustomVolume.value || 0) * DROPS_PER_ML)
return Math.round(Number(formVolume.value) * DROPS_PER_ML)
})
const cocoActualDrops = computed(() => {
if (!formCocoRow.value) return 0
if (formVolume.value === 'single') return formCocoRow.value.drops || 0
if (!targetTotalDrops.value) return 0
return Math.max(0, targetTotalDrops.value - eoTotalDrops.value)
})
const cocoFillMl = computed(() => Math.round(cocoActualDrops.value / DROPS_PER_ML))
const suggestedEoDrops = computed(() => {
if (formVolume.value === 'single') {
const cocoDrops = formCocoRow.value ? (formCocoRow.value.drops || 10) : 10
return Math.round(cocoDrops / formDilution.value)
}
const total = targetTotalDrops.value || 0
return Math.round(total / (1 + formDilution.value))
})
const recipeSummaryText = computed(() => {
const eo = eoTotalDrops.value
const coco = cocoActualDrops.value
const ratio = eo > 0 ? Math.round(coco / eo) : 0
if (formVolume.value === 'single') {
return `该配方为单次用量,纯精油 ${eo} 滴,椰子油 ${coco} 滴,稀释比例 1:${ratio}`
}
const vol = formVolume.value === 'custom' ? (formCustomVolume.value || 0) : Number(formVolume.value)
return `该配方总容量 ${vol}ml纯精油 ${eo} 滴,剩余用椰子油填满,稀释比例 1:${ratio}`
})
const formTotalCost = computed(() => {
let cost = formIngredients.value
.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
.reduce((sum, i) => sum + oils.pricePerDrop(i.oil) * i.drops, 0)
cost += oils.pricePerDrop('椰子油') * cocoActualDrops.value
return oils.fmtPrice(cost)
})
// Tag picker state
const showTagPicker = ref(false)
const tagPickerName = ref('')
const tagPickerTags = ref([])
// Computed lists
// "我的配方" = diary (user_diary table), personal recipes
const myRecipes = computed(() => diaryStore.userDiary)
// "公共配方库" = all recipes in public library (recipes table)
const publicRecipes = computed(() => recipeStore.recipes)
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.slice().sort((a, b) => a.name.localeCompare(b.name, 'zh'))
}
const myFilteredRecipes = computed(() => filterBySearchAndTags(myRecipes.value))
const publicFilteredRecipes = computed(() => filterBySearchAndTags(publicRecipes.value))
const globalNewTag = ref('')
async function addGlobalTag() {
const tag = globalNewTag.value.trim()
if (!tag) return
try {
await api('/api/tags', { method: 'POST', body: JSON.stringify({ name: tag }) })
if (!recipeStore.allTags.includes(tag)) {
recipeStore.allTags.push(tag)
recipeStore.allTags.sort((a, b) => a.localeCompare(b, 'zh'))
}
globalNewTag.value = ''
ui.showToast('标签已添加')
} catch {
ui.showToast('添加失败')
}
}
async function deleteGlobalTag(tag) {
const { showConfirm } = await import('../composables/useDialog')
const ok = await showConfirm(`确定删除标签「${tag}」?`)
if (!ok) return
try {
await api(`/api/tags/${encodeURIComponent(tag)}`, { method: 'DELETE' })
const idx = recipeStore.allTags.indexOf(tag)
if (idx >= 0) recipeStore.allTags.splice(idx, 1)
ui.showToast('标签已删除')
} catch {
ui.showToast('删除失败')
}
}
function toggleTagFilter() {
if (showTagFilter.value) {
showTagFilter.value = false
selectedTags.value = []
} else {
showTagFilter.value = true
}
}
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 toggleDiarySelect(id) {
if (selectedDiaryIds.has(id)) selectedDiaryIds.delete(id)
else selectedDiaryIds.add(id)
}
function clearSelection() {
selectedIds.clear()
selectedDiaryIds.clear()
showBatchMenu.value = false
}
function addBatchTag() {
const tag = batchNewTag.value.trim()
if (tag && !batchTagsSelected.value.includes(tag)) {
batchTagsSelected.value.push(tag)
}
batchNewTag.value = ''
}
async function applyBatchTags() {
const tagsToAdd = batchTagsSelected.value
const tagsToRemove = batchTagsToRemove.value
if (!tagsToAdd.length && !tagsToRemove.length) { ui.showToast('请选择要添加或移除的标签'); return }
const pubIds = [...selectedIds]
const diaryIds = [...selectedDiaryIds]
for (const id of pubIds) {
const recipe = recipeStore.recipes.find(r => r._id === id)
if (!recipe) continue
let newTags = [...recipe.tags]
let changed = false
for (const t of tagsToAdd) {
if (!newTags.includes(t)) { newTags.push(t); changed = true }
}
for (const t of tagsToRemove) {
const idx = newTags.indexOf(t)
if (idx >= 0) { newTags.splice(idx, 1); changed = true }
}
if (changed) {
await api(`/api/recipes/${recipe._id}`, {
method: 'PUT',
body: JSON.stringify({ tags: newTags }),
})
recipe.tags = newTags
}
}
for (const id of diaryIds) {
const d = diaryStore.userDiary.find(r => r.id === id)
if (!d) continue
let dtags = [...(d.tags || [])]
let changed = false
for (const t of tagsToAdd) {
if (!dtags.includes(t)) { dtags.push(t); changed = true }
}
for (const t of tagsToRemove) {
const idx = dtags.indexOf(t)
if (idx >= 0) { dtags.splice(idx, 1); changed = true }
}
if (changed) await diaryStore.updateDiary(id, { ...d, tags: dtags })
}
showBatchTagPicker.value = false
const msgs = []
if (tagsToAdd.length) msgs.push(`添加${tagsToAdd.length}`)
if (tagsToRemove.length) msgs.push(`移除${tagsToRemove.length}`)
ui.showToast(`已为 ${pubIds.length + diaryIds.length} 个配方${msgs.join('、')}标签`)
clearSelection()
}
function previewRecipe() {
const eoIngs = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
const cleanIngs = eoIngs.map(i => ({ oil: i.oil, drops: i.drops }))
if (formCocoRow.value && cocoActualDrops.value > 0) {
cleanIngs.push({ oil: '椰子油', drops: cocoActualDrops.value })
}
previewRecipeData.value = {
_id: null,
name: formName.value || '未命名配方',
note: formNote.value || '',
tags: formTags.value,
ingredients: cleanIngs,
}
}
function doBatch(action) {
showBatchMenu.value = false
executeBatchAction(action)
}
function toggleSelectAll() {
if (isAllSelected.value) {
clearSelection()
} else {
myFilteredRecipes.value.forEach(d => selectedDiaryIds.add(d.id))
if (auth.canEdit) publicFilteredRecipes.value.forEach(r => selectedIds.add(r._id))
showMyRecipes.value = true
if (auth.canEdit) showPublicRecipes.value = true
}
}
function toggleMySelect() {
if (isMyAllSelected.value) {
selectedDiaryIds.clear()
} else {
myFilteredRecipes.value.forEach(d => selectedDiaryIds.add(d.id))
showMyRecipes.value = true
}
}
function togglePubSelect() {
if (isPubAllSelected.value) {
selectedIds.clear()
} else {
publicFilteredRecipes.value.forEach(r => selectedIds.add(r._id))
showPublicRecipes.value = true
}
}
async function executeBatchAction(action) {
const pubIds = [...selectedIds]
const diaryIds = [...selectedDiaryIds]
const totalCount = pubIds.length + diaryIds.length
if (!totalCount) return
if (action === 'delete') {
const ok = await showConfirm(`确定删除 ${totalCount} 个配方?`)
if (!ok) return
for (const id of pubIds) {
await recipeStore.deleteRecipe(id)
}
for (const id of diaryIds) {
await diaryStore.deleteDiary(id)
}
ui.showToast(`已删除 ${totalCount} 个配方`)
} else if (action === 'tag') {
batchTagsSelected.value = []
batchTagsToRemove.value = []
batchNewTag.value = ''
showBatchTagPicker.value = true
return // don't clear selection yet
} else if (action === 'share_public') {
const ok = await showConfirm(`${diaryIds.length} 个配方分享到公共配方库?`)
if (!ok) return
let count = 0
for (const id of diaryIds) {
const d = diaryStore.userDiary.find(r => r.id === id)
if (!d) continue
try {
await api('/api/recipes', {
method: 'POST',
body: JSON.stringify({
name: d.name,
note: d.note || '',
ingredients: (d.ingredients || []).map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: d.tags || [],
}),
})
count++
} catch {}
}
await recipeStore.loadRecipes()
ui.showToast(`已提交 ${count} 个配方,等待审核`)
} else if (action === 'export') {
ui.showToast('导出卡片功能开发中')
}
clearSelection()
}
function calcDilutionFromIngs() {
// No default selection — user chooses
}
function editRecipe(recipe) {
editingRecipe.value = { _id: recipe._id, _version: recipe._version, name: recipe.name }
formName.value = recipe.name
const ings = recipe.ingredients || []
formIngredients.value = ings.filter(i => i.oil !== '椰子油').map(i => ({ ...i, _search: i.oil, _open: false }))
const coco = ings.find(i => i.oil === '椰子油')
if (coco) {
formCocoRow.value = { ...coco, _search: '椰子油', _open: false }
// Guess volume from total drops
const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
const totalDrops = eoDrops + coco.drops
const ml = totalDrops / DROPS_PER_ML
if (ml <= 2) formVolume.value = 'single'
else if (Math.abs(ml - 5) < 1.5) formVolume.value = '5'
else if (Math.abs(ml - 10) < 2.5) formVolume.value = '10'
else if (Math.abs(ml - 15) < 2.5) formVolume.value = '15'
else if (Math.abs(ml - 20) < 3) formVolume.value = '20'
else if (Math.abs(ml - 30) < 6) formVolume.value = '30'
else { formVolume.value = 'custom'; formCustomVolume.value = Math.round(ml) }
// Guess dilution
if (eoDrops > 0 && coco.drops > 0) {
const ratio = Math.round(coco.drops / eoDrops)
const options = [3,4,5,6,7,8,9,10,12,15,20]
formDilution.value = options.reduce((a, b) => Math.abs(b - ratio) < Math.abs(a - ratio) ? b : a)
}
} else {
formCocoRow.value = null
formVolume.value = ''
}
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, _search: '', _open: false }]
formCocoRow.value = null
formNote.value = ''
formTags.value = []
smartPasteText.value = ''
parsedRecipes.value = []
parsedCurrentIndex.value = -1
showAddIngRow.value = false
newIngOil.value = ''
newIngSearch.value = ''
newIngDrops.value = 1
formVolume.value = ''
formCustomVolume.value = null
formDilution.value = 6
}
function handleSmartPaste() {
const results = parseMultiRecipes(smartPasteText.value, oils.oilNames)
if (results.length === 0) {
ui.showToast('未能识别出任何配方')
return
}
if (results.length === 1) {
// Single recipe: populate form directly
const r = results[0]
formName.value = r.name
formIngredients.value = r.ingredients.length > 0
? r.ingredients.map(i => ({ ...i, _search: i.oil, _open: false }))
: [{ oil: '', drops: 1, _search: '', _open: false }]
if (r.notFound.length > 0) {
ui.showToast(`未识别: ${r.notFound.join('、')}`)
}
parsedRecipes.value = []
} else {
// Multiple recipes: store queue, load first into form
parsedRecipes.value = results
loadParsedIntoForm(0)
ui.showToast(`识别出 ${results.length} 条配方,逐条编辑保存`)
}
}
function filteredOilNames(search) {
if (!search) return oils.oilNames
const q = search.toLowerCase()
const results = oils.oilNames.filter(name =>
name.toLowerCase().includes(q) || matchesPinyinInitials(name, q)
)
// Sort: pinyin prefix match first, then name contains, then rest
results.sort((a, b) => {
const aPin = matchesPinyinInitials(a, q) ? 0 : 1
const bPin = matchesPinyinInitials(b, q) ? 0 : 1
if (aPin !== bPin) return aPin - bPin
return a.localeCompare(b, 'zh')
})
return results
}
function selectOil(ing, name) {
ing.oil = name
ing._search = name
ing._open = false
}
function onOilBlur(ing) {
setTimeout(() => {
ing._open = false
if (!ing.oil) ing._search = ''
else ing._search = ing.oil
}, 150)
}
const newTagInput = ref('')
const formCandidateTags = computed(() =>
recipeStore.allTags.filter(t => !formTags.value.includes(t))
)
function addNewFormTag() {
const tag = newTagInput.value.trim()
if (!tag) return
if (!formTags.value.includes(tag)) {
formTags.value.push(tag)
}
// Also add to global tags so it appears in candidates if removed
if (!recipeStore.allTags.includes(tag)) {
recipeStore.allTags.push(tag)
recipeStore.allTags.sort((a, b) => a.localeCompare(b, 'zh'))
}
newTagInput.value = ''
}
const DROPS_PER_ML = 18.6
const formDilutionHint = computed(() => {
const eoIngs = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
const eoDrops = eoIngs.reduce((s, i) => s + i.drops, 0)
if (formVolume.value === 'single') {
const cocoDrops = Math.round(eoDrops * formDilution.value)
const totalDrops = eoDrops + cocoDrops
return `单次用量:纯精油约 ${eoDrops} 滴 + 椰子油约 ${cocoDrops} 滴,共 ${totalDrops} 滴 (${Math.round(totalDrops / DROPS_PER_ML)}ml),稀释 1:${formDilution.value}`
}
let totalDrops
if (formVolume.value === 'custom') {
totalDrops = formCustomUnit.value === 'ml' ? Math.round(formCustomVolume.value * DROPS_PER_ML) : formCustomVolume.value
} else {
totalDrops = Math.round(Number(formVolume.value) * DROPS_PER_ML)
}
const targetEo = Math.round(totalDrops / (1 + formDilution.value))
const cocoDrops = totalDrops - targetEo
return `总容量 ${totalDrops} 滴 (${Math.round(totalDrops / DROPS_PER_ML)}ml),纯精油约 ${targetEo} 滴 + 椰子油约 ${cocoDrops} 滴,稀释 1:${formDilution.value}`
})
function applyVolumeDilution() {
const eoIngs = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油')
if (eoIngs.length === 0) { ui.showToast('请先添加精油'); return }
let targetTotalDrops
if (formVolume.value === 'single') {
const targetEoDrops = 10
const currentEoTotal = eoIngs.reduce((s, i) => s + (i.drops || 0), 0)
if (currentEoTotal <= 0) return
const scale = targetEoDrops / currentEoTotal
eoIngs.forEach(i => { i.drops = Math.max(0.5, Math.round(i.drops * scale * 2) / 2) })
const actualEo = eoIngs.reduce((s, i) => s + i.drops, 0)
setFormCoconut(actualEo * formDilution.value)
ui.showToast('已应用单次用量')
return
}
if (formVolume.value === 'custom') {
targetTotalDrops = formCustomUnit.value === 'ml' ? Math.round(formCustomVolume.value * DROPS_PER_ML) : formCustomVolume.value
} else {
targetTotalDrops = Math.round(Number(formVolume.value) * DROPS_PER_ML)
}
const targetEoDrops = Math.round(targetTotalDrops / (1 + formDilution.value))
const currentEoTotal = eoIngs.reduce((s, i) => s + (i.drops || 0), 0)
if (currentEoTotal <= 0) return
const scale = targetEoDrops / currentEoTotal
eoIngs.forEach(i => { i.drops = Math.max(1, Math.round(i.drops * scale)) })
const actualEo = eoIngs.reduce((s, i) => s + i.drops, 0)
setFormCoconut(targetTotalDrops - actualEo)
ui.showToast('已应用容量设置')
}
function setFormCoconut(drops) {
drops = Math.max(0, Math.round(drops))
const idx = formIngredients.value.findIndex(i => i.oil === '椰子油')
if (idx >= 0) {
formIngredients.value[idx].drops = drops
} else if (drops > 0) {
formIngredients.value.push({ oil: '椰子油', drops, _search: '椰子油', _open: false })
}
}
function addOilRow() {
formIngredients.value.push({ oil: '', drops: 1, _search: '', _open: false })
}
function removeEoRow(index) {
// Find the actual index in formIngredients (skip coconut)
const eoIngs = formIngredients.value.filter(i => i.oil !== '椰子油')
const target = eoIngs[index]
const realIdx = formIngredients.value.indexOf(target)
if (realIdx >= 0) formIngredients.value.splice(realIdx, 1)
}
function confirmAddIng() {
if (!newIngOil.value || !newIngDrops.value) return
formIngredients.value.push({ oil: newIngOil.value, drops: newIngDrops.value, _search: newIngOil.value, _open: false })
newIngOil.value = ''
newIngSearch.value = ''
newIngDrops.value = 1
showAddIngRow.value = false
}
function toggleFormTag(tag) {
const idx = formTags.value.indexOf(tag)
if (idx >= 0) formTags.value.splice(idx, 1)
else formTags.value.push(tag)
}
/**
* Check name against public + personal recipes.
* Same name + same content → toast and return false.
* Same name + different content → show diff, prompt rename, loop until unique.
* Returns final name or false if cancelled.
*/
async function checkDupName(name, ings, target = 'diary') {
let currentName = name
while (true) {
const dup = target === 'public'
? recipeStore.recipes.find(r => r.name === currentName)
: diaryStore.userDiary.find(d => d.name === currentName)
if (!dup) return currentName
const dupIngs = (dup.ingredients || []).filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
const myIngs = ings.filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
const identical = dupIngs.length === myIngs.length && dupIngs.every((d, i) => d.oil === myIngs[i].oil && d.drops === myIngs[i].drops)
const where = target === 'public' ? '公共配方库' : '我的配方'
if (identical) {
ui.showToast(`${where}中已有一模一样的配方「${currentName}」,已跳过`)
return false
}
const existIngs = dupIngs.map(i => `${i.oil}${i.drops}`).join('、')
const newIngs = myIngs.map(i => `${i.oil}${i.drops}`).join('、')
const ok = await showConfirm(
`${where}中已有同名配方「${currentName}」,内容不同:\n\n已有${existIngs}\n新的${newIngs}\n\n请改名后保存`,
{ okText: '改名', cancelText: '取消' }
)
if (!ok) return false
const newName = await showPrompt('请输入新名称:', currentName)
if (!newName || !newName.trim()) return false
currentName = newName.trim()
// Loop back to check the new name
}
}
async function saveCurrentRecipe() {
if (formVolume.value === 'custom' && !formCustomVolume.value) {
ui.showToast('请输入自定义容量')
return
}
const eoIngs = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
if (!formName.value.trim()) {
ui.showToast('请输入配方名称')
return
}
if (eoIngs.length === 0) {
ui.showToast('请至少添加一个精油')
return
}
// Combine EO + coconut
const cleanIngs = eoIngs.map(i => ({ oil: i.oil, drops: i.drops }))
if (formCocoRow.value && cocoActualDrops.value > 0) {
cleanIngs.push({ oil: '椰子油', drops: cocoActualDrops.value })
}
const diaryPayload = {
name: formName.value.trim(),
ingredients: cleanIngs,
note: formNote.value,
tags: formTags.value,
}
if (editingRecipe.value && editingRecipe.value._diary_id) {
// Editing an existing diary recipe
try {
await diaryStore.updateDiary(editingRecipe.value._diary_id, diaryPayload)
ui.showToast('个人配方已更新')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
return
}
if (editingRecipe.value && editingRecipe.value._id) {
// Editing an existing public recipe — safety check
const mappedIngs = cleanIngs.map(i => ({ oil_name: i.oil, drops: i.drops }))
if (mappedIngs.length === 0) {
const ok = await showConfirm('配方中没有精油成分,确定保存吗?这将清空所有成分。')
if (!ok) return
}
const payload = {
_id: editingRecipe.value._id,
_version: editingRecipe.value._version,
name: formName.value.trim(),
ingredients: mappedIngs,
note: formNote.value,
tags: formTags.value,
}
try {
await recipeStore.saveRecipe(payload)
ui.showToast('配方已更新')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
return
}
// New recipe: admin/senior_editor can choose public or personal
if (auth.canManage) {
const toPublic = await showConfirm('保存到哪里?', { okText: '公共配方库', cancelText: '个人配方' })
if (toPublic) {
const finalName = await dedupOrSkip(diaryPayload.name, cleanIngs, 'public')
if (!finalName) return
try {
await recipeStore.saveRecipe({
name: finalName,
ingredients: cleanIngs.map(i => ({ oil_name: i.oil, drops: i.drops })),
note: formNote.value,
tags: formTags.value,
})
ui.showToast('已添加到公共配方库')
if (!loadNextParsed()) closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
return
}
}
const finalName = await dedupOrSkip(diaryPayload.name, cleanIngs, 'diary')
if (!finalName) return
diaryPayload.name = finalName
formName.value = finalName
try {
await diaryStore.createDiary(diaryPayload)
ui.showToast('已添加到我的配方')
if (!loadNextParsed()) closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
}
async function saveParsedRecipe(index) {
const r = parsedRecipes.value[index]
if (!r.name.trim() || r.ingredients.length === 0) {
ui.showToast('配方名称和成分不能为空')
return
}
try {
await diaryStore.createDiary({
name: r.name.trim(),
ingredients: r.ingredients.map(i => ({ oil: i.oil, drops: i.drops })),
note: '',
tags: [],
})
parsedRecipes.value.splice(index, 1)
ui.showToast(`${r.name}」已保存到我的配方`)
} catch (e) {
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
}
}
async function saveAllParsed() {
// Sync current form edits back first
syncFormToParsed()
const toPublic = auth.canManage && await showConfirm('全部保存到哪里?', { okText: '公共配方库', cancelText: '个人配方' })
let saved = 0, skipped = 0
for (let i = 0; i < parsedRecipes.value.length; i++) {
const r = parsedRecipes.value[i]
if (!r.name.trim() || r.ingredients.length === 0) continue
const ings = r.ingredients.map(ing => ({ oil: ing.oil, drops: ing.drops }))
const finalName = await checkDupName(r.name.trim(), ings, toPublic ? 'public' : 'diary')
if (finalName === false) { skipped++; continue }
try {
if (toPublic) {
await recipeStore.saveRecipe({
name: finalName,
ingredients: ings.map(ing => ({ oil_name: ing.oil, drops: ing.drops })),
note: '',
tags: [],
})
} else {
await diaryStore.createDiary({ name: finalName, ingredients: ings, note: '', tags: [] })
}
saved++
} catch {}
}
parsedRecipes.value = []
parsedCurrentIndex.value = -1
ui.showToast(`已保存 ${saved} 条配方到${toPublic ? '公共配方库' : '我的配方'}`)
closeOverlay()
}
/** Skip current parsed recipe and load next, or close if none left. */
function skipCurrentParsed() {
if (parsedCurrentIndex.value < 0) return
const skipIdx = parsedCurrentIndex.value
parsedCurrentIndex.value = -1
parsedRecipes.value.splice(skipIdx, 1)
if (parsedRecipes.value.length > 0) {
const next = Math.min(skipIdx, parsedRecipes.value.length - 1)
parsedCurrentIndex.value = next
const r = parsedRecipes.value[next]
formName.value = r.name
const cocoIng = r.ingredients.find(i => i.oil === '椰子油')
const eoIngs = r.ingredients.filter(i => i.oil !== '椰子油')
formIngredients.value = eoIngs.length > 0
? eoIngs.map(i => ({ ...i, _search: i.oil, _open: false }))
: [{ oil: '', drops: 1, _search: '', _open: false }]
if (cocoIng) {
formCocoRow.value = { oil: '椰子油', drops: cocoIng.drops, _search: '椰子油', _open: false }
formVolume.value = 'single'
} else {
formCocoRow.value = null
formVolume.value = ''
}
} else {
closeOverlay()
}
}
/**
* Run dedup check for saveCurrentRecipe. Returns final name or null if should stop.
*/
async function dedupOrSkip(name, ings, target) {
if (editingRecipe.value) return name
const result = await checkDupName(name, ings, target)
if (result === false) {
if (parsedCurrentIndex.value >= 0) skipCurrentParsed()
return null
}
return result
}
/** After saving, mark current as done and load next. Returns true if there's a next one. */
function loadNextParsed() {
if (parsedCurrentIndex.value < 0 || parsedRecipes.value.length === 0) return false
// Remove the just-saved recipe
parsedRecipes.value.splice(parsedCurrentIndex.value, 1)
if (parsedRecipes.value.length === 0) {
parsedCurrentIndex.value = -1
return false
}
// Load next (same index, or last if was at end)
const next = Math.min(parsedCurrentIndex.value, parsedRecipes.value.length - 1)
loadParsedIntoForm(next)
return true
}
/** Sync current form edits back to parsedRecipes before switching */
function syncFormToParsed() {
if (parsedCurrentIndex.value < 0) return
const r = parsedRecipes.value[parsedCurrentIndex.value]
if (!r) return
r.name = formName.value
// Rebuild ingredients from form (EO + coco)
const ings = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油').map(i => ({ oil: i.oil, drops: i.drops }))
if (formCocoRow.value && cocoActualDrops.value > 0) {
ings.push({ oil: '椰子油', drops: cocoActualDrops.value })
}
r.ingredients = ings
}
function loadParsedIntoForm(index) {
// Save current edits before switching
syncFormToParsed()
const r = parsedRecipes.value[index]
if (!r) return
parsedCurrentIndex.value = index
formName.value = r.name
const cocoIng = r.ingredients.find(i => i.oil === '椰子油')
const eoIngs = r.ingredients.filter(i => i.oil !== '椰子油')
formIngredients.value = eoIngs.length > 0
? eoIngs.map(i => ({ ...i, _search: i.oil, _open: false }))
: [{ oil: '', drops: 1, _search: '', _open: false }]
if (cocoIng) {
if (cocoIng._ml) {
// Written as ml — use ml volume mode
const mlStr = String(cocoIng._ml)
const standardMls = ['5', '10', '15', '20', '30']
formCocoRow.value = { oil: '椰子油', drops: 0, _search: '椰子油', _open: false }
formVolume.value = standardMls.includes(mlStr) ? mlStr : 'custom'
if (!standardMls.includes(mlStr)) formCustomVolume.value = cocoIng._ml
} else {
// Written as drops — use single mode
formCocoRow.value = { oil: '椰子油', drops: cocoIng.drops, _search: '椰子油', _open: false }
formVolume.value = 'single'
}
} else {
formCocoRow.value = null
}
formNote.value = ''
formTags.value = []
if (!cocoIng) formVolume.value = ''
if (r.notFound && r.notFound.length > 0) {
ui.showToast(`未识别: ${r.notFound.join('、')}`)
}
}
const sharedCount = ref({ adopted: 0, total: 0, adoptedNames: [], pendingNames: [] })
async function loadContribution() {
try {
const res = await api('/api/me/contribution')
if (res.ok) {
const data = await res.json()
sharedCount.value = {
adopted: data.adopted_count || 0,
total: data.shared_count || 0,
adoptedNames: data.adopted_names || [],
pendingNames: data.pending_names || [],
}
}
} catch {}
}
const previewRecipeIndex = ref(null)
const previewRecipeData = ref(null)
const showBatchMenu = ref(false)
const visibleAllTags = computed(() => {
const tags = recipeStore.allTags
if (auth.canEdit) return tags
return tags.filter(t => !EDITOR_ONLY_TAGS.includes(t))
})
const showBatchTagPicker = ref(false)
const batchTagsSelected = ref([])
const batchNewTag = ref('')
const batchTagsToRemove = ref([])
const batchExistingTags = computed(() => {
const tagSets = []
for (const id of selectedIds) {
const r = recipeStore.recipes.find(x => x._id === id)
if (r) tagSets.push(r.tags || [])
}
for (const id of selectedDiaryIds) {
const d = diaryStore.userDiary.find(x => x.id === id)
if (d) tagSets.push(d.tags || [])
}
if (!tagSets.length) return []
const all = new Set(tagSets.flat())
return [...all].sort((a, b) => a.localeCompare(b, 'zh'))
})
const totalSelected = computed(() => selectedIds.size + selectedDiaryIds.size)
const isMyAllSelected = computed(() => myFilteredRecipes.value.length > 0 && selectedDiaryIds.size === myFilteredRecipes.value.length)
const isPubAllSelected = computed(() => publicFilteredRecipes.value.length > 0 && selectedIds.size === publicFilteredRecipes.value.length)
const isAllSelected = computed(() => {
const myOk = myFilteredRecipes.value.length > 0 && isMyAllSelected.value
const pubOk = !auth.canEdit || (publicFilteredRecipes.value.length > 0 && isPubAllSelected.value)
return myOk && pubOk
})
const showMyRecipes = ref(false)
const showPublicRecipes = ref(false)
const showReviewHistory = ref(false)
const reviewHistory = ref([])
function parseReviewDetail(detail) {
try {
const d = JSON.parse(detail)
if (d.from_user) return `来自: ${d.from_user}`
if (d.reason) return `原因: ${d.reason}`
} catch {}
return ''
}
function formatDate(d) {
if (!d) return ''
return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
// Load diary on mount
onMounted(async () => {
if (auth.isLoggedIn) {
await diaryStore.loadDiary()
await loadContribution()
}
if (auth.isAdmin) {
try {
const res = await api('/api/recipe-reviews')
if (res.ok) reviewHistory.value = await res.json()
} catch {}
try {
const res = await api('/api/users')
if (res.ok) {
const users = await res.json()
seniorEditors.value = users.filter(u => u.role === 'senior_editor')
}
} catch {}
}
// Auto-expand pending if navigated from notification
if (localStorage.getItem('oil_open_pending')) {
localStorage.removeItem('oil_open_pending')
showPending.value = true
}
// Open recipe editor if redirected from card view
const editId = localStorage.getItem('oil_edit_recipe_id')
if (editId) {
localStorage.removeItem('oil_edit_recipe_id')
const recipe = recipeStore.recipes.find(r => String(r._id) === editId)
if (recipe) editRecipe(recipe)
}
})
function editDiaryRecipe(diary) {
editingRecipe.value = { _diary_id: diary.id, name: diary.name }
formName.value = diary.name
const ings = diary.ingredients || []
formIngredients.value = ings.filter(i => i.oil !== '椰子油').map(i => ({ ...i, _search: i.oil, _open: false }))
const coco = ings.find(i => i.oil === '椰子油')
if (coco) {
formCocoRow.value = { ...coco, _search: '椰子油', _open: false }
const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
const totalDrops = eoDrops + coco.drops
const ml = totalDrops / DROPS_PER_ML
if (ml <= 2) formVolume.value = 'single'
else if (Math.abs(ml - 5) < 1.5) formVolume.value = '5'
else if (Math.abs(ml - 10) < 2.5) formVolume.value = '10'
else if (Math.abs(ml - 15) < 2.5) formVolume.value = '15'
else if (Math.abs(ml - 20) < 3) formVolume.value = '20'
else if (Math.abs(ml - 30) < 6) formVolume.value = '30'
else { formVolume.value = 'custom'; formCustomVolume.value = Math.round(ml) }
if (eoDrops > 0 && coco.drops > 0) {
const ratio = Math.round(coco.drops / eoDrops)
const options = [3,4,5,6,7,8,9,10,12,15,20]
formDilution.value = options.reduce((a, b) => Math.abs(b - ratio) < Math.abs(a - ratio) ? b : a)
}
} else {
formCocoRow.value = null
formVolume.value = ''
}
formNote.value = diary.note || ''
formTags.value = [...(diary.tags || [])]
showAddOverlay.value = true
}
async function assignReview(recipe) {
const userId = recipe._assignTo
if (!userId) return
try {
const res = await api('/api/recipes/' + recipe._id + '/assign-review', {
method: 'POST',
body: JSON.stringify({ user_id: userId }),
})
if (res.ok) {
recipe._showAssign = false
recipe._assignTo = ''
ui.showToast('已指派审核')
}
} catch {
ui.showToast('指派失败')
}
}
function openRecipeDetail(recipe) {
const idx = recipeStore.recipes.findIndex(r => r._id === recipe._id)
if (idx >= 0) previewRecipeIndex.value = idx
}
function getVolumeLabel(ingredients) {
const ings = ingredients || []
const coco = ings.find(i => i.oil === '椰子油')
if (!coco || !coco.drops) return ''
const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0)
const ml = totalDrops / 18.6
if (ml <= 2) return '单次'
return `${Math.round(ml)}ml`
}
function diaryMatchesPublic(d) {
const pub = recipeStore.recipes.find(r => r.name === d.name)
if (!pub) return false
const dIngs = (d.ingredients || []).filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
const pIngs = (pub.ingredients || []).filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
return dIngs.length === pIngs.length && dIngs.every((ing, i) => ing.oil === pIngs[i].oil && ing.drops === pIngs[i].drops)
}
function getDiaryShareStatus(d) {
// Admin/senior_editor share directly — check public match first
if (diaryMatchesPublic(d)) return 'shared'
// Non-admin: check pending (owned by user, not yet adopted)
if (!auth.isAdmin && !auth.canManage && sharedCount.value.pendingNames.includes(d.name)) return 'pending'
return null
}
function handleShare(d) {
const status = getDiaryShareStatus(d)
if (status === 'shared') {
ui.showToast('该配方已共享到公共配方库,感谢你的贡献!')
return
}
if (status === 'pending') {
ui.showToast('该配方正在审核中,请耐心等待')
return
}
shareDiaryToPublic(d)
}
async function recommendApprove(recipe) {
try {
await api('/api/recipes/' + recipe._id + '/recommend', {
method: 'POST',
body: JSON.stringify({ recommendation: 'approve' }),
})
ui.showToast('已推荐通过,等待管理员最终审核')
} catch {
ui.showToast('操作失败')
}
}
async function shareDiaryToPublic(diary) {
const ings = (diary.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
const result = await checkDupName(diary.name, ings, 'public')
if (result === false) return
if (result !== diary.name) diary = { ...diary, name: result }
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?`)
if (!ok) return
try {
const res = await api('/api/recipes', {
method: 'POST',
body: JSON.stringify({
name: diary.name,
note: diary.note || '',
ingredients: (diary.ingredients || []).map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: diary.tags || [],
}),
})
if (res.ok) {
if (auth.isAdmin || auth.canManage) {
ui.showToast('已共享到公共配方库')
} else {
ui.showToast('已提交,等待管理员审核')
}
await recipeStore.loadRecipes()
await loadContribution()
}
} catch {
ui.showToast('共享失败')
}
}
async function removeDiaryRecipe(diary) {
const ok = await showConfirm(`确定删除个人配方 "${diary.name}"`)
if (!ok) return
try {
await diaryStore.deleteDiary(diary.id)
ui.showToast('已删除')
} catch {
ui.showToast('删除失败')
}
}
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('删除失败')
}
}
function recipesIdentical(a, b) {
if ((a.ingredients || []).length !== (b.ingredients || []).length) return false
const aIngs = [...a.ingredients].sort((x, y) => x.oil.localeCompare(y.oil))
const bIngs = [...b.ingredients].sort((x, y) => x.oil.localeCompare(y.oil))
return aIngs.every((ing, i) => ing.oil === bIngs[i].oil && ing.drops === bIngs[i].drops)
}
function formatIngsCompare(ings) {
return (ings || []).map(i => `${i.oil} ${i.drops}`).join('、')
}
async function approveRecipe(recipe) {
const dup = recipeStore.recipes.find(r => r.name === recipe.name && r._id !== recipe._id)
if (dup) {
if (recipesIdentical(recipe, dup)) {
// Identical — delete this duplicate silently
const ok = await showConfirm(`公共配方库中已有一模一样的配方「${recipe.name}」,忽略这条?`)
if (!ok) return
try {
await api(`/api/recipes/${recipe._id}/reject`, { method: 'POST', body: '{}' })
await recipeStore.loadRecipes()
ui.showToast('已忽略重复配方')
} catch { ui.showToast('操作失败') }
return
}
// Different content
const msg = `已有同名配方「${recipe.name}」但内容不同:\n\n` +
`已有:${formatIngsCompare(dup.ingredients)}\n` +
`新的:${formatIngsCompare(recipe.ingredients)}`
const action = await showConfirm(msg, { okText: '改名后采纳', cancelText: '放弃' })
if (!action) {
// User chose to discard
try {
await api(`/api/recipes/${recipe._id}/reject`, { method: 'POST', body: JSON.stringify({ reason: '与已有同名配方重复' }) })
await recipeStore.loadRecipes()
ui.showToast('已放弃')
} catch { ui.showToast('操作失败') }
return
}
// User wants to rename
const newName = await showPrompt('请输入新名称:', recipe.name)
if (!newName || !newName.trim()) return
try {
await api(`/api/recipes/${recipe._id}`, {
method: 'PUT',
body: JSON.stringify({ name: newName.trim() }),
})
recipe.name = newName.trim()
} catch {
ui.showToast('改名失败')
return
}
}
try {
const res = await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
if (res.ok) {
ui.showToast('已采纳并通知提交者')
await recipeStore.loadRecipes()
}
} catch {
ui.showToast('操作失败')
}
}
async function rejectRecipe(recipe) {
const reason = await showPrompt(`拒绝「${recipe.name}」的原因(选填):`)
if (reason === null) return
try {
const res = await api(`/api/recipes/${recipe._id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason || '' }),
})
if (res.ok) {
await recipeStore.loadRecipes()
ui.showToast('已拒绝并通知提交者')
}
} catch {
ui.showToast('操作失败')
}
}
async function exportExcel() {
const recipes = recipeStore.recipes
if (!recipes.length) { ui.showToast('没有配方可导出'); return }
const XLSX = (await import('xlsx')).default || await import('xlsx')
function recipesToRows(list) {
return list.map(r => ({
'配方名称': r.name,
'标签': (r.tags || []).join('/'),
'精油成分': r.ingredients.map(i => `${i.oil}${i.drops}`).join('、'),
'成本': oils.fmtPrice(oils.calcCost(r.ingredients)),
'备注': r.note || '',
}))
}
const wb = XLSX.utils.book_new()
// Sheet 1: 全部
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(recipesToRows(recipes)), '全部')
// Per tag sheets
const allTags = [...new Set(recipes.flatMap(r => r.tags || []))].sort((a, b) => a.localeCompare(b, 'zh'))
for (const tag of allTags) {
const tagged = recipes.filter(r => r.tags && r.tags.includes(tag))
if (tagged.length) {
const name = tag.substring(0, 31) // Excel sheet name max 31 chars
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(recipesToRows(tagged)), name)
}
}
const today = new Date().toISOString().slice(0, 10)
XLSX.writeFile(wb, `精油配方${today}.xlsx`)
ui.showToast('导出成功')
}
function onTagPickerSave(tags) {
formTags.value = tags
showTagPicker.value = false
}
watch(() => recipeStore.recipes, () => {
if (auth.isAdmin) {
const pending = recipeStore.recipes
.filter(r => r._owner_id && r._owner_id !== auth.user.id)
.map(r => ({ ...r, _showAssign: false, _assignTo: '' }))
pendingRecipes.value = pending
pendingCount.value = pending.length
}
}, { immediate: true })
</script>
<style scoped>
.recipe-manager {
padding: 0 12px 24px;
}
.login-prompt {
text-align: center; padding: 60px 20px; color: #6b6375;
}
.login-prompt p { margin-bottom: 16px; font-size: 15px; }
.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-name.clickable {
cursor: pointer;
color: #4a9d7e;
text-decoration: underline;
}
.pending-name.clickable:hover {
color: #2e7d5a;
}
.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;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.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;
}
.share-tag {
font-size: 11px;
padding: 1px 8px;
border-radius: 8px;
font-weight: 500;
white-space: nowrap;
}
.share-tag.shared { background: #e8f5e9; color: #2e7d32; }
.share-tag.pending { background: #fff3e0; color: #e65100; }
.row-volume { font-size: 10px; color: #b0aab5; white-space: nowrap; }
.review-history { max-height: 300px; overflow-y: auto; }
.review-log-item {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
border-bottom: 1px solid #f5f5f5; font-size: 13px; flex-wrap: wrap;
}
.log-approve { color: #2e7d32; font-weight: 600; white-space: nowrap; }
.log-reject { color: #c62828; font-weight: 600; white-space: nowrap; }
.log-recipe { font-weight: 500; color: #3e3a44; }
.log-from { color: #999; font-size: 12px; }
.log-time { color: #bbb; font-size: 11px; margin-left: auto; white-space: nowrap; }
.section-title.clickable { cursor: pointer; display: flex; align-items: center; gap: 6px; }
.toggle-icon { font-size: 12px; color: #999; margin-left: auto; }
.contrib-tag {
font-size: 11px;
color: #4a9d7e;
background: #e8f5e9;
padding: 2px 8px;
border-radius: 8px;
font-weight: 500;
}
.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;
}
.parsed-queue { margin: 12px 0; background: #f8faf8; border: 1.5px solid #d4e8d4; border-radius: 10px; padding: 10px 12px; }
.parsed-queue-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.parsed-queue-label { font-size: 13px; font-weight: 600; color: #2e7d5a; flex: 1; }
.parsed-queue-list { display: flex; flex-wrap: wrap; gap: 6px; }
.parsed-queue-item {
display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 8px;
border: 1.5px solid #e5e4e7; background: #fff; font-size: 12px; cursor: pointer; font-family: inherit;
}
.parsed-queue-item.active { border-color: #7ec6a4; background: #e8f5e9; font-weight: 600; }
.parsed-queue-item:hover { border-color: #d4cfc7; }
.parsed-queue-name { color: #3e3a44; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.parsed-queue-count { color: #b0aab5; font-size: 11px; }
.editor-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.editor-name-input { width: 100%; font-size: 17px; font-weight: 600; border: none; border-bottom: 2px solid #e5e4e7; padding: 6px 0; outline: none; font-family: inherit; background: transparent; color: #3e3a44; }
.editor-name-input::placeholder { color: #ccc; font-weight: 400; }
.editor-name-input:focus { border-bottom-color: #7ec6a4; }
.editor-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
.editor-tip { font-size: 12px; color: #999; background: #f8f7f5; padding: 8px 12px; border-radius: 8px; margin-bottom: 12px; }
.editor-section { margin-bottom: 16px; }
.editor-label { font-size: 13px; font-weight: 600; color: #3e3a44; margin-bottom: 6px; display: block; }
.editor-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 8px; }
.editor-table th { text-align: left; padding: 6px 4px; color: #999; font-weight: 500; font-size: 12px; border-bottom: 1px solid #eee; }
.editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; }
.editor-drops { width: 65px; padding: 6px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; }
.editor-drops:focus { border-color: #7ec6a4; }
.editor-input { padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; outline: none; font-family: inherit; width: 100%; box-sizing: border-box; }
.editor-input:focus { border-color: #7ec6a4; }
.editor-textarea { width: 100%; padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; font-family: inherit; outline: none; resize: vertical; box-sizing: border-box; }
.editor-textarea:focus { border-color: #7ec6a4; }
.ing-ppd { color: #b0aab5; font-size: 12px; }
.ing-cost { color: #4a9d7e; font-weight: 500; font-size: 13px; }
.remove-row-btn { border: none; background: none; color: #ccc; cursor: pointer; font-size: 16px; padding: 2px 4px; }
.remove-row-btn:hover { color: #c0392b; }
.add-row-btn { border: 1.5px dashed #d4cfc7; background: none; color: #999; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; width: 100%; }
.add-row-btn:hover { border-color: #7ec6a4; color: #4a9d7e; }
.add-ingredient-row { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }
.editor-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.editor-tag { background: #e8f5e9; color: #2e7d5a; padding: 4px 10px; border-radius: 12px; font-size: 12px; display: flex; align-items: center; gap: 4px; }
.tag-remove { cursor: pointer; font-size: 14px; color: #999; }
.tag-remove:hover { color: #c0392b; }
.candidate-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.candidate-tag { background: #f0eeeb; color: #6b6375; padding: 4px 10px; border-radius: 12px; font-size: 12px; cursor: pointer; }
.candidate-tag:hover { background: #e8f5e9; color: #2e7d5a; }
.tag-input-row { display: flex; gap: 6px; align-items: center; margin-top: 6px; }
.editor-total { text-align: right; font-size: 15px; font-weight: 600; color: #4a9d7e; padding: 10px 0; border-top: 1px solid #eee; }
.action-btn { border: 1.5px solid #d4cfc7; background: #fff; color: #6b6375; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; font-family: inherit; white-space: nowrap; }
.action-btn:hover { background: #f8f7f5; }
.action-btn-primary { background: linear-gradient(135deg, #7ec6a4, #4a9d7e); color: #fff; border-color: transparent; }
.action-btn-primary:hover { opacity: 0.9; }
.action-btn-sm { padding: 5px 12px; font-size: 12px; }
.volume-controls { display: flex; gap: 4px; margin-bottom: 8px; flex-wrap: wrap; }
.volume-btn { padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; background: #fff; font-size: 13px; cursor: pointer; font-family: inherit; color: #6b6375; text-align: center; }
.volume-btn.active { background: #e8f5e9; border-color: #7ec6a4; color: #2e7d5a; font-weight: 600; }
.volume-btn:hover { border-color: #7ec6a4; }
.custom-volume-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
.ratio-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.ratio-label { font-size: 12px; color: #4a9d7e; font-weight: 500; white-space: nowrap; }
.ratio-options { display: flex; gap: 4px; flex-wrap: wrap; }
.ratio-btn {
padding: 3px 8px; border: 1px solid #e5e4e7; border-radius: 6px; background: #fff;
font-size: 11px; cursor: pointer; font-family: inherit; color: #6b6375; min-width: 28px; text-align: center;
}
.ratio-btn.active { background: #e8f5e9; border-color: #7ec6a4; color: #2e7d5a; font-weight: 600; }
.ratio-btn:hover { border-color: #7ec6a4; }
.ratio-hint { font-size: 12px; color: #4a9d7e; font-weight: 500; white-space: nowrap; }
.coco-row { background: #f8faf8; }
.coco-label { font-weight: 600; color: #4a9d7e; font-size: 13px; }
.coco-fill { font-size: 12px; color: #4a9d7e; font-weight: 500; }
.recipe-summary {
padding: 10px 14px; background: #f0faf5; border-radius: 10px; border-left: 3px solid #7ec6a4;
font-size: 13px; color: #2e7d5a; margin-bottom: 12px; line-height: 1.6;
}
.drops-sm { width: 50px; padding: 4px 6px; border: 1.5px solid #d4cfc7; border-radius: 6px; font-size: 12px; text-align: center; outline: none; font-family: inherit; }
.drops-sm:focus { border-color: #7ec6a4; }
.select-sm { padding: 3px 6px; border: 1.5px solid #d4cfc7; border-radius: 6px; font-size: 12px; font-family: inherit; background: #fff; width: auto; }
.btn-select-active { background: #e8f5e9; color: #2e7d5a; border: 1.5px solid #7ec6a4; border-radius: 10px; padding: 7px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 600; }
.search-bar { margin-bottom: 8px; }
.search-bar .search-box {
display: flex; align-items: center; background: #f8f7f5; border: 1.5px solid #e5e4e7;
border-radius: 10px; padding: 2px 10px;
}
.search-bar .search-input {
flex: 1; border: none; background: transparent; padding: 8px 4px; font-size: 14px;
outline: none; font-family: inherit;
}
.action-bar {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 10px;
}
.action-chip {
padding: 5px 14px; border: 1.5px solid #e5e4e7; border-radius: 20px; background: #fff;
font-size: 13px; cursor: pointer; font-family: inherit; color: #6b6375; white-space: nowrap;
transition: all 0.15s;
}
.action-chip:hover { border-color: #7ec6a4; color: #4a9d7e; }
.action-chip.active { background: #e8f5e9; border-color: #7ec6a4; color: #2e7d5a; font-weight: 600; }
.action-chip.cancel { color: #999; }
.chip-count {
display: inline-block; background: #4a9d7e; color: #fff; font-size: 11px;
min-width: 16px; height: 16px; line-height: 16px; text-align: center;
border-radius: 8px; margin-left: 4px; padding: 0 4px;
}
.detail-close-btn {
border: none; background: #f0eeeb; width: 26px; height: 26px; border-radius: 50%;
cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center; color: #6b6375;
}
.select-count { font-size: 12px; color: #4a9d7e; font-weight: 500; white-space: nowrap; }
.batch-menu { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.batch-menu-btn {
padding: 5px 12px; border: 1.5px solid #e5e4e7; border-radius: 8px; background: #fff;
font-size: 12px; cursor: pointer; font-family: inherit; color: #6b6375;
}
.batch-menu-btn:hover { border-color: #7ec6a4; color: #4a9d7e; }
.batch-delete { color: #c0392b; border-color: #e8b4b0; }
.batch-delete:hover { background: #fdf0ee; border-color: #c0392b; }
.batch-tag-picker {
padding: 12px; background: #f8faf8; border: 1.5px solid #d4e8d4; border-radius: 10px; margin-bottom: 10px;
}
.tag-removable { cursor: pointer; transition: all 0.15s; }
.tag-removable:hover { background: #fce4ec; border-color: #e8b4b0; color: #c62828; }
.tag-marked-remove { background: #ffebee !important; color: #c62828 !important; text-decoration: line-through; }
.mini-select {
width: 18px; height: 18px; border: 1.5px solid #d4cfc7; border-radius: 4px;
background: #fff; color: transparent; font-size: 11px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
padding: 0; margin-right: 4px; line-height: 1;
}
.mini-select.active { background: #4a9d7e; border-color: #4a9d7e; color: #fff; }
.assign-row { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
.assign-select { padding: 4px 8px; border: 1px solid #d4cfc7; border-radius: 6px; font-size: 12px; font-family: inherit; }
.btn-primary { background: linear-gradient(135deg, #7ec6a4, #4a9d7e); color: #fff; border: none; border-radius: 8px; cursor: pointer; font-family: inherit; }
.tag-list-bar {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; padding: 8px 0;
}
.tag-delete { margin-left: 4px; cursor: pointer; font-size: 12px; color: #ccc; }
.tag-delete:hover { color: #c0392b; }
.tag-add-row { display: inline-flex; gap: 4px; align-items: center; }
.tag-add-input { width: 80px; padding: 4px 8px; border: 1px solid #e5e4e7; border-radius: 8px; font-size: 12px; outline: none; font-family: inherit; }
.tag-add-input:focus { border-color: #7ec6a4; }
.tag-add-btn { border: none; background: #e8f5e9; color: #4a9d7e; border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer; font-family: inherit; }
.tag-add-btn:disabled { opacity: 0.4; }
.export-btn {
margin-left: auto;
border: none; background: none; cursor: pointer; font-size: 16px; padding: 4px 6px;
opacity: 0.6;
}
.export-btn:hover { opacity: 1; }
.batch-count { font-size: 12px; color: #4a9d7e; font-weight: 600; white-space: nowrap; }
.batch-select { padding: 5px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 12px; font-family: inherit; background: #fff; }
.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;
}
.oil-search-wrap {
flex: 1;
position: relative;
}
.oil-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
max-height: 180px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.oil-option {
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
}
.oil-option:hover {
background: #e8f5e9;
}
.oil-empty {
color: #999;
cursor: default;
}
.oil-empty:hover {
background: transparent;
}
.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;
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 7px 14px;
font-size: 13px;
}
.btn-danger-outline {
background: #fff;
color: #c0392b;
border: 1.5px solid #e8b4b0;
border-radius: 10px;
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.btn-danger-outline:hover {
background: #fdf0ee;
}
.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>