feat: 配方卡片容量切换、预览/保存流程优化、精油搜索自动补全、精油英文名编辑
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 13s
Test / e2e-test (push) Failing after 1m20s

1. 配方卡片视图加回容量切换按钮(单次/2.5ml/5ml…),
   非单次容量的滴数通过 Math.round 取整显示
2. 编辑器「预览」按钮改为展示当前未保存数据;
   预览后点关闭询问是否保存;
   直接点「保存」后留在配方卡片视图(不再关闭弹层)
3. 添加精油改为搜索输入框 + 下拉自动补全,
   支持中文名和英文名筛选
4. 精油价目:添加/编辑表单加入英文名字段;
   编辑/删除按钮改为悬停(桌面)或点击(移动端)才显示;
   后端及数据库同步支持 oils.en_name
This commit is contained in:
2026-04-08 20:03:04 +00:00
parent cc79ae1211
commit 31b46d59b6
5 changed files with 230 additions and 28 deletions

View File

@@ -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()]

View File

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

View File

@@ -19,7 +19,7 @@
class="action-btn action-btn-sm"
@click="viewMode = 'editor'"
>编辑</button>
<button class="detail-close-btn" @click="$emit('close')"></button>
<button class="detail-close-btn" @click="handleClose"></button>
</div>
<!-- Language toggle -->
@@ -36,6 +36,17 @@
>English</button>
</div>
<!-- Volume selector -->
<div class="card-volume-toggle">
<button
v-for="(drops, ml) in VOLUME_DROPS"
:key="ml"
class="volume-btn"
:class="{ active: selectedCardVolume === ml }"
@click="onCardVolumeChange(ml)"
>{{ ml === '单次' ? '单次' : ml + 'ml' }}</button>
</div>
<!-- Card image (rendered by html2canvas) -->
<div v-show="!cardImageUrl" ref="cardRef" class="export-card">
<!-- Brand overlay layers -->
@@ -91,8 +102,8 @@
<div v-if="dilutionDesc" class="card-dilution">{{ dilutionDesc }}</div>
<!-- Note -->
<div v-if="recipe.note" class="card-note">
{{ cardLang === 'en' ? '📝 ' + recipe.note : '📝 ' + recipe.note }}
<div v-if="displayRecipe.note" class="card-note">
{{ '📝 ' + displayRecipe.note }}
</div>
<!-- Total cost bar -->
@@ -162,7 +173,7 @@
<div class="editor-header-actions">
<button class="action-btn action-btn-primary action-btn-sm" @click="saveRecipe">💾 保存</button>
<button class="action-btn action-btn-sm" @click="previewFromEditor">👁 预览</button>
<button class="detail-close-btn" @click="$emit('close')"></button>
<button class="detail-close-btn" @click="handleEditorClose"></button>
</div>
</div>
@@ -215,10 +226,29 @@
<!-- Add ingredient row -->
<div v-if="showAddRow" class="add-ingredient-row">
<select v-model="newIngOil" class="editor-select">
<option value=""> 选择精油 </option>
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<div class="oil-autocomplete">
<input
v-model="oilSearchQuery"
@focus="showOilDropdown = true"
@blur="closeOilDropdown"
@input="newIngOil = ''"
class="editor-input oil-search-input"
placeholder="搜索精油名称或英文..."
autocomplete="off"
/>
<div v-if="showOilDropdown && filteredOilsForAdd.length" class="oil-dropdown">
<div
v-for="name in filteredOilsForAdd"
:key="name"
class="oil-dropdown-item"
:class="{ 'is-selected': newIngOil === name }"
@mousedown.prevent="selectNewOil(name)"
>
<span>{{ name }}</span>
<span class="oil-dropdown-en">{{ oilEn(name) }}</span>
</div>
</div>
</div>
<input
v-model.number="newIngDrops"
type="number"
@@ -228,7 +258,7 @@
class="editor-drops"
/>
<button class="action-btn action-btn-primary action-btn-sm" @click="confirmAddIngredient">确认</button>
<button class="action-btn action-btn-sm" @click="showAddRow = false">取消</button>
<button class="action-btn action-btn-sm" @click="cancelAddRow">取消</button>
</div>
<button v-else class="add-row-btn" @click="showAddRow = true">+ 添加精油</button>
</div>
@@ -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 {

View File

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

View File

@@ -75,6 +75,7 @@
<h3 class="section-title">添加精油</h3>
<div class="form-row">
<input v-model="newOilName" class="form-input" placeholder="精油名称" />
<input v-model="newOilEnName" class="form-input" placeholder="英文名 (English)" />
<input v-model.number="newBottlePrice" class="form-input-sm" type="number" placeholder="瓶价 ¥" />
<select v-model="newVolume" class="form-select">
<option value="2.5">2.5ml (46)</option>
@@ -129,7 +130,7 @@
v-for="name in filteredOilNames"
:key="name"
class="oil-chip"
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false }"
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--active': activeChip === name }"
@click="openOilDetail(name)"
>
<span v-if="getOilCard(name)" class="oil-badge" title="有知识卡片">📖</span>
@@ -159,7 +160,7 @@
<div class="oil-chip-volume" v-if="getMeta(name)?.dropCount">
{{ volumeLabel(getMeta(name).dropCount) }}
</div>
<div class="oil-actions" v-if="auth.canManage" @click.stop>
<div class="oil-actions" v-if="auth.canManage" @click.stop @touchstart.stop="toggleChip(name)">
<button class="btn-icon-sm" @click="editOil(name)" title="编辑"></button>
<button class="btn-icon-sm" @click="removeOil(name)" title="删除">🗑</button>
</div>
@@ -271,6 +272,10 @@
<button class="btn-close" @click="editingOilName = null"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>英文名</label>
<input v-model="editOilEnName" class="form-input" type="text" placeholder="English name" />
</div>
<div class="form-group">
<label>瓶价 (¥)</label>
<input v-model.number="editBottlePrice" class="form-input" type="number" />
@@ -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;
}