@hbdlzy/ui-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @hbdlzy/ui-core
2
+
3
+ `@hbdlzy/ui-core` 负责沉淀跨项目复用的基础 UI 组件和无业务依赖的前端工具。
4
+
5
+ ## 当前内容
6
+
7
+ - `BaseExportButton`
8
+ - `exportExcel`
9
+
10
+ ## 使用方式
11
+
12
+ ```ts
13
+ import { BaseExportButton, exportExcel } from '@hbdlzy/ui-core'
14
+ ```
15
+
16
+ ## 设计原则
17
+
18
+ - 只做基础 UI 和交互
19
+ - 不直接依赖接口、路由、store
20
+ - 每个组件必须配套 README 和 AI manifest
@@ -0,0 +1,50 @@
1
+ {
2
+ "package": "@hbdlzy/ui-core",
3
+ "version": 1,
4
+ "updatedAt": "2026-04-21",
5
+ "components": [
6
+ {
7
+ "name": "BaseExportButton",
8
+ "category": "ui-core",
9
+ "entry": "@hbdlzy/ui-core",
10
+ "importName": "BaseExportButton",
11
+ "docs": "packages/ui-core/src/components/BaseExportButton/README.md",
12
+ "description": "统一处理前端 Excel 导出和后�?blob 下载的基础导出按钮�?,
13
+ "props": [
14
+ {
15
+ "name": "label",
16
+ "type": "string",
17
+ "default": "导出"
18
+ },
19
+ {
20
+ "name": "fileName",
21
+ "type": "string",
22
+ "default": ""
23
+ },
24
+ {
25
+ "name": "excelOptions",
26
+ "type": "ExcelExportOptions",
27
+ "required": false
28
+ },
29
+ {
30
+ "name": "requestHandler",
31
+ "type": "() => Promise<ExportResponseLike>",
32
+ "required": false
33
+ },
34
+ {
35
+ "name": "beforeExport",
36
+ "type": "() => boolean | Promise<boolean>",
37
+ "required": false
38
+ }
39
+ ],
40
+ "emits": ["start", "success", "error"]
41
+ }
42
+ ],
43
+ "utilities": [
44
+ {
45
+ "name": "exportExcel",
46
+ "entry": "@hbdlzy/ui-core",
47
+ "description": "支持多级表头和自动列宽的纯前�?Excel 导出工具�?
48
+ }
49
+ ]
50
+ }
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});var c=require("vue"),g=require("element-plus"),w=require("xlsx"),O=require("file-saver");function D(e){if(e&&e.__esModule)return e;var r=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});return e&&Object.keys(e).forEach(function(t){if(t!=="default"){var n=Object.getOwnPropertyDescriptor(e,t);Object.defineProperty(r,t,n.get?n:{enumerable:!0,get:function(){return e[t]}})}}),r.default=e,Object.freeze(r)}var x=D(w);const S="Sheet1",A=16;function h(e){var r;return(r=e.children)!=null&&r.length?e.children.reduce((t,n)=>t+h(n),0):1}function y(e,r=1){return e.reduce((t,n)=>{var o;return(o=n.children)!=null&&o.length?Math.max(t,y(n.children,r+1)):Math.max(t,r)},r)}function b(e){const r=`${e!=null?e:""}`;return Array.from(r).reduce((t,n)=>t+(n.charCodeAt(0)>255?2:1),0)}function _(e,r){return r?r.split(".").reduce((t,n)=>{if(t!=null)return t[n]},e):""}function N(e,r,t){return typeof t.formatter=="function"?t.formatter(e,r,t):_(e,t.key)}function q(e){const r=y(e),t=e.reduce((u,s)=>u+h(s),0),n=Array.from({length:r},()=>Array.from({length:t}).fill("")),o=[],l=[];let f=0;const d=(u,s)=>{var p;const a=f,i=h(u);if(n[s][a]=u.label,(p=u.children)!=null&&p.length){u.children.forEach(m=>d(m,s+1)),i>1&&o.push({s:{r:s,c:a},e:{r:s,c:a+i-1}});return}l.push(u),f+=1,s<r-1&&o.push({s:{r:s,c:a},e:{r:r-1,c:a}})};return e.forEach(u=>d(u,0)),{headerRows:n,leafColumns:l,merges:o}}function H(e,r,t,n){return e.map((o,l)=>{var s;const f=b(o.label),d=r.reduce((a,i)=>Math.max(a,b(i[l])),0);return{wch:(s=o.width)!=null?s:n?Math.max(t,f,d):t}})}function k(e){return e.endsWith(".xlsx")?e:`${e}.xlsx`}function F(e){const{fileName:r,sheetName:t=S,data:n,autoWidth:o=!0,defaultColumnWidth:l=A}=e,f=e.columns.filter(m=>!!m);if(!f.length)throw new Error("Excel export columns can not be empty.");const{headerRows:d,leafColumns:u,merges:s}=q(f),a=n.map((m,B)=>u.map(M=>N(m,B,M))),i=x.utils.aoa_to_sheet([...d,...a]);i["!merges"]=s,i["!cols"]=H(u,a,l,o);const p=x.utils.book_new();x.utils.book_append_sheet(p,i,t),x.writeFile(p,k(r))}const L="export-file";function z(e,r){var o;if(!e)return"";const t=r.toLowerCase(),n=Object.keys(e).find(l=>l.toLowerCase()===t);return n&&(o=e[n])!=null?o:""}function C(e){if(!e)return"";const r=e.match(/filename\*=UTF-8''([^;]+)/i);if(r!=null&&r[1])return decodeURIComponent(r[1].replace(/["']/g,""));const t=e.match(/filename="?([^";]+)"?/i);return t!=null&&t[1]?decodeURIComponent(t[1].replace(/["']/g,"")):""}function E(e){return`${e!=null?e:""}`.trim()||L}function T(e){return e instanceof Blob?e:new Blob([e])}function v(e,r=""){const t=z(e.headers,"content-disposition"),n=E(C(t)||r);return O.saveAs(T(e.data),n),n}const j=c.defineComponent({__name:"BaseExportButton",props:{label:{type:String,default:"\u5BFC\u51FA"},type:{type:String,default:"primary"},size:{type:String,default:"default"},plain:{type:Boolean,default:!1},link:{type:Boolean,default:!1},text:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},fileName:{type:String,default:""},excelOptions:Object,requestHandler:Function,beforeExport:Function,successMessage:{type:String,default:"\u5BFC\u51FA\u6210\u529F"},errorMessage:{type:String,default:"\u5BFC\u51FA\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"},autoMessage:{type:Boolean,default:!0}},emits:["start","success","error"],setup(e,{emit:r}){const t=e,n=r,o=c.ref(!1),l=c.computed(()=>t.excelOptions&&t.requestHandler?"invalid":t.excelOptions?"excel":t.requestHandler?"request":"none"),f=c.computed(()=>t.disabled||o.value||l.value==="none");async function d(){return t.beforeExport?await t.beforeExport()!==!1:!0}async function u(){if(o.value||t.disabled)return;if(l.value==="none"){g.ElMessage.warning("\u8BF7\u5148\u914D\u7F6E\u5BFC\u51FA\u903B\u8F91");return}if(l.value==="invalid"){const a=new Error("BaseExportButton \u4E0D\u80FD\u540C\u65F6\u63A5\u6536 excelOptions \u548C requestHandler");n("error",a),g.ElMessage.error(a.message);return}if(!!await d()){o.value=!0,n("start");try{if(l.value==="excel"&&t.excelOptions){F(t.excelOptions),t.autoMessage&&g.ElMessage.success(t.successMessage),n("success",{mode:"excel",fileName:E(t.excelOptions.fileName)});return}if(l.value==="request"&&t.requestHandler){const a=await t.requestHandler(),i=v(a,t.fileName);t.autoMessage&&g.ElMessage.success(t.successMessage),n("success",{mode:"request",fileName:i})}}catch(a){console.error(a),t.autoMessage&&g.ElMessage.error(t.errorMessage),n("error",a)}finally{o.value=!1}}}return(s,a)=>{const i=c.resolveComponent("el-button");return c.openBlock(),c.createBlock(i,{class:"base-export-button",type:e.type,size:e.size,plain:e.plain,link:e.link,text:e.text,disabled:f.value,loading:o.value,onClick:u},{default:c.withCtx(()=>[c.renderSlot(s.$slots,"default",{},()=>[c.createTextVNode(c.toDisplayString(e.label),1)])]),_:3},8,["type","size","plain","link","text","disabled","loading"])}}});exports.BaseExportButton=j;exports.downloadExportResponse=v;exports.exportExcel=F;exports.extractFileNameFromDisposition=C;exports.normalizeExportFileName=E;
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ import { defineComponent, ref, computed, resolveComponent, openBlock, createBlock, withCtx, renderSlot, createTextVNode, toDisplayString } from "vue";
2
+ import { ElMessage } from "element-plus";
3
+ import * as XLSX from "xlsx";
4
+ import { saveAs } from "file-saver";
5
+ const DEFAULT_SHEET_NAME = "Sheet1";
6
+ const DEFAULT_COLUMN_WIDTH = 16;
7
+ function getLeafCount(column) {
8
+ var _a;
9
+ if (!((_a = column.children) == null ? void 0 : _a.length)) {
10
+ return 1;
11
+ }
12
+ return column.children.reduce((count, child) => count + getLeafCount(child), 0);
13
+ }
14
+ function getMaxDepth(columns, depth = 1) {
15
+ return columns.reduce((maxDepth, column) => {
16
+ var _a;
17
+ if (!((_a = column.children) == null ? void 0 : _a.length)) {
18
+ return Math.max(maxDepth, depth);
19
+ }
20
+ return Math.max(maxDepth, getMaxDepth(column.children, depth + 1));
21
+ }, depth);
22
+ }
23
+ function getDisplayLength(value) {
24
+ const text = `${value != null ? value : ""}`;
25
+ return Array.from(text).reduce((length, char) => {
26
+ return length + (char.charCodeAt(0) > 255 ? 2 : 1);
27
+ }, 0);
28
+ }
29
+ function getValueByPath(source, path) {
30
+ if (!path) {
31
+ return "";
32
+ }
33
+ return path.split(".").reduce((current, key) => {
34
+ if (current === null || current === void 0) {
35
+ return void 0;
36
+ }
37
+ return current[key];
38
+ }, source);
39
+ }
40
+ function getCellValue(row, rowIndex, column) {
41
+ if (typeof column.formatter === "function") {
42
+ return column.formatter(row, rowIndex, column);
43
+ }
44
+ return getValueByPath(row, column.key);
45
+ }
46
+ function buildHeaderMatrix(columns) {
47
+ const maxDepth = getMaxDepth(columns);
48
+ const totalLeafCount = columns.reduce((count, column) => count + getLeafCount(column), 0);
49
+ const headerRows = Array.from({ length: maxDepth }, () => Array.from({ length: totalLeafCount }).fill(""));
50
+ const merges = [];
51
+ const leafColumns = [];
52
+ let currentColumnIndex = 0;
53
+ const fillHeaders = (column, depth) => {
54
+ var _a;
55
+ const startColumnIndex = currentColumnIndex;
56
+ const leafCount = getLeafCount(column);
57
+ headerRows[depth][startColumnIndex] = column.label;
58
+ if ((_a = column.children) == null ? void 0 : _a.length) {
59
+ column.children.forEach((child) => fillHeaders(child, depth + 1));
60
+ if (leafCount > 1) {
61
+ merges.push({
62
+ s: { r: depth, c: startColumnIndex },
63
+ e: { r: depth, c: startColumnIndex + leafCount - 1 }
64
+ });
65
+ }
66
+ return;
67
+ }
68
+ leafColumns.push(column);
69
+ currentColumnIndex += 1;
70
+ if (depth < maxDepth - 1) {
71
+ merges.push({
72
+ s: { r: depth, c: startColumnIndex },
73
+ e: { r: maxDepth - 1, c: startColumnIndex }
74
+ });
75
+ }
76
+ };
77
+ columns.forEach((column) => fillHeaders(column, 0));
78
+ return {
79
+ headerRows,
80
+ leafColumns,
81
+ merges
82
+ };
83
+ }
84
+ function buildColumnWidths(leafColumns, dataRows, defaultColumnWidth, autoWidth) {
85
+ return leafColumns.map((column, columnIndex) => {
86
+ var _a;
87
+ const headerWidth = getDisplayLength(column.label);
88
+ const dataWidth = dataRows.reduce((maxWidth, row) => {
89
+ return Math.max(maxWidth, getDisplayLength(row[columnIndex]));
90
+ }, 0);
91
+ const targetWidth = (_a = column.width) != null ? _a : autoWidth ? Math.max(defaultColumnWidth, headerWidth, dataWidth) : defaultColumnWidth;
92
+ return { wch: targetWidth };
93
+ });
94
+ }
95
+ function normalizeFileName(fileName) {
96
+ return fileName.endsWith(".xlsx") ? fileName : `${fileName}.xlsx`;
97
+ }
98
+ function exportExcel(options) {
99
+ const {
100
+ fileName,
101
+ sheetName = DEFAULT_SHEET_NAME,
102
+ data,
103
+ autoWidth = true,
104
+ defaultColumnWidth = DEFAULT_COLUMN_WIDTH
105
+ } = options;
106
+ const columns = options.columns.filter((column) => !!column);
107
+ if (!columns.length) {
108
+ throw new Error("Excel export columns can not be empty.");
109
+ }
110
+ const { headerRows, leafColumns, merges } = buildHeaderMatrix(columns);
111
+ const dataRows = data.map((row, rowIndex) => leafColumns.map((column) => getCellValue(row, rowIndex, column)));
112
+ const worksheet = XLSX.utils.aoa_to_sheet([...headerRows, ...dataRows]);
113
+ worksheet["!merges"] = merges;
114
+ worksheet["!cols"] = buildColumnWidths(leafColumns, dataRows, defaultColumnWidth, autoWidth);
115
+ const workbook = XLSX.utils.book_new();
116
+ XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
117
+ XLSX.writeFile(workbook, normalizeFileName(fileName));
118
+ }
119
+ const DEFAULT_EXPORT_FILE_NAME = "export-file";
120
+ function getHeaderValue(headers, headerName) {
121
+ var _a;
122
+ if (!headers) {
123
+ return "";
124
+ }
125
+ const targetHeaderName = headerName.toLowerCase();
126
+ const matchedHeaderKey = Object.keys(headers).find((key) => key.toLowerCase() === targetHeaderName);
127
+ return matchedHeaderKey ? (_a = headers[matchedHeaderKey]) != null ? _a : "" : "";
128
+ }
129
+ function extractFileNameFromDisposition(contentDisposition) {
130
+ if (!contentDisposition) {
131
+ return "";
132
+ }
133
+ const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
134
+ if (utf8Match == null ? void 0 : utf8Match[1]) {
135
+ return decodeURIComponent(utf8Match[1].replace(/["']/g, ""));
136
+ }
137
+ const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
138
+ if (basicMatch == null ? void 0 : basicMatch[1]) {
139
+ return decodeURIComponent(basicMatch[1].replace(/["']/g, ""));
140
+ }
141
+ return "";
142
+ }
143
+ function normalizeExportFileName(fileName) {
144
+ const normalizedFileName = `${fileName != null ? fileName : ""}`.trim();
145
+ return normalizedFileName || DEFAULT_EXPORT_FILE_NAME;
146
+ }
147
+ function toBlob(payload) {
148
+ return payload instanceof Blob ? payload : new Blob([payload]);
149
+ }
150
+ function downloadExportResponse(response, fallbackFileName = "") {
151
+ const contentDisposition = getHeaderValue(response.headers, "content-disposition");
152
+ const fileName = normalizeExportFileName(extractFileNameFromDisposition(contentDisposition) || fallbackFileName);
153
+ saveAs(toBlob(response.data), fileName);
154
+ return fileName;
155
+ }
156
+ const _sfc_main = defineComponent({
157
+ __name: "BaseExportButton",
158
+ props: {
159
+ label: {
160
+ type: String,
161
+ default: "\u5BFC\u51FA"
162
+ },
163
+ type: {
164
+ type: String,
165
+ default: "primary"
166
+ },
167
+ size: {
168
+ type: String,
169
+ default: "default"
170
+ },
171
+ plain: {
172
+ type: Boolean,
173
+ default: false
174
+ },
175
+ link: {
176
+ type: Boolean,
177
+ default: false
178
+ },
179
+ text: {
180
+ type: Boolean,
181
+ default: false
182
+ },
183
+ disabled: {
184
+ type: Boolean,
185
+ default: false
186
+ },
187
+ fileName: {
188
+ type: String,
189
+ default: ""
190
+ },
191
+ excelOptions: Object,
192
+ requestHandler: Function,
193
+ beforeExport: Function,
194
+ successMessage: {
195
+ type: String,
196
+ default: "\u5BFC\u51FA\u6210\u529F"
197
+ },
198
+ errorMessage: {
199
+ type: String,
200
+ default: "\u5BFC\u51FA\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
201
+ },
202
+ autoMessage: {
203
+ type: Boolean,
204
+ default: true
205
+ }
206
+ },
207
+ emits: ["start", "success", "error"],
208
+ setup(__props, { emit: __emit }) {
209
+ const props = __props;
210
+ const emit = __emit;
211
+ const loading = ref(false);
212
+ const exportMode = computed(() => {
213
+ if (props.excelOptions && props.requestHandler) {
214
+ return "invalid";
215
+ }
216
+ if (props.excelOptions) {
217
+ return "excel";
218
+ }
219
+ if (props.requestHandler) {
220
+ return "request";
221
+ }
222
+ return "none";
223
+ });
224
+ const isDisabled = computed(() => props.disabled || loading.value || exportMode.value === "none");
225
+ async function runBeforeExport() {
226
+ if (!props.beforeExport) {
227
+ return true;
228
+ }
229
+ const result = await props.beforeExport();
230
+ return result !== false;
231
+ }
232
+ async function handleExport() {
233
+ if (loading.value || props.disabled) {
234
+ return;
235
+ }
236
+ if (exportMode.value === "none") {
237
+ ElMessage.warning("\u8BF7\u5148\u914D\u7F6E\u5BFC\u51FA\u903B\u8F91");
238
+ return;
239
+ }
240
+ if (exportMode.value === "invalid") {
241
+ const conflictError = new Error("BaseExportButton \u4E0D\u80FD\u540C\u65F6\u63A5\u6536 excelOptions \u548C requestHandler");
242
+ emit("error", conflictError);
243
+ ElMessage.error(conflictError.message);
244
+ return;
245
+ }
246
+ const canContinue = await runBeforeExport();
247
+ if (!canContinue) {
248
+ return;
249
+ }
250
+ loading.value = true;
251
+ emit("start");
252
+ try {
253
+ if (exportMode.value === "excel" && props.excelOptions) {
254
+ exportExcel(props.excelOptions);
255
+ if (props.autoMessage) {
256
+ ElMessage.success(props.successMessage);
257
+ }
258
+ emit("success", {
259
+ mode: "excel",
260
+ fileName: normalizeExportFileName(props.excelOptions.fileName)
261
+ });
262
+ return;
263
+ }
264
+ if (exportMode.value === "request" && props.requestHandler) {
265
+ const response = await props.requestHandler();
266
+ const fileName = downloadExportResponse(response, props.fileName);
267
+ if (props.autoMessage) {
268
+ ElMessage.success(props.successMessage);
269
+ }
270
+ emit("success", {
271
+ mode: "request",
272
+ fileName
273
+ });
274
+ }
275
+ } catch (error) {
276
+ console.error(error);
277
+ if (props.autoMessage) {
278
+ ElMessage.error(props.errorMessage);
279
+ }
280
+ emit("error", error);
281
+ } finally {
282
+ loading.value = false;
283
+ }
284
+ }
285
+ return (_ctx, _cache) => {
286
+ const _component_el_button = resolveComponent("el-button");
287
+ return openBlock(), createBlock(_component_el_button, {
288
+ class: "base-export-button",
289
+ type: __props.type,
290
+ size: __props.size,
291
+ plain: __props.plain,
292
+ link: __props.link,
293
+ text: __props.text,
294
+ disabled: isDisabled.value,
295
+ loading: loading.value,
296
+ onClick: handleExport
297
+ }, {
298
+ default: withCtx(() => [
299
+ renderSlot(_ctx.$slots, "default", {}, () => [
300
+ createTextVNode(toDisplayString(__props.label), 1)
301
+ ])
302
+ ]),
303
+ _: 3
304
+ }, 8, ["type", "size", "plain", "link", "text", "disabled", "loading"]);
305
+ };
306
+ }
307
+ });
308
+ export { _sfc_main as BaseExportButton, downloadExportResponse, exportExcel, extractFileNameFromDisposition, normalizeExportFileName };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@hbdlzy/ui-core",
3
+ "version": "0.1.0",
4
+ "description": "Company shared Vue UI core package.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/hbdlzy/componentized-library.git",
9
+ "directory": "packages/ui-core"
10
+ },
11
+ "main": "dist/index.cjs",
12
+ "module": "dist/index.js",
13
+ "types": "src/index.ts",
14
+ "exports": {
15
+ ".": {
16
+ "import": "./dist/index.js",
17
+ "require": "./dist/index.cjs",
18
+ "types": "./src/index.ts"
19
+ },
20
+ "./components.manifest.json": "./components.manifest.json"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "README.md",
26
+ "components.manifest.json"
27
+ ],
28
+ "peerDependencies": {
29
+ "element-plus": "^2.13.7",
30
+ "vue": "^3.5.14"
31
+ },
32
+ "dependencies": {
33
+ "file-saver": "2.0.5",
34
+ "xlsx": "^0.18.5"
35
+ },
36
+ "sideEffects": [
37
+ "**/*.css",
38
+ "**/*.vue"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org/"
43
+ },
44
+ "scripts": {
45
+ "build": "vite build"
46
+ }
47
+ }
@@ -0,0 +1,29 @@
1
+ import type { ExcelExportOptions } from '../../excel/exportExcel'
2
+
3
+ export interface ExportResponseHeaders {
4
+ [key: string]: string | undefined
5
+ }
6
+
7
+ export interface ExportResponseLike {
8
+ data: Blob | BlobPart | ArrayBuffer
9
+ headers?: ExportResponseHeaders
10
+ }
11
+
12
+ export type ExportRequestHandler = () => Promise<ExportResponseLike>
13
+
14
+ export type ExportMode = 'excel' | 'request' | 'none' | 'invalid'
15
+
16
+ export type ExportBeforeHandler = () => boolean | void | Promise<boolean | void>
17
+
18
+ export interface ExportSuccessPayload {
19
+ mode: Exclude<ExportMode, 'none' | 'invalid'>
20
+ fileName?: string
21
+ }
22
+
23
+ export interface BaseExportExcelConfig<Row = Record<string, any>> {
24
+ excelOptions: ExcelExportOptions<Row>
25
+ }
26
+
27
+ export interface BaseExportRequestConfig {
28
+ requestHandler: ExportRequestHandler
29
+ }
@@ -0,0 +1,54 @@
1
+ import { saveAs } from 'file-saver'
2
+ import type { ExportResponseHeaders, ExportResponseLike } from './BaseExportButton.types'
3
+
4
+ const DEFAULT_EXPORT_FILE_NAME = 'export-file'
5
+
6
+ function getHeaderValue(headers: ExportResponseHeaders | undefined, headerName: string) {
7
+ if (!headers) {
8
+ return ''
9
+ }
10
+
11
+ const targetHeaderName = headerName.toLowerCase()
12
+ const matchedHeaderKey = Object.keys(headers).find(key => key.toLowerCase() === targetHeaderName)
13
+
14
+ return matchedHeaderKey ? headers[matchedHeaderKey] ?? '' : ''
15
+ }
16
+
17
+ export function extractFileNameFromDisposition(contentDisposition?: string) {
18
+ if (!contentDisposition) {
19
+ return ''
20
+ }
21
+
22
+ const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
23
+
24
+ if (utf8Match?.[1]) {
25
+ return decodeURIComponent(utf8Match[1].replace(/["']/g, ''))
26
+ }
27
+
28
+ const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
29
+
30
+ if (basicMatch?.[1]) {
31
+ return decodeURIComponent(basicMatch[1].replace(/["']/g, ''))
32
+ }
33
+
34
+ return ''
35
+ }
36
+
37
+ export function normalizeExportFileName(fileName?: string) {
38
+ const normalizedFileName = `${fileName ?? ''}`.trim()
39
+
40
+ return normalizedFileName || DEFAULT_EXPORT_FILE_NAME
41
+ }
42
+
43
+ function toBlob(payload: ExportResponseLike['data']) {
44
+ return payload instanceof Blob ? payload : new Blob([payload])
45
+ }
46
+
47
+ export function downloadExportResponse(response: ExportResponseLike, fallbackFileName = '') {
48
+ const contentDisposition = getHeaderValue(response.headers, 'content-disposition')
49
+ const fileName = normalizeExportFileName(extractFileNameFromDisposition(contentDisposition) || fallbackFileName)
50
+
51
+ saveAs(toBlob(response.data), fileName)
52
+
53
+ return fileName
54
+ }
@@ -0,0 +1,182 @@
1
+ <template>
2
+ <el-button
3
+ class="base-export-button"
4
+ :type="type"
5
+ :size="size"
6
+ :plain="plain"
7
+ :link="link"
8
+ :text="text"
9
+ :disabled="isDisabled"
10
+ :loading="loading"
11
+ @click="handleExport"
12
+ >
13
+ <slot>{{ label }}</slot>
14
+ </el-button>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { computed, ref } from 'vue'
19
+ import type { PropType } from 'vue'
20
+ import { ElMessage } from 'element-plus'
21
+ import { exportExcel, type ExcelExportOptions } from '../../excel/exportExcel'
22
+ import { downloadExportResponse, normalizeExportFileName } from './BaseExportButton.utils'
23
+ import type {
24
+ ExportBeforeHandler,
25
+ ExportMode,
26
+ ExportRequestHandler,
27
+ ExportSuccessPayload
28
+ } from './BaseExportButton.types'
29
+
30
+ const props = defineProps({
31
+ label: {
32
+ type: String,
33
+ default: '导出'
34
+ },
35
+ type: {
36
+ type: String as PropType<'primary' | 'success' | 'warning' | 'danger' | 'info' | ''>,
37
+ default: 'primary'
38
+ },
39
+ size: {
40
+ type: String as PropType<'large' | 'default' | 'small'>,
41
+ default: 'default'
42
+ },
43
+ plain: {
44
+ type: Boolean,
45
+ default: false
46
+ },
47
+ link: {
48
+ type: Boolean,
49
+ default: false
50
+ },
51
+ text: {
52
+ type: Boolean,
53
+ default: false
54
+ },
55
+ disabled: {
56
+ type: Boolean,
57
+ default: false
58
+ },
59
+ fileName: {
60
+ type: String,
61
+ default: ''
62
+ },
63
+ excelOptions: Object as PropType<ExcelExportOptions<Record<string, any>>>,
64
+ requestHandler: Function as PropType<ExportRequestHandler>,
65
+ beforeExport: Function as PropType<ExportBeforeHandler>,
66
+ successMessage: {
67
+ type: String,
68
+ default: '导出成功'
69
+ },
70
+ errorMessage: {
71
+ type: String,
72
+ default: '导出失败,请稍后重试'
73
+ },
74
+ autoMessage: {
75
+ type: Boolean,
76
+ default: true
77
+ }
78
+ })
79
+
80
+ const emit = defineEmits<{
81
+ (event: 'start'): void
82
+ (event: 'success', payload: ExportSuccessPayload): void
83
+ (event: 'error', error: unknown): void
84
+ }>()
85
+
86
+ const loading = ref(false)
87
+
88
+ const exportMode = computed<ExportMode>(() => {
89
+ if (props.excelOptions && props.requestHandler) {
90
+ return 'invalid'
91
+ }
92
+
93
+ if (props.excelOptions) {
94
+ return 'excel'
95
+ }
96
+
97
+ if (props.requestHandler) {
98
+ return 'request'
99
+ }
100
+
101
+ return 'none'
102
+ })
103
+
104
+ const isDisabled = computed(() => props.disabled || loading.value || exportMode.value === 'none')
105
+
106
+ async function runBeforeExport() {
107
+ if (!props.beforeExport) {
108
+ return true
109
+ }
110
+
111
+ const result = await props.beforeExport()
112
+ return result !== false
113
+ }
114
+
115
+ async function handleExport() {
116
+ if (loading.value || props.disabled) {
117
+ return
118
+ }
119
+
120
+ if (exportMode.value === 'none') {
121
+ ElMessage.warning('请先配置导出逻辑')
122
+ return
123
+ }
124
+
125
+ if (exportMode.value === 'invalid') {
126
+ const conflictError = new Error('BaseExportButton 不能同时接收 excelOptions 和 requestHandler')
127
+ emit('error', conflictError)
128
+ ElMessage.error(conflictError.message)
129
+ return
130
+ }
131
+
132
+ const canContinue = await runBeforeExport()
133
+
134
+ if (!canContinue) {
135
+ return
136
+ }
137
+
138
+ loading.value = true
139
+ emit('start')
140
+
141
+ try {
142
+ if (exportMode.value === 'excel' && props.excelOptions) {
143
+ exportExcel(props.excelOptions)
144
+
145
+ if (props.autoMessage) {
146
+ ElMessage.success(props.successMessage)
147
+ }
148
+
149
+ emit('success', {
150
+ mode: 'excel',
151
+ fileName: normalizeExportFileName(props.excelOptions.fileName)
152
+ })
153
+
154
+ return
155
+ }
156
+
157
+ if (exportMode.value === 'request' && props.requestHandler) {
158
+ const response = await props.requestHandler()
159
+ const fileName = downloadExportResponse(response, props.fileName)
160
+
161
+ if (props.autoMessage) {
162
+ ElMessage.success(props.successMessage)
163
+ }
164
+
165
+ emit('success', {
166
+ mode: 'request',
167
+ fileName
168
+ })
169
+ }
170
+ } catch (error) {
171
+ console.error(error)
172
+
173
+ if (props.autoMessage) {
174
+ ElMessage.error(props.errorMessage)
175
+ }
176
+
177
+ emit('error', error)
178
+ } finally {
179
+ loading.value = false
180
+ }
181
+ }
182
+ </script>
@@ -0,0 +1,25 @@
1
+ # BaseExportButton
2
+
3
+ `BaseExportButton` 是一个基础导出按钮组件,用来统一项目里的导出入口。
4
+
5
+ 它支持两类场景:
6
+
7
+ - 纯前端 Excel 导出
8
+ - 调接口返回 blob 文件下载
9
+
10
+ ## 使用方式
11
+
12
+ ```vue
13
+ <script setup lang="ts">
14
+ import { BaseExportButton } from '@hbdlzy/ui-core'
15
+ </script>
16
+
17
+ <template>
18
+ <BaseExportButton :excel-options="excelOptions" />
19
+ </template>
20
+ ```
21
+
22
+ ## 约束
23
+
24
+ - `excelOptions` 和 `requestHandler` 二选一
25
+ - 组件只负责触发导出,不负责业务数据查询
@@ -0,0 +1,20 @@
1
+ import BaseExportButton from './BaseExportButton.vue'
2
+
3
+ export { BaseExportButton }
4
+ export {
5
+ downloadExportResponse,
6
+ extractFileNameFromDisposition,
7
+ normalizeExportFileName
8
+ } from './BaseExportButton.utils'
9
+ export type {
10
+ BaseExportExcelConfig,
11
+ BaseExportRequestConfig,
12
+ ExportBeforeHandler,
13
+ ExportMode,
14
+ ExportRequestHandler,
15
+ ExportResponseHeaders,
16
+ ExportResponseLike,
17
+ ExportSuccessPayload
18
+ } from './BaseExportButton.types'
19
+
20
+ export default BaseExportButton
@@ -0,0 +1,170 @@
1
+ import * as XLSX from 'xlsx'
2
+
3
+ export type ExcelCellValue = string | number | boolean | null | undefined | Date
4
+
5
+ export interface ExcelExportColumn<Row = Record<string, any>> {
6
+ label: string
7
+ key?: string
8
+ width?: number
9
+ formatter?: (row: Row, rowIndex: number, column: ExcelExportColumn<Row>) => ExcelCellValue
10
+ children?: Array<ExcelExportColumn<Row>>
11
+ }
12
+
13
+ export interface ExcelExportOptions<Row = Record<string, any>> {
14
+ fileName: string
15
+ sheetName?: string
16
+ columns: Array<ExcelExportColumn<Row>>
17
+ data: Array<Row>
18
+ autoWidth?: boolean
19
+ defaultColumnWidth?: number
20
+ }
21
+
22
+ const DEFAULT_SHEET_NAME = 'Sheet1'
23
+ const DEFAULT_COLUMN_WIDTH = 16
24
+
25
+ function getLeafCount<Row>(column: ExcelExportColumn<Row>): number {
26
+ if (!column.children?.length) {
27
+ return 1
28
+ }
29
+
30
+ return column.children.reduce((count, child) => count + getLeafCount(child), 0)
31
+ }
32
+
33
+ function getMaxDepth<Row>(columns: Array<ExcelExportColumn<Row>>, depth = 1): number {
34
+ return columns.reduce((maxDepth, column) => {
35
+ if (!column.children?.length) {
36
+ return Math.max(maxDepth, depth)
37
+ }
38
+
39
+ return Math.max(maxDepth, getMaxDepth(column.children, depth + 1))
40
+ }, depth)
41
+ }
42
+
43
+ function getDisplayLength(value: ExcelCellValue): number {
44
+ const text = `${value ?? ''}`
45
+
46
+ return Array.from(text).reduce((length, char) => {
47
+ return length + (char.charCodeAt(0) > 255 ? 2 : 1)
48
+ }, 0)
49
+ }
50
+
51
+ function getValueByPath(source: Record<string, any>, path?: string): ExcelCellValue {
52
+ if (!path) {
53
+ return ''
54
+ }
55
+
56
+ return path.split('.').reduce<any>((current, key) => {
57
+ if (current === null || current === undefined) {
58
+ return undefined
59
+ }
60
+
61
+ return current[key]
62
+ }, source)
63
+ }
64
+
65
+ function getCellValue<Row>(row: Row, rowIndex: number, column: ExcelExportColumn<Row>): ExcelCellValue {
66
+ if (typeof column.formatter === 'function') {
67
+ return column.formatter(row, rowIndex, column)
68
+ }
69
+
70
+ return getValueByPath(row as Record<string, any>, column.key)
71
+ }
72
+
73
+ function buildHeaderMatrix<Row>(columns: Array<ExcelExportColumn<Row>>) {
74
+ const maxDepth = getMaxDepth(columns)
75
+ const totalLeafCount = columns.reduce((count, column) => count + getLeafCount(column), 0)
76
+ const headerRows = Array.from({ length: maxDepth }, () => Array.from({ length: totalLeafCount }).fill(''))
77
+ const merges: XLSX.Range[] = []
78
+ const leafColumns: Array<ExcelExportColumn<Row>> = []
79
+
80
+ let currentColumnIndex = 0
81
+
82
+ const fillHeaders = (column: ExcelExportColumn<Row>, depth: number) => {
83
+ const startColumnIndex = currentColumnIndex
84
+ const leafCount = getLeafCount(column)
85
+
86
+ headerRows[depth][startColumnIndex] = column.label
87
+
88
+ if (column.children?.length) {
89
+ column.children.forEach(child => fillHeaders(child, depth + 1))
90
+
91
+ if (leafCount > 1) {
92
+ merges.push({
93
+ s: { r: depth, c: startColumnIndex },
94
+ e: { r: depth, c: startColumnIndex + leafCount - 1 }
95
+ })
96
+ }
97
+
98
+ return
99
+ }
100
+
101
+ leafColumns.push(column)
102
+ currentColumnIndex += 1
103
+
104
+ if (depth < maxDepth - 1) {
105
+ merges.push({
106
+ s: { r: depth, c: startColumnIndex },
107
+ e: { r: maxDepth - 1, c: startColumnIndex }
108
+ })
109
+ }
110
+ }
111
+
112
+ columns.forEach(column => fillHeaders(column, 0))
113
+
114
+ return {
115
+ headerRows,
116
+ leafColumns,
117
+ merges
118
+ }
119
+ }
120
+
121
+ function buildColumnWidths<Row>(
122
+ leafColumns: Array<ExcelExportColumn<Row>>,
123
+ dataRows: Array<Array<ExcelCellValue>>,
124
+ defaultColumnWidth: number,
125
+ autoWidth: boolean
126
+ ) {
127
+ return leafColumns.map((column, columnIndex) => {
128
+ const headerWidth = getDisplayLength(column.label)
129
+ const dataWidth = dataRows.reduce((maxWidth, row) => {
130
+ return Math.max(maxWidth, getDisplayLength(row[columnIndex]))
131
+ }, 0)
132
+
133
+ const targetWidth =
134
+ column.width ?? (autoWidth ? Math.max(defaultColumnWidth, headerWidth, dataWidth) : defaultColumnWidth)
135
+
136
+ return { wch: targetWidth }
137
+ })
138
+ }
139
+
140
+ function normalizeFileName(fileName: string) {
141
+ return fileName.endsWith('.xlsx') ? fileName : `${fileName}.xlsx`
142
+ }
143
+
144
+ export function exportExcel<Row = Record<string, any>>(options: ExcelExportOptions<Row>) {
145
+ const {
146
+ fileName,
147
+ sheetName = DEFAULT_SHEET_NAME,
148
+ data,
149
+ autoWidth = true,
150
+ defaultColumnWidth = DEFAULT_COLUMN_WIDTH
151
+ } = options
152
+ const columns = options.columns.filter(column => !!column)
153
+
154
+ if (!columns.length) {
155
+ throw new Error('Excel export columns can not be empty.')
156
+ }
157
+
158
+ const { headerRows, leafColumns, merges } = buildHeaderMatrix(columns)
159
+ const dataRows = data.map((row, rowIndex) => leafColumns.map(column => getCellValue(row, rowIndex, column)))
160
+
161
+ const worksheet = XLSX.utils.aoa_to_sheet([...headerRows, ...dataRows])
162
+ worksheet['!merges'] = merges
163
+ worksheet['!cols'] = buildColumnWidths(leafColumns, dataRows, defaultColumnWidth, autoWidth)
164
+
165
+ const workbook = XLSX.utils.book_new()
166
+ XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
167
+ XLSX.writeFile(workbook, normalizeFileName(fileName))
168
+ }
169
+
170
+ export default exportExcel
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { default as BaseExportButton } from './components/BaseExportButton'
2
+ export * from './components/BaseExportButton'
3
+ export { exportExcel } from './excel/exportExcel'
4
+ export type { ExcelCellValue, ExcelExportColumn, ExcelExportOptions } from './excel/exportExcel'