Răsfoiți Sursa

feat: 补充充值功能

陈文艺 3 luni în urmă
părinte
comite
3704ceefde
32 a modificat fișierele cu 864 adăugiri și 144 ștergeri
  1. 12 4
      Lanu.xcodeproj/project.pbxproj
  2. 1 0
      Lanu/AppDelegate.swift
  3. 22 0
      Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunded.imageset/Contents.json
  4. BIN
      Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunded.imageset/ic_im_chat_order_refunded@2x.png
  5. BIN
      Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunded.imageset/ic_im_chat_order_refunded@3x.png
  6. 22 0
      Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunding.imageset/Contents.json
  7. BIN
      Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunding.imageset/ic_im_chat_order_refunding@2x.png
  8. BIN
      Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunding.imageset/ic_im_chat_order_refunding@3x.png
  9. 1 37
      Lanu/Common/Extension/Date+Extension.swift
  10. 0 4
      Lanu/Common/Extension/TimeInterval+Extension.swift
  11. 2 0
      Lanu/Common/Storage/LNUserDefaultsKey.swift
  12. 8 0
      Lanu/Common/Views/Loading/LNLoadingView.swift
  13. 0 2
      Lanu/Info.plist
  14. 1 2
      Lanu/Manager/IM/LNIMManager.swift
  15. 10 0
      Lanu/Manager/Order/LNOrderManager.swift
  16. 12 0
      Lanu/Manager/Order/Network/LNHttpManager+Order.swift
  17. 25 0
      Lanu/Manager/Order/Network/LNOrderResponse.swift
  18. 227 3
      Lanu/Manager/Purchase/LNPurchaseManager.swift
  19. 252 0
      Lanu/Manager/Purchase/LNPurchaseManagerOld.swift
  20. 20 2
      Lanu/Manager/Purchase/Network/LNHttpManager+Purchase.swift
  21. 16 3
      Lanu/Manager/Purchase/Network/LNPurchaseResponse.swift
  22. 1 1
      Lanu/Views/IM/Chat/Cells/LNIMChatBaseMessageCell.swift
  23. 22 9
      Lanu/Views/IM/Chat/Cells/LNIMChatOrderMessageCell.swift
  24. 53 37
      Lanu/Views/IM/Chat/GameMate/LNIMChatGameMateSkillCell.swift
  25. 86 11
      Lanu/Views/IM/Chat/GameMate/LNIMChatGameMateSkillView.swift
  26. 10 14
      Lanu/Views/IM/Chat/LNIMChatViewController.swift
  27. 2 0
      Lanu/Views/IM/Chat/ViewModel/LNIMChatViewModel.swift
  28. 3 3
      Lanu/Views/Main/LNMainViewController.swift
  29. 26 3
      Lanu/Views/Order/Create/LNCreateOrderFromSkillListPanel.swift
  30. 20 6
      Lanu/Views/Wallet/Coin/LNCoinViewController.swift
  31. 3 3
      Lanu/Views/Wallet/Diamond/LNDiamondViewController.swift
  32. 7 0
      Lanu/Views/Wallet/LNWalletViewController.swift

+ 12 - 4
Lanu.xcodeproj/project.pbxproj

@@ -12,6 +12,7 @@
 		FB9CD1192EC1EEA10033B14B /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = FB9CD1182EC1EEA10033B14B /* FirebaseCore */; };
 		FB9CD11B2EC1EEA10033B14B /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB9CD11A2EC1EEA10033B14B /* FirebaseCrashlytics */; };
 		FB9CD11E2EC1EEF30033B14B /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = FB9CD11D2EC1EEF30033B14B /* GoogleSignIn */; };
+		FB9EAE7B2F011ACD00E77B7C /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB9EAE7A2F011ACD00E77B7C /* StoreKit.framework */; };
 		FB9FCD262EF25D6B00DDAAC9 /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = FB9FCD252EF25D6B00DDAAC9 /* SDWebImage */; };
 		FBECA9BE2EC1C50F0013A5E6 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = FBECA9BD2EC1C50F0013A5E6 /* SnapKit */; };
 		FBECA9C42EC1C5250013A5E6 /* AutoCodable in Frameworks */ = {isa = PBXBuildFile; productRef = FBECA9C32EC1C5250013A5E6 /* AutoCodable */; };
@@ -23,6 +24,7 @@
 		006A9E8625309678F4BEF8AB /* Pods-Lanu.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lanu.release.xcconfig"; path = "Target Support Files/Pods-Lanu/Pods-Lanu.release.xcconfig"; sourceTree = "<group>"; };
 		036D0D2B175E726D1FD387A9 /* Pods-Lanu.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Lanu.debug.xcconfig"; path = "Target Support Files/Pods-Lanu/Pods-Lanu.debug.xcconfig"; sourceTree = "<group>"; };
 		ABE0E79254D94F91C773E929 /* Pods_Lanu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Lanu.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		FB9EAE7A2F011ACD00E77B7C /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
 		FBFE13C02EBC39B000DCE6E9 /* Lanu.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Lanu.app; sourceTree = BUILT_PRODUCTS_DIR; };
 /* End PBXFileReference section */
 
@@ -74,6 +76,7 @@
 				Common/Views/LNCircleProgressView.swift,
 				Common/Views/LNPopupView.swift,
 				Common/Views/LNSortedEditView.swift,
+				Common/Views/Loading/LNLoadingView.swift,
 				Common/Views/Menu/LNBottomSheetMenu.swift,
 				Common/Views/Menu/LNCommonAlertView.swift,
 				Common/Views/ScrollView/LNNestedScrollView.swift,
@@ -134,6 +137,7 @@
 				"Manager/Profile/Network/LNHttpManager+Profile.swift",
 				Manager/Profile/Network/LNProfileResponse.swift,
 				Manager/Purchase/LNPurchaseManager.swift,
+				Manager/Purchase/LNPurchaseManagerOld.swift,
 				Manager/Purchase/LNUserWalletInfo.swift,
 				"Manager/Purchase/Network/LNHttpManager+Purchase.swift",
 				Manager/Purchase/Network/LNPurchaseResponse.swift,
@@ -202,6 +206,7 @@
 				Views/Main/LNMainViewController.swift,
 				Views/Order/Alerts/LNCreateOrderFailedPanel.swift,
 				Views/Order/Alerts/LNCreateOrderSuccessPanel.swift,
+				Views/Order/Create/LNCreateOrderFromSkillListPanel.swift,
 				Views/Order/Create/LNCreateOrderPanel.swift,
 				Views/Order/Create/LNCreateOrderViewController.swift,
 				Views/Order/Detail/LNOrderDetailCardView.swift,
@@ -239,7 +244,6 @@
 				Views/Profile/Profile/LNProfileBottomMenu.swift,
 				Views/Profile/Profile/LNProfileInfosView.swift,
 				Views/Profile/Profile/LNProfileNaviBarView.swift,
-				Views/Profile/Profile/LNProfileOrderPanel.swift,
 				Views/Profile/Profile/LNProfilePhotoWall.swift,
 				Views/Profile/Profile/LNProfileScoreFloatingView.swift,
 				Views/Profile/Profile/LNProfileSkillListView.swift,
@@ -303,6 +307,7 @@
 				FBECA9C42EC1C5250013A5E6 /* AutoCodable in Frameworks */,
 				FBECA9BE2EC1C50F0013A5E6 /* SnapKit in Frameworks */,
 				FB9CD11E2EC1EEF30033B14B /* GoogleSignIn in Frameworks */,
+				FB9EAE7B2F011ACD00E77B7C /* StoreKit.framework in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -321,6 +326,7 @@
 		B29B70049832FAE5EB51A5C4 /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
+				FB9EAE7A2F011ACD00E77B7C /* StoreKit.framework */,
 				ABE0E79254D94F91C773E929 /* Pods_Lanu.framework */,
 			);
 			name = Frameworks;
