Răsfoiți Sursa

feat: 补充送礼余额不足的弹窗逻辑

陈文艺 1 săptămână în urmă
părinte
comite
31fd3f69b0
36 a modificat fișierele cu 670 adăugiri și 89 ștergeri
  1. 9 2
      Lanu.xcodeproj/project.pbxproj
  2. 18 0
      Lanu/Common/Extension/Date+Extension.swift
  3. 40 0
      Lanu/Common/Extension/TimeInterval+Extension.swift
  4. 2 2
      Lanu/Common/Extension/UIImage+Extension.swift
  5. 2 0
      Lanu/Common/Storage/LNUserDefaultsKey.swift
  6. 1 1
      Lanu/Common/Views/ImagePreview/LNImagePreviewController.swift
  7. 3 3
      Lanu/Common/Views/LNVideoPlayerView.swift
  8. 37 13
      Lanu/Common/Views/Menu/LNCommonAlertView.swift
  9. 1 1
      Lanu/Common/Views/Selection/LNHourRangePickerPanel.swift
  10. 1 1
      Lanu/Common/Views/VideoPreview/LNVideoPreviewController.swift
  11. 2 2
      Lanu/Common/Views/VideoUpload/LNVideoUploadView.swift
  12. 1 1
      Lanu/Common/Voice/LNVoiceRecorder.swift
  13. 1 1
      Lanu/Common/Voice/LNVoiceResourceManager.swift
  14. 187 3
      Lanu/Localizable.xcstrings
  15. 1 1
      Lanu/Manager/Account/LNAccountManager.swift
  16. 2 2
      Lanu/Manager/Deeplink/LNDeeplinkManager.swift
  17. 17 1
      Lanu/Manager/Gift/LNGiftManager.swift
  18. 1 0
      Lanu/Manager/Gift/Network/LNGiftResponse.swift
  19. 5 5
      Lanu/Manager/Network/Download/LNFileDownloader.swift
  20. 3 3
      Lanu/Manager/Network/Upload/LNFileUploader.swift
  21. 2 1
      Lanu/Manager/Order/LNOrderManager.swift
  22. 1 1
      Lanu/Manager/Profile/LNProfileManager.swift
  23. 7 2
      Lanu/Manager/Purchase/LNPurchaseManager.swift
  24. 24 0
      Lanu/Manager/Purchase/Network/LNPurchaseResponse.swift
  25. 0 25
      Lanu/Manager/Room/Network/LNRoomResponse.swift
  26. 3 3
      Lanu/Views/Game/Category/LNGameCategoryListView.swift
  27. 1 1
      Lanu/Views/Game/MateFilter/LNGameCategoryFilterPanel.swift
  28. 1 1
      Lanu/Views/Order/OrderQR/LNOrderGenerateQRCodePanel.swift
  29. 1 1
      Lanu/Views/Room/Gift/LNRoomGiftBottomView.swift
  30. 108 7
      Lanu/Views/Room/Gift/LNRoomGiftHeaderView.swift
  31. 1 1
      Lanu/Views/Room/Join/Apply/LNRoomApplySeatCell.swift
  32. 1 1
      Lanu/Views/Room/Join/Manage/LNRoomManageSeatCell.swift
  33. 2 2
      Lanu/Views/Room/Message/Cells/LNRoomChatMessageCell.swift
  34. 4 1
      Lanu/Views/Room/Message/Cells/LNRoomGiftMessageCell.swift
  35. 49 0
      Lanu/Views/Wallet/LNExchangePanel.swift
  36. 131 0
      Lanu/Views/Wallet/LNMoneyNotEnoughAlertView.swift

+ 9 - 2
Lanu.xcodeproj/project.pbxproj

@@ -422,6 +422,7 @@
 				Views/Wallet/Coin/LNCoinViewController.swift,
 				Views/Wallet/Diamond/LNDiamondViewController.swift,
 				Views/Wallet/LNExchangePanel.swift,
+				Views/Wallet/LNMoneyNotEnoughAlertView.swift,
 				Views/Wallet/LNPurchasePanel.swift,
 				Views/Wallet/LNPurchaseProductView.swift,
 				Views/Wallet/LNWalletViewController.swift,
@@ -442,8 +443,6 @@
 		};
 		FBB67E232EC48B440070E686 /* ThirdParty */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
-			exceptions = (
-			);
 			path = ThirdParty;
 			sourceTree = "<group>";
 		};
@@ -596,10 +595,14 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-input-files.xcfilelist",
 			);
+			inputPaths = (
+			);
 			name = "[CP] Copy Pods Resources";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-output-files.xcfilelist",
 			);
+			outputPaths = (
+			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources.sh\"\n";
@@ -635,10 +638,14 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 			);
+			inputPaths = (
+			);
 			name = "[CP] Embed Pods Frameworks";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 			);
+			outputPaths = (
+			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks.sh\"\n";

+ 18 - 0
Lanu/Common/Extension/Date+Extension.swift

@@ -114,3 +114,21 @@ extension Date {
         return yearsAgo
     }
 }
+
+extension Date {
+    var inMinutes: Bool {
+        timeIntervalSince1970.inMinutes
+    }
+    
+    var inHour: Bool {
+        timeIntervalSince1970.inHour
+    }
+    
+    var inDay: Bool {
+        timeIntervalSince1970.inDay
+    }
+    
+    var isSameDay: Bool {
+        Calendar.current.isDate(Date(), inSameDayAs: self)
+    }
+}

+ 40 - 0
Lanu/Common/Extension/TimeInterval+Extension.swift

@@ -49,4 +49,44 @@ extension TimeInterval {
     var tencentIMTimeDesc: String {
         Date(timeIntervalSince1970: self).tencentIMTimeDesc
     }
+    
+    var relativeTimeText: String {
+        guard curTime > self else { return .init(key: "A00352") }
+        
+        let diff = curTime - self
+        if diff < 60 {
+            return .init(key: "A00352")
+        }
+        
+        let minute = diff / 60
+        if minute < 60 {
+            return minute == 1 ? .init(key: "A00353", minute) : .init(key: "A00354", minute)
+        }
+        
+        let hour = minute / 60
+        if hour < 24 {
+            return hour == 1 ? .init(key: "A00355", hour) : .init(key: "A00356", hour)
+        }
+        
+        let day = hour / 24
+        return day == 1 ? .init(key: "A00357", day) : .init(key: "A00358", day)
+    }
+}
+
+extension TimeInterval {
+    var inMinutes: Bool {
+        curTime - self < 60
+    }
+    
+    var inHour: Bool {
+        curTime - self < 3600
+    }
+    
+    var inDay: Bool {
+        curTime - self < 3600 * 24
+    }
+    
+    var isSameDay: Bool {
+        Calendar.current.isDate(Date(), inSameDayAs: Date(timeIntervalSince1970: self))
+    }
 }

+ 2 - 2
Lanu/Common/Extension/UIImage+Extension.swift

@@ -26,7 +26,7 @@ extension UIImage {
     func saveToLibrary(completion: ((Bool, Error?) -> Void)?) {
         // 步骤1:检查并请求相册权限
         PHPhotoLibrary.requestAuthorization { status in
-            DispatchQueue.main.async { // 回调默认在子线程,切回主线程处理UI
+            runOnMain { // 回调默认在子线程,切回主线程处理UI
                 switch status {
                 case .authorized, .limited: // 授权(含iOS 14+有限授权)
                     // 步骤2:异步保存图片到相册
@@ -34,7 +34,7 @@ extension UIImage {
                         // 创建保存请求
                         PHAssetChangeRequest.creationRequestForAsset(from: self)
                     }) { success, error in
-                        DispatchQueue.main.async {
+                        runOnMain {
                             completion?(success, error)
                         }
                     }

+ 2 - 0
Lanu/Common/Storage/LNUserDefaultsKey.swift

@@ -26,4 +26,6 @@ enum LNUserDefaultsKey: String {
     case reportLocationTime
     
     case joinedRoomId
+    
+    case remainExchange
 }

+ 1 - 1
Lanu/Common/Views/ImagePreview/LNImagePreviewController.swift

@@ -38,7 +38,7 @@ class LNImagePreviewController: LNViewController {
         curIndex = targetIndex
         
         collectionView?.reloadData()
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             collectionView?.scrollToItem(
                 at: .init(row: curIndex, section: 0),

+ 3 - 3
Lanu/Common/Views/LNVideoPlayerView.swift

@@ -100,11 +100,11 @@ class LNVideoPlayerView: UIView {
                 do {
                     let cgImage = try imageGenerator.copyCGImage(at: time, actualTime: nil)
                     let thumbnailImage = UIImage(cgImage: cgImage)
-                    DispatchQueue.main.async {
+                    runOnMain {
                         self.coverImageView.image = thumbnailImage
                     }
                 } catch {
-                    DispatchQueue.main.async {
+                    runOnMain {
                         self.coverImageView.image = UIImage(systemName: "film")
                     }
                 }
@@ -129,7 +129,7 @@ class LNVideoPlayerView: UIView {
                 videoSize = CGSize(width: naturalSize.height, height: naturalSize.width)
             }
             
-            DispatchQueue.main.async { [weak self] in
+            runOnMain { [weak self] in
                 guard let self else { return }
                 guard curSource == url else { return }
                 delegate?.onVideoDidLoad(view: self)

+ 37 - 13
Lanu/Common/Views/Menu/LNCommonAlertView.swift

@@ -26,14 +26,17 @@ class LNCommonAlertView: UIView {
     private let background = UIView()
     private let container = UIView()
     
-    private let messageViews = UIStackView()
-    private let buttonViews = UIStackView()
-    
     private let miniScale = 0.01
     private let animateDuration = 0.15
     
+    let textViews = UIStackView()
     let titleLabel = UILabel()
+    let messageView = UIStackView()
     let messageLabel = UILabel()
+    let subMessageLabel = UILabel()
+    
+    let buttonViews = UIStackView()
+    
     var touchOutsideCancel = true
     
     override init(frame: CGRect) {
@@ -73,12 +76,23 @@ class LNCommonAlertView: UIView {
 
 extension LNCommonAlertView {
     func popup(_ holder: UIView? = nil) {
-        guard let holder = holder ?? UIView.appKeyWindow else { return }
-        holder.addSubview(self)
-        frame = holder.bounds
+        let parentView: UIView? = if let window = holder as? UIWindow {
+            window
+        } else if let view = holder?.viewController?.view {
+            view
+        } else if let window = UIView.appKeyWindow {
+            window
+        } else {
+            nil
+        }
+        guard let parentView else { return }
+        parentView.addSubview(self)
+        frame = parentView.bounds
         
         titleLabel.isHidden = titleLabel.text?.isEmpty != false
         messageLabel.isHidden = messageLabel.text?.isEmpty != false
+        subMessageLabel.isHidden = subMessageLabel.text?.isEmpty != false
+        messageView.isHidden = messageLabel.isHidden && subMessageLabel.isHidden
         
         layoutIfNeeded()
         
@@ -139,10 +153,10 @@ extension LNCommonAlertView {
             make.width.height.equalTo(24)
         }
         
-        messageViews.axis = .vertical
-        messageViews.spacing = 10
-        container.addSubview(messageViews)
-        messageViews.snp.makeConstraints { make in
+        textViews.axis = .vertical
+        textViews.spacing = 10
+        container.addSubview(textViews)
+        textViews.snp.makeConstraints { make in
             make.horizontalEdges.equalToSuperview().inset(24)
             make.top.equalToSuperview().offset(30)
         }
@@ -151,20 +165,30 @@ extension LNCommonAlertView {
         titleLabel.textColor = .text_4
         titleLabel.textAlignment = .center
         titleLabel.numberOfLines = 0
-        messageViews.addArrangedSubview(titleLabel)
+        textViews.addArrangedSubview(titleLabel)
+        
+        messageView.axis = .vertical
+        messageView.spacing = 6
+        textViews.addArrangedSubview(messageView)
         
         messageLabel.font = .body_m
         messageLabel.textColor = .text_4
         messageLabel.textAlignment = .center
         messageLabel.numberOfLines = 0
-        messageViews.addArrangedSubview(messageLabel)
+        messageView.addArrangedSubview(messageLabel)
+        
+        subMessageLabel.font = .body_xs
+        subMessageLabel.textColor = .text_4
+        subMessageLabel.textAlignment = .center
+        subMessageLabel.numberOfLines = 0
+        messageView.addArrangedSubview(subMessageLabel)
         
         buttonViews.axis = .vertical
         buttonViews.spacing = 16
         container.addSubview(buttonViews)
         buttonViews.snp.makeConstraints { make in
             make.horizontalEdges.equalToSuperview().inset(50)
-            make.top.equalTo(messageViews.snp.bottom).offset(16)
+            make.top.equalTo(textViews.snp.bottom).offset(16)
             make.bottom.equalToSuperview().offset(-30)
         }
     }

+ 1 - 1
Lanu/Common/Views/Selection/LNHourRangePickerPanel.swift

@@ -188,7 +188,7 @@ extension LNHourRangePickerPanel {
             make.top.equalTo(pickerView.snp.bottom).offset(4)
         }
         
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             fromPicker.subviews.forEach {
                 if $0.subviews.isEmpty {

+ 1 - 1
Lanu/Common/Views/VideoPreview/LNVideoPreviewController.swift

@@ -38,7 +38,7 @@ class LNVideoPreviewController: LNViewController {
         curIndex = targetIndex
         
         collectionView?.reloadData()
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             collectionView?.scrollToItem(
                 at: .init(row: curIndex, section: 0),

+ 2 - 2
Lanu/Common/Views/VideoUpload/LNVideoUploadView.swift

@@ -74,13 +74,13 @@ class LNVideoUploadView: UIImageView {
                 let cgImage = try imageGenerator.copyCGImage(at: time, actualTime: nil)
                 let thumbnailImage = UIImage(cgImage: cgImage)
                 // 更新UI(必须在主线程)
-                DispatchQueue.main.async {
+                runOnMain {
                     self.image = thumbnailImage
                 }
             } catch {
                 print("生成视频缩略图失败:\(error.localizedDescription)")
                 // 生成失败时显示占位图
-                DispatchQueue.main.async {
+                runOnMain {
                     self.image = UIImage(systemName: "film")
                 }
             }

+ 1 - 1
Lanu/Common/Voice/LNVoiceRecorder.swift

@@ -67,7 +67,7 @@ class LNVoiceRecorder {
         curState = .recording
         curTaskId = "\(curTime)"
         
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             notifyTaskStart()
         }

+ 1 - 1
Lanu/Common/Voice/LNVoiceResourceManager.swift

@@ -131,7 +131,7 @@ class LNVoiceResourceManager {
             var error: NSError?
             let status = asset.statusOfValue(forKey: "duration", error: &error)
             
-            DispatchQueue.main.async { [weak self] in
+            runOnMain { [weak self] in
                 guard let self else { return }
                 switch status {
                 case .loaded:

+ 187 - 3
Lanu/Localizable.xcstrings

@@ -6344,7 +6344,7 @@
         "zh-Hans" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "豆"
+            "value" : "豆"
           }
         }
       }
@@ -11875,13 +11875,13 @@
         "en" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "%1$@ sent %2$@ {icon} ×%3$d"
+            "value" : "%1$@ sent %2$@ {icon} x%3$d"
           }
         },
         "id" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "%1$@ memberi %2$@ {icon} ×%3$d"
+            "value" : "%1$@ memberi %2$@ {icon} x%3$d"
           }
         },
         "zh-Hans" : {
@@ -11892,6 +11892,190 @@
         }
       }
     },
+    "B00131" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Insufficient %@, go to recharge now?"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "%@ tidak cukup, apakah ingin isi ulang sekarang?"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "%@不足,是否前往充值?"
+          }
+        }
+      }
+    },
+    "B00132" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Insufficient %@?"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "%@ tidak cukup?"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "%@不足?"
+          }
+        }
+      }
+    },
+    "B00133" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Available %1$@: {icon}%2$@"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "%1$@ Tersedia: {icon}%2$@"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "可用 %1$@:{icon}%2$@"
+          }
+        }
+      }
+    },
+    "B00134" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Don't remind me today"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Jangan ingatkan hari ini"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "今日不再提醒"
+          }
+        }
+      }
+    },
+    "B00135" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "diamond"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "berlian"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "钻石"
+          }
+        }
+      }
+    },
+    "B00136" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "coin"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "coin"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "金币"
+          }
+        }
+      }
+    },
+    "B00137" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "beans"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "kacang"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "金豆"
+          }
+        }
+      }
+    },
+    "B00138" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Rate: {icon1}%1$@ = {icon2}%2$@"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Tingkat: {icon1}%1$@ = {icon2}%2$@"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "比例: {icon1}%1$@ = {icon2}%2$@"
+          }
+        }
+      }
+    },
     "C00001" : {
       "extractionState" : "manual",
       "localizations" : {

+ 1 - 1
Lanu/Manager/Account/LNAccountManager.swift

@@ -248,7 +248,7 @@ extension LNAccountManager {
     }
     
     private func startCaptchaTimer() {
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             stopCaptchaTimer()
             

+ 2 - 2
Lanu/Manager/Deeplink/LNDeeplinkManager.swift

@@ -110,7 +110,7 @@ class LNDeeplinkManager {
             } else {
                 model = nil
             }
-            DispatchQueue.main.async {
+            runOnMain {
                 handler(model)
             }
         }
@@ -120,7 +120,7 @@ class LNDeeplinkManager {
     func register(url: String, handler: @escaping () -> Void) {
         lock.lock()
         routerMap[url] = { _ in
-            DispatchQueue.main.async {
+            runOnMain {
                 handler()
             }
         }

+ 17 - 1
Lanu/Manager/Gift/LNGiftManager.swift

@@ -130,15 +130,31 @@ extension LNGiftManager {
 extension LNGiftManager {
     func sendGift(params: LNSendGiftParams, queue: DispatchQueue = .main,
                   handler: @escaping (Bool) -> Void) {
+        let remain: TimeInterval = LNUserDefaults[.remainExchange, 0]
+        params.seamlessRedeem = remain.isSameDay
+        
         LNHttpManager.shared.sendGift(params: params) { res, err in
             queue.asyncIfNotGlobal {
                 handler(err == nil)
             }
             if let err {
                 showToast(err.errorDesc)
+                if case .serverError(let code, _) = err {
+                    runOnMain {
+                        if code == LNOrderErrorCode.NotEnoughMoney.rawValue {
+                            let panel = LNMoneyNotEnoughAlertView()
+                            panel.update(.diamond)
+                            panel.popup()
+                        } else if LNOrderErrorCode.NotEnoughMoneyButCanExchange.rawValue == code {
+                            let panel = LNMoneyNotEnoughAlertView()
+                            panel.update(.diamond, exchange: .coin)
+                            panel.popup()
+                        }
+                    }
+                }
             }
             if let res {
-                LNPurchaseManager.shared.updateDiamond(res.diamond)
+                LNPurchaseManager.shared.updateWallet(diamond: res.diamond, coin: res.goldcoin)
             }
         }
     }

+ 1 - 0
Lanu/Manager/Gift/Network/LNGiftResponse.swift

@@ -38,4 +38,5 @@ class LNGiftListResponse: Decodable {
 @AutoCodable
 class LNSendGiftResponse: Decodable {
     var diamond: Double = 0
+    var goldcoin: Double = 0
 }

+ 5 - 5
Lanu/Manager/Network/Download/LNFileDownloader.swift

@@ -111,7 +111,7 @@ class LNFileDownloader: NSObject {
             removeTask(urlString: urlString)
             
             let handlers = task.completionHandler
-            DispatchQueue.main.async {
+            runOnMain {
                 handlers.forEach { $0(.failure(LNFileDownloadError.beCancelled)) }
             }
         }
@@ -173,7 +173,7 @@ extension LNFileDownloader: URLSessionDownloadDelegate {
         
         let progress = totalBytesExpectedToWrite > 0 ? Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) : 0.0
         let handlers = taskModel.progressHandler
-        DispatchQueue.main.async {
+        runOnMain {
             handlers.forEach { $0(progress) }
         }
     }
@@ -211,13 +211,13 @@ extension LNFileDownloader: URLSessionDownloadDelegate {
             
             // 回调成功结果
             let handlers = taskModel.completionHandler
-            DispatchQueue.main.async {
+            runOnMain {
                 handlers.forEach { $0(.success(destinationPath)) }
             }
         } catch {
             // 回调文件移动失败
             let handlers = taskModel.completionHandler
-            DispatchQueue.main.async {
+            runOnMain {
                 handlers.forEach { $0(.failure(LNFileDownloadError.fileMoveFailed)) }
             }
         }
@@ -245,7 +245,7 @@ extension LNFileDownloader: URLSessionTaskDelegate {
             }
             // 其他错误:回调网络错误
             let handlers = taskModel.completionHandler
-            DispatchQueue.main.async {
+            runOnMain {
                 handlers.forEach { $0(.failure(LNFileDownloadError.networkError(error))) }
             }
         }

+ 3 - 3
Lanu/Manager/Network/Upload/LNFileUploader.swift

@@ -111,7 +111,7 @@ class LNFileUploader: NSObject {
         LNHttpManager.shared.getUploadOssUrl(type: type, suffix: suffix) { [weak self] res, err in
             guard let self else { return }
             guard err == nil, let res, let url = URL(string: res.preSignUrl) else {
-                DispatchQueue.main.async {
+                runOnMain {
                     completionHandler?(nil, err?.errorDesc ?? LNHttpError.invalidResponse.errorDesc)
                 }
                 return
@@ -164,7 +164,7 @@ extension LNFileUploader: URLSessionTaskDelegate, URLSessionDataDelegate {
         guard let progressHandler = uploadTasks.first(where: { $0.value.task == task })?.value.progress else { return }
         guard totalBytesExpectedToSend > 0 else { return }
         let progress = Float(totalBytesSent) / Float(totalBytesExpectedToSend)
-        DispatchQueue.main.async {
+        runOnMain {
             progressHandler(progress)
         }
     }
@@ -177,7 +177,7 @@ extension LNFileUploader: URLSessionTaskDelegate, URLSessionDataDelegate {
         uploadTasks.removeValue(forKey: uploadTask.id)
         guard let completionHandler = uploadTask.completion else { return }
         
-        DispatchQueue.main.async {
+        runOnMain {
             if let error = error {
                 if (error as NSError).code == NSURLErrorCancelled {
                     completionHandler(nil, .init(key: "B00015"))

+ 2 - 1
Lanu/Manager/Order/LNOrderManager.swift

@@ -19,6 +19,7 @@ extension LNOrderManagerNotify {
 
 enum LNOrderErrorCode: Int {
     case NotEnoughMoney = 100018
+    case NotEnoughMoneyButCanExchange = 50002
 }
 
 protocol LNOrderProtocol {
@@ -154,7 +155,7 @@ extension LNOrderManager {
             }
             
             if case .serverError(let code, let err) = err {
-                DispatchQueue.main.async {
+                runOnMain {
                     if code == LNOrderErrorCode.NotEnoughMoney.rawValue {
                         let panel = LNPurchasePanel()
                         panel.update(.coin)

+ 1 - 1
Lanu/Manager/Profile/LNProfileManager.swift

@@ -292,7 +292,7 @@ extension LNProfileManager {
     }
     
     private func startCaptchaTimer() {
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             stopCaptchaTimer()
             

+ 7 - 2
Lanu/Manager/Purchase/LNPurchaseManager.swift

@@ -86,8 +86,13 @@ class LNPurchaseManager {
         }
     }
     
-    func updateDiamond(_ diamond: Double) {
-        myWalletInfo.diamond = diamond
+    func updateWallet(diamond: Double? = nil, coin: Double? = nil) {
+        if let diamond {
+            myWalletInfo.diamond = diamond
+        }
+        if let coin {
+            myWalletInfo.coin = coin
+        }
         notifyWalletInfoChanged()
     }
     

+ 24 - 0
Lanu/Manager/Purchase/Network/LNPurchaseResponse.swift

@@ -14,6 +14,30 @@ enum LNCurrencyType: Int, Decodable {
     case coin = 0
     case diamond = 1
     case bean = 2
+    
+    func name(lowcase: Bool = false) -> String {
+        switch self {
+        case .coin: lowcase ? .init(key: "B00136") : .init(key: "A00216")
+        case .diamond: lowcase ? .init(key: "B00135") : .init(key: "A00217")
+        case .bean: lowcase ? .init(key: "B00137") : .init(key: "A00277")
+        }
+    }
+    
+    var icon: UIImage {
+        switch self {
+        case .coin: .icCoin42
+        case .diamond: .icDiamond42
+        case .bean: .icBean
+        }
+    }
+    
+    var currentValue: Double {
+        switch self {
+        case .coin: myWalletInfo.coin
+        case .diamond: myWalletInfo.diamond
+        case .bean: myWalletInfo.bean
+        }
+    }
 }
 
 enum LNPurchasePlatform: Int, Decodable {

+ 0 - 25
Lanu/Manager/Room/Network/LNRoomResponse.swift

@@ -64,31 +64,6 @@ class LNRoomMicApplyPageVO: Decodable {
     var user: LNRoomUserVO = LNRoomUserVO()
     
     var hasAccept = false
-    
-    var relativeTimeText: String {
-        let time = applyTime / 1_000 - Int64(curTime)
-        guard time > 0 else { return .init(key: "A00352") }
-        
-        let now = Int64(Date().timeIntervalSince1970 * 1_000)
-        let diff = max(0, now - time) / 1_000
-        
-        if diff < 60 {
-            return .init(key: "A00352")
-        }
-        
-        let minute = diff / 60
-        if minute < 60 {
-            return minute == 1 ? .init(key: "A00353", minute) : .init(key: "A00354", minute)
-        }
-        
-        let hour = minute / 60
-        if hour < 24 {
-            return hour == 1 ? .init(key: "A00355", hour) : .init(key: "A00356", hour)
-        }
-        
-        let day = hour / 24
-        return day == 1 ? .init(key: "A00357", day) : .init(key: "A00358", day)
-    }
 }
 
 @AutoCodable

+ 3 - 3
Lanu/Views/Game/Category/LNGameCategoryListView.swift

@@ -40,7 +40,7 @@ class LNGameCategoryListView: UIView {
         self.categories = categories
         collectionView.reloadData()
         
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             fixBottomSpace()
         }
@@ -50,7 +50,7 @@ class LNGameCategoryListView: UIView {
         guard let index = categories.firstIndex(where: { $0.code == category.code }) else {
             return
         }
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             if let headerAttributes = collectionView.layoutAttributesForSupplementaryElement(
                 ofKind: UICollectionView.elementKindSectionHeader,
@@ -69,7 +69,7 @@ class LNGameCategoryListView: UIView {
         let width = (bounds.width - collectionViewLayout.minimumInteritemSpacing) / CGFloat(columns) - collectionViewLayout.minimumInteritemSpacing
         collectionViewLayout.itemSize = .init(width: width, height: 68)
         
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             fixBottomSpace()
         }

+ 1 - 1
Lanu/Views/Game/MateFilter/LNGameCategoryFilterPanel.swift

@@ -128,7 +128,7 @@ extension LNGameCategoryFilterPanel {
             }), for: .touchUpInside)
             
             if index == 0 {
-                DispatchQueue.main.async { [weak self, weak button] in
+                runOnMain { [weak self, weak button] in
                     guard let self, let button else { return }
                     handleClickTab(view: button, typeItem: title)
                 }

+ 1 - 1
Lanu/Views/Order/OrderQR/LNOrderGenerateQRCodePanel.swift

@@ -33,7 +33,7 @@ class LNOrderGenerateQRCodePanel: LNPopupView {
         
         setupViews()
         
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             tabView.curType = .normal
             curSkill = myUserInfo.skills.first

+ 1 - 1
Lanu/Views/Room/Gift/LNRoomGiftBottomView.swift

@@ -56,7 +56,7 @@ class LNRoomGiftBottomView: UIView {
         setupViews()
         LNEventDeliver.addObserver(self)
         
-        DispatchQueue.main.async { [weak self] in
+        runOnMain { [weak self] in
             guard let self else { return }
             curCount = 1
             enable = false

+ 108 - 7
Lanu/Views/Room/Gift/LNRoomGiftHeaderView.swift

@@ -16,12 +16,21 @@ protocol LNRoomGiftHeaderViewDelegate: AnyObject {
 
 
 class LNRoomGiftHeaderView: UIView {
-    private let scrollView = UIScrollView()
+    private let roomSeatsView = UIScrollView()
     private let stackView = UIStackView()
+    
+    private let specifiedUserView = LNRoomGiftSpecifiedUserView()
+    
     private weak var roomSession: LNRoomViewModel?
     private var headers: [LNRoomSeatNum: LNRoomGiftAvatarView] = [:]
     var selection: [String] {
-        headers.map { $1 }.filter { !$0.isHidden && $0.isSelected }.compactMap { $0.curSeatItem?.uid }
+        if !roomSeatsView.isHidden {
+            headers.map { $1 }.filter { !$0.isHidden && $0.isSelected }.compactMap { $0.curSeatItem?.uid }
+        } else if let uid = specifiedUserView.curUid {
+            [uid]
+        } else {
+            []
+        }
     }
     weak var delegate: LNRoomGiftHeaderViewDelegate?
     
@@ -34,7 +43,20 @@ class LNRoomGiftHeaderView: UIView {
     
     func update(_ room: LNRoomViewModel?, selectedUid: String?) {
         roomSession = room
-        onRoomSeatsChanged()
+        if selectedUid == nil
+            || room?.seatsInfo.contains(where: { $0.uid == selectedUid }) == true {
+            onRoomSeatsChanged()
+            
+            roomSeatsView.isHidden = false
+            specifiedUserView.isHidden = true
+        } else {
+            roomSeatsView.isHidden = true
+            specifiedUserView.isHidden = false
+        }
+        if let selectedUid {
+            headers.first { $1.curSeatItem?.uid == selectedUid }?.value.isSelected = true
+            specifiedUserView.update(selectedUid)
+        }
     }
     
     required init?(coder: NSCoder) {
@@ -85,21 +107,40 @@ private extension LNRoomGiftHeaderView {
             make.centerY.equalToSuperview()
         }
         
-        scrollView.showsHorizontalScrollIndicator = false
-        addSubview(scrollView)
-        scrollView.snp.makeConstraints { make in
+        let roomSeatsView = buildRoomSeatsView()
+        addSubview(roomSeatsView)
+        roomSeatsView.snp.makeConstraints { make in
             make.leading.equalTo(titleLabel.snp.trailing).offset(6)
             make.trailing.equalToSuperview().offset(-10)
             make.verticalEdges.equalToSuperview()
             make.height.equalTo(40)
         }
         
+        let userView = buildSpecifiedUserView()
+        addSubview(userView)
+        userView.snp.makeConstraints { make in
+            make.leading.equalTo(titleLabel.snp.trailing).offset(6)
+            make.trailing.equalToSuperview().offset(-10)
+            make.centerY.equalToSuperview()
+        }
+    }
+    
+    private func buildRoomSeatsView() -> UIView {
+        roomSeatsView.showsHorizontalScrollIndicator = false
+        
         stackView.axis = .horizontal
-        scrollView.addSubview(stackView)
+        roomSeatsView.addSubview(stackView)
         stackView.snp.makeConstraints { make in
             make.edges.equalToSuperview()
             make.height.equalToSuperview()
         }
+        
+        return roomSeatsView
+    }
+    
+    private func buildSpecifiedUserView() -> UIView {
+        
+        return specifiedUserView
     }
 }
 
@@ -185,3 +226,63 @@ private class LNRoomGiftAvatarView: UIView {
         fatalError("init(coder:) has not been implemented")
     }
 }
+
+private class LNRoomGiftSpecifiedUserView: UIView {
+    private let nameLabel = UILabel()
+    private let avatar = UIImageView()
+    private(set) var curUid: String?
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        let avatarBg = UIView()
+        avatarBg.backgroundColor = .clear
+        avatarBg.layer.cornerRadius = 17
+        avatarBg.layer.borderWidth = 1
+        avatarBg.layer.borderColor = UIColor.primary_4.cgColor
+        addSubview(avatarBg)
+        avatarBg.snp.makeConstraints { make in
+            make.width.height.equalTo(34)
+            make.leading.equalToSuperview()
+            make.verticalEdges.equalToSuperview()
+        }
+        
+        avatar.layer.cornerRadius = 15
+        avatar.clipsToBounds = true
+        avatar.contentMode = .scaleAspectFill
+        avatarBg.addSubview(avatar)
+        avatar.snp.makeConstraints { make in
+            make.center.equalToSuperview()
+            make.width.height.equalTo(30)
+        }
+        
+        nameLabel.font = .body_s
+        nameLabel.textColor = .text_1
+        addSubview(nameLabel)
+        nameLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(avatarBg.snp.trailing).offset(5)
+            make.trailing.equalToSuperview()
+        }
+    }
+    
+    func update(_ uid: String?) {
+        curUid = uid
+        guard let uid, !uid.isEmpty else {
+            isHidden = true
+            return
+        }
+        
+        LNProfileManager.shared.getCachedProfileUserInfo(uid: uid, fetchIfNeeded: true)
+        { [weak self] info in
+            guard let self else { return }
+            guard let info, info.uid == curUid else { return }
+            nameLabel.text = info.name
+            avatar.sd_setImage(with: URL(string: info.avatar))
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}

+ 1 - 1
Lanu/Views/Room/Join/Apply/LNRoomApplySeatCell.swift

@@ -28,7 +28,7 @@ class LNRoomApplySeatCell: UITableViewCell {
         indexLabel.text = "\(index)"
         avatarView.showAvatar(item.user.avatar)
         nameLabel.text = item.user.nickname
-        timeLabel.text = item.relativeTimeText
+        timeLabel.text = TimeInterval(item.applyTime / 1_000).relativeTimeText
         
         genderView.image = switch item.user.gender {
         case .unknow: nil

+ 1 - 1
Lanu/Views/Room/Join/Manage/LNRoomManageSeatCell.swift

@@ -31,7 +31,7 @@ class LNRoomManageSeatCell: UITableViewCell {
         indexLabel.text = "\(index)"
         avatarView.showAvatar(item.user.avatar)
         nameLabel.text = item.user.nickname
-        timeLabel.text = item.relativeTimeText
+        timeLabel.text = TimeInterval(item.applyTime).relativeTimeText
         
         genderView.image = switch item.user.gender {
         case .unknow: nil

+ 2 - 2
Lanu/Views/Room/Message/Cells/LNRoomChatMessageCell.swift

@@ -74,7 +74,7 @@ extension LNRoomChatMessageCell {
             make.trailing.lessThanOrEqualToSuperview()
         }
         
-        nameLabel.font = .heading_h5
+        nameLabel.font = .body_s
         nameLabel.textColor = .text_2
         nameLabel.setContentHuggingPriority(.required, for: .vertical)
         nameLabel.onTap { [weak self] in
@@ -98,7 +98,7 @@ extension LNRoomChatMessageCell {
             make.bottom.equalToSuperview()
         }
         
-        contentLabel.font = .body_s
+        contentLabel.font = .heading_h5
         contentLabel.textColor = .text_1
         contentLabel.numberOfLines = 0
         bubble.addSubview(contentLabel)

+ 4 - 1
Lanu/Views/Room/Message/Cells/LNRoomGiftMessageCell.swift

@@ -52,7 +52,10 @@ class LNRoomGiftMessageCell: UITableViewCell {
 extension LNRoomGiftMessageCell {
     private func buildContent(_ message: LNRoomGiftMessageItem, giftImage: UIImage = .icGift) -> NSAttributedString {
         let text = String(key: "B00130", message.senderName, message.receiverName, message.giftCount)
-        let attr = NSMutableAttributedString(string: text)
+        let attr = NSMutableAttributedString(string: text, attributes: [
+            .font: UIFont.body_s,
+            .foregroundColor: UIColor.text_1
+        ])
         
         let senderRange = (text as NSString).range(of: message.senderName)
         attr.addAttributes([

+ 49 - 0
Lanu/Views/Wallet/LNExchangePanel.swift

@@ -24,6 +24,55 @@ enum LNExchangeType {
         case .beanToDiamond: (.bean, .diamond)
         }
     }
+    
+    static func map(from: LNCurrencyType, to: LNCurrencyType) -> Self? {
+        if from == .coin, to == .diamond {
+            .coinToDiamond
+        } else if from == .diamond, to == .coin {
+            .diamondToCoin
+        } else if from == .bean, to == .coin {
+            .beanToCoin
+        } else if from == .bean, to == .diamond {
+            .beanToDiamond
+        } else {
+            nil
+        }
+    }
+    
+    var rate: Double? {
+        let config = LNConfigManager.shared.commonConfig.commonCoinExchangeConsts
+        
+        switch self {
+        case .coinToDiamond:
+            guard let coin = config.first(where: { $0.IDR != nil && $0.goldCoin != nil }),
+                  let diamond = config.first(where: { $0.IDR != nil && $0.diamond != nil })
+            else {
+                return nil
+            }
+            return coin.IDR! / coin.goldCoin! / (diamond.IDR! / diamond.diamond!)
+        case .diamondToCoin:
+            guard let coin = config.first(where: { $0.IDR != nil && $0.goldCoin != nil }),
+                  let diamond = config.first(where: { $0.IDR != nil && $0.diamond != nil })
+            else {
+                return nil
+            }
+            return diamond.IDR! / diamond.diamond! / (coin.IDR! / coin.goldCoin!)
+        case .beanToCoin:
+            guard let bean = config.first(where: { $0.IDR != nil && $0.bean != nil }),
+                  let coin = config.first(where: { $0.IDR != nil && $0.goldCoin != nil })
+            else {
+                return nil
+            }
+            return bean.IDR! / bean.bean! / (coin.IDR! / coin.goldCoin!)
+        case .beanToDiamond:
+            guard let bean = config.first(where: { $0.IDR != nil && $0.bean != nil }),
+                  let diamond = config.first(where: { $0.IDR != nil && $0.diamond != nil })
+            else {
+                return nil
+            }
+            return bean.IDR! / bean.bean! / (diamond.IDR! / diamond.diamond!)
+        }
+    }
 }
 
 

+ 131 - 0
Lanu/Views/Wallet/LNMoneyNotEnoughAlertView.swift

@@ -0,0 +1,131 @@
+//
+//  LNMoneyNotEnoughAlertView.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/3/30.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNMoneyNotEnoughAlertView: LNCommonAlertView {
+    private var exchangeType:LNCurrencyType?
+    private let remainButton = UIButton()
+    
+    func update(_ currency: LNCurrencyType, exchange: LNCurrencyType? = nil) {
+        if let exchange, let exchangeType = LNExchangeType.map(from: exchange, to: currency) {
+            titleLabel.text = .init(key: "B00132", currency.name(lowcase: true))
+            
+            messageLabel.font = .heading_h4
+            messageLabel.textColor = .text_4
+            messageLabel.attributedText = buildRemainString(exchangeType: exchange)
+            
+            if let rate = exchangeType.rate {
+                subMessageLabel.attributedText = buildExchangeString(from: currency, fromValue: "1", exchange: exchange, exchangeValue: rate.toDisplay)
+            }
+            
+            showConfirm(.init(key: "A00262")) { [weak self] in
+                guard let self else { return }
+                dismiss()
+                
+                if remainButton.isSelected {
+                    LNUserDefaults[.remainExchange] = curTime
+                }
+                
+                let panel = LNExchangePanel(exchangeType: exchangeType)
+                panel.popup(self)
+            }
+            showCancel()
+            
+            remainButton.setTitle(String(key: "B00134"), for: .normal)
+            remainButton.setImage(.icCheck, for: .selected)
+            remainButton.setImage(.icUncheck, for: .normal)
+            remainButton.setTitleColor(.text_4, for: .normal)
+            remainButton.titleLabel?.font = .body_s
+            remainButton.imageEdgeInsets = .init(top: 0, left: 0, bottom: 0, right: 8)
+            remainButton.addAction(UIAction(handler: { [weak remainButton] _ in
+                guard let remainButton else { return }
+                remainButton.isSelected.toggle()
+            }), for: .touchUpInside)
+            buttonViews.addArrangedSubview(remainButton)
+        } else {
+            titleLabel.text = .init(key: "B00131", currency.name(lowcase: true))
+            showConfirm { [weak self] in
+                guard let self else { return }
+                dismiss()
+                let panel = LNPurchasePanel()
+                panel.update(currency)
+                panel.popup(self)
+            }
+            showCancel()
+        }
+    }
+    
+    func buildRemainString(exchangeType: LNCurrencyType) -> NSMutableAttributedString {
+        let icon = NSTextAttachment(image: exchangeType.icon)
+        icon.bounds = .init(x: 0, y: -4, width: 19, height: 19)
+        let text = String(key: "B00133", exchangeType.name(lowcase: true), exchangeType.currentValue.toDisplay)
+        let attrString = NSMutableAttributedString(string: text)
+        let iconRange = (text as NSString).range(of: "{icon}")
+        attrString.replaceCharacters(in: iconRange, with: .init(attachment: icon))
+        
+        return attrString
+    }
+    
+    func buildExchangeString(from: LNCurrencyType, fromValue: String,
+                             exchange: LNCurrencyType, exchangeValue: String) -> NSMutableAttributedString {
+        let text = String(key: "B00138", fromValue, exchangeValue)
+        let attrString = NSMutableAttributedString(string: text)
+        
+        let toIcon = NSTextAttachment(image: exchange.icon)
+        toIcon.bounds = .init(x: 0, y: -3, width: 14, height: 14)
+        let toIconRange = (text as NSString).range(of: "{icon2}")
+        attrString.replaceCharacters(in: toIconRange, with: .init(attachment: toIcon))
+        
+        let fromIcon = NSTextAttachment(image: from.icon)
+        fromIcon.bounds = .init(x: 0, y: -3, width: 14, height: 14)
+        let fromIconRange = (text as NSString).range(of: "{icon1}")
+        attrString.replaceCharacters(in: fromIconRange, with: .init(attachment: fromIcon))
+        
+        return attrString
+    }
+}
+
+#if DEBUG
+
+import SwiftUI
+
+struct LNMoneyNotEnoughAlertViewPreview: UIViewRepresentable {
+    func makeUIView(context: Context) -> some UIView {
+        let container = UIView()
+        container.backgroundColor = .lightGray
+        
+        let button = UIButton()
+        button.backgroundColor = .red
+        button.addAction(UIAction(handler: { [weak container] _ in
+            guard let container else { return }
+            
+            let view = LNMoneyNotEnoughAlertView()
+            view.update(.diamond, exchange: .coin)
+            view.popup(container)
+            
+        }), for: .touchUpInside)
+        container.addSubview(button)
+        button.snp.makeConstraints { make in
+            make.center.equalToSuperview()
+            make.width.height.equalTo(50)
+        }
+        
+        return container
+    }
+    
+    func updateUIView(_ uiView: UIViewType, context: Context) { }
+}
+
+#Preview(body: {
+    LNMoneyNotEnoughAlertViewPreview()
+})
+#endif
+