@hlw-uni/mp-vue 1.2.26 → 2.0.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.
Files changed (68) hide show
  1. package/dist/app.d.ts +33 -0
  2. package/dist/composables/ad/index.d.ts +134 -0
  3. package/dist/composables/color/index.d.ts +8 -0
  4. package/dist/composables/contact/index.d.ts +28 -0
  5. package/dist/composables/device/index.d.ts +129 -0
  6. package/dist/composables/format/index.d.ts +9 -0
  7. package/dist/composables/http/adapters/alist.d.ts +3 -0
  8. package/dist/composables/http/adapters/base.d.ts +19 -0
  9. package/dist/composables/http/adapters/cos.d.ts +3 -0
  10. package/dist/composables/http/adapters/index.d.ts +15 -0
  11. package/dist/composables/http/adapters/oss.d.ts +3 -0
  12. package/dist/composables/http/adapters/qiniu.d.ts +3 -0
  13. package/dist/composables/http/client.d.ts +66 -0
  14. package/dist/composables/http/index.d.ts +8 -0
  15. package/dist/composables/http/types.d.ts +51 -0
  16. package/dist/composables/index.d.ts +26 -0
  17. package/dist/composables/loading/index.d.ts +7 -0
  18. package/dist/composables/msg/index.d.ts +36 -0
  19. package/dist/composables/navigator/index.d.ts +47 -0
  20. package/dist/composables/page-meta/index.d.ts +18 -0
  21. package/dist/composables/refs/index.d.ts +8 -0
  22. package/dist/composables/share/index.d.ts +67 -0
  23. package/dist/composables/storage/index.d.ts +16 -0
  24. package/dist/composables/theme/font.d.ts +1 -1
  25. package/dist/composables/theme/index.d.ts +15 -2
  26. package/dist/composables/utils/index.d.ts +39 -0
  27. package/dist/composables/validate/index.d.ts +12 -0
  28. package/dist/directives/copy.d.ts +3 -0
  29. package/dist/directives/index.d.ts +1 -0
  30. package/dist/hlw.d.ts +14 -0
  31. package/dist/index.d.ts +10 -7
  32. package/dist/index.js +1722 -63
  33. package/dist/index.mjs +1721 -62
  34. package/package.json +5 -3
  35. package/src/app.ts +173 -0
  36. package/src/components/hlw-ad/index.vue +74 -30
  37. package/src/composables/ad/index.ts +386 -0
  38. package/src/composables/color/index.ts +44 -0
  39. package/src/composables/contact/index.ts +88 -0
  40. package/src/composables/device/index.ts +168 -0
  41. package/src/composables/format/index.ts +48 -0
  42. package/src/composables/http/adapters/alist.ts +19 -0
  43. package/src/composables/http/adapters/base.ts +21 -0
  44. package/src/composables/http/adapters/cos.ts +23 -0
  45. package/src/composables/http/adapters/index.ts +31 -0
  46. package/src/composables/http/adapters/oss.ts +22 -0
  47. package/src/composables/http/adapters/qiniu.ts +19 -0
  48. package/src/composables/http/client.ts +237 -0
  49. package/src/composables/http/index.ts +8 -0
  50. package/src/composables/http/types.ts +57 -0
  51. package/src/composables/http/useRequest.ts +107 -0
  52. package/src/composables/index.ts +82 -0
  53. package/src/composables/loading/index.ts +23 -0
  54. package/src/composables/msg/index.ts +132 -0
  55. package/src/composables/navigator/index.ts +104 -0
  56. package/src/composables/page-meta/index.ts +49 -0
  57. package/src/composables/refs/index.ts +30 -0
  58. package/src/composables/share/index.ts +185 -0
  59. package/src/composables/storage/index.ts +76 -0
  60. package/src/composables/theme/font.ts +26 -5
  61. package/src/composables/theme/index.ts +26 -11
  62. package/src/composables/theme/palette.ts +1 -1
  63. package/src/composables/utils/index.ts +160 -0
  64. package/src/composables/validate/index.ts +58 -0
  65. package/src/directives/copy.ts +50 -0
  66. package/src/directives/index.ts +1 -0
  67. package/src/hlw.ts +37 -0
  68. package/src/index.ts +21 -20