@@ -510,13 +516,14 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 3;
+				CURRENT_PROJECT_VERSION = 5;
 				DEVELOPMENT_TEAM = 5H8D98R72W;
 				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = Lanu/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "Gami(Debug)";
 				INFOPLIST_KEY_NSCameraUsageDescription = "need permission";
+				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = 12;
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "need permission";
 				INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "need permission";
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -529,7 +536,7 @@
 					"@executable_path/Frameworks",
 				);
 				MARKETING_VERSION = 1.0.0;
-				PRODUCT_BUNDLE_IDENTIFIER = com.jiehe.gami.debug;
+				PRODUCT_BUNDLE_IDENTIFIER = com.jiehe.gami;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 				SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -551,13 +558,14 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 3;
+				CURRENT_PROJECT_VERSION = 5;
 				DEVELOPMENT_TEAM = 5H8D98R72W;
 				ENABLE_USER_SCRIPT_SANDBOXING = NO;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_FILE = Lanu/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "Gami(Debug)";
 				INFOPLIST_KEY_NSCameraUsageDescription = "need permission";
+				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = 12;
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "need permission";
 				INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "need permission";
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;

+ 1 - 0
Lanu/AppDelegate.swift

@@ -23,6 +23,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         _ = LNIMManager.shared
         _ = LNGameMateManager.shared
         _ = LNPurchaseManager.shared
+        _ = RechargeManager.shared
         _ = LNLocationManager.shared
         
         LNEventDeliver.notifyAppLaunchFinished()

+ 22 - 0
Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunded.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_im_chat_order_refunded@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_im_chat_order_refunded@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunded.imageset/ic_im_chat_order_refunded@2x.png


BIN
Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunded.imageset/ic_im_chat_order_refunded@3x.png


+ 22 - 0
Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunding.imageset/Contents.json

@@ -0,0 +1,22 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "ic_im_chat_order_refunding@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "ic_im_chat_order_refunding@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunding.imageset/ic_im_chat_order_refunding@2x.png


BIN
Lanu/Assets.xcassets/IM/Order/ic_im_chat_order_refunding.imageset/ic_im_chat_order_refunding@3x.png


+ 1 - 37
Lanu/Common/Extension/Date+Extension.swift

@@ -60,42 +60,6 @@ extension Date {
         return formatter.string(from: self)
     }
     
