Files
oil-formula-calculator/frontend/src/views/RecipeManager.vue
Hera Zhao 0985719212
Some checks failed
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 24s
Test / e2e-test (push) Failing after 55s
feat: 审核同名配方智能检测
- 完全相同:提示"已有一模一样的",不采纳
- 内容不同:显示两个配方成分对比,可选择直接采纳或改名后采纳
- 存为我的只检查个人配方同名

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

2074 lines
68 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">
<!-- 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 clickable" @click="openRecipeDetail(r)">{{ 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>
<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>
</div>
</div>
<!-- Toolbar -->
<div class="toolbar-row">
<template v-if="auth.canEdit">
<div class="search-box search-compact">
<input class="search-input" v-model="manageSearch" placeholder="搜索..." />
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''"></button>
</div>
</template>
<button class="btn-outline btn-sm" @click="showAddOverlay = true">新增</button>
<button
class="btn-sm"
:class="isAllSelected ? 'btn-select-active' : 'btn-outline'"
@click="toggleSelectAll"
>全选</button>
<span v-if="totalSelected > 0" class="select-count">{{ totalSelected }}</span>
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
标签 {{ showTagFilter ? '' : '' }}
</button>
<!-- Batch -->
<template v-if="totalSelected > 0">
<button class="tag-toggle-btn" @click="showBatchMenu = !showBatchMenu">
批量操作 {{ showBatchMenu ? '' : '' }}
</button>
<button class="btn-sm btn-outline" @click="clearSelection">取消</button>
</template>
<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 recipeStore.allTags"
: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 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">
<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 class="row-tags">
<span v-for="t in (d.tags || [])" :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 v-if="getDiaryShareStatus(d) !== 'shared'" class="btn-icon" @click="shareDiaryToPublic(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" 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-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="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 (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"
rows="4"
></textarea>
<button class="btn-primary btn-sm" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
🪄 智能识别
</button>
</div>
<!-- Parsed results preview -->
<div v-if="parsedRecipes.length > 0" class="parsed-results">
<div v-for="(pr, pi) in parsedRecipes" :key="pi" class="parsed-recipe-card">
<div class="parsed-header">
<input v-model="pr.name" class="form-input parsed-name" placeholder="配方名称" />
<button class="btn-icon-sm" @click="parsedRecipes.splice(pi, 1)" title="放弃"></button>
</div>
<div class="parsed-ings">
<div v-for="(ing, ii) in pr.ingredients" :key="ii" class="parsed-ing">
<span class="parsed-oil">{{ ing.oil }}</span>
<input v-model.number="ing.drops" type="number" min="0" class="form-input-sm" />
<button class="btn-icon-sm" @click="pr.ingredients.splice(ii, 1)"></button>
</div>
</div>
<div v-if="pr.notFound && pr.notFound.length" class="parsed-warn">
未识别: {{ pr.notFound.join('') }}
</div>
<button class="btn-primary btn-sm" @click="saveParsedRecipe(pi)">💾 保存此条</button>
</div>
<div class="parsed-actions">
<button class="btn-primary btn-sm" @click="saveAllParsed" :disabled="parsedRecipes.length === 0">全部保存 ({{ parsedRecipes.length }})</button>
<button class="btn-outline btn-sm" @click="parsedRecipes = []">取消全部</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 -->
<div 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="previewRecipeData !== null"
@close="previewRecipeIndex = null; previewRecipeData = null"
/>
<!-- 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, onMounted, watch } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } 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 showAddIngRow = ref(false)
const newIngOil = ref('')
const newIngSearch = ref('')
const newIngDrops = ref(1)
const newIngDropdownOpen = ref(false)
const formVolume = ref('30')
const formCustomVolume = ref(null)
const formCustomUnit = ref('drops')
const formDilution = ref(6)
const formCocoRow = ref({ oil: '椰子油', drops: 10, _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 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 tags = batchTagsSelected.value
if (!tags.length) { ui.showToast('请选择至少一个标签'); return }
const pubIds = [...selectedIds]
const diaryIds = [...selectedDiaryIds]
for (const tagName of tags) {
for (const id of pubIds) {
const recipe = recipeStore.recipes.find(r => r._id === id)
if (recipe && !recipe.tags.includes(tagName)) {
recipe.tags.push(tagName)
await recipeStore.saveRecipe(recipe)
}
}
for (const id of diaryIds) {
const d = diaryStore.userDiary.find(r => r.id === id)
if (d) {
const dtags = [...(d.tags || [])]
if (!dtags.includes(tagName)) {
dtags.push(tagName)
await diaryStore.updateDiary(id, { ...d, tags: dtags })
}
}
}
}
showBatchTagPicker.value = false
ui.showToast(`已为 ${pubIds.length + diaryIds.length} 个配方添加 ${tags.length} 个标签`)
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 = []
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(ingredients) {
const ings = ingredients || []
const coco = ings.find(i => i.oil === '椰子油')
const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
const cocoDrops = coco ? (coco.drops || 0) : 0
const totalDrops = eoDrops + cocoDrops
if (eoDrops > 0 && cocoDrops > 0) {
formDilution.value = Math.round(cocoDrops / eoDrops)
}
// Guess volume
const DROPS_PER_ML = 18.6
const ml = totalDrops / DROPS_PER_ML
if (ml <= 1.5) formVolume.value = 'single'
else if (Math.abs(ml - 5) < 1.5) formVolume.value = '5'
else if (Math.abs(ml - 10) < 3) formVolume.value = '10'
else if (Math.abs(ml - 30) < 8) formVolume.value = '30'
else { formVolume.value = 'custom'; formCustomVolume.value = Math.round(totalDrops); formCustomUnit.value = 'drops' }
}
function editRecipe(recipe) {
editingRecipe.value = recipe
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 === '椰子油')
formCocoRow.value = coco ? { ...coco, _search: '椰子油', _open: false } : { oil: '椰子油', drops: 10, _search: '椰子油', _open: false }
formNote.value = recipe.note || ''
formTags.value = [...(recipe.tags || [])]
calcDilutionFromIngs(recipe.ingredients)
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 = { oil: '椰子油', drops: 10, _search: '椰子油', _open: false }
formNote.value = ''
formTags.value = []
smartPasteText.value = ''
parsedRecipes.value = []
showAddIngRow.value = false
newIngOil.value = ''
newIngSearch.value = ''
newIngDrops.value = 1
formVolume.value = '30'
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: show preview cards
parsedRecipes.value = results
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)
}
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
const payload = {
_id: editingRecipe.value._id,
_version: editingRecipe.value._version,
name: formName.value.trim(),
ingredients: cleanIngs.map(i => ({ oil_name: i.oil, drops: i.drops })),
note: formNote.value,
tags: formTags.value,
}
try {
await recipeStore.saveRecipe(payload)
ui.showToast('配方已更新')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
return
}
// New recipe: always save to diary (personal)
try {
await diaryStore.createDiary(diaryPayload)
ui.showToast('已添加到我的配方')
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() {
let saved = 0
for (let i = parsedRecipes.value.length - 1; i >= 0; i--) {
const r = parsedRecipes.value[i]
if (!r.name.trim() || r.ingredients.length === 0) continue
try {
await diaryStore.createDiary({
name: r.name.trim(),
ingredients: r.ingredients.map(i => ({ oil: i.oil, drops: i.drops })),
note: '',
tags: [],
})
saved++
} catch {}
}
parsedRecipes.value = []
ui.showToast(`已保存 ${saved} 条配方到我的配方`)
closeOverlay()
}
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 showBatchTagPicker = ref(false)
const batchTagsSelected = ref([])
const batchNewTag = ref('')
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 {}
}
// 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 === '椰子油')
formCocoRow.value = coco ? { ...coco, _search: '椰子油', _open: false } : { oil: '椰子油', drops: 10, _search: '椰子油', _open: false }
formNote.value = diary.note || ''
formTags.value = [...(diary.tags || [])]
calcDilutionFromIngs(diary.ingredients)
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 getDiaryShareStatus(d) {
if (sharedCount.value.adoptedNames.includes(d.name)) return 'shared'
if (sharedCount.value.pendingNames.includes(d.name)) return 'pending'
return null
}
async function shareDiaryToPublic(diary) {
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) {
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)) {
ui.showToast('公共配方库中已有一模一样的配方「' + recipe.name + '」,无需重复采纳')
return
}
// Different content, show comparison
const msg = `公共配方库中已有同名配方「${recipe.name}」但内容不同:\n\n` +
`已有:${formatIngsCompare(dup.ingredients)}\n` +
`新的:${formatIngsCompare(recipe.ingredients)}\n\n` +
`点击"采纳"直接采纳,或"改名"修改新配方名称后采纳`
const action = await showConfirm(msg, { okText: '采纳', cancelText: '改名' })
if (!action) {
// 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;
}
.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; }
.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-results { margin: 12px 0; }
.parsed-recipe-card {
background: #f8faf8;
border: 1.5px solid #d4e8d4;
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
}
.parsed-header { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.parsed-name { flex: 1; font-weight: 600; }
.parsed-ings { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.parsed-ing {
display: flex; align-items: center; gap: 4px;
background: #fff; border: 1px solid #e5e4e7; border-radius: 8px; padding: 4px 8px; font-size: 13px;
}
.parsed-oil { color: #3e3a44; font-weight: 500; }
.parsed-ing .form-input-sm { width: 50px; padding: 4px 6px; font-size: 12px; }
.parsed-warn { color: #e65100; font-size: 12px; margin-bottom: 6px; }
.parsed-actions { display: flex; gap: 8px; justify-content: center; margin-top: 8px; }
.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; }
.toolbar-row {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-bottom: 10px;
}
.search-compact {
flex: 0 1 160px; min-width: 100px; padding: 2px 8px;
background: #f8f7f5; border: 1.5px solid #e5e4e7; border-radius: 10px;
display: flex; align-items: center;
}
.search-compact .search-input { border: none; background: transparent; padding: 6px 4px; font-size: 13px; outline: none; width: 100%; font-family: inherit; }
.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;
}
.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>