From dedac6901142d0f745e1e957b531388c6b87c9fb Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Fri, 10 Apr 2026 15:25:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E5=87=BAExcel=E5=A4=9Asheet?= =?UTF-8?q?=EF=BC=8C=E6=8C=89=E6=A0=87=E7=AD=BE=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件名:精油配方YYYY-MM-DD.xlsx - 第一个sheet"全部"包含所有配方 - 每个标签一个单独的sheet - 使用SheetJS生成真正的xlsx格式 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package-lock.json | 94 +++++++++++++++++++++++++++- frontend/package.json | 3 +- frontend/src/views/RecipeManager.vue | 43 ++++++++----- 3 files changed, 122 insertions(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0516a38..7f8eb56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,8 @@ "html2canvas": "^1.4.1", "pinia": "^2.3.1", "vue": "^3.5.32", - "vue-router": "^4.6.4" + "vue-router": "^4.6.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.5", @@ -1179,6 +1180,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1637,6 +1647,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1761,6 +1784,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2571,6 +2603,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -4684,6 +4725,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -5490,6 +5543,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5533,6 +5604,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 03f758a..2633374 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,8 @@ "html2canvas": "^1.4.1", "pinia": "^2.3.1", "vue": "^3.5.32", - "vue-router": "^4.6.4" + "vue-router": "^4.6.4", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.5", diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index 3d83aee..4654118 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -1109,25 +1109,36 @@ async function rejectRecipe(recipe) { } } -function exportExcel() { +async function exportExcel() { const recipes = recipeStore.recipes if (!recipes.length) { ui.showToast('没有配方可导出'); return } - // BOM for UTF-8 Excel compatibility - let csv = '\uFEFF配方名称,标签,精油成分,滴数,成本\n' - for (const r of recipes) { - const tags = (r.tags || []).join('/') - const ings = r.ingredients.map(i => i.oil).join('、') - const drops = r.ingredients.map(i => `${i.oil}${i.drops}滴`).join('、') - const cost = oils.fmtPrice(oils.calcCost(r.ingredients)) - csv += `"${r.name}","${tags}","${ings}","${drops}","${cost}"\n` + const XLSX = (await import('xlsx')).default || await import('xlsx') + + function recipesToRows(list) { + return list.map(r => ({ + '配方名称': r.name, + '标签': (r.tags || []).join('/'), + '精油成分': r.ingredients.map(i => `${i.oil}${i.drops}滴`).join('、'), + '成本': oils.fmtPrice(oils.calcCost(r.ingredients)), + '备注': r.note || '', + })) } - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = '配方导出.csv' - a.click() - URL.revokeObjectURL(url) + + const wb = XLSX.utils.book_new() + // Sheet 1: 全部 + XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(recipesToRows(recipes)), '全部') + // Per tag sheets + const allTags = [...new Set(recipes.flatMap(r => r.tags || []))].sort((a, b) => a.localeCompare(b, 'zh')) + for (const tag of allTags) { + const tagged = recipes.filter(r => r.tags && r.tags.includes(tag)) + if (tagged.length) { + const name = tag.substring(0, 31) // Excel sheet name max 31 chars + XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(recipesToRows(tagged)), name) + } + } + + const today = new Date().toISOString().slice(0, 10) + XLSX.writeFile(wb, `精油配方${today}.xlsx`) ui.showToast('导出成功') }