koishi-plugin-chat-patch 3.0.2 → 5.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 (194) hide show
  1. package/client/vue/index.vue +60 -634
  2. package/client/web/env.d.ts +56 -0
  3. package/client/web/index.html +21 -0
  4. package/client/web/package-lock.json +3419 -0
  5. package/client/web/package.json +62 -0
  6. package/client/web/public/bcui/css/color-dark.css +35 -0
  7. package/client/web/public/bcui/css/color-light.css +35 -0
  8. package/client/web/public/bcui/css/style.css +468 -0
  9. package/client/web/public/bcui/js/main.js +193 -0
  10. package/client/web/public/css/append-dark.css +13 -0
  11. package/client/web/public/css/append-light.css +13 -0
  12. package/client/web/public/css/append-napcat-dark.css +14 -0
  13. package/client/web/public/css/append-napcat-light.css +14 -0
  14. package/client/web/public/favicon.ico +0 -0
  15. package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
  16. package/client/web/public/img/icons/icon-maskable.png +0 -0
  17. package/client/web/public/img/icons/icon.png +0 -0
  18. package/client/web/public/img/icons/icon.svg +10 -0
  19. package/client/web/public/robots.txt +2 -0
  20. package/client/web/public/sw.js +58 -0
  21. package/client/web/scripts/copy-bcui.mjs +17 -0
  22. package/client/web/src/App.vue +1127 -0
  23. package/client/web/src/assets/css/append/append_darwin.css +68 -0
  24. package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
  25. package/client/web/src/assets/css/append/append_linux.css +125 -0
  26. package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
  27. package/client/web/src/assets/css/append/append_napcat.css +35 -0
  28. package/client/web/src/assets/css/append/append_new.css +843 -0
  29. package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
  30. package/client/web/src/assets/css/append/append_win32.css +109 -0
  31. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
  32. package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
  33. package/client/web/src/assets/css/chat.css +2104 -0
  34. package/client/web/src/assets/css/msg.css +941 -0
  35. package/client/web/src/assets/css/options.css +811 -0
  36. package/client/web/src/assets/css/sys_notice.css +62 -0
  37. package/client/web/src/assets/css/view.css +2105 -0
  38. package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
  39. package/client/web/src/assets/img/Vue.png +0 -0
  40. package/client/web/src/assets/img/cakes.svg +189 -0
  41. package/client/web/src/assets/img/icons/AppIcon.png +0 -0
  42. package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
  43. package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
  44. package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
  45. package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
  46. package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
  47. package/client/web/src/assets/img/icons/exit.png +0 -0
  48. package/client/web/src/assets/img/icons/pen.png +0 -0
  49. package/client/web/src/assets/img/stars.svg +89 -0
  50. package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
  51. package/client/web/src/assets/l10n/_About I10n.md +35 -0
  52. package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
  53. package/client/web/src/assets/l10n/zh-CN.po +1941 -0
  54. package/client/web/src/assets/logo.png +0 -0
  55. package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
  56. package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
  57. package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
  58. package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
  59. package/client/web/src/assets/qed.txt +1 -0
  60. package/client/web/src/components/AboutPan.vue +197 -0
  61. package/client/web/src/components/BulletinBody.vue +129 -0
  62. package/client/web/src/components/DepPan.vue +310 -0
  63. package/client/web/src/components/EmojiFace.vue +32 -0
  64. package/client/web/src/components/FacePan.vue +461 -0
  65. package/client/web/src/components/FileBody.vue +129 -0
  66. package/client/web/src/components/FileManager.vue +532 -0
  67. package/client/web/src/components/FriendBody.vue +54 -0
  68. package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
  69. package/client/web/src/components/History.vue +49 -0
  70. package/client/web/src/components/LazyLottie.vue +82 -0
  71. package/client/web/src/components/MergePan.vue +127 -0
  72. package/client/web/src/components/MsgBody.vue +1478 -0
  73. package/client/web/src/components/MusicPlayer.vue +549 -0
  74. package/client/web/src/components/NoticeBody.vue +168 -0
  75. package/client/web/src/components/QzonePermissionPan.vue +150 -0
  76. package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
  77. package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
  78. package/client/web/src/components/TinySessionBody.vue +51 -0
  79. package/client/web/src/components/UmamiInfoPan.vue +1549 -0
  80. package/client/web/src/components/UpdatePan.vue +263 -0
  81. package/client/web/src/components/ViewerCom.vue +1360 -0
  82. package/client/web/src/components/VoiceMsg.vue +626 -0
  83. package/client/web/src/components/WelPan.vue +894 -0
  84. package/client/web/src/components/msg-body/.gitkeep +0 -0
  85. package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
  86. package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
  87. package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
  88. package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
  89. package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
  90. package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
  91. package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
  92. package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
  93. package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
  94. package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
  95. package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
  96. package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
  97. package/client/web/src/components/notice-component/.gitkeep +0 -0
  98. package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
  99. package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
  100. package/client/web/src/components/tooltip/Tooltip.vue +50 -0
  101. package/client/web/src/components/tooltip/Tooltips.vue +14 -0
  102. package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
  103. package/client/web/src/function/base.ts +238 -0
  104. package/client/web/src/function/connect.ts +2042 -0
  105. package/client/web/src/function/debug.ts +10 -0
  106. package/client/web/src/function/elements/information.ts +141 -0
  107. package/client/web/src/function/elements/system.ts +101 -0
  108. package/client/web/src/function/elements/vueComp.ts +62 -0
  109. package/client/web/src/function/favicon.ts +76 -0
  110. package/client/web/src/function/input.ts +69 -0
  111. package/client/web/src/function/model/emoji.ts +127 -0
  112. package/client/web/src/function/model/img.ts +150 -0
  113. package/client/web/src/function/model/msg-body.ts +51 -0
  114. package/client/web/src/function/msg.ts +2674 -0
  115. package/client/web/src/function/notify.ts +225 -0
  116. package/client/web/src/function/option.ts +749 -0
  117. package/client/web/src/function/satori-model.ts +818 -0
  118. package/client/web/src/function/satori.ts +325 -0
  119. package/client/web/src/function/sender.ts +178 -0
  120. package/client/web/src/function/tooltip.ts +49 -0
  121. package/client/web/src/function/utils/appUtil.ts +2288 -0
  122. package/client/web/src/function/utils/avatarUtil.ts +31 -0
  123. package/client/web/src/function/utils/backgroundUtil.ts +110 -0
  124. package/client/web/src/function/utils/linkViewUtil.ts +101 -0
  125. package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
  126. package/client/web/src/function/utils/msgUtil.ts +1242 -0
  127. package/client/web/src/function/utils/pinyin.ts +141 -0
  128. package/client/web/src/function/utils/recordUtil.ts +187 -0
  129. package/client/web/src/function/utils/sessionUtil.ts +114 -0
  130. package/client/web/src/function/utils/systemUtil.ts +664 -0
  131. package/client/web/src/main.ts +91 -0
  132. package/client/web/src/pages/Chat.vue +3453 -0
  133. package/client/web/src/pages/Friends.vue +636 -0
  134. package/client/web/src/pages/Info.vue +717 -0
  135. package/client/web/src/pages/Messages.vue +875 -0
  136. package/client/web/src/pages/Options.vue +82 -0
  137. package/client/web/src/pages/Qzone.vue +1178 -0
  138. package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
  139. package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
  140. package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
  141. package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
  142. package/client/web/src/pages/options/OptAccount.vue +107 -0
  143. package/client/web/src/pages/options/OptAddon.vue +185 -0
  144. package/client/web/src/pages/options/OptDev.vue +704 -0
  145. package/client/web/src/pages/options/OptFunction.vue +403 -0
  146. package/client/web/src/pages/options/OptInfo.vue +165 -0
  147. package/client/web/src/pages/options/OptView.vue +846 -0
  148. package/client/web/src/runtime/backend.ts +310 -0
  149. package/client/web/src/state/auth.ts +14 -0
  150. package/client/web/src/state/chat.ts +36 -0
  151. package/client/web/src/state/connection.ts +20 -0
  152. package/client/web/src/state/contact.ts +37 -0
  153. package/client/web/src/state/qzone.ts +18 -0
  154. package/client/web/src/state/sessionHistory.ts +25 -0
  155. package/client/web/src/state/settings.ts +18 -0
  156. package/client/web/src/state/sticker.ts +10 -0
  157. package/client/web/src/state/ui.ts +56 -0
  158. package/client/web/tsconfig.json +27 -0
  159. package/client/web/tsconfig.tsbuildinfo +1 -0
  160. package/client/web/vite.config.ts +33 -0
  161. package/dist/index.js +1 -12
  162. package/dist/style.css +1 -1
  163. package/lib/index.js +2070 -2077
  164. package/package.json +15 -2
  165. package/readme.md +0 -22
  166. package/src/bootstrap.ts +71 -0
  167. package/src/cache.ts +319 -0
  168. package/src/config.ts +23 -35
  169. package/src/database.ts +650 -0
  170. package/src/index.ts +27 -59
  171. package/src/media.ts +302 -0
  172. package/src/recorder.ts +175 -0
  173. package/src/self-message.ts +456 -0
  174. package/src/server.d.ts +26 -0
  175. package/src/types.ts +108 -48
  176. package/src/web.ts +712 -0
  177. package/client/vue/chat-logic.ts +0 -683
  178. package/client/vue/composables/useChatActions.ts +0 -61
  179. package/client/vue/composables/useChatData.ts +0 -185
  180. package/client/vue/composables/useImageCache.ts +0 -281
  181. package/client/vue/composables/useVideoCache.ts +0 -125
  182. package/client/vue/types.ts +0 -72
  183. package/lib/api-handlers.d.ts +0 -25
  184. package/lib/config.d.ts +0 -14
  185. package/lib/file-manager.d.ts +0 -124
  186. package/lib/index.d.ts +0 -17
  187. package/lib/logger.d.ts +0 -9
  188. package/lib/message-handler.d.ts +0 -35
  189. package/lib/types.d.ts +0 -57
  190. package/lib/utils.d.ts +0 -18
  191. package/src/api-handlers.ts +0 -646
  192. package/src/file-manager.ts +0 -1436
  193. package/src/message-handler.ts +0 -507
  194. package/src/utils.ts +0 -189
