From 31b46d59b64b5f81f525d2dedaa114ee9a287eb7 Mon Sep 17 00:00:00 2001 From: YoYo Date: Wed, 8 Apr 2026 20:03:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E6=96=B9=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=AE=B9=E9=87=8F=E5=88=87=E6=8D=A2=E3=80=81=E9=A2=84=E8=A7=88?= =?UTF-8?q?/=E4=BF=9D=E5=AD=98=E6=B5=81=E7=A8=8B=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E3=80=81=E7=B2=BE=E6=B2=B9=E6=90=9C=E7=B4=A2=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E3=80=81=E7=B2=BE=E6=B2=B9=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E5=90=8D=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 配方卡片视图加回容量切换按钮(单次/2.5ml/5ml…), 非单次容量的滴数通过 Math.round 取整显示 2. 编辑器「预览」按钮改为展示当前未保存数据; 预览后点关闭询问是否保存; 直接点「保存」后留在配方卡片视图(不再关闭弹层) 3. 添加精油改为搜索输入框 + 下拉自动补全, 支持中文名和英文名筛选 4. 精油价目:添加/编辑表单加入英文名字段; 编辑/删除按钮改为悬停(桌面)或点击(移动端)才显示; 后端及数据库同步支持 oils.en_name --- backend/database.py | 2 + backend/main.py | 10 +- .../src/components/RecipeDetailOverlay.vue | 207 ++++++++++++++++-- frontend/src/stores/oils.js | 4 +- frontend/src/views/OilReference.vue | 35 ++- 5 files changed, 230 insertions(+), 28 deletions(-) diff --git a/backend/database.py b/backend/database.py index b62f382..56bf3bc 100644 --- a/backend/database.py +++ b/backend/database.py @@ -221,6 +221,8 @@ def init_db(): c.execute("ALTER TABLE oils ADD COLUMN retail_price REAL") if "is_active" not in oil_cols: c.execute("ALTER TABLE oils ADD COLUMN is_active INTEGER DEFAULT 1") + if "en_name" not in oil_cols: + c.execute("ALTER TABLE oils ADD COLUMN en_name TEXT DEFAULT ''") # Migration: add new columns to category_modules if missing cat_cols = [row[1] for row in c.execute("PRAGMA table_info(category_modules)").fetchall()] diff --git a/backend/main.py b/backend/main.py index 0931c4c..9786964 100644 --- a/backend/main.py +++ b/backend/main.py @@ -79,6 +79,7 @@ class OilIn(BaseModel): bottle_price: float drop_count: int retail_price: Optional[float] = None + en_name: Optional[str] = None class IngredientIn(BaseModel): @@ -649,7 +650,7 @@ def impersonate(body: dict, user=Depends(require_role("admin"))): @app.get("/api/oils") def list_oils(): conn = get_db() - rows = conn.execute("SELECT name, bottle_price, drop_count, retail_price, is_active FROM oils ORDER BY name").fetchall() + rows = conn.execute("SELECT name, bottle_price, drop_count, retail_price, is_active, en_name FROM oils ORDER BY name").fetchall() conn.close() return [dict(r) for r in rows] @@ -658,9 +659,10 @@ def list_oils(): def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))): conn = get_db() conn.execute( - "INSERT INTO oils (name, bottle_price, drop_count, retail_price) VALUES (?, ?, ?, ?) " - "ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, retail_price=excluded.retail_price", - (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price), + "INSERT INTO oils (name, bottle_price, drop_count, retail_price, en_name) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, " + "retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name)", + (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, oil.en_name), ) log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name, json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count})) diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index c1112ff..da08c3d 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -19,7 +19,7 @@ class="action-btn action-btn-sm" @click="viewMode = 'editor'" >编辑 - + @@ -36,6 +36,17 @@ >English + +
+ +
+
@@ -91,8 +102,8 @@
{{ dilutionDesc }}
-
- {{ cardLang === 'en' ? '📝 ' + recipe.note : '📝 ' + recipe.note }} +
+ {{ '📝 ' + displayRecipe.note }}
@@ -162,7 +173,7 @@
- +
@@ -215,10 +226,29 @@
- +
+ +
+
+ {{ name }} + {{ oilEn(name) }} +
+
+
- +
@@ -384,11 +414,20 @@ const customRecipeNameEn = ref('') const customOilNameEn = ref({}) const generatingImage = ref(false) +// ---- Preview override: holds unsaved editor state when user clicks "预览" ---- +const previewOverride = ref(null) + // ---- Source recipe ---- const recipe = computed(() => recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' } ) +// ---- Display recipe: previewOverride when in preview mode, otherwise saved recipe ---- +const displayRecipe = computed(() => { + if (!previewOverride.value) return recipe.value + return { ...recipe.value, ...previewOverride.value } +}) + const canEditThisRecipe = computed(() => { if (authStore.canEdit) return true if (authStore.isLoggedIn && recipe.value._owner_id === authStore.user.id) return true @@ -411,7 +450,7 @@ function scaleIngredients(ingredients, volume) { // Card ingredients: scaled to selected volume, coconut oil excluded from display const scaledCardIngredients = computed(() => - scaleIngredients(recipe.value.ingredients, selectedCardVolume.value) + scaleIngredients(displayRecipe.value.ingredients, selectedCardVolume.value) ) const cardIngredients = computed(() => @@ -559,12 +598,12 @@ function copyText() { }) const total = priceInfo.value.cost const text = [ - recipe.value.name, + displayRecipe.value.name, '---', ...lines, '---', `总成本: ${total}`, - recipe.value.note ? `备注: ${recipe.value.note}` : '', + displayRecipe.value.note ? `备注: ${displayRecipe.value.note}` : '', ].filter(Boolean).join('\n') navigator.clipboard.writeText(text).then(() => { @@ -602,9 +641,9 @@ function getCardOilName(name) { function getCardRecipeName() { if (cardLang.value === 'en') { - return customRecipeNameEn.value || recipe.value.en_name || recipeNameEn(recipe.value.name) + return customRecipeNameEn.value || displayRecipe.value.en_name || recipeNameEn(displayRecipe.value.name) } - return recipe.value.name + return displayRecipe.value.name } // ---- Favorite ---- @@ -657,6 +696,36 @@ const newIngOil = ref('') const newIngDrops = ref(1) const newTagInput = ref('') +// Oil autocomplete for add-ingredient row +const oilSearchQuery = ref('') +const showOilDropdown = ref(false) + +const filteredOilsForAdd = computed(() => { + const q = oilSearchQuery.value.trim().toLowerCase() + if (!q) return oilsStore.oilNames + return oilsStore.oilNames.filter(n => { + const en = oilEn(n).toLowerCase() + return n.includes(q) || en.startsWith(q) || en.includes(q) + }) +}) + +function selectNewOil(name) { + newIngOil.value = name + oilSearchQuery.value = name + showOilDropdown.value = false +} + +function closeOilDropdown() { + setTimeout(() => { showOilDropdown.value = false }, 150) +} + +function cancelAddRow() { + showAddRow.value = false + newIngOil.value = '' + oilSearchQuery.value = '' + newIngDrops.value = 1 +} + // Volume & dilution const selectedVolume = ref('single') const customVolumeValue = ref(100) @@ -737,6 +806,7 @@ function confirmAddIngredient() { } editIngredients.value.push({ oil: newIngOil.value, drops: newIngDrops.value }) newIngOil.value = '' + oilSearchQuery.value = '' newIngDrops.value = 1 showAddRow.value = false } @@ -829,9 +899,44 @@ function setCoconutDrops(drops) { } } +// Handle close from card view — if previewing unsaved data, ask user +async function handleClose() { + if (previewOverride.value !== null) { + const save = await showConfirm('还有未保存的修改,是否保存?', { okText: '保存', cancelText: '不保存' }) + if (save) { + viewMode.value = 'editor' + await nextTick() + await saveRecipe() + return + } + previewOverride.value = null + } + emit('close') +} + +// Handle close from editor view +async function handleEditorClose() { + if (previewOverride.value !== null) { + // Came from preview, back to preview without saving + previewOverride.value = null + viewMode.value = 'card' + cardImageUrl.value = null + nextTick(() => generateCardImage()) + return + } + emit('close') +} + function previewFromEditor() { - // Temporarily update recipe view with editor data, switch to card - // We just switch to card mode; the card shows the saved recipe + // Capture current editor state and show it in card view + previewOverride.value = { + name: editName.value.trim() || recipe.value.name, + note: editNote.value.trim(), + tags: [...editTags.value], + ingredients: editIngredients.value + .filter(i => i.oil && i.drops > 0) + .map(i => ({ oil: i.oil, drops: i.drops })), + } viewMode.value = 'card' cardImageUrl.value = null nextTick(() => generateCardImage()) @@ -860,7 +965,11 @@ async function saveRecipe() { // Reload recipes so the data is fresh when re-opened await recipesStore.loadRecipes() ui.showToast('保存成功') - emit('close') + // Go to card view instead of closing + previewOverride.value = null + viewMode.value = 'card' + cardImageUrl.value = null + nextTick(() => generateCardImage()) } catch (e) { ui.showToast('保存失败: ' + (e?.message || '未知错误')) } @@ -1683,6 +1792,70 @@ async function saveRecipe() { cursor: not-allowed; } +/* Card volume toggle */ +.card-volume-toggle { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 12px; +} + +/* Oil autocomplete */ +.oil-autocomplete { + position: relative; + flex: 1; + min-width: 140px; +} + +.oil-search-input { + width: 100%; + box-sizing: border-box; +} + +.oil-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #fff; + border: 1.5px solid var(--sage, #7a9e7e); + border-radius: 10px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); + max-height: 220px; + overflow-y: auto; + z-index: 100; + margin-top: 4px; +} + +.oil-dropdown-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 14px; + cursor: pointer; + font-size: 13px; + color: var(--text-dark, #2c2416); + border-bottom: 1px solid var(--border, #e0d4c0); + transition: background 0.1s; +} + +.oil-dropdown-item:last-child { + border-bottom: none; +} + +.oil-dropdown-item:hover, +.oil-dropdown-item.is-selected { + background: var(--sage-mist, #eef4ee); +} + +.oil-dropdown-en { + font-size: 11px; + color: var(--text-light, #9a8570); + margin-left: 8px; + white-space: nowrap; +} + /* Responsive */ @media (max-width: 600px) { .detail-panel { diff --git a/frontend/src/stores/oils.js b/frontend/src/stores/oils.js index f890b0e..1509776 100644 --- a/frontend/src/stores/oils.js +++ b/frontend/src/stores/oils.js @@ -69,18 +69,20 @@ export const useOilsStore = defineStore('oils', () => { dropCount: oil.drop_count, retailPrice: oil.retail_price ?? null, isActive: oil.is_active ?? true, + enName: oil.en_name ?? null, } } oils.value = newOils oilsMeta.value = newMeta } - async function saveOil(name, bottlePrice, dropCount, retailPrice) { + async function saveOil(name, bottlePrice, dropCount, retailPrice, enName = null) { await api.post('/api/oils', { name, bottle_price: bottlePrice, drop_count: dropCount, retail_price: retailPrice, + en_name: enName, }) await loadOils() } diff --git a/frontend/src/views/OilReference.vue b/frontend/src/views/OilReference.vue index 2074e8d..2d8bc6a 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -75,6 +75,7 @@

