ソースを参照

feat: 完善订单二维码页面,完善个人页分享页

陈文艺 4 ヶ月 前
コミット
3979ff3226
47 ファイル変更2264 行追加58 行削除
  1. 10 0
      Lanu.xcodeproj/project.pbxproj
  2. 1 0
      Lanu/AppDelegate.swift
  3. 22 0
      Lanu/Assets.xcassets/Order/ic_order_share_bg.imageset/Contents.json
  4. BIN
      Lanu/Assets.xcassets/Order/ic_order_share_bg.imageset/ic_order_share@2x.png
  5. BIN
      Lanu/Assets.xcassets/Order/ic_order_share_bg.imageset/ic_order_share@3x.png
  6. 6 0
      Lanu/Assets.xcassets/Profile/Post/Contents.json
  7. 22 0
      Lanu/Assets.xcassets/Profile/Post/ic_post_location_bg.imageset/Contents.json
  8. BIN
      Lanu/Assets.xcassets/Profile/Post/ic_post_location_bg.imageset/ic_post_location_bg@2x.png
  9. BIN
      Lanu/Assets.xcassets/Profile/Post/ic_post_location_bg.imageset/ic_post_location_bg@3x.png
  10. 22 0
      Lanu/Assets.xcassets/Profile/Post/ic_post_share.imageset/Contents.json
  11. BIN
      Lanu/Assets.xcassets/Profile/Post/ic_post_share.imageset/ic_profile_share_bg@2x.png
  12. BIN
      Lanu/Assets.xcassets/Profile/Post/ic_post_share.imageset/ic_profile_share_bg@3x.png
  13. 22 0
      Lanu/Assets.xcassets/Profile/Post/ic_post_share_new.imageset/Contents.json
  14. BIN
      Lanu/Assets.xcassets/Profile/Post/ic_post_share_new.imageset/ic_post_share_new@2x.png
  15. BIN
      Lanu/Assets.xcassets/Profile/Post/ic_post_share_new.imageset/ic_post_share_new@3x.png
  16. 34 0
      Lanu/Common/Utils/String+Extension.swift
  17. 37 0
      Lanu/Common/Utils/UIImage+Extension.swift
  18. 76 0
      Lanu/Common/Views/LNPopupViewProtocol.swift
  19. 17 3
      Lanu/Manager/Account/LNAccountManager.swift
  20. 3 3
      Lanu/Manager/Account/Network/LNHttpManager+Login.swift
  21. 7 2
      Lanu/Manager/Account/Network/LNLoginResponse.swift
  22. 84 1
      Lanu/Manager/GameMate/LNGameMateManager.swift
  23. 16 0
      Lanu/Manager/GameMate/LNUserGameMateInfo.swift
  24. 24 0
      Lanu/Manager/GameMate/Network/LNGameMateResponse.swift
  25. 13 0
      Lanu/Manager/GameMate/Network/LNHttpManager+GameMate.swift
  26. 24 4
      Lanu/Manager/Order/LNOrderManager.swift
  27. 20 2
      Lanu/Manager/Order/Network/LNHttpManager+Order.swift
  28. 9 3
      Lanu/Manager/Order/Network/LNOrderResponse.swift
  29. 5 5
      Lanu/Manager/Profile/LNProfileManager.swift
  30. 0 2
      Lanu/Manager/Profile/LNUserProfileInfo.swift
  31. 0 1
      Lanu/Manager/Profile/Network/LNProfileResponse.swift
  32. 4 0
      Lanu/Views/Game/MateList/LNGameMateListView.swift
  33. 93 0
      Lanu/Views/Game/Skill/LNSkillDetailViewController.swift
  34. 1 4
      Lanu/Views/Main/LNLanguageSettingPanel.swift
  35. 1 1
      Lanu/Views/Order/LNOrderDetailViewController.swift
  36. 2 2
      Lanu/Views/Order/OrderList/LNOrderListItemCell.swift
  37. 18 4
      Lanu/Views/Order/OrderList/LNOrderListViewController.swift
  38. 263 0
      Lanu/Views/Order/OrderQR/LNOrderCustomView.swift
  39. 183 0
      Lanu/Views/Order/OrderQR/LNOrderGenerateQRCodePanel.swift
  40. 162 0
      Lanu/Views/Order/OrderQR/LNOrderQRCodeShowView.swift
  41. 95 0
      Lanu/Views/Order/OrderQR/LNOrderQRTabView.swift
  42. 176 0
      Lanu/Views/Order/OrderQR/LNOrderShareImageGenerator.swift
  43. 124 0
      Lanu/Views/Order/OrderQR/LNOrderSkillListPanel.swift
  44. 7 9
      Lanu/Views/Profile/LNEditProfileViewController.swift
  45. 30 12
      Lanu/Views/Profile/LNMineViewController.swift
  46. 319 0
      Lanu/Views/Profile/Post/LNPostShareImageGenerator.swift
  47. 312 0
      Lanu/Views/Profile/Post/LNSharePostViewController.swift

+ 10 - 0
Lanu.xcodeproj/project.pbxproj

@@ -70,6 +70,7 @@
 				"Manager/Account/Network/LNHttpManager+Login.swift",
 				Manager/Account/Network/LNLoginResponse.swift,
 				Manager/GameMate/LNGameMateManager.swift,
+				Manager/GameMate/LNUserGameMateInfo.swift,
 				Manager/GameMate/Network/LNGameMateResponse.swift,
 				"Manager/GameMate/Network/LNHttpManager+GameMate.swift",
 				Manager/IM/GenerateTestUserSig.m,
@@ -100,6 +101,7 @@
 				Views/Game/MateList/LNGameMateListMenuView.swift,
 				Views/Game/MateList/LNGameMateListView.swift,
 				Views/Game/MateList/LNGameMateListViewController.swift,
+				Views/Game/Skill/LNSkillDetailViewController.swift,
 				Views/IM/LNIMViewController.swift,
 				Views/Login/LNLoginViewController.swift,
 				Views/Login/LNPrivacyTextView.swift,
@@ -116,9 +118,17 @@
 				Views/Order/LNOrderDetailViewController.swift,
 				Views/Order/OrderList/LNOrderListItemCell.swift,
 				Views/Order/OrderList/LNOrderListViewController.swift,
+				Views/Order/OrderQR/LNOrderCustomView.swift,
+				Views/Order/OrderQR/LNOrderGenerateQRCodePanel.swift,
+				Views/Order/OrderQR/LNOrderQRCodeShowView.swift,
+				Views/Order/OrderQR/LNOrderQRTabView.swift,
+				Views/Order/OrderQR/LNOrderShareImageGenerator.swift,
+				Views/Order/OrderQR/LNOrderSkillListPanel.swift,
 				Views/Profile/LNEditProfileViewController.swift,
 				Views/Profile/LNMineViewController.swift,
 				Views/Profile/LNProfileViewController.swift,
+				Views/Profile/Post/LNPostShareImageGenerator.swift,
+				Views/Profile/Post/LNSharePostViewController.swift,
 			);
 			target = FBFE13BF2EBC39B000DCE6E9 /* Lanu */;
 		};

+ 1 - 0
Lanu/AppDelegate.swift

@@ -21,6 +21,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         
         _ = LNProfileManager.shared
         _ = LNIMManager.shared
+        _ = LNGameMateManager.shared
         
         LNEventDeliver.notifyAppLaunchFinished()
         

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

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

BIN
Lanu/Assets.xcassets/Order/ic_order_share_bg.imageset/ic_order_share@2x.png


BIN
Lanu/Assets.xcassets/Order/ic_order_share_bg.imageset/ic_order_share@3x.png


+ 6 - 0
Lanu/Assets.xcassets/Profile/Post/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 22 - 0
Lanu/Assets.xcassets/Profile/Post/ic_post_location_bg.imageset/Contents.json

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

BIN
Lanu/Assets.xcassets/Profile/Post/ic_post_location_bg.imageset/ic_post_location_bg@2x.png


BIN
Lanu/Assets.xcassets/Profile/Post/ic_post_location_bg.imageset/ic_post_location_bg@3x.png


+ 22 - 0
Lanu/Assets.xcassets/Profile/Post/ic_post_share.imageset/Contents.json

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

BIN
Lanu/Assets.xcassets/Profile/Post/ic_post_share.imageset/ic_profile_share_bg@2x.png


BIN
Lanu/Assets.xcassets/Profile/Post/ic_post_share.imageset/ic_profile_share_bg@3x.png


+ 22 - 0
Lanu/Assets.xcassets/Profile/Post/ic_post_share_new.imageset/Contents.json

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

BIN
Lanu/Assets.xcassets/Profile/Post/ic_post_share_new.imageset/ic_post_share_new@2x.png


BIN
Lanu/Assets.xcassets/Profile/Post/ic_post_share_new.imageset/ic_post_share_new@3x.png


+ 34 - 0
Lanu/Common/Utils/String+Extension.swift

@@ -40,3 +40,37 @@ extension String {
         return digest.map { String(format: "%02x", $0) }.joined()
     }
 }
+
+extension String {
+    func toQRCode(size: CGFloat = 200, correctionLevel: String = "H") -> UIImage? {
+        guard let contentData = data(using: .utf8) else {
+//            print("字符串编码失败(仅支持 UTF-8)")
+            return nil
+        }
+        
+        // 2. 创建 CIQRCodeGenerator 滤镜
+        guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else {
+//            print("创建二维码滤镜失败")
+            return nil
+        }
+        
+        // 3. 设置滤镜参数:内容 + 容错率
+        qrFilter.setValue(contentData, forKey: "inputMessage")
+        // 容错率可选值:L(7%)、M(15%)、Q(25%)、H(30%),H 容错率最高
+        qrFilter.setValue(correctionLevel, forKey: "inputCorrectionLevel")
+        
+        // 4. 获取 CIImage(原始二维码是小尺寸模糊图,需缩放)
+        guard let ciImage = qrFilter.outputImage else {
+//            print("生成 CIImage 失败")
+            return nil
+        }
+        
+        // 5. 缩放 CIImage 到指定尺寸(避免模糊)
+        let scaleX = size / ciImage.extent.width
+        let scaleY = size / ciImage.extent.height
+        let scaledImage = ciImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
+        
+        // 6. 转为 UIImage 并返回
+        return UIImage(ciImage: scaledImage)
+    }
+}

+ 37 - 0
Lanu/Common/Utils/UIImage+Extension.swift

@@ -6,6 +6,7 @@
 //
 
 import Foundation
+import Photos
 
 
 extension UIImage {
@@ -43,3 +44,39 @@ extension UIImage {
         }
     }
 }
