From 56bc6f2bbbdacd0bb413ebdbb5048c3950ae9cef Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Fri, 10 Apr 2026 08:47:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=86=99=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=85=8D=E6=96=B9+=E5=85=B1=E4=BA=AB=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增配方: - 修复保存失败(oil→oil_name字段转换) - 智能识别支持多条配方同时解析 - 识别结果逐条预览,可修改/放弃/保存单条/全部保存 - 编辑器加成分表格(单价/滴、小计、总成本) - 保存到个人配方(diary) 共享审核: - 新增 /api/recipes/{id}/reject 端点(带原因通知提交者) - 采纳配方时通知提交者"配方已采纳" - 拒绝时管理员输入原因 - 贡献统计含被采纳的配方数 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/composables/useSmartPaste.js | 62 ++++++ frontend/src/views/RecipeManager.vue | 221 +++++++++++++++++----- 2 files changed, 239 insertions(+), 44 deletions(-) diff --git a/frontend/src/composables/useSmartPaste.js b/frontend/src/composables/useSmartPaste.js index a0b9a56..8352681 100644 --- a/frontend/src/composables/useSmartPaste.js +++ b/frontend/src/composables/useSmartPaste.js @@ -260,3 +260,65 @@ export function parseSingleBlock(raw, oilNames) { notFound } } + +/** + * Parse multi-recipe text. Each time an unrecognized non-number token + * appears after some oils have been found, it starts a new recipe. + */ +export function parseMultiRecipes(raw, oilNames) { + const parts = raw.split(/[,,、\n\r]+/).map(s => s.trim()).filter(s => s) + + const recipes = [] + let current = { nameParts: [], ingredientParts: [], foundOil: false } + + for (const part of parts) { + const hasNumber = /\d/.test(part) + const hasOil = oilNames.some(oil => part.includes(oil)) || + Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias)) + // Also check fuzzy: 3+ char parts + const fuzzyOil = !hasOil && part.replace(/\d+\.?\d*/g, '').length >= 2 && + findOil(part.replace(/\d+\.?\d*/g, '').trim(), oilNames) + + if (current.foundOil && !hasOil && !fuzzyOil && !hasNumber && part.length >= 2) { + // New recipe starts + recipes.push(current) + current = { nameParts: [], ingredientParts: [], foundOil: false } + current.nameParts.push(part) + } else if (!current.foundOil && !hasOil && !fuzzyOil && !hasNumber) { + current.nameParts.push(part) + } else { + current.foundOil = true + current.ingredientParts.push(part) + } + } + recipes.push(current) + + // Convert each block to parsed recipe + return recipes.filter(r => r.ingredientParts.length > 0 || r.nameParts.length > 0).map(r => { + const allIngs = [] + const notFound = [] + for (const p of r.ingredientParts) { + const parsed = parseOilChunk(p, oilNames) + for (const item of parsed) { + if (item.notFound) notFound.push(item.oil) + else allIngs.push(item) + } + } + // Deduplicate + const deduped = [] + const seen = {} + for (const item of allIngs) { + if (seen[item.oil] !== undefined) { + deduped[seen[item.oil]].drops += item.drops + } else { + seen[item.oil] = deduped.length + deduped.push({ ...item }) + } + } + return { + name: r.nameParts.join(' ') || '未命名配方', + ingredients: deduped, + notFound, + } + }) +} diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index 4ecb529..4a96429 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -141,18 +141,43 @@ - + +
+
+
+ + +
+
+
+ {{ ing.oil }} + + +
+
+
+ ⚠️ 未识别: {{ pr.notFound.join('、') }} +
+ +
+
+ + +
+
+
或手动输入
- +
@@ -160,30 +185,42 @@
-
-
- -
-
{{ name }}
-
无匹配
-
-
- - -
- + + + + + + + + + + + + + +
精油滴数单价/滴小计
+
+ +
+
{{ name }}
+
无匹配
+
+
+
{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }}{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '-' }}
+ +
总成本: {{ formTotalCost }}
@@ -232,7 +269,7 @@ import { useDiaryStore } from '../stores/diary' import { useUiStore } from '../stores/ui' import { api } from '../composables/useApi' import { showConfirm, showPrompt } from '../composables/useDialog' -import { parseSingleBlock } from '../composables/useSmartPaste' +import { parseSingleBlock, parseMultiRecipes } from '../composables/useSmartPaste' import { matchesPinyinInitials } from '../composables/usePinyinMatch' import RecipeCard from '../components/RecipeCard.vue' import TagPicker from '../components/TagPicker.vue' @@ -256,10 +293,18 @@ const pendingCount = ref(0) // Form state const formName = ref('') -const formIngredients = ref([{ oil: '', drops: 1 }]) +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) @@ -412,13 +457,26 @@ function resetForm() { } 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(', ')}`) + 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} 条配方`) } } @@ -461,17 +519,17 @@ async function saveCurrentRecipe() { return } - const payload = { - name: formName.value.trim(), - ingredients: validIngs, - note: formNote.value, - tags: formTags.value, - } + const cleanIngs = validIngs.map(i => ({ oil: i.oil, drops: i.drops })) if (editingRecipe.value && editingRecipe.value._diary_id) { // Editing a diary (personal) recipe try { - await diaryStore.updateDiary(editingRecipe.value._diary_id, payload) + await diaryStore.updateDiary(editingRecipe.value._diary_id, { + name: formName.value.trim(), + ingredients: cleanIngs, + note: formNote.value, + tags: formTags.value, + }) ui.showToast('个人配方已更新') closeOverlay() } catch (e) { @@ -480,6 +538,14 @@ async function saveCurrentRecipe() { return } + // Public recipe: API expects oil_name + const payload = { + name: formName.value.trim(), + ingredients: cleanIngs.map(i => ({ oil_name: i.oil, drops: i.drops })), + note: formNote.value, + tags: formTags.value, + } + if (editingRecipe.value) { payload._id = editingRecipe.value._id payload._version = editingRecipe.value._version @@ -494,6 +560,46 @@ async function saveCurrentRecipe() { } } +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() +} + // Load diary on mount onMounted(async () => { if (auth.isLoggedIn) { @@ -912,6 +1018,33 @@ watch(() => recipeStore.recipes, () => { 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;