feat: 编辑器对齐+审核记录+UI调整
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Successful in 54s
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Successful in 54s
- 新增/编辑配方编辑器与配方卡片编辑界面完全一致(含容量与稀释) - 自定义滴数/稀释比例框缩小,应用按钮放在稀释比例同一行 - 管理员可查看所有审核记录(采纳/拒绝历史) - 标签筛选和全选按钮对所有用户可见 - 我的配方/公共配方库均可折叠 - viewer 看配方卡片无编辑按钮 - diary 配方卡片无编辑按钮 - 退出登录跳转首页并刷新 - 新增 /api/recipe-reviews 端点 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1438,23 +1438,38 @@ def get_unmatched_searches(days: int = 7, user=Depends(require_role("admin", "se
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Recipe review history ──────────────────────────────
|
||||||
|
@app.get("/api/recipe-reviews")
|
||||||
|
def list_recipe_reviews(user=Depends(require_role("admin"))):
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT a.id, a.action, a.target_name, a.detail, a.created_at, "
|
||||||
|
"u.display_name, u.username "
|
||||||
|
"FROM audit_log a LEFT JOIN users u ON a.user_id = u.id "
|
||||||
|
"WHERE a.action IN ('adopt_recipe', 'reject_recipe') "
|
||||||
|
"ORDER BY a.id DESC LIMIT 100"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ── Contribution stats ─────────────────────────────────
|
# ── Contribution stats ─────────────────────────────────
|
||||||
@app.get("/api/me/contribution")
|
@app.get("/api/me/contribution")
|
||||||
def my_contribution(user=Depends(get_current_user)):
|
def my_contribution(user=Depends(get_current_user)):
|
||||||
if not user.get("id"):
|
if not user.get("id"):
|
||||||
return {"shared_count": 0}
|
return {"adopted_count": 0, "shared_count": 0}
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
# Count recipes adopted from this user (tracked in audit_log)
|
# adopted_count: recipes adopted from this user (owner changed to admin)
|
||||||
count = conn.execute(
|
adopted = conn.execute(
|
||||||
"SELECT COUNT(*) FROM audit_log WHERE action = 'adopt_recipe' AND detail LIKE ?",
|
"SELECT COUNT(*) FROM audit_log WHERE action = 'adopt_recipe' AND detail LIKE ?",
|
||||||
(f'%"from_user": "{user.get("display_name") or user.get("username")}"%',)
|
(f'%"from_user": "{user.get("display_name") or user.get("username")}"%',)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
# Also count recipes still owned by user in public library
|
# pending: recipes still owned by user in public library (not yet adopted)
|
||||||
own_count = conn.execute(
|
pending = conn.execute(
|
||||||
"SELECT COUNT(*) FROM recipes WHERE owner_id = ?", (user["id"],)
|
"SELECT COUNT(*) FROM recipes WHERE owner_id = ?", (user["id"],)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"shared_count": count + own_count}
|
return {"adopted_count": adopted, "shared_count": adopted + pending}
|
||||||
|
|
||||||
|
|
||||||
# ── Notifications ──────────────────────────────────────
|
# ── Notifications ──────────────────────────────────────
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ const displayRecipe = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canEditThisRecipe = computed(() => {
|
const canEditThisRecipe = computed(() => {
|
||||||
|
if (props.isDiary) return false
|
||||||
if (authStore.canEdit) return true
|
if (authStore.canEdit) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ function handleLogout() {
|
|||||||
auth.logout()
|
auth.logout()
|
||||||
ui.showToast('已退出登录')
|
ui.showToast('已退出登录')
|
||||||
emit('close')
|
emit('close')
|
||||||
router.push('/')
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadNotifications)
|
onMounted(loadNotifications)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="showPending && pendingRecipes.length" class="pending-list">
|
<div v-if="showPending && pendingRecipes.length" class="pending-list">
|
||||||
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
|
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
|
||||||
<span class="pending-name">{{ r.name }}</span>
|
<span class="pending-name clickable" @click="openRecipeDetail(r)">{{ r.name }}</span>
|
||||||
<span class="pending-owner">{{ r._owner_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-approve" @click="approveRecipe(r)">通过</button>
|
||||||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
||||||
@@ -31,23 +31,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
|
<!-- Tag Filter & Select All (visible to all) -->
|
||||||
|
<div class="tag-filter-bar">
|
||||||
|
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
|
||||||
|
🏷️ 标签筛选 {{ showTagFilter ? '▾' : '▸' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm btn-outline" @click="toggleSelectAllDiary">全选/取消</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 -->
|
<!-- Batch Operations -->
|
||||||
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
|
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
|
||||||
<span>已选 {{ selectedIds.size + selectedDiaryIds.size }} 项</span>
|
<span>已选 {{ selectedIds.size + selectedDiaryIds.size }} 项</span>
|
||||||
@@ -60,11 +62,12 @@
|
|||||||
|
|
||||||
<!-- My Recipes Section (from diary) -->
|
<!-- My Recipes Section (from diary) -->
|
||||||
<div class="recipe-section">
|
<div class="recipe-section">
|
||||||
<h3 class="section-title">
|
<h3 class="section-title clickable" @click="showMyRecipes = !showMyRecipes">
|
||||||
<span>📖 我的配方 ({{ myRecipes.length }})</span>
|
<span>📖 我的配方 ({{ myRecipes.length }})</span>
|
||||||
<span v-if="!auth.isAdmin && sharedCount > 0" class="contrib-tag">已贡献 {{ sharedCount }} 条</span>
|
<span v-if="!auth.isAdmin" class="contrib-tag">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} 条</span>
|
||||||
<button class="btn-sm btn-outline" @click="toggleSelectAllDiary">全选/取消</button>
|
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<template v-if="showMyRecipes">
|
||||||
<div class="recipe-list">
|
<div class="recipe-list">
|
||||||
<div
|
<div
|
||||||
v-for="d in myFilteredRecipes"
|
v-for="d in myFilteredRecipes"
|
||||||
@@ -96,12 +99,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
|
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Public Recipes Section (editor+) -->
|
<!-- Public Recipes Section (editor+) -->
|
||||||
<div v-if="auth.canEdit" class="recipe-section">
|
<div v-if="auth.canEdit" class="recipe-section">
|
||||||
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
<h3 class="section-title clickable" @click="showPublicRecipes = !showPublicRecipes">
|
||||||
<div class="recipe-list">
|
<span>🌿 公共配方库 ({{ publicRecipes.length }})</span>
|
||||||
|
<span class="toggle-icon">{{ showPublicRecipes ? '▾' : '▸' }}</span>
|
||||||
|
</h3>
|
||||||
|
<div v-if="showPublicRecipes" class="recipe-list">
|
||||||
<div
|
<div
|
||||||
v-for="r in publicFilteredRecipes"
|
v-for="r in publicFilteredRecipes"
|
||||||
:key="r._id"
|
:key="r._id"
|
||||||
@@ -181,14 +188,23 @@
|
|||||||
<div class="divider-text">或手动输入</div>
|
<div class="divider-text">或手动输入</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Manual Form (same as RecipeDetailOverlay editor) -->
|
<!-- Manual Form (matches RecipeDetailOverlay editor) -->
|
||||||
<div class="form-group">
|
<div class="editor-header">
|
||||||
<label>配方名称</label>
|
<div style="flex:1;min-width:0">
|
||||||
<input v-model="formName" class="form-input" placeholder="配方名称" />
|
<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="closeOverlay">✕ 取消</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="editor-tip">
|
||||||
<label>成分</label>
|
💡 推荐按照单次用量(椰子油10~20滴)添加纯精油,系统会根据容量和稀释比例自动计算。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ingredients table -->
|
||||||
|
<div class="editor-section">
|
||||||
<table class="editor-table">
|
<table class="editor-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>精油</th><th>滴数</th><th>单价/滴</th><th>小计</th><th></th></tr>
|
<tr><th>精油</th><th>滴数</th><th>单价/滴</th><th>小计</th><th></th></tr>
|
||||||
@@ -216,42 +232,121 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><input v-model.number="ing.drops" type="number" min="0" step="0.5" class="form-input-sm" /></td>
|
<td><input v-model.number="ing.drops" type="number" min="0.5" step="0.5" class="editor-drops" /></td>
|
||||||
<td class="cell-muted">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }}</td>
|
<td class="ing-ppd">{{ 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 class="ing-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>
|
<td><button class="remove-row-btn" @click="formIngredients.splice(i, 1)">✕</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button class="btn-outline btn-sm" @click="formIngredients.push({ oil: '', drops: 1, _search: '', _open: false })">+ 添加精油</button>
|
|
||||||
<div class="form-total">总成本: {{ formTotalCost }}</div>
|
<!-- Add ingredient row -->
|
||||||
|
<div v-if="showAddIngRow" class="add-ingredient-row">
|
||||||
|
<div class="oil-search-wrap" style="flex:1">
|
||||||
|
<input
|
||||||
|
v-model="newIngSearch"
|
||||||
|
class="editor-input"
|
||||||
|
placeholder="搜索精油名称..."
|
||||||
|
@focus="newIngDropdownOpen = true"
|
||||||
|
@input="newIngDropdownOpen = true"
|
||||||
|
@blur="setTimeout(() => newIngDropdownOpen = false, 150)"
|
||||||
|
/>
|
||||||
|
<div v-if="newIngDropdownOpen && filteredOilNames(newIngSearch).length" class="oil-dropdown">
|
||||||
|
<div
|
||||||
|
v-for="name in filteredOilNames(newIngSearch)"
|
||||||
|
:key="name"
|
||||||
|
class="oil-option"
|
||||||
|
@mousedown.prevent="newIngOil = name; newIngSearch = name; newIngDropdownOpen = false"
|
||||||
|
>{{ name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input v-model.number="newIngDrops" type="number" placeholder="滴数" min="0.5" step="0.5" class="editor-drops" />
|
||||||
|
<button class="action-btn action-btn-primary action-btn-sm" @click="confirmAddIng">确认</button>
|
||||||
|
<button class="action-btn action-btn-sm" @click="showAddIngRow = false">取消</button>
|
||||||
|
</div>
|
||||||
|
<button v-else class="add-row-btn" @click="showAddIngRow = true">+ 添加精油</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Volume & Dilution -->
|
||||||
<label>备注</label>
|
<div class="editor-section">
|
||||||
<textarea v-model="formNote" class="form-input" rows="2" placeholder="配方备注"></textarea>
|
<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 === '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="数量" />
|
||||||
|
<select v-model="formCustomUnit" class="select-sm">
|
||||||
|
<option value="drops">滴</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="dilution-row">
|
||||||
|
<span class="dilution-label">稀释 1:</span>
|
||||||
|
<select v-model.number="formDilution" class="select-sm">
|
||||||
|
<option v-for="n in 20" :key="n" :value="n">{{ n }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="action-btn action-btn-primary action-btn-sm" @click="applyVolumeDilution">应用</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" style="margin-top:6px;font-size:11px;color:#999">{{ formDilutionHint }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Notes -->
|
||||||
<label>标签</label>
|
<div class="editor-section">
|
||||||
<div class="tag-list">
|
<label class="editor-label">备注</label>
|
||||||
<span
|
<textarea v-model="formNote" class="editor-textarea" rows="2" placeholder="配方备注..."></textarea>
|
||||||
v-for="tag in recipeStore.allTags"
|
</div>
|
||||||
:key="tag"
|
|
||||||
class="tag-chip"
|
<!-- Tags -->
|
||||||
:class="{ active: formTags.includes(tag) }"
|
<div class="editor-section">
|
||||||
@click="toggleFormTag(tag)"
|
<label class="editor-label">标签</label>
|
||||||
>{{ tag }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overlay-footer">
|
<!-- Total cost -->
|
||||||
<button class="btn-outline" @click="closeOverlay">取消</button>
|
<div class="editor-total">
|
||||||
<button class="btn-primary" @click="saveCurrentRecipe">保存</button>
|
总计: {{ formTotalCost }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
||||||
|
:recipeIndex="previewRecipeIndex"
|
||||||
|
@close="previewRecipeIndex = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Tag Picker Overlay -->
|
<!-- Tag Picker Overlay -->
|
||||||
<TagPicker
|
<TagPicker
|
||||||
v-if="showTagPicker"
|
v-if="showTagPicker"
|
||||||
@@ -277,6 +372,7 @@ import { parseSingleBlock, parseMultiRecipes } from '../composables/useSmartPast
|
|||||||
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
|
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
|
||||||
import RecipeCard from '../components/RecipeCard.vue'
|
import RecipeCard from '../components/RecipeCard.vue'
|
||||||
import TagPicker from '../components/TagPicker.vue'
|
import TagPicker from '../components/TagPicker.vue'
|
||||||
|
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const oils = useOilsStore()
|
const oils = useOilsStore()
|
||||||
@@ -302,6 +398,15 @@ const formNote = ref('')
|
|||||||
const formTags = ref([])
|
const formTags = ref([])
|
||||||
const smartPasteText = ref('')
|
const smartPasteText = ref('')
|
||||||
const parsedRecipes = 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('single')
|
||||||
|
const formCustomVolume = ref(100)
|
||||||
|
const formCustomUnit = ref('drops')
|
||||||
|
const formDilution = ref(3)
|
||||||
|
|
||||||
const formTotalCost = computed(() => {
|
const formTotalCost = computed(() => {
|
||||||
const cost = formIngredients.value
|
const cost = formIngredients.value
|
||||||
@@ -458,6 +563,11 @@ function resetForm() {
|
|||||||
formNote.value = ''
|
formNote.value = ''
|
||||||
formTags.value = []
|
formTags.value = []
|
||||||
smartPasteText.value = ''
|
smartPasteText.value = ''
|
||||||
|
parsedRecipes.value = []
|
||||||
|
showAddIngRow.value = false
|
||||||
|
newIngOil.value = ''
|
||||||
|
newIngSearch.value = ''
|
||||||
|
newIngDrops.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSmartPaste() {
|
function handleSmartPaste() {
|
||||||
@@ -506,6 +616,83 @@ function onOilBlur(ing) {
|
|||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formCandidateTags = computed(() =>
|
||||||
|
recipeStore.allTags.filter(t => !formTags.value.includes(t))
|
||||||
|
)
|
||||||
|
|
||||||
|
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} 滴 (${(totalDrops / DROPS_PER_ML).toFixed(1)}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} 滴 (${(totalDrops / DROPS_PER_ML).toFixed(1)}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(0.5, Math.round(i.drops * scale * 2) / 2) })
|
||||||
|
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 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) {
|
function toggleFormTag(tag) {
|
||||||
const idx = formTags.value.indexOf(tag)
|
const idx = formTags.value.indexOf(tag)
|
||||||
if (idx >= 0) formTags.value.splice(idx, 1)
|
if (idx >= 0) formTags.value.splice(idx, 1)
|
||||||
@@ -613,7 +800,26 @@ async function saveAllParsed() {
|
|||||||
closeOverlay()
|
closeOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedCount = ref(0)
|
const sharedCount = ref({ adopted: 0, total: 0 })
|
||||||
|
const previewRecipeIndex = ref(null)
|
||||||
|
const showMyRecipes = ref(true)
|
||||||
|
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
|
// Load diary on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -623,10 +829,16 @@ onMounted(async () => {
|
|||||||
const res = await api('/api/me/contribution')
|
const res = await api('/api/me/contribution')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
sharedCount.value = data.shared_count || 0
|
sharedCount.value = { adopted: data.adopted_count || 0, total: data.shared_count || 0 }
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
if (auth.isAdmin) {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/recipe-reviews')
|
||||||
|
if (res.ok) reviewHistory.value = await res.json()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function editDiaryRecipe(diary) {
|
function editDiaryRecipe(diary) {
|
||||||
@@ -638,6 +850,11 @@ function editDiaryRecipe(diary) {
|
|||||||
showAddOverlay.value = true
|
showAddOverlay.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRecipeDetail(recipe) {
|
||||||
|
const idx = recipeStore.recipes.findIndex(r => r._id === recipe._id)
|
||||||
|
if (idx >= 0) previewRecipeIndex.value = idx
|
||||||
|
}
|
||||||
|
|
||||||
function getDiaryShareStatus(d) {
|
function getDiaryShareStatus(d) {
|
||||||
// Check if a public recipe with same name exists, owned by current user or adopted by admin
|
// 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)
|
const pub = recipeStore.recipes.find(r => r.name === d.name)
|
||||||
@@ -792,6 +1009,14 @@ watch(() => recipeStore.recipes, () => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.pending-name.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #4a9d7e;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.pending-name.clickable:hover {
|
||||||
|
color: #2e7d5a;
|
||||||
|
}
|
||||||
|
|
||||||
.pending-owner {
|
.pending-owner {
|
||||||
color: #999;
|
color: #999;
|
||||||
@@ -836,6 +1061,10 @@ watch(() => recipeStore.recipes, () => {
|
|||||||
|
|
||||||
.tag-filter-bar {
|
.tag-filter-bar {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-toggle-btn {
|
.tag-toggle-btn {
|
||||||
@@ -985,6 +1214,19 @@ watch(() => recipeStore.recipes, () => {
|
|||||||
.share-tag.shared { background: #e8f5e9; color: #2e7d32; }
|
.share-tag.shared { background: #e8f5e9; color: #2e7d32; }
|
||||||
.share-tag.pending { background: #fff3e0; color: #e65100; }
|
.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; justify-content: space-between; align-items: center; }
|
||||||
|
.toggle-icon { font-size: 12px; color: #999; }
|
||||||
|
|
||||||
.contrib-tag {
|
.contrib-tag {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #4a9d7e;
|
color: #4a9d7e;
|
||||||
@@ -1113,12 +1355,52 @@ watch(() => recipeStore.recipes, () => {
|
|||||||
.parsed-warn { color: #e65100; font-size: 12px; margin-bottom: 6px; }
|
.parsed-warn { color: #e65100; font-size: 12px; margin-bottom: 6px; }
|
||||||
.parsed-actions { display: flex; gap: 8px; justify-content: center; margin-top: 8px; }
|
.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; }
|
||||||
|
.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 { 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 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-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; }
|
||||||
.cell-muted { color: #b0aab5; font-size: 12px; }
|
.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; }
|
||||||
.cell-cost { color: #4a9d7e; font-weight: 500; font-size: 13px; }
|
.editor-drops:focus { border-color: #7ec6a4; }
|
||||||
.form-total { text-align: right; font-size: 14px; font-weight: 600; color: #4a9d7e; margin-top: 8px; }
|
.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; }
|
||||||
|
.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: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.volume-btn { padding: 6px 14px; border: 1.5px solid #d4cfc7; border-radius: 8px; background: #fff; font-size: 13px; cursor: pointer; font-family: inherit; color: #6b6375; }
|
||||||
|
.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; }
|
||||||
|
.dilution-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
||||||
|
.dilution-label { font-size: 12px; color: #3e3a44; white-space: nowrap; }
|
||||||
|
.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: 4px 6px; border: 1.5px solid #d4cfc7; border-radius: 6px; font-size: 12px; font-family: inherit; background: #fff; width: auto; }
|
||||||
|
|
||||||
.divider-text {
|
.divider-text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<template v-if="!searchQuery || myDiaryRecipes.length > 0">
|
<template v-if="!searchQuery || myDiaryRecipes.length > 0">
|
||||||
<div class="section-header" @click="showMyRecipes = !showMyRecipes">
|
<div class="section-header" @click="showMyRecipes = !showMyRecipes">
|
||||||
<span>📖 我的配方 ({{ myDiaryRecipes.length }})</span>
|
<span>📖 我的配方 ({{ myDiaryRecipes.length }})</span>
|
||||||
<span v-if="!auth.isAdmin && sharedCount > 0" class="contrib-badge">已贡献 {{ sharedCount }} 条公共配方</span>
|
<span v-if="!auth.isAdmin && sharedCount.total > 0" class="contrib-badge">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} 条</span>
|
||||||
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
|
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showMyRecipes" class="recipe-grid">
|
<div v-if="showMyRecipes" class="recipe-grid">
|
||||||
@@ -192,7 +192,7 @@ const selectedDiaryRecipe = ref(null)
|
|||||||
const showMyRecipes = ref(false)
|
const showMyRecipes = ref(false)
|
||||||
const showFavorites = ref(false)
|
const showFavorites = ref(false)
|
||||||
const catIdx = ref(0)
|
const catIdx = ref(0)
|
||||||
const sharedCount = ref(0)
|
const sharedCount = ref({ adopted: 0, total: 0 })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -209,7 +209,7 @@ onMounted(async () => {
|
|||||||
const cRes = await api('/api/me/contribution')
|
const cRes = await api('/api/me/contribution')
|
||||||
if (cRes.ok) {
|
if (cRes.ok) {
|
||||||
const data = await cRes.json()
|
const data = await cRes.json()
|
||||||
sharedCount.value = data.shared_count || 0
|
sharedCount.value = { adopted: data.adopted_count || 0, total: data.shared_count || 0 }
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user