+
+extension UIImage {
+    func saveToLibrary(completion: ((Bool, Error?) -> Void)?) {
+        // 步骤1:检查并请求相册权限
+        PHPhotoLibrary.requestAuthorization { status in
+            DispatchQueue.main.async { // 回调默认在子线程,切回主线程处理UI
+                switch status {
+                case .authorized, .limited: // 授权(含iOS 14+有限授权)
+                    // 步骤2:异步保存图片到相册
+                    PHPhotoLibrary.shared().performChanges({
+                        // 创建保存请求
+                        PHAssetChangeRequest.creationRequestForAsset(from: self)
+                    }) { success, error in
+                        completion?(success, error)
+                    }
+                    
+                case .denied, .restricted: // 权限拒绝/受限
+                    let error = NSError(domain: "AlbumError", code: -1, userInfo: [
+                        NSLocalizedDescriptionKey: "相册权限已拒绝,请前往设置开启"
+                    ])
+                    completion?(false, error)
+                    
+                case .notDetermined: // 理论上不会走到这里(requestAuthorization已触发授权)
+                    completion?(false, NSError(domain: "AlbumError", code: -2, userInfo: [
+                        NSLocalizedDescriptionKey: "权限请求未完成"
+                    ]))
+                    
+                @unknown default:
+                    completion?(false, NSError(domain: "AlbumError", code: -3, userInfo: [
+                        NSLocalizedDescriptionKey: "未知权限状态"
+                    ]))
+                }
+            }
+        }
+    }
+}

+ 76 - 0
Lanu/Common/Views/LNPopupViewProtocol.swift

@@ -19,6 +19,8 @@ protocol LNPopupViewProtocol: UIView {
     var container: UIView { get }
     var popupAxis: NSLayoutConstraint.Axis { get }
     var containerHeight: LNPopupViewHeight { get }
+    
+    func onWillPopup()
 }
 
 extension LNPopupViewProtocol {
@@ -79,6 +81,20 @@ extension LNPopupViewProtocol {
         }
     }
     
+    private func onKeyboardShowup(_ keyboardHeight: CGFloat) {
+        container.snp.remakeConstraints { make in
+            make.leading.trailing.equalToSuperview()
+            switch containerHeight {
+            case .percent(let percent):
+                make.height.equalToSuperview().multipliedBy(percent)
+            case .height(let height):
+                make.height.equalTo(height)
+            case .auto: break
+            }
+            make.bottom.equalToSuperview().offset(-keyboardHeight)
+        }
+    }
+    
     private func prepare() {
         let bg = UIView()
         bg.onTap { [weak self] in
@@ -97,6 +113,63 @@ extension LNPopupViewProtocol {
         if container.superview == nil {
             self.addSubview(container)
         }
+        addKeyboardObservers()
+    }
+    
+    private func addKeyboardObservers() {
+        NotificationCenter.default.addObserver(
+            forName: UIResponder.keyboardWillShowNotification,
+            object: nil, queue: .main
+        ) { [weak self] notify in
+            guard let self else { return }
+            
+            guard let userInfo = notify.userInfo,
+                  let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
+            else { return }
+            
+            // 获取键盘动画时长(默认0.25秒)
+            let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
+            
+            // 获取键盘动画曲线(默认UIView.AnimationCurve.easeInOut)
+            let animationCurveRawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? UIView.AnimationCurve.easeInOut.rawValue
+            
+            // 应用动画参数调整UI(使视图动画与键盘动画同步)
+            onKeyboardShowup(CGRectGetHeight(keyboardFrame))
+            UIView.animate(withDuration: animationDuration,
+                           delay: 0,
+                           options: UIView
+                .AnimationOptions(rawValue: UInt(animationCurveRawValue)),
+                           animations: { [weak self] in
+                guard let self else { return }
+                self.layoutIfNeeded()
+            })
+        }
+        NotificationCenter.default.addObserver(
+            forName: UIResponder.keyboardWillHideNotification,
+            object: nil, queue: .main
+        ) { [weak self] notify in
+            guard let self else { return }
+            
+            guard let userInfo = notify.userInfo
+            else { return }
+            
+            // 获取键盘动画时长(默认0.25秒)
+            let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
+            
+            // 获取键盘动画曲线(默认UIView.AnimationCurve.easeInOut)
+            let animationCurveRawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? UIView.AnimationCurve.easeInOut.rawValue
+            // 应用动画参数调整UI(使视图动画与键盘动画同步)
+            
+            moveToShowupPosition()
+            UIView.animate(withDuration: animationDuration,
+                           delay: 0,
+                           options: UIView
+                .AnimationOptions(rawValue: UInt(animationCurveRawValue)),
+                           animations: { [weak self] in
+                guard let self else { return }
+                self.layoutIfNeeded()
+            })
+        }
     }
 }
 
@@ -114,6 +187,7 @@ extension LNPopupViewProtocol {
         
         moveToHiddenPosition()
         layoutIfNeeded()
+        onWillPopup()
         moveToShowupPosition()
         
         backgroundColor = .clear
@@ -132,4 +206,6 @@ extension LNPopupViewProtocol {
             self.removeFromSuperview()
         }
     }
+    
+    func onWillPopup() {}
 }

+ 17 - 3
Lanu/Manager/Account/LNAccountManager.swift

@@ -28,6 +28,10 @@ var myUid: String {
     LNAccountManager.shared.uid
 }
 
+var hasLogin: Bool {
+    !myUid.isEmpty
+}
+
 class LNAccountManager {
     static let shared = LNAccountManager()
     
@@ -50,7 +54,7 @@ class LNAccountManager {
                 self.clean()
                 return
             }
-            self.token = res
+            self.token = res.token
             completion?(true)
             
             self.notifyUserLogin()
@@ -66,13 +70,23 @@ class LNAccountManager {
                 return
             }
             self.token = response.token
-            self.uid = response.userProfile.id
+            self.uid = response.userProfile.userNo
             completion?(true)
             
             self.notifyUserLogin()
         }
     }
     
