feat: 精油价目导出改为 Excel(.xlsx)
All checks were successful
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 5s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Successful in 3m2s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s

- 按钮从「导出PDF」改「导出Excel」,动态 import xlsx
- 列:精油/英文名/会员价/零售价/容量/单价/状态
- 文件名:精油价目表YYYY-MM-DD.xlsx
- 新增 e2e(按钮可见 + 点击出 toast)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 22:31:15 +00:00
parent fef28330f0
commit a28ba1ef57
2 changed files with 52 additions and 52 deletions

View File

@@ -0,0 +1,28 @@
describe('Oil Reference Excel Export', () => {
let adminToken
before(() => {
cy.getAdminToken().then(token => { adminToken = token })
})
beforeEach(() => {
cy.visit('/oils', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.get('.oil-chip, .oils-grid', { timeout: 10000 }).should('exist')
})
it('shows the Excel export button for admins', () => {
cy.contains('button', '📥 导出Excel').should('be.visible')
})
it('clicking Excel export shows success toast', () => {
cy.window().then(win => {
cy.stub(win.HTMLAnchorElement.prototype, 'click').returns(undefined)
})
cy.contains('button', '📥 导出Excel').click()
cy.get('.toast', { timeout: 10000 }).should('contain', '导出成功')
})
})

View File

@@ -99,10 +99,10 @@
</div> </div>
<!-- Desktop: text buttons --> <!-- Desktop: text buttons -->
<button v-if="auth.canManage" class="toolbar-btn-text" @click="showAddForm = !showAddForm">{{ showAddForm ? '收起' : ' 新增' }}</button> <button v-if="auth.canManage" class="toolbar-btn-text" @click="showAddForm = !showAddForm">{{ showAddForm ? '收起' : ' 新增' }}</button>
<button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportPDF">📥 导出PDF</button> <button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportExcel">📥 导出Excel</button>
<!-- Mobile: emoji-only buttons --> <!-- Mobile: emoji-only buttons -->
<button v-if="auth.canManage" class="toolbar-btn-icon" @click="showAddForm = !showAddForm" title="新增精油"></button> <button v-if="auth.canManage" class="toolbar-btn-icon" @click="showAddForm = !showAddForm" title="新增精油"></button>
<button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportPDF" title="导出PDF">📄</button> <button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportExcel" title="导出Excel">📄</button>
</div> </div>
<!-- Add Oil Form (toggleable) --> <!-- Add Oil Form (toggleable) -->
@@ -891,66 +891,38 @@ async function removeOil(name) {
} }
} }
// PDF Export // Excel Export
function exportPDF() { async function exportExcel() {
const XLSX = (await import('xlsx')).default || await import('xlsx')
const today = new Date() const today = new Date()
const dateStr = today.getFullYear() + String(today.getMonth()+1).padStart(2,'0') + String(today.getDate()).padStart(2,'0') const dateStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0')
const title = '精油价目表' + dateStr
const sortedNames = [...oils.oilNames].sort((a, b) => a.localeCompare(b, 'zh')) const sortedNames = [...oils.oilNames].sort((a, b) => a.localeCompare(b, 'zh'))
let rows = '' const rows = []
for (const name of sortedNames) { for (const name of sortedNames) {
const meta = getMeta(name) const meta = getMeta(name)
if (!meta) continue if (!meta) continue
const en = getEnglishName(name) 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, name) const vol = volumeLabel(meta.dropCount, name)
const unit = oilPriceUnit(name) const unit = oilPriceUnit(name)
const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) + '/' + unit : '--' const ppdNum = oils.pricePerDrop(name)
rows += `<tr> rows.push({
<td>${name}</td> '精油': name,
<td>${en}</td> '英文名': en,
<td>${bp}</td> '会员价': meta.bottlePrice != null ? Number(meta.bottlePrice.toFixed(2)) : '',
<td>${rp}</td> '零售价': meta.retailPrice != null ? Number(meta.retailPrice.toFixed(2)) : '',
<td>${vol}</td> '容量': vol,
<td>${ppd}</td> [`单价(¥/${unit})`]: ppdNum ? Number(ppdNum.toFixed(2)) : '',
</tr>` '状态': meta.isActive === false ? '下架' : '在售',
})
} }
const html = `<!DOCTYPE html>
<html> const ws = XLSX.utils.json_to_sheet(rows)
<head> ws['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 14 }, { wch: 8 }]
<meta charset="utf-8"> const wb = XLSX.utils.book_new()
<title>${title}</title> XLSX.utils.book_append_sheet(wb, ws, '精油价目表')
<style> XLSX.writeFile(wb, `精油价目表${dateStr}.xlsx`)
body { font-family: 'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; padding: 20px; font-size: 11px; color: #333; } ui.showToast('导出成功')
h1 { font-size: 18px; text-align: center; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; }
th { background: #7a9e7e; color: white; padding: 6px 8px; text-align: center; font-size: 11px; font-weight: 600; }
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; text-align: center; font-size: 11px; }
td:first-child, th:first-child { text-align: left; font-weight: 500; }
td:nth-child(2), th:nth-child(2) { text-align: left; }
tr:nth-child(even) { background: #f9f9f9; }
tr:hover { background: #e8f5e9; }
@media print { body { padding: 10px; } h1 { font-size: 16px; } }
</style>
</head>
<body>
<h1>doTERRA 精油价目表 ${dateStr}</h1>
<table>
<thead>
<tr><th>精油</th><th>英文名</th><th>会员价</th><th>零售价</th><th>容量</th><th>单价</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p style="text-align:center;font-size:10px;color:#aaa;margin-top:12px">共 ${sortedNames.length} 种精油 · doTERRA 配方计算器导出</p>
</body>
</html>`
const w = window.open('', '_blank')
w.document.write(html)
w.document.close()
w.document.title = title
setTimeout(() => w.print(), 500)
} }
// ──── Save image logic (identical to RecipeDetailOverlay) ──── // ──── Save image logic (identical to RecipeDetailOverlay) ────