feat: 权限细化、商业认证跳转、UI改进 #19
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,18 +141,43 @@
|
||||
<textarea
|
||||
v-model="smartPasteText"
|
||||
class="paste-input"
|
||||
placeholder="粘贴配方文本,支持智能识别... 例如: 薰衣草3滴 茶树2滴"
|
||||
placeholder="直接粘贴配方文本,支持多条配方同时识别 例如: 舒缓放松,薰衣草3,茶树2 提神醒脑,柠檬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,30 +185,42 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label>成分</label>
|
||||
<div v-for="(ing, i) in formIngredients" :key="i" class="ing-row">
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user