Compare commits

..

3 Commits

Author SHA1 Message Date
3d642d35f4 fix: 导入api模块,修复品牌数据加载失败
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Successful in 3m5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:46:58 +00:00
00f6815652 fix: 导出PDF修复 — volumeLabel缺name参数,单价列适配单位
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 7s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Successful in 3m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:43:35 +00:00
553850a369 feat: 精油知识卡片加品牌元素 — QR码、背景图、Logo、品牌名
Some checks failed
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 4m37s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 14s
复用配方卡片的品牌逻辑:
- 右上角显示QR码+品牌名
- 背景图半透明覆盖
- 底部显示Logo
- 保存图片时包含品牌元素

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:36:32 +00:00
9 changed files with 148 additions and 262 deletions

View File

@@ -12,7 +12,7 @@ jobs:
e2e-test:
runs-on: test
needs: unit-test
timeout-minutes: 15
timeout-minutes: 8
steps:
- uses: actions/checkout@v4
@@ -62,37 +62,23 @@ jobs:
exit 1
fi
# Run all specs in 3 batches to avoid Electron memory crashes
# Run all specs except demo-walkthrough (too slow for CI)
cd frontend
CYPRESS_CFG="video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT,experimentalMemoryManagement=true,numTestsKeptInMemory=0"
echo "=== Batch 1: API & data tests ==="
timeout 300 npx cypress run \
--spec "cypress/e2e/api-crud.cy.js,cypress/e2e/api-health.cy.js,cypress/e2e/oil-data-integrity.cy.js,cypress/e2e/recipe-cost-parity.cy.js,cypress/e2e/endpoint-parity.cy.js,cypress/e2e/registration-flow.cy.js,cypress/e2e/pr27-features.cy.js,cypress/e2e/kit-export.cy.js" \
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
B1=$?
echo "=== Batch 2: UI flow tests ==="
timeout 300 npx cypress run \
--spec "cypress/e2e/auth-flow.cy.js,cypress/e2e/admin-flow.cy.js,cypress/e2e/navigation.cy.js,cypress/e2e/recipe-detail.cy.js,cypress/e2e/recipe-search.cy.js,cypress/e2e/manage-recipes.cy.js,cypress/e2e/diary-flow.cy.js,cypress/e2e/favorites.cy.js,cypress/e2e/inventory-flow.cy.js,cypress/e2e/demo-walkthrough.cy.js" \
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
B2=$?
echo "=== Batch 3: Remaining tests ==="
timeout 300 npx cypress run \
--spec "cypress/e2e/app-load.cy.js,cypress/e2e/account-settings.cy.js,cypress/e2e/audit-log-advanced.cy.js,cypress/e2e/batch-operations.cy.js,cypress/e2e/bug-tracker-flow.cy.js,cypress/e2e/category-modules.cy.js,cypress/e2e/notification-flow.cy.js,cypress/e2e/oil-reference.cy.js,cypress/e2e/performance.cy.js,cypress/e2e/price-display.cy.js,cypress/e2e/projects-flow.cy.js,cypress/e2e/responsive.cy.js,cypress/e2e/search-advanced.cy.js,cypress/e2e/user-management-flow.cy.js,cypress/e2e/visual-check.cy.js" \
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
B3=$?
timeout 420 npx cypress run \
--spec "cypress/e2e/!(demo-walkthrough).cy.js" \
--config "video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT,experimentalMemoryManagement=true,numTestsKeptInMemory=0" \
--env "ADMIN_TOKEN=$ADMIN_TOKEN"
EXIT_CODE=$?
# Cleanup
kill $BE_PID $FE_PID 2>/dev/null
pkill -f "Cypress" 2>/dev/null || true
rm -f "$DB_FILE"
echo "Results: Batch1=$B1 Batch2=$B2 Batch3=$B3"
if [ $B1 -ne 0 ] || [ $B2 -ne 0 ] || [ $B3 -ne 0 ]; then
if [ $EXIT_CODE -eq 124 ]; then
echo "ERROR: Cypress timed out after 7 minutes"
exit 1
fi
exit $EXIT_CODE
build-check:
runs-on: test

View File