+    func logout() {
+        LNHttpManager.shared.logout { [weak self] err in
+            guard let self else { return }
+            guard err == nil else {
+                return
+            }
+            self.clean()
+        }
+    }
+    
 #if DEBUG
     func loginByEmail(email: String, completion: @escaping (Bool) -> Void) {
         LNHttpManager.shared.loginByEmail(email: email) { [weak self] response, err in
@@ -83,7 +97,7 @@ class LNAccountManager {
                 return
             }
             self.token = response.token
-            self.uid = response.userProfile.id
+            self.uid = response.userProfile.userNo
             completion(true)
             
             self.notifyUserLogin()

+ 3 - 3
Lanu/Manager/Account/Network/LNHttpManager+Login.swift

@@ -16,17 +16,17 @@ let kNetPath_Login_Refresh = "/user/renewalToken"
 let kNetPath_Logout = "/user/logout"
 
 extension LNHttpManager {
-    func loginByGoogle(data: String, completion: @escaping (LNLoginResponseVO?, LNHttpError?) -> Void) {
+    func loginByGoogle(data: String, completion: @escaping (LNLoginResponse?, LNHttpError?) -> Void) {
         post(path: kNetPath_Login_Google, params: ["data": data], completion: completion)
     }
     
 #if DEBUG
-    func loginByEmail(email: String, completion: @escaping (LNLoginResponseVO?, LNHttpError?) -> Void) {
+    func loginByEmail(email: String, completion: @escaping (LNLoginResponse?, LNHttpError?) -> Void) {
         post(path: kNetPath_Login_Email, params: ["email": email], completion: completion)
     }
 #endif
     
-    func refreshToken(completion: @escaping (String?, LNHttpError?) -> Void) {
+    func refreshToken(completion: @escaping (LNRefreshTokenResponse?, LNHttpError?) -> Void) {
         post(path: kNetPath_Login_Refresh, completion: completion)
     }
     

+ 7 - 2
Lanu/Manager/Account/Network/LNLoginResponse.swift

@@ -10,13 +10,18 @@ import AutoCodable
 
 @AutoCodable
 class LNLoginUserInfoVO: Decodable {
-    var id: String = ""
+    var userNo: String = ""
     
     init() { }
 }
 
 @AutoCodable
-class LNLoginResponseVO: Decodable {
+class LNLoginResponse: Decodable {
     var token: String = ""
     var userProfile: LNLoginUserInfoVO = LNLoginUserInfoVO()
 }
+
+@AutoCodable
+class LNRefreshTokenResponse: Decodable {
+    var token: String = ""
+}

+ 84 - 1
Lanu/Manager/GameMate/LNGameMateManager.swift

@@ -8,11 +8,77 @@
 import Foundation
 
 
+protocol LNGameMateManagerNotify {
+    func onUserGameMateInfoChanged(info: LNUserGameMateInfo)
+}
+
+var myGameMateInfo: LNUserGameMateInfo {
+    LNGameMateManager.shared.myGameMateInfo
+}
+
+
 class LNGameMateManager {
     static let shared = LNGameMateManager()
     private(set) var curGameTypes: [LNGameTypeItemVO] = []
     
-    private init() {}
+    fileprivate var myGameMateInfo: LNUserGameMateInfo = LNUserGameMateInfo()
+    
+    private init() {
+        LNEventDeliver.addObserver(self)
+    }
+}
+
+extension LNGameMateManager {
+    func getUserSkills(uid: String, queue: DispatchQueue = .main,
+                       handler: @escaping ([LNGameMateSkillVO]?) -> Void) {
+        LNHttpManager.shared.getUserSkills(uid: uid) { res, err in
+            guard err == nil, let res else {
+                queue.asyncIfNotGlobal {
+                    handler(nil)
+                }
+                return
+            }
+            if uid.isMyUid {
+                self.myGameMateInfo.skills = res.list
+                
+                self.notifyUserGameMateInfoChanged()
+            }
+            queue.asyncIfNotGlobal {
+                handler(res.list)
+            }
+        }
+    }
+    
+    func getUserGameMateInfo(uid: String, queue: DispatchQueue = .main,
+                             handler: @escaping ([String]?, [LNGameMateSkillVO]?) -> Void) {
+        LNHttpManager.shared.getGameMateInfo(uid: uid) { res, err in
+            guard err == nil, let res else {
+                queue.asyncIfNotGlobal {
+                    handler(nil, nil)
+                }
+                return
+            }
+            if uid.isMyUid {
+                self.myGameMateInfo.area = res.area
+                self.myGameMateInfo.skills = res.skills
+//                self.myGameMateInfo.photos = res.photos
+                self.myGameMateInfo.photos = [
+                    "https://c-ssl.duitang.com/uploads/blog/202205/14/20220514100830_15165.jpg",
+                    "https://c-ssl.duitang.com/uploads/blog/202205/14/20220514101201_cff3f.jpg",
+                    "https://c-ssl.duitang.com/uploads/blog/202205/14/20220514100750_a6bdb.jpg",
+                    "https://img.redocn.com/sheji/20231026/katongnvhairenwu_13141963.jpg",
+                    "https://pic57.photophoto.cn/20201012/0005018305835826_b.jpg",
+                    "https://img.guomanbizhi.com/tianxingjiuge/chaonvyao-224zyifgsg.jpg",
+                ]
+                self.myGameMateInfo.star = res.star
+                
+                self.notifyUserGameMateInfoChanged()
+            }
+            queue.asyncIfNotGlobal {
+                handler(res.photos, res.skills)
+            }
+        }
+    }
 }
 
 extension LNGameMateManager {
@@ -53,3 +119,20 @@ extension LNGameMateManager {
                 }
         }
 }
+
+extension LNGameMateManager: LNUserMainEvent {
+    func onUserLogout() {
+        myGameMateInfo = LNUserGameMateInfo()
+    }
+    
+    func onUserLogin() {
+        getUserGameMateInfo(uid: myUid) { _, _ in }
+    }
+}
+
+extension LNGameMateManager {
+    private func notifyUserGameMateInfoChanged() {
+        let curInfo = myGameMateInfo
+        LNEventDeliver.notifyEvent { ($0 as? LNGameMateManagerNotify)?.onUserGameMateInfoChanged(info: curInfo) }
+    }
+}

+ 16 - 0
Lanu/Manager/GameMate/LNUserGameMateInfo.swift

@@ -0,0 +1,16 @@
+//
+//  LNUserGameMateInfo.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/28.
+//
+
+import Foundation
+
+
+class LNUserGameMateInfo {
+    var photos: [String] = []
+    var skills: [LNGameMateSkillVO] = []
+    var star: Double = 0.0
+    var area: String = ""
+}

+ 24 - 0
Lanu/Manager/GameMate/Network/LNGameMateResponse.swift

@@ -53,3 +53,27 @@ class LNGameTypeItemVO: Decodable {
 class LNGameTypeListResponse: Decodable {
     var list: [LNGameTypeItemVO] = []
 }
+
+@AutoCodable
+class LNGameMateSkillVO: Decodable {
+    var id: String = ""
+    var name: String = ""
+    var icon: String = ""
+    var price: Int = 0
+    var unit: String = ""
+    var cover: String = ""
+    var value: Double = 0.0
+}
+
+@AutoCodable
+class LNGameMateSkillListResponse: Decodable {
+    var list: [LNGameMateSkillVO] = []
+}
+
+@AutoCodable
+class LNGameMateInfoResponse: Decodable {
+    var photos: [String] = []
+    var skills: [LNGameMateSkillVO] = []
+    var star: Double = 0.0
+    var area: String = ""
+}

+ 13 - 0
Lanu/Manager/GameMate/Network/LNHttpManager+GameMate.swift

@@ -11,6 +11,9 @@ let kNetPath_GameMate_Category = "/biz/categorys"
 
 let kNetPath_GameMate_List = "/skill/list"
 
+let kNetPath_GameMate_Skills = "/skill/user/goods"
+let kNetPath_GameMate_Info = "/user/playmate/info"
+
 enum LNGameMateAgeRange: Int, CaseIterable {
     case all = -1
     case `15-20` = 0
@@ -144,3 +147,13 @@ extension LNHttpManager {
              completion: completion)
     }
 }
+
+extension LNHttpManager {
+    func getUserSkills(uid: String, completion: @escaping (LNGameMateSkillListResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_GameMate_Skills, params: ["id": uid], completion: completion)
+    }
+    
+    func getGameMateInfo(uid: String, completion: @escaping (LNGameMateInfoResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_GameMate_Info, params: ["id": uid], completion: completion)
+    }
+}

+ 24 - 4
Lanu/Manager/Order/LNOrderManager.swift

@@ -15,9 +15,9 @@ class LNOrderManager {
 }
 
 extension LNOrderManager {
-    func getList(size: Int, next: String? = nil, queue: DispatchQueue = .main,
+    func getList(filter: LNOrderStatus?, size: Int, next: String? = nil, queue: DispatchQueue = .main,
                  handler: @escaping ([LNOrderListItemVO]?, String) -> Void) {
-        LNHttpManager.shared.getOrderList(size: size, next: next ?? "") { res, err in
+        LNHttpManager.shared.getOrderList(filter: filter, size: size, next: next ?? "") { res, err in
             guard err == nil, let res else {
                 queue.asyncIfNotGlobal {
                     handler(nil, "")
@@ -26,7 +26,7 @@ extension LNOrderManager {
             }
             queue.asyncIfNotGlobal {
                 handler(res.list.filter({
-                    $0.status.rawValue > LNOrderStatus.toPay.rawValue
+                    $0.status.rawValue > LNOrderStatus.pending.rawValue
                     && $0.status.rawValue <= LNOrderStatus.refunded.rawValue
                 }), res.next)
             }
@@ -47,7 +47,9 @@ extension LNOrderManager {
             }
         }
     }
-    
+}
+
+extension LNOrderManager {
     func finishOrder(orderId: String, queue: DispatchQueue = .main,
                      handler: @escaping (Bool) -> Void) {
         LNHttpManager.shared.finishOrder(orderId: orderId) { err in
@@ -65,6 +67,7 @@ extension LNOrderManager {
             }
         }
     }
+    
     func starOrder(orderId: String, star: Int, queue: DispatchQueue = .main,
                    handler: @escaping (Bool) -> Void) {
         LNHttpManager.shared.starOrder(orderId: orderId, star: star) { err in
@@ -74,3 +77,20 @@ extension LNOrderManager {
         }
     }
 }
+
+extension LNOrderManager {
+    func createOrderQR(skillId: String,
+                       count: Int, type: LNOrderSource,
+                       queue: DispatchQueue = .main,
+                       completion: @escaping (String?, LNHttpError?) -> Void) {
+        LNHttpManager.shared.createOrderQR(skillId: skillId, count: count, type: type) { data, err in
+            queue.asyncIfNotGlobal {
+                if let data, err == nil {
+                    completion("http://localhost:3000/user/category?id=\(skillId)&code=\(data)", err)
+                } else {
+                    completion(data, err)
+                }
+            }
+        }
+    }
+}

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

@@ -15,9 +15,16 @@ let kNetPath_Order_Finish = "/skill/order/finish"
 let kNetPath_Order_Delete = "/skill/order/del"
 let kNetPath_Order_Star = "/skill/order/star"
 
+let kNetPath_Order_QR_Create = "/skill/create/order/qrcode"
+
 extension LNHttpManager {
-    func getOrderList(size: Int, next: String, completion: @escaping (LNOrderListResponse?, LNHttpError?) -> Void) {
-        post(path: kNetPath_Order_List, params: ["size": size, "next": next], completion: completion)
+    func getOrderList(filter: LNOrderStatus?, size: Int, next: String, completion: @escaping (LNOrderListResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_Order_List, params: [
+            "status": filter?.rawValue ?? -1,
+            "page": [
+                "size": size, "next": next
+            ]
+        ], completion: completion)
     }
     
     func getOrderDetail(orderId: String, completion: @escaping (LNOrderDetailVO?, LNHttpError?) -> Void) {
@@ -38,3 +45,14 @@ extension LNHttpManager {
         post(path: kNetPath_Order_Star, completion: completion)
     }
 }
+
+extension LNHttpManager {
+    func createOrderQR(skillId: String, count: Int, type: LNOrderSource,
+                       completion: @escaping (String?, LNHttpError?) -> Void) {
+        post(path: kNetPath_Order_QR_Create, params: [
+            "skillId": skillId,
+            "purchaseQty": count,
+            "type": type.rawValue
+        ], completion: completion)
+    }
+}

+ 9 - 3
Lanu/Manager/Order/Network/LNOrderResponse.swift

@@ -10,13 +10,19 @@ import AutoCodable
 
 
 enum LNOrderStatus: Int, Decodable {
-    case toPay = 0
+    case pending = 0
     case inProgress = 1
     case completed = 2
     case refunded = 3
 }
 
 
+enum LNOrderSource: Int {
+    case normal = 1
+    case custom = 2
+}
+
+
 @AutoCodable
 class LNOrderListItemVO: Decodable {
     var orderId: String = ""
@@ -26,7 +32,7 @@ class LNOrderListItemVO: Decodable {
     var price: Int = 0
     var unit: Int = 0
     var purchaseQty: Int = 0
-    var status: LNOrderStatus = .toPay
+    var status: LNOrderStatus = .pending
     var createTime: Int = 0
 }
 
@@ -47,6 +53,6 @@ class LNOrderDetailVO: Decodable {
     var price: Int = 0
     var unit: Int = 0
     var purchaseQty: Int = 0
-    var status: LNOrderStatus = .toPay
+    var status: LNOrderStatus = .pending
     var createTime: Int = 0
 }

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

@@ -12,14 +12,14 @@ protocol LNProfileManagerNotify {
     func onUserInfoChanged(userInfo: LNUserProfileInfo)
 }
 
-var myUserInfo: LNUserProfileInfo? {
+var myUserInfo: LNUserProfileInfo {
     LNProfileManager.shared.myUserInfo
 }
 
 class LNProfileManager {
     static let shared = LNProfileManager()
     
-    fileprivate var myUserInfo: LNUserProfileInfo?
+    fileprivate var myUserInfo: LNUserProfileInfo = LNUserProfileInfo()
     
     private let lock = NSLock()
     private var profileCached: [String: LNUserProfileInfo] = [:]
@@ -90,11 +90,11 @@ extension LNProfileManager {
 
 extension LNProfileManager {
     private func updateUserInfo(info: LNUserProfileInfo) {
-        if info.id.isMyUid {
+        if info.userNo.isMyUid {
             myUserInfo = info
         }
         lock.lock()
-        profileCached[info.id] = info
+        profileCached[info.userNo] = info
         lock.unlock()
         
         notifyUserInfoChanged(newInfo: info)
@@ -107,7 +107,7 @@ extension LNProfileManager: LNUserMainEvent {
     }
     
     func onUserLogout() {
-        myUserInfo = nil
+        myUserInfo = LNUserProfileInfo()
     }
 }
 

+ 0 - 2
Lanu/Manager/Profile/LNUserProfileInfo.swift

@@ -9,7 +9,6 @@ import Foundation
 
 
 class LNUserProfileInfo {
-    var id: String = ""
     var userNo: String = ""
     var avatar: String = ""
     var nickname: String = ""
@@ -21,7 +20,6 @@ class LNUserProfileInfo {
     init() {}
     
     init(profile: LNUserProfileVO) {
-        id = profile.id
         userNo = profile.userNo
         avatar = profile.avatar
         nickname = profile.nickname

+ 0 - 1
Lanu/Manager/Profile/Network/LNProfileResponse.swift

@@ -16,7 +16,6 @@ enum LNUserGender: Int, Decodable {
 
 @AutoCodable
 class LNUserProfileVO: Decodable {
-    var id: String = ""
     var userNo: String = ""
     var avatar: String = ""
     var nickname: String = ""

+ 4 - 0
Lanu/Views/Game/MateList/LNGameMateListView.swift

@@ -110,6 +110,10 @@ extension LNGameMateListView: UITableViewDataSource, UITableViewDelegate {
     
     func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
         tableView.deselectRow(at: indexPath, animated: true)
+        
+        let item = curMateList[indexPath.row]
+        
+        pushToSkillDetail(item.id)
     }
 }
 

+ 93 - 0
Lanu/Views/Game/Skill/LNSkillDetailViewController.swift

@@ -0,0 +1,93 @@
+//
+//  LNSkillDetailViewController.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/28.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+extension UIView {
+    func pushToSkillDetail(_ skillId: String) {
+        let vc = LNSkillDetailViewController(skillId: skillId)
+        navigationController?.pushViewController(vc, animated: true)
+    }
+}
+
+
+class LNSkillDetailViewController: LNViewController {
+    private let skillId: String
+    
+    init(skillId: String) {
+        self.skillId = skillId
+        
+        super.init(nibName: nil, bundle: nil)
+    }
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        showNavigationBar = false
+        setupViews()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNSkillDetailViewController {
+    private func setupViews() {
+        let navBar = buildFakeNavBar()
+        view.addSubview(navBar)
+        navBar.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
+        }
+    }
+    
+    private func buildFakeNavBar() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(44)
+        }
+        
+        let button = UIButton(type: .system)
+        button.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            navigationController?.popViewController(animated: true)
+        }), for: .touchUpInside)
+        let buttonImage: UIImage? = .init(systemName: "chevron.backward")?.withRenderingMode(.alwaysTemplate)
+        button.contentHorizontalAlignment = .center
+        button.setImage(buttonImage, for: .normal)
+        button.tintColor = .white
+        container.addSubview(button)
+        button.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(16)
+        }
+        
+        return container
+    }
+}
+
+
+#if DEBUG
+
+import SwiftUI
+
+struct LNSkillDetailViewControllerPreview: UIViewControllerRepresentable {
+    func makeUIViewController(context: Context) -> some UIViewController {
+        LNNavigationController(rootViewController: LNSkillDetailViewController(skillId: ""))
+    }
+    
+    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { }
+}
+
+#Preview(body: {
+    LNSkillDetailViewControllerPreview()
+})
+#endif

+ 1 - 4
Lanu/Views/Main/LNLanguageSettingPanel.swift

@@ -95,10 +95,7 @@ struct LNLanguageSettingPanelPreview: UIViewRepresentable {
         container.backgroundColor = .lightGray
         
         let view = LNLanguageSettingPanel()
-        container.addSubview(view)
-        view.snp.makeConstraints { make in
-            make.leading.trailing.top.equalToSuperview()
-        }
+        view.showIn(container)
         
         return container
     }

+ 1 - 1
Lanu/Views/Order/LNOrderDetailViewController.swift

@@ -71,7 +71,7 @@ extension LNOrderDetailViewController {
         scoreContainer.subviews.forEach { $0.removeFromSuperview() }
         
         switch item.status {
-        case .toPay: return
+        case .pending: return
         case .inProgress:
             backgroundIc.image = .init(named: "ic_order_normal_bg")
             stateIc.image = .init(named: "ic_order_status_in_progress")

+ 2 - 2
Lanu/Views/Order/OrderList/LNOrderListItemCell.swift

@@ -75,7 +75,7 @@ class LNOrderListItemCell: UITableViewCell {
             operations.append(buildFinishView())
         case .completed:
             operations.append(buildCommentView())
-        case .toPay, .refunded:
+        case .pending, .refunded:
             break
         }
         operations.forEach {
@@ -315,7 +315,7 @@ extension LNOrderListItemCell {
         }
         
         switch status {
-        case .toPay:
+        case .pending:
             container.backgroundColor = .init(hex: "#C9CDD4")
             title.text = .init(key: "Error")
         case .inProgress:

+ 18 - 4
Lanu/Views/Order/OrderList/LNOrderListViewController.swift

@@ -12,30 +12,43 @@ import MJRefresh
 
 
 extension UIView {
-    func pushToOrderList() {
-        let vc = LNOrderListViewController()
+    func pushToOrderList(filterStatus: LNOrderStatus? = nil) {
+        let vc = LNOrderListViewController(filterStatus: filterStatus)
         navigationController?.pushViewController(vc, animated: true)
     }
 }
 
 
 class LNOrderListViewController: LNViewController {
+    private let filterStatus: LNOrderStatus?
+    
     private let tableView = UITableView()
     
     private var orders: [LNOrderListItemVO] = []
     private var nextTag: String? = nil
     private let pageSize = 30
     
+    init(filterStatus: LNOrderStatus?) {
+        self.filterStatus = filterStatus
+        super.init(nibName: nil, bundle: nil)
+    }
+    
     override func viewDidLoad() {
         super.viewDidLoad()
         
         setupViews()
     }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
 }
 
 extension LNOrderListViewController {
     private func loadOrderList() {
-        LNOrderManager.shared.getList(size: pageSize, next: nextTag) { [weak self] list, next in
+        LNOrderManager.shared.getList(
+            filter: filterStatus, size: pageSize, next: nextTag
+        ) { [weak self] list, next in
             guard let self else { return }
             self.nextTag = next
             self.tableView.mj_header?.endRefreshing()
@@ -102,6 +115,7 @@ extension LNOrderListViewController: UITableViewDelegate, UITableViewDataSource
 
 extension LNOrderListViewController {
     private func setupViews() {
+        title = .init(key: "我的订单")
         view.backgroundColor = .primary_1
         
         let header = MJRefreshNormalHeader { [weak self] in
@@ -141,7 +155,7 @@ import SwiftUI
 
 struct LNOrderListViewControllerPreview: UIViewControllerRepresentable {
     func makeUIViewController(context: Context) -> some UIViewController {
-        LNNavigationController(rootViewController: LNOrderListViewController())
+        LNNavigationController(rootViewController: LNOrderListViewController(filterStatus: nil))
     }
     
     func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { }

+ 263 - 0
Lanu/Views/Order/OrderQR/LNOrderCustomView.swift

@@ -0,0 +1,263 @@
+//
+//  LNOrderCustomView.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/27.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNOrderCustomView: UIView {
+    private let showView = LNOrderQRCodeShowView()
+    
+    private let editView = UIView()
+    private let priceLabel = UILabel()
+    private let mimuButton = UIButton()
+    private let unitLabel = UILabel()
+    private let countLabel = UILabel()
+    private let addButton = UIButton()
+    private let costLabtl = UILabel()
+    
+    private var curSkill: LNGameMateSkillVO?
+    
+    private var customCount = 1 {
+        didSet {
+            countLabel.text = "\(customCount)"
+            if customCount <= 1 {
+                mimuButton.isEnabled = false
+            } else {
+                mimuButton.isEnabled = true
+            }
+            updateEditCost()
+        }
+    }
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func update(_ skill: LNGameMateSkillVO) {
+        guard curSkill?.id != skill.id else { return }
+        
+        showView.isHidden = true
+        editView.isHidden = false
+        
+        priceLabel.text = "\(skill.price)"
+        unitLabel.text = skill.unit
+        customCount = 1
+        
+        curSkill = skill
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNOrderCustomView {
+    private func updateEditCost() {
+        guard let skill = curSkill else { return }
+        let cost = skill.price * customCount
+        let text: String = .init(key: "%d/ %d %@", cost, customCount, skill.unit)
+        let attrStr = NSMutableAttributedString(string: text)
+        let range = (text as NSString).range(of: "\(cost)")
+        attrStr.addAttribute(.font, value: UIFont.heading_h2, range: range)
+        costLabtl.attributedText = attrStr
+    }
+    
+    private func setupViews() {
+        showView.isHidden = true
+        addSubview(showView)
+        showView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
+        }
+        
+        let editView = buildEditView()
+        addSubview(editView)
+        editView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+    }
+    
+    private func buildEditView() -> UIView {
+        editView.isHidden = false
+        
+        let container = UIView()
+        container.backgroundColor = .fill
+        container.layer.cornerRadius = 16
+        editView.addSubview(container)
+        container.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(26)
+        }
+        
+        let priceView = buildPriceView()
+        container.addSubview(priceView)
+        priceView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview().offset(16)
+        }
+        
+        let line = UIView()
+        line.backgroundColor = .fill_4
+        container.addSubview(line)
+        line.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalTo(priceView.snp.bottom).offset(6)
+            make.height.equalTo(0.5)
+        }
+        
+        let unit = buildEditUnitView()
+        container.addSubview(unit)
+        unit.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalTo(line.snp.bottom).offset(6)
+            make.bottom.equalToSuperview().offset(-12)
+        }
+        
+        let generateButton = UIButton()
+        generateButton.setTitle(.init(key: "生成二维码"), for: .normal)
+        generateButton.setBackgroundImage(.primary_8, for: .normal)
+        generateButton.layer.cornerRadius = 23.5
+        generateButton.backgroundColor = .fill_4
+        generateButton.clipsToBounds = true
+        generateButton.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            guard let skill = self.curSkill else { return }
+            
+            self.showView.update(skill, customCount: customCount)
+            self.showView.isHidden = false
+            self.editView.isHidden = true
+        }), for: .touchUpInside)
+        editView.addSubview(generateButton)
+        generateButton.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.bottom.equalToSuperview().offset(-10)
+            make.height.equalTo(47)
+        }
+        
+        return editView
+    }
+    
+    private func buildPriceView() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(45)
+        }
+        
+        let diamond = UIImageView()
+        diamond.image = .init(named: "ic_diamond")
+        container.addSubview(diamond)
+        diamond.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(16)
+            make.width.height.equalTo(23)
+        }
+        
+        priceLabel.font = .heading_h1
+        priceLabel.textColor = .text_4
+        container.addSubview(priceLabel)
+        priceLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(diamond.snp.trailing).offset(3)
+            make.trailing.equalToSuperview().offset(-16)
+            make.height.equalTo(22)
+        }
+        
+        return container
+    }
+    
+    private func buildEditUnitView() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(45)
+        }
+        
+        addButton.setTitle("+", for: .normal)
+        addButton.setTitleColor(.text_4, for: .normal)
+        addButton.setTitleColor(.text_2, for: .disabled)
+        addButton.backgroundColor = .primary_1
+        addButton.layer.cornerRadius = 12
+        addButton.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            self.customCount += 1
+        }), for: .touchUpInside)
+        container.addSubview(addButton)
+        addButton.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalToSuperview().offset(-12)
+            make.width.height.equalTo(24)
+        }
+        
+        countLabel.text = "\(customCount)"
+        countLabel.font = .body_m
+        countLabel.textColor = .text_5
+        container.addSubview(countLabel)
+        countLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalTo(addButton.snp.leading).offset(-10)
+        }
+        
+        mimuButton.setTitle("-", for: .normal)
+        mimuButton.setTitleColor(.text_4, for: .normal)
+        mimuButton.setTitleColor(.text_2, for: .disabled)
+        mimuButton.backgroundColor = .primary_1
+        mimuButton.layer.cornerRadius = 12
+        mimuButton.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            self.customCount -= 1
+        }), for: .touchUpInside)
+        container.addSubview(mimuButton)
+        mimuButton.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalTo(countLabel.snp.leading).offset(-10)
+            make.width.height.equalTo(24)
+        }
+        
+        unitLabel.font = .body_m
+        unitLabel.textColor = .text_5
+        container.addSubview(unitLabel)
+        unitLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(16)
+        }
+        
+        return container
+    }
+    
+    private func buildCostView() -> UIView {
+        let container = UIView()
+        
+        let priceView = UIView()
+        editView.addSubview(priceView)
+        priceView.snp.makeConstraints { make in
+            make.trailing.equalToSuperview().offset(-16)
+            make.top.equalTo(container.snp.bottom).offset(16)
+        }
+        
+        let diamond = UIImageView()
+        diamond.image = .init(named: "ic_diamond")
+        priceView.addSubview(diamond)
+        diamond.snp.makeConstraints { make in
+            make.leading.equalToSuperview()
+            make.centerY.equalToSuperview()
+        }
+        
+        costLabtl.font = .body_m
+        costLabtl.textColor = .text_5
+        priceView.addSubview(costLabtl)
+        costLabtl.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview()
+            make.leading.equalTo(diamond.snp.trailing).offset(6)
+            make.trailing.equalToSuperview()
+        }
+        
+        return container
+    }
+}

+ 183 - 0
Lanu/Views/Order/OrderQR/LNOrderGenerateQRCodePanel.swift

@@ -0,0 +1,183 @@
+//
+//  LNOrderGenerateQRCodePanel.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/26.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNOrderGenerateQRCodePanel: UIView, LNPopupViewProtocol {
+    var container: UIView = UIView()
+    var popupAxis: NSLayoutConstraint.Axis = .vertical
+    var containerHeight: LNPopupViewHeight = .auto
+    
+    private let curSkillIc = UIImageView()
+    private let curSkillNameLabel = UILabel()
+    private let skillArrow = UIImageView()
+    
+    private let tabView = LNOrderQRTabView()
+    
+    private let showView = LNOrderQRCodeShowView()
+    private let customView = LNOrderCustomView()
+    
+    private var curSkill: LNGameMateSkillVO? {
+        didSet {
+            if oldValue?.id != curSkill?.id {
+                onSkillChanged()
+            }
+        }
+    }
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func onWillPopup() {
+        container.backgroundColor = .primary_1
+        
+        tabView.curType = .normal
+        
+        curSkill = myGameMateInfo.skills.first
+    }
+}
+
+extension LNOrderGenerateQRCodePanel: LNOrderQRTabViewDelegate {
+    func onOrderQRTabView(view: LNOrderQRTabView, didChangedType newType: LNOrderSource) {
+        switch newType {
+        case .normal:
+            showView.isHidden = false
+            customView.isHidden = true
+        case .custom:
+            showView.isHidden = true
+            customView.isHidden = false
+        }
+    }
+}
+
+extension LNOrderGenerateQRCodePanel {
+    private func onSkillChanged() {
+        curSkillIc.sd_setImage(with: URL(string: curSkill?.icon ?? ""))
+        curSkillNameLabel.text = curSkill?.name
+        
+        if let curSkill {
+            showView.update(curSkill)
+            customView.update(curSkill)
+        }
+    }
+}
+
+extension LNOrderGenerateQRCodePanel {
+    private func setupViews() {
+        let skill = buildSkillView()
+        container.addSubview(skill)
+        skill.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
+        }
+        
+        let tab = buildTabView()
+        container.addSubview(tab)
+        tab.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalTo(skill.snp.bottom)
+        }
+        
+        container.addSubview(showView)
+        showView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalTo(tab.snp.bottom)
+            make.bottom.equalToSuperview().offset(-36)
+        }
+        
+        container.addSubview(customView)
+        customView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalTo(tab.snp.bottom)
+            make.bottom.equalToSuperview().offset(-36)
+        }
+    }
+    
+    private func buildSkillView() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(45)
+        }
+        
+        curSkillIc.layer.cornerRadius = 10
+        curSkillIc.clipsToBounds = true
+        container.addSubview(curSkillIc)
+        curSkillIc.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(22)
+            make.width.height.equalTo(20)
+        }
+        
+        curSkillNameLabel.font = .heading_h5
+        curSkillNameLabel.textColor = .text_5
+        container.addSubview(curSkillNameLabel)
+        curSkillNameLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(curSkillIc.snp.trailing).offset(4)
+        }
+        
+        let config = UIImage.SymbolConfiguration(pointSize: 10)
+        skillArrow.image = .init(systemName: "chevron.forward", withConfiguration: config)
+        skillArrow.tintColor = .text_4
+        container.addSubview(skillArrow)
+        skillArrow.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(curSkillNameLabel.snp.trailing).offset(16)
+        }
+        
+        container.onTap { [weak self] in
+            guard let self else { return }
+            let panel = LNOrderSkillListPanel(curSkillId: curSkill?.id) { [weak self] skill in
+                guard let self else { return }
+                self.curSkill = skill
+            }
+            panel.showIn()
+        }
+        
+        return container
+    }
+    
+    private func buildTabView() -> UIView {
+        tabView.delegate = self
+        
+        return tabView
+    }
+}
+
+#if DEBUG
+
+import SwiftUI
+
+struct LNOrderQRPanelPreview: UIViewRepresentable {
+    func makeUIView(context: Context) -> some UIView {
+        let container = UIView()
+        container.backgroundColor = .lightGray
+        
+        let view = LNOrderGenerateQRCodePanel()
+        view.showIn(container)
+        
+        return container
+    }
+    
+    func updateUIView(_ uiView: UIViewType, context: Context) { }
+}
+
+#Preview(body: {
+    LNOrderQRPanelPreview()
+})
+#endif
+