@@ -0,0 +1,386 @@
1
+ /**
2
+ * useAd —— 完整的广告业务封装
3
+ *
4
+ * 内置了:
5
+ * - 6 个 unit_id 配置(store 缓存,调 getConfig 拉一次)
6
+ * - 激励视频:广告加载 loading → onShown 自动关闭 → 中途关闭挽留 modal → 看完调业务方的 claim 发奖
7
+ * - 插屏:一行调用
8
+ * - 底层 SDK 适配(onLoad / offClose / show 兜底 / 实例缓存)
9
+ *
10
+ * 业务侧使用方式:
11
+ *
12
+ * 1. 在 App.vue 注入「全局」回调(拿配置 + 校验登录):
13
+ *
14
+ * import { configAd } from "@hlw-uni/mp-vue";
15
+ * import { getAdConfig } from "@/api/ad";
16
+ * import { useUserStore } from "@/store";
17
+ *
18
+ * configAd({
19
+ * getConfig: async () => {
20
+ * const res = await getAdConfig();
21
+ * return res.code === 1 && res.data ? res.data : null;
22
+ * },
23
+ * isAuth: () => !!useUserStore().token,
24
+ * });
25
+ *
26
+ * 2. 任意页面拉配置 / 用 unit_id 渲染:
27
+ *
28
+ * const { config, loadConfig, showPopup } = useAd();
29
+ * await loadConfig();
30
+ * <hlw-ad type="banner" :unit-id="config.banner" />
31
+ *
32
+ * 3. 看广告领奖励(业务方按场景传不同的 claim 接口):
33
+ *
34
+ * const ok = await showReward(async () => {
35
+ * const res = await claimAdReward();
36
+ * return res.code === 1
37
+ * ? { ok: true, reward: res.data?.reward }
38
+ * : { ok: false, msg: res.info };
39
+ * });
40
+ *
41
+ * // 不传 claim 就是纯展示,看完即返回 true
42
+ * const ok = await showReward();
43
+ */
44
+
45
+ import { defineStore, storeToRefs } from "pinia";
46
+ import { ref } from "vue";
47
+ import { useMsg, type HlwMsg } from "../msg";
48
+
49
+ /** 6 种广告类型 */
50
+ export type AdType = "banner" | "grid" | "custom" | "video" | "reward" | "popup";
51
+
52
+ /** 广告配置 —— 字段名跟后端表列名对齐(plugin_qz_mp.{type}_unit_id) */
53
+ export interface AdConfig {
54
+ banner_unit_id: string;
55
+ grid_unit_id: string;
56
+ custom_unit_id: string;
57
+ video_unit_id: string;
58
+ reward_unit_id: string;
59
+ popup_unit_id: string;
60
+ }
61
+
62
+ /** 广告错误对象(onError 回调参数) */
63
+ export interface AdError {
64
+ errCode: number;
65
+ errMsg: string;
66
+ }
67
+
68
+ /** 业务回调注入接口 —— configAd 时由项目提供 */
69
+ export interface AdAdapter {
70
+ /** 拉取广告配置;返回 null 表示拉失败,store 不更新 */
71
+ getConfig: () => Promise<AdConfig | null>;
72
+ /** 是否已登录;不传 = 不校验(showReward 调用前会问一次) */
73
+ isAuth?: () => boolean;
74
+ }
75
+
76
+ /** 激励视频关闭回调返回 */
77
+ export interface AdCloseResult {
78
+ /** 用户是否完整观看 */
79
+ isEnded: boolean;
80
+ }
81
+
82
+ /** showReward 的 claim 回调返回契约 */
83
+ export interface AdClaimResult {
84
+ /** 本次结果:true=成功 / false=失败 */
85
+ ok: boolean;
86
+ /** 奖励数(用于 toast 显示 +N 积分;无则不 toast) */
87
+ reward?: number;
88
+ /** 失败提示语;不传不弹 toast */
89
+ msg?: string;
90
+ /** 仅 isEnded=false 时常用:true 让 mp-core 重新 show 一次(业务方挽留确认后用) */
91
+ retry?: boolean;
92
+ }
93
+
94
+ /**
95
+ * showReward 的 claim 回调签名 —— 业务方按场景实现(领积分、解锁、抽奖等)。
96
+ * 每次广告关闭后被调用一次(无论是否完整看完),业务方根据 closeRes.isEnded 决定怎么走:
97
+ * - isEnded=true → 调发奖接口 → return { ok: true, reward: N }
98
+ * - isEnded=false → 业务自己决定挽留:return { ok: false, retry: true } 让重新 show
99
+ */
100
+ export type AdClaimFn = (closeRes: AdCloseResult) => Promise<AdClaimResult>;
101
+
102
+ const EMPTY: AdConfig = {
103
+ banner_unit_id: "",
104
+ grid_unit_id: "",
105
+ custom_unit_id: "",
106
+ video_unit_id: "",
107
+ reward_unit_id: "",
108
+ popup_unit_id: "",
109
+ };
110
+
111
+ let adapter: AdAdapter | null = null;
112
+
113
+ /**
114
+ * 注入业务回调,应用启动时调用一次。
115
+ * 不调用也不会崩,但 loadConfig / showReward 会无效。
116
+ */
117
+ export function configAd(a: AdAdapter): void {
118
+ adapter = a;
119
+ }
120
+
121
+ const useAdStore = defineStore("hlw_ad", () => {
122
+ const config = ref<AdConfig>({ ...EMPTY });
123
+ const loaded = ref(false);
124
+ return { config, loaded };
125
+ });
126
+
127
+ /* ============================================================
128
+ * 内部状态:消息单例 + 广告实例缓存
129
+ * ============================================================ */
130
+
131
+ let _msg: HlwMsg | null = null;
132
+ const msg = (): HlwMsg => (_msg ??= useMsg());
133
+
134
+ interface RewardedEntry {
135
+ ad: any;
136
+ /** 同一 unit_id 是否在 load 中,避免并发 */
137
+ loading: boolean;
138
+ }
139
+ const rewardedCache = new Map<string, RewardedEntry>();
140
+ const interstitialCache = new Map<string, any>();
141
+
142
+ /* ============================================================
143
+ * 公共 composable
144
+ * ============================================================ */
145
+
146
+ export function useAd() {
147
+ const store = useAdStore();
148
+ const { config, loaded } = storeToRefs(store);
149
+
150
+ /** 拉取广告配置(小程序冷启动后调一次即可) */
151
+ async function loadConfig(force = false): Promise<void> {
152
+ if (loaded.value && !force) return;
153
+ if (!adapter?.getConfig) {
154
+ console.warn("[useAd] adapter.getConfig 未注入;先调用 configAd()");
155
+ return;
156
+ }
157
+ try {
158
+ const cfg = await adapter.getConfig();
159
+ if (cfg) {
160
+ store.config = cfg;
161
+ store.loaded = true;
162
+ }
163
+ } catch (e) {
164
+ console.warn("[useAd] load config failed", e);
165
+ }
166
+ }
167
+
168
+ /** 取指定类型的 unit_id(hlw-ad 组件 / 业务直接调时用) */
169
+ function getUnitId(type: AdType): string {
170
+ return store.config[`${type}_unit_id` as keyof AdConfig] || "";
171
+ }
172
+
173
+ /**
174
+ * 显示激励视频
175
+ *
176
+ * 流程:loading → 广告 UI → 关闭 → 业务 claim 回调(按 closeRes.isEnded 决定怎么走)
177
+ *
178
+ * @param claim 关闭后业务回调;不传则纯展示,看完即返回 true、未看完返回 false
179
+ * @returns true=最终成功;false=未看完且业务放弃 / 配置缺失 / claim 失败
180
+ */
181
+ async function showReward(claim?: AdClaimFn): Promise<boolean> {
182
+ const unitId = getUnitId("reward");
183
+ if (!unitId) {
184
+ msg().toast("激励广告未配置");
185
+ return false;
186
+ }
187
+ if (adapter?.isAuth && !adapter.isAuth()) {
188
+ msg().toast("请先登录");
189
+ return false;
190
+ }
191
+
192
+ while (true) {
193
+ const closeRes = await playRewardedOnce(unitId);
194
+
195
+ // 没传 claim:默认行为 = 看完了 true / 没看完弹挽留 + 重试 / 放弃 false
196
+ if (!claim) {
197
+ if (closeRes.isEnded) return true;
198
+ const goon = await confirmReward();
199
+ if (!goon) return false;
200
+ continue;
201
+ }
202
+
203
+ // 业务方决定怎么走(包括「未看完是否挽留」)
204
+ try {
205
+ const r = await claim(closeRes);
206
+ if (r.retry) continue;
207
+ if (!r.ok) {
208
+ if (r.msg) msg().error(r.msg);
209
+ return false;
210
+ }
211
+ if (r.reward && r.reward > 0) msg().success(`+${r.reward} 积分`);
212
+ return true;
213
+ } catch (e) {
214
+ console.warn("[useAd] claim failed", e);
215
+ msg().error("领取失败,请稍后再试");
216
+ return false;
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * 显示插屏广告
223
+ * @returns true=展示成功;false=配置缺失 / show 失败(如近期已展示过)
224
+ */
225
+ async function showPopup(): Promise<boolean> {
226
+ const unitId = getUnitId("popup");
227
+ if (!unitId) return false;
228
+ return await showInterstitialAd(unitId);
229
+ }
230
+
231
+ return {
232
+ // 状态
233
+ config,
234
+ loaded,
235
+ // 方法
236
+ loadConfig,
237
+ getUnitId,
238
+ showReward,
239
+ showPopup,
240
+ };
241
+ }
242
+
243
+ /* ============================================================
244
+ * 内部 helper:业务流程层
245
+ * ============================================================ */
246
+
247
+ /** 播放一次激励视频,期间显示加载提示,广告 UI 弹出后自动 hide */
248
+ async function playRewardedOnce(unitId: string): Promise<AdCloseResult> {
249
+ msg().showLoading("广告加载中");
250
+ let hidden = false;
251
+ const hide = () => {
252
+ if (hidden) return;
253
+ hidden = true;
254
+ msg().hideLoading();
255
+ };
256
+ try {
257
+ const isEnded = await showRewardedAd(unitId, { onShown: hide });
258
+ return { isEnded };
259
+ } finally {
260
+ // 兜底:如果 onShown 没触发(show 直接 reject 等),最终也得关 loading
261
+ hide();
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 激励视频中途关闭挽留弹窗 —— 业务方在 claim 回调里也可以复用:
267
+ *
268
+ * showReward(async (closeRes) => {
269
+ * if (!closeRes.isEnded) {
270
+ * const goon = await confirmReward();
271
+ * return { ok: false, retry: goon };
272
+ * }
273
+ * const r = await claimAdReward();
274
+ * return r.code === 1 ? { ok: true, reward: r.data?.reward } : { ok: false, msg: r.info };
275
+ * });
276
+ *
277
+ * @returns true=用户选「继续观看」 / false=放弃
278
+ */
279
+ export function confirmReward(): Promise<boolean> {
280
+ return new Promise((resolve) => {
281
+ uni.showModal({
282
+ title: "提示",
283
+ content: "看完广告才可以领取奖励哦,要继续观看吗?",
284
+ confirmText: "继续观看",
285
+ cancelText: "放弃",
286
+ success: (r) => resolve(!!r.confirm),
287
+ fail: () => resolve(false),
288
+ });
289
+ });
290
+ }
291
+
292
+ /* ============================================================
293
+ * 内部 helper:底层 SDK 适配
294
+ * ============================================================ */
295
+
296
+ /**
297
+ * 底层激励视频包装:onClose / onError 解绑 + load + show 兜底
298
+ *
299
+ * @param hooks.onShown 广告 UI 已渲染时回调(业务用来关 loading)
300
+ */
301
+ function showRewardedAd(unitId: string, hooks?: { onShown?: () => void }): Promise<boolean> {
302
+ return new Promise((resolve) => {
303
+ let entry = rewardedCache.get(unitId);
304
+ if (!entry) {
305
+ const ad: any = uni.createRewardedVideoAd({ adUnitId: unitId });
306
+ if (!ad) { resolve(false); return; }
307
+ ad.onError?.((err: AdError) => {
308
+ console.warn(`[useAd] reward onError (${unitId})`, err);
309
+ });
310
+ entry = { ad, loading: false };
311
+ rewardedCache.set(unitId, entry);
312
+ }
313
+ const { ad } = entry;
314
+
315
+ let closeHandler: ((res: { isEnded: boolean }) => void) | null = null;
316
+ let errorHandler: ((err: AdError) => void) | null = null;
317
+ const cleanup = () => {
318
+ if (closeHandler) { ad.offClose?.(closeHandler); closeHandler = null; }
319
+ if (errorHandler) { ad.offError?.(errorHandler); errorHandler = null; }
320
+ };
321
+ closeHandler = (res) => {
322
+ cleanup();
323
+ resolve(!!(res && res.isEnded));
324
+ };
325
+ errorHandler = (err) => {
326
+ console.warn(`[useAd] reward show error (${unitId})`, err);
327
+ cleanup();
328
+ resolve(false);
329
+ };
330
+ ad.onClose(closeHandler);
331
+ ad.onError(errorHandler);
332
+
333
+ const doShow = () =>
334
+ ad.show()
335
+ .then(() => {
336
+ try { hooks?.onShown?.(); } catch (e) { console.warn("[useAd] onShown error", e); }
337
+ })
338
+ .catch(() => {
339
+ cleanup();
340
+ resolve(false);
341
+ });
342
+
343
+ if (entry.loading) {
344
+ Promise.resolve().then(doShow);
345
+ } else {
346
+ entry.loading = true;
347
+ ad.load()
348
+ .then(doShow)
349
+ .catch(doShow)
350
+ .finally(() => { if (entry) entry.loading = false; });
351
+ }
352
+ });
353
+ }
354
+
355
+ /** 底层插屏广告包装:show 失败兜底 load → 再 show */
356
+ function showInterstitialAd(unitId: string): Promise<boolean> {
357
+ return new Promise((resolve) => {
358
+ let ad: any = interstitialCache.get(unitId);
359
+ if (!ad) {
360
+ ad = uni.createInterstitialAd({ adUnitId: unitId });
361
+ if (!ad) { resolve(false); return; }
362
+ ad.onError?.((err: AdError) => {
363
+ console.warn(`[useAd] popup onError (${unitId})`, err);
364
+ });
365
+ interstitialCache.set(unitId, ad);
366
+ }
367
+
368
+ ad.show()
369
+ .then(() => resolve(true))
370
+ .catch(() => {
371
+ if (typeof ad.load !== "function") { resolve(false); return; }
372
+ ad.load()
373
+ .then(() => ad.show())
374
+ .then(() => resolve(true))
375
+ .catch(() => resolve(false));
376
+ });
377
+ });
378
+ }
379
+
380
+ /** 销毁全部广告实例并清空缓存(业务一般不用,hot reload 时调) */
381
+ export function destroyAds(): void {
382
+ rewardedCache.forEach((e) => e.ad?.destroy?.());
383
+ rewardedCache.clear();
384
+ interstitialCache.forEach((ad) => ad?.destroy?.());
385
+ interstitialCache.clear();
386
+ }
@@ -0,0 +1,44 @@
1
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
2
+
3
+ /**
4
+ * 解析 6 位十六进制颜色值,返回 RGB 数组。
5
+ */
6
+ function parseHex(hex: string): [number, number, number] {
7
+ if (!HEX_RE.test(hex)) throw new Error(`Invalid hex color: ${hex}`);
8
+ return [
9
+ parseInt(hex.slice(1, 3), 16),
10
+ parseInt(hex.slice(3, 5), 16),
11
+ parseInt(hex.slice(5, 7), 16),
12
+ ];
13
+ }
14
+
15
+ /**
16
+ * 颜色处理工具。
17
+ */
18
+ export function useColor() {
19
+ /**
20
+ * 将 CSS 变量对象拼接成内联 style 字符串。
21
+ */
22
+ function varsToStyle(vars: Record<string, string>): string {
23
+ return Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(";") + ";";
24
+ }
25
+
26
+ /**
27
+ * 将十六进制颜色转为 rgba 字符串。
28
+ */
29
+ function hexToRgba(hex: string, alpha: number): string {
30
+ const [r, g, b] = parseHex(hex);
31
+ return `rgba(${r},${g},${b},${alpha})`;
32
+ }
33
+
34
+ /**
35
+ * 将十六进制颜色按比例压暗。
36
+ */
37
+ function darkenHex(hex: string, amount = 0.15): string {
38
+ const [r, g, b] = parseHex(hex);
39
+ const d = (c: number) => Math.max(0, Math.round(c * (1 - amount)));
40
+ return `#${d(r).toString(16).padStart(2, "0")}${d(g).toString(16).padStart(2, "0")}${d(b).toString(16).padStart(2, "0")}`;
41
+ }
42
+
43
+ return { varsToStyle, hexToRgba, darkenHex };
44
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * useContact —— 客服按钮配置(business-level cache)
3
+ *
4
+ * 后端返回 <button open-type="contact"> 的 4 个微信原生属性,模块级缓存共享给所有客服按钮。
5
+ *
6
+ * 使用方式(业务侧):
7
+ *
8
+ * 1. App.vue / bootstrap 注入回调:
9
+ *
10
+ * configContact({
11
+ * getConfig: async () => {
12
+ * const res = await getContactConfig();
13
+ * return res.code === 1 ? res.data : null;
14
+ * },
15
+ * });
16
+ *
17
+ * 2. 任意按钮组件:
18
+ *
19
+ * const contact = useContact();
20
+ * <hlw-button open-type="contact" v-bind="contact">联系客服</hlw-button>
21
+ */
22
+ import { computed, ref } from "vue";
23
+
24
+ /** 后端返回的客服配置(按 button open-type=contact 标准属性命名) */
25
+ export interface ContactConfig {
26
+ send_message_title: string;
27
+ send_message_path: string;
28
+ send_message_img: string;
29
+ show_message_card: boolean;
30
+ }
31
+
32
+ /** Adapter 注入接口 */
33
+ export interface ContactAdapter {
34
+ /** 拉取客服配置;返回 null 表示拉失败,store 不更新 */
35
+ getConfig: () => Promise<ContactConfig | null>;
36
+ }
37
+
38
+ /** v-bind 到 button 的 camelCase props(微信原生属性约定) */
39
+ export interface ContactBindProps {
40
+ sendMessageTitle: string;
41
+ sendMessagePath: string;
42
+ sendMessageImg: string;
43
+ showMessageCard: boolean;
44
+ }
45
+
46
+ let adapter: ContactAdapter | null = null;
47
+ const config = ref<ContactConfig | null>(null);
48
+ let pending: Promise<void> | null = null;
49
+
50
+ /**
51
+ * 注入业务回调(应用启动时调用一次;不调用则 useContact 始终返回空字段)。
52
+ */
53
+ export function configContact(a: ContactAdapter): void {
54
+ adapter = a;
55
+ }
56
+
57
+ function loadConfig(): Promise<void> {
58
+ if (config.value) return Promise.resolve();
59
+ if (pending) return pending;
60
+ if (!adapter?.getConfig) {
61
+ console.warn("[useContact] adapter.getConfig 未注入;先调用 configContact()");
62
+ return Promise.resolve();
63
+ }
64
+ pending = adapter.getConfig()
65
+ .then((cfg) => {
66
+ if (cfg) config.value = cfg;
67
+ })
68
+ .catch((e) => {
69
+ console.warn("[useContact] load config failed", e);
70
+ })
71
+ .finally(() => {
72
+ pending = null;
73
+ });
74
+ return pending;
75
+ }
76
+
77
+ /**
78
+ * 返回 v-bind 友好的客服配置 computed(首次调用会异步拉一次配置)。
79
+ */
80
+ export function useContact() {
81
+ void loadConfig();
82
+ return computed<ContactBindProps>(() => ({
83
+ sendMessageTitle: config.value?.send_message_title || "",
84
+ sendMessagePath: config.value?.send_message_path || "",
85
+ sendMessageImg: config.value?.send_message_img || "",
86
+ showMessageCard: config.value?.show_message_card ?? false,
87
+ }));
88
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * useDevice — 设备信息 composable(单例缓存)
3
+ * 使用微信 3.7.0+ 推荐的新 API 替代废弃的 getSystemInfoSync
4
+ */
5
+ import { ref } from "vue";
6
+
7
+ export interface DeviceInfo {
8
+ /** 小程序 appId */
9
+ appid: string;
10
+ /** 应用名称 */
11
+ app_name: string;
12
+ /** 小程序版本号(版本名称) */
13
+ app_version: string;
14
+ /** 小程序版本号(版本号) */
15
+ app_version_code: string;
16
+ /** 小程序来源渠道 */
17
+ app_channel: string;
18
+ /** 设备品牌。如:apple、huawei */
19
+ device_brand: string;
20
+ /** 设备型号 */
21
+ device_model: string;
22
+ /** 设备 ID */
23
+ device_id: string;
24
+ /** 设备类型:phone/pad/pc */
25
+ device_type: string;
26
+ /** 设备方向:portrait/landscape */
27
+ device_orientation: "portrait" | "landscape";
28
+ /** 手机品牌。H5 不支持 */
29
+ brand: string;
30
+ /** 手机型号 */
31
+ model: string;
32
+ /** 操作系统版本 */
33
+ system: string;
34
+ /** 操作系统版本(简写) */
35
+ os: string;
36
+ /** 设备像素比 */
37
+ pixel_ratio: number;
38
+ /** 屏幕宽度 (px) */
39
+ screen_width: number;
40
+ /** 屏幕高度 (px) */
41
+ screen_height: number;
42
+ /** 可用窗口宽度 (px) */
43
+ window_width: number;
44
+ /** 可用窗口高度 (px) */
45
+ window_height: number;
46
+ /** 状态栏高度 (px) */
47
+ status_bar_height: number;
48
+ /** 微信基础库版本 */
49
+ sdk_version: string;
50
+ /** 宿主名称。如:WeChat、alipay */
51
+ host_name: string;
52
+ /** 宿主版本。如:微信版本号 */
53
+ host_version: string;
54
+ /** 宿主语言 */
55
+ host_language: string;
56
+ /** 宿主主题:light/dark */
57
+ host_theme: string;
58
+ /** 平台类型 weapp/toutiao/h5 */
59
+ platform: string;
60
+ /** 客户端语言 */
61
+ language: string;
62
+ /** 客户端版本号 */
63
+ version: string;
64
+ }
65
+
66
+ const _info = ref<DeviceInfo | null>(null);
67
+
68
+ /**
69
+ * 安全调用 uni API,失败时返回空对象,避免平台差异导致中断。
70
+ */
71
+ function tryCall(fn: (() => unknown) | undefined): Record<string, unknown> {
72
+ try { return (fn?.() ?? {}) as Record<string, unknown>; } catch { return {}; }
73
+ }
74
+
75
+ /**
76
+ * 收集当前设备、窗口与宿主应用信息并归一化字段。
77
+ */
78
+ function collect(): DeviceInfo {
79
+ // @ts-ignore — 新 API 在旧版 @dcloudio/types 中可能未声明
80
+ let deviceInfo = tryCall(uni.getDeviceInfo);
81
+ // @ts-ignore
82
+ let windowInfo = tryCall(uni.getWindowInfo);
83
+ let appBaseInfo = tryCall(uni.getAppBaseInfo);
84
+
85
+ if (!deviceInfo.brand && !deviceInfo.model) {
86
+ const sys = tryCall(() => uni.getSystemInfoSync());
87
+ deviceInfo = { ...sys };
88
+ windowInfo = { ...sys };
89
+ appBaseInfo = { ...sys };
90
+ }
91
+
92
+ let appid = "";
93
+ try {
94
+ const accountInfo = uni.getAccountInfoSync() as { miniProgram?: { appId?: string } };
95
+ appid = accountInfo?.miniProgram?.appId || "";
96
+ } catch {
97
+ appid = (deviceInfo.appId as string) || "";
98
+ }
99
+
100
+ const system = (deviceInfo.system as string) || "";
101
+
102
+ return {
103
+ appid,
104
+ app_name: (appBaseInfo.appName as string) || "",
105
+ app_version: (appBaseInfo.appVersion as string) || "",
106
+ app_version_code: (appBaseInfo.appVersionCode as string) || "",
107
+ app_channel: (appBaseInfo.appChannel as string) || "",
108
+ device_brand: (deviceInfo.brand as string) || "",
109
+ device_model: (deviceInfo.model as string) || "",
110
+ device_id: (deviceInfo.deviceId as string) || "",
111
+ device_type: (deviceInfo.deviceType as string) || "",
112
+ device_orientation: (windowInfo.deviceOrientation as "portrait" | "landscape") || "portrait",
113
+ brand: (deviceInfo.brand as string) || "",
114
+ model: (deviceInfo.model as string) || "",
115
+ system,
116
+ os: system.split(" ")[0] || "",
117
+ pixel_ratio: (windowInfo.pixelRatio as number) || 0,
118
+ screen_width: (windowInfo.screenWidth as number) || 0,
119
+ screen_height: (windowInfo.screenHeight as number) || 0,
120
+ window_width: (windowInfo.windowWidth as number) || 0,
121
+ window_height: (windowInfo.windowHeight as number) || 0,
122
+ status_bar_height: (windowInfo.statusBarHeight as number) || 0,
123
+ sdk_version: (appBaseInfo.SDKVersion as string) || "",
124
+ host_name: (appBaseInfo.hostName as string) || "",
125
+ host_version: (appBaseInfo.hostVersion as string) || "",
126
+ host_language: (appBaseInfo.hostLanguage as string) || "",
127
+ host_theme: (appBaseInfo.hostTheme as string) || "",
128
+ platform: (deviceInfo.platform as string) || "",
129
+ language: (appBaseInfo.language as string) || "",
130
+ version: (appBaseInfo.version as string) || "",
131
+ };
132
+ }
133
+
134
+ /**
135
+ * 确保设备信息只在首次访问时采集一次。
136
+ */
137
+ function ensure() {
138
+ if (!_info.value) {
139
+ _info.value = collect();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 获取单例缓存的设备信息。
145
+ */
146
+ export function useDevice() {
147
+ ensure();
148
+ return _info;
149
+ }
150
+
151
+ /**
152
+ * 把 deviceInfo 对象转成 URL query string(不含前导 ?)
153
+ */
154
+ export function deviceToQuery(): string {
155
+ ensure();
156
+ if (!_info.value) return "";
157
+ return Object.entries(_info.value)
158
+ .filter(([, v]) => v !== "" && v !== 0)
159
+ .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
160
+ .join("&");
161
+ }
162
+
163
+ /**
164
+ * 手动清除缓存(切换账号等场景可能需要)
165
+ */
166
+ export function clearDeviceCache(): void {
167
+ _info.value = null;
168
+ }