Compare commits

..

2 Commits

Author SHA1 Message Date
2dca4d13b9 fix: 导出 Excel 合并「单价」为一列,值形如 ¥X.XX/ml|g|颗|滴
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 13s
Test / e2e-test (push) Successful in 3m0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:37:57 +00:00
a28ba1ef57 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>
2026-04-14 22:31:15 +00:00
3 changed files with 55 additions and 58 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

@@ -267,9 +267,8 @@ async function exportExcel(mode) {
const unit = oils.unitLabel(i.oil)
return `${i.oil} ${i.drops}${unit}`
}).join('、')
const vol = volumeLabel(r)
ws.addRow([
vol ? `${r.name}${vol}` : r.name,
r.name,
(r.tags || []).join('/'),
ingredientStr,
calcMaxTimes(r),
@@ -288,9 +287,8 @@ async function exportExcel(mode) {
for (const r of ka.recipes) {
const price = getSellingPrice(r._id)
const margin = calcMargin(r.kitCost, price)
const vol = volumeLabel(r)
ws.addRow([
vol ? `${r.name}${vol}` : r.name,
r.name,
calcMaxTimes(r),
Number(r.kitCost.toFixed(2)),
Number(r.originalCost.toFixed(2)),
@@ -328,8 +326,7 @@ async function exportExcel(mode) {
for (const row of crossComparison.value) {
const price = getSellingPrice(row.id)
const vol = volumeLabel(row)
const vals = [vol ? `${row.name}${vol}` : row.name]
const vals = [row.name]
if (mode === 'full') vals.push((row.tags || []).join('/'))
for (const ka of kitAnalysis.value) {
const cost = row.costs[ka.id]

View File

@@ -99,10 +99,10 @@
</div>
<!-- Desktop: text buttons -->
<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 -->
<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>
<!-- Add Oil Form (toggleable) -->
@@ -891,66 +891,38 @@ async function removeOil(name) {
}
}
// PDF Export
function exportPDF() {
// Excel Export
async function exportExcel() {
const XLSX = (await import('xlsx')).default || await import('xlsx')
const today = new Date()
const dateStr = today.getFullYear() + String(today.getMonth()+1).padStart(2,'0') + String(today.getDate()).padStart(2,'0')
const title = '精油价目表' + dateStr
const dateStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0')
const sortedNames = [...oils.oilNames].sort((a, b) => a.localeCompare(b, 'zh'))
let rows = ''
const rows = []
for (const name of sortedNames) {
const meta = getMeta(name)
if (!meta) continue
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 unit = oilPriceUnit(name)
const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) + '/' + unit : '--'
rows += `<tr>
<td>${name}</td>
<td>${en}</td>
<td>${bp}</td>
<td>${rp}</td>
<td>${vol}</td>
<td>${ppd}</td>
</tr>`
const ppdNum = oils.pricePerDrop(name)
rows.push({
'精油': name,
'英文名': en,
'会员价': meta.bottlePrice != null ? Number(meta.bottlePrice.toFixed(2)) : '',
'零售价': meta.retailPrice != null ? Number(meta.retailPrice.toFixed(2)) : '',
'容量': vol,
'单价': ppdNum ? `¥${ppdNum.toFixed(2)}/${unit}` : '',
'状态': meta.isActive === false ? '下架' : '在售',
})
}
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body { font-family: 'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; padding: 20px; font-size: 11px; color: #333; }
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)
const ws = XLSX.utils.json_to_sheet(rows)
ws['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 16 }, { wch: 8 }]
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '精油价目表')
XLSX.writeFile(wb, `精油价目表${dateStr}.xlsx`)
ui.showToast('导出成功')
}
// ──── Save image logic (identical to RecipeDetailOverlay) ────