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 @@
添加精油