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
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:
@@ -221,6 +221,8 @@ def init_db():
|
|||||||
c.execute("ALTER TABLE oils ADD COLUMN retail_price REAL")
|
c.execute("ALTER TABLE oils ADD COLUMN retail_price REAL")
|
||||||
if "is_active" not in oil_cols:
|
if "is_active" not in oil_cols:
|
||||||
c.execute("ALTER TABLE oils ADD COLUMN is_active INTEGER DEFAULT 1")
|
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
|
# Migration: add new columns to category_modules if missing
|
||||||
cat_cols = [row[1] for row in c.execute("PRAGMA table_info(category_modules)").fetchall()]
|
cat_cols = [row[1] for row in c.execute("PRAGMA table_info(category_modules)").fetchall()]
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class OilIn(BaseModel):
|
|||||||
bottle_price: float
|
bottle_price: float
|
||||||
drop_count: int
|
drop_count: int
|
||||||
retail_price: Optional[float] = None
|
retail_price: Optional[float] = None
|
||||||
|
en_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class IngredientIn(BaseModel):
|
class IngredientIn(BaseModel):
|
||||||
@@ -649,7 +650,7 @@ def impersonate(body: dict, user=Depends(require_role("admin"))):
|
|||||||
@app.get("/api/oils")
|
@app.get("/api/oils")
|
||||||
def list_oils():
|
def list_oils():
|
||||||
conn = get_db()
|
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()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
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"))):
|
def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO oils (name, bottle_price, drop_count, retail_price) VALUES (?, ?, ?, ?) "
|
"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",
|
"ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, "
|
||||||
(oil.name, oil.bottle_price, oil.drop_count, oil.retail_price),
|
"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,
|
log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name,
|
||||||
json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count}))
|
json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count}))
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
class="action-btn action-btn-sm"
|
class="action-btn action-btn-sm"
|
||||||
@click="viewMode = 'editor'"
|
@click="viewMode = 'editor'"
|
||||||
>编辑</button>
|
>编辑</button>
|
||||||
<button class="detail-close-btn" @click="$emit('close')">✕</button>
|
<button class="detail-close-btn" @click="handleClose">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Language toggle -->
|
<!-- Language toggle -->
|
||||||
@@ -36,6 +36,17 @@
|
|||||||
>English</button>
|
>English</button>
|
||||||
</div>
|
</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) -->
|
<!-- Card image (rendered by html2canvas) -->
|
||||||
<div v-show="!cardImageUrl" ref="cardRef" class="export-card">
|
<div v-show="!cardImageUrl" ref="cardRef" class="export-card">
|
||||||
<!-- Brand overlay layers -->
|
<!-- Brand overlay layers -->
|
||||||
@@ -91,8 +102,8 @@
|
|||||||
<div v-if="dilutionDesc" class="card-dilution">{{ dilutionDesc }}</div>
|
<div v-if="dilutionDesc" class="card-dilution">{{ dilutionDesc }}</div>
|
||||||
|
|
||||||
<!-- Note -->
|
<!-- Note -->
|
||||||
<div v-if="recipe.note" class="card-note">
|
<div v-if="displayRecipe.note" class="card-note">
|
||||||
{{ cardLang === 'en' ? '📝 ' + recipe.note : '📝 ' + recipe.note }}
|
{{ '📝 ' + displayRecipe.note }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total cost bar -->
|
<!-- Total cost bar -->
|
||||||
@@ -162,7 +173,7 @@
|
|||||||
<div class="editor-header-actions">
|
<div class="editor-header-actions">
|
||||||
<button class="action-btn action-btn-primary action-btn-sm" @click="saveRecipe">💾 保存</button>
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -215,10 +226,29 @@
|
|||||||
|
|
||||||
<!-- Add ingredient row -->
|
<!-- Add ingredient row -->
|
||||||
<div v-if="showAddRow" class="add-ingredient-row">
|
<div v-if="showAddRow" class="add-ingredient-row">
|
||||||
<select v-model="newIngOil" class="editor-select">
|
<div class="oil-autocomplete">
|
||||||
<option value="">— 选择精油 —</option>
|
<input
|
||||||
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
|
v-model="oilSearchQuery"
|
||||||
</select>
|
@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
|
<input
|
||||||
v-model.number="newIngDrops"
|
v-model.number="newIngDrops"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -228,7 +258,7 @@
|
|||||||
class="editor-drops"
|
class="editor-drops"
|
||||||
/>
|
/>
|
||||||
<button class="action-btn action-btn-primary action-btn-sm" @click="confirmAddIngredient">确认</button>
|
<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>
|
</div>
|
||||||
<button v-else class="add-row-btn" @click="showAddRow = true">+ 添加精油</button>
|
<button v-else class="add-row-btn" @click="showAddRow = true">+ 添加精油</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,11 +414,20 @@ const customRecipeNameEn = ref('')
|
|||||||
const customOilNameEn = ref({})
|
const customOilNameEn = ref({})
|
||||||
const generatingImage = ref(false)
|
const generatingImage = ref(false)
|
||||||
|
|
||||||
|
// ---- Preview override: holds unsaved editor state when user clicks "预览" ----
|
||||||
|
const previewOverride = ref(null)
|
||||||
|
|
||||||
// ---- Source recipe ----
|
// ---- Source recipe ----
|
||||||
const recipe = computed(() =>
|
const recipe = computed(() =>
|
||||||
recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' }
|
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(() => {
|
const canEditThisRecipe = computed(() => {
|
||||||
if (authStore.canEdit) return true
|
if (authStore.canEdit) return true
|
||||||
if (authStore.isLoggedIn && recipe.value._owner_id === authStore.user.id) 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
|
// Card ingredients: scaled to selected volume, coconut oil excluded from display
|
||||||
const scaledCardIngredients = computed(() =>
|
const scaledCardIngredients = computed(() =>
|
||||||
scaleIngredients(recipe.value.ingredients, selectedCardVolume.value)
|
scaleIngredients(displayRecipe.value.ingredients, selectedCardVolume.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const cardIngredients = computed(() =>
|
const cardIngredients = computed(() =>
|
||||||
@@ -559,12 +598,12 @@ function copyText() {
|
|||||||
})
|
})
|
||||||
const total = priceInfo.value.cost
|
const total = priceInfo.value.cost
|
||||||
const text = [
|
const text = [
|
||||||
recipe.value.name,
|
displayRecipe.value.name,
|
||||||
'---',
|
'---',
|
||||||
...lines,
|
...lines,
|
||||||
'---',
|
'---',
|
||||||
`总成本: ${total}`,
|
`总成本: ${total}`,
|
||||||
recipe.value.note ? `备注: ${recipe.value.note}` : '',
|
displayRecipe.value.note ? `备注: ${displayRecipe.value.note}` : '',
|
||||||
].filter(Boolean).join('\n')
|
].filter(Boolean).join('\n')
|
||||||
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
@@ -602,9 +641,9 @@ function getCardOilName(name) {
|
|||||||
|
|
||||||
function getCardRecipeName() {
|
function getCardRecipeName() {
|
||||||
if (cardLang.value === 'en') {
|
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 ----
|
// ---- Favorite ----
|
||||||
@@ -657,6 +696,36 @@ const newIngOil = ref('')
|
|||||||
const newIngDrops = ref(1)
|
const newIngDrops = ref(1)
|
||||||
const newTagInput = ref('')
|
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
|
// Volume & dilution
|
||||||
const selectedVolume = ref('single')
|
const selectedVolume = ref('single')
|
||||||
const customVolumeValue = ref(100)
|
const customVolumeValue = ref(100)
|
||||||
@@ -737,6 +806,7 @@ function confirmAddIngredient() {
|
|||||||
}
|
}
|
||||||
editIngredients.value.push({ oil: newIngOil.value, drops: newIngDrops.value })
|
editIngredients.value.push({ oil: newIngOil.value, drops: newIngDrops.value })
|
||||||
newIngOil.value = ''
|
newIngOil.value = ''
|
||||||
|
oilSearchQuery.value = ''
|
||||||
newIngDrops.value = 1
|
newIngDrops.value = 1
|
||||||
showAddRow.value = false
|
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() {
|
function previewFromEditor() {
|
||||||
// Temporarily update recipe view with editor data, switch to card
|
// Capture current editor state and show it in card view
|
||||||
// We just switch to card mode; the card shows the saved recipe
|
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'
|
viewMode.value = 'card'
|
||||||
cardImageUrl.value = null
|
cardImageUrl.value = null
|
||||||
nextTick(() => generateCardImage())
|
nextTick(() => generateCardImage())
|
||||||
@@ -860,7 +965,11 @@ async function saveRecipe() {
|
|||||||
// Reload recipes so the data is fresh when re-opened
|
// Reload recipes so the data is fresh when re-opened
|
||||||
await recipesStore.loadRecipes()
|
await recipesStore.loadRecipes()
|
||||||
ui.showToast('保存成功')
|
ui.showToast('保存成功')
|
||||||
emit('close')
|
// Go to card view instead of closing
|
||||||
|
previewOverride.value = null
|
||||||
|
viewMode.value = 'card'
|
||||||
|
cardImageUrl.value = null
|
||||||
|
nextTick(() => generateCardImage())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
|
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
|
||||||
}
|
}
|
||||||
@@ -1683,6 +1792,70 @@ async function saveRecipe() {
|
|||||||
cursor: not-allowed;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
|
|||||||
@@ -69,18 +69,20 @@ export const useOilsStore = defineStore('oils', () => {
|
|||||||
dropCount: oil.drop_count,
|
dropCount: oil.drop_count,
|
||||||
retailPrice: oil.retail_price ?? null,
|
retailPrice: oil.retail_price ?? null,
|
||||||
isActive: oil.is_active ?? true,
|
isActive: oil.is_active ?? true,
|
||||||
|
enName: oil.en_name ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
oils.value = newOils
|
oils.value = newOils
|
||||||
oilsMeta.value = newMeta
|
oilsMeta.value = newMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveOil(name, bottlePrice, dropCount, retailPrice) {
|
async function saveOil(name, bottlePrice, dropCount, retailPrice, enName = null) {
|
||||||
await api.post('/api/oils', {
|
await api.post('/api/oils', {
|
||||||
name,
|
name,
|
||||||
bottle_price: bottlePrice,
|
bottle_price: bottlePrice,
|
||||||
drop_count: dropCount,
|
drop_count: dropCount,
|
||||||
retail_price: retailPrice,
|
retail_price: retailPrice,
|
||||||
|
en_name: enName,
|
||||||
})
|
})
|
||||||
await loadOils()
|
await loadOils()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
<h3 class="section-title">添加精油</h3>
|
<h3 class="section-title">添加精油</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input v-model="newOilName" class="form-input" placeholder="精油名称" />
|
<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="瓶价 ¥" />
|
<input v-model.number="newBottlePrice" class="form-input-sm" type="number" placeholder="瓶价 ¥" />
|
||||||
<select v-model="newVolume" class="form-select">
|
<select v-model="newVolume" class="form-select">
|
||||||
<option value="2.5">2.5ml (46滴)</option>
|
<option value="2.5">2.5ml (46滴)</option>
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
v-for="name in filteredOilNames"
|
v-for="name in filteredOilNames"
|
||||||
:key="name"
|
:key="name"
|
||||||
class="oil-chip"
|
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)"
|
@click="openOilDetail(name)"
|
||||||
>
|
>
|
||||||
<span v-if="getOilCard(name)" class="oil-badge" title="有知识卡片">📖</span>
|
<span v-if="getOilCard(name)" class="oil-badge" title="有知识卡片">📖</span>
|
||||||
@@ -159,7 +160,7 @@
|
|||||||
<div class="oil-chip-volume" v-if="getMeta(name)?.dropCount">
|
<div class="oil-chip-volume" v-if="getMeta(name)?.dropCount">
|
||||||
{{ volumeLabel(getMeta(name).dropCount) }}
|
{{ volumeLabel(getMeta(name).dropCount) }}
|
||||||
</div>
|
</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="editOil(name)" title="编辑">✏️</button>
|
||||||
<button class="btn-icon-sm" @click="removeOil(name)" title="删除">🗑️</button>
|
<button class="btn-icon-sm" @click="removeOil(name)" title="删除">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,6 +272,10 @@
|
|||||||
<button class="btn-close" @click="editingOilName = null">✕</button>
|
<button class="btn-close" @click="editingOilName = null">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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">
|
<div class="form-group">
|
||||||
<label>瓶价 (¥)</label>
|
<label>瓶价 (¥)</label>
|
||||||
<input v-model.number="editBottlePrice" class="form-input" type="number" />
|
<input v-model.number="editBottlePrice" class="form-input" type="number" />
|
||||||
@@ -334,6 +339,7 @@ const activeCard = ref(null)
|
|||||||
|
|
||||||
// Add oil form
|
// Add oil form
|
||||||
const newOilName = ref('')
|
const newOilName = ref('')
|
||||||
|
const newOilEnName = ref('')
|
||||||
const newBottlePrice = ref(null)
|
const newBottlePrice = ref(null)
|
||||||
const newVolume = ref('5')
|
const newVolume = ref('5')
|
||||||
const newCustomDrops = ref(null)
|
const newCustomDrops = ref(null)
|
||||||
@@ -345,6 +351,14 @@ const editBottlePrice = ref(0)
|
|||||||
const editVolume = ref('5')
|
const editVolume = ref('5')
|
||||||
const editDropCount = ref(0)
|
const editDropCount = ref(0)
|
||||||
const editRetailPrice = ref(null)
|
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
|
// Volume-to-drops mapping
|
||||||
const VOLUME_OPTIONS = {
|
const VOLUME_OPTIONS = {
|
||||||
@@ -407,9 +421,13 @@ function getMeta(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEnglishName(name) {
|
function getEnglishName(name) {
|
||||||
// First check the oil card for English name
|
// 1. Oil card has priority
|
||||||
const card = getOilCard(name)
|
const card = getOilCard(name)
|
||||||
if (card && card.en) return card.en
|
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)
|
return oilEn(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,10 +484,12 @@ async function addOil() {
|
|||||||
newOilName.value.trim(),
|
newOilName.value.trim(),
|
||||||
newBottlePrice.value || 0,
|
newBottlePrice.value || 0,
|
||||||
dropCount,
|
dropCount,
|
||||||
newRetailPrice.value || null
|
newRetailPrice.value || null,
|
||||||
|
newOilEnName.value.trim() || null
|
||||||
)
|
)
|
||||||
ui.showToast(`已添加: ${newOilName.value}`)
|
ui.showToast(`已添加: ${newOilName.value}`)
|
||||||
newOilName.value = ''
|
newOilName.value = ''
|
||||||
|
newOilEnName.value = ''
|
||||||
newBottlePrice.value = null
|
newBottlePrice.value = null
|
||||||
newVolume.value = '5'
|
newVolume.value = '5'
|
||||||
newCustomDrops.value = null
|
newCustomDrops.value = null
|
||||||
@@ -487,6 +507,7 @@ function editOil(name) {
|
|||||||
editVolume.value = dropCountToVolume(dc)
|
editVolume.value = dropCountToVolume(dc)
|
||||||
editDropCount.value = dc
|
editDropCount.value = dc
|
||||||
editRetailPrice.value = meta?.retailPrice || null
|
editRetailPrice.value = meta?.retailPrice || null
|
||||||
|
editOilEnName.value = meta?.enName || getEnglishName(name) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEditOil() {
|
async function saveEditOil() {
|
||||||
@@ -496,7 +517,8 @@ async function saveEditOil() {
|
|||||||
editingOilName.value,
|
editingOilName.value,
|
||||||
editBottlePrice.value,
|
editBottlePrice.value,
|
||||||
dropCount,
|
dropCount,
|
||||||
editRetailPrice.value
|
editRetailPrice.value,
|
||||||
|
editOilEnName.value.trim() || null
|
||||||
)
|
)
|
||||||
ui.showToast('已更新')
|
ui.showToast('已更新')
|
||||||
editingOilName.value = null
|
editingOilName.value = null
|
||||||
@@ -982,7 +1004,8 @@ function saveContraImage() {
|
|||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oil-chip:hover .oil-actions {
|
.oil-chip:hover .oil-actions,
|
||||||
|
.oil-chip--active .oil-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user