@@ -0,0 +1,2288 @@
1
+ import app from '../../main'
2
+ import FileDownloader from 'js-file-downloader'
3
+ import option from '../../function/option'
4
+ import semver from 'semver'
5
+ import appInfo from '../../../package.json'
6
+
7
+ import AboutPan from '../../components/AboutPan.vue'
8
+ import UpdatePan from '../../components/UpdatePan.vue'
9
+ import WelPan from '../../components/WelPan.vue'
10
+ import MealHungryPan from '../../components/notice-component/MealHungryPan.vue'
11
+
12
+ import { KeyboardInfo } from '@capacitor/keyboard'
13
+ import { LogType, Logger, PopInfo, PopType } from '../../function/base'
14
+ import { Connector, loadChatHistoryFromCache, login, refreshAllBots } from '../../function/connect'
15
+ import { BaseChatInfoElem, MenuEventData } from '../../function/elements/information'
16
+ import { useAuthStore } from '../../state/auth'
17
+ import { useContactStore } from '../../state/contact'
18
+ import { useChatStore } from '../../state/chat'
19
+ import { useUIStore } from '../../state/ui'
20
+ import { useSettingsStore } from '../../state/settings'
21
+ import {
22
+ hslToRgb,
23
+ rgbToHsl,
24
+ } from '../../function/utils/systemUtil'
25
+ import {
26
+ markRaw,
27
+ defineAsyncComponent,
28
+ toRaw,
29
+ nextTick,
30
+ Directive,
31
+ WatchHandle,
32
+ onMounted,
33
+ onUnmounted,
34
+ ref,
35
+ watchEffect,
36
+ watch,
37
+ shallowReactive,
38
+ ShallowRef,
39
+ shallowRef,
40
+ Component,
41
+ DirectiveBinding,
42
+ Ref,
43
+ } from 'vue'
44
+ import { sendMsgRaw } from './msgUtil'
45
+ import { dbGetLatest } from './localHistoryUtil'
46
+ import { parseMsg } from '../sender'
47
+ import { Notify } from '../notify'
48
+ import { normalizeSessionId } from './sessionUtil'
49
+
50
+ const popInfo = new PopInfo()
51
+ const logger = new Logger()
52
+
53
+ function encodeKeyPart(value: unknown): string {
54
+ return encodeURIComponent(String(value ?? ''))
55
+ }
56
+
57
+ function ensureChannelPrefix(channelId: string, prefix: string): string {
58
+ if (/^(?:group|room|chat|channel|guild|private):/i.test(channelId)) return channelId
59
+ return `${prefix}:${channelId}`
60
+ }
61
+
62
+ /**
63
+ * 滚动到目标消息(不自动加载)
64
+ * @param seqName DOM 名(chat-xx)
65
+ */
66
+ export function scrollToMsg(seqName: string, showAnimation: boolean, showHighlight = true): boolean {
67
+ const msg = document.getElementById(seqName)
68
+ if (msg) {
69
+ const pan = document.getElementById('msgPan')
70
+ if (pan !== null) {
71
+ if (showAnimation === false) {
72
+ pan.style.scrollBehavior = 'unset'
73
+ } else {
74
+ pan.style.scrollBehavior = 'smooth'
75
+ }
76
+ pan.scrollTop = msg.offsetTop - msg.offsetHeight + 10
77
+ pan.style.scrollBehavior = 'smooth'
78
+ if (showHighlight) {
79
+ msg.style.transition = 'background 1s'
80
+ msg.style.background = 'rgba(0, 0, 0, 0.06)'
81
+ setTimeout(() => {
82
+ msg.style.background = 'unset'
83
+ setTimeout(() => {
84
+ msg.style.transition = 'background .3s'
85
+ }, 1100)
86
+ }, 3000)
87
+ }
88
+ return true
89
+ }
90
+ }
91
+ return false
92
+ }
93
+
94
+ /**
95
+ * 打开链接
96
+ * @param url 链接
97
+ * @param external 是否外部打开
98
+ */
99
+ export function openLink(url: string) {
100
+ // 判断是不是 Electron,是的话打开内嵌 iframe
101
+ if (backend.isDesktop()) {
102
+ const shell = window.electron?.shell
103
+ if (shell) {
104
+ shell.openExternal(url)
105
+ } else {
106
+ backend.call('', 'sys:openInBrowser', false, backend.unProxyUrl(url))
107
+ }
108
+ } else {
109
+ window.open(url)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 加载历史消息
115
+ * @param info 聊天基本信息
116
+ */
117
+ export async function loadHistory(info: BaseChatInfoElem) {
118
+ const authStore = useAuthStore()
119
+ const chatStore = useChatStore()
120
+ const settingsStore = useSettingsStore()
121
+ const uiStore = useUIStore()
122
+ uiStore.nowGetHistory = false
123
+ uiStore.historyBeforeTime = undefined
124
+ chatStore.messageList = []
125
+ const cacheKey = [authStore.loginInfo.platform, authStore.loginInfo.uin, info.id].map(encodeKeyPart).join(':')
126
+ const cachedMessages = chatStore.sessionMessageCache.get(cacheKey)
127
+ if (cachedMessages?.length) {
128
+ chatStore.messageList = [...cachedMessages]
129
+ }
130
+ // 本地有数据时立即显示,同时仍发网络请求以获取最新消息(避免遗漏)
131
+ if (
132
+ settingsStore.sysConfig.enable_local_history &&
133
+ settingsStore.sysConfig.mixed_load_messages !== false
134
+ ) {
135
+ const localMsgs = await dbGetLatest(
136
+ authStore.loginInfo.uin,
137
+ info.id,
138
+ 20,
139
+ )
140
+ if (localMsgs.length > 0) {
141
+ chatStore.messageList = localMsgs
142
+ }
143
+ }
144
+ const rawChannelId = String(info.channel_id ?? '')
145
+ const channelId = info.type === 'group'
146
+ ? ensureChannelPrefix(rawChannelId || String(info.id), 'group')
147
+ : ensureChannelPrefix(rawChannelId || String(info.id), 'private')
148
+ try {
149
+ const cachedMessages = await loadChatHistoryFromCache({
150
+ platform: String(authStore.loginInfo.platform ?? ''),
151
+ selfId: String(authStore.loginInfo.uin ?? ''),
152
+ channelId,
153
+ limit: 500,
154
+ })
155
+ if (cachedMessages.length > 0) {
156
+ const mergedMap = new Map<string, any>()
157
+ for (const msg of chatStore.messageList) {
158
+ const id = String(msg?.message_id ?? msg?.fake_message_id ?? '')
159
+ if (id && !mergedMap.has(id)) mergedMap.set(id, msg)
160
+ }
161
+ for (const msg of cachedMessages) {
162
+ const id = String(msg?.message_id ?? msg?.fake_message_id ?? '')
163
+ if (id && !mergedMap.has(id)) mergedMap.set(id, msg)
164
+ }
165
+ const merged = [...mergedMap.values()]
166
+ merged.sort((a, b) => {
167
+ const timeA = Number(a?.local_time ?? a?.timestamp_ms ?? a?.time_ms ?? 0)
168
+ || Number(a?.time ? Number(a.time) * 1000 : 0)
169
+ const timeB = Number(b?.local_time ?? b?.timestamp_ms ?? b?.time_ms ?? 0)
170
+ || Number(b?.time ? Number(b.time) * 1000 : 0)
171
+ if (timeA && timeB && timeA !== timeB) return timeA - timeB
172
+ const seqA = Number(a?.message_seq ?? a?.seq_id ?? a?.seq ?? 0)
173
+ const seqB = Number(b?.message_seq ?? b?.seq_id ?? b?.seq ?? 0)
174
+ if (Number.isFinite(seqA) && Number.isFinite(seqB) && seqA !== seqB) {
175
+ return seqA - seqB
176
+ }
177
+ return 0
178
+ })
179
+ chatStore.messageList = merged.slice(-500)
180
+ }
181
+ } catch (error) {
182
+ logger.error(error as Error, '[LocalHistory] 加载聊天记录缓存失败')
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 重新加载用户列表
188
+ */
189
+ export function reloadUsers() {
190
+ // 加载用户列表
191
+ void refreshAllBots()
192
+ }
193
+
194
+ /**
195
+ * 获取 Cookie
196
+ */
197
+ export function reloadCookies(domain = 'qun.qq.com') {
198
+ Connector.send(
199
+ 'get_cookies',
200
+ { domain: domain },
201
+ 'getCookies_' + domain,
202
+ )
203
+ }
204
+
205
+ /**
206
+ * 通过用户和消息 ID 跳转到对应的消息
207
+ * @param id
208
+ * @param msgId
209
+ */
210
+ export function jumpToChat(userId: string, msgId: string) {
211
+ const chatStore = useChatStore()
212
+ const contactStore = useContactStore()
213
+ if (chatStore.chatInfo.show.id != Number(userId)) {
214
+ const body = document.getElementById('user-' + userId)
215
+ if (body === null) {
216
+ // 从缓存列表里寻找这个 ID
217
+ for (let i = 0; i < contactStore.userList.length; i++) {
218
+ const item = contactStore.userList[i]
219
+ const id =
220
+ item.user_id !== undefined ? item.user_id : item.group_id
221
+ if (String(id) === userId) {
222
+ // 把它插入到显示列表的第一个
223
+ contactStore.showList?.unshift(item)
224
+ nextTick(() => {
225
+ const bodyNext = document.getElementById(
226
+ 'user-' + userId,
227
+ )
228
+ if (bodyNext !== null) {
229
+ // 添加一个消息跳转标记
230
+ bodyNext.dataset.jump = msgId
231
+ // 然后点一下它触发聊天框切换
232
+ bodyNext.click()
233
+ }
234
+ })
235
+ break
236
+ }
237
+ }
238
+ } else {
239
+ body.click()
240
+ }
241
+ } else {
242
+ // 当前聊天已经打开,是没有焦点触发的消息通知;直接滚动到消息。
243
+ scrollToMsg(msgId, true)
244
+ }
245
+ }
246
+
247
+ /**
248
+ * 下载文件
249
+ * @param url 文件链接
250
+ * @param name 文件名
251
+ * @param onprocess 下载进度回调
252
+ * @param oncancel 下载取消回调
253
+ * @returns 清理函数,用于移除监听器
254
+ */
255
+ export function downloadFile(
256
+ url: string,
257
+ name: string,
258
+ onprocess: (event: ProgressEvent & { [key: string]: any }) => undefined,
259
+ oncancel: (event: ProgressEvent & { [key: string]: any }) => undefined,
260
+ ): () => void {
261
+ if (document.location.protocol == 'https:') {
262
+ // 判断下载文件 URL 的协议
263
+ // PS:Chrome 不会对 http 下载的文件进行协议升级
264
+ if (url.toLowerCase().startsWith('http:')) {
265
+ url = 'https' + url.substring(url.indexOf('://'))
266
+ }
267
+ }
268
+ if (backend.isWeb()) {
269
+ try {
270
+ new FileDownloader({
271
+ url: url,
272
+ autoStart: true,
273
+ process: onprocess,
274
+ nameCallback: function () {
275
+ return name
276
+ },
277
+ })
278
+ } catch (e) {
279
+ logger.error(e as Error, '下载文件失败')
280
+ }
281
+ return () => {} // Web 平台不需要清理
282
+ } else {
283
+ // 创建命名回调函数以便后续移除
284
+ const processCallback = (event: any, data: any) => {
285
+ onprocess(data || event.payload)
286
+ }
287
+ const cancelCallback = (event: any, data: any) => {
288
+ oncancel(data || event.payload)
289
+ }
290
+ backend.addListener(undefined, 'sys:downloadBack', processCallback)
291
+ backend.addListener(undefined, 'sys:downloadCancel', cancelCallback)
292
+ backend.call(undefined, 'sys:download', false, {
293
+ downloadPath: url,
294
+ fileName: name,
295
+ })
296
+ // 返回清理函数
297
+ return () => {
298
+ backend.removeListener(undefined, 'sys:downloadBack', processCallback)
299
+ backend.removeListener(undefined, 'sys:downloadCancel', cancelCallback)
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Windows:获取加载系统主题色
306
+ * @param color 颜色
307
+ */
308
+ export function updateWinColor(color: string, type: string) {
309
+ if (type == 'windows') {
310
+ const red = parseInt(color.substr(0, 2), 16)
311
+ const green = parseInt(color.substr(2, 2), 16)
312
+ const blue = parseInt(color.substr(4, 2), 16)
313
+ // 平衡颜色亮度
314
+ const hsl = rgbToHsl(red, green, blue)
315
+ const dark = option.get('opt_dark')
316
+ let min = 0.35
317
+ let max = 0.9
318
+ if (dark == true) {
319
+ min += (max - min) / 2
320
+ } else {
321
+ min = 0.35
322
+ max -= (max - min) / 2
323
+ }
324
+ hsl[2] = min + hsl[2] * (max - min)
325
+ const finalColor = hslToRgb(hsl[0], hsl[1], hsl[2])
326
+ document.documentElement.style.setProperty(
327
+ '--color-main',
328
+ 'rgb(' +
329
+ finalColor[0] +
330
+ ',' +
331
+ finalColor[1] +
332
+ ',' +
333
+ finalColor[2] +
334
+ ')',
335
+ )
336
+ } else if (type == 'macos') {
337
+ document.documentElement.style.setProperty(
338
+ '--color-main',
339
+ '#' + color.substring(0, 6) + 'CF',
340
+ )
341
+ }
342
+ }
343
+ export async function loadWinColor() {
344
+ const process = window.electron?.process
345
+ let type = 'macos'
346
+ if (process && process.platform == 'win32') {
347
+ type = 'windows'
348
+ }
349
+ // 获取系统主题色
350
+ updateWinColor(await backend.call(undefined, 'sys:getWinColor', true), type)
351
+ }
352
+
353
+ /**
354
+ * macOS:创建应用菜单
355
+ */
356
+ export function createMenu() {
357
+ const { $t } = app.config.globalProperties
358
+ const contactStore = useContactStore()
359
+ // MacOS:初始化菜单
360
+ if (backend.isDesktop()) {
361
+ // 初始化菜单
362
+ const menuTitles = {} as { [key: string]: string }
363
+ menuTitles.success = $t(
364
+ '应用显示完成,应用初始化完成!欢迎使用 {name}!',
365
+ {
366
+ name: $t('Stapxs QQ Lite'),
367
+ },
368
+ )
369
+
370
+ menuTitles.repo = import.meta.env.VITE_APP_REPO_NAME
371
+
372
+ menuTitles.title = $t('Stapxs QQ Lite')
373
+ menuTitles.about = $t('关于') + ' ' + $t('Stapxs QQ Lite')
374
+ menuTitles.update = $t('检查更新…')
375
+ menuTitles.hide = $t('隐藏') + ' ' + $t('Stapxs QQ Lite')
376
+ menuTitles.hideOthers = $t('隐藏其他')
377
+ menuTitles.unhide = $t('全部显示')
378
+ menuTitles.quit = $t('退出') + ' ' + $t('Stapxs QQ Lite')
379
+
380
+ menuTitles.close = $t('关闭窗口')
381
+
382
+ menuTitles.edit = $t('编辑')
383
+ menuTitles.undo = $t('撤销')
384
+ menuTitles.redo = $t('重做')
385
+ menuTitles.cut = $t('剪切')
386
+ menuTitles.copy = $t('复制')
387
+ menuTitles.paste = $t('粘贴')
388
+ menuTitles.selectAll = $t('全选')
389
+
390
+ menuTitles.account = $t('账户')
391
+ menuTitles.login = $t('连接')
392
+ menuTitles.logout = $t('登出')
393
+ menuTitles.userList = $t('用户列表({count})', {
394
+ count: contactStore.userList.length,
395
+ })
396
+ menuTitles.flushUser = $t('刷新列表…')
397
+
398
+ menuTitles.help = $t('帮助')
399
+ menuTitles.doc = $t('帮助文档')
400
+ menuTitles.feedback = $t('在 Github 上反馈问题')
401
+ menuTitles.license = $t('许可协议')
402
+
403
+ backend.call(undefined, 'sys:createMenu', false,
404
+ backend.type == 'tauri' ? { data: menuTitles } : menuTitles)
405
+ }
406
+ }
407
+ export function updateMenu(config: { parent: string, id: string; action: string; value: string }) {
408
+ // MacOS:更新菜单
409
+ backend.call(undefined, 'sys:updateMenu', false, config)
410
+ }
411
+
412
+ /**
413
+ * Electron:注册系统 IPC
414
+ */
415
+ export function createIpc() {
416
+ const contactStore = useContactStore()
417
+ const uiStore = useUIStore()
418
+ // 服务发现
419
+ backend.addListener(undefined, 'sys:serviceFound', (event, data) => {
420
+ const info = data ?? event.payload
421
+ setQuickLogin(info.address, info.port)
422
+ })
423
+ // bot 功能
424
+ backend.addListener(undefined, 'bot:flushUser', () => {
425
+ reloadUsers()
426
+ popInfo.add(PopType.INFO, app.config.globalProperties.$t('刷新用户列表成功'))
427
+ })
428
+ backend.addListener(undefined, 'bot:logout', () => {
429
+ option.remove('auto_connect')
430
+ Connector.close()
431
+ })
432
+ backend.addListener(undefined, 'bot:quickReply', (event, data) => {
433
+ const info = data ?? event.payload
434
+ sendMsgRaw(info.id, info.type,
435
+ parseMsg(info.content, [{ type: 'reply', id: String(info.msg) }], []), true)
436
+ // 去消息列表内寻找,去除新消息标记
437
+ const item = contactStore.baseOnMsgList.get(normalizeSessionId(info.id))
438
+ if (item) {
439
+ if (item.new_msg) {
440
+ item.new_msg = false
441
+ contactStore.newMsgCount--
442
+ }
443
+ item.highlight = undefined
444
+ contactStore.baseOnMsgList.set(normalizeSessionId(info.id), item)
445
+ }
446
+ })
447
+ // 应用功能
448
+ backend.addListener(undefined, 'app:about', () => {
449
+ const popInfo = {
450
+ title: app.config.globalProperties.$t('关于') + ' ' +
451
+ app.config.globalProperties.$t('Stapxs QQ Lite'),
452
+ template: markRaw(AboutPan),
453
+ templateValue: {
454
+ showUI: false
455
+ },
456
+ allowQuickClose: false,
457
+ }
458
+ uiStore.popBoxList.push(popInfo)
459
+ })
460
+ backend.addListener(undefined, 'sys:handleUri', (event, data) => {
461
+ logger.info(JSON.stringify(data ?? event.payload))
462
+ })
463
+ backend.addListener(undefined, 'app:changeTab', (event, name) => {
464
+ window.focus()
465
+ document.getElementById('bar-' + (name ?? event.payload).toLowerCase())?.click()
466
+ })
467
+ backend.addListener(undefined, 'app:openLink', (event, link) => {
468
+ openLink(link ?? event.payload)
469
+ })
470
+ backend.addListener(undefined, 'app:error', (event, text) => {
471
+ new Logger().add(LogType.ERR, text ?? event.payload)
472
+ })
473
+ backend.addListener(undefined, 'app:jumpChat', (event, data) => {
474
+ const info = data ?? event.payload
475
+ jumpToChat(info.userId, info.msgId)
476
+ new Notify().closeAll(info.userId)
477
+ })
478
+ // 后端连接模式
479
+ backend.addListener(undefined, 'onebot:onopen', (event, data) => {
480
+ const info = data ?? event.payload
481
+ Connector.onopen(info.address, info.token)
482
+ })
483
+ backend.addListener(undefined, 'onebot:onmessage', (event, message) => {
484
+ Connector.onmessage(message ?? event.payload)
485
+ })
486
+ backend.addListener(undefined, 'onebot:onclose', (event, data) => {
487
+ const info = data ?? event.payload
488
+ Connector.onclose(info.code, info.reason || info.message, info.address, info.token)
489
+ })
490
+ }
491
+
492
+ /**
493
+ * Capacitor:初始化移动平台
494
+ */
495
+ export async function loadMobile() {
496
+ const { $t } = app.config.globalProperties
497
+ // Capacitor:相关初始化
498
+ if (backend.isMobile()) {
499
+ // 注册回调监听
500
+ backend.addListener('Onebot', 'onebot:event', (data) => {
501
+ const msg = JSON.parse(data.data)
502
+ switch (data.type) {
503
+ case 'onopen': {
504
+ login.creating = false
505
+ Connector.onopen(login.address, login.token)
506
+ break
507
+ }
508
+ case 'onmessage': Connector.onmessage(data.data); break
509
+ case 'onclose': {
510
+ login.creating = false
511
+ Connector.onclose(msg.code, msg.message, login.address, login.token)
512
+ break
513
+ }
514
+ case 'onerror': {
515
+ login.creating = false
516
+ popInfo.add(PopType.ERR, $t('连接失败') + ': ' + msg.type, false);
517
+ break
518
+ }
519
+ case 'onServiceFound': setQuickLogin(msg.address, msg.port); break
520
+ default: break
521
+ }
522
+ })
523
+ // initial-scale 缩放固定为 0.9
524
+ const viewport = document.getElementById('viewport')
525
+ if (viewport) {
526
+ (viewport as any).content =
527
+ 'width=device-width, initial-scale=0.9, maximum-scale=5, user-scalable=0'
528
+ }
529
+ // 通知
530
+ const permission = await backend.call('LocalNotifications', 'checkPermissions', true)
531
+ const permissionStr = permission || permission.display
532
+ if (permissionStr.indexOf('prompt') != -1) {
533
+ await backend.call('LocalNotifications', 'requestPermissions', false)
534
+ } else if (permissionStr.indexOf('denied') != -1) {
535
+ logger.error(null, '通知权限已被拒绝')
536
+ logger.system('开发者阁下为什么要拒绝通知权限的请求呢?')
537
+ } else {
538
+ logger.debug('通知权限已开启')
539
+ // 注册通知类型
540
+ backend.call('LocalNotifications', 'registerActionTypes', false, {
541
+ types: [{
542
+ id: 'msgQuickReply',
543
+ actions: [{
544
+ id: 'REPLY_ACTION',
545
+ title: '快速回复',
546
+ requiresAuthentication: true,
547
+ input: true,
548
+ inputButtonTitle: '发送',
549
+ inputPlaceholder: '输入回复内容……'
550
+ }]
551
+ }] as ActionType[]
552
+ })
553
+ // 注册相关事件
554
+ backend.addListener('LocalNotifications', 'localNotificationActionPerformed', (info) => {
555
+ const contactStore = useContactStore()
556
+ const notification =
557
+ info.notification as LocalNotificationSchema
558
+ if (info.actionId == 'tap') {
559
+ // PS:通知被点击后会自动被关闭,所以这里不需要处理
560
+ jumpToChat(notification.extra.userId,
561
+ notification.extra.msgId)
562
+ } else if (info.actionId == 'REPLY_ACTION') {
563
+ // 快速回复
564
+ sendMsgRaw(
565
+ notification.extra.userId,
566
+ notification.extra.chatType,
567
+ parseMsg(info.inputValue ?? '', [{ type: 'reply', id: String(notification.extra.msgId) }], []),
568
+ true
569
+ )
570
+ // 去消息列表内寻找,去除新消息标记
571
+ const item = contactStore.baseOnMsgList.get(String(notification.extra.userId))
572
+ if (item) {
573
+ if (item.new_msg) {
574
+ item.new_msg = false
575
+ contactStore.newMsgCount--
576
+ }
577
+ item.highlight = undefined
578
+ contactStore.baseOnMsgList.set(String(notification.extra.userId), item)
579
+ }
580
+ }
581
+ })
582
+ }
583
+ // 键盘
584
+ backend.call('Keyboard', 'setAccessoryBarVisible', false, { isVisible: false })
585
+ backend.call('Keyboard', 'setResizeMode', false, { mode: 'none' })
586
+ backend.addListener('Keyboard', 'keyboardWillShow', async (info: KeyboardInfo) => {
587
+ const keyboardHeight = info.keyboardHeight
588
+
589
+ // 调整输入框高度
590
+ const sendMore = document.getElementById('send-more')
591
+ if (sendMore && keyboardHeight > window.innerHeight / 3) {
592
+ sendMore.style.paddingBottom = '105px'
593
+ }
594
+
595
+ const safeArea = await backend.call('SafeArea', 'getSafeArea', true)
596
+ const tabBar = document.getElementsByTagName('ul')[0]
597
+ // iOS 26 后键盘背景是半透明的,不能让 webview 调整高度,会漏出背景的黑色
598
+ // 干脆把所有的 iOS 版本处理方法都改为内部避让
599
+ if (backend.platform == 'ios') {
600
+ const baseApp = document.getElementById('base-app')
601
+ // 使用键盘高度减去底部安全区域,不添加额外偏移量
602
+ // 避免硬编码的 +100 导致 WebView 定位错误,引发键盘焦点丢失
603
+ const keyboardOffset = Math.max(0, keyboardHeight - safeArea.bottom)
604
+ if (safeArea && baseApp) {
605
+ baseApp.style.setProperty('--safe-area-bottom', keyboardOffset + 'px')
606
+ }
607
+ // 调整菜单高度
608
+ if (safeArea && tabBar) {
609
+ tabBar.style.setProperty('padding-bottom', keyboardOffset + 'px', 'important')
610
+ }
611
+ }
612
+
613
+ // 调整整个 HTML 的高度
614
+ // PS:仅用于解决 Android 在全屏沉浸式下键盘遮挡问题
615
+ // const html = document.getElementsByTagName('html')[0]
616
+ // if (html && backend.platform == 'android') {
617
+ // html.style.height = `calc(100% - ${keyboardHeight + safeArea.top}px)`
618
+ // }
619
+ })
620
+ backend.addListener('Keyboard', 'keyboardWillHide', async () => {
621
+ const sendMore = document.getElementById('send-more')
622
+ if (sendMore) {
623
+ sendMore.style.paddingBottom = 'var(--safe-area-bottom)'
624
+ }
625
+ if (backend.platform == 'ios') {
626
+ const baseApp = document.getElementById('base-app')
627
+ const safeArea = await backend.call('SafeArea', 'getSafeArea', true)
628
+ if (safeArea && baseApp) {
629
+ baseApp.style.setProperty('--safe-area-bottom', safeArea.bottom + 'px')
630
+ }
631
+
632
+ const tabBar = document.getElementsByTagName('ul')[0]
633
+ if (tabBar) {
634
+ tabBar.style.paddingBottom = ''
635
+ }
636
+ }
637
+ // 调整整个 HTML 的高度
638
+ // PS:仅用于解决 Android 在全屏沉浸式下键盘遮挡问题
639
+ const html = document.getElementsByTagName('html')[0]
640
+ if (html && backend.platform == 'android') {
641
+ html.style.height = 'calc(100%)'
642
+ }
643
+ })
644
+ // 状态栏(Android)
645
+ backend.call('NavigationBar', 'setTransparency', false, { isTransparent: true })
646
+ backend.call('StatusBar', 'setOverlaysWebView', false, { overlay: true })
647
+ backend.call('StatusBar', 'setBackgroundColor', false, { color: '#ffffff00' })
648
+ }
649
+ }
650
+
651
+ import horizontalCss from '../../assets/css/append/mobile/append_mobile_horizontal.css?raw'
652
+ import verticalCss from '../../assets/css/append/mobile/append_mobile_vertical.css?raw'
653
+ import { ActionType, LocalNotificationSchema } from '@capacitor/local-notifications'
654
+ import { backend } from '../../runtime/backend'
655
+ import { NoticeBodyV3 } from '../elements/system'
656
+ import { wheelMask } from '../input'
657
+ import { addTooltip, TooltipController } from '../tooltip'
658
+ import { VueCompData } from '../elements/vueComp'
659
+ // import windowsCss from '../../assets/css/append/mobile/append_windows.css?raw'
660
+ /**
661
+ * 装载补充样式
662
+ */
663
+ export async function loadAppendStyle() {
664
+ const platform = backend.platform
665
+ logger.info('正在装载补充样式……')
666
+ if (platform != undefined) {
667
+ import(`../../assets/css/append/append_${platform}.css`)
668
+ .then(() => {
669
+ logger.info(`${platform} 平台附加样式加载完成`)
670
+ })
671
+ .catch(() => {
672
+ logger.info('未找到对应平台的附加样式:' + platform)
673
+ })
674
+ }
675
+
676
+ // 添加手机端样式
677
+ const updateCss = (appendCss = '') => {
678
+ const cssStype = document.getElementById('mobile-css')
679
+
680
+ const width = window.innerWidth
681
+ const height = window.innerHeight
682
+ if (cssStype) {
683
+ if (width > 600) {
684
+ cssStype.innerHTML = (width > height ? horizontalCss : (horizontalCss + verticalCss)) + appendCss
685
+ } else {
686
+ cssStype.innerHTML = horizontalCss + verticalCss + appendCss
687
+ }
688
+ }
689
+
690
+ if (backend.isDesktop()) {
691
+ backend.call(undefined, 'win:maximize', false)
692
+ const topBar = document.getElementsByClassName('top-bar')[0] as HTMLElement
693
+ if (topBar) {
694
+ topBar.style.display = 'none'
695
+ }
696
+ }
697
+ }
698
+ if (backend.isMobile()) {
699
+ const styleTag = document.createElement('style')
700
+ styleTag.id = 'mobile-css'
701
+ document.head.appendChild(styleTag)
702
+ updateCss()
703
+ // 屏幕旋转事件处理
704
+ window.addEventListener('resize', () => {
705
+ updateCss()
706
+ })
707
+ }
708
+
709
+ // UI 2.0 附加样式
710
+ if (backend.isDesktop()) {
711
+ import('../../assets/css/append/append_new.css').then(() => {
712
+ logger.info('UI 2.0 附加样式加载完成')
713
+ })
714
+ }
715
+
716
+ if (option.get('chat_more_blur')) {
717
+ import('../../assets/css/append/append_full_vibrancy.css').then(() => {
718
+ logger.info('完全透明 UI 附加样式加载完成')
719
+ })
720
+ }
721
+
722
+ // napcat 插件模式附加样式
723
+ if (import.meta.env.VITE_NAPCAT) {
724
+ import('../../assets/css/append/append_full_vibrancy.css').then(() => {
725
+ logger.info('完全透明 UI 附加样式加载完成')
726
+ })
727
+ import('../../assets/css/append/append_napcat.css').then(() => {
728
+ logger.info('napcat 插件模式附加样式加载完成')
729
+ })
730
+ }
731
+
732
+ // 透明 UI 附加样式
733
+ let subVersion = backend.release?.split(' ')?.[1]?.split('.') as any
734
+ subVersion = subVersion ? Number(subVersion[2]) : 0
735
+ if (backend.isDesktop() &&
736
+ (platform == 'darwin' || (platform == 'win32' && subVersion > 22621))) {
737
+ import('../../assets/css/append/append_vibrancy.css').then(() => {
738
+ logger.info('透明 UI 附加样式加载完成')
739
+ })
740
+ }
741
+ if (backend.isDesktop() && platform == 'linux') {
742
+ const gnomeExtInfo = await backend.call(undefined, 'sys:getGnomeExt', true)
743
+ if (gnomeExtInfo) {
744
+ gnomeExtInfo.then((info: any) => {
745
+ if (
746
+ info['enable-all'] == 'true' ||
747
+ (info['whitelist'] != undefined &&
748
+ info['whitelist'].indexOf('stapxs-qq-lite')) > 0
749
+ ) {
750
+ import(
751
+ '../../assets/css/append/append_vibrancy.css'
752
+ ).then(() => {
753
+ logger.info('透明 UI 附加样式加载完成')
754
+ })
755
+ import(
756
+ '../../assets/css/append/append_linux_vibrancy.css'
757
+ ).then(() => {
758
+ logger.info('Linux 透明 UI 附加样式加载完成')
759
+ })
760
+ }
761
+ })
762
+ }
763
+ }
764
+ }
765
+
766
+ /**
767
+ * 初始化快速连接信息
768
+ * @param address 地址
769
+ * @param port 端口
770
+ */
771
+ function setQuickLogin(address: string, port: number) {
772
+ if (login.quickLogin != null)
773
+ login.quickLogin.push({ address: address, port: port })
774
+ }
775
+
776
+ /**
777
+ * 检查更新
778
+ */
779
+ export function checkUpdate() {
780
+ const repoName = import.meta.env.VITE_APP_REPO_NAME
781
+ // 获取最新的 release 信息
782
+ const packageUrl =
783
+ `https://api.github.com/repos/${repoName}/releases/latest`
784
+ fetch(packageUrl).then((response) => {
785
+ if (response.ok) {
786
+ response.json().then((data) => {
787
+ showUpadteLog(data)
788
+ })
789
+ }
790
+ })
791
+ localStorage.setItem('version', appInfo.version)
792
+ }
793
+
794
+ /**
795
+ * 展示更新弹窗
796
+ * @param data 更新数据
797
+ */
798
+ function showUpadteLog(data: any) {
799
+ const appVersion = appInfo.version // 当前版本
800
+ const cacheVersion = localStorage.getItem('version') // 缓存版本
801
+ // 这儿有两种情况:
802
+ // 如果当前版本小于获取到的版本就是有更新
803
+ // 如果缓存版本小于获取到的版本但是当前版本等于获取到的版本就是更新完成首次启动
804
+ const latestVersion = data.tag_name.substring(1)
805
+
806
+ if (semver.lt(appVersion, latestVersion)) {
807
+ // 有更新
808
+ showReleaseLog(data, false)
809
+ }
810
+ if (
811
+ cacheVersion &&
812
+ semver.eq(appVersion, latestVersion) &&
813
+ semver.lt(cacheVersion, latestVersion)
814
+ ) {
815
+ // 更新完成首次启动
816
+ showReleaseLog(data, true)
817
+ }
818
+ }
819
+ function showReleaseLog(data: any, isUpdated: boolean) {
820
+ const uiStore = useUIStore()
821
+ const { $t } = app.config.globalProperties
822
+ let msg = data.body
823
+ // 处理 title,取开头到下一个 “\r\n” 之间的内容
824
+ const title = msg.split('\r\n')[0].substring(1)
825
+ // 处理 msg,取 “## 更新内容” 到下一个 “##” 之间的内容
826
+ const start = msg.indexOf('## 更新内容\r\n')
827
+ if (start != -1) {
828
+ msg = msg.substring(start + 9)
829
+ const end = msg.indexOf('##')
830
+ if (end != -1) {
831
+ msg = msg.substring(0, end)
832
+ }
833
+ }
834
+ msg = title + '\r\n' + msg
835
+ const info = {
836
+ version:
837
+ (isUpdated ? localStorage.getItem('version') + ' -> ' : '') +
838
+ data.tag_name.substring(1),
839
+ date: data.published_at,
840
+ user: {
841
+ name: data.author.login,
842
+ avatar: data.author.avatar_url,
843
+ url: data.author.html_url,
844
+ },
845
+ message: msg,
846
+ updated: isUpdated,
847
+ }
848
+ const buttonGoUpdate = (!backend.isWeb()) ? [
849
+ {
850
+ text: $t('知道了'),
851
+ fun: () => uiStore.popBoxList.shift(),
852
+ },
853
+ {
854
+ text: $t('下载更新…'),
855
+ master: true,
856
+ fun: () => openLink(data.html_url),
857
+ },
858
+ ] : [
859
+ {
860
+ text: $t('查看…'),
861
+ fun: () => openLink(data.html_url),
862
+ },
863
+ {
864
+ text: $t('刷新页面'),
865
+ master: true,
866
+ fun: () => location.reload(),
867
+ },
868
+ ]
869
+ const popInfo = {
870
+ template: markRaw(UpdatePan),
871
+ templateValue: toRaw(info),
872
+ button: isUpdated ? [
873
+ {
874
+ text: $t('查看…'),
875
+ fun: () => openLink(data.html_url),
876
+ },
877
+ {
878
+ text: $t('知道了'),
879
+ master: true,
880
+ fun: () => {
881
+ uiStore.popBoxList.shift()
882
+ },
883
+ },
884
+ ] : buttonGoUpdate,
885
+ }
886
+ uiStore.popBoxList.push(popInfo)
887
+ }
888
+
889
+ /**
890
+ * 获取并展示最近5条更新记录
891
+ */
892
+ export function showReleaseHistory() {
893
+ const uiStore = useUIStore()
894
+ const { $t } = app.config.globalProperties
895
+ const repoName = import.meta.env.VITE_APP_REPO_NAME
896
+ const packageUrl = `https://api.github.com/repos/${repoName}/releases?per_page=5`
897
+
898
+ fetch(packageUrl).then((response) => {
899
+ if (response.ok) {
900
+ response.json().then((dataList: any[]) => {
901
+ // 解析最近5条更新记录
902
+ const releases = dataList.map((data) => {
903
+ let msg = data.body
904
+ const title = msg.split('\r\n')[0].substring(1)
905
+ const start = msg.indexOf('## 更新内容\r\n')
906
+ if (start != -1) {
907
+ msg = msg.substring(start + 9)
908
+ const end = msg.indexOf('##')
909
+ if (end != -1) {
910
+ msg = msg.substring(0, end)
911
+ }
912
+ }
913
+ msg = title + '\r\n' + msg
914
+
915
+ return {
916
+ version: data.tag_name.substring(1),
917
+ date: data.published_at,
918
+ user: {
919
+ name: data.author.login,
920
+ avatar: data.author.avatar_url,
921
+ url: data.author.html_url,
922
+ },
923
+ message: msg,
924
+ html_url: data.html_url,
925
+ }
926
+ })
927
+
928
+ const popInfo = {
929
+ title: $t('更新历史'),
930
+ template: markRaw(UpdatePan),
931
+ templateValue: toRaw({ releases }),
932
+ full: true,
933
+ button: [
934
+ {
935
+ text: $t('关闭'),
936
+ master: true,
937
+ fun: () => {
938
+ uiStore.popBoxList.shift()
939
+ },
940
+ },
941
+ ],
942
+ }
943
+ uiStore.popBoxList.push(popInfo)
944
+ })
945
+ } else {
946
+ new PopInfo().add(
947
+ PopType.ERR,
948
+ $t('获取更新历史失败'),
949
+ false,
950
+ )
951
+ }
952
+ }).catch(() => {
953
+ new PopInfo().add(
954
+ PopType.ERR,
955
+ $t('获取更新历史失败'),
956
+ false,
957
+ )
958
+ })
959
+ }
960
+
961
+ /**
962
+ * 显示使用次数弹窗
963
+ */
964
+ export function checkOpenTimes() {
965
+ const uiStore = useUIStore()
966
+ if (import.meta.env.DEV) return // 开发环境不显示
967
+ const { $t } = app.config.globalProperties
968
+ const repoName = import.meta.env.VITE_APP_REPO_NAME
969
+ const times = localStorage.getItem('times')
970
+ if (times != null) {
971
+ const getTimes = Number(times) + 1
972
+ localStorage.setItem('times', getTimes.toString())
973
+ if (getTimes % 20 == 0) {
974
+ // 构建 HTML
975
+ let html =
976
+ '<div style="display:flex;flex-direction:column;padding:10px 5%;align-items:center;">'
977
+ html +=
978
+ '<svg style="height:2rem;fill:var(--color-font);margin-bottom:20px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M16 0H144c5.3 0 10.3 2.7 13.3 7.1l81.1 121.6c-49.5 4.1-94 25.6-127.6 58.3L2.7 24.9C-.6 20-.9 13.7 1.9 8.5S10.1 0 16 0zM509.3 24.9L401.2 187.1c-33.5-32.7-78.1-54.2-127.6-58.3L354.7 7.1c3-4.5 8-7.1 13.3-7.1H496c5.9 0 11.3 3.2 14.1 8.5s2.5 11.5-.8 16.4zM432 336c0 97.2-78.8 176-176 176s-176-78.8-176-176s78.8-176 176-176s176 78.8 176 176zM264.4 241.1c-3.4-7-13.3-7-16.8 0l-22.4 45.4c-1.4 2.8-4 4.7-7 5.1L168 298.9c-7.7 1.1-10.7 10.5-5.2 16l36.3 35.4c2.2 2.2 3.2 5.2 2.7 8.3l-8.6 49.9c-1.3 7.6 6.7 13.5 13.6 9.9l44.8-23.6c2.7-1.4 6-1.4 8.7 0l44.8 23.6c6.9 3.6 14.9-2.2 13.6-9.9l-8.6-49.9c-.5-3 .5-6.1 2.7-8.3l36.3-35.4c5.6-5.4 2.5-14.8-5.2-16l-50.1-7.3c-3-.4-5.7-2.4-7-5.1l-22.4-45.4z"/></svg>'
979
+ html += `<span>${$t('好耶!Stapxs QQ Lite 已经被打开 {times} 次了!', { times: getTimes })}</span>`
980
+ html += `<span>${$t('真的不去点个 star 吗 ……')}</span>`
981
+ html += '</div>'
982
+ const popInfo = {
983
+ title: $t('好耶'),
984
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"/></svg>',
985
+ html: html,
986
+ button: [
987
+ {
988
+ text: $t('不要'),
989
+ fun: () => {
990
+ uiStore.popBoxList.shift()
991
+ },
992
+ },
993
+ {
994
+ text: $t('好喔'),
995
+ master: true,
996
+ fun: () => {
997
+ openLink(
998
+ `https://github.com/${repoName}`,
999
+ )
1000
+ uiStore.popBoxList.shift()
1001
+ },
1002
+ },
1003
+ ],
1004
+ }
1005
+ uiStore.popBoxList.push(popInfo)
1006
+ }
1007
+ if (getTimes % 50 == 0 && import.meta.env.VITE_APP_SPONSORS_URL) {
1008
+ const popInfo = {
1009
+ title: '',
1010
+ template: markRaw(MealHungryPan),
1011
+ templateValue: { times: getTimes },
1012
+ button: [
1013
+ {
1014
+ text: $t('打开…'),
1015
+ fun: () => {
1016
+ openLink(import.meta.env.VITE_APP_SPONSORS_URL)
1017
+ uiStore.popBoxList.shift()
1018
+ },
1019
+ },
1020
+ {
1021
+ text: $t('好耶'),
1022
+ master: true,
1023
+ fun: () => {
1024
+ uiStore.popBoxList.shift()
1025
+ },
1026
+ },
1027
+ ],
1028
+ }
1029
+ uiStore.popBoxList.push(popInfo)
1030
+ }
1031
+ } else {
1032
+ localStorage.setItem('times', '1')
1033
+ }
1034
+ // 使用引导
1035
+ const guide = localStorage.getItem('guide')
1036
+ const guideVersion = 1
1037
+ if (guide != guideVersion.toString()) {
1038
+ // 首次打开,显示首次打开引导信息
1039
+ const popInfo = {
1040
+ template: markRaw(WelPan),
1041
+ allowClose: false,
1042
+ button: [],
1043
+ }
1044
+ uiStore.popBoxList.push(popInfo)
1045
+ localStorage.setItem('guide', guideVersion.toString())
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * 显示全局公告弹窗
1051
+ */
1052
+ export function checkNotice() {
1053
+ const uiStore = useUIStore()
1054
+ let url = 'https://lib.stapxs.cn/download/stapxs-qq-lite/notice-config.json'
1055
+ if (import.meta.env.DEV) {
1056
+ url = 'notice_local.json'
1057
+ }
1058
+ const version = 3
1059
+ const fetchData = {
1060
+ time: new Date().getTime().toString(),
1061
+ } as Record<string, string>
1062
+ fetch(url + '?' + new URLSearchParams(fetchData).toString())
1063
+ .then(async (response) => {
1064
+ if (!response.ok) return null
1065
+ const contentType = response.headers.get('content-type') ?? ''
1066
+ if (!contentType.includes('json')) return null
1067
+ return response.json() as Promise<unknown>
1068
+ })
1069
+ .then((data) => {
1070
+ if (!Array.isArray(data)) return
1071
+ // 获取已显示过的公告 ID
1072
+ let noticeShow = [] as number[]
1073
+ const showId = localStorage.getItem('notice_show')
1074
+ if (showId) {
1075
+ noticeShow = showId.split(',').map((id: string) => parseInt(id))
1076
+ }
1077
+ // 解析公告列表
1078
+ data.forEach((notice: any) => {
1079
+ if (notice.version == version && (notice.client == import.meta.env.VITE_APP_CLIENT_TAG || notice.client == 'all')) {
1080
+ const noticeBody = notice as NoticeBodyV3
1081
+ // 当前时间戳(毫秒)
1082
+ const now = new Date().getTime()
1083
+ noticeBody.show_date.forEach((dateInterval: number[]) => {
1084
+ if (dateInterval.length == 2) {
1085
+ // 判断是否在时间区间内
1086
+ if (now >= dateInterval[0] && now <= dateInterval[1]) {
1087
+ noticeBody.is_show = true
1088
+ }
1089
+ }
1090
+ })
1091
+ if (noticeBody.is_important == true || (noticeBody.is_show && noticeBody.id && noticeShow.indexOf(noticeBody.id) < 0)) {
1092
+ // 加载公告弹窗列表
1093
+ for (let i = 0; i < noticeBody.pops.length; i++) {
1094
+ // 添加弹窗
1095
+ const info = noticeBody.pops[i]
1096
+ let popInfo = null as any
1097
+ const button = [
1098
+ {
1099
+ text:
1100
+ /* eslint-disable */
1101
+ noticeBody.pops.length > 1 && i != noticeBody.pops.length - 1 ?
1102
+ app.config.globalProperties.$t('继续') :
1103
+ (info.button_text ? info.button_text : app.config.globalProperties.$t('确定')),
1104
+ /* eslint-enable */
1105
+ master: true,
1106
+ fun: () => {
1107
+ // 添加已读记录
1108
+ if (noticeShow.indexOf(noticeBody.id) < 0 && !noticeBody.is_important) {
1109
+ noticeShow.push(noticeBody.id)
1110
+ }
1111
+ localStorage.setItem(
1112
+ 'notice_show',
1113
+ noticeShow.toString(),
1114
+ )
1115
+ // 关闭弹窗
1116
+ uiStore.popBoxList.shift()
1117
+ },
1118
+ },
1119
+ ]
1120
+ if (info.link_url) {
1121
+ button.unshift({
1122
+ text: app.config.globalProperties.$t('打开…'),
1123
+ master: false,
1124
+ fun: () => {
1125
+ if (info.link_url) {
1126
+ openLink(info.link_url)
1127
+ }
1128
+ }
1129
+ })
1130
+ }
1131
+ if (info.html) {
1132
+ popInfo = {
1133
+ title: info.title,
1134
+ html: info.html,
1135
+ button: button
1136
+ }
1137
+ } else if (info.template) {
1138
+ popInfo = {
1139
+ title: info.title,
1140
+ template: markRaw(defineAsyncComponent(
1141
+ () => import(`../../components/notice-component/${info.template}.vue`),
1142
+ )),
1143
+ templateValue: markRaw(info.template_data ? info.template_data : {}),
1144
+ button: button
1145
+ }
1146
+ } else {
1147
+ logger.error(null, '未知的公告类型')
1148
+ }
1149
+ if (popInfo) {
1150
+ uiStore.popBoxList.push(popInfo)
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+ })
1156
+ })
1157
+ .catch(() => {
1158
+ // 本地开发没有公告配置时静默跳过
1159
+ })
1160
+ }
1161
+
1162
+ /**
1163
+ * TODO:后端代理请求模式(暂未使用)
1164
+ * @param type 请求类型
1165
+ * @param url 地址
1166
+ * @param cookies Cookies
1167
+ * @param data 数据
1168
+ */
1169
+ export function BackendRequest(type: 'GET' | 'POST', url: string,
1170
+ cookies: string[], data: any = undefined) {
1171
+ backend.call(undefined, 'sys:requestHttp', false, {
1172
+ type: type,
1173
+ url: url,
1174
+ cookies: JSON.stringify(cookies),
1175
+ data: data,
1176
+ })
1177
+ }
1178
+
1179
+ /**
1180
+ * 加载数据解析映射表(JSON Path)
1181
+ * @param name 配置名称
1182
+ * @returns 映射表
1183
+ */
1184
+ export function loadJsonMap(name: string) {
1185
+ let msgPath = undefined as { [key: string]: any } | undefined
1186
+ if (name !== undefined) {
1187
+ try {
1188
+ const msgPathList = import.meta.glob(
1189
+ '../../assets/pathMap/*.yaml', { eager: true })
1190
+ const msgPathKey = Object.keys(msgPathList).find((key) => {
1191
+ return key.includes(name)
1192
+ })
1193
+ if (msgPathKey) {
1194
+ msgPath = (msgPathList[msgPathKey] as any).default
1195
+ }
1196
+ if (msgPath) {
1197
+ logger.system('开发者,请稍等一下(翻找),正在为阁下加载 ' + msgPath.name + ' 的服务映射表。')
1198
+ if (msgPath.redirect) {
1199
+ // eslint-disable-next-line
1200
+ const newMsgPathKey = Object.keys(msgPathList).find((key) => {
1201
+ return key.includes(msgPath?.redirect)
1202
+ })
1203
+ let newMsgPath = undefined as
1204
+ { [key: string]: any } | undefined
1205
+ if (newMsgPathKey) {
1206
+ newMsgPath = (msgPathList[newMsgPathKey] as any).default
1207
+ }
1208
+ // 合并映射表
1209
+ if (newMsgPath) {
1210
+ msgPath = {
1211
+ ...newMsgPath,
1212
+ ...msgPath,
1213
+ name: msgPath.name,
1214
+ }
1215
+ }
1216
+ logger.system('非常抱歉开发者,已帮阁下将映射表重定向加载为 :' + msgPath?.name + ' (慌张)')
1217
+ }
1218
+ } else {
1219
+ logger.system('开发者,没有找到你需要的映射表……')
1220
+ }
1221
+ const authStore = useAuthStore()
1222
+ authStore.jsonMap = msgPath
1223
+ } catch (ex) {
1224
+ logger.system('很抱歉开发者,映射表加载失败 ……' + ex)
1225
+ }
1226
+ }
1227
+ return msgPath
1228
+ }
1229
+
1230
+ /**
1231
+ * UM:上报事件
1232
+ * @param event 事件名
1233
+ * @param data 数据
1234
+ * @param saveLocal 是否额外保存到前端 localStorage
1235
+ */
1236
+ export interface LocalStatEventRecord {
1237
+ event: string
1238
+ data: { [key: string]: any }
1239
+ time: number
1240
+ }
1241
+
1242
+ export const LOCAL_STAT_EVENT_STORAGE_KEY = 'local_umami_stat_events'
1243
+ const LOCAL_STAT_EVENT_MAX_COUNT = 500
1244
+
1245
+ function getBrowserLocalStatEvents(): LocalStatEventRecord[] {
1246
+ try {
1247
+ const storage = globalThis.localStorage
1248
+ const raw = storage.getItem(LOCAL_STAT_EVENT_STORAGE_KEY)
1249
+ if (!raw) return []
1250
+ const parsed = JSON.parse(raw)
1251
+ return Array.isArray(parsed) ? parsed : []
1252
+ } catch {
1253
+ return []
1254
+ }
1255
+ }
1256
+
1257
+ function saveBrowserLocalStatEvent(record: LocalStatEventRecord): void {
1258
+ try {
1259
+ const storage = globalThis.localStorage
1260
+ const list = getBrowserLocalStatEvents()
1261
+ list.push(record)
1262
+ if (list.length > LOCAL_STAT_EVENT_MAX_COUNT) {
1263
+ list.splice(0, list.length - LOCAL_STAT_EVENT_MAX_COUNT)
1264
+ }
1265
+ storage.setItem(LOCAL_STAT_EVENT_STORAGE_KEY, JSON.stringify(list))
1266
+ } catch {
1267
+ // ignore local cache failures
1268
+ }
1269
+ }
1270
+
1271
+ export function sendStatEvent(event: string, data: { [key: string]: any }, saveLocal = false) {
1272
+ if (saveLocal) {
1273
+ saveBrowserLocalStatEvent({
1274
+ event,
1275
+ data,
1276
+ time: Date.now(),
1277
+ })
1278
+ }
1279
+
1280
+ // Satori 版本不启用 Stapxs 统计服务
1281
+ }
1282
+
1283
+ /**
1284
+ * UM:上报会话数据
1285
+ * @param data 数据
1286
+ */
1287
+ export function sendIdentifyData(data: { [key: string]: any }) {
1288
+ // Satori 版本不启用 Stapxs 统计服务
1289
+ }
1290
+
1291
+ /**
1292
+ * 切换群组通知状态
1293
+ * @param group_id 群组 ID
1294
+ * @param open 是否开启通知
1295
+ */
1296
+ export function changeGroupNotice(group_id: number, open: boolean) {
1297
+ const authStore = useAuthStore()
1298
+ const noticeInfo = option.get('notice_group') ?? {}
1299
+ const list = noticeInfo[authStore.loginInfo.uin]
1300
+ if (open) {
1301
+ if (list) {
1302
+ list.push(group_id)
1303
+ } else {
1304
+ noticeInfo[authStore.loginInfo.uin] = [group_id]
1305
+ }
1306
+ option.save('notice_group', noticeInfo)
1307
+ } else {
1308
+ if (list) {
1309
+ const index = list.indexOf(group_id)
1310
+ if (index >= 0) {
1311
+ list.splice(index, 1)
1312
+ }
1313
+ }
1314
+ option.save('notice_group', noticeInfo)
1315
+ }
1316
+ }
1317
+
1318
+ /**
1319
+ * 是否应该自动聚焦输入框
1320
+ * @returns
1321
+ */
1322
+ export function shouldAutoFocus(): boolean {
1323
+ // 桌面端
1324
+ if (backend.type !== 'web') {
1325
+ // 除了苹果的不知道啥东西,都可以
1326
+ if (['electron', 'tauri'].includes(backend.type)) {
1327
+ return true
1328
+ }
1329
+ return false
1330
+ }
1331
+ // web端
1332
+ else {
1333
+ // 移动端浏览器不自动聚焦
1334
+ if (/Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
1335
+ return false
1336
+ }
1337
+ return true
1338
+ }
1339
+ }
1340
+
1341
+ /**
1342
+ * 判断是不是机器人
1343
+ * @param id 用户id
1344
+ * @returns
1345
+ */
1346
+ export function isRobot(id: number | string): boolean {
1347
+ id = Number(id)
1348
+ if (id >= 4010000000 && id <= 4019999999) return true
1349
+ if (id >= 2854196301 && id <= 2854216399) return true
1350
+ if (id >= 3889000000 && id <= 3889999999) return true
1351
+ if (id === 66600000) return true
1352
+ return false
1353
+ }
1354
+
1355
+ //#region == use封装 ========================================
1356
+ /**
1357
+ * 用来封装一个停留事件的处理, 支持传递额外上下文
1358
+ * 适用于鼠标或触摸事件,停留一段时间后触发
1359
+ * 例如:长按菜单,鼠标悬浮等
1360
+ * @param getPos 从事件里提取坐标的函数
1361
+ * @param continueTime 成功的持续时间
1362
+ * @param hooks 钩子函数 支持成功时,失败时,退出时
1363
+ * @returns {
1364
+ * acceptStartEvent: (event: T, exArg?: E) => void, // 接受开始事件
1365
+ * acceptUpdateEvent: (event: T) => void, // 接受更新事件
1366
+ * acceptEndEvent: (event: T) => void // 接受结束事件
1367
+ * }
1368
+ */
1369
+ export function useStayEvent<T extends Event, C>(
1370
+ getPos: (event: T) => { x: number, y: number } | void,
1371
+ hooks: {
1372
+ onFit?: ((eventData: MenuEventData, ctx: C) => void)
1373
+ | ((eventData: MenuEventData) => void)
1374
+ | ((ctx: C) => void)
1375
+ | (() => void)
1376
+ onLeave?: ((ctx: C) => void)
1377
+ | (() => void)
1378
+ onFail?: ((ctx: C) => void)
1379
+ | (() => void)
1380
+ },
1381
+ continueTime: number,
1382
+ ): {
1383
+ handle: (event: T, ctx?: C | undefined) => void,
1384
+ handleEnd: (event: T) => void,
1385
+ } {
1386
+ // 表示结束
1387
+ let end: boolean = true
1388
+ // 表示是否符合条件
1389
+ let fit: boolean = false
1390
+ // 记录开始位置
1391
+ let startPos: { x: number, y: number } | undefined = undefined
1392
+ // settiemout
1393
+ let timeout: number
1394
+ // 开始时事件数据
1395
+ let startEventData: MenuEventData
1396
+ // 额外参数
1397
+ let ctx: C | undefined
1398
+ const handle = (event: T, _ctx?: C | undefined) => {
1399
+ if (end) _acceptStartEvent(event, _ctx)
1400
+ else _acceptUpdateEvent(event)
1401
+ }
1402
+ const handleEnd = (event: T) => {
1403
+ if (end) return
1404
+ _acceptEndEvent(event)
1405
+ }
1406
+ const _acceptStartEvent = (event: T, _ctx?: C | undefined) => {
1407
+ fit = false
1408
+ end = false
1409
+ ctx = _ctx
1410
+ startPos = getPos(event) as { x: number, y: number }
1411
+ if (!startPos) return
1412
+ startEventData = {
1413
+ x: startPos.x,
1414
+ y: startPos.y,
1415
+ target: event.target as HTMLElement,
1416
+ }
1417
+ timeout = setTimeout(() => {
1418
+ fit = true
1419
+ _callFit()
1420
+ }, continueTime) as unknown as number
1421
+ }
1422
+ const _acceptUpdateEvent = (event: T) => {
1423
+ if (end) return
1424
+ const pos = getPos(event)
1425
+ if (!pos) return
1426
+ if (!startPos) return
1427
+ // 位置改变
1428
+ if (Math.abs(pos.x - startPos.x) > 10 ||
1429
+ Math.abs(pos.y - startPos.y) > 10) {
1430
+ _setEnd()
1431
+ }
1432
+ }
1433
+ const _acceptEndEvent = (event: T) => {
1434
+ if (end) return
1435
+ _setEnd()
1436
+ if (getPos(event)) _acceptUpdateEvent(event)
1437
+ }
1438
+ // ==工具函数=====================================
1439
+ const _setEnd = () => {
1440
+ end = true
1441
+ if (fit) _callLeave()
1442
+ else _callFail()
1443
+ // 清除定时器
1444
+ clearTimeout(timeout)
1445
+ }
1446
+ const _callFit = () => {
1447
+ if (hooks.onFit?.length === 0) {
1448
+ (hooks.onFit as () => void)()
1449
+ } else if (hooks.onFit?.length === 1) {
1450
+ let arg
1451
+ if (ctx) arg = ctx
1452
+ else arg = startEventData;
1453
+
1454
+ (hooks.onFit as (arg: MenuEventData | C) => void)(arg)
1455
+ } else if (hooks.onFit?.length === 2) {
1456
+ (hooks.onFit as (eventData: MenuEventData, ctx?: C) => void)(startEventData, ctx)
1457
+ }
1458
+ }
1459
+ const _callLeave = () => {
1460
+ if (hooks.onLeave?.length === 0) {
1461
+ (hooks.onLeave as () => void)()
1462
+ } else if (hooks.onLeave?.length === 1) {
1463
+ (hooks.onLeave as (ctx?: C) => void)(ctx)
1464
+ }
1465
+ }
1466
+ const _callFail = () => {
1467
+ if (hooks.onFail?.length === 0) {
1468
+ (hooks.onFail as () => void)()
1469
+ } else if (hooks.onFail?.length === 1) {
1470
+ (hooks.onFail as (ctx?: C) => void)(ctx)
1471
+ }
1472
+ }
1473
+ return {
1474
+ handle,
1475
+ handleEnd,
1476
+ }
1477
+ }
1478
+
1479
+ /**
1480
+ * 使用事件监听器
1481
+ * @param target 目标dom
1482
+ * @param event 事件
1483
+ * @param callback 回调
1484
+ */
1485
+ export function useEventListener<T extends keyof DocumentEventMap>(
1486
+ target: Document,
1487
+ event: T,
1488
+ callback: (event: DocumentEventMap[T]) => void) {
1489
+ // 如果你想的话,
1490
+ // 也可以用字符串形式的 CSS 选择器来寻找目标 DOM 元素
1491
+ onMounted(() => target.addEventListener(event, callback))
1492
+ onUnmounted(() => target.removeEventListener(event, callback))
1493
+ }
1494
+
1495
+ let viewportUnitsCache: { vw: ShallowRef<number>, vh: ShallowRef<number> } | undefined
1496
+ export function useViewportUnits(): { vw: ShallowRef<number>, vh: ShallowRef<number> } {
1497
+ if (viewportUnitsCache)
1498
+ return viewportUnitsCache
1499
+
1500
+ const vw = shallowRef(window.innerWidth / 100)
1501
+ const vh = shallowRef(window.innerHeight / 100)
1502
+
1503
+ const updateUnits = () => {
1504
+ vw.value = window.innerWidth / 100
1505
+ vh.value = window.innerHeight / 100
1506
+ }
1507
+
1508
+ onMounted(() => {
1509
+ window.addEventListener('resize', updateUnits)
1510
+ })
1511
+
1512
+ onUnmounted(() => {
1513
+ window.removeEventListener('resize', updateUnits)
1514
+ })
1515
+
1516
+ viewportUnitsCache = { vw, vh }
1517
+
1518
+ return { vw, vh }
1519
+ }
1520
+
1521
+ /**
1522
+ * 添加一个按键监听,支持组合键,注意,不支持多普通键组合,如`a+b`
1523
+ * @param keys 按键
1524
+ * @param callback 回调,返回true则阻断事件传播
1525
+ */
1526
+ export function useKeyboard(...args: [string, ...string[], () => boolean | undefined]) {
1527
+ if (args.length > 2) {
1528
+ const cb = args.at(-1) as () => boolean | undefined
1529
+ for (const key of args.slice(0, -1)) {
1530
+ if (typeof key !== 'string') continue
1531
+ useKeyboard(key, cb)
1532
+ }
1533
+ return
1534
+ }
1535
+ // 支持组合键,如 'ctrl+shift+alt+s' 或 'a+b+c'
1536
+ const keyList = args[0].toLowerCase().split('+').map(k => k.trim())
1537
+ const cb = args[1] as () => boolean | undefined
1538
+ const modifierKeys = ['ctrl', 'shift', 'alt', 'meta']
1539
+
1540
+ useEventListener(document, 'keydown', (event) => {
1541
+ let allMatch = true
1542
+
1543
+ for (const key of keyList) {
1544
+ if (modifierKeys.includes(key)) {
1545
+ if (!event[`${key}Key`]) {
1546
+ allMatch = false
1547
+ break
1548
+ }
1549
+ }
1550
+ // 普通键
1551
+ else if (event.key.toLowerCase() !== key) {
1552
+ allMatch = false
1553
+ break
1554
+ }
1555
+ }
1556
+
1557
+ // 只在所有键都匹配时触发
1558
+ if (allMatch) {
1559
+ const re = cb()
1560
+ if (re) {
1561
+ event.preventDefault()
1562
+ event.stopPropagation()
1563
+ }
1564
+ }
1565
+ })
1566
+ }
1567
+
1568
+
1569
+ function localStorageGetItem(key: string): string | null {
1570
+ if (backend.type === 'electron') {
1571
+ return backend.callSync('opt:get', key)
1572
+ } else {
1573
+ // eslint-disable-next-line no-restricted-globals
1574
+ return localStorage.getItem(key)
1575
+ }
1576
+ }
1577
+
1578
+ function localStorageSetItem(key: string, value: string): void {
1579
+ if (backend.type === 'electron') {
1580
+ backend.callSync('opt:store', { key, value })
1581
+ } else {
1582
+ // eslint-disable-next-line no-restricted-globals
1583
+ localStorage.setItem(key, value)
1584
+ }
1585
+ }
1586
+
1587
+ /**
1588
+ * 使用 localStorage
1589
+ * @param key 保存的键值
1590
+ * @param defaultValue 默认值
1591
+ * @returns
1592
+ */
1593
+ export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
1594
+ const parser = (data: string) => {
1595
+ return JSON.parse(data).value as T
1596
+ }
1597
+ const serializer = (data: T) => {
1598
+ return JSON.stringify({ value: data })
1599
+ }
1600
+ const storageData = localStorageGetItem(key)
1601
+ const data = ref<T>(storageData ? parser(storageData) : defaultValue)
1602
+ watch(
1603
+ data,
1604
+ (newValue) => {
1605
+ localStorageSetItem(key, serializer(newValue))
1606
+ },
1607
+ { deep: true },
1608
+ )
1609
+ return data as Ref<T>
1610
+ }
1611
+ //#endregion
1612
+
1613
+ //#region == v命令封装 ======================================
1614
+ /**
1615
+ * 创建一个右键菜单指令
1616
+ * 用于闭包公用停留事件控制器
1617
+ * @returns
1618
+ */
1619
+ function createVMenu(): Directive<HTMLElement, (event: MenuEventData) => void> {
1620
+ // 右键菜单事件数据类型
1621
+ type Binding = DirectiveBinding<(event: MenuEventData) => void> & { modifiers: { prevent?: boolean, stop?: boolean } }
1622
+
1623
+ let activeMenu = false
1624
+ const activePenPointers = new Set<number>()
1625
+
1626
+ const isPenTouchEvent = (event: TouchEvent) => {
1627
+ if (activePenPointers.size > 0) return true
1628
+ const touch = event.changedTouches[0] || event.touches[0]
1629
+ const touchType = (touch as Touch & { touchType?: string })?.touchType
1630
+ return touchType === 'stylus'
1631
+ }
1632
+
1633
+ const recordPointerType = (event: PointerEvent, isStart: boolean) => {
1634
+ if (event.pointerType !== 'pen') return
1635
+ if (isStart) {
1636
+ activePenPointers.add(event.pointerId)
1637
+ activeMenu = false
1638
+ } else {
1639
+ activePenPointers.delete(event.pointerId)
1640
+ }
1641
+ }
1642
+
1643
+ // 右键菜单事件数据类型
1644
+ const {
1645
+ handle: menuTouchHandle,
1646
+ handleEnd: menuTouchEnd,
1647
+ } = useStayEvent(
1648
+ (event: TouchEvent) => {
1649
+ if (event.touches.length > 0) {
1650
+ const touch = event.touches[0]
1651
+ return { x: touch.clientX, y: touch.clientY }
1652
+ }
1653
+ return undefined
1654
+ },
1655
+ {
1656
+ onFit: (data: MenuEventData, binding: Binding) => {
1657
+ // 触发右键菜单事件
1658
+ binding.value(data)
1659
+ activeMenu = true
1660
+ }
1661
+ },
1662
+ 400,
1663
+ )
1664
+
1665
+ // 创建指令
1666
+ return {
1667
+ mounted(el: HTMLElement, binding: Binding,) {
1668
+ // 创建变量
1669
+ const prevent = binding.modifiers.prevent || false
1670
+ const stop = binding.modifiers.stop || false
1671
+ const controller = new AbortController()
1672
+ const options = { signal: controller.signal }
1673
+
1674
+ // 修复由于 touch 阻断 click 事件冒泡的问题
1675
+ let touchStartTime = 0
1676
+
1677
+ // 添加监听
1678
+ el.addEventListener('contextmenu', (event) => {
1679
+ if (prevent) event.preventDefault()
1680
+ if (stop) event.stopPropagation()
1681
+ const data: MenuEventData = {
1682
+ x: event.clientX,
1683
+ y: event.clientY,
1684
+ target: event.target as HTMLElement,
1685
+ }
1686
+ binding.value(data)
1687
+ }, options)
1688
+ el.addEventListener('touchstart', (event) => {
1689
+ if (isPenTouchEvent(event)) return
1690
+ menuTouchHandle(event, binding)
1691
+ touchStartTime = Date.now()
1692
+ }, options)
1693
+ el.addEventListener('touchmove', (event) => {
1694
+ if (isPenTouchEvent(event)) return
1695
+ if (prevent && activeMenu) event.preventDefault()
1696
+ if (stop && activeMenu) event.stopPropagation()
1697
+ menuTouchHandle(event, binding)
1698
+ }, options)
1699
+ el.addEventListener('touchend', (event) => {
1700
+ if (isPenTouchEvent(event)) {
1701
+ activeMenu = false
1702
+ return
1703
+ }
1704
+ if (prevent && activeMenu) event.preventDefault()
1705
+ if (stop && activeMenu) event.stopPropagation()
1706
+ menuTouchEnd(event)
1707
+ activeMenu = false
1708
+ // 快速点击则触发点击事件
1709
+ if (Date.now() - touchStartTime < 200)
1710
+ event.target?.['click']?.()
1711
+ }, options)
1712
+ el.addEventListener('pointerdown', (event) => {
1713
+ recordPointerType(event, true)
1714
+ }, options)
1715
+ el.addEventListener('pointerup', (event) => {
1716
+ recordPointerType(event, false)
1717
+ }, options)
1718
+ el.addEventListener('pointercancel', (event) => {
1719
+ recordPointerType(event, false)
1720
+ }, options)
1721
+
1722
+ // 绑定控制器
1723
+ ; (el as any)._vMenuController = controller
1724
+ },
1725
+ unmounted(el: HTMLElement) {
1726
+ const controller = (el as any)._vMenuController
1727
+ if (!controller) return
1728
+
1729
+ controller.abort()
1730
+ delete (el as any)._vMenuController
1731
+ },
1732
+ }
1733
+ }
1734
+ /**
1735
+ * 创建一个右键菜单指令
1736
+ * @example v-menu="(data: MenuEventData) => 打开菜单函数(data, 其他参数)"
1737
+ */
1738
+ export const vMenu: Directive<HTMLElement, (event: MenuEventData) => void, 'prevent' | 'stop'> = createVMenu()
1739
+ /**
1740
+ * 挂在时如果设备支持,自动聚焦输入框
1741
+ */
1742
+ export const vAutoFocus: Directive<HTMLInputElement | HTMLTextAreaElement, undefined> = {
1743
+ mounted(el: HTMLInputElement | HTMLTextAreaElement) {
1744
+ // 判断是否支持聚焦
1745
+ if (!shouldAutoFocus()) return
1746
+
1747
+ // 检查元素是否可见
1748
+ const isVisible = () => {
1749
+ const style = window.getComputedStyle(el)
1750
+ return style.display !== 'none' &&
1751
+ style.visibility !== 'hidden' &&
1752
+ style.opacity !== '0'
1753
+ }
1754
+
1755
+ if (!isVisible()) return
1756
+
1757
+ el.focus()
1758
+ }
1759
+ }
1760
+
1761
+ export interface SearchBinding<T extends object> {
1762
+ originList: Iterable<T>
1763
+ isSearch: boolean
1764
+ query: T[]
1765
+ forceUpdate?: number // 强制刷新
1766
+ match: (item: T, query: string) => boolean
1767
+ }
1768
+
1769
+ /**
1770
+ * 生成一个 Search 指令
1771
+ */
1772
+ export function createVSearch<T extends object>(): Directive<HTMLInputElement, SearchBinding<T>> {
1773
+ return {
1774
+ mounted(el, binding: DirectiveBinding<SearchBinding<T>>) {
1775
+ const controller = new AbortController()
1776
+ const queryTxt = ref('')
1777
+
1778
+ el.addEventListener('input', () => {
1779
+ queryTxt.value = el.value.trim()
1780
+ }, { signal: controller.signal })
1781
+
1782
+ const stopWatchEffect = watchEffect(() => {
1783
+ void binding.value.forceUpdate
1784
+ if (!queryTxt.value) {
1785
+ binding.value.isSearch = false
1786
+ binding.value.query = []
1787
+ } else {
1788
+ binding.value.isSearch = true
1789
+ binding.value.query = shallowReactive(Array.from(binding.value.originList)
1790
+ .filter(item => binding.value.match(item, queryTxt.value)))
1791
+ }
1792
+ })
1793
+ const stopWatch = watch(() => binding.value.isSearch, (isSearch) => {
1794
+ if (!isSearch) {
1795
+ binding.value.query = []
1796
+ }
1797
+ })
1798
+ ; (el as any)._vSearchController = controller
1799
+ ; (el as any)._vStopWatch = { stopWatch, stopWatchEffect }
1800
+ },
1801
+ unmounted(el) {
1802
+ const controller = (el as any)._vSearchController
1803
+ const stopWatch = (el as any)._vStopWatch
1804
+ if (controller) {
1805
+ controller.abort()
1806
+ delete (el as any)._vSearchController
1807
+ }
1808
+ if (stopWatch) {
1809
+ (stopWatch.stopWatch as WatchHandle).stop()
1810
+ ; (stopWatch.stopWatchEffect as WatchHandle).stop()
1811
+ delete (el as any)._vStopWatch
1812
+ }
1813
+ }
1814
+ }
1815
+ }
1816
+ /**
1817
+ * 输入时在制定列表里搜索匹配的项
1818
+ * @example v-search="{
1819
+ * originList: 制定的列表,
1820
+ * isSearch: 当前是否在搜索,
1821
+ * query: 搜索结果列表,
1822
+ * }"
1823
+ * @see createVSearch
1824
+ */
1825
+ export const vSearch = createVSearch<any>()
1826
+
1827
+ /**
1828
+ * 是否隐藏元素
1829
+ * 如果值为 true,则隐藏元素(通过设置 opacity 为 0)
1830
+ * 如果值为 false,则显示元素(通过清除 opacity)
1831
+ * @example v-hide="true"
1832
+ */
1833
+ export const vHide: Directive<HTMLElement, boolean> = {
1834
+ mounted(el: HTMLElement, binding: DirectiveBinding<boolean>) {
1835
+ if (binding.value)
1836
+ el.style.opacity = '0'
1837
+
1838
+ else
1839
+ el.style.opacity = ''
1840
+ },
1841
+ updated(el: HTMLElement, binding: DirectiveBinding<boolean>) {
1842
+ if (binding.value)
1843
+ el.style.opacity = '0'
1844
+
1845
+ else
1846
+ el.style.opacity = ''
1847
+ }
1848
+ }
1849
+
1850
+ /**
1851
+ * 监听 Esc 键按下事件
1852
+ * 当按下 Esc 键时,执行绑定的函数
1853
+ * @example v-esc="退出函数"
1854
+ */
1855
+ export const vEsc: Directive<HTMLElement, () => void> = {
1856
+ mounted(el: HTMLElement, binding: DirectiveBinding<() => void>) {
1857
+ const controller = new AbortController()
1858
+ const options = { signal: controller.signal }
1859
+
1860
+ // 监听键盘事件
1861
+ const keydownHandler = (event: KeyboardEvent) => {
1862
+ if (event.key === 'Escape') {
1863
+ event.stopPropagation()
1864
+ binding.value()
1865
+ }
1866
+ }
1867
+ document.addEventListener('keydown', keydownHandler, options)
1868
+ ; (el as any)._vEscController = controller
1869
+ },
1870
+ unmounted(el: HTMLElement) {
1871
+ const controller = (el as any)._vEscController
1872
+
1873
+ if (!controller) return
1874
+
1875
+ controller.abort()
1876
+ delete (el as any)._vEscController
1877
+ }
1878
+ }
1879
+
1880
+ /**
1881
+ * v-move的选项
1882
+ */
1883
+ export interface VMoveOptions<T extends HTMLElement> {
1884
+ beforeHook?: (el: T) => void
1885
+ moveHook?: (el: T, move: number) => void
1886
+ endHook?: (el: T) => void
1887
+ leftLimit?: {
1888
+ value: number,
1889
+ type: 'px' | '%'
1890
+ }
1891
+ rightLimit?: {
1892
+ value: number,
1893
+ type: 'px' | '%'
1894
+ }
1895
+ speedCondition?: {
1896
+ minMove: {
1897
+ value: number,
1898
+ type: 'px' | '%'
1899
+ }
1900
+ minSpeed: number
1901
+ },
1902
+ moveCondition?: {
1903
+ minMove: {
1904
+ value: number,
1905
+ type: 'px' | '%'
1906
+ }
1907
+ }
1908
+ }
1909
+
1910
+ function createVMove<T extends HTMLElement>(): Directive<T, VMoveOptions<T>> {
1911
+ return {
1912
+ mounted(el: T, binding: DirectiveBinding<VMoveOptions<T>>) {
1913
+ const options = binding.value
1914
+
1915
+ const moveFlag = {
1916
+ _move: 0,
1917
+ get move() {
1918
+ return this._move
1919
+ },
1920
+ set move(value: number) {
1921
+ if (value < -getLimit('left')) value = -getLimit('left')
1922
+ if (value > getLimit('right')) value = getLimit('right')
1923
+ this._move = value
1924
+ },
1925
+ onScroll: 'none' as 'none' | 'touch' | 'wheel',
1926
+ lastTime: null as null | number,
1927
+ speedList: [] as number[],
1928
+ touchLast: null as null | TouchEvent,
1929
+ }
1930
+ const activePenPointers = new Set<number>()
1931
+
1932
+ const isPenTouchEvent = (event: TouchEvent) => {
1933
+ if (activePenPointers.size > 0) return true
1934
+ const touch = event.changedTouches[0] || event.touches[0]
1935
+ const touchType = (touch as Touch & { touchType?: string })?.touchType
1936
+ return touchType === 'stylus'
1937
+ }
1938
+
1939
+ const recordPointerType = (event: PointerEvent, isStart: boolean) => {
1940
+ if (event.pointerType !== 'pen') return
1941
+ if (isStart) {
1942
+ activePenPointers.add(event.pointerId)
1943
+ moveFlag.touchLast = null
1944
+ } else {
1945
+ activePenPointers.delete(event.pointerId)
1946
+ }
1947
+ }
1948
+
1949
+ const getPxValue = (option: { type: 'px' | '%', value: number }) => {
1950
+ if (option.type === 'px') return option.value
1951
+ return el.getBoundingClientRect().width * option.value / 100
1952
+ }
1953
+ const getLimit = (type: 'left' | 'right') => {
1954
+ const option: { type: 'px' | '%', value: number } = options?.[type + 'Limit']
1955
+ if (!option) return 0
1956
+ return getPxValue(option)
1957
+ }
1958
+
1959
+ // 滚轮滑动
1960
+ const chatWheelEvent = (event: WheelEvent) => {
1961
+ const process = (event: WheelEvent) => {
1962
+ // 正在触屏,不处理
1963
+ if (moveFlag.onScroll === 'touch') return false
1964
+ const x = event.deltaX
1965
+ const y = event.deltaY
1966
+ const absX = Math.abs(x)
1967
+ const absY = Math.abs(y)
1968
+ // 斜度过大
1969
+ if (absY !== 0 && absX / absY < 2) return false
1970
+ dispenseMove('wheel', -x / 3)
1971
+ return true
1972
+ }
1973
+ if (!process(event)) return
1974
+ event.stopPropagation()
1975
+ event.preventDefault()
1976
+ // 创建遮罩
1977
+ // 由于在窗口移动中,窗口判定箱也在移动,当指针不再窗口外,事件就断了
1978
+ // 所以要创建一个不会动的全局遮罩来处理
1979
+ wheelMask(process, () => {
1980
+ dispenseMove('wheel', 0, true)
1981
+ })
1982
+ }
1983
+
1984
+ // 触屏开始
1985
+ const chatMoveStartEvent = (event: TouchEvent) => {
1986
+ if (moveFlag.onScroll === 'wheel') return
1987
+ if (isPenTouchEvent(event)) return
1988
+ // 触屏开始时,记录触摸点
1989
+ moveFlag.touchLast = event
1990
+ }
1991
+
1992
+ // 触屏滑动
1993
+ const chatMoveEvent = (event: TouchEvent) => {
1994
+ if (moveFlag.onScroll === 'wheel') return
1995
+ if (isPenTouchEvent(event)) return
1996
+ if (!moveFlag.touchLast) return
1997
+ const touch = event.changedTouches[0]
1998
+ const lastTouch = moveFlag.touchLast.changedTouches[0]
1999
+ const deltaX = touch.clientX - lastTouch.clientX
2000
+ const deltaY = touch.clientY - lastTouch.clientY
2001
+ const absX = Math.abs(deltaX)
2002
+ const absY = Math.abs(deltaY)
2003
+ // 斜度过大
2004
+ if (absY !== 0 && absX / absY < 2) return
2005
+ event.stopPropagation()
2006
+ event.preventDefault()
2007
+ // 触屏移动
2008
+ moveFlag.touchLast = event
2009
+ dispenseMove('touch', deltaX)
2010
+ }
2011
+
2012
+ // 触屏滑动结束
2013
+ const chatMoveEndEvent = (event: TouchEvent) => {
2014
+ if (moveFlag.onScroll === 'wheel') return
2015
+ if (isPenTouchEvent(event)) {
2016
+ moveFlag.touchLast = null
2017
+ return
2018
+ }
2019
+ const touch = event.changedTouches[0]
2020
+ const lastTouch = moveFlag.touchLast?.changedTouches[0]
2021
+ if (lastTouch) {
2022
+ const deltaX = touch.clientX - lastTouch.clientX
2023
+ const deltaY = touch.clientY - lastTouch.clientY
2024
+ const absX = Math.abs(deltaX)
2025
+ const absY = Math.abs(deltaY)
2026
+ // 斜度过大
2027
+ if (absY === 0 || absX / absY > 2) {
2028
+ dispenseMove('touch', deltaX)
2029
+ }
2030
+ }
2031
+ dispenseMove('touch', 0, true)
2032
+ moveFlag.touchLast = null
2033
+ }
2034
+ /**
2035
+ * 分发触屏/滚轮情况
2036
+ */
2037
+ const dispenseMove = (type: 'touch' | 'wheel', value: number, end: boolean = false) => {
2038
+ if (!end && moveFlag.onScroll === 'none') startMove(type, value)
2039
+ if (moveFlag.onScroll === 'none') return
2040
+ if (end) endMove()
2041
+ else keepMove(value)
2042
+ }
2043
+
2044
+ /**
2045
+ * 开始窗口移动
2046
+ */
2047
+ const startMove = (type: 'touch' | 'wheel', value: number) => {
2048
+ // 记录 flag
2049
+ moveFlag.onScroll = type
2050
+ moveFlag.move = value
2051
+ moveFlag.lastTime = Date.now()
2052
+
2053
+ // 执行前置钩子
2054
+ options?.beforeHook?.(el)
2055
+ // 执行移动钩子
2056
+ options?.moveHook?.(el, moveFlag.move)
2057
+ }
2058
+ /**
2059
+ * 保持窗口移动
2060
+ */
2061
+ const keepMove = (value: number) => {
2062
+ // 增加移动值
2063
+ moveFlag.move += value
2064
+ // 计算速度
2065
+ const nowDate = Date.now()
2066
+ if (!moveFlag.lastTime) return
2067
+ const deltaTime = nowDate - moveFlag.lastTime
2068
+ moveFlag.lastTime = nowDate
2069
+ moveFlag.speedList.push(value / deltaTime)
2070
+
2071
+ // 执行移动钩子
2072
+ options?.moveHook?.(el, moveFlag.move)
2073
+ }
2074
+ /**
2075
+ * 结束窗口移动
2076
+ */
2077
+ const endMove = () => {
2078
+ // 保留自己要的数据
2079
+ const move = moveFlag.move
2080
+ const speedList = moveFlag.speedList
2081
+
2082
+ // 移动距离判定
2083
+ if (
2084
+ options?.moveCondition &&
2085
+ Math.abs(move) >= getPxValue(options.moveCondition.minMove)
2086
+ ) {
2087
+ if (move > 0)
2088
+ el.dispatchEvent(new CustomEvent('v-move-right', { detail: move }))
2089
+ else
2090
+ el.dispatchEvent(new CustomEvent('v-move-left', { detail: move }))
2091
+ } else
2092
+ // 速度判定
2093
+ if (
2094
+ options?.speedCondition &&
2095
+ Math.abs(move) >= getPxValue(options.speedCondition.minMove)
2096
+ ) {
2097
+ const endSpeedList = speedList.toReversed().slice(0, 10)
2098
+ let endSpeed = 0
2099
+ for (const speed of endSpeedList) {
2100
+ endSpeed += speed
2101
+ }
2102
+ endSpeed /= endSpeedList.length
2103
+ if (Math.abs(endSpeed) > options.speedCondition.minSpeed) {
2104
+ if (endSpeed > 0)
2105
+ el.dispatchEvent(new CustomEvent('v-move-right', { detail: move }))
2106
+ else
2107
+ el.dispatchEvent(new CustomEvent('v-move-left', { detail: move }))
2108
+ }
2109
+ }
2110
+
2111
+ // 执行结束钩子
2112
+ binding.value?.endHook?.(el)
2113
+
2114
+ // 重置数据
2115
+ moveFlag.onScroll = 'none'
2116
+ moveFlag.lastTime = 0
2117
+ moveFlag.speedList = []
2118
+ moveFlag.move = 0
2119
+ }
2120
+
2121
+ // 添加监听
2122
+ const controller = new AbortController()
2123
+ const listenerOptions = { signal: controller.signal }
2124
+ el.addEventListener('wheel', chatWheelEvent, { ...listenerOptions, passive: false })
2125
+ el.addEventListener('pointerdown', (event) => {
2126
+ recordPointerType(event, true)
2127
+ }, listenerOptions)
2128
+ el.addEventListener('pointerup', (event) => {
2129
+ recordPointerType(event, false)
2130
+ }, listenerOptions)
2131
+ el.addEventListener('pointercancel', (event) => {
2132
+ recordPointerType(event, false)
2133
+ }, listenerOptions)
2134
+ el.addEventListener('touchstart', chatMoveStartEvent, listenerOptions)
2135
+ el.addEventListener('touchmove', chatMoveEvent, listenerOptions)
2136
+ el.addEventListener('touchend', chatMoveEndEvent, listenerOptions)
2137
+ ; (el as any)._vMoveController = controller
2138
+
2139
+ },
2140
+ unmounted(el: T) {
2141
+ const controller = (el as any)._vMoveController
2142
+ if (!controller) return
2143
+
2144
+ controller.abort()
2145
+ delete (el as any)._vMoveController
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ function createVLongHover(): Directive<HTMLElement, undefined> {
2151
+ const {
2152
+ handle: userHoverHandle,
2153
+ handleEnd: userHoverEnd,
2154
+ } = useStayEvent((event: MouseEvent) => {
2155
+ return { x: event.clientX, y: event.clientY, }
2156
+ }, {
2157
+ onFit: (eventData, ctx: HTMLElement) => {
2158
+ ctx.dispatchEvent(new CustomEvent('v-long-hover', { detail: eventData }))
2159
+ },
2160
+ onLeave: (ctx: HTMLElement) => {
2161
+ ctx.dispatchEvent(new CustomEvent('v-long-hover-end'))
2162
+ }
2163
+ }, 495
2164
+ )
2165
+ return {
2166
+ mounted(el: HTMLElement) {
2167
+ const controller = new AbortController()
2168
+ const options = { signal: controller.signal }
2169
+
2170
+ el.addEventListener('mouseenter', (event) => {
2171
+ userHoverHandle(event, el)
2172
+ }, options)
2173
+ el.addEventListener('mousemove', (event) => {
2174
+ userHoverHandle(event, el)
2175
+ }, options)
2176
+ el.addEventListener('mouseleave', (event) => {
2177
+ userHoverEnd(event)
2178
+ }, options)
2179
+ ; (el as any)._vLongHoverController = controller
2180
+ },
2181
+ unmounted(el: HTMLElement) {
2182
+ const controller = (el as any)._vLongHoverController
2183
+ if (!controller) return
2184
+
2185
+ controller.abort()
2186
+ delete (el as any)._vLongHoverController
2187
+ }
2188
+ }
2189
+ }
2190
+
2191
+ /**
2192
+ * 监听元素左滑动/右滑动事件
2193
+ * 当元素被左滑动时,触发 'v-move-left' 事件
2194
+ * 当元素被右滑动时,触发 'v-move-right' 事件
2195
+ * @example <dom v-move="{
2196
+ * beforeHook: 开始移动前的钩子函数,
2197
+ * moveHook: 移动中的钩子函数,
2198
+ * endHook: 结束移动后的钩子函数,
2199
+ * leftLimit: 左侧移动限制,
2200
+ * rightLimit: 右侧移动限制,
2201
+ * speedCondition: 根据速度判定是否触发移动事件的条件,
2202
+ * moveCondition: 根据移动距离判定是否触发移动事件的条件,
2203
+ * }"
2204
+ * onV-move-left="(move) => 左滑动事件(move)"
2205
+ * onV-move-right="(move) => 右滑动事件(move)"
2206
+ * />
2207
+ */
2208
+ export const vMove = createVMove<any>()
2209
+
2210
+ /**
2211
+ * 监听元素长时间悬停事件
2212
+ * 当元素被鼠标悬停超过一定时间后,触发 'v-long-hover' 事件
2213
+ * 当鼠标移出元素时,触发 'v-long-hover-end' 事件
2214
+ * @example <dom v-long-hover
2215
+ * onV-long-hover="(eventData) => 长悬停事件(eventData)"
2216
+ * onV-long-hover-end="() => 长悬停结束事件()"
2217
+ * />
2218
+ */
2219
+ export const vLongHover = createVLongHover()
2220
+
2221
+ type VTooltipBinding<T extends Component> =
2222
+ | T
2223
+ | VueCompData<T>
2224
+ | (() => T | VueCompData<T>)
2225
+ | ((eventData: { x: number, y: number }) => T | VueCompData<T>)
2226
+
2227
+ function resolveBinding<T extends Component>(binding: VTooltipBinding<T>, eventData: { x: number, y: number }): VueCompData<T> {
2228
+ if (typeof binding === 'function') {
2229
+ // eslint-disable-next-line multiline-ternary
2230
+ const result = binding.length === 0
2231
+ // eslint-disable-next-line multiline-ternary
2232
+ ? (binding as () => T | VueCompData<T>)()
2233
+ : (binding as (eventData: { x: number, y: number }) => T | VueCompData<T>)(eventData)
2234
+ if ('comp' in result) return result
2235
+ return { comp: result } as VueCompData<T>
2236
+ } else if ('comp' in binding) {
2237
+ return binding
2238
+ } else {
2239
+ return { comp: binding, props: {}, model: undefined } as unknown as VueCompData<T>
2240
+ }
2241
+ }
2242
+
2243
+ /**
2244
+ * 监听元素长时间悬停事件以显示提示工具
2245
+ * 当元素被鼠标悬停超过一定时间后,显示提示工具
2246
+ * 当鼠标移出元素时,关闭提示工具
2247
+ * @modifiers debug - 调试模式,启用后悬停结束时不会关闭提示工具
2248
+ * @example <dom v-tooltip="{
2249
+ * comp: 提示组件,
2250
+ * props: 传递给提示组件的属性,
2251
+ * model: 传递给提示组件的 v-model 数据,
2252
+ * emit: 传递给提示组件的事件,
2253
+ * }" />
2254
+ */
2255
+ export const vTooltip = {
2256
+ mounted<T extends Component>(el: HTMLElement, binding: DirectiveBinding<VTooltipBinding<T>> & { modifiers: { debug?: boolean } }) {
2257
+ const controller = new AbortController()
2258
+ const options = { signal: controller.signal }
2259
+ ; (vLongHover as any).mounted(el)
2260
+ ; (el as any)._vTooltipController = controller
2261
+
2262
+ let tooltip: TooltipController | undefined
2263
+
2264
+ el.addEventListener('v-long-hover', (ev: Event) => {
2265
+ const event = ev as CustomEvent<{ x: number, y: number }>
2266
+ const detail = event.detail
2267
+ const compData = resolveBinding(binding.value, detail)
2268
+ tooltip = addTooltip(compData, { x: detail.x, y: detail.y })
2269
+ }, options)
2270
+
2271
+ el.addEventListener('v-long-hover-end', () => {
2272
+ if (binding.modifiers?.debug) return
2273
+ tooltip?.close()
2274
+ tooltip = undefined
2275
+ }, options)
2276
+ },
2277
+
2278
+ unmounted(el: HTMLElement) {
2279
+ (vLongHover as any).unmounted(el)
2280
+ const controller = (el as any)._vTooltipController
2281
+ if (!controller) return
2282
+
2283
+ controller.abort()
2284
+ delete (el as any)._vTooltipController
2285
+ }
2286
+ }
2287
+
2288
+ //#endregion