+ 162 - 0
Lanu/Views/Order/OrderQR/LNOrderQRCodeShowView.swift

@@ -0,0 +1,162 @@
+//
+//  LNOrderQRCodeShowView.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/27.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNOrderQRCodeShowView: UIView {
+    private let qrCodeSize: CGFloat = 145
+    
+    private let qrCodeIc = UIImageView()
+    private let costLabel = UILabel()
+    
+    private let saveButton = UIButton()
+    private let copyButton = UIButton()
+    
+    private var qrCodeData: String?
+    
+    private var curSkill: LNGameMateSkillVO?
+    private var count: Int = 0
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func update(_ skill: LNGameMateSkillVO, customCount: Int? = nil) {
+        let count = customCount ?? 1
+        if skill.id == curSkill?.id, count == self.count { return }
+        
+        let price = skill.price
+        
+        let type: LNOrderSource = customCount != nil ? .custom : .normal
+        
+        let cost = price * count
+        let text: String = if count <= 1 {
+            .init(key: "%d/ %@", cost, skill.unit)
+        } else {
+            .init(key: "%d/%d %@", cost, count, skill.unit)
+        }
+        let attrStr = NSMutableAttributedString(string: text)
+        let range = (text as NSString).range(of: "\(cost)")
+        attrStr.addAttribute(.font, value: UIFont.heading_h1, range: range)
+        costLabel.attributedText = attrStr
+        
+        qrCodeIc.image = nil
+        
+        curSkill = skill
+        self.count = count
+        
+        saveButton.isEnabled = false
+        copyButton.isEnabled = false
+        
+        LNOrderManager.shared.createOrderQR(
+            skillId: skill.id,
+            count: count,
+            type: type) { [weak self] data, err in
+                guard let self else { return }
+                guard skill.id == self.curSkill?.id,
+                      self.count == count else { return }
+                self.qrCodeIc.image = data?.toQRCode(size: qrCodeSize)
+                self.qrCodeData = data
+                self.saveButton.isEnabled = true
+                self.copyButton.isEnabled = true
+            }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNOrderQRCodeShowView {
+    private func setupViews() {
+        let container = UIView()
+        container.backgroundColor = .fill
+        container.layer.cornerRadius = 6
+        addSubview(container)
+        container.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalToSuperview().offset(20)
+        }
+        
+        container.addSubview(qrCodeIc)
+        qrCodeIc.snp.makeConstraints { make in
+            make.edges.equalToSuperview().inset(12)
+            make.width.height.equalTo(qrCodeSize)
+        }
+        
+        let priceView = UIView()
+        addSubview(priceView)
+        priceView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalTo(container.snp.bottom).offset(12)
+        }
+        
+        let diamond = UIImageView()
+        diamond.image = .init(named: "ic_diamond")
+        priceView.addSubview(diamond)
+        diamond.snp.makeConstraints { make in
+            make.leading.equalToSuperview()
+            make.centerY.equalToSuperview()
+            make.width.height.equalTo(19)
+        }
+        
+        costLabel.font = .body_m
+        costLabel.textColor = .text_5
+        priceView.addSubview(costLabel)
+        costLabel.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview()
+            make.leading.equalTo(diamond.snp.trailing).offset(6)
+            make.trailing.equalToSuperview()
+        }
+        
+        saveButton.layer.borderWidth = 1
+        saveButton.layer.borderColor = UIColor.text_4.cgColor
+        saveButton.layer.cornerRadius = 23.5
+        saveButton.setTitleColor(.text_4, for: .normal)
+        saveButton.setTitle(.init(key: "Save image"), for: .normal)
+        saveButton.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            guard let skill = self.curSkill else { return }
+            guard let image = qrCodeIc.image else { return }
+            let generator = LNOrderShareImageGenerator()
+            generator.update(skill: skill, count: self.count, image: image)
+            let share = generator.generate()
+            share.saveToLibrary { success, err in
+                
+            }
+        }), for: .touchUpInside)
+        addSubview(saveButton)
+        saveButton.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(16)
+            make.top.equalTo(priceView.snp.bottom).offset(22)
+            make.height.equalTo(47)
+            make.bottom.equalToSuperview().offset(-10)
+        }
+        
+        copyButton.layer.cornerRadius = 23.5
+        copyButton.clipsToBounds = true
+        copyButton.setBackgroundImage(.primary_8, for: .normal)
+        copyButton.setTitle(.init(key: "Copy link"), for: .normal)
+        copyButton.setTitleColor(.text_1, for: .normal)
+        copyButton.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            UIPasteboard.general.string = self.qrCodeData
+        }), for: .touchUpInside)
+        addSubview(copyButton)
+        copyButton.snp.makeConstraints { make in
+            make.leading.equalTo(saveButton.snp.trailing).offset(10)
+            make.trailing.equalToSuperview().offset(-16)
+            make.centerY.equalTo(saveButton)
+            make.width.height.equalTo(saveButton)
+        }
+    }
+}

