All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 13s
Test / e2e-test (push) Successful in 58s
- 我的配方每行加📤共享按钮 - 显示共享状态:已共享(绿)/等待审核(橙) - 已共享的隐藏共享按钮 - 非管理员显示"已贡献 X 条"统计 - 配方查询页去掉共享按钮(移到管理配方) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1363 lines
36 KiB
Vue
1363 lines
36 KiB
Vue
<template>
|
||
<div class="recipe-manager">
|
||
<!-- Review Bar (admin only) -->
|
||
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending" >
|
||
📝 待审核配方: {{ pendingCount }} 条
|
||
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
|
||
</div>
|
||
<div v-if="showPending && pendingRecipes.length" class="pending-list">
|
||
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
|
||
<span class="pending-name">{{ r.name }}</span>
|
||
<span class="pending-owner">{{ r._owner_name }}</span>
|
||
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
|
||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search & Actions Bar (editor+) -->
|
||
<template v-if="auth.canEdit">
|
||
<div class="manage-toolbar">
|
||
<div class="search-box">
|
||
<input
|
||
class="search-input"
|
||
v-model="manageSearch"
|
||
placeholder="搜索配方..."
|
||
/>
|
||
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''">✕</button>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<button class="btn-outline btn-sm" @click="showAddOverlay = true">+ 添加配方</button>
|
||
<button class="btn-outline btn-sm" @click="exportExcel">📥 导出Excel</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tag Filter Bar -->
|
||
<div class="tag-filter-bar">
|
||
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
|
||
🏷️ 标签筛选 {{ showTagFilter ? '▾' : '▸' }}
|
||
</button>
|
||
<div v-if="showTagFilter" class="tag-list">
|
||
<span
|
||
v-for="tag in recipeStore.allTags"
|
||
:key="tag"
|
||
class="tag-chip"
|
||
:class="{ active: selectedTags.includes(tag) }"
|
||
@click="toggleTag(tag)"
|
||
>{{ tag }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Batch Operations -->
|
||
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
|
||
<span>已选 {{ selectedIds.size + selectedDiaryIds.size }} 项</span>
|
||
<button class="btn-sm btn-outline" @click="executeBatchAction('tag')">🏷 打标签</button>
|
||
<button class="btn-sm btn-outline" @click="executeBatchAction('share_public')" v-if="selectedDiaryIds.size > 0">📤 分享到公共库</button>
|
||
<button class="btn-sm btn-outline" @click="executeBatchAction('export')">📷 导出卡片</button>
|
||
<button class="btn-sm btn-danger-outline" @click="executeBatchAction('delete')">🗑 删除</button>
|
||
<button class="btn-sm btn-outline" @click="clearSelection">取消</button>
|
||
</div>
|
||
|
||
<!-- My Recipes Section (from diary) -->
|
||
<div class="recipe-section">
|
||
<h3 class="section-title">
|
||
<span>📖 我的配方 ({{ myRecipes.length }})</span>
|
||
<span v-if="!auth.isAdmin && sharedCount > 0" class="contrib-tag">已贡献 {{ sharedCount }} 条</span>
|
||
<button class="btn-sm btn-outline" @click="toggleSelectAllDiary">全选/取消</button>
|
||
</h3>
|
||
<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-owner">{{ auth.user?.display_name || auth.user?.username }}</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="editDiaryRecipe(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>
|
||
</div>
|
||
|
||
<!-- Public Recipes Section (editor+) -->
|
||
<div v-if="auth.canEdit" class="recipe-section">
|
||
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
||
<div class="recipe-list">
|
||
<div
|
||
v-for="r in publicFilteredRecipes"
|
||
:key="r._id"
|
||
class="recipe-row"
|
||
:class="{ selected: selectedIds.has(r._id) }"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
:checked="selectedIds.has(r._id)"
|
||
@change="toggleSelect(r._id)"
|
||
class="row-check"
|
||
/>
|
||
<div class="row-info" @click="editRecipe(r)">
|
||
<span class="row-name">{{ r.name }}</span>
|
||
<span class="row-owner">{{ r._owner_name }}</span>
|
||
<span class="row-tags">
|
||
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span>
|
||
</span>
|
||
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
|
||
</div>
|
||
<div class="row-actions" v-if="auth.canEditRecipe(r)">
|
||
<button class="btn-icon" @click="editRecipe(r)" title="编辑">✏️</button>
|
||
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑️</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="publicFilteredRecipes.length === 0" class="empty-hint">暂无公共配方</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add/Edit Recipe Overlay -->
|
||
<div v-if="showAddOverlay" class="overlay" @click.self="closeOverlay">
|
||
<div class="overlay-panel">
|
||
<div class="overlay-header">
|
||
<h3>{{ editingRecipe ? '编辑配方' : '添加配方' }}</h3>
|
||
<button class="btn-close" @click="closeOverlay">✕</button>
|
||
</div>
|
||
|
||
<!-- Smart Paste Section (only for new recipes) -->
|
||
<template v-if="!editingRecipe">
|
||
<div class="paste-section">
|
||
<textarea
|
||
v-model="smartPasteText"
|
||
class="paste-input"
|
||
placeholder="直接粘贴配方文本,支持多条配方同时识别 例如: 舒缓放松,薰衣草3,茶树2 提神醒脑,柠檬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 (same as RecipeDetailOverlay editor) -->
|
||
<div class="form-group">
|
||
<label>配方名称</label>
|
||
<input v-model="formName" class="form-input" placeholder="配方名称" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>成分</label>
|
||
<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 formIngredients" :key="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" step="0.5" class="form-input-sm" /></td>
|
||
<td class="cell-muted">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }}</td>
|
||
<td class="cell-cost">{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '-' }}</td>
|
||
<td><button class="btn-icon-sm" @click="formIngredients.splice(i, 1)">✕</button></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<button class="btn-outline btn-sm" @click="formIngredients.push({ oil: '', drops: 1, _search: '', _open: false })">+ 添加精油</button>
|
||
<div class="form-total">总成本: {{ formTotalCost }}</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>备注</label>
|
||
<textarea v-model="formNote" class="form-input" rows="2" placeholder="配方备注"></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>标签</label>
|
||
<div class="tag-list">
|
||
<span
|
||
v-for="tag in recipeStore.allTags"
|
||
:key="tag"
|
||
class="tag-chip"
|
||
:class="{ active: formTags.includes(tag) }"
|
||
@click="toggleFormTag(tag)"
|
||
>{{ tag }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay-footer">
|
||
<button class="btn-outline" @click="closeOverlay">取消</button>
|
||
<button class="btn-primary" @click="saveCurrentRecipe">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tag Picker Overlay -->
|
||
<TagPicker
|
||
v-if="showTagPicker"
|
||
:name="tagPickerName"
|
||
:currentTags="tagPickerTags"
|
||
:allTags="recipeStore.allTags"
|
||
@save="onTagPickerSave"
|
||
@close="showTagPicker = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, reactive, 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'
|
||
|
||
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)
|
||
|
||
// 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 formTotalCost = computed(() => {
|
||
const cost = formIngredients.value
|
||
.filter(i => i.oil && i.drops > 0)
|
||
.reduce((sum, i) => sum + oils.pricePerDrop(i.oil) * i.drops, 0)
|
||
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))
|
||
|
||
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()
|
||
}
|
||
|
||
function toggleSelectAllDiary() {
|
||
if (selectedDiaryIds.size === myFilteredRecipes.value.length) {
|
||
selectedDiaryIds.clear()
|
||
} else {
|
||
myFilteredRecipes.value.forEach(d => selectedDiaryIds.add(d.id))
|
||
}
|
||
}
|
||
|
||
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') {
|
||
const tagName = await showPrompt('输入要添加的标签:')
|
||
if (!tagName) return
|
||
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 tags = [...(d.tags || [])]
|
||
if (!tags.includes(tagName)) {
|
||
tags.push(tagName)
|
||
await diaryStore.updateDiary(id, { ...d, tags })
|
||
}
|
||
}
|
||
}
|
||
ui.showToast(`已为 ${totalCount} 个配方添加标签`)
|
||
} 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 editRecipe(recipe) {
|
||
editingRecipe.value = recipe
|
||
formName.value = recipe.name
|
||
formIngredients.value = recipe.ingredients.map(i => ({ ...i, _search: i.oil, _open: false }))
|
||
formNote.value = recipe.note || ''
|
||
formTags.value = [...(recipe.tags || [])]
|
||
showAddOverlay.value = true
|
||
}
|
||
|
||
function closeOverlay() {
|
||
showAddOverlay.value = false
|
||
editingRecipe.value = null
|
||
resetForm()
|
||
}
|
||
|
||
function resetForm() {
|
||
formName.value = ''
|
||
formIngredients.value = [{ oil: '', drops: 1, _search: '', _open: false }]
|
||
formNote.value = ''
|
||
formTags.value = []
|
||
smartPasteText.value = ''
|
||
}
|
||
|
||
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()
|
||
return oils.oilNames.filter(name =>
|
||
name.toLowerCase().includes(q) || matchesPinyinInitials(name, q)
|
||
)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
function toggleFormTag(tag) {
|
||
const idx = formTags.value.indexOf(tag)
|
||
if (idx >= 0) formTags.value.splice(idx, 1)
|
||
else formTags.value.push(tag)
|
||
}
|
||
|
||
async function saveCurrentRecipe() {
|
||
const validIngs = formIngredients.value.filter(i => i.oil && i.drops > 0)
|
||
if (!formName.value.trim()) {
|
||
ui.showToast('请输入配方名称')
|
||
return
|
||
}
|
||
if (validIngs.length === 0) {
|
||
ui.showToast('请至少添加一个成分')
|
||
return
|
||
}
|
||
|
||
const cleanIngs = validIngs.map(i => ({ oil: i.oil, drops: i.drops }))
|
||
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(0)
|
||
|
||
// Load diary on mount
|
||
onMounted(async () => {
|
||
if (auth.isLoggedIn) {
|
||
await diaryStore.loadDiary()
|
||
try {
|
||
const res = await api('/api/me/contribution')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
sharedCount.value = data.shared_count || 0
|
||
}
|
||
} catch {}
|
||
}
|
||
})
|
||
|
||
function editDiaryRecipe(diary) {
|
||
editingRecipe.value = { _diary_id: diary.id, name: diary.name }
|
||
formName.value = diary.name
|
||
formIngredients.value = (diary.ingredients || []).map(i => ({ ...i, _search: i.oil, _open: false }))
|
||
formNote.value = diary.note || ''
|
||
formTags.value = [...(diary.tags || [])]
|
||
showAddOverlay.value = true
|
||
}
|
||
|
||
function getDiaryShareStatus(d) {
|
||
// Check if a public recipe with same name exists, owned by current user or adopted by admin
|
||
const pub = recipeStore.recipes.find(r => r.name === d.name)
|
||
if (!pub) return null
|
||
if (pub._owner_id === auth.user?.id) return 'pending'
|
||
return 'shared'
|
||
}
|
||
|
||
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()
|
||
}
|
||
} 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('删除失败')
|
||
}
|
||
}
|
||
|
||
async function approveRecipe(recipe) {
|
||
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() {
|
||
try {
|
||
const res = await api('/api/recipes/export-excel')
|
||
if (!res.ok) throw new Error('Export failed')
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = '配方导出.xlsx'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
ui.showToast('导出成功')
|
||
} catch {
|
||
ui.showToast('导出失败')
|
||
}
|
||
}
|
||
|
||
function onTagPickerSave(tags) {
|
||
formTags.value = tags
|
||
showTagPicker.value = false
|
||
}
|
||
|
||
watch(() => recipeStore.recipes, () => {
|
||
if (auth.isAdmin) {
|
||
const pending = recipeStore.recipes.filter(r => r._owner_id && r._owner_id !== auth.user.id)
|
||
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-owner {
|
||
color: #999;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.manage-toolbar {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
background: #f8f7f5;
|
||
border-radius: 10px;
|
||
padding: 2px 8px;
|
||
border: 1.5px solid #e5e4e7;
|
||
flex: 1;
|
||
min-width: 160px;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
border: none;
|
||
background: transparent;
|
||
padding: 8px 6px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.search-clear-btn {
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
color: #999;
|
||
padding: 4px;
|
||
}
|
||
|
||
.tag-filter-bar {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tag-toggle-btn {
|
||
background: #f8f7f5;
|
||
border: 1.5px solid #e5e4e7;
|
||
border-radius: 10px;
|
||
padding: 8px 16px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.tag-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.tag-chip {
|
||
padding: 4px 12px;
|
||
border-radius: 16px;
|
||
background: #f0eeeb;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
color: #6b6375;
|
||
border: 1.5px solid transparent;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.tag-chip.active {
|
||
background: #e8f5e9;
|
||
border-color: #7ec6a4;
|
||
color: #2e7d5a;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.batch-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
background: #e8f5e9;
|
||
border-radius: 10px;
|
||
margin-bottom: 12px;
|
||
font-size: 13px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.batch-select {
|
||
padding: 6px 10px;
|
||
border-radius: 8px;
|
||
border: 1.5px solid #d4cfc7;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
background: #fff;
|
||
}
|
||
|
||
.recipe-section {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #3e3a44;
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
.recipe-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.recipe-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
background: #fff;
|
||
border: 1.5px solid #e5e4e7;
|
||
border-radius: 10px;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.recipe-row:hover {
|
||
border-color: #d4cfc7;
|
||
background: #fafaf8;
|
||
}
|
||
|
||
.recipe-row.selected {
|
||
border-color: #7ec6a4;
|
||
background: #f0faf5;
|
||
}
|
||
|
||
.row-check {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: pointer;
|
||
accent-color: #4a9d7e;
|
||
}
|
||
|
||
.row-info {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
min-width: 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.row-name {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.row-owner {
|
||
font-size: 11px;
|
||
color: #b0aab5;
|
||
}
|
||
|
||
.row-tags {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.mini-tag {
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
background: #f0eeeb;
|
||
font-size: 11px;
|
||
color: #6b6375;
|
||
}
|
||
|
||
.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; }
|
||
|
||
.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-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; }
|
||
.cell-muted { color: #b0aab5; font-size: 12px; }
|
||
.cell-cost { color: #4a9d7e; font-weight: 500; font-size: 13px; }
|
||
.form-total { text-align: right; font-size: 14px; font-weight: 600; color: #4a9d7e; margin-top: 8px; }
|
||
|
||
.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>
|