feat: 配方卡片加入上传个人二维码功能 #5
@@ -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()]
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user