+ 95 - 0
Lanu/Views/Order/OrderQR/LNOrderQRTabView.swift

@@ -0,0 +1,95 @@
+//
+//  LNOrderQRTabView.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/26.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+protocol LNOrderQRTabViewDelegate: NSObject {
+    func onOrderQRTabView(view: LNOrderQRTabView, didChangedType newType: LNOrderSource)
+}
+
+
+class LNOrderQRTabView: UIView {
+    private let indicator = UIView()
+    private let normal = UIButton()
+    private let edit = UIButton()
+    
+    weak var delegate: LNOrderQRTabViewDelegate?
+    
+    var curType: LNOrderSource = .normal {
+        didSet {
+            normal.isSelected = curType == .normal
+            edit.isSelected = curType == .custom
+            
+            indicator.snp.remakeConstraints { make in
+                make.edges.equalTo(curType == .normal ? normal : edit)
+            }
+            UIView.animate(withDuration: 0.25) { [weak self] in
+                guard let self else { return }
+                layoutIfNeeded()
+            }
+            delegate?.onOrderQRTabView(view: self, didChangedType: curType)
+        }
+    }
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNOrderQRTabView {
+    private func setupViews() {
+        backgroundColor = .fill_3
+        layer.cornerRadius = 20
+        snp.makeConstraints { make in
+            make.height.equalTo(40)
+        }
+        
+        indicator.backgroundColor = .fill
+        indicator.layer.cornerRadius = 16
+        addSubview(indicator)
+        
+        normal.setTitle(.init(key: "通用码"), for: .normal)
+        normal.setTitleColor(.text_5, for: .selected)
+        normal.setTitleColor(.text_4, for: .normal)
+        addSubview(normal)
+        normal.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(4)
+            make.height.equalTo(32)
+        }
+        
+        edit.setTitle(.init(key: "特定码"), for: .normal)
+        edit.setTitleColor(.text_5, for: .selected)
+        edit.setTitleColor(.text_4, for: .normal)
+        addSubview(edit)
+        edit.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalToSuperview().offset(-4)
+            make.height.equalTo(32)
+            make.leading.equalTo(normal.snp.trailing).offset(3)
+            make.width.equalTo(normal)
+        }
+        
+        normal.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            curType = .normal
+        }), for: .touchUpInside)
+        edit.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            curType = .custom
+        }), for: .touchUpInside)
+    }
+}

