useGlobalLoading.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { computed, reactive } from 'vue'
  2. const DEFAULT_LOADING_TEXT = '数据加载中,请稍候...'
  3. const ROUTE_LOADING_TEXT = '页面加载中,请稍候...'
  4. const SUBMIT_LOADING_TEXT = '数据提交中,请稍候...'
  5. const EXPORT_LOADING_TEXT = '文件导出处理中,请稍候...'
  6. const DOWNLOAD_LOADING_TEXT = '文件下载处理中,请稍候...'
  7. const UPLOAD_LOADING_TEXT = '文件上传中,请稍候...'
  8. const DEFAULT_SHOW_DELAY = 280
  9. const DEFAULT_MIN_VISIBLE_MS = 220
  10. const state = reactive({
  11. entries: [],
  12. visible: false,
  13. message: DEFAULT_LOADING_TEXT,
  14. })
  15. let tokenSeed = 0
  16. let showTimer = null
  17. let hideTimer = null
  18. let visibleSince = 0
  19. function normalizeText(text) {
  20. return String(text || '').trim()
  21. }
  22. function normalizeUrl(url) {
  23. return String(url || '').toLowerCase()
  24. }
  25. function normalizeMethod(method) {
  26. return String(method || 'get').toLowerCase()
  27. }
  28. function readHeader(headers, name) {
  29. if (!headers)
  30. return undefined
  31. if (typeof headers.get === 'function')
  32. return headers.get(name)
  33. const lowerName = name.toLowerCase()
  34. const foundKey = Object.keys(headers).find(key => key.toLowerCase() === lowerName)
  35. return foundKey ? headers[foundKey] : undefined
  36. }
  37. function hasExplicitGlobalLoading(options = {}) {
  38. return options.globalLoading === true
  39. || options.forceGlobalLoading === true
  40. || normalizeText(options.globalLoadingType || options.loadingType)
  41. || normalizeText(options.globalLoadingText || options.loadingText || options.text || options.message)
  42. }
  43. function hasTransferGlobalLoadingHint(options = {}) {
  44. const url = normalizeUrl(options.url)
  45. return options.data instanceof FormData
  46. || url.includes('upload')
  47. || url.includes('import')
  48. || url.includes('export')
  49. || url.includes('download')
  50. }
  51. function shouldAttachRequestGlobalLoading(options = {}) {
  52. return hasExplicitGlobalLoading(options) || hasTransferGlobalLoadingHint(options)
  53. }
  54. function isGlobalLoadingSkipped(options = {}) {
  55. const headerSkip = readHeader(options.headers, 'X-Skip-Global-Loading')
  56. return options.skipGlobalLoading === true
  57. || options.globalLoading === false
  58. || headerSkip === true
  59. || String(headerSkip || '').toLowerCase() === 'true'
  60. }
  61. function normalizeDelay(value, fallback) {
  62. const numberValue = Number(value)
  63. return Number.isFinite(numberValue) && numberValue >= 0 ? numberValue : fallback
  64. }
  65. function resolveShowDelay(options = {}) {
  66. return normalizeDelay(options.globalLoadingDelay ?? options.loadingDelay, DEFAULT_SHOW_DELAY)
  67. }
  68. export function resolveRequestLoadingText(options = {}) {
  69. const explicitText = normalizeText(options.globalLoadingText || options.loadingText || options.text || options.message)
  70. if (explicitText)
  71. return explicitText
  72. const type = normalizeText(options.globalLoadingType || options.loadingType).toLowerCase()
  73. if (type === 'route')
  74. return ROUTE_LOADING_TEXT
  75. if (type === 'upload')
  76. return UPLOAD_LOADING_TEXT
  77. if (type === 'export')
  78. return EXPORT_LOADING_TEXT
  79. if (type === 'download')
  80. return DOWNLOAD_LOADING_TEXT
  81. if (type === 'submit')
  82. return SUBMIT_LOADING_TEXT
  83. const url = normalizeUrl(options.url)
  84. if (url.includes('upload') || options.data instanceof FormData)
  85. return UPLOAD_LOADING_TEXT
  86. if (url.includes('export'))
  87. return EXPORT_LOADING_TEXT
  88. if (url.includes('download'))
  89. return DOWNLOAD_LOADING_TEXT
  90. const method = normalizeMethod(options.method)
  91. if (['post', 'put', 'patch', 'delete'].includes(method))
  92. return SUBMIT_LOADING_TEXT
  93. return DEFAULT_LOADING_TEXT
  94. }
  95. function applyDocumentLock(locked) {
  96. if (typeof document === 'undefined')
  97. return
  98. const className = 'forge-global-loading-locked'
  99. document.documentElement.classList.toggle(className, locked)
  100. document.body?.classList.toggle(className, locked)
  101. }
  102. function syncMessage() {
  103. state.message = state.entries[state.entries.length - 1]?.text || DEFAULT_LOADING_TEXT
  104. }
  105. function clearShowTimer() {
  106. if (!showTimer)
  107. return
  108. clearTimeout(showTimer)
  109. showTimer = null
  110. }
  111. function clearHideTimer() {
  112. if (!hideTimer)
  113. return
  114. clearTimeout(hideTimer)
  115. hideTimer = null
  116. }
  117. function showNow() {
  118. clearShowTimer()
  119. if (state.entries.length === 0)
  120. return
  121. clearHideTimer()
  122. syncMessage()
  123. state.visible = true
  124. visibleSince = Date.now()
  125. applyDocumentLock(true)
  126. }
  127. function hideNow() {
  128. clearShowTimer()
  129. clearHideTimer()
  130. state.visible = false
  131. visibleSince = 0
  132. state.message = DEFAULT_LOADING_TEXT
  133. applyDocumentLock(false)
  134. }
  135. function scheduleShow() {
  136. clearHideTimer()
  137. syncMessage()
  138. if (state.visible) {
  139. applyDocumentLock(true)
  140. return
  141. }
  142. if (showTimer)
  143. return
  144. const delay = state.entries[state.entries.length - 1]?.delay ?? DEFAULT_SHOW_DELAY
  145. if (delay <= 0) {
  146. showNow()
  147. return
  148. }
  149. showTimer = setTimeout(showNow, delay)
  150. }
  151. function scheduleHide() {
  152. clearShowTimer()
  153. if (!state.visible) {
  154. hideNow()
  155. return
  156. }
  157. const elapsed = Date.now() - visibleSince
  158. if (elapsed >= DEFAULT_MIN_VISIBLE_MS) {
  159. hideNow()
  160. return
  161. }
  162. clearHideTimer()
  163. hideTimer = setTimeout(hideNow, DEFAULT_MIN_VISIBLE_MS - elapsed)
  164. }
  165. function syncState() {
  166. if (state.entries.length > 0)
  167. scheduleShow()
  168. else
  169. scheduleHide()
  170. }
  171. export function startGlobalLoading(options = {}) {
  172. if (isGlobalLoadingSkipped(options))
  173. return null
  174. const token = `global-loading-${Date.now()}-${++tokenSeed}`
  175. state.entries = [
  176. ...state.entries,
  177. {
  178. token,
  179. text: resolveRequestLoadingText(options),
  180. delay: resolveShowDelay(options),
  181. startedAt: Date.now(),
  182. },
  183. ]
  184. syncState()
  185. return token
  186. }
  187. export function finishGlobalLoading(token) {
  188. if (!token)
  189. return
  190. const nextEntries = state.entries.filter(item => item.token !== token)
  191. if (nextEntries.length === state.entries.length)
  192. return
  193. state.entries = nextEntries
  194. syncState()
  195. }
  196. export function clearGlobalLoading() {
  197. state.entries = []
  198. syncState()
  199. }
  200. export async function withGlobalLoading(task, options = {}) {
  201. const token = startGlobalLoading(options)
  202. try {
  203. return await task()
  204. }
  205. finally {
  206. finishGlobalLoading(token)
  207. }
  208. }
  209. export async function managedFetch(input, init = {}, options = {}) {
  210. const requestOptions = {
  211. ...options,
  212. method: init?.method || options.method || 'get',
  213. url: typeof input === 'string' ? input : input?.url,
  214. headers: init?.headers || options.headers,
  215. }
  216. const token = startGlobalLoading(requestOptions)
  217. let finished = false
  218. let fallbackTimer = null
  219. function finishOnce() {
  220. if (finished)
  221. return
  222. finished = true
  223. if (fallbackTimer) {
  224. clearTimeout(fallbackTimer)
  225. fallbackTimer = null
  226. }
  227. finishGlobalLoading(token)
  228. }
  229. try {
  230. const response = await fetch(input, init)
  231. const method = normalizeMethod(requestOptions.method)
  232. const hasReadableBody = response.body && method !== 'head' && response.status !== 204 && response.status !== 304
  233. if (!hasReadableBody) {
  234. finishOnce()
  235. return response
  236. }
  237. fallbackTimer = setTimeout(finishOnce, options.globalLoadingMaxBodyWaitMs || 60000)
  238. const bodyMethods = new Set(['arrayBuffer', 'blob', 'formData', 'json', 'text'])
  239. return new Proxy(response, {
  240. get(target, prop) {
  241. const value = Reflect.get(target, prop, target)
  242. if (bodyMethods.has(prop) && typeof value === 'function') {
  243. return async (...args) => {
  244. try {
  245. return await value.apply(target, args)
  246. }
  247. finally {
  248. finishOnce()
  249. }
  250. }
  251. }
  252. return value
  253. },
  254. })
  255. }
  256. catch (error) {
  257. finishOnce()
  258. throw error
  259. }
  260. }
  261. export function attachRequestGlobalLoading(config = {}) {
  262. if (config.__globalLoadingToken)
  263. return config
  264. if (!shouldAttachRequestGlobalLoading(config))
  265. return config
  266. const token = startGlobalLoading({
  267. ...config,
  268. url: config.url,
  269. method: config.method,
  270. data: config.data,
  271. headers: config.headers,
  272. })
  273. if (token)
  274. config.__globalLoadingToken = token
  275. return config
  276. }
  277. export function finishRequestGlobalLoading(config = {}) {
  278. const token = config?.__globalLoadingToken
  279. if (!token)
  280. return
  281. finishGlobalLoading(token)
  282. delete config.__globalLoadingToken
  283. }
  284. export function useGlobalLoading() {
  285. return {
  286. active: computed(() => state.visible),
  287. count: computed(() => state.entries.length),
  288. message: computed(() => state.message),
  289. state,
  290. start: startGlobalLoading,
  291. finish: finishGlobalLoading,
  292. clear: clearGlobalLoading,
  293. }
  294. }