Compare commits
9 Commits
feat/kit-c
...
feat/oil-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e11270fbf | |||
| cccf0091ba | |||
| 5ebffb8da4 | |||
| 3953218e41 | |||
| c7d86b909a | |||
| 06b29e6446 | |||
| 3043d4d6c4 | |||
| 9ba0f6e9b5 | |||
| 8a49938929 |
@@ -12,7 +12,7 @@ jobs:
|
||||
e2e-test:
|
||||
runs-on: test
|
||||
needs: unit-test
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -62,23 +62,37 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run all specs except demo-walkthrough (too slow for CI)
|
||||
# Run all specs in 3 batches to avoid Electron memory crashes
|
||||
cd frontend
|
||||
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=$?
|
||||
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" \
|
||||
--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,cypress/e2e/demo-walkthrough.cy.js" \
|
||||
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
|
||||
B3=$?
|
||||
|
||||
# Cleanup
|
||||
kill $BE_PID $FE_PID 2>/dev/null
|
||||
pkill -f "Cypress" 2>/dev/null || true
|
||||
rm -f "$DB_FILE"
|
||||
if [ $EXIT_CODE -eq 124 ]; then
|
||||
echo "ERROR: Cypress timed out after 7 minutes"
|
||||
|
||||
echo "Results: Batch1=$B1 Batch2=$B2 Batch3=$B3"
|
||||
if [ $B1 -ne 0 ] || [ $B2 -ne 0 ] || [ $B3 -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
|
||||
build-check:
|
||||
runs-on: test
|
||||
|
||||
@@ -5,82 +5,26 @@ describe('doTERRA 精油配方计算器 - 功能演示', () => {
|
||||
cy.getAdminToken().then(token => { adminToken = token })
|
||||
})
|
||||
|
||||
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.wait(500)
|
||||
|
||||
// ===== 点击配方卡片 =====
|
||||
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.wait(1000)
|
||||
|
||||
// ===== 精油页面 =====
|
||||
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.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)
|
||||
}
|
||||
})
|
||||
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('薰衣草')
|
||||
cy.get('input[placeholder*="搜索"]').clear()
|
||||
cy.get('.recipe-card').should('have.length.gte', 1)
|
||||
})
|
||||
|
||||
it('页面导航', { defaultCommandTimeout: 10000 }, () => {
|
||||
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||
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()
|
||||
})
|
||||
|
||||
it('管理页面可访问', { defaultCommandTimeout: 10000 }, () => {
|
||||
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.contains('用户管理', { timeout: 10000 }).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,57 +1,44 @@
|
||||
describe('Visual Check - Screenshots', () => {
|
||||
describe('Visual Check', () => {
|
||||
let adminToken
|
||||
|
||||
before(() => {
|
||||
cy.getAdminToken().then(token => { adminToken = token })
|
||||
})
|
||||
|
||||
it('homepage with recipes', () => {
|
||||
it('homepage loads 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('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', () => {
|
||||
it('oil reference loads with chips', () => {
|
||||
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', () => {
|
||||
it('manage recipes page loads', () => {
|
||||
cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||
cy.wait(2000)
|
||||
cy.screenshot('04-manage-recipes')
|
||||
cy.get('.recipe-manager', { timeout: 10000 }).should('exist')
|
||||
})
|
||||
|
||||
it('inventory page', () => {
|
||||
it('inventory page loads', () => {
|
||||
cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||
cy.wait(1500)
|
||||
cy.screenshot('05-inventory')
|
||||
cy.get('.inventory-page', { timeout: 10000 }).should('exist')
|
||||
})
|
||||
|
||||
it('check if recipe cards show price > 0', () => {
|
||||
it('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) {
|
||||
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')
|
||||
expect(parseFloat(priceMatch[1])).to.be.gt(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -499,3 +499,46 @@ 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('颗')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -829,7 +829,7 @@ onMounted(() => {
|
||||
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) }
|
||||
|
||||
loadBrand()
|
||||
nextTick(() => generateCardImage())
|
||||
// Don't auto-generate card image on mount — generate on demand when saving
|
||||
})
|
||||
|
||||
function addIngredient() {
|
||||
|
||||
@@ -181,9 +181,16 @@
|
||||
|
||||
<!-- Oil Knowledge Card Modal -->
|
||||
<div v-if="activeCard && activeCardName" class="modal-overlay" @mousedown.self="closeOilModal">
|
||||
<div class="oil-card-modal">
|
||||
<div class="oil-card-header">
|
||||
<div class="oil-card-header-content">
|
||||
<div class="oil-card-modal" style="position:relative;overflow:hidden">
|
||||
<!-- Brand background -->
|
||||
<div v-if="brand.brand_bg" style="position:absolute;inset:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0.08;pointer-events:none;z-index:0" :style="{ backgroundImage: `url('${brand.brand_bg}')` }"></div>
|
||||
<!-- QR code -->
|
||||
<div v-if="brand.qr_code" style="position:absolute;top:16px;right:16px;display:flex;flex-direction:column;gap:3px;z-index:3" :style="{ alignItems: (brand.brand_align === 'left' ? 'flex-start' : brand.brand_align === 'right' ? 'flex-end' : 'center') }">
|
||||
<img :src="brand.qr_code" crossorigin="anonymous" style="width:48px;height:48px;object-fit:cover;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,0.1)" />
|
||||
<div v-if="brand.brand_name" style="font-size:7px;color:rgba(255,255,255,0.8);line-height:1.3;max-width:60px;white-space:pre-line;text-align:center">{{ brand.brand_name }}</div>
|
||||
</div>
|
||||
<div class="oil-card-header" style="position:relative;z-index:1">
|
||||
<div class="oil-card-header-content" :style="brand.qr_code ? 'padding-right:70px' : ''">
|
||||
<span class="oil-card-emoji">{{ activeCard.emoji }}</span>
|
||||
<div>
|
||||
<h2 class="oil-card-title">{{ activeCardName }}</h2>
|
||||
@@ -196,7 +203,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-close btn-close-light" @click="closeOilModal">✕</button>
|
||||
<button class="btn-close btn-close-light" @click="closeOilModal" style="z-index:4">✕</button>
|
||||
</div>
|
||||
<!-- Method badges -->
|
||||
<div class="oil-card-methods">
|
||||
@@ -227,6 +234,10 @@
|
||||
<h4 class="oil-card-caution-title">⚠️ 注意事项</h4>
|
||||
<p>{{ activeCard.caution }}</p>
|
||||
</div>
|
||||
<!-- Logo -->
|
||||
<div v-if="brand.brand_logo" style="padding-top:8px">
|
||||
<img :src="brand.brand_logo" crossorigin="anonymous" style="height:24px;opacity:0.7" />
|
||||
</div>
|
||||
<div style="text-align:center;padding-top:12px">
|
||||
<button class="btn btn-outline btn-sm" @click="saveCardImage(activeCardName)">💾 保存图片</button>
|
||||
</div>
|
||||
@@ -408,12 +419,19 @@ import { useRecipesStore } from '../stores/recipes'
|
||||
import { oilEn } from '../composables/useOilTranslation'
|
||||
import { getOilCard, setOilCard } from '../composables/useOilCards'
|
||||
import { showConfirm } from '../composables/useDialog'
|
||||
import { api } from '../composables/useApi'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
const recipeStore = useRecipesStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
// Brand data for card
|
||||
const brand = ref({})
|
||||
async function loadBrand() {
|
||||
try { brand.value = await api.get('/api/brand') } catch {}
|
||||
}
|
||||
|
||||
// Modal states
|
||||
const showDilution = ref(false)
|
||||
const showContra = ref(false)
|
||||
@@ -637,12 +655,9 @@ async function openOilDetail(name) {
|
||||
activeCardName.value = name
|
||||
activeCard.value = card
|
||||
selectedOilName.value = null
|
||||
// Pre-generate card image for instant save
|
||||
loadBrand()
|
||||
// Generate image on demand when saving, not on open
|
||||
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
|
||||
@@ -825,8 +840,9 @@ function exportPDF() {
|
||||
const en = getEnglishName(name)
|
||||
const bp = meta.bottlePrice != null ? '¥' + meta.bottlePrice.toFixed(0) : '--'
|
||||
const rp = meta.retailPrice != null ? '¥' + meta.retailPrice.toFixed(0) : '--'
|
||||
const vol = volumeLabel(meta.dropCount)
|
||||
const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) : '--'
|
||||
const vol = volumeLabel(meta.dropCount, name)
|
||||
const unit = oilPriceUnit(name)
|
||||
const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) + '/' + unit : '--'
|
||||
rows += `<tr>
|
||||
<td>${name}</td>
|
||||
<td>${en}</td>
|
||||
@@ -858,7 +874,7 @@ function exportPDF() {
|
||||
<h1>doTERRA 精油价目表 ${dateStr}</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>精油</th><th>英文名</th><th>会员价</th><th>零售价</th><th>容量</th><th>单价/滴</th></tr>
|
||||
<tr><th>精油</th><th>英文名</th><th>会员价</th><th>零售价</th><th>容量</th><th>单价</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user