+ 176 - 0
Lanu/Views/Order/OrderQR/LNOrderShareImageGenerator.swift

@@ -0,0 +1,176 @@
+//
+//  LNOrderShareImageGenerator.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/27.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNOrderShareImageGenerator {
+    private let container = UIImageView()
+    
+    private let avatar = UIImageView()
+    private let userNameLabel = UILabel()
+    private let scoreView = LNFiveStarScoreView()
+    private let qrCodeView = UIImageView()
+    private let costLabel = UILabel()
+    
+    private let skillIc = UIImageView()
+    private let skillNameLabel = UILabel()
+    private let countLabel = UILabel()
+    
+    init() {
+        setupViews()
+    }
+    
+    func update(skill: LNGameMateSkillVO, count: Int, image: UIImage) {
+        skillIc.sd_setImage(with: URL(string: skill.icon))
+        skillNameLabel.text = skill.name
+        scoreView.score = myGameMateInfo.star
+        qrCodeView.image = image
+        
+        countLabel.text = .init(key: "%d %@", count, skill.unit)
+        
+        let cost = skill.price * count
+        let text: String = .init(key: "%d = IDR %0.3f", cost, Double(cost) / 1000.0)
+        let attrStr = NSMutableAttributedString(string: text)
+        let range = (text as NSString).range(of: "\(cost)")
+        attrStr.addAttribute(.font, value: UIFont.heading_h2, range: range)
+        costLabel.attributedText = attrStr
+        
+        container.layoutIfNeeded()
+    }
+    
+    func generate() -> UIImage {
+        let renderer = UIGraphicsImageRenderer(bounds: container.bounds)
+        
+        return renderer.image { _ in
+            container.drawHierarchy(
+                in: container.bounds,
+                afterScreenUpdates: true
+            )
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNOrderShareImageGenerator {
+    private func setupViews() {
+        let image: UIImage = .init(named: "ic_order_share_bg")!
+        container.image = image
+        container.frame = .init(x: 0, y: 0, width: image.size.width, height: image.size.height)
+        
+        avatar.backgroundColor = .fill
+        avatar.layer.cornerRadius = 37.5
+        avatar.layer.borderWidth = 2
+        avatar.layer.borderColor = UIColor.fill.cgColor
+        avatar.sd_setImage(with: URL(string: myUserInfo.avatar))
+        container.addSubview(avatar)
+        avatar.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalToSuperview().offset(30)
+            make.width.height.equalTo(75)
+        }
+        
+        userNameLabel.font = .heading_h2
+        userNameLabel.textColor = .text_5
+        userNameLabel.text = myUserInfo.nickname
+        container.addSubview(userNameLabel)
+        userNameLabel.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalTo(avatar.snp.bottom).offset(2)
+        }
+        
+        scoreView.icSize = 10
+        scoreView.spacing = 4
+        container.addSubview(scoreView)
+        scoreView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalTo(userNameLabel.snp.bottom).offset(2)
+        }
+        
+        container.addSubview(qrCodeView)
+        qrCodeView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalTo(scoreView.snp.bottom).offset(13)
+            make.width.height.equalTo(145)
+        }
+        
+        let priceView = UIView()
+        container.addSubview(priceView)
+        priceView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalTo(qrCodeView.snp.bottom).offset(6)
+        }
+        
+        let diamond = UIImageView()
+        diamond.image = .init(named: "ic_diamond")
+        priceView.addSubview(diamond)
+        diamond.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview()
+            make.width.height.equalTo(15)
+        }
+        
+        costLabel.font = .body_m
+        costLabel.textColor = .text_5
+        priceView.addSubview(costLabel)
+        costLabel.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview()
+            make.trailing.equalToSuperview()
+            make.leading.equalTo(diamond.snp.trailing).offset(2)
+        }
+        
+        let tipsView = UIImageView()
+        tipsView.image = .primary_7
+        tipsView.layer.cornerRadius = 14.5
+        tipsView.clipsToBounds = true
+        container.addSubview(tipsView)
+        tipsView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalTo(priceView.snp.bottom).offset(9)
+        }
+        
+        let tipsLabel = UILabel()
+        tipsLabel.text = .init(key: "长按扫码付款")
+        tipsLabel.font = .heading_h4
+        tipsLabel.textColor = .text_1
+        tipsView.addSubview(tipsLabel)
+        tipsLabel.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview().inset(6)
+            make.horizontalEdges.equalToSuperview().inset(21)
+        }
+        
+        skillIc.layer.cornerRadius = 10
+        skillIc.clipsToBounds = true
+        container.addSubview(skillIc)
+        skillIc.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(56)
+            make.bottom.equalToSuperview().offset(-88)
+            make.width.height.equalTo(20)
+        }
+        
+        skillNameLabel.font = .heading_h5
+        skillNameLabel.textColor = .text_5
+        container.addSubview(skillNameLabel)
+        skillNameLabel.snp.makeConstraints { make in
+            make.centerY.equalTo(skillIc)
+            make.leading.equalTo(skillIc.snp.trailing).offset(8)
+        }
+        
+        countLabel.font = .body_m
+        countLabel.textColor = .text_5
+        container.addSubview(countLabel)
+        countLabel.snp.makeConstraints { make in
+            make.centerY.equalTo(skillIc)
+            make.trailing.equalToSuperview().offset(-58)
+        }
+    }
+}

+ 124 - 0
Lanu/Views/Order/OrderQR/LNOrderSkillListPanel.swift

@@ -0,0 +1,124 @@
+//
+//  LNOrderSkillListPanel.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/27.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+class LNOrderSkillListPanel: UIView, LNPopupViewProtocol {
+    var container: UIView = UIView()
+    var containerHeight: LNPopupViewHeight = .auto
+    var popupAxis: NSLayoutConstraint.Axis = .vertical
+    
+    private let curSkillId: String?
+    private var handler: ((LNGameMateSkillVO) -> Void)?
+    
+    init(curSkillId: String?, handler: @escaping (LNGameMateSkillVO) -> Void) {
+        self.curSkillId = curSkillId
+        self.handler = handler
+        
+        super.init(frame: .zero)
+        
+        setupViews()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNOrderSkillListPanel {
+    private func setupViews() {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.spacing = 0
+        container.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.leading.trailing.equalToSuperview()
+            make.top.equalToSuperview().offset(6)
+            make.bottom.equalToSuperview().offset(-safeBottomInset - 5)
+        }
+        
+        var itemViews: [UIView] = []
+        myGameMateInfo.skills.forEach {
+            itemViews.append(buildSkillItem($0))
+        }
+        itemViews.forEach {
+            stackView.addArrangedSubview($0)
+        }
+    }
+    
+    private func buildSkillItem(_ skill: LNGameMateSkillVO) -> UIView {
+        let container = UIView()
+        
+        let checkIc = UIImageView()
+        checkIc.image = .init(named: skill.id == curSkillId ? "ic_check" : "ic_uncheck")
+        container.addSubview(checkIc)
+        checkIc.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalToSuperview().offset(-16)
+        }
+        
+        let skillIc = UIImageView()
+        skillIc.layer.cornerRadius = 12
+        skillIc.clipsToBounds = true
+        skillIc.sd_setImage(with: URL(string: skill.icon))
+        container.addSubview(skillIc)
+        skillIc.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(16)
+            make.centerY.equalToSuperview()
+            make.width.height.equalTo(24)
+        }
+        
+        let titleLabel = UILabel()
+        titleLabel.text = skill.name
+        titleLabel.font = .heading_h4
+        titleLabel.textColor = .text_5
+        container.addSubview(titleLabel)
+        titleLabel.snp.makeConstraints { make in
+            make.leading.equalTo(skillIc.snp.trailing).offset(8)
+            make.top.equalToSuperview().offset(12)
+            make.bottom.equalToSuperview().offset(-12)
+            make.trailing.lessThanOrEqualTo(checkIc.snp.leading).offset(-5)
+        }
+        
+        container.onTap { [weak self] in
+            guard let self else { return }
+            self.handler?(skill)
+            self.dismiss()
+        }
+        
+        return container
+    }
+}
+
+
+#if DEBUG
+
+import SwiftUI
+
+struct LNOrderSkillListPanelPreview: UIViewRepresentable {
+    func makeUIView(context: Context) -> some UIView {
+        let container = UIView()
+        container.backgroundColor = .lightGray
+        
+        let view = LNOrderSkillListPanel(curSkillId: "") { skill in
+            
+        }
+        view.showIn(container)
+        
+        return container
+    }
+    
+    func updateUIView(_ uiView: UIViewType, context: Context) { }
+}
+
+#Preview(body: {
+    LNOrderSkillListPanelPreview()
+})
+#endif // DEBUG
+

+ 7 - 9
Lanu/Views/Profile/LNEditProfileViewController.swift