-    var imTimeDesc: String {
-        let now = Date()
-        
-        let calendar = Calendar.current
-        let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: self, to: now)
-        
-        // 1. 小于等于15分钟,显示"现在"
-        let minutesDiff = (components.minute ?? 0) + (components.hour ?? 0) * 60 + (components.day ?? 0) * 1440
-        if minutesDiff <= 15 {
-            return .init(key: "Now")
-        }
-        
-        // 2. 计算时间差(秒)
-        let timeDiff = now.timeIntervalSince(self)
-        let oneDay: TimeInterval = 24 * 60 * 60
-        
-        // 3. 小于24小时且是今天
-        if timeDiff < oneDay, calendar.isDateInToday(self) {
-            return formattedTime()
-        }
-        
-        // 4. 处理日期显示逻辑(≥15分钟且≥24小时 或 小于24小时但不是今天)
-        let targetYear = calendar.component(.year, from: self)
-        let currentYear = calendar.component(.year, from: now)
-        
-        let formatter = DateFormatter()
-        // 跨年显示日月年,否则显示日月
-        if targetYear != currentYear {
-            formatter.dateFormat = "dd/MM/yyyy"
-        } else {
-            formatter.dateFormat = "dd/MM"
-        }
-        
-        return formatter.string(from: self)
-    }
-    
     var tencentIMTimeDesc: String {
         if self == Date.distantPast {
             return ""
@@ -112,7 +76,7 @@ extension Date {
             if nowComponent.month == dateComponent.month {
                 if nowComponent.weekOfMonth == dateComponent.weekOfMonth {
                     if nowComponent.day == dateComponent.day {
-                        dateFmt.dateFormat = "HH:mm";
+                        dateFmt.dateFormat = "HH:mm"
                     } else {
                         dateFmt.dateFormat = "EEEE"
                     }

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

@@ -46,10 +46,6 @@ extension TimeInterval {
 }
 
 extension TimeInterval {
-    var imTimeDesc: String {
-        Date(timeIntervalSince1970: self).imTimeDesc
-    }
-    
     var tencentIMTimeDesc: String {
         Date(timeIntervalSince1970: self).tencentIMTimeDesc
     }

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

@@ -19,4 +19,6 @@ enum LNUserDefaultsKey: String {
     case userSearchHistory
     
     case imRecentEmojis
+    
+    case purchaseOrderId
 }

+ 8 - 0
Lanu/Common/Views/Loading/LNLoadingView.swift

@@ -0,0 +1,8 @@
+//
+//  LNLoadingView.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/12/29.
+//
+
+import Foundation

+ 0 - 2
Lanu/Info.plist

@@ -2,8 +2,6 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
-	<key>NSLocationWhenInUseUsageDescription</key>
-	<string>12</string>
 	<key>CFBundleURLTypes</key>
 	<array>
 		<dict>

+ 1 - 2
Lanu/Manager/IM/LNIMManager.swift

@@ -145,8 +145,7 @@ extension LNIMManager: LNAccountManagerNotify {
             return
         }
         
-        getIMSignToken { [weak self] token in
-            guard let self else { return }
+        getIMSignToken { token in
             guard let token else { return }
             V2TIMManager.sharedInstance().login(userID: myUid, userSig: token, succ: loginSuccessBlock)
         }

+ 10 - 0
Lanu/Manager/Order/LNOrderManager.swift

@@ -64,6 +64,16 @@ extension LNOrderManager {
             }
         }
     }
+    
+    func getUnfinishedOrderWith(uid: String, size: Int, next: String?,
+                                queue: DispatchQueue = .main,
+                                handler: @escaping ([LNUnfinishedOrderVO], String?) -> Void) {
+        LNHttpManager.shared.getUnfinishedOrderWith(uid: uid, size: size, next: next ?? "") { res, err in
+            queue.asyncIfNotGlobal {
+                handler(res?.list ?? [], res?.next)
+            }
+        }
+    }
 }
 
 

+ 12 - 0
Lanu/Manager/Order/Network/LNHttpManager+Order.swift

@@ -12,6 +12,7 @@ private let kNetPath_Order_Create = "/skill/order/payment"
 
 private let kNetPath_Order_List = "/skill/order/list"
 private let kNetPath_Order_Detail = "/skill/order/detail"
+private let kNetPath_Order_Unfinished = "/skill/order/unfinishedWith"
 
 private let kNetPath_Order_Finish = "/skill/order/finish"
 private let kNetPath_Order_Refund = "/skill/order/refund/apply"
@@ -72,6 +73,17 @@ extension LNHttpManager {
             ]
         ], completion: completion)
     }
+    
+    func getUnfinishedOrderWith(uid: String, size: Int, next: String,
+                                completion: @escaping (LNUnfinishedOrderListResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_Order_Unfinished, params: [
+            "userNo": uid,
+            "page": [
+                "size": size,
+                "next": next
+            ]
+        ], completion: completion)
+    }
 }
 
 extension LNHttpManager {

+ 25 - 0
Lanu/Manager/Order/Network/LNOrderResponse.swift

@@ -118,3 +118,28 @@ class LNOrderRecordListResponse: Decodable {
     var list: [LNOrderRecordItemVO] = []
     var next: String = ""
 }
+
+@AutoCodable
+class LNUnfinishedOrderVO: Decodable {
+    var orderId: String = ""
+    var avatar: String = ""
+    var nickname: String = ""
+    var bizCategoryName: String = ""
+    var categoryIcon: String = ""
+    var price: Double = 0
+    var unit: String = ""
+    var purchaseQty: Int = 0
+    var status: LNOrderStatus = .created
+    var createTime: Int = 0
+    var star: Double = 0
+    var refundApply: Bool = false
+    var customerRemark: String = ""
+    var gender: LNUserGender = .unknow
+    var hasCredentials: Bool = false
+}
+
+@AutoCodable
+class LNUnfinishedOrderListResponse: Decodable {
+    var list: [LNUnfinishedOrderVO] = []
+    var next: String = ""
+}

+ 227 - 3
Lanu/Manager/Purchase/LNPurchaseManager.swift

@@ -6,6 +6,7 @@
 //
 
 import Foundation
+import StoreKit
 
 
 var myWalletInfo: LNUserWalletInfo {
@@ -15,6 +16,36 @@ var myWalletInfo: LNUserWalletInfo {
 
 protocol LNPurchaseManagerNotify {
     func onUserWalletInfoChanged(info: LNUserWalletInfo)
+    func onUserPurchaseResult(err: LNPurchaseError?)
+}
+extension LNPurchaseManagerNotify {
+    func onUserWalletInfoChanged(info: LNUserWalletInfo) {}
+    func onUserPurchaseResult(err: LNPurchaseError?) {}
+}
+
+
+enum LNPurchaseError: LocalizedError {
+    case productNotFound
+    case createOrderfailed
+    case paymentInvalid
+    case paymentCancelled
+    case paymentFailed(Error)
+    case receiptVerifyFailed
+    case receiptParseFailed
+    case unknownError(Error?)
+    
+    var errorDescription: String? {
+        switch self {
+        case .productNotFound: return "未找到对应的充值套餐"
+        case .createOrderfailed: return "订单创建失败"
+        case .paymentInvalid: return "当前设备不支持内购(请检查App Store账号)"
+        case .paymentCancelled: return "你取消了支付"
+        case .paymentFailed(let error): return "支付失败:\(error.localizedDescription)"
+        case .receiptVerifyFailed: return "订单验证失败"
+        case .receiptParseFailed: return "订单信息解析失败"
+        case .unknownError(let error): return "未知错误: \(error?.localizedDescription ?? "")"
+        }
+    }
 }
 
 
@@ -24,12 +55,15 @@ class LNPurchaseManager {
     private(set) var myWalletInfo: LNUserWalletInfo = LNUserWalletInfo()
     
     private let lock = NSLock()
-    private var goodsCache: [LNCurrencyType: [LNPurchaseGoodVO]] = [:]
+    private var goodsCache: [LNCurrencyType: [LNPurchaseGoodsVO]] = [:]
+    private var productMap: [String: Product] = [:]
+    private var transactionListeningTask: Task<Void, Never>?
     
     private var exchangeConfig: LNCurrencyExchangeConfig?
     
     private init() {
         LNEventDeliver.addObserver(self)
+        refreshReceipt()
     }
     
     func reloadWalletInfo() {
@@ -45,7 +79,7 @@ class LNPurchaseManager {
     
     func loadGoodsList(currencyType: LNCurrencyType,
                        queue: DispatchQueue = .main,
-                       handler: @escaping ([LNPurchaseGoodVO]?) -> Void)
+                       handler: @escaping ([LNPurchaseGoodsVO]?) -> Void)
     {
         lock.lock()
         let cache = goodsCache[currencyType]
@@ -60,9 +94,15 @@ class LNPurchaseManager {
         LNHttpManager.shared.getGoodsList(currencyType: currencyType) { [weak self] res, err in
             guard let self else { return }
             if let res, err == nil {
+                var codes = Set<String>()
                 lock.lock()
                 goodsCache[currencyType] = res.items
+                res.items.forEach {
+                    codes.insert($0.code)
+                }
                 lock.unlock()
+                getRechargeProductInfo(ids: codes, handler: nil)
+                RechargeManager.shared.loadRechargeProducts(productIds: codes.sorted(), completion: nil)
             }
             if !cacheAvailable {
                 // 如果前面没有缓存,则在更新时触发回调
@@ -72,7 +112,9 @@ class LNPurchaseManager {
             }
         }
     }
-    
+}
+ 
+extension LNPurchaseManager {
     func exchangeCoinToDiamond(
         amount: Double, queue: DispatchQueue = .main,
         handler: @escaping (Bool) -> Void)
@@ -152,10 +194,180 @@ extension LNPurchaseManager: LNAccountManagerNotify {
         
         // 拉取转换配置
         getExchangeConfig()
+        
+//        restoreCompletedTransactions()
+//        startObservingTransactionUpdates()
     }
     
     func onUserLogout() {
         myWalletInfo = LNUserWalletInfo()
+        
+        transactionListeningTask?.cancel()
+        transactionListeningTask = nil
+    }
+}
+
+extension LNPurchaseManager {
+    func purchaseProduct(goods: LNPurchaseGoodsVO) {
+        guard AppStore.canMakePayments else {
+            log("In-app purchase is not enable")
+            notifyPurchaseResult(err: .paymentInvalid)
+            return
+        }
+        
+        getRechargeProductInfo(ids: [goods.code]) { [weak self] list in
+            guard let self else { return }
+            guard let product = list.first else {
+                log("product not found for \(goods.code)")
+                notifyPurchaseResult(err: .productNotFound)
+                return
+            }
+            
+            LNHttpManager.shared.createPurchase(id: goods.id) { [weak self] res, err in
+                guard let self else { return }
+                guard let res, err == nil else {
+                    log("create order failed \(String(describing: err?.errorDescription))")
+                    notifyPurchaseResult(err: .createOrderfailed)
+                    return
+                }
+                doPurchase(orderId: res.result, product: product)
+            }
+        }
+    }
+    
+    private func doPurchase(orderId: String, product: Product) {
+        Task { [weak self] in
+            guard let self else { return }
+                
+            do {
+                log("start puchase order \(orderId) product \(product.id)")
+                
+                let uuid = Product.PurchaseOption.appAccountToken(UUID(uuidString: orderId)!)
+                let result = try await product.purchase(options: [uuid])
+                    
+                switch result {
+                case .success(let verification):
+                    // 验证收据
+                    await checkTransationVerify(result: verification)
+                case .userCancelled:
+                    log("puchase order \(orderId) product \(product.id) user cancelled")
+                    notifyPurchaseResult(err: .paymentCancelled)
+                case .pending:
+                    break
+                @unknown default:
+                    log("puchase order \(orderId) product \(product.id) return unknown err")
+                    notifyPurchaseResult(err: .unknownError(nil))
+                }
+            } catch (let err) {
+                log("puchase order \(orderId) product \(product.id) return err \(err)")
+                notifyPurchaseResult(err: .paymentFailed(err))
+            }
+        }
+    }
+    
+    private func restoreCompletedTransactions() {
+        Task { [weak self] in
+            guard let self else { return }
+            
+            for await result in Transaction.currentEntitlements {
+                // 验证收据
+                await checkTransationVerify(result: result)
+            }
+        }
+    }
+    
+    private func startObservingTransactionUpdates() {
+        // 避免重复创建Task
+        guard transactionListeningTask == nil else { return }
+        
+        transactionListeningTask = Task(priority: .background) { [weak self] in
+            guard let self = self else { return }
+            
+            // 持续监听所有交易更新(异步序列)
+            for await transactionUpdate in Transaction.updates {
+                // 验证收据
+                await checkTransationVerify(result: transactionUpdate)
+            }
+        }
+    }
+    
+    private func getRechargeProductInfo(ids: Set<String>, handler: (([Product]) -> Void)?) {
+        let validIds = ids.filter { !$0.isEmpty }
+        guard !validIds.isEmpty else {
+            handler?([])
+            return
+        }
+        Task {
+            guard AppStore.canMakePayments else {
+                handler?([])
+                return
+            }
+            
+            // 检查缓存
+            let cachedProducts = validIds.compactMap { productMap[$0] }
+            if cachedProducts.count == validIds.count {
+                handler?(cachedProducts)
+                return
+            }
+            
+            do {
+                let products = try await Product.products(for: Set(ids))
+                guard !products.isEmpty else {
+                    handler?([])
+                    return
+                }
+                
+                products.forEach {
+                    productMap[$0.id] = $0
+                }
+                handler?(products)
+            } catch (_) {
+                handler?([])
+            }
+        }
+    }
+    
+    private func checkTransationVerify(result: VerificationResult<Transaction>) async {
+        switch result {
+        case .unverified(let transaction, let error):
+            if let orderId = transaction.appAccountToken?.uuidString {
+                log("puchase order \(orderId) product \(transaction.productID) user unverified err \(error)")
+            }
+            
+            await transaction.finish()
+            notifyPurchaseResult(err: .receiptVerifyFailed)
+        case .verified(let transaction):
+            guard let orderId = transaction.appAccountToken?.uuidString else {
+                notifyPurchaseResult(err: .receiptVerifyFailed)
+                return
+            }
+            log("puchase order \(orderId) product \(transaction.productID) user verified")
+            
+            guard let receiptURL = Bundle.main.appStoreReceiptURL,
+                  FileManager.default.fileExists(atPath: receiptURL.path),
+                  let receiptData = try? Data(contentsOf: receiptURL)
+            else {
+                log("can't find appStore Receipt File data")
+                notifyPurchaseResult(err: .receiptParseFailed)
+                return
+            }
+            
+            let receiptBase64 = receiptData.base64EncodedString()
+            log("start verify order \(orderId)")
+            LNHttpManager.shared.verifyPurchase(orderId: orderId, receipt: receiptBase64) { [weak self] err in
+                guard let self else { return }
+                log("verify order \(orderId) return err \(String(describing: err?.errorDescription))")
+                notifyPurchaseResult(err: err == nil ? nil : .receiptVerifyFailed)
+            }
+            
+        }
+    }
+    
+    private func refreshReceipt() {
+        // 沙盒环境需指定测试账号(可选,部分场景需要)
+        let request = SKReceiptRefreshRequest(receiptProperties: [:])
+        
+        request.start()
     }
 }
 
@@ -164,4 +376,16 @@ extension LNPurchaseManager {
         let info = myWalletInfo
         LNEventDeliver.notifyEvent { ($0 as? LNPurchaseManagerNotify)?.onUserWalletInfoChanged(info: info) }
     }
+    
+    private func notifyPurchaseResult(err: LNPurchaseError?) {
+        LNEventDeliver.notifyEvent {
+            ($0 as? LNPurchaseManagerNotify)?.onUserPurchaseResult(err: err)
+        }
+    }
+}
+
+extension LNPurchaseManager {
+    func log(_ items: Any...,) {
+        Log.w("-----> LNPurchaseManager <----- ", items)
+    }
 }

+ 252 - 0
Lanu/Manager/Purchase/LNPurchaseManagerOld.swift

@@ -0,0 +1,252 @@
+//
+//  LNPurchaseManagerOld.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/12/29.
+//
+
+import Foundation
+import StoreKit
+import Foundation
+
+
+/// 充值状态回调
+typealias fetchCompletion = (Result<[SKProduct], LNPurchaseError>) -> Void
+
+/// 充值管理单例类(StoreKit 1)
+final class RechargeManager: NSObject {
+    // MARK: - 单例
+    static let shared = RechargeManager()
+    private override init() {
+        super.init()
+        
+        LNEventDeliver.addObserver(self)
+    }
+    
+    // MARK: - 私有属性
+    /// 商品信息缓存
+    private var productCache: [String: SKProduct] = [:]
+    /// 支付完成回调
+    private var fetchCompletion: fetchCompletion?
+    /// 监听交易队列
+    private var paymentQueue: SKPaymentQueue { SKPaymentQueue.default() }
+    
+    // MARK: - 生命周期
+    /// 启动监听(建议在 AppDelegate 或 SceneDelegate 中调用)
+    func startObserving() {
+        paymentQueue.add(self)
+        // 恢复未完成的交易
+        restoreUnfinishedTransactions()
+    }
+    
+    /// 停止监听(建议在 App 退出时调用)
+    func stopObserving() {
+        paymentQueue.remove(self)
+    }
+    
+    // MARK: - 公开方法
+    /// 加载充值商品信息
+    /// - Parameters:
+    ///   - productIds: 商品ID数组
+    ///   - completion: 加载完成回调
+    func loadRechargeProducts(productIds: [String], completion: fetchCompletion?) {
+        // 过滤空ID
+        let validIds = productIds.filter { !$0.isEmpty }
+        guard !validIds.isEmpty else {
+            completion?(.failure(.productNotFound))
+            return
+        }
+        
+        // 检查缓存
+        let cachedProducts = validIds.compactMap { productCache[$0] }
+        if cachedProducts.count == validIds.count {
+            completion?(.success(cachedProducts))
+            return
+        }
+        
+        // 从苹果服务器请求商品
+        fetchCompletion = completion
+        let request = SKProductsRequest(productIdentifiers: Set(validIds))
+        request.delegate = self
+        request.start()
+    }
+    
+    /// 发起充值支付
+    /// - Parameters:
+    ///   - productId: 商品ID
+    ///   - completion: 支付完成回调
+    func startRecharge(goods: LNPurchaseGoodsVO) {
+        // 检查支付是否可用
+        guard SKPaymentQueue.canMakePayments() else {
+            notifyPurchaseResult(err: .paymentInvalid)
+            return
+        }
+        
+        // 加载商品并发起支付
+        loadRechargeProducts(productIds: [goods.code]) { [weak self] result in
+            guard let self = self else { return }
+            
+            switch result {
+            case .success(let products):
+                guard let product = products.first else {
+                    notifyPurchaseResult(err: .productNotFound)
+                    return
+                }
+                
+                LNHttpManager.shared.createPurchase(id: goods.id) { [weak self] res, err in
+                    guard let self else { return }
+                    guard let res, err == nil else {
+                        notifyPurchaseResult(err: .createOrderfailed)
+                        return
+                    }
+                    // 创建支付请求
+                    // 保存当前回调
+                    let payment = SKMutablePayment(product: product)
+                    payment.applicationUsername = res.result
+                    LNUserDefaults[.purchaseOrderId] = res.result
+                    self.paymentQueue.add(payment)
+                }
+            case .failure(let error):
+                notifyPurchaseResult(err: error)
+            }
+        }
+    }
+    
+    /// 恢复未完成的交易
+    func restoreUnfinishedTransactions() {
+        paymentQueue.restoreCompletedTransactions()
+    }
+    
+    // MARK: - 私有方法
+    /// 验证交易收据(本地验证,建议添加服务端验证)
+    private func validateReceipt(for transaction: SKPaymentTransaction) {
+        // 1. 获取应用收据
+        guard let receiptURL = Bundle.main.appStoreReceiptURL,
+              FileManager.default.fileExists(atPath: receiptURL.path) else {
+            notifyPurchaseResult(err: .receiptParseFailed)
+            return
+        }
+        
+        // 2. 读取收据数据
+        do {
+            let receiptData = try Data(contentsOf: receiptURL)
+            let receiptString = receiptData.base64EncodedString(options: [])
+            
+            let orderId = transaction.payment.applicationUsername ?? LNUserDefaults[.purchaseOrderId, ""]
+            
+            // 3. 本地验证逻辑(简化版,生产环境需服务端验证)
+            // 实际应将 receiptString 发送到后端,由后端调用苹果验证接口
+            LNHttpManager.shared.verifyPurchase(orderId: orderId, receipt: receiptString) { [weak self] err in
+                guard let self else { return }
+                let success = err == nil
+                if success {
+                    notifyPurchaseResult(err: nil)
+                   print("充值成功 - 商品ID: \(transaction.payment.productIdentifier)")
+                    LNPurchaseManager.shared.reloadWalletInfo()
+               } else {
+                   notifyPurchaseResult(err: .receiptVerifyFailed)
+                   print("充值失败 - 收据验证失败")
+               }
+                // 结束交易
+                paymentQueue.finishTransaction(transaction)
+            }
+        } catch {
+            print("收据验证失败: \(error)")
+            notifyPurchaseResult(err: .unknownError(error))
+        }
+    }
+}
+
+extension RechargeManager: LNAccountManagerNotify {
+    func onUserLogin() {
+        startObserving()
+    }
+    
+    func onUserLogout() {
+        stopObserving()
+    }
+}
+
+// MARK: - SKProductsRequestDelegate
+extension RechargeManager: SKProductsRequestDelegate {
+    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
+        // 缓存商品信息
+        let products = response.products
+        products.forEach { productCache[$0.productIdentifier] = $0 }
+        
+        // 处理无效商品ID
+        let invalidIds = response.invalidProductIdentifiers
+        if !invalidIds.isEmpty {
+            print("无效的商品ID: \(invalidIds)")
+        }
+        
+        // 回调结果
+        if !products.isEmpty {
+            // 这里是加载商品的回调,需要匹配原始请求的completion
+            // 注:实际使用时建议通过闭包捕获或使用更优雅的回调管理
+            fetchCompletion?(.success(products))
+        } else {
+            print("未找到对应商品")
+            fetchCompletion?(.failure(.productNotFound))
+        }
+        fetchCompletion = nil
+    }
+    
+    func request(_ request: SKRequest, didFailWithError error: Error) {
+        print("商品请求失败: \(error.localizedDescription)")
+        // 匹配错误类型
+        fetchCompletion?(.failure(.unknownError(error)))
+        fetchCompletion = nil
+    }
+}
+
+// MARK: - SKPaymentTransactionObserver
+extension RechargeManager: SKPaymentTransactionObserver {
+    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
+        transactions.forEach { transaction in
+            switch transaction.transactionState {
+            case .purchased:
+                // 交易完成,验证收据
+                validateReceipt(for: transaction)
+            case .failed:
+                // 交易失败
+                let error = transaction.error as NSError?
+                if error?.code == SKError.paymentCancelled.rawValue {
+                    notifyPurchaseResult(err: .paymentCancelled)
+                    paymentQueue.finishTransaction(transaction)
+                    print("用户取消支付")
+                } else {
+                    notifyPurchaseResult(err: .unknownError(transaction.error ?? NSError(domain: "RechargeError", code: -1, userInfo: nil)))
+                    print("交易失败: \(transaction.error?.localizedDescription ?? "未知错误")")
+                }
+            case .restored:
+                // 恢复交易
+                validateReceipt(for: transaction)
+            case .purchasing, .deferred:
+                // 交易中/延迟处理(如家长审核)
+                print("交易状态: \(transaction.transactionState.rawValue)")
+                
+            @unknown default:
+                notifyPurchaseResult(err: .unknownError(NSError(domain: "RechargeError", code: -2, userInfo: nil)))
+                print("未知交易状态")
+            }
+        }
+    }
+    
+    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
+        print("恢复交易完成 - 共恢复 \(queue.transactions.count) 笔交易")
+    }
+    
+    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
+        print("恢复交易失败: \(error.localizedDescription)")
+        notifyPurchaseResult(err: .unknownError(error))
+    }
+}
+
+extension RechargeManager {
+    private func notifyPurchaseResult(err: LNPurchaseError?) {
+        LNEventDeliver.notifyEvent {
+            ($0 as? LNPurchaseManagerNotify)?.onUserPurchaseResult(err: err)
+        }
+    }
+}

+ 20 - 2
Lanu/Manager/Purchase/Network/LNHttpManager+Purchase.swift

@@ -13,6 +13,9 @@ import SnapKit
 private let kNetPath_Purchase_Wallet = "/user/my/wallet"
 
 private let kNetPath_Purchase_Goods = "/wallet/recharge/goods"
+private let kNetPath_Purchase_Create = "/wallet/recharge/submit"
+private let kNetPath_Purchase_Verify = "/wallet/recharge/goods/payBak"
+
 private let kNetPath_Purchase_Exchange = "/wallet/exchange"
 
 
@@ -21,10 +24,16 @@ extension LNHttpManager {
         post(path: kNetPath_Purchase_Wallet, completion: completion)
     }
     
-    func getGoodsList(currencyType: LNCurrencyType, completion: @escaping (LNPUrchaseGoodsListResponse?, LNHttpError?) -> Void) {
+    func getGoodsList(currencyType: LNCurrencyType, completion: @escaping (LNPurchaseGoodsListResponse?, LNHttpError?) -> Void) {
         post(path: kNetPath_Purchase_Goods, params: [
             "walletType": currencyType.rawValue,
-            "rechargePlatform": LNPurchasePlatform.official.rawValue
+            "rechargePlatform": LNPurchasePlatform.iOS.rawValue
+        ], completion: completion)
+    }
+    
+    func createPurchase(id: String, completion: @escaping (LNPurchaseCreateOrderResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_Purchase_Create, params: [
+            "id": id
         ], completion: completion)
     }
     
@@ -36,4 +45,13 @@ extension LNHttpManager {
             "amount": amount
         ], completion: completion)
     }
