Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E
- Replace single-file 8441-line HTML with Vue 3 SPA - Pinia stores: auth, oils, recipes, diary, ui - Composables: useApi, useDialog, useSmartPaste, useOilTranslation - 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc. - 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc. - 14 Cypress E2E test specs (113 tests), all passing - Multi-stage Dockerfile (Node build + Python runtime) - Demo video generation scripts (TTS + subtitles + screen recording) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
967
frontend/src/views/RecipeManager.vue
Normal file
967
frontend/src/views/RecipeManager.vue
Normal file
@@ -0,0 +1,967 @@
|
||||
<template>
|
||||
<div class="recipe-manager">
|
||||
<!-- Review Bar (admin only) -->
|
||||
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending">
|
||||
📝 待审核配方: {{ pendingCount }} 条
|
||||
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-if="showPending && pendingRecipes.length" class="pending-list">
|
||||
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
|
||||
<span class="pending-name">{{ r.name }}</span>
|
||||
<span class="pending-owner">{{ r._owner_name }}</span>
|
||||
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
|
||||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Actions Bar -->
|
||||
<div class="manage-toolbar">
|
||||
<div class="search-box">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="manageSearch"
|
||||
placeholder="搜索配方..."
|
||||
/>
|
||||
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''">✕</button>
|
||||
</div>
|
||||
<button class="btn-primary" @click="showAddOverlay = true">+ 添加配方</button>
|
||||
<button class="btn-outline" @click="exportExcel">📊 导出Excel</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag Filter Bar -->
|
||||
<div class="tag-filter-bar">
|
||||
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
|
||||
🏷️ 标签筛选 {{ showTagFilter ? '▾' : '▸' }}
|
||||
</button>
|
||||
<div v-if="showTagFilter" class="tag-list">
|
||||
<span
|
||||
v-for="tag in recipeStore.allTags"
|
||||
:key="tag"
|
||||
class="tag-chip"
|
||||
:class="{ active: selectedTags.includes(tag) }"
|
||||
@click="toggleTag(tag)"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Operations -->
|
||||
<div v-if="selectedIds.size > 0" class="batch-bar">
|
||||
<span>已选 {{ selectedIds.size }} 项</span>
|
||||
<select v-model="batchAction" class="batch-select">
|
||||
<option value="">批量操作...</option>
|
||||
<option value="tag">添加标签</option>
|
||||
<option value="share">分享</option>
|
||||
<option value="export">导出卡片</option>
|
||||
<option value="delete">删除</option>
|
||||
</select>
|
||||
<button class="btn-sm btn-primary" @click="executeBatch" :disabled="!batchAction">执行</button>
|
||||
<button class="btn-sm btn-outline" @click="clearSelection">取消选择</button>
|
||||
</div>
|
||||
|
||||
<!-- My Recipes Section -->
|
||||
<div class="recipe-section">
|
||||
<h3 class="section-title">📖 我的配方 ({{ myRecipes.length }})</h3>
|
||||
<div class="recipe-list">
|
||||
<div
|
||||
v-for="r in myFilteredRecipes"
|
||||
:key="r._id"
|
||||
class="recipe-row"
|
||||
:class="{ selected: selectedIds.has(r._id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(r._id)"
|
||||
@change="toggleSelect(r._id)"
|
||||
class="row-check"
|
||||
/>
|
||||
<div class="row-info" @click="editRecipe(r)">
|
||||
<span class="row-name">{{ r.name }}</span>
|
||||
<span class="row-tags">
|
||||
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span>
|
||||
</span>
|
||||
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn-icon" @click="editRecipe(r)" title="编辑">✏️</button>
|
||||
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Recipes Section -->
|
||||
<div class="recipe-section">
|
||||
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
||||
<div class="recipe-list">
|
||||
<div
|
||||
v-for="r in publicFilteredRecipes"
|
||||
:key="r._id"
|
||||
class="recipe-row"
|
||||
:class="{ selected: selectedIds.has(r._id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(r._id)"
|
||||
@change="toggleSelect(r._id)"
|
||||
class="row-check"
|
||||
/>
|
||||
<div class="row-info" @click="editRecipe(r)">
|
||||
<span class="row-name">{{ r.name }}</span>
|
||||
<span class="row-owner">{{ r._owner_name }}</span>
|
||||
<span class="row-tags">
|
||||
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span>
|
||||
</span>
|
||||
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
|
||||
</div>
|
||||
<div class="row-actions" v-if="auth.canEditRecipe(r)">
|
||||
<button class="btn-icon" @click="editRecipe(r)" title="编辑">✏️</button>
|
||||
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publicFilteredRecipes.length === 0" class="empty-hint">暂无公共配方</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Recipe Overlay -->
|
||||
<div v-if="showAddOverlay" class="overlay" @click.self="closeOverlay">
|
||||
<div class="overlay-panel">
|
||||
<div class="overlay-header">
|
||||
<h3>{{ editingRecipe ? '编辑配方' : '添加配方' }}</h3>
|
||||
<button class="btn-close" @click="closeOverlay">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Smart Paste Section -->
|
||||
<div class="paste-section">
|
||||
<textarea
|
||||
v-model="smartPasteText"
|
||||
class="paste-input"
|
||||
placeholder="粘贴配方文本,支持智能识别... 例如: 薰衣草3滴 茶树2滴"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<button class="btn-primary" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
|
||||
智能识别
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider-text">或手动输入</div>
|
||||
|
||||
<!-- Manual Form -->
|
||||
<div class="form-group">
|
||||
<label>配方名称</label>
|
||||
<input v-model="formName" class="form-input" placeholder="配方名称" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>成分</label>
|
||||
<div v-for="(ing, i) in formIngredients" :key="i" class="ing-row">
|
||||
<select v-model="ing.oil" class="form-select">
|
||||
<option value="">选择精油</option>
|
||||
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
<input v-model.number="ing.drops" type="number" min="0" class="form-input-sm" placeholder="滴数" />
|
||||
<button class="btn-icon-sm" @click="formIngredients.splice(i, 1)">✕</button>
|
||||
</div>
|
||||
<button class="btn-outline btn-sm" @click="formIngredients.push({ oil: '', drops: 1 })">+ 添加成分</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>备注</label>
|
||||
<textarea v-model="formNote" class="form-input" rows="2" placeholder="配方备注"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>标签</label>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="tag in recipeStore.allTags"
|
||||
:key="tag"
|
||||
class="tag-chip"
|
||||
:class="{ active: formTags.includes(tag) }"
|
||||
@click="toggleFormTag(tag)"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay-footer">
|
||||
<button class="btn-outline" @click="closeOverlay">取消</button>
|
||||
<button class="btn-primary" @click="saveCurrentRecipe">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Picker Overlay -->
|
||||
<TagPicker
|
||||
v-if="showTagPicker"
|
||||
:name="tagPickerName"
|
||||
:currentTags="tagPickerTags"
|
||||
:allTags="recipeStore.allTags"
|
||||
@save="onTagPickerSave"
|
||||
@close="showTagPicker = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { api } from '../composables/useApi'
|
||||
import { showConfirm, showPrompt } from '../composables/useDialog'
|
||||
import { parseSingleBlock } from '../composables/useSmartPaste'
|
||||
import RecipeCard from '../components/RecipeCard.vue'
|
||||
import TagPicker from '../components/TagPicker.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
const recipeStore = useRecipesStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const manageSearch = ref('')
|
||||
const selectedTags = ref([])
|
||||
const showTagFilter = ref(false)
|
||||
const selectedIds = reactive(new Set())
|
||||
const batchAction = ref('')
|
||||
const showAddOverlay = ref(false)
|
||||
const editingRecipe = ref(null)
|
||||
const showPending = ref(false)
|
||||
const pendingRecipes = ref([])
|
||||
const pendingCount = ref(0)
|
||||
|
||||
// Form state
|
||||
const formName = ref('')
|
||||
const formIngredients = ref([{ oil: '', drops: 1 }])
|
||||
const formNote = ref('')
|
||||
const formTags = ref([])
|
||||
const smartPasteText = ref('')
|
||||
|
||||
// Tag picker state
|
||||
const showTagPicker = ref(false)
|
||||
const tagPickerName = ref('')
|
||||
const tagPickerTags = ref([])
|
||||
|
||||
// Computed lists
|
||||
const myRecipes = computed(() =>
|
||||
recipeStore.recipes.filter(r => r._owner_id === auth.user.id)
|
||||
)
|
||||
|
||||
const publicRecipes = computed(() =>
|
||||
recipeStore.recipes.filter(r => r._owner_id !== auth.user.id)
|
||||
)
|
||||
|
||||
function filterBySearchAndTags(list) {
|
||||
let result = list
|
||||
const q = manageSearch.value.trim().toLowerCase()
|
||||
if (q) {
|
||||
result = result.filter(r =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.ingredients.some(ing => ing.oil.toLowerCase().includes(q)) ||
|
||||
(r.tags && r.tags.some(t => t.toLowerCase().includes(q)))
|
||||
)
|
||||
}
|
||||
if (selectedTags.value.length > 0) {
|
||||
result = result.filter(r =>
|
||||
r.tags && selectedTags.value.every(t => r.tags.includes(t))
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const myFilteredRecipes = computed(() => filterBySearchAndTags(myRecipes.value))
|
||||
const publicFilteredRecipes = computed(() => filterBySearchAndTags(publicRecipes.value))
|
||||
|
||||
function toggleTag(tag) {
|
||||
const idx = selectedTags.value.indexOf(tag)
|
||||
if (idx >= 0) selectedTags.value.splice(idx, 1)
|
||||
else selectedTags.value.push(tag)
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
if (selectedIds.has(id)) selectedIds.delete(id)
|
||||
else selectedIds.add(id)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.clear()
|
||||
batchAction.value = ''
|
||||
}
|
||||
|
||||
async function executeBatch() {
|
||||
const ids = [...selectedIds]
|
||||
if (!ids.length || !batchAction.value) return
|
||||
|
||||
if (batchAction.value === 'delete') {
|
||||
const ok = await showConfirm(`确定删除 ${ids.length} 个配方?`)
|
||||
if (!ok) return
|
||||
for (const id of ids) {
|
||||
await recipeStore.deleteRecipe(id)
|
||||
}
|
||||
ui.showToast(`已删除 ${ids.length} 个配方`)
|
||||
} else if (batchAction.value === 'tag') {
|
||||
const tagName = await showPrompt('输入要添加的标签:')
|
||||
if (!tagName) return
|
||||
for (const id of ids) {
|
||||
const recipe = recipeStore.recipes.find(r => r._id === id)
|
||||
if (recipe && !recipe.tags.includes(tagName)) {
|
||||
recipe.tags.push(tagName)
|
||||
await recipeStore.saveRecipe(recipe)
|
||||
}
|
||||
}
|
||||
ui.showToast(`已为 ${ids.length} 个配方添加标签`)
|
||||
} else if (batchAction.value === 'share') {
|
||||
const text = ids.map(id => {
|
||||
const r = recipeStore.recipes.find(rec => rec._id === id)
|
||||
if (!r) return ''
|
||||
const ings = r.ingredients.map(ing => `${ing.oil} ${ing.drops}滴`).join(',')
|
||||
return `${r.name}:${ings}`
|
||||
}).filter(Boolean).join('\n\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ui.showToast('已复制到剪贴板')
|
||||
} catch {
|
||||
ui.showToast('复制失败')
|
||||
}
|
||||
} else if (batchAction.value === 'export') {
|
||||
ui.showToast('导出卡片功能开发中')
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
function editRecipe(recipe) {
|
||||
editingRecipe.value = recipe
|
||||
formName.value = recipe.name
|
||||
formIngredients.value = recipe.ingredients.map(i => ({ ...i }))
|
||||
formNote.value = recipe.note || ''
|
||||
formTags.value = [...(recipe.tags || [])]
|
||||
showAddOverlay.value = true
|
||||
}
|
||||
|
||||
function closeOverlay() {
|
||||
showAddOverlay.value = false
|
||||
editingRecipe.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formName.value = ''
|
||||
formIngredients.value = [{ oil: '', drops: 1 }]
|
||||
formNote.value = ''
|
||||
formTags.value = []
|
||||
smartPasteText.value = ''
|
||||
}
|
||||
|
||||
function handleSmartPaste() {
|
||||
const result = parseSingleBlock(smartPasteText.value, oils.oilNames)
|
||||
formName.value = result.name
|
||||
formIngredients.value = result.ingredients.length > 0
|
||||
? result.ingredients
|
||||
: [{ oil: '', drops: 1 }]
|
||||
if (result.notFound.length > 0) {
|
||||
ui.showToast(`未识别: ${result.notFound.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFormTag(tag) {
|
||||
const idx = formTags.value.indexOf(tag)
|
||||
if (idx >= 0) formTags.value.splice(idx, 1)
|
||||
else formTags.value.push(tag)
|
||||
}
|
||||
|
||||
async function saveCurrentRecipe() {
|
||||
const validIngs = formIngredients.value.filter(i => i.oil && i.drops > 0)
|
||||
if (!formName.value.trim()) {
|
||||
ui.showToast('请输入配方名称')
|
||||
return
|
||||
}
|
||||
if (validIngs.length === 0) {
|
||||
ui.showToast('请至少添加一个成分')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formName.value.trim(),
|
||||
ingredients: validIngs,
|
||||
note: formNote.value,
|
||||
tags: formTags.value,
|
||||
}
|
||||
|
||||
if (editingRecipe.value) {
|
||||
payload._id = editingRecipe.value._id
|
||||
payload._version = editingRecipe.value._version
|
||||
}
|
||||
|
||||
try {
|
||||
await recipeStore.saveRecipe(payload)
|
||||
ui.showToast(editingRecipe.value ? '配方已更新' : '配方已添加')
|
||||
closeOverlay()
|
||||
} catch (e) {
|
||||
ui.showToast('保存失败: ' + (e.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRecipe(recipe) {
|
||||
const ok = await showConfirm(`确定删除配方 "${recipe.name}"?`)
|
||||
if (!ok) return
|
||||
try {
|
||||
await recipeStore.deleteRecipe(recipe._id)
|
||||
ui.showToast('已删除')
|
||||
} catch (e) {
|
||||
ui.showToast('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function approveRecipe(recipe) {
|
||||
try {
|
||||
await api('/api/recipes/' + recipe._id + '/approve', { method: 'POST' })
|
||||
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
|
||||
pendingCount.value--
|
||||
ui.showToast('已通过')
|
||||
await recipeStore.loadRecipes()
|
||||
} catch {
|
||||
ui.showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectRecipe(recipe) {
|
||||
try {
|
||||
await api('/api/recipes/' + recipe._id + '/reject', { method: 'POST' })
|
||||
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
|
||||
pendingCount.value--
|
||||
ui.showToast('已拒绝')
|
||||
} catch {
|
||||
ui.showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
try {
|
||||
const res = await api('/api/recipes/export-excel')
|
||||
if (!res.ok) throw new Error('Export failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '配方导出.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ui.showToast('导出成功')
|
||||
} catch {
|
||||
ui.showToast('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onTagPickerSave(tags) {
|
||||
formTags.value = tags
|
||||
showTagPicker.value = false
|
||||
}
|
||||
|
||||
// Load pending if admin
|
||||
if (auth.isAdmin) {
|
||||
api('/api/recipes/pending').then(async res => {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
pendingRecipes.value = data
|
||||
pendingCount.value = data.length
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recipe-manager {
|
||||
padding: 0 12px 24px;
|
||||
}
|
||||
|
||||
.review-bar {
|
||||
background: linear-gradient(135deg, #fff3e0, #ffe0b2);
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.pending-list {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pending-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #fffde7;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pending-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pending-owner {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.manage-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8f7f5;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
border: 1.5px solid #e5e4e7;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tag-filter-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tag-toggle-btn {
|
||||
background: #f8f7f5;
|
||||
border: 1.5px solid #e5e4e7;
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: #3e3a44;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
background: #f0eeeb;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #6b6375;
|
||||
border: 1.5px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tag-chip.active {
|
||||
background: #e8f5e9;
|
||||
border-color: #7ec6a4;
|
||||
color: #2e7d5a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.batch-select {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.recipe-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.recipe-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recipe-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1.5px solid #e5e4e7;
|
||||
border-radius: 10px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.recipe-row:hover {
|
||||
border-color: #d4cfc7;
|
||||
background: #fafaf8;
|
||||
}
|
||||
|
||||
.recipe-row.selected {
|
||||
border-color: #7ec6a4;
|
||||
background: #f0faf5;
|
||||
}
|
||||
|
||||
.row-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: #4a9d7e;
|
||||
}
|
||||
|
||||
.row-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #3e3a44;
|
||||
}
|
||||
|
||||
.row-owner {
|
||||
font-size: 11px;
|
||||
color: #b0aab5;
|
||||
}
|
||||
|
||||
.row-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mini-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f0eeeb;
|
||||
font-size: 11px;
|
||||
color: #6b6375;
|
||||
}
|
||||
|
||||
.row-cost {
|
||||
font-size: 13px;
|
||||
color: #4a9d7e;
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #f0eeeb;
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.overlay-panel {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.overlay-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.overlay-header h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
color: #3e3a44;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
border: none;
|
||||
background: #f0eeeb;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b6375;
|
||||
}
|
||||
|
||||
.paste-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.paste-input {
|
||||
width: 100%;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.paste-input:focus {
|
||||
border-color: #7ec6a4;
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
text-align: center;
|
||||
color: #b0aab5;
|
||||
font-size: 12px;
|
||||
margin: 12px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider-text::before,
|
||||
.divider-text::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 35%;
|
||||
height: 1px;
|
||||
background: #e5e4e7;
|
||||
}
|
||||
|
||||
.divider-text::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.divider-text::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #7ec6a4;
|
||||
}
|
||||
|
||||
.ing-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-input-sm {
|
||||
width: 70px;
|
||||
padding: 8px 10px;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-icon-sm {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.overlay-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 9px 20px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: #fff;
|
||||
color: #6b6375;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
border-radius: 10px;
|
||||
padding: 9px 20px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f8f7f5;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: #4a9d7e;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: #ef5350;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
color: #b0aab5;
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.manage-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user