@@ -5,26 +5,82 @@ describe('doTERRA 精油配方计算器 - 功能演示', () => {
cy.getAdminToken().then(token => { adminToken = token })
})
it('首页和搜索', { defaultCommandTimeout: 10000 }, () => {
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.get('input[placeholder*="搜索"]').type('薰衣草')
it('完整功能演示', { defaultCommandTimeout: 20000 }, () => {
// ===== 开场:首页加载 =====
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.get('.app-header').should('be.visible')
cy.wait(1000)
// ===== 配方卡片列表 =====
cy.get('.recipe-card', { timeout: 15000 }).should('have.length.gte', 1)
cy.wait(500)
// ===== 搜索框输入 =====
cy.get('input[placeholder*="搜索"]').should('be.visible').click()
cy.get('input[placeholder*="搜索"]').type('薰衣草', { delay: 100 })
cy.wait(500)
cy.get('input[placeholder*="搜索"]').clear()
cy.get('.recipe-card').should('have.length.gte', 1)
})
cy.wait(500)
it('页面导航', { defaultCommandTimeout: 10000 }, () => {
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
// ===== 点击配方卡片 =====
cy.get('.recipe-card', { timeout: 10000 }).first().click()
cy.wait(1000)
// ===== 查看详情 =====
cy.get('[class*="overlay"], [class*="detail"]', { timeout: 10000 }).should('be.visible')
cy.get('.detail-close-btn').first().click({ force: true })
cy.wait(500)
// ===== 切换精油价目 =====
cy.get('.nav-tab').contains('精油价目').click()
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
cy.get('.nav-tab').contains('管理配方').click()
cy.get('.nav-tab').contains('个人库存').click()
})
cy.wait(1000)
it('管理页面可访问', { defaultCommandTimeout: 10000 }, () => {
cy.visit('/audit', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
// ===== 精油页面 =====
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
// ===== 管理配方 =====
cy.get('.nav-tab').contains('管理配方').click()
cy.wait(1000)
// ===== 个人库存 =====
cy.get('.nav-tab').contains('个人库存').click()
cy.wait(1000)
// ===== Admin pages via direct URL =====
cy.visit('/audit', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.contains('操作日志', { timeout: 10000 }).should('be.visible')
cy.visit('/users', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.wait(500)
cy.visit('/bugs', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.contains('Bug', { timeout: 10000 }).should('be.visible')
cy.wait(500)
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.contains('用户管理', { timeout: 10000 }).should('be.visible')
cy.wait(500)
// ===== 回到首页 =====
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
})
})

View File

@@ -29,14 +29,16 @@ describe('Price Display Regression', () => {
})
})
it('recipe cards show price in correct format', () => {
it('recipe detail shows non-zero total cost', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// Verify multiple cards have prices
cy.get('.recipe-card-price').should('have.length.gte', 1)
cy.get('.recipe-card-price').each($el => {
const text = $el.text()
expect(text).to.match(|💰/)
cy.get('.recipe-card', { timeout: 10000 }).first().click()
cy.wait(1000)
// Look for any ¥ amount > 0 in the detail overlay
cy.get('[class*="overlay"], [class*="detail"]').invoke('text').then(text => {
const prices = [...text.matchAll(\s*(\d+\.?\d*)/g)].map(m => parseFloat(m[1]))
const nonZero = prices.filter(p => p > 0)
expect(nonZero.length, 'Detail should show at least one non-zero price').to.be.gte(1)
})
})
})

View File

@@ -1,44 +1,57 @@
describe('Visual Check', () => {
describe('Visual Check - Screenshots', () => {
let adminToken
before(() => {
cy.getAdminToken().then(token => { adminToken = token })
})
it('homepage loads with recipes', () => {
it('homepage with recipes', () => {
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.wait(1000)
cy.screenshot('01-homepage')
})
it('oil reference loads with chips', () => {
it('recipe detail overlay', () => {
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.recipe-card', { timeout: 10000 }).first().click()
cy.wait(1000)
cy.screenshot('02-recipe-detail')
})
it('oil reference page', () => {
cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
cy.wait(500)
cy.screenshot('03-oil-reference')
})
it('manage recipes page loads', () => {
it('manage recipes page', () => {
cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.recipe-manager', { timeout: 10000 }).should('exist')
cy.wait(2000)
cy.screenshot('04-manage-recipes')
})
it('inventory page loads', () => {
it('inventory page', () => {
cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.inventory-page', { timeout: 10000 }).should('exist')
cy.wait(1500)
cy.screenshot('05-inventory')
})
it('recipe cards show price > 0', () => {
it('check if recipe cards show price > 0', () => {
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// Check if any card shows a non-zero price
cy.get('.recipe-card').first().invoke('text').then(text => {
cy.log('First card text: ' + text)
const priceMatch = text.match(/¥\s*(\d+\.?\d*)/)
if (priceMatch) {
expect(parseFloat(priceMatch[1])).to.be.gt(0)
cy.log('Price found: ¥' + priceMatch[1])
const price = parseFloat(priceMatch[1])
expect(price, 'Recipe card should show price > 0').to.be.gt(0)
} else {
cy.log('WARNING: No price found on recipe card')
}
})
})
it('recipe detail overlay opens', () => {
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
cy.get('.recipe-card', { timeout: 10000 }).first().click()
cy.get('.detail-overlay', { timeout: 10000 }).should('exist')
})
})

View File

@@ -499,149 +499,3 @@ describe('volume field in recipe mapping — PR31', () => {
expect(labels['']).toBe('')
})
})
// ---------------------------------------------------------------------------
// PR33: Oil card branding logic
// ---------------------------------------------------------------------------
describe('oil card branding — PR33', () => {
it('brand data determines card display elements', () => {
const brand = { qr_code: 'data:image/png;base64,abc', brand_bg: 'data:image/png;base64,bg', brand_logo: null, brand_name: '测试品牌', brand_align: 'center' }
expect(!!brand.qr_code).toBe(true)
expect(!!brand.brand_bg).toBe(true)
expect(!!brand.brand_logo).toBe(false)
expect(!!brand.brand_name).toBe(true)
})
it('empty brand shows plain card', () => {
const brand = {}
expect(!!brand.qr_code).toBe(false)
expect(!!brand.brand_bg).toBe(false)
expect(!!brand.brand_logo).toBe(false)
})
it('volumeLabel with name parameter works for drops and ml', () => {
// Simulates the fix: volumeLabel(dropCount, name) needs both params
const DROPS_TO_VOLUME = { 93: '5ml', 280: '15ml' }
function volumeLabel(dropCount, name) {
if (name === '无香乳液') return dropCount + 'ml' // ml unit
return DROPS_TO_VOLUME[dropCount] || (dropCount + '滴')
}
expect(volumeLabel(280, '薰衣草')).toBe('15ml')
expect(volumeLabel(200, '无香乳液')).toBe('200ml')
expect(volumeLabel(93, '茶树')).toBe('5ml')
})
it('PDF export price unit adapts to product type', () => {
function oilPriceUnit(name) {
if (name === '无香乳液') return 'ml'
if (name === '植物空胶囊') return '颗'
return '滴'
}
expect(oilPriceUnit('薰衣草')).toBe('滴')
expect(oilPriceUnit('无香乳液')).toBe('ml')
expect(oilPriceUnit('植物空胶囊')).toBe('颗')
})
})
// ---------------------------------------------------------------------------
// PR34: Product edit UI — unit-based form switching
// ---------------------------------------------------------------------------
describe('product edit UI logic — PR34', () => {
it('drop unit shows standard volume selector', () => {
const unit = 'drop'
expect(unit === 'drop').toBe(true)
})
it('non-drop unit shows amount + unit selector', () => {
for (const u of ['ml', 'g', 'capsule']) {
expect(u !== 'drop').toBe(true)
}
})
it('edit form initializes correct unit from meta', () => {
const meta = { unit: 'g', dropCount: 80 }
const editUnit = meta.unit || 'drop'
const editProductAmount = editUnit !== 'drop' ? meta.dropCount : null
const editProductUnit = editUnit !== 'drop' ? editUnit : 'ml'
expect(editUnit).toBe('g')
expect(editProductAmount).toBe(80)
expect(editProductUnit).toBe('g')
})
it('edit form defaults to drop for oils', () => {
const meta = { unit: 'drop', dropCount: 280 }
const editUnit = meta.unit || 'drop'
expect(editUnit).toBe('drop')
})
it('edit form defaults to drop when unit is undefined', () => {
const meta = { dropCount: 280 }
const editUnit = meta.unit || 'drop'
expect(editUnit).toBe('drop')
})
it('save uses product amount and unit for non-drop', () => {
const editUnit = 'ml'
const editProductAmount = 200
const editProductUnit = 'ml'
const dropCount = 280 // from standard volume selector
const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount
const finalUnit = editUnit !== 'drop' ? editProductUnit : null
expect(finalDropCount).toBe(200)
expect(finalUnit).toBe('ml')
})
it('save uses standard drop count for oils', () => {
const editUnit = 'drop'
const editProductAmount = null
const dropCount = 280
const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount
const finalUnit = editUnit !== 'drop' ? 'ml' : null
expect(finalDropCount).toBe(280)
expect(finalUnit).toBeNull()
})
it('label adapts: 精油名称 for oils, 产品名称 for products', () => {
const labelForDrop = 'drop' === 'drop' ? '精油名称' : '产品名称'
const labelForMl = 'ml' === 'drop' ? '精油名称' : '产品名称'
expect(labelForDrop).toBe('精油名称')
expect(labelForMl).toBe('产品名称')
})
})
// ---------------------------------------------------------------------------
// PR34: Share text and consumption analysis use dynamic unit
// ---------------------------------------------------------------------------
describe('share text and consumption use dynamic unit — PR34', () => {
const UNIT_MAP = { drop: '滴', ml: 'ml', g: 'g', capsule: '颗' }
function unitLabel(name, unitMap) { return UNIT_MAP[unitMap[name] || 'drop'] }
it('share text uses unitLabel for each ingredient', () => {
const units = { '薰衣草': 'drop', '无香乳液': 'ml', '植物空胶囊': 'capsule' }
const ings = [
{ oil: '薰衣草', drops: 3 },
{ oil: '无香乳液', drops: 30 },
{ oil: '植物空胶囊', drops: 2 },
]
const lines = ings.map(i => `${i.oil} ${i.drops}${unitLabel(i.oil, units)}`)
expect(lines[0]).toBe('薰衣草 3滴')
expect(lines[1]).toBe('无香乳液 30ml')
expect(lines[2]).toBe('植物空胶囊 2颗')
})
it('consumption analysis uses unitLabel per oil', () => {
const units = { '薰衣草': 'drop', '活力磨砂膏': 'g' }
const data = [
{ oil: '薰衣草', drops: 15, bottleDrops: 280 },
{ oil: '活力磨砂膏', drops: 30, bottleDrops: 70 },
]
const display = data.map(c => ({
usage: `${c.drops}${unitLabel(c.oil, units)}`,
capacity: `${c.bottleDrops}${unitLabel(c.oil, units)}`,
}))
expect(display[0].usage).toBe('15滴')
expect(display[0].capacity).toBe('280滴')
expect(display[1].usage).toBe('30g')
expect(display[1].capacity).toBe('70g')
})
})

View File

@@ -483,7 +483,7 @@ function copyText() {
const ings = cardIngredients.value
const lines = ings.map(ing => {
const cost = oilsStore.pricePerDrop(ing.oil) * ing.drops
return `${ing.oil} ${ing.drops}${oilsStore.unitLabel(ing.oil)} ${oilsStore.fmtPrice(cost)}`
return `${ing.oil} ${ing.drops} ${oilsStore.fmtPrice(cost)}`
})
const total = priceInfo.value.cost
const text = [
@@ -829,7 +829,7 @@ onMounted(() => {
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) }
loadBrand()
// Don't auto-generate card image on mount — generate on demand when saving
nextTick(() => generateCardImage())
})
function addIngredient() {

View File

@@ -327,15 +327,13 @@
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ editUnit === 'drop' ? '精油名称' : '产品名称' }}</label>
<input v-model="editOilDisplayName" class="form-input" type="text" :placeholder="editUnit === 'drop' ? '精油名称' : '产品名称'" />
<label>精油名称</label>
<input v-model="editOilDisplayName" class="form-input" type="text" placeholder="精油名称" />
</div>
<div class="form-group">
<label>英文名</label>
<input v-model="editOilEnName" class="form-input" type="text" placeholder="English name" />
</div>
<!-- 精油容量 -->
<template v-if="editUnit === 'drop'">
<div class="form-group">
<label>容量</label>
<select v-model="editVolume" class="form-select">
@@ -351,21 +349,6 @@
<label>自定义滴数</label>
<input v-model.number="editDropCount" class="form-input" type="number" />
</div>
</template>
<!-- 其他产品容量 -->
<template v-else>
<div class="form-group">
<label>容量</label>
<div style="display:flex;gap:6px;align-items:center">
<input v-model.number="editProductAmount" class="form-input" type="number" min="1" style="flex:1" />
<select v-model="editProductUnit" class="form-select" style="width:70px">
<option value="ml">ml</option>
<option value="g">g</option>
<option value="capsule"></option>
</select>
</div>
</div>
</template>
<div class="form-group">
<label>会员价 (¥)</label>
<input v-model.number="editBottlePrice" class="form-input" type="number" />
@@ -485,9 +468,6 @@ const editVolume = ref('5')
const editDropCount = ref(0)
const editRetailPrice = ref(null)
const editOilEnName = ref('')
const editUnit = ref('drop')
const editProductAmount = ref(null)
const editProductUnit = ref('ml')
const editCardEmoji = ref('')
const editCardEffects = ref('')
const editCardUsage = ref('')
@@ -676,8 +656,12 @@ async function openOilDetail(name) {
activeCard.value = card
selectedOilName.value = null
loadBrand()
// Generate image on demand when saving, not on open
// Pre-generate card image for instant save
oilCardImageUrl.value = null
await nextTick()
await new Promise(r => setTimeout(r, 300))
const el = document.querySelector('.oil-card-modal')
if (el) await generateImageFromRef({ value: el }, oilCardImageUrl)
} else {
activeCard.value = null
activeCardName.value = null
@@ -747,11 +731,6 @@ function editOil(name) {
editDropCount.value = dc
editRetailPrice.value = meta?.retailPrice || null
editOilEnName.value = meta?.enName || getEnglishName(name) || ''
editUnit.value = meta?.unit || 'drop'
if (editUnit.value !== 'drop') {
editProductAmount.value = dc
editProductUnit.value = editUnit.value
}
// Load knowledge card if exists
const card = getOilCard(name)
editCardEmoji.value = card?.emoji || ''
@@ -777,15 +756,12 @@ async function saveEditOil() {
if (newName && newName !== oldName) {
await oils.deleteOil(oldName)
}
const finalDropCount = editUnit.value !== 'drop' ? editProductAmount.value : dropCount
const finalUnit = editUnit.value !== 'drop' ? editProductUnit.value : null
await oils.saveOil(
newName || oldName,
editBottlePrice.value,
finalDropCount,
dropCount,
editRetailPrice.value,
editOilEnName.value.trim() || null,
finalUnit
editOilEnName.value.trim() || null
)
// Save knowledge card if any content provided
const finalName = newName || oldName

View File

@@ -81,7 +81,7 @@
<tr>
<th>精油</th>
<th>用量</th>
<th>单价</th>
<th>每滴</th>
<th>小计</th>
<th></th>
</tr>
@@ -133,8 +133,8 @@
<tbody>
<tr v-for="c in consumptionData" :key="c.oil" :class="{ 'limit-oil': c.isLimit }">
<td>{{ c.oil }}</td>
<td>{{ c.drops }}{{ oils.unitLabel(c.oil) }}</td>
<td>{{ c.bottleDrops }}{{ oils.unitLabel(c.oil) }}</td>
<td>{{ c.drops }}</td>
<td>{{ c.bottleDrops }}</td>
<td>{{ c.sessions }}</td>
<td></td>
</tr>

View File

@@ -44,7 +44,7 @@
<!-- Action buttons -->
<div class="action-bar">
<button class="action-chip" @click="oils.loadOils(); showAddOverlay = true">新增</button>
<button class="action-chip" @click="showAddOverlay = true">新增</button>
<button class="action-chip" :class="{ active: isAllSelected }" @click="toggleSelectAll">
全选<span v-if="totalSelected > 0" class="chip-count">{{ totalSelected }}</span>
</button>
@@ -808,7 +808,6 @@ function editRecipe(recipe) {
}
formNote.value = recipe.note || ''
formTags.value = [...(recipe.tags || [])]
oils.loadOils()
showAddOverlay.value = true
}