feat: 重写新增配方+共享审核完整流程

新增配方:
- 修复保存失败(oil→oil_name字段转换)
- 智能识别支持多条配方同时解析
- 识别结果逐条预览,可修改/放弃/保存单条/全部保存
- 编辑器加成分表格(单价/滴、小计、总成本)
- 保存到个人配方(diary)

共享审核:
- 新增 /api/recipes/{id}/reject 端点(带原因通知提交者)
- 采纳配方时通知提交者"配方已采纳"
- 拒绝时管理员输入原因
- 贡献统计含被采纳的配方数

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 08:47:44 +00:00
parent 3c3ce30b48
commit 56bc6f2bbb
2 changed files with 239 additions and 44 deletions

View File

@@ -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,
}
})
}

View File

@@ -141,18 +141,43 @@
<textarea
v-model="smartPasteText"
class="paste-input"
placeholder="粘贴配方文本,支持智能识别...&#10;例如: 薰衣草3茶树2"
placeholder="直接粘贴配方文本,支持多条配方同时识别&#10;例如: 舒缓放松,薰衣草3茶树2&#10;提神醒脑柠檬5椒样薄荷3"
rows="4"
></textarea>
<button class="btn-primary" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
智能识别
<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 -->
<!-- Manual Form (same as RecipeDetailOverlay editor) -->
<div class="form-group">
<label>配方名称</label>
<input v-model="formName" class="form-input" placeholder="配方名称" />
@@ -160,12 +185,18 @@
<div class="form-group">
<label>成分</label>
<div v-for="(ing, i) in formIngredients" :key="i" class="ing-row">
<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="输入搜索精油..."
placeholder="搜索精油..."
@focus="ing._open = true"
@input="ing._open = true"
@blur="onOilBlur(ing)"
@@ -180,10 +211,16 @@
<div v-if="filteredOilNames(ing._search || '').length === 0" class="oil-option oil-empty">无匹配</div>
</div>
</div>
<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, _search: '', _open: false })">+ 添加成分</button>
</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">
@@ -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;