소스 검색

修复bug

yuanmingze 2 주 전
부모
커밋
b1896489e0

+ 4 - 0
README.md

@@ -7,6 +7,10 @@
 - pnpm build:h5
 - pnpm build:mp-weixin
 
+微信真机调试请运行 `pnpm dev:mp-weixin`,并在微信开发者工具中导入
+`dist/dev/mp-weixin`。该目录包含开发日志和源码映射;`dist/build/mp-weixin`
+是压缩后的发布产物,不用于定位真机接口或源码报错。
+
 微信小程序生产构建支持以下三个目标环境:
 
 | 环境   | 接口                       | AppID      |

+ 7 - 1
src/App.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
+import { onError, onHide, onLaunch, onShow, onUnhandledRejection } from '@dcloudio/uni-app'
 
 onLaunch(() => {
   console.log('App Launch')
@@ -10,6 +10,12 @@ onShow(() => {
 onHide(() => {
   console.log('App Hide')
 })
+onError((error) => {
+  console.error('[App] 运行时异常', error)
+})
+onUnhandledRejection((result) => {
+  console.error('[App] 未处理的 Promise 异常', result.reason)
+})
 </script>
 <style lang="scss">
 @import '@/styles/index.scss';

+ 1 - 6
src/pages-task/task-form/components/DynamicTaskFormFields.vue

@@ -54,9 +54,7 @@
             v-model:value="form.value[item.id].value"
             :task-field-config="item"
             :disabled="isFieldDisabled(item)"
-            @keyboard-focus="emit('long-text-keyboard-focus')"
-            @keyboard-blur="emit('long-text-keyboard-blur')"
-            @keyboard-height-change="emit('long-text-keyboard-height-change', $event)"
+            @uploading-change="emit('uploading-change', item.id, $event)"
           />
         </template>
 
@@ -179,9 +177,6 @@ const props = withDefaults(
 const emit = defineEmits<{
   (event: 'derived-change', field: TaskFieldConfigViewItem, change: DerivedFieldChangeEvent): void
   (event: 'uploading-change', fieldId: string, uploading: boolean): void
-  (event: 'long-text-keyboard-focus'): void
-  (event: 'long-text-keyboard-blur'): void
-  (event: 'long-text-keyboard-height-change', result: { height?: number }): void
 }>()
 
 const form = defineModel<DynamicFormState>({ required: true })

+ 87 - 7
src/pages-task/task-form/components/ImgAndFileUpload.vue

@@ -225,6 +225,11 @@ const FILE_EXTENSIONS = new Set(['pdf', 'ppt', 'pptx'])
 const FILE_EXTENSION_LIST = ['ppt', 'pptx', 'pdf']
 const IMAGE_MAX_SIZE = 2 * 1024 * 1024
 const FILE_MAX_SIZE = 50 * 1024 * 1024
+const MAX_WATERMARK_LONG_EDGE = 1920
+const WATERMARK_JPEG_QUALITY = 0.82
+const WATERMARK_TIMEOUT_MS = 20000
+const UPLOAD_TIMEOUT_MS = 30000
+const uploadLogEnabled = import.meta.env.DEV || import.meta.env.VITE_REQUEST_LOG === 'true'
 const SOURCE_SUFFIX_MAP: Record<UploadSource, SourceSuffix> = {
   camera: ';1',
   album: ';2',
@@ -483,7 +488,10 @@ const uploadBatch = async (files: SelectedFile[], source: UploadSource): Promise
       }
 
       try {
-        const uploadPath = source === 'camera' ? await addWatermark(file.path) : file.path
+        const uploadPath =
+          source === 'camera'
+            ? await withTimeout(addWatermark(file.path), WATERMARK_TIMEOUT_MS, '图片处理超时')
+            : file.path
         const originalFileName = isFileUpload ? name : ''
         const uploadedUrl = await uploadFile(uploadPath, originalFileName, isFileUpload)
 
@@ -532,15 +540,27 @@ const uploadFile = (
   trackProgress = false
 ): Promise<string> => {
   return new Promise((resolve, reject) => {
+    const startedAt = Date.now()
+    const uploadUrl = getUploadUrl(originalFileName)
+
+    if (uploadLogEnabled) {
+      console.info(`[ImgAndFileUpload][upload] POST ${uploadUrl.split('?')[0]}`)
+    }
+
     const uploadTask = uni.uploadFile({
-      url: getUploadUrl(originalFileName),
+      url: uploadUrl,
       filePath,
       name: 'file',
+      timeout: UPLOAD_TIMEOUT_MS,
       header: {
         Authorization: `Bearer ${userStore.access_token}`,
       },
       success: (result) => {
         if (result.statusCode < 200 || result.statusCode >= 300) {
+          console.error('[ImgAndFileUpload][upload] 接口状态异常', {
+            statusCode: result.statusCode,
+            durationMs: Date.now() - startedAt,
+          })
           reject(new Error(`上传失败(${result.statusCode})`))
           return
         }
@@ -550,16 +570,42 @@ const uploadFile = (
 
           if (String(response.code) !== '0' || !response.data?.url) {
             const message = response.msg === 'duplicate' ? '上传文件重复' : response.msg
+
+            console.error('[ImgAndFileUpload][upload] 接口返回失败', {
+              statusCode: result.statusCode,
+              code: response.code,
+              message: response.msg,
+              durationMs: Date.now() - startedAt,
+            })
             reject(new Error(message || '上传失败'))
             return
           }
 
+          if (uploadLogEnabled) {
+            console.info('[ImgAndFileUpload][upload] 上传成功', {
+              statusCode: result.statusCode,
+              durationMs: Date.now() - startedAt,
+            })
+          }
           resolve(response.data.url)
-        } catch {
+        } catch (error) {
+          console.error('[ImgAndFileUpload][upload] 响应解析失败', {
+            statusCode: result.statusCode,
+            response: result.data,
+            durationMs: Date.now() - startedAt,
+            error,
+          })
           reject(new Error('上传结果解析失败'))
         }
       },
-      fail: (error) => reject(new Error(error.errMsg || '上传失败')),
+      fail: (error) => {
+        console.error('[ImgAndFileUpload][upload] 网络请求失败', {
+          message: error.errMsg,
+          durationMs: Date.now() - startedAt,
+          error,
+        })
+        reject(new Error(error.errMsg || '上传失败'))
+      },
     })
 
     if (trackProgress) {
@@ -792,8 +838,20 @@ const addWatermark = (filePath: string): Promise<string> => {
     uni.getImageInfo({
       src: filePath,
       success: async (imageInfo) => {
-        const width = imageInfo.width
-        const height = imageInfo.height
+        const sourceWidth = Number(imageInfo.width)
+        const sourceHeight = Number(imageInfo.height)
+
+        if (!sourceWidth || !sourceHeight) {
+          reject(new Error('无法读取图片尺寸'))
+          return
+        }
+
+        const canvasScale = Math.min(
+          1,
+          MAX_WATERMARK_LONG_EDGE / Math.max(sourceWidth, sourceHeight)
+        )
+        const width = Math.max(1, Math.round(sourceWidth * canvasScale))
+        const height = Math.max(1, Math.round(sourceHeight * canvasScale))
 
         watermarkCanvasWidth.value = width
         watermarkCanvasHeight.value = height
@@ -818,7 +876,7 @@ const addWatermark = (filePath: string): Promise<string> => {
         const textTop = 6 * scaleRatio
         const fontSize = 10 * scaleRatio
 
-        context.drawImage(filePath, 0, 0, width, height)
+        context.drawImage(imageInfo.path || filePath, 0, 0, width, height)
         context.setFontSize(fontSize)
 
         const textRows = wrapText(
@@ -862,6 +920,11 @@ const addWatermark = (filePath: string): Promise<string> => {
               {
                 canvasId: canvasId.value,
                 fileType: 'jpg',
+                width,
+                height,
+                destWidth: width,
+                destHeight: height,
+                quality: WATERMARK_JPEG_QUALITY,
                 success: (result) => resolve(result.tempFilePath),
                 fail: reject,
               },
@@ -963,6 +1026,23 @@ const isCancelError = (error: unknown): boolean => {
   return getErrorMessage(error).toLowerCase().includes('cancel')
 }
 
+const withTimeout = <T,>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> => {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
+
+    promise.then(
+      (result) => {
+        clearTimeout(timer)
+        resolve(result)
+      },
+      (error) => {
+        clearTimeout(timer)
+        reject(error)
+      }
+    )
+  })
+}
+
 const showMessage = (title: string, duration?: number): void => {
   uni.showToast({
     title,

+ 136 - 17
src/pages-task/task-form/components/ImgUpload.vue

@@ -40,7 +40,16 @@
 </template>
 
 <script setup lang="ts">
-import { computed, type CSSProperties, getCurrentInstance, onMounted, ref } from 'vue'
+import {
+  computed,
+  type CSSProperties,
+  getCurrentInstance,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  watch,
+} from 'vue'
 
 import { getLocation } from '@/lib/location'
 
@@ -52,8 +61,8 @@ type FieldValue = string
 type UploadSource = 'camera' | 'album'
 
 interface UploadResponse {
-  code: number
-  success: boolean
+  code: number | string
+  success?: boolean
   msg: string | null
   data: {
     url: string
@@ -79,6 +88,10 @@ const props = withDefaults(defineProps<Props>(), {
   canvasId: 'imgUploadWatermarkCanvas',
 })
 
+const emit = defineEmits<{
+  (event: 'uploading-change', uploading: boolean): void
+}>()
+
 const value = defineModel<FieldValue>('value', {
   default: '',
 })
@@ -97,6 +110,11 @@ const watermarkCanvasHeight = ref(1)
 let watermarkAddressPromise: Promise<void> | null = null
 
 const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
+const MAX_WATERMARK_LONG_EDGE = 1920
+const WATERMARK_JPEG_QUALITY = 0.82
+const WATERMARK_TIMEOUT_MS = 20000
+const UPLOAD_TIMEOUT_MS = 30000
+const uploadLogEnabled = import.meta.env.DEV || import.meta.env.VITE_REQUEST_LOG === 'true'
 
 const isDisabled = computed(() => props.disabled)
 
@@ -207,16 +225,25 @@ const chooseImage = async () => {
 
     const uploadedUrls: string[] = []
     let failedCount = 0
+    let firstFailureMessage = ''
 
     for (const filePath of filePaths) {
       try {
-        const uploadFilePath = source === 'camera' ? await addWatermark(filePath) : filePath
+        const uploadFilePath =
+          source === 'camera'
+            ? await withTimeout(addWatermark(filePath), WATERMARK_TIMEOUT_MS, '图片处理超时')
+            : filePath
         const uploadedUrl = await upload(uploadFilePath)
 
         uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
       } catch (error) {
         failedCount += 1
-        console.error('[ImgUpload] 单张图片上传失败', error)
+        firstFailureMessage ||= getErrorMessage(error)
+        console.error('[ImgUpload] 图片处理或上传失败', {
+          source,
+          message: getErrorMessage(error),
+          error,
+        })
       }
     }
 
@@ -230,7 +257,7 @@ const chooseImage = async () => {
     if (!allUploaded) {
       resultTitle = uploadedUrls.length
         ? `${uploadedUrls.length}张成功,${failedCount}张失败`
-        : '上传失败'
+        : firstFailureMessage || '上传失败'
     }
 
     hideUploadLoading()
@@ -297,10 +324,18 @@ const chooseImages = (count: number, source: UploadSource): Promise<string[]> =>
 
 const upload = (filePath: string): Promise<string> => {
   return new Promise((resolve, reject) => {
+    const startedAt = Date.now()
+    const uploadUrl = `${baseUrl}/admin/api/file/upload/mobile`
+
+    if (uploadLogEnabled) {
+      console.info(`[ImgUpload][upload] POST ${uploadUrl}`)
+    }
+
     uni.uploadFile({
-      url: `${baseUrl}/admin/api/file/upload/mobile`,
+      url: uploadUrl,
       filePath,
       name: 'file',
+      timeout: UPLOAD_TIMEOUT_MS,
       header: {
         Authorization: `Bearer ${userStore.access_token}`,
       },
@@ -311,20 +346,46 @@ const upload = (filePath: string): Promise<string> => {
           if (
             res.statusCode < 200 ||
             res.statusCode >= 300 ||
-            !parsed.success ||
-            parsed.code !== 0 ||
+            String(parsed.code) !== '0' ||
             !parsed.data?.url
           ) {
-            reject(new Error(parsed.msg || 'upload failed'))
+            const error = new Error(parsed.msg || `上传失败(${res.statusCode})`)
+
+            console.error('[ImgUpload][upload] 接口返回失败', {
+              statusCode: res.statusCode,
+              code: parsed.code,
+              message: parsed.msg,
+              durationMs: Date.now() - startedAt,
+            })
+            reject(error)
             return
           }
 
+          if (uploadLogEnabled) {
+            console.info('[ImgUpload][upload] 上传成功', {
+              statusCode: res.statusCode,
+              durationMs: Date.now() - startedAt,
+            })
+          }
           resolve(parsed.data.url)
-        } catch {
-          reject(new Error('parse upload response failed'))
+        } catch (error) {
+          console.error('[ImgUpload][upload] 响应解析失败', {
+            statusCode: res.statusCode,
+            response: res.data,
+            durationMs: Date.now() - startedAt,
+            error,
+          })
+          reject(new Error('上传结果解析失败'))
         }
       },
-      fail: reject,
+      fail: (error) => {
+        console.error('[ImgUpload][upload] 网络请求失败', {
+          message: error.errMsg,
+          durationMs: Date.now() - startedAt,
+          error,
+        })
+        reject(new Error(error.errMsg || '上传失败'))
+      },
     })
   })
 }
@@ -333,12 +394,25 @@ const addWatermark = (filePath: string): Promise<string> => {
   return new Promise((resolve, reject) => {
     uni.getImageInfo({
       src: filePath,
-      success: (imageInfo) => {
-        const width = imageInfo.width
-        const height = imageInfo.height
+      success: async (imageInfo) => {
+        const sourceWidth = Number(imageInfo.width)
+        const sourceHeight = Number(imageInfo.height)
+
+        if (!sourceWidth || !sourceHeight) {
+          reject(new Error('无法读取图片尺寸'))
+          return
+        }
+
+        const canvasScale = Math.min(
+          1,
+          MAX_WATERMARK_LONG_EDGE / Math.max(sourceWidth, sourceHeight)
+        )
+        const width = Math.max(1, Math.round(sourceWidth * canvasScale))
+        const height = Math.max(1, Math.round(sourceHeight * canvasScale))
 
         watermarkCanvasWidth.value = width
         watermarkCanvasHeight.value = height
+        await nextTick()
 
         const componentInstance = instance?.proxy
         const ctx = uni.createCanvasContext(
@@ -361,7 +435,7 @@ const addWatermark = (filePath: string): Promise<string> => {
         const textTop = 6 * scaleRatio
         const fontSize = 10 * scaleRatio
 
-        ctx.drawImage(filePath, 0, 0, width, height)
+        ctx.drawImage(imageInfo.path || filePath, 0, 0, width, height)
         ctx.setFontSize(fontSize)
 
         const text = getWatermarkText()
@@ -402,6 +476,11 @@ const addWatermark = (filePath: string): Promise<string> => {
               {
                 canvasId: props.canvasId,
                 fileType: 'jpg',
+                width,
+                height,
+                destWidth: width,
+                destHeight: height,
+                quality: WATERMARK_JPEG_QUALITY,
                 success: (res) => {
                   resolve(res.tempFilePath)
                 },
@@ -584,6 +663,46 @@ const isCancelError = (error: unknown) => {
 
   return errMsg.includes('cancel')
 }
+
+const getErrorMessage = (error: unknown): string => {
+  if (error instanceof Error) return error.message
+
+  if (error && typeof error === 'object') {
+    const rawError = error as { errMsg?: unknown; msg?: unknown }
+    const message = rawError.errMsg ?? rawError.msg
+
+    if (typeof message === 'string') return message
+  }
+
+  return ''
+}
+
+const withTimeout = <T,>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> => {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
+
+    promise.then(
+      (result) => {
+        clearTimeout(timer)
+        resolve(result)
+      },
+      (error) => {
+        clearTimeout(timer)
+        reject(error)
+      }
+    )
+  })
+}
+
+watch(uploading, (isUploading) => {
+  emit('uploading-change', isUploading)
+})
+
+onBeforeUnmount(() => {
+  if (uploading.value) {
+    emit('uploading-change', false)
+  }
+})
 </script>
 
 <style lang="scss" scoped>

+ 2 - 9
src/pages-task/task-form/components/LongText.vue

@@ -11,11 +11,10 @@
           :maxlength="maxLength"
           :placeholder="'请输入' + taskFieldConfig.taskFiledValue"
           :disabled="props.disabled"
+          :adjust-position="true"
+          :cursor-spacing="120"
           clearable
           custom-style="--wot-textarea-inner-min-height: 220rpx; min-height: 300rpx; width: 100%; padding-bottom: 56rpx; box-sizing: border-box;"
-          @focus="emit('keyboard-focus')"
-          @blur="emit('keyboard-blur')"
-          @keyboardheightchange="emit('keyboard-height-change', $event)"
         />
 
         <text class="word-limit"> {{ currentLength }}/{{ maxLength }} </text>
@@ -40,12 +39,6 @@ const props = withDefaults(defineProps<Props>(), {
   disabled: false,
 })
 
-const emit = defineEmits<{
-  (event: 'keyboard-focus'): void
-  (event: 'keyboard-blur'): void
-  (event: 'keyboard-height-change', result: { height?: number }): void
-}>()
-
 const value = defineModel<FieldValue>('value', {
   default: '',
 })

+ 0 - 63
src/pages-task/task-form/composables/useLongTextKeyboardVisibility.ts

@@ -1,63 +0,0 @@
-import { ref } from 'vue'
-
-import { onShow, onUnload } from '@dcloudio/uni-app'
-
-type KeyboardHeightChangeResult = {
-  height?: number
-}
-
-const KEYBOARD_BLUR_FALLBACK_DELAY_MS = 350
-
-export const useLongTextKeyboardVisibility = () => {
-  const longTextKeyboardVisible = ref(false)
-  let keyboardBlurFallbackTimer: ReturnType<typeof setTimeout> | undefined
-
-  const clearKeyboardBlurFallback = () => {
-    if (keyboardBlurFallbackTimer === undefined) return
-
-    clearTimeout(keyboardBlurFallbackTimer)
-    keyboardBlurFallbackTimer = undefined
-  }
-
-  const resetLongTextKeyboardState = () => {
-    clearKeyboardBlurFallback()
-    longTextKeyboardVisible.value = false
-  }
-
-  const handleLongTextKeyboardFocus = () => {
-    clearKeyboardBlurFallback()
-    longTextKeyboardVisible.value = true
-  }
-
-  const handleLongTextKeyboardBlur = () => {
-    clearKeyboardBlurFallback()
-
-    // 长文本之间切换时会先 blur 再 focus,延迟恢复可避免底部操作栏闪现。
-    keyboardBlurFallbackTimer = setTimeout(() => {
-      longTextKeyboardVisible.value = false
-      keyboardBlurFallbackTimer = undefined
-    }, KEYBOARD_BLUR_FALLBACK_DELAY_MS)
-  }
-
-  const handleLongTextKeyboardHeightChange = (result: KeyboardHeightChangeResult) => {
-    const height = Number(result.height ?? 0)
-
-    if (Number.isFinite(height) && height > 0) {
-      clearKeyboardBlurFallback()
-      longTextKeyboardVisible.value = true
-      return
-    }
-
-    resetLongTextKeyboardState()
-  }
-
-  onShow(resetLongTextKeyboardState)
-  onUnload(resetLongTextKeyboardState)
-
-  return {
-    longTextKeyboardVisible,
-    handleLongTextKeyboardFocus,
-    handleLongTextKeyboardBlur,
-    handleLongTextKeyboardHeightChange,
-  }
-}

+ 2 - 12
src/pages-task/task-form/index.vue

@@ -44,9 +44,6 @@
             :disabled="isActionPending"
             @derived-change="handleDerivedFieldChange"
             @uploading-change="handleFieldUploadingChange"
-            @long-text-keyboard-focus="handleLongTextKeyboardFocus"
-            @long-text-keyboard-blur="handleLongTextKeyboardBlur"
-            @long-text-keyboard-height-change="handleLongTextKeyboardHeightChange"
           />
         </template>
       </view>
@@ -61,7 +58,7 @@
 
     <view
       v-if="!isQuestionnaireTask && taskFieldConfigList.length"
-      v-show="!longTextKeyboardVisible"
+      v-show="!keyboardVisible"
       class="task-form-footer"
       :style="footerStyle"
     >
@@ -98,7 +95,6 @@ import DuplicateImageDialog from './components/DuplicateImageDialog.vue'
 import DynamicTaskFormFields from './components/DynamicTaskFormFields.vue'
 import QuestionnaireTask from './components/QuestionnaireTask.vue'
 import TaskFormSkeleton from './components/TaskFormSkeleton.vue'
-import { useLongTextKeyboardVisibility } from './composables/useLongTextKeyboardVisibility'
 import { useTaskForm } from './composables/useTaskForm'
 import { useTaskFormAction } from './composables/useTaskFormAction'
 import { useTaskFormPageFeatures } from './composables/useTaskFormPageFeatures'
@@ -138,13 +134,7 @@ const {
 })
 const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskFormPageFeatures()
 
-const { contentStyle, footerStyle, initSafeArea } = useTaskFormSafeArea()
-const {
-  longTextKeyboardVisible,
-  handleLongTextKeyboardFocus,
-  handleLongTextKeyboardBlur,
-  handleLongTextKeyboardHeightChange,
-} = useLongTextKeyboardVisibility()
+const { contentStyle, footerStyle, keyboardVisible, initSafeArea } = useTaskFormSafeArea()
 
 const isQuestionnaireTask = computed(() => taskTypeId.value === '801')
 const isAttachmentUploading = computed(() => uploadingFieldIds.value.size > 0)

+ 60 - 2
src/services/request/index.ts

@@ -31,6 +31,22 @@ interface RequestConfig extends RequestOptions {
   data?: any
 }
 
+const requestLogEnabled = import.meta.env.DEV || import.meta.env.VITE_REQUEST_LOG === 'true'
+let requestSequence = 0
+
+const getLogUrl = (url: string): string => url.split('?')[0]
+
+const getBusinessSummary = (data: unknown): Record<string, unknown> => {
+  if (!data || typeof data !== 'object') return {}
+
+  const body = data as Record<string, unknown>
+
+  return {
+    code: body.code,
+    message: body.msg ?? body.message,
+  }
+}
+
 async function finishLoading(loading: boolean, failed = false) {
   if (failed) {
     await dismissGlobalLoading()
@@ -43,13 +59,22 @@ function send<T>(config: RequestConfig, raw: true): Promise<T>
 function send<T>(config: RequestConfig, raw?: false): Promise<ApiResponse<T>>
 function send<T>(config: RequestConfig, raw = false): Promise<T | ApiResponse<T>> {
   const loading = config.loading === true
+  const method = config.method ?? 'GET'
+  const resolvedUrl = resolveUrl(config.url)
+  const logUrl = getLogUrl(resolvedUrl)
+  const requestId = ++requestSequence
+  const startedAt = Date.now()
 
   if (loading) showGlobalLoading(config.loadingText)
 
+  if (requestLogEnabled) {
+    console.info(`[HTTP ${requestId}] ${method} ${logUrl}`)
+  }
+
   return new Promise((resolve, reject) => {
     uni.request({
-      url: resolveUrl(config.url),
-      method: config.method ?? 'GET',
+      url: resolvedUrl,
+      method,
       data: config.data,
       timeout: config.timeout ?? TIMEOUT,
       header: buildHeaders(config.url, config.header),
@@ -57,8 +82,14 @@ function send<T>(config: RequestConfig, raw = false): Promise<T | ApiResponse<T>
 
       async success(res) {
         const statusCode = res.statusCode ?? 0
+        const durationMs = Date.now() - startedAt
 
         if (statusCode === 401) {
+          console.error(`[HTTP ${requestId}] ${method} ${logUrl} 未授权`, {
+            statusCode,
+            durationMs,
+            ...getBusinessSummary(res.data),
+          })
           await finishLoading(loading, true)
           handleUnauthorized()
           reject(createRequestError(res.data, config.url, statusCode))
@@ -66,6 +97,11 @@ function send<T>(config: RequestConfig, raw = false): Promise<T | ApiResponse<T>
         }
 
         if (statusCode !== 200) {
+          console.error(`[HTTP ${requestId}] ${method} ${logUrl} 请求失败`, {
+            statusCode,
+            durationMs,
+            ...getBusinessSummary(res.data),
+          })
           await finishLoading(loading, true)
           showErrorModal(res.data)
           reject(createRequestError(res.data, config.url, statusCode))
@@ -73,6 +109,12 @@ function send<T>(config: RequestConfig, raw = false): Promise<T | ApiResponse<T>
         }
 
         if (raw) {
+          if (requestLogEnabled) {
+            console.info(`[HTTP ${requestId}] ${method} ${logUrl} 完成`, {
+              statusCode,
+              durationMs,
+            })
+          }
           await finishLoading(loading)
           resolve(res.data as T)
           return
@@ -80,17 +122,33 @@ function send<T>(config: RequestConfig, raw = false): Promise<T | ApiResponse<T>
 
         const body = res.data as ApiResponse<T>
         if (body?.code === 1 && !isWhiteList(config.url)) {
+          console.error(`[HTTP ${requestId}] ${method} ${logUrl} 业务失败`, {
+            statusCode,
+            durationMs,
+            ...getBusinessSummary(body),
+          })
           await finishLoading(loading, true)
           showErrorModal(body)
           reject(createRequestError(body, config.url, statusCode))
           return
         }
 
+        if (requestLogEnabled) {
+          console.info(`[HTTP ${requestId}] ${method} ${logUrl} 完成`, {
+            statusCode,
+            durationMs,
+            ...getBusinessSummary(body),
+          })
+        }
         await finishLoading(loading)
         resolve(body)
       },
 
       async fail(error) {
+        console.error(`[HTTP ${requestId}] ${method} ${logUrl} 网络异常`, {
+          durationMs: Date.now() - startedAt,
+          error,
+        })
         await finishLoading(loading, true)
         showErrorModal(error)
         reject(createRequestError(error, config.url))