添加精油

+ +
@@ -334,6 +339,7 @@ const activeCard = ref(null) // Add oil form const newOilName = ref('') +const newOilEnName = ref('') const newBottlePrice = ref(null) const newVolume = ref('5') const newCustomDrops = ref(null) @@ -345,6 +351,14 @@ const editBottlePrice = ref(0) const editVolume = ref('5') const editDropCount = ref(0) const editRetailPrice = ref(null) +const editOilEnName = ref('') + +// Active chip (for mobile hover) +const activeChip = ref(null) + +function toggleChip(name) { + activeChip.value = activeChip.value === name ? null : name +} // Volume-to-drops mapping const VOLUME_OPTIONS = { @@ -407,9 +421,13 @@ function getMeta(name) { } function getEnglishName(name) { - // First check the oil card for English name + // 1. Oil card has priority const card = getOilCard(name) if (card && card.en) return card.en + // 2. Stored en_name in meta + const meta = oils.oilsMeta[name] + if (meta?.enName) return meta.enName + // 3. Static translation map return oilEn(name) } @@ -466,10 +484,12 @@ async function addOil() { newOilName.value.trim(), newBottlePrice.value || 0, dropCount, - newRetailPrice.value || null + newRetailPrice.value || null, + newOilEnName.value.trim() || null ) ui.showToast(`已添加: ${newOilName.value}`) newOilName.value = '' + newOilEnName.value = '' newBottlePrice.value = null newVolume.value = '5' newCustomDrops.value = null @@ -487,6 +507,7 @@ function editOil(name) { editVolume.value = dropCountToVolume(dc) editDropCount.value = dc editRetailPrice.value = meta?.retailPrice || null + editOilEnName.value = meta?.enName || getEnglishName(name) || '' } async function saveEditOil() { @@ -496,7 +517,8 @@ async function saveEditOil() { editingOilName.value, editBottlePrice.value, dropCount, - editRetailPrice.value + editRetailPrice.value, + editOilEnName.value.trim() || null ) ui.showToast('已更新') editingOilName.value = null @@ -982,7 +1004,8 @@ function saveContraImage() { transition: opacity 0.15s; } -.oil-chip:hover .oil-actions { +.oil-chip:hover .oil-actions, +.oil-chip--active .oil-actions { opacity: 1; }