Files
oil-formula-calculator/frontend/src/views/RecipeManager.vue
Hera Zhao 0dfef3ab16
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Has been cancelled
fix: 已审核标签对viewer完全不可见
- EDITOR_ONLY_TAGS常量从recipes store导出,统一引用
- RecipeCard: viewer不显示已审核标签
- RecipeSearch: viewer搜索不匹配已审核标签
- RecipeManager: 标签筛选栏、配方行标签对viewer隐藏
- 所有标签按字母排序(已有)

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

2260 lines
76 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 + 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="showTagFilter = !showTagFilter">标签</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 class="row-tags">
<span v-for="t in (d.tags || []).filter(t => auth.canEdit || !EDITOR_ONLY_TAGS.includes(t))" :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 class="row-tags">
<span v-for="t in (r.tags || []).filter(t => auth.canEdit || !EDITOR_ONLY_TAGS.includes(t))" :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="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"
/>
</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 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({ 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 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 === '椰子油')
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 = ''
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,
}
// Dedup check for new recipes (not editing)
if (!editingRecipe.value) {
const name = formName.value.trim()
// Check public library
const pubDup = recipeStore.recipes.find(r => r.name === name)
// Check personal diary
const diaryDup = diaryStore.userDiary.find(d => d.name === name)
const dup = pubDup || diaryDup
if (dup) {
const dupIngs = (dup.ingredients || []).filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
const myIngs = cleanIngs.filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
const identical = dupIngs.length === myIngs.length && dupIngs.every((ing, i) => ing.oil === myIngs[i].oil && ing.drops === myIngs[i].drops)
const where = pubDup ? '公共配方库' : '我的配方'
if (identical) {
ui.showToast(`${where}中已有一模一样的配方「${name}`)
return
}
// Show difference
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}中已有同名配方「${name}」,内容不同:\n\n已有${existIngs}\n新的${newIngs}\n\n是否改名后保存`,
{ okText: '改名', cancelText: '取消' }
)
if (!ok) return
const newName = await showPrompt('请输入新名称:', name)
if (!newName || !newName.trim()) return
formName.value = newName.trim()
diaryPayload.name = newName.trim()
}
}
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) {
try {
const pubPayload = {
name: formName.value.trim(),
ingredients: cleanIngs.map(i => ({ oil_name: i.oil, drops: i.drops })),
note: formNote.value,
tags: formTags.value,
}
await recipeStore.saveRecipe(pubPayload)
ui.showToast('已添加到公共配方库')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
return
}
}
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 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 === '椰子油')
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 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) {
// Check pending (owned by user in public library, not yet adopted)
if (sharedCount.value.pendingNames.includes(d.name)) return 'pending'
// Check if public library has same recipe with same content
if (diaryMatchesPublic(d)) return 'shared'
// Check adopted names from audit log
if (sharedCount.value.adoptedNames.includes(d.name) && diaryMatchesPublic(d)) return 'shared'
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) {
// Check for duplicates in public library
const dup = recipeStore.recipes.find(r => r.name === diary.name)
if (dup) {
const dIngs = (diary.ingredients || []).filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
const pIngs = (dup.ingredients || []).filter(i => i.oil).sort((a, b) => a.oil.localeCompare(b.oil))
const identical = dIngs.length === pIngs.length && dIngs.every((ing, i) => ing.oil === pIngs[i].oil && ing.drops === pIngs[i].drops)
if (identical) {
ui.showToast('公共配方库中已有一模一样的配方「' + diary.name + '」')
return
}
// Same name, different content — show details
const existIngs = pIngs.map(i => `${i.oil}${i.drops}`).join('、')
const newIngs = dIngs.map(i => `${i.oil}${i.drops}`).join('、')
const action = await showConfirm(
`公共配方库中已有同名配方「${diary.name}」,内容不同:\n\n已有${existIngs}\n新的${newIngs}\n\n是否改名后共享`,
{ okText: '改名', cancelText: '取消' }
)
if (!action) return
const newName = await showPrompt('请输入新名称:', diary.name)
if (!newName || !newName.trim()) return
diary = { ...diary, name: newName.trim() }
}
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;
}
.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; }
.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>