diff --git a/docs/excel导出.md b/docs/excel导出.md new file mode 100644 index 0000000..e97b812 --- /dev/null +++ b/docs/excel导出.md @@ -0,0 +1,102 @@ +要真·生成 Excel(.xlsx)并避免中文乱码,最简单稳妥的做法是用 **SheetJS (xlsx)**。xlsx 本身就是 Unicode,不需要再加 BOM,中文不会乱码。 + +### 1) 安装依赖 + +```bash +npm i xlsx +``` + +### 2) 在你的工具函数里引入并替换 `exportToExcel` + +把你原来的 `exportToExcel`(写 `application/vnd.ms-excel` 的 HTML 方案)换成下面这个基于 xlsx 的实现——它会按 `headers` 的顺序与标题导出为真正的 `.xlsx` 文件,并自动设置列宽、表头冻结、筛选,同时做了“公式注入”防护。 + +```js +// 顶部添加 +import { utils as XLSXUtils, writeFile as XLSXWriteFile } from 'xlsx' + +// 计算列宽(中文按双字节处理) +function calcWch(val) { + const s = (val ?? '').toString() + let w = 0 + for (const ch of s) w += ch.charCodeAt(0) > 255 ? 2 : 1 + return Math.min(Math.max(w, 8), 60) // 8~60 字符宽 +} + +// 简单日期格式化(如果字段是 Date 或 ISO 字符串) +function fmtDate(v) { + const d = v instanceof Date ? v : (typeof v === 'string' && !isNaN(Date.parse(v)) ? new Date(v) : null) + if (!d) return v + const pad = n => (n < 10 ? '0' + n : '' + n) + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +// 真实 .xlsx 导出(中文不会乱码) +export function exportToExcel(data, headers, filename = '导出.xlsx', sheetName = 'Sheet1') { + // 1) 规范化数据:按 headers 顺序映射到“显示列名 -> 值” + const headerLabels = headers.map(h => h.label) + const rows = (data || []).map(row => { + const obj = {} + headers.forEach(h => { + let v = row?.[h.key] + // 识别并格式化日期 + v = fmtDate(v) + + // 复杂对象转字符串 + if (v && typeof v === 'object' && !(v instanceof Date)) { + try { v = JSON.stringify(v) } catch { v = String(v) } + } + + // 公式注入防护(以 = + - @ 开头的文本加前导单引号) + if (typeof v === 'string' && /^[=+\-@]/.test(v)) v = "'" + v + + // null/undefined 转为空串,保留 0/false + if (v === null || v === undefined) v = '' + obj[h.label] = v + }) + return obj + }) + + // 2) 生成工作表 + const ws = XLSXUtils.json_to_sheet(rows, { header: headerLabels }) + + // 3) 列宽:根据表头和数据计算 + const cols = headers.map(h => { + const headW = calcWch(h.label) + const dataW = rows.reduce((mx, r) => Math.max(mx, calcWch(r[h.label])), 0) + return { wch: Math.max(headW, dataW) } + }) + ws['!cols'] = cols + + // 4) 冻结首行、开启筛选 + if (ws['!ref']) { + ws['!freeze'] = { xSplit: 0, ySplit: 1 } + ws['!autofilter'] = { ref: ws['!ref'] } + } + + // 5) 生成工作簿并下载 + const wb = XLSXUtils.book_new() + XLSXUtils.book_append_sheet(wb, ws, sheetName) + const safeName = filename.endsWith('.xlsx') ? filename : `${filename}.xlsx` + XLSXWriteFile(wb, safeName) +} +``` + +### 3) 用法保持不变 + +```js +const headers = [ + { label: '链接编号', key: 'codeNo' }, + { label: '状态', key: 'status' }, + { label: '到期时间', key: 'expiredAt' }, + { label: '备注', key: 'remark' }, +] + +exportToExcel(listData, headers, '链接列表.xlsx') +``` + +> 说明 +> +> * 现在导出的是 **真正的 .xlsx 文件**,Excel/Numbers/WPS 打开都不会中文乱码。 +> * 不再需要 BOM、小心 Excel 旧版 HTML 方案的各种兼容问题。 +> * 已对以 `= + - @` 开头的字符串做了前缀 `'` 处理,避免“公式注入”。 +> * 如需多表导出,可多次 `book_append_sheet` 追加不同工作表。 diff --git a/package-lock.json b/package-lock.json index 911f95d..8b73f8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "axios": "^1.7.7", "element-plus": "^2.8.8", "vue": "^3.4.38", - "vue-router": "^4.4.5" + "vue-router": "^4.4.5", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^5.1.4", @@ -1088,6 +1089,15 @@ "node": ">=0.4.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/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1131,6 +1141,28 @@ "node": ">= 0.4" } }, + "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/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/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1150,6 +1182,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1355,6 +1399,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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1669,6 +1722,18 @@ "source-map": "^0.6.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/terser": { "version": "5.43.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", @@ -1783,6 +1848,45 @@ "peerDependencies": { "vue": "^3.2.0" } + }, + "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/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" + } } } } diff --git a/package.json b/package.json index d7b9bcb..49e3fda 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "axios": "^1.7.7", "element-plus": "^2.8.8", "vue": "^3.4.38", - "vue-router": "^4.4.5" + "vue-router": "^4.4.5", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^5.1.4", diff --git a/src/api/points.js b/src/api/points.js index 6f619e9..c574bac 100644 --- a/src/api/points.js +++ b/src/api/points.js @@ -6,7 +6,7 @@ import http from '@/plugins/http' */ export async function getPointsBalance() { try { - const response = await http.get('/admin/accounts/me/points-balance') + const response = await http.get('/api/admin/accounts/me/points-balance') return response.data } catch (error) { console.error('获取积分余额失败:', error) diff --git a/src/config/links.js b/src/config/links.js index 7e16e79..098fc6e 100644 --- a/src/config/links.js +++ b/src/config/links.js @@ -3,7 +3,7 @@ // 链接地址生成规则 export const LINK_CONFIG = { // 基础域名 - 生产环境需要修改为实际域名 - BASE_URL: import.meta.env.VITE_BASE_URL || window.location.origin, + BASE_URL: import.meta.env.VITE_BASE_URL || 'http://localhost:5173', // 游戏页面路径 GAME_PATH: '/play', diff --git a/src/utils/links.js b/src/utils/links.js index c61579f..d5ef8c9 100644 --- a/src/utils/links.js +++ b/src/utils/links.js @@ -71,66 +71,77 @@ export async function copyToClipboard(text) { } } -// 导出CSV数据 -export function exportToCSV(data, headers, filename) { - const csvContent = [ - headers.map(h => h.label).join(','), - ...data.map(row => - headers.map(header => { - const value = row[header.key] || '' - // 处理包含逗号的值 - return value.toString().includes(',') ? `"${value}"` : value - }).join(',') - ) - ].join('\n') - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) - const link = document.createElement('a') - link.href = URL.createObjectURL(blob) - link.download = filename - link.click() +// 计算列宽(中文按双字节处理) +function calcWch(val) { + const s = (val ?? '').toString() + let w = 0 + for (const ch of s) w += ch.charCodeAt(0) > 255 ? 2 : 1 + return Math.min(Math.max(w, 8), 60) // 8~60 字符宽 } -// 导出Excel数据 -export function exportToExcel(data, headers, filename) { - // 创建HTML表格格式 - const tableHTML = ` - -
- - - - -| ${header.label} | `).join('')} -
|---|
| ${row[header.key] || ''} | `).join('')} -