@@ -142,8 +142,8 @@ extension LNEditProfileViewController {
     }
     
     private func buildAvatar() -> UIView {
-        if let url = myUserInfo?.avatar, !url.isEmpty {
-            avatar.sd_setImage(with: URL(string: url))
+        if !myUserInfo.avatar.isEmpty {
+            avatar.sd_setImage(with: URL(string: myUserInfo.avatar))
         } else {
             avatar.image = .init(named: "ic_profile_login_avatar")
         }
@@ -217,14 +217,14 @@ extension LNEditProfileViewController {
             make.height.equalTo(36)
         }
         
-        nameInputField.text = myUserInfo?.nickname
+        nameInputField.text = myUserInfo.nickname
         nameInputField.font = .heading_h4
         nameInputField.textColor = .text_5
         nameInputField.backgroundColor = .primary_1
         nameInputField.layer.cornerRadius = 18
         nameInputField.attributedPlaceholder = NSAttributedString(string: .init(key: "Please enter"), attributes: [.font: UIFont.body_m])
         nameInputField.clearButtonMode = .whileEditing
-        nameInputField.addTarget(self, action: #selector(onInputFieldValueChanged(_:)), for: .valueChanged)
+        nameInputField.addTarget(self, action: #selector(onInputFieldValueChanged(_:)), for: .editingChanged)
         holder.addSubview(nameInputField)
         nameInputField.snp.makeConstraints { make in
             make.horizontalEdges.equalToSuperview().inset(12)
@@ -270,8 +270,8 @@ extension LNEditProfileViewController {
             self.checkSaveButton()
         }), for: .touchUpInside)
         
-        maleButton.isSelected = myUserInfo?.gender == .male
-        femaleButton.isSelected = myUserInfo?.gender == .female
+        maleButton.isSelected = myUserInfo.gender == .male
+        femaleButton.isSelected = myUserInfo.gender == .female
         
         return container
     }
@@ -299,9 +299,7 @@ extension LNEditProfileViewController {
             make.height.equalTo(36)
         }
         
-        if let age = myUserInfo?.age {
-            ageInputField.text = "\(age)"
-        }
+        ageInputField.text = "\(myUserInfo.age)"
         ageInputField.font = .heading_h4
         ageInputField.textColor = .text_5
         ageInputField.backgroundColor = .primary_1

+ 30 - 12
Lanu/Views/Profile/LNMineViewController.swift

@@ -44,7 +44,7 @@ class LNMineViewController: LNViewController {
 
 extension LNMineViewController: LNProfileManagerNotify, LNPurchaseManagerNotify {
     func onUserInfoChanged(userInfo: LNUserProfileInfo) {
-        guard userInfo.id.isMyUid else { return }
+        guard userInfo.userNo.isMyUid else { return }
         updateUserContent()
     }
     
@@ -63,13 +63,13 @@ extension LNMineViewController {
     }
     
     private func updateUserContent() {
-        if let myUserInfo {
+        if hasLogin {
             loginView.isHidden = true
             userView.isHidden = false
             
             avatar.sd_setImage(with: URL(string: myUserInfo.avatar))
             userNameLabel.text = myUserInfo.nickname
-            idLabel.text = myUserInfo.id
+            idLabel.text = myUserInfo.userNo
         } else {
             loginView.isHidden = false
             userView.isHidden = true
@@ -234,6 +234,11 @@ extension LNMineViewController {
         let container = UIView()
         
         let qr = buildQRView()
+        qr.onTap { [weak self] in
+            guard self != nil else { return }
+            let panel = LNOrderGenerateQRCodePanel()
+            panel.showIn()
+        }
         container.addSubview(qr)
         qr.snp.makeConstraints { make in
             make.leading.equalToSuperview()
@@ -241,6 +246,10 @@ extension LNMineViewController {
         }
         
         let share = buildShare()
+        share.onTap { [weak self] in
+            guard let self else { return }
+            self.view.pushToSharePost()
+        }
         container.addSubview(share)
         share.snp.makeConstraints { make in
             make.trailing.equalToSuperview()
@@ -255,7 +264,7 @@ extension LNMineViewController {
     private func buildQRView() -> UIView {
         let ic = UIImageView()
         ic.image = .init(named: "ic_profile_qr")
-        
+        ic.isUserInteractionEnabled = true
         
         let label = UILabel()
         label.font = .heading_h3
@@ -287,6 +296,7 @@ extension LNMineViewController {
     private func buildShare() -> UIView {
         let ic = UIImageView()
         ic.image = .init(named: "ic_profile_share")
+        ic.isUserInteractionEnabled = true
         
         let label = UILabel()
         label.font = .heading_h3
@@ -414,26 +424,30 @@ extension LNMineViewController {
         
         let pending = buildOrderSubItemView(icName: "ic_order_pending", title: .init(key: "待付款"))
         stackView.addArrangedSubview(pending)
-        pending.onTap {
-            
+        pending.onTap { [weak self] in
+            guard let self else { return }
+            self.view.pushToOrderList(filterStatus: .pending)
         }
         
         let finish = buildOrderSubItemView(icName: "ic_order_done", title: .init(key: "已完成"))
         stackView.addArrangedSubview(finish)
-        finish.onTap {
-            
+        finish.onTap { [weak self] in
+            guard let self else { return }
+            self.view.pushToOrderList(filterStatus: .completed)
         }
         
         let refund = buildOrderSubItemView(icName: "ic_order_refund", title: .init(key: "退款"))
         stackView.addArrangedSubview(refund)
-        refund.onTap {
-            
+        refund.onTap { [weak self] in
+            guard let self else { return }
+            self.view.pushToOrderList(filterStatus: .refunded)
         }
         
         let all = buildOrderSubItemView(icName: "ic_order_all", title: .init(key: "全部订单"))
         stackView.addArrangedSubview(all)
-        all.onTap {
-            
+        all.onTap { [weak self] in
+            guard let self else { return }
+            self.view.pushToOrderList()
         }
         
         return container
@@ -444,6 +458,10 @@ extension LNMineViewController {
         button.layer.cornerRadius = 15
         button.layer.borderColor = UIColor.fill_4.cgColor
         button.layer.borderWidth = 1
+        button.addAction(UIAction(handler: { [weak self] _ in
+            guard self != nil else { return }
+            LNAccountManager.shared.logout()
+        }), for: .touchUpInside)
         view.addSubview(button)
         button.snp.makeConstraints { make in
             make.centerX.equalToSuperview()

+ 319 - 0
Lanu/Views/Profile/Post/LNPostShareImageGenerator.swift

@@ -0,0 +1,319 @@
+//
+//  LNPostShareImageGenerator.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/28.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNPostShareImageGenerator {
+    private let container = UIImageView()
+    private let postView = UIImageView()
+    private let qrCodeView = UIImageView()
+    
+    init() {
+        setupViews()
+    }
+    
+    func update(album: UIImage) {
+        postView.image = album
+        
+        container.layoutIfNeeded()
+    }
+    
+    func generate() -> UIImage {
+        let renderer = UIGraphicsImageRenderer(bounds: container.bounds)
+        
+        return renderer.image { _ in
+            container.drawHierarchy(
+                in: container.bounds,
+                afterScreenUpdates: true
+            )
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNPostShareImageGenerator {
+    private func setupViews() {
+        let image: UIImage = .init(named: "ic_post_share")!
+        container.image = image
+        container.frame = .init(x: 0, y: 0, width: image.size.width, height: image.size.height)
+        
+        let record = buildRecord()
+        container.addSubview(record)
+        record.snp.makeConstraints { make in
+            make.leading.top.equalToSuperview()
+            make.trailing.equalToSuperview()
+        }
+        
+        let post = buildPost()
+        container.addSubview(post)
+        post.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(59)
+        }
+        
+        let album = buildSkillView()
+        container.addSubview(album)
+        album.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.bottom.equalToSuperview().offset(-152)
+            make.height.equalTo(100)
+        }
+        
+        let url = "http://localhost:3000/user/category"
+        qrCodeView.image = url.toQRCode(size: 88)
+        container.addSubview(qrCodeView)
+        qrCodeView.snp.makeConstraints { make in
+            make.trailing.equalToSuperview().offset(-74)
+            make.bottom.equalToSuperview().offset(-33)
+        }
+    }
+    
+    private func buildRecord() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(32)
+        }
+        
+        let gradientLayer = CAGradientLayer()
+        gradientLayer.frame = CGRect(origin: .zero, size: .init(width: self.container.bounds.size.width, height: 32))
+        gradientLayer.colors = [UIColor.init(hex: "#E0FFED").cgColor, UIColor.init(hex: "#FFFFFF00").cgColor]
+        gradientLayer.locations = [0, 1]
+        gradientLayer.startPoint = .init(x: 0, y: 0)
+        gradientLayer.endPoint = .init(x: 1, y: 0)
+        container.layer.insertSublayer(gradientLayer, at: 0)
+        
+        let ic = UIImageView()
+        ic.image = .init(named: "ic_post_share_new")
+        container.addSubview(ic)
+        ic.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(13)
+            make.centerY.equalToSuperview()
+        }
+        
+        let recordLabel = UILabel()
+        recordLabel.font = .body_xs
+        recordLabel.textColor = .init(hex: "#466811")
+        recordLabel.text = .init(key: "在 11.11 与「用户xxx」进行了一场「网球」比赛")
+        container.addSubview(recordLabel)
+        recordLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(ic.snp.trailing).offset(6)
+        }
+        
+        return container
+    }
+    
+    private func buildPost() -> UIView {
+        let container = UIView()
+        
+        let info = buildUserInfo()
+        container.addSubview(info)
+        info.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(12)
+        }
+        
+        postView.contentMode = .scaleAspectFill
+        postView.layer.cornerRadius = 20
+        postView.clipsToBounds = true
+        container.addSubview(postView)
+        postView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(44)
+            make.height.equalTo(postView.snp.width).multipliedBy(317.0/311.0)
+        }
+        
+        let tag = UIImageView()
+        tag.image = .init(named: "ic_post_location_bg")
+        container.addSubview(tag)
+        tag.snp.makeConstraints { make in
+            make.leading.bottom.equalTo(postView)
+        }
+        
+        let locationIc = UIImageView()
+        locationIc.image = .init(named: "ic_location")
+        tag.addSubview(locationIc)
+        locationIc.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(13)
+            make.width.height.equalTo(16)
+        }
+        
+        let locationLabel = UILabel()
+        locationLabel.text = myGameMateInfo.area
+        locationLabel.font = .body_m
+        locationLabel.textColor = .text_4
+        tag.addSubview(locationLabel)
+        locationLabel.snp.makeConstraints { make in
+            make.leading.equalTo(locationIc.snp.trailing).offset(4)
+            make.centerY.equalToSuperview()
+            make.trailing.lessThanOrEqualToSuperview().offset(-13)
+        }
+        
+        let bio = UILabel()
+        bio.text = myUserInfo.intro
+        bio.font = .body_m
+        bio.textColor = .text_4
+        container.addSubview(bio)
+        bio.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(22)
+            make.top.equalTo(postView.snp.bottom).offset(14)
+            make.bottom.equalToSuperview().offset(-20)
+        }
+        
+        return container
+    }
+    
+    private func buildUserInfo() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(22)
+        }
+        
+        let userNameLabel = UILabel()
+        userNameLabel.text = myUserInfo.nickname
+        userNameLabel.font = .heading_h2
+        userNameLabel.textColor = .text_5
+        container.addSubview(userNameLabel)
+        userNameLabel.snp.makeConstraints { make in
+            make.leading.centerY.equalToSuperview()
+        }
+        
+        let gender = LNGenderView()
+        gender.update(myUserInfo.gender, myUserInfo.age)
+        container.addSubview(gender)
+        gender.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(userNameLabel.snp.trailing).offset(4)
+        }
+        
+        let starLabel = UILabel()
+        starLabel.text = "\(myGameMateInfo.star)"
+        starLabel.font = .heading_h2
+        starLabel.textColor = .text_5
+        container.addSubview(starLabel)
+        starLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalToSuperview()
+        }
+        
+        let star = LNStarScoreView()
+        star.score = 1.0
+        star.icSize = 18
+        container.addSubview(star)
+        star.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalTo(starLabel.snp.leading).offset(-4)
+        }
+        
+        return container
+    }
+    
+    private func buildSkillView() -> UIView {
+        let container = UIView()
+        
+        let stackView = UIStackView()
+        stackView.axis = .horizontal
+        stackView.spacing = 10
+        container.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(113)
+            make.bottom.equalToSuperview().offset(-18)
+        }
+        
+        myGameMateInfo.skills.prefix(3).forEach {
+            let itemView = LNPostShareSkillItemView()
+            itemView.icon.sd_setImage(with: URL(string: $0.icon))
+            itemView.nameLabel.text = $0.name
+            stackView.addArrangedSubview(itemView)
+        }
+        
+        let ic = UIImageView()
+        ic.image = .init(named: "ic_main_tab_selected")
+        ic.alpha = 0.5
+        container.addSubview(ic)
+        ic.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(20)
+            make.top.equalToSuperview().offset(31)
+        }
+        
+        let tipsLabel = UILabel()
+        tipsLabel.text = .init(key: "扫码跟我一起玩")
+        tipsLabel.font = .heading_h4
+        tipsLabel.textColor = .text_5
+        tipsLabel.numberOfLines = 0
+        container.addSubview(tipsLabel)
+        tipsLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(30)
+            make.width.equalTo(60)
+        }
+        
+        return container
+    }
+}
+
+private class LNPostShareSkillItemView: UIView {
+    let icon = UIImageView()
+    let nameLabel = UILabel()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        let bg = UIImageView()
+        bg.image = .primary_7
+        bg.layer.cornerRadius = 26
+        bg.clipsToBounds = true
+        addSubview(bg)
+        bg.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.top.equalToSuperview()
+            make.width.height.equalTo(52)
+        }
+        
+        icon.backgroundColor = .fill
+        icon.layer.cornerRadius = 25.5
+        icon.clipsToBounds = true
+        addSubview(icon)
+        icon.snp.makeConstraints { make in
+            make.center.equalTo(bg)
+            make.width.height.equalTo(bg).inset(1)
+        }
+        
+        let nameBg = UIImageView()
+        nameBg.image = .primary_7
+        nameBg.layer.cornerRadius = 10.5
+        nameBg.clipsToBounds = true
+        addSubview(nameBg)
+        nameBg.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.bottom.equalToSuperview()
+            make.top.equalTo(bg.snp.bottom).offset(-9)
+            make.height.equalTo(21)
+            make.width.equalTo(67)
+        }
+        
+        nameLabel.font = .body_xs
+        nameLabel.textColor = .text_1
+        nameLabel.textAlignment = .center
+        nameBg.addSubview(nameLabel)
+        nameLabel.snp.makeConstraints { make in
+            make.center.equalToSuperview()
+            make.horizontalEdges.equalToSuperview().inset(6)
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}

+ 312 - 0
Lanu/Views/Profile/Post/LNSharePostViewController.swift

@@ -0,0 +1,312 @@
+//
+//  LNSharePostViewController.swift
+//  Lanu
+//
+//  Created by OneeChan on 2025/11/27.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+extension UIView {
+    func pushToSharePost() {
+        let vc = LNSharePostViewController()
+        navigationController?.pushViewController(vc, animated: true)
+    }
+}
+
+
+class LNSharePostViewController: LNViewController {
+    private let postView = UIImageView()
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        setupViews()
+        prefetchSkillIcon()
+    }
+}
+
+extension LNSharePostViewController {
+    private func prefetchSkillIcon() {
+        myGameMateInfo.skills.forEach {
+            SDWebImageManager.shared.loadImage(with: URL(string: $0.icon),
+                                               progress: nil)
+            { _, _, _, _, _, _ in }
+        }
+    }
+}
+
+extension LNSharePostViewController {
+    private func setupViews() {
+        title = .init(key: "生成你的专属海报")
+        view.backgroundColor = .primary_1
+        
+        let topCover = UIImageView()
+        topCover.image = .init(named: "ic_main_top_bg")
+        view.addSubview(topCover)
+        topCover.snp.makeConstraints { make in
+            make.top.leading.trailing.equalToSuperview()
+        }
+        
+        let post = buildPost()
+        view.addSubview(post)
+        post.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(32)
+        }
+        
+        let album = buildAlbum()
+        view.addSubview(album)
+        album.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(16)
+            make.top.equalTo(post.snp.bottom).offset(25)
+            make.trailing.equalToSuperview()
+        }
+        
+        let share = UIButton()
+        share.setTitle(.init(key: "分享海报"), for: .normal)
+        share.setTitleColor(.text_1, for: .normal)
+        share.setBackgroundImage(.primary_8, for: .normal)
+        share.layer.cornerRadius = 23.5
+        share.clipsToBounds = true
+        share.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            guard let image = postView.image else { return }
+            let generator = LNPostShareImageGenerator()
+            generator.update(album: image)
+            let shareImage = generator.generate()
+            shareImage.saveToLibrary { success, err in
+                
+            }
+        }), for: .touchUpInside)
+        view.addSubview(share)
+        share.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).offset(-30)
+            make.height.equalTo(47)
+        }
+    }
+    
+    private func buildPost() -> UIView {
+        let container = UIView()
+        container.backgroundColor = .fill
+        container.layer.cornerRadius = 20
+        
+        let info = buildUserInfo()
+        container.addSubview(info)
+        info.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(12)
+        }
+        
+        postView.contentMode = .scaleAspectFill
+        postView.layer.cornerRadius = 20
+        postView.clipsToBounds = true
+        container.addSubview(postView)
+        postView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(44)
+            make.height.equalTo(postView.snp.width).multipliedBy(317.0/311.0)
+        }
+        
+        let tag = UIImageView()
+        tag.image = .init(named: "ic_post_location_bg")
+        container.addSubview(tag)
+        tag.snp.makeConstraints { make in
+            make.leading.bottom.equalTo(postView)
+        }
+        
+        let locationIc = UIImageView()
+        locationIc.image = .init(named: "ic_location")
+        tag.addSubview(locationIc)
+        locationIc.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalToSuperview().offset(13)
+            make.width.height.equalTo(16)
+        }
+        
+        let locationLabel = UILabel()
+        locationLabel.text = myGameMateInfo.area
+        locationLabel.font = .body_m
+        locationLabel.textColor = .text_4
+        tag.addSubview(locationLabel)
+        locationLabel.snp.makeConstraints { make in
+            make.leading.equalTo(locationIc.snp.trailing).offset(4)
+            make.centerY.equalToSuperview()
+            make.trailing.lessThanOrEqualToSuperview().offset(-13)
+        }
+        
+        let bio = UILabel()
+        bio.text = myUserInfo.intro
+        bio.font = .body_m
+        bio.textColor = .text_4
+        bio.numberOfLines = 0
+        container.addSubview(bio)
+        bio.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(22)
+            make.top.equalTo(postView.snp.bottom).offset(14)
+            make.bottom.equalToSuperview().offset(-20)
+        }
+        
+        return container
+    }
+    
+    private func buildUserInfo() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(22)
+        }
+        
+        let userNameLabel = UILabel()
+        userNameLabel.text = myUserInfo.nickname
+        userNameLabel.font = .heading_h2
+        userNameLabel.textColor = .text_5
+        container.addSubview(userNameLabel)
+        userNameLabel.snp.makeConstraints { make in
+            make.leading.centerY.equalToSuperview()
+        }
+        
+        let gender = LNGenderView()
+        gender.update(myUserInfo.gender, myUserInfo.age)
+        container.addSubview(gender)
+        gender.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(userNameLabel.snp.trailing).offset(4)
+        }
+        
+        let starLabel = UILabel()
+        starLabel.text = "\(myGameMateInfo.star)"
+        starLabel.font = .heading_h2
+        starLabel.textColor = .text_5
+        container.addSubview(starLabel)
+        starLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalToSuperview()
+        }
+        
+        let star = LNStarScoreView()
+        star.score = 1.0
+        star.icSize = 18
+        container.addSubview(star)
+        star.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalTo(starLabel.snp.leading).offset(-4)
+        }
+        
+        return container
+    }
+    
+    private func buildAlbum() -> UIView {
+        let scrollView = UIScrollView()
+        scrollView.showsVerticalScrollIndicator = false
+        scrollView.showsHorizontalScrollIndicator = false
+        
+        let fakeView = UIView()
+        scrollView.addSubview(fakeView)
+        fakeView.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview()
+            make.height.equalToSuperview()
+            make.leading.equalToSuperview()
+            make.width.equalTo(0)
+        }
+        
+        let stackView = UIStackView()
+        stackView.axis = .horizontal
+        stackView.spacing = 10
+        scrollView.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+        
+        var albumItemViews: [LNSharePostAlbumItemView] = []
+        myGameMateInfo.photos.forEach { url in
+            let album = LNSharePostAlbumItemView()
+            album.imageView.sd_setImage(with: URL(string: url))
+            album.onTap { [weak self, weak album] in
+                guard let self, let album else { return }
+                albumItemViews.forEach {
+                    $0.isSelected = $0 == album
+                }
+                self.postView.sd_setImage(with: URL(string: url))
+            }
+            album.isSelected = false
+            stackView.addArrangedSubview(album)
+            albumItemViews.append(album)
+        }
+        albumItemViews.first?.isSelected = true
+        postView.sd_setImage(with: URL(string: myGameMateInfo.photos.first ?? ""))
+        
+        return scrollView
+    }
+}
+
+private class LNSharePostAlbumItemView: UIView {
+    let imageView = UIImageView()
+    var isSelected: Bool = false {
+        didSet {
+            if isSelected {
+                imageView.layer.cornerRadius = 11
+                imageView.snp.remakeConstraints { make in
+                    make.center.equalToSuperview()
+                    make.width.height.equalToSuperview().inset(2)
+                }
+                selectedBorder.isHidden = false
+                alpha = 1.0
+            } else {
+                imageView.layer.cornerRadius = 12
+                imageView.snp.remakeConstraints { make in
+                    make.center.equalToSuperview()
+                    make.width.height.equalToSuperview()
+                }
+                selectedBorder.isHidden = true
+                alpha = 0.5
+            }
+        }
+    }
+    private let selectedBorder = UIImageView()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        snp.makeConstraints { make in
+            make.width.height.equalTo(90)
+        }
+        
+        selectedBorder.image = .primary_7
+        selectedBorder.layer.cornerRadius = 12
+        selectedBorder.clipsToBounds = true
+        selectedBorder.isHidden = true
+        addSubview(selectedBorder)
+        selectedBorder.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+        
+        imageView.clipsToBounds = true
+        imageView.contentMode = .scaleAspectFill
+        addSubview(imageView)
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+#if DEBUG
+
+import SwiftUI
+
+struct LNSharePostViewControllerPreview: UIViewControllerRepresentable {
+    func makeUIViewController(context: Context) -> some UIViewController {
+        LNNavigationController(rootViewController: LNSharePostViewController())
+    }
+    
+    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { }
+}
+
+#Preview(body: {
+    LNSharePostViewControllerPreview()
+})
+#endif