@hb-hellotech/hb-ui 2.2.2 → 2.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hb-hellotech/hb-ui",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/hb_component_lib.umd.cjs",
6
6
  "module": "dist/hb_component_lib.js",
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @desc 触发文件下载。优先拉取 Blob 后在本地触发保存,避免 IE/Edge IE 模式下对跨域直链 `a.click()` 新开页签。
3
+ * @remarks 阿里云 OSS 等跨域地址若未配置带 Cookie 的 CORS,须使用 omit 凭证,否则预检会失败。
4
+ */
5
+
6
+ type NavigatorWithMsSave = Navigator & {
7
+ msSaveOrOpenBlob?: (blob: Blob, defaultName?: string) => boolean;
8
+ };
9
+
10
+ /**
11
+ * 将含中文、空格等非 ASCII 的路径规范为百分号编码,便于 XHR/fetch/iframe 在各浏览器中一致请求。
12
+ * 网关拼接的预览 URL 若含未编码空格,`new URL` 可能失败,会先尝试把空格替换为 %20 再解析。
13
+ */
14
+ export function normalizeHttpUrlForRequest(raw: string): string {
15
+ const trimmed = raw.trim();
16
+ try {
17
+ return new URL(trimmed).href;
18
+ } catch {
19
+ const spaced = trimmed.replace(/ /g, '%20');
20
+ try {
21
+ return new URL(spaced).href;
22
+ } catch {
23
+ return spaced;
24
+ }
25
+ }
26
+ }
27
+
28
+ function shouldSendCredentialsForUrl(url: string): boolean {
29
+ try {
30
+ if (typeof window === 'undefined' || !window.location?.origin)
31
+ return false;
32
+ return new URL(url).origin === window.location.origin;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function getBlobViaXhr(url: string): Promise<Blob> {
39
+ return new Promise((resolve, reject) => {
40
+ const xhr = new XMLHttpRequest();
41
+ xhr.open('GET', url, true);
42
+ xhr.responseType = 'blob';
43
+ xhr.withCredentials = shouldSendCredentialsForUrl(url);
44
+ xhr.onload = () => {
45
+ if (xhr.status >= 200 && xhr.status < 300) {
46
+ resolve(xhr.response as Blob);
47
+ } else {
48
+ reject(new Error(String(xhr.status)));
49
+ }
50
+ };
51
+ xhr.onerror = () => reject(new Error('xhr'));
52
+ xhr.send();
53
+ });
54
+ }
55
+
56
+ async function getBlobFromUrl(url: string): Promise<Blob> {
57
+ const normalized = normalizeHttpUrlForRequest(url);
58
+ const docMode =
59
+ typeof document !== 'undefined'
60
+ ? (document as Document & { documentMode?: number }).documentMode
61
+ : undefined;
62
+ if (typeof docMode === 'number' && docMode > 0) {
63
+ return getBlobViaXhr(normalized);
64
+ }
65
+ if (typeof fetch === 'function') {
66
+ const res = await fetch(normalized, {
67
+ credentials: shouldSendCredentialsForUrl(normalized)
68
+ ? 'include'
69
+ : 'omit',
70
+ mode: 'cors',
71
+ });
72
+ if (!res.ok) throw new Error(String(res.status));
73
+ return res.blob();
74
+ }
75
+ return getBlobViaXhr(normalized);
76
+ }
77
+
78
+ function downloadBlobInBrowser(blob: Blob, filename: string): void {
79
+ const safeName = filename.trim() || 'download';
80
+ const nav = navigator as NavigatorWithMsSave;
81
+ if (typeof nav.msSaveOrOpenBlob === 'function') {
82
+ nav.msSaveOrOpenBlob(blob, safeName);
83
+ return;
84
+ }
85
+ const objectUrl = URL.createObjectURL(blob);
86
+ try {
87
+ const link = document.createElement('a');
88
+ link.href = objectUrl;
89
+ link.rel = 'noopener noreferrer';
90
+ link.setAttribute('download', safeName);
91
+ link.style.display = 'none';
92
+ document.body.appendChild(link);
93
+ link.click();
94
+ document.body.removeChild(link);
95
+ } finally {
96
+ URL.revokeObjectURL(objectUrl);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 依赖服务端 Content-Disposition;不再对远程 URL 使用 `a.click()`,以免 IE 新开页签。
102
+ */
103
+ function openUrlInHiddenIframe(url: string): void {
104
+ const normalized = normalizeHttpUrlForRequest(url);
105
+ const iframe = document.createElement('iframe');
106
+ iframe.style.cssText =
107
+ 'position:fixed;left:-9999px;top:-9999px;width:0;height:0;border:0;';
108
+ iframe.src = normalized;
109
+ document.body.appendChild(iframe);
110
+ window.setTimeout(() => {
111
+ try {
112
+ document.body.removeChild(iframe);
113
+ } catch {
114
+ /* noop */
115
+ }
116
+ }, 120000);
117
+ }
118
+
119
+ export async function triggerBrowserDownload(
120
+ url: string,
121
+ filename: string
122
+ ): Promise<void> {
123
+ const safeName = (filename || 'download').trim() || 'download';
124
+
125
+ try {
126
+ const blob = await getBlobFromUrl(url);
127
+ downloadBlobInBrowser(blob, safeName);
128
+ return;
129
+ } catch {
130
+ openUrlInHiddenIframe(url);
131
+ }
132
+ }
package/utils/util.ts ADDED
@@ -0,0 +1,274 @@
1
+ type Func = (...args: any[]) => any;
2
+ /**
3
+ * 防抖函数
4
+ * @param { Function } func 函数
5
+ * @param { Number } delay 防抖时间
6
+ * @param { Boolean } immediate 是否立即执行
7
+ * @param { Function } resultCallback
8
+ */
9
+ export function debounce(
10
+ func: Func,
11
+ delay: number = 500,
12
+ immediate?: boolean,
13
+ resultCallback?: Func
14
+ ) {
15
+ let timer: null | ReturnType<typeof setTimeout> = null;
16
+ let isInvoke = false;
17
+ const _debounce = function (this: unknown, ...args: any[]) {
18
+ return new Promise((resolve, reject) => {
19
+ if (timer) clearTimeout(timer);
20
+ if (immediate && !isInvoke) {
21
+ try {
22
+ const result = func.apply(this, args);
23
+ if (resultCallback) resultCallback(result);
24
+ resolve(result);
25
+ } catch (e) {
26
+ reject(e);
27
+ }
28
+ isInvoke = true;
29
+ } else {
30
+ timer = setTimeout(() => {
31
+ try {
32
+ const result = func.apply(this, args);
33
+ if (resultCallback) resultCallback(result);
34
+ resolve(result);
35
+ } catch (e) {
36
+ reject(e);
37
+ }
38
+ isInvoke = false;
39
+ timer = null;
40
+ }, delay);
41
+ }
42
+ });
43
+ };
44
+ _debounce.cancel = function () {
45
+ if (timer) clearTimeout(timer);
46
+ isInvoke = false;
47
+ timer = null;
48
+ };
49
+ return _debounce;
50
+ }
51
+
52
+ /**
53
+ * 节流函数
54
+ * @param { Function } func
55
+ * @param { Boolean } interval
56
+ * @param { Object } options
57
+ * leading:初始 trailing:结尾
58
+ */
59
+ export function throttle(
60
+ func: Func,
61
+ interval: number,
62
+ options = { leading: false, trailing: true }
63
+ ) {
64
+ let timer: null | ReturnType<typeof setTimeout> = null;
65
+ let lastTime = 0;
66
+ const { leading, trailing } = options;
67
+ const _throttle = function (this: unknown, ...args: any[]) {
68
+ const nowTime = Date.now();
69
+ if (!lastTime && !leading) lastTime = nowTime;
70
+ const remainTime = interval - (nowTime - lastTime);
71
+ if (remainTime <= 0) {
72
+ if (timer) {
73
+ clearTimeout(timer);
74
+ timer = null;
75
+ }
76
+ lastTime = nowTime;
77
+ func.apply(this, args);
78
+ }
79
+ if (trailing && !timer) {
80
+ timer = setTimeout(() => {
81
+ lastTime = !leading ? 0 : Date.now();
82
+ timer = null;
83
+ func.apply(this, args);
84
+ }, remainTime);
85
+ }
86
+ };
87
+ _throttle.cancel = function () {
88
+ if (timer) clearTimeout(timer);
89
+ timer = null;
90
+ lastTime = 0;
91
+ };
92
+ return _throttle;
93
+ }
94
+
95
+ /**
96
+ * 驼峰转换下划线
97
+ * @param { String } name
98
+ */
99
+ export function toLine(name: string) {
100
+ return name.replace(/([A-Z])/g, '_$1').toLowerCase();
101
+ }
102
+
103
+ /*
104
+ 自定义保留 precision 位小数,并使用 separator 分隔符进行数字格式化
105
+ value:格式化目标数字
106
+ precision:精度,保留小数点后几位,默认2位
107
+ separator:千分位分隔符,默认为','
108
+ decimal:小数点符号,默认'.'
109
+ prefix:前缀字符,默认''
110
+ suffix:后缀字符,默认''
111
+ formatNumber(123456789.87654321, 2, ',') // 123,456,789.88
112
+ */
113
+ export function formatNumber(
114
+ value: number | string,
115
+ precision = 2,
116
+ separator = ',',
117
+ decimal = '.',
118
+ prefix = '',
119
+ suffix = ''
120
+ ): string {
121
+ if (Number(value) === 0) {
122
+ return Number(value).toFixed(precision);
123
+ }
124
+ if (!value) {
125
+ return '';
126
+ }
127
+ value = Number(value).toFixed(precision);
128
+ value += '';
129
+ const nums = value.split('.');
130
+ let integer = nums[0];
131
+ const decimals = nums.length > 1 ? decimal + nums[1] : '';
132
+ const reg = /(\d+)(\d{3})/;
133
+ function isNumber(value: any) {
134
+ return Object.prototype.toString.call(value) === '[object Number]';
135
+ }
136
+ if (separator && !isNumber(separator)) {
137
+ while (reg.test(integer)) {
138
+ integer = integer.replace(reg, '$1' + separator + '$2');
139
+ }
140
+ }
141
+ return prefix + integer + decimals + suffix;
142
+ }
143
+
144
+ /**
145
+ * PC / 移动端设备判断
146
+ * 用于路由划分、样式适配等
147
+ */
148
+
149
+ /** 视口宽度 >= 此值视为 PC 端(px) */
150
+ export const PC_BREAKPOINT = 768;
151
+
152
+ export function isPC(): boolean {
153
+ if (typeof window === 'undefined') return true;
154
+ return window.innerWidth >= PC_BREAKPOINT;
155
+ }
156
+
157
+ export function isMobile(): boolean {
158
+ return !isPC();
159
+ }
160
+
161
+ export type DeviceType = 'pc' | 'mobile';
162
+
163
+ export function getDeviceType(): DeviceType {
164
+ return isPC() ? 'pc' : 'mobile';
165
+ }
166
+
167
+ export interface HbFileItem_Intf {
168
+ // 文件名称
169
+ fileName: string;
170
+ // 文件预览URL
171
+ previewUrl: string;
172
+ // 文件下载URL
173
+ downloadUrl: string;
174
+ // 文件描述
175
+ attachmentDesc?: string;
176
+ // 文件ID
177
+ attachmentId?: string;
178
+ // 文件上传时间
179
+ uploadDate?: string;
180
+
181
+ [key: string]: unknown;
182
+ }
183
+
184
+ /** 可从文件名或完整 URL 解析扩展名(忽略 ?# 后参数) */
185
+ export const getFileExtensionFromName = (nameOrUrl: string): string => {
186
+ const s = nameOrUrl.trim().split(/[?#]/)[0];
187
+ const match = s.match(/\.([^.]+)$/);
188
+ return match ? match[1].toLowerCase() : '';
189
+ };
190
+
191
+ const PREVIEW_IMAGE_EXT = new Set([
192
+ 'png',
193
+ 'jpg',
194
+ 'jpeg',
195
+ 'gif',
196
+ 'bmp',
197
+ 'webp',
198
+ 'svg',
199
+ ]);
200
+
201
+ /** 是否为可内嵌预览的图片类型(依据文件名或 URL) */
202
+ export const isAttachmentImagePreview = (fileNameOrUrl: string): boolean => {
203
+ return PREVIEW_IMAGE_EXT.has(getFileExtensionFromName(fileNameOrUrl));
204
+ };
205
+
206
+ /** 供应商附件:仅 PDF 与常见图片支持内嵌预览 */
207
+ export const isAttachmentPreviewable = (fileName: string): boolean => {
208
+ const ext = getFileExtensionFromName(fileName);
209
+ return ext === 'pdf' || PREVIEW_IMAGE_EXT.has(ext);
210
+ };
211
+
212
+ export const getAttachmentDownloadUrl = <T extends HbFileItem_Intf>(
213
+ row: T
214
+ ): string => {
215
+ return row.downloadUrl;
216
+ };
217
+ /**
218
+ * @desc 在 `attachmentInfos` 中定位被点击的附件下标。
219
+ * 仅依赖 fileUuid 会在接口缺省、全空或重复时始终命中第一项,需结合列表下标与预览/下载地址兜底。
220
+ */
221
+ export const resolveAttachmentIndexInList = <T extends HbFileItem_Intf>(
222
+ list: T[],
223
+ item: T,
224
+ options?: {
225
+ /** 列表 v-for 下标,优先使用 */
226
+ preferredIndex?: number;
227
+ }
228
+ ): number => {
229
+ const getUrl = (row: T) => row.previewUrl;
230
+
231
+ const preferredIndex = options?.preferredIndex;
232
+ if (
233
+ typeof preferredIndex === 'number' &&
234
+ preferredIndex >= 0 &&
235
+ preferredIndex < list.length
236
+ ) {
237
+ const row = list[preferredIndex];
238
+ if (row === item) return preferredIndex;
239
+ const rowDate = 'uploadDate' in row ? row.uploadDate || '' : '';
240
+ const itemDate = 'uploadDate' in item ? item.uploadDate || '' : '';
241
+
242
+ if (
243
+ getUrl(row) === getUrl(item) &&
244
+ (row.attachmentDesc || '') === (item.attachmentDesc || '') &&
245
+ rowDate === itemDate
246
+ ) {
247
+ return preferredIndex;
248
+ }
249
+ }
250
+
251
+ const uid = item.attachmentId;
252
+ if (uid) {
253
+ const idx = list.findIndex((a) => {
254
+ const s = a as T;
255
+ const id = s.attachmentId;
256
+ return id === uid;
257
+ });
258
+ if (idx >= 0) return idx;
259
+ }
260
+
261
+ const previewUrl = getUrl(item);
262
+ if (previewUrl) {
263
+ const idx = list.findIndex((a) => getUrl(a) === previewUrl);
264
+ if (idx >= 0) return idx;
265
+ }
266
+
267
+ const dl = getAttachmentDownloadUrl<HbFileItem_Intf>(item);
268
+ if (dl) {
269
+ const idx = list.findIndex((a) => getAttachmentDownloadUrl(a) === dl);
270
+ if (idx >= 0) return idx;
271
+ }
272
+
273
+ return list.indexOf(item);
274
+ };