+    
+    func verifyPurchase(orderId: String, receipt: String, completion: @escaping (LNHttpError?) -> Void) {
+        post(path: kNetPath_Purchase_Verify, params: [
+            "userId": myUid,
+            "orderId": orderId,
+            "payType": LNPurchasePayType.apple.rawValue,
+            "receipt": receipt
+        ], completion: completion)
+    }
 }

+ 16 - 3
Lanu/Manager/Purchase/Network/LNPurchaseResponse.swift

@@ -19,6 +19,12 @@ enum LNPurchasePlatform: Int, Decodable {
     case official = 0
     case android = 1
     case web = 2
+    case iOS = 3
+}
+
+enum LNPurchasePayType: Int, Decodable {
+    case apple = 1
+    case google = 2
 }
 
 @AutoCodable
@@ -28,13 +34,20 @@ class LNUserWalletInfoResponse: Decodable {
 }
 
 @AutoCodable
-class LNPurchaseGoodVO: Decodable {
+class LNPurchaseGoodsVO: Decodable {
     var id: String = ""
     var coinRechargeAmount: Double = 0
     var amount: Double = 0
+    var currency: String = ""
+    var code: String = ""
+}
+
+@AutoCodable
+class LNPurchaseGoodsListResponse: Decodable {
+    var items: [LNPurchaseGoodsVO] = []
 }
 
 @AutoCodable
-class LNPUrchaseGoodsListResponse: Decodable {
-    var items: [LNPurchaseGoodVO] = []
+class LNPurchaseCreateOrderResponse: Decodable {
+    var result: String = ""
 }

+ 1 - 1
Lanu/Views/IM/Chat/Cells/LNIMChatBaseMessageCell.swift

@@ -70,7 +70,7 @@ class LNIMChatBaseMessageCell: UITableViewCell {
         sendStateIc.isHidden = data.imMessage.isSelf != true || data.imMessage.status != .MSG_STATUS_SEND_FAIL
         
         if let time = data.imMessage.timestamp {
-            timeLabel.text = time.imTimeDesc
+            timeLabel.text = time.tencentIMTimeDesc
         }
         curItem = data
         self.viewModel = viewModel

+ 22 - 9
Lanu/Views/IM/Chat/Cells/LNIMChatOrderMessageCell.swift

@@ -40,6 +40,21 @@ class LNIMChatOrderMessageCell: UITableViewCell {
         gameIc.sd_setImage(with: URL(string: order.categoryIcon))
         billInfoLabel.text = "\(order.bizCategoryName)x\(order.purchaseQty)\(order.unit)"
         
+        curItem = data
+        
+        if order.refundApply {
+            if isCreator {
+                statusIc.image = .init(named: "ic_im_chat_order_pending")
+                statusLabel.text = .init(key: "Refund Under Review")
+                tipsLabel.text = .init(key: "wait for review")
+            } else {
+                statusIc.image = .init(named: "ic_im_chat_order_refunding")
+                statusLabel.text = .init(key: "Order Refunding")
+                tipsLabel.text = .init(key: "refunding")
+            }
+            return
+        }
+        
         switch order.status {
         case .created, .waitingForAccept:
             if isCreator {
@@ -61,13 +76,10 @@ class LNIMChatOrderMessageCell: UITableViewCell {
                 tipsLabel.text = .init(key: "The service has been completed and the payment has been sent to your wallet!")
             }
         case .refunded:
-            if isCreator {
-                statusIc.image = .init(named: "ic_im_chat_order_success")
-                statusLabel.text = .init(key: "")
-            } else {
-                statusIc.image = .init(named: "ic_im_chat_order_pending")
-                statusLabel.text = .init(key: "")
-            }
+            statusIc.image = .init(named: "ic_im_chat_order_refunded")
+            statusLabel.text = .init(key: "Order Refunded")
+            
+            tipsLabel.text = .init(key: "refund done")
         case .accepted:
             statusIc.image = .init(named: "ic_im_chat_order_accepted")
             statusLabel.text = .init(key: "Order Accepted")
@@ -137,7 +149,8 @@ extension LNIMChatOrderMessageCell {
         contentView.addSubview(container)
         container.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview().inset(16)
-            make.verticalEdges.equalToSuperview()
+            make.top.equalToSuperview()
+            make.bottom.equalToSuperview().offset(-8)
         }
         
         let mainStackView = UIStackView()
@@ -211,7 +224,7 @@ extension LNIMChatOrderMessageCell {
         let line = UIView()
         line.backgroundColor = .fill_2
         line.snp.makeConstraints { make in
-            make.height.equalTo(0.5)
+            make.height.equalTo(1)
         }
         
         return line

+ 53 - 37
Lanu/Views/IM/Chat/GameMate/LNIMChatGameMateSkillCell.swift

@@ -33,6 +33,14 @@ class LNIMChatGameMateSkillCell: UIView {
         setupViews()
     }
     
+    func update(_ skill: LNGameMateSkillVO) {
+        gameView.isHidden = false
+        
+        gameIc.sd_setImage(with: URL(string: skill.icon))
+        gameNameLabel.text = skill.name
+        gamePriceLabel.text = "\(skill.price.toDisplay)/\(skill.unit)"
+    }
+    
     required init?(coder: NSCoder) {
         fatalError("init(coder:) has not been implemented")
     }
@@ -41,22 +49,22 @@ class LNIMChatGameMateSkillCell: UIView {
 extension LNIMChatGameMateSkillCell {
     private func setupViews() {
         let container = UIView()
+        container.backgroundColor = .fill
+        container.layer.cornerRadius = 12
+        container.layer.borderWidth = 1
+        container.layer.borderColor = UIColor.fill.cgColor
         addSubview(container)
         container.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview().inset(4)
             make.directionalVerticalEdges.equalToSuperview()
         }
         
-        container.backgroundColor = .fill
-        container.layer.cornerRadius = 12
-        container.layer.borderWidth = 1
-        container.layer.borderColor = UIColor.fill.cgColor
-        
         background.layer.cornerRadius = 11
         background.clipsToBounds = true
+        background.image = .primary_6
         container.addSubview(background)
         background.snp.makeConstraints { make in
-            make.directionalEdges.equalToSuperview().inset(1)
+            make.directionalEdges.equalToSuperview().inset(1).priority(.medium)
         }
         
         gameIc.layer.cornerRadius = 25
@@ -92,6 +100,12 @@ extension LNIMChatGameMateSkillCell {
         order.setBackgroundImage(.primary_8, for: .normal)
         order.layer.cornerRadius = 12
         order.clipsToBounds = true
+        order.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            let panel = LNCreateOrderFromSkillListPanel()
+//            panel.update(<#T##skills: [LNGameMateSkillVO]##[LNGameMateSkillVO]#>, selected: <#T##LNGameMateSkillVO?#>)
+            panel.showIn()
+        }), for: .touchUpInside)
         gameView.addSubview(order)
         order.snp.makeConstraints { make in
             make.centerY.equalToSuperview()
@@ -107,8 +121,8 @@ extension LNIMChatGameMateSkillCell {
         orderTitle.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
         order.addSubview(orderTitle)
         orderTitle.snp.makeConstraints { make in
-            make.center.equalToSuperview()
-            make.leading.equalToSuperview().offset(17)
+            make.centerY.equalToSuperview()
+            make.directionalHorizontalEdges.equalToSuperview().inset(17)
         }
         
         let infoView = UIView()
@@ -122,6 +136,8 @@ extension LNIMChatGameMateSkillCell {
         gameNameLabel.text = "123"
         gameNameLabel.font = .heading_h4
         gameNameLabel.textColor = .text_5
+        gameNameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        gameNameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         infoView.addSubview(gameNameLabel)
         gameNameLabel.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview()
@@ -144,7 +160,7 @@ extension LNIMChatGameMateSkillCell {
         gamePriceLabel.snp.makeConstraints { make in
             make.leading.equalTo(coin.snp.trailing).offset(2)
             make.centerY.equalTo(coin)
-            make.trailing.equalToSuperview()
+            make.trailing.lessThanOrEqualToSuperview()
         }
         
         return gameView
@@ -163,6 +179,33 @@ extension LNIMChatGameMateSkillCell {
             make.height.equalTo(24)
         }
         
+        replyView.isHidden = true
+        orderView.addSubview(replyView)
+        replyView.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalToSuperview().offset(-16)
+        }
+        
+        let replyTitleLabel = UILabel()
+        replyTitleLabel.font = .body_s
+        replyTitleLabel.text = .init(key: "Reply")
+        replyTitleLabel.textColor = .text_5
+        replyTitleLabel.textAlignment = .center
+        replyView.addSubview(replyTitleLabel)
+        replyTitleLabel.snp.makeConstraints { make in
+            make.directionalHorizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
+        }
+        
+        replyRemainLabel.font = .heading_h5
+        replyRemainLabel.textColor = .text_5
+        replyView.addSubview(replyRemainLabel)
+        replyRemainLabel.snp.makeConstraints { make in
+            make.directionalHorizontalEdges.equalToSuperview()
+            make.bottom.equalToSuperview()
+            make.top.equalTo(replyTitleLabel.snp.bottom).offset(1)
+        }
+        
         let orderTitle = UILabel()
         orderTitle.text = .init(key: "Go voice")
         orderTitle.font = .heading_h5
@@ -208,37 +251,10 @@ extension LNIMChatGameMateSkillCell {
         orderDescLabel.snp.makeConstraints { make in
             make.leading.equalToSuperview()
             make.bottom.equalToSuperview()
-            make.trailing.equalToSuperview()
+            make.trailing.lessThanOrEqualToSuperview()
             make.top.equalTo(orderTitleLabel.snp.bottom).offset(2)
         }
         
-        replyView.isHidden = true
-        orderView.addSubview(replyView)
-        replyView.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.trailing.equalToSuperview().offset(-16)
-        }
-        
-        let replyTitleLabel = UILabel()
-        replyTitleLabel.font = .body_s
-        replyTitleLabel.text = .init(key: "Reply")
-        replyTitleLabel.textColor = .text_5
-        replyTitleLabel.textAlignment = .center
-        replyView.addSubview(replyTitleLabel)
-        replyTitleLabel.snp.makeConstraints { make in
-            make.directionalHorizontalEdges.equalToSuperview()
-            make.top.equalToSuperview()
-        }
-        
-        replyRemainLabel.font = .heading_h5
-        replyRemainLabel.textColor = .text_5
-        replyView.addSubview(replyRemainLabel)
-        replyRemainLabel.snp.makeConstraints { make in
-            make.directionalHorizontalEdges.equalToSuperview()
-            make.bottom.equalToSuperview()
-            make.top.equalTo(replyTitleLabel.snp.bottom).offset(1)
-        }
-        
         return orderView
     }
 }

+ 86 - 11
Lanu/Views/IM/Chat/GameMate/LNIMChatGameMateSkillView.swift

@@ -12,11 +12,16 @@ import SnapKit
 
 class LNIMChatGameMateSkillView: UIView {
     private let scrollView = UIScrollView()
+    
     private let stackView = UIStackView()
     
+    private var skills: [LNGameMateSkillVO] = []
+    
+    private var itemViews: [LNIMChatGameMateSkillCell] = []
+    
     weak var viewModel: LNIMChatViewModel? {
         didSet {
-            updateSkills()
+            loadSkillList()
         }
     }
     
@@ -32,23 +37,81 @@ class LNIMChatGameMateSkillView: UIView {
 }
 
 extension LNIMChatGameMateSkillView {
-    private func updateSkills() {
-        stackView.arrangedSubviews.forEach {
-            stackView.removeArrangedSubview($0)
-            $0.removeFromSuperview()
+    private func loadSkillList() {
+        guard let uid = viewModel?.userId else { return }
+        LNProfileManager.shared.userInfo(for: uid) { [weak self] info in
+            guard let self else { return }
+            guard let info, info.playmate else { return }
+            LNGameMateManager.shared.getUserSkills(uid: uid) { [weak self] list in
+                guard let self else { return }
+                guard let list else { return }
+                skills = list
+                buildSkillViews()
+            }
         }
-        for _ in 0..<Int.random(in: 3...6) {
-            let cell = LNIMChatGameMateSkillCell()
-            stackView.addArrangedSubview(cell)
-            cell.snp.makeConstraints { make in
+    }
+}
+
+extension LNIMChatGameMateSkillView: UIScrollViewDelegate {
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        guard skills.count > 2 else { return }
+        
+        if scrollView.contentOffset.x < scrollView.bounds.width * 0.5 {
+            let last = itemViews.removeLast()
+            stackView.removeArrangedSubview(last)
+            last.removeFromSuperview()
+            itemViews.insert(last, at: 0)
+            stackView.insertArrangedSubview(last, at: 0)
+            last.snp.makeConstraints { make in
+                make.width.equalTo(scrollView)
+                make.height.equalToSuperview()
+            }
+            scrollView.contentOffset.x = scrollView.contentOffset.x + scrollView.bounds.width
+        } else if scrollView.contentOffset.x > scrollView.bounds.width * 1.5 {
+            let first = itemViews.removeFirst()
+            stackView.removeArrangedSubview(first)
+            first.removeFromSuperview()
+            itemViews.append(first)
+            stackView.addArrangedSubview(first)
+            first.snp.makeConstraints { make in
                 make.width.equalTo(scrollView)
+                make.height.equalToSuperview()
             }
+            scrollView.contentOffset.x = scrollView.contentOffset.x - scrollView.bounds.width
+        }
+    }
+}
+ 
+extension LNIMChatGameMateSkillView {
+    private func buildSkillViews() {
+        snp.updateConstraints{ make in
+            make.height.equalTo(skills.isEmpty ? 0 : 72)
+        }
+        
+        if skills.count < 3 {
+            scrollView.setContentOffset(.init(x: 0, y: 0), animated: false)
+            for (index, skill) in skills.enumerated() {
+                itemViews[index].isHidden = false
+                itemViews[index].update(skill)
+            }
+            for index in skills.count..<itemViews.count {
+                itemViews[index].isHidden = true
+            }
+        } else {
+            scrollView.setContentOffset(.init(x: scrollView.bounds.width, y: 0), animated: false)
+            for index in 0..<itemViews.count {
+                itemViews[index].isHidden = false
+            }
+            itemViews[0].update(skills[skills.count - 1])
+            itemViews[1].update(skills[0])
+            itemViews[2].update(skills[1])
         }
     }
     
     private func setupViews() {
+        clipsToBounds = true
         snp.makeConstraints { make in
-            make.height.equalTo(72)
+            make.height.equalTo(0)
         }
         
         scrollView.backgroundColor = .clear
@@ -56,9 +119,10 @@ extension LNIMChatGameMateSkillView {
         scrollView.isPagingEnabled = true
         scrollView.showsVerticalScrollIndicator = false
         scrollView.showsHorizontalScrollIndicator = false
+        scrollView.delegate = self
         addSubview(scrollView)
         scrollView.snp.makeConstraints { make in
-            make.directionalHorizontalEdges.equalToSuperview().inset(18)
+            make.directionalHorizontalEdges.equalToSuperview().inset(14)
             make.directionalVerticalEdges.equalToSuperview()
         }
         
@@ -77,5 +141,16 @@ extension LNIMChatGameMateSkillView {
             make.directionalEdges.equalToSuperview()
             make.height.equalToSuperview()
         }
+        
+        for _ in 0..<3 {
+            let itemView = LNIMChatGameMateSkillCell()
+            stackView.addArrangedSubview(itemView)
+            itemView.snp.makeConstraints { make in
+                make.width.equalTo(scrollView)
+                make.height.equalToSuperview()
+            }
+            
+            itemViews.append(itemView)
+        }
     }
 }

+ 10 - 14
Lanu/Views/IM/Chat/LNIMChatViewController.swift

@@ -22,7 +22,7 @@ class LNIMChatViewController: LNViewController {
     
     private let topMenu = LNIMChatTopMenuView()
     
-    private let orderView = UIView()
+    private let stackView = UIStackView()
     private let skillView = LNIMChatGameMateSkillView()
     
     private let tableView = UITableView()
@@ -193,13 +193,17 @@ extension LNIMChatViewController {
             make.top.equalToSuperview()
         }
         
-        view.addSubview(orderView)
-        orderView.snp.makeConstraints { make in
+        stackView.axis = .vertical
+        stackView.spacing = 8
+        view.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview()
-            make.top.equalTo(topMenu.snp.bottom)
-            make.height.equalTo(0).priority(.low)
+            make.top.equalTo(topMenu.snp.bottom).offset(8)
         }
         
+        skillView.viewModel = viewModel
+        stackView.addArrangedSubview(skillView)
+        
         bottomMenu.viewModel = viewModel
         view.addSubview(bottomMenu)
         bottomMenu.snp.makeConstraints { make in
@@ -221,18 +225,10 @@ extension LNIMChatViewController {
         view.addSubview(tableView)
         tableView.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview()
-            make.top.equalTo(orderView.snp.bottom)
+            make.top.equalTo(stackView.snp.bottom)
             make.bottom.equalTo(bottomMenu.snp.top)
         }
         
-        skillView.viewModel = viewModel
-        orderView.addSubview(skillView)
-        skillView.snp.makeConstraints { make in
-            make.directionalHorizontalEdges.equalToSuperview()
-            make.top.equalTo(topMenu.snp.bottom).offset(8)
-            make.bottom.equalToSuperview()
-        }
-        
         tableView.onTap { [weak self] in
             guard let self else { return }
             bottomMenu.hideInput()

+ 2 - 0
Lanu/Views/IM/Chat/ViewModel/LNIMChatViewModel.swift

@@ -36,6 +36,8 @@ class LNIMChatViewModel: NSObject {
     @Published
     private(set) var messageOpt: V2TIMReceiveMessageOpt = .RECEIVE_MESSAGE
     
+    private var myOrders: [LNUnfinishedOrderVO] = []
+    
     init(userId: String) {
         self.userId = userId
         super.init()

+ 3 - 3
Lanu/Views/Main/LNMainViewController.swift

@@ -65,8 +65,8 @@ extension LNMainViewController: LNProfileManagerNotify {
     func onUserInfoChanged(userInfo: LNUserProfileVO) {
         guard userInfo.userNo.isMyUid else { return }
         
-        if !userInfo.isAvailable {
-            view.pushToGenderSetup()
-        }
+//        if !userInfo.isAvailable {
+//            view.pushToGenderSetup()
+//        }
     }
 }

+ 26 - 3
Lanu/Views/Profile/Profile/LNProfileOrderPanel.swift → Lanu/Views/Order/Create/LNCreateOrderFromSkillListPanel.swift

@@ -1,5 +1,5 @@
 //
-//  LNProfileOrderPanel.swift
+//  LNCreateOrderFromSkillListPanel.swift
 //  Lanu
 //
 //  Created by OneeChan on 2025/12/2.
@@ -10,7 +10,7 @@ import UIKit
 import SnapKit
 
 
-class LNProfileOrderPanel: LNPopupView {
+class LNCreateOrderFromSkillListPanel: LNPopupView {
     private let skillListView = UIStackView()
     private let priceLabel = UILabel()
     
@@ -72,7 +72,7 @@ class LNProfileOrderPanel: LNPopupView {
     }
 }
 
-extension LNProfileOrderPanel {
+extension LNCreateOrderFromSkillListPanel {
     private func updatePrice() {
         guard let skill = curSelected else { return }
         let cost = skill.price * Double(curCount)
@@ -295,3 +295,26 @@ private class LNProfileOrderSkillItemView: UIView {
         fatalError("init(coder:) has not been implemented")
     }
 }
+
+#if DEBUG
+
+import SwiftUI
+
+struct LNProfileOrderPanelPreview: UIViewRepresentable {
+    func makeUIView(context: Context) -> some UIView {
+        let container = UIView()
+        container.backgroundColor = .lightGray
+        
+        let view = LNCreateOrderFromSkillListPanel()
+        view.showIn(container)
+        
+        return container
+    }
+    
+    func updateUIView(_ uiView: UIViewType, context: Context) { }
+}
+
+#Preview(body: {
+    LNProfileOrderPanelPreview()
+})
+#endif // DEBUG

+ 20 - 6
Lanu/Views/Wallet/Coin/LNCoinViewController.swift

@@ -50,8 +50,8 @@ extension LNCoinViewController {
             list.forEach {
                 let itemView = LNCoinRechargeItemView()
                 itemView.coinLabel.text = "\($0.coinRechargeAmount)"
-                itemView.moneyLabel.text = "IDR \($0.amount)"
-                itemView.goodId = $0.id
+                itemView.moneyLabel.text = "\($0.currency) \($0.amount)"
+                itemView.goods = $0
                 itemView.onTap { [weak self, weak itemView] in
                     guard let self, let itemView else { return }
                     updateSelection(newSelection: itemView)
@@ -70,6 +70,14 @@ extension LNCoinViewController: LNPurchaseManagerNotify {
     func onUserWalletInfoChanged(info: LNUserWalletInfo) {
         valueLabel.text = info.coin.toDisplay
     }
+    
+    func onUserPurchaseResult(err: LNPurchaseError?) {
+        if let err {
+            showToast(err.localizedDescription)
+            return
+        }
+        
+    }
 }
 
 extension LNCoinViewController {
@@ -215,7 +223,8 @@ extension LNCoinViewController {
         
         rechargeView.columns = 3
         rechargeView.spacing = 16
-        rechargeView.itemDistribution = .equalSpacing
+        rechargeView.itemDistribution = .fillEqually
+        rechargeView.itemSpacing = 10
         scrollView.addSubview(rechargeView)
         rechargeView.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview()
@@ -229,6 +238,12 @@ extension LNCoinViewController {
         rechargeButton.setTitleColor(.text_1, for: .normal)
         rechargeButton.titleLabel?.font = .heading_h3
         rechargeButton.clipsToBounds = true
+        rechargeButton.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            guard let goods = itemViews.first(where: { $0.isSelected })?.goods else { return }
+//            LNPurchaseManager.shared.purchaseProduct(goods: goods)
+            RechargeManager.shared.startRecharge(goods: goods)
+        }), for: .touchUpInside)
         scrollView.addSubview(rechargeButton)
         rechargeButton.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview()
@@ -282,10 +297,9 @@ extension LNCoinViewController {
 }
 
 private class LNCoinRechargeItemView: UIView {
-    static let size: CGSize = .init(width: 107, height: 76)
     let coinLabel = UILabel()
     let moneyLabel = UILabel()
-    var goodId: String = ""
+    var goods: LNPurchaseGoodsVO?
     
     var isSelected: Bool = false {
         didSet {
@@ -302,7 +316,7 @@ private class LNCoinRechargeItemView: UIView {
         layer.cornerRadius = 12
         clipsToBounds = true
         snp.makeConstraints { make in
-            make.size.equalTo(Self.size)
+            make.height.equalTo(76)
         }
         
         bg.image = .primary_7

+ 3 - 3
Lanu/Views/Wallet/Diamond/LNDiamondViewController.swift

@@ -215,7 +215,8 @@ extension LNDiamondViewController {
         
         rechargeView.columns = 3
         rechargeView.spacing = 16
-        rechargeView.itemDistribution = .equalSpacing
+        rechargeView.itemDistribution = .fillEqually
+        rechargeView.itemSpacing = 10
         scrollView.addSubview(rechargeView)
         rechargeView.snp.makeConstraints { make in
             make.directionalHorizontalEdges.equalToSuperview()
@@ -282,7 +283,6 @@ extension LNDiamondViewController {
 }
 
 private class LNDiamondRechargeItemView: UIView {
-    static let size: CGSize = .init(width: 107, height: 76)
     let diamondLabel = UILabel()
     let moneyLabel = UILabel()
     var goodId: String = ""
@@ -302,7 +302,7 @@ private class LNDiamondRechargeItemView: UIView {
         layer.cornerRadius = 12
         clipsToBounds = true
         snp.makeConstraints { make in
-            make.size.equalTo(Self.size)
+            make.height.equalTo(76)
         }
         
         bg.image = .primary_7

+ 7 - 0
Lanu/Views/Wallet/LNWalletViewController.swift

@@ -30,6 +30,13 @@ class LNWalletViewController: LNViewController {
         setupViews()
         
         updateWalletInfo()
+        LNEventDeliver.addObserver(self)
+    }
+}
+
+extension LNWalletViewController: LNPurchaseManagerNotify {
+    func onUserWalletInfoChanged(info: LNUserWalletInfo) {
+        updateWalletInfo()
     }
 }