Jelajahi Sumber

Merge remote-tracking branch 'origin/dev' into feat/1v1_call

* origin/dev:
  feat: 补充 IM 备注功能
  feat: 补充评论列表点击跳转个人页的逻辑
  feat: 补充 adjust 配置
  fix: 修复评论列表加载下一页后没有隐藏页脚的问题
  feat: 调整评论标题文案
  feat: 调整评论标题文案
  feat: 补充技能订单评论列表功能
  feat: 接入 Adjust 组件

# Conflicts:
#	Lanu/Localizable.xcstrings
#	Lanu/Manager/IM/LNIMManager.swift
#	Lanu/Manager/IM/Network/LNHttpManager+IM.swift
#	Lanu/Manager/IM/Network/LNIMResponse.swift
#	Podfile.lock
陈文艺 1 bulan lalu
induk
melakukan
9f9e8fce10
29 mengubah file dengan 944 tambahan dan 180 penghapusan
  1. 5 8
      Lanu.xcodeproj/project.pbxproj
  2. 19 0
      Lanu/AppDelegate.swift
  3. 1 1
      Lanu/Common/Storage/LNUserDefaultsKey.swift
  4. 10 8
      Lanu/Common/Views/LNPopupView.swift
  5. 2 1
      Lanu/Lanu-Bridging-Header.h
  6. 72 3
      Lanu/Localizable.xcstrings
  7. 13 0
      Lanu/Manager/GameMate/LNGameMateManager.swift
  8. 17 0
      Lanu/Manager/GameMate/Network/LNGameMateResponse.swift
  9. 13 1
      Lanu/Manager/GameMate/Network/LNHttpManager+GameMate.swift
  10. 35 4
      Lanu/Manager/IM/LNIMManager.swift
  11. 17 0
      Lanu/Manager/IM/Network/LNHttpManager+IM.swift
  12. 14 1
      Lanu/Manager/IM/Network/LNIMResponse.swift
  13. 16 4
      Lanu/Manager/IM/TUIUtils/V2TIMConversation+Extension.swift
  14. 8 4
      Lanu/Manager/Location/LNLocationManager.swift
  15. 163 0
      Lanu/Views/Game/Skill/LNSkillCommentsPanel.swift
  16. 235 0
      Lanu/Views/Game/Skill/LNSkillCommentsView.swift
  17. 32 22
      Lanu/Views/Game/Skill/LNSkillDetailViewController.swift
  18. 1 0
      Lanu/Views/Game/Skill/LNSkillPhotosView.swift
  19. 6 0
      Lanu/Views/Game/Skill/LNSkillTagView.swift
  20. 2 2
      Lanu/Views/Game/Skill/LNSkillUserInfoView.swift
  21. 7 1
      Lanu/Views/IM/Chat/LNIMChatViewController.swift
  22. 62 113
      Lanu/Views/IM/Chat/UserMenu/LNIMChatUserMenuView.swift
  23. 155 0
      Lanu/Views/IM/Chat/UserMenu/LNIMChatUserRemarkPanel.swift
  24. 19 0
      Lanu/Views/IM/Chat/ViewModel/LNIMChatViewModel.swift
  25. 1 1
      Lanu/Views/IM/Chat/VoiceCall/LNVoiceCallPanel.swift
  26. 5 4
      Lanu/Views/IM/ConversationList/LNIMConversationCell.swift
  27. 1 1
      Lanu/Views/Settings/LNAboutViewController.swift
  28. 2 0
      Podfile
  29. 11 1
      Podfile.lock

+ 5 - 8
Lanu.xcodeproj/project.pbxproj

@@ -206,6 +206,8 @@
 				Views/Game/Skill/Edit/LNSkillFieldSingleLineEditView.swift,
 				Views/Game/Skill/Edit/LNSkillFieldVoiceEditView.swift,
 				Views/Game/Skill/LNSkillBottomMenuView.swift,
+				Views/Game/Skill/LNSkillCommentsPanel.swift,
+				Views/Game/Skill/LNSkillCommentsView.swift,
 				Views/Game/Skill/LNSkillDetailViewController.swift,
 				Views/Game/Skill/LNSkillPhotosView.swift,
 				Views/Game/Skill/LNSkillSettingMenu.swift,
@@ -241,6 +243,7 @@
 				Views/IM/Chat/InputMenu/LNIMChatVoiceWaveView.swift,
 				Views/IM/Chat/LNIMChatViewController.swift,
 				Views/IM/Chat/UserMenu/LNIMChatUserMenuView.swift,
+				Views/IM/Chat/UserMenu/LNIMChatUserRemarkPanel.swift,
 				Views/IM/Chat/ViewModel/LNIMChatViewModel.swift,
 				Views/IM/Chat/VoiceCall/LNVoiceCallFloatingView.swift,
 				Views/IM/Chat/VoiceCall/LNVoiceCallPanel.swift,
@@ -347,6 +350,8 @@
 		};
 		FBB67E232EC48B440070E686 /* ThirdParty */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
+			exceptions = (
+			);
 			path = ThirdParty;
 			sourceTree = "<group>";
 		};
@@ -501,14 +506,10 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-input-files.xcfilelist",
 			);
-			inputPaths = (
-			);
 			name = "[CP] Copy Pods Resources";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources-${CONFIGURATION}-output-files.xcfilelist",
 			);
-			outputPaths = (
-			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-resources.sh\"\n";
@@ -544,14 +545,10 @@
 			inputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-input-files.xcfilelist",
 			);
-			inputPaths = (
-			);
 			name = "[CP] Embed Pods Frameworks";
 			outputFileListPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks-${CONFIGURATION}-output-files.xcfilelist",
 			);
-			outputPaths = (
-			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Gami/Pods-Gami-frameworks.sh\"\n";

+ 19 - 0
Lanu/AppDelegate.swift

@@ -17,6 +17,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         
         setupLogger()
         setupFirebase()
+        setupAdjust()
         LNNetworkMonitor.startMonitoring()
         
         _ = LNProfileManager.shared
@@ -97,4 +98,22 @@ extension AppDelegate {
         fileLogger.maximumFileSize = 5 * 1024 * 1024 // 5M 最大限制
         DDLog.add(fileLogger)
     }
+    
+    private func setupAdjust() {
+        let token = "fbze46mdkxds"
+        let env: String
+        let logLevel: ADJLogLevel
+        if LNAppConfig.shared.curEnv == .test {
+            env = ADJEnvironmentSandbox
+            logLevel = .verbose
+        } else {
+            env = ADJEnvironmentProduction
+            logLevel = .suppress
+        }
+        let config = ADJConfig(appToken: token, environment: env)
+        config?.logLevel = logLevel
+        config?.enableCostDataInAttribution()
+//        config?.delegate = self
+        Adjust.initSdk(config)
+    }
 }

+ 1 - 1
Lanu/Common/Storage/LNUserDefaultsKey.swift

@@ -23,5 +23,5 @@ enum LNUserDefaultsKey: String {
     case purchaseOrderId
     
     case location
-    case locationTime
+    case reportLocationTime
 }

+ 10 - 8
Lanu/Common/Views/LNPopupView.swift

@@ -16,6 +16,7 @@ enum LNPopupViewHeight {
 }
 
 class LNPopupView: UIView {
+    let backgroundView = UIView()
     let container = UIView()
     var containerHeight: LNPopupViewHeight = .auto
     var touchOutsideToCancel = true
@@ -48,9 +49,9 @@ class LNPopupView: UIView {
         
         moveToShowupPosition()
         window?.endEditing(true)
-        backgroundColor = .clear
+        backgroundView.backgroundColor = .clear
         UIView.animate(withDuration: 0.2) {
-            self.backgroundColor = .black.withAlphaComponent(0.4)
+            self.backgroundView.backgroundColor = .black.withAlphaComponent(0.4)
             self.layoutIfNeeded()
         }
     }
@@ -59,7 +60,7 @@ class LNPopupView: UIView {
         endEditing(true)
         moveToHiddenPosition()
         UIView.animate(withDuration: 0.2) {
-            self.backgroundColor = .clear
+            self.backgroundView.backgroundColor = .clear
             self.layoutIfNeeded()
         } completion: { [weak self] _ in
             guard let self else { return }
@@ -74,8 +75,7 @@ class LNPopupView: UIView {
 
 extension LNPopupView {
     private func setupViews() {
-        let bg = UIView()
-        bg.onTap { [weak self] in
+        backgroundView.onTap { [weak self] in
             guard let self else { return }
             if LNKeyboardManager.shared.isEditing {
                 endEditing(true)
@@ -89,9 +89,11 @@ extension LNPopupView {
                 endEditing(true)
             }
         }
-        insertSubview(bg, at: 0)
-        bg.snp.makeConstraints { make in
-            make.edges.equalToSuperview()
+        insertSubview(backgroundView, at: 0)
+        backgroundView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.bottom.equalToSuperview()
+            make.top.equalToSuperview().offset(-(UIView.navigationBarHeight + UIView.statusBarHeight))
         }
         
         container.backgroundColor = .white

+ 2 - 1
Lanu/Lanu-Bridging-Header.h

@@ -4,6 +4,7 @@
 
 #import <SDWebImage/SDWebImage.h>
 
-#import "TIMPush/TIMPush.h"
+#import <TIMPush/TIMPush.h>
+#import <AdjustSdk/AdjustSdk.h>
 
 @import ImSDK_Plus;

+ 72 - 3
Lanu/Localizable.xcstrings

@@ -3733,19 +3733,19 @@
         "en" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Bluetooth"
+            "value" : "Reviews (%d)"
           }
         },
         "id" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Bluetooth"
+            "value" : "Komentar (%d)"
           }
         },
         "zh-Hans" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "蓝牙"
+            "value" : "用户评论 (%d)"
           }
         }
       }
@@ -6625,6 +6625,29 @@
         }
       }
     },
+    "A00290" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Fill in the note name"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Isi nama catatan"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "请填写备注名称"
+          }
+        }
+      }
+    },
     "B00001" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -9155,6 +9178,29 @@
         }
       }
     },
+    "B00111" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "All reviews"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Semua Ulasan"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "全部评论"
+          }
+        }
+      }
+    },
     "C00001" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -9315,6 +9361,29 @@
           }
         }
       }
+    },
+    "C00008" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Bluetooth"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Bluetooth"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "蓝牙"
+          }
+        }
+      }
     }
   },
   "version" : "1.1"

+ 13 - 0
Lanu/Manager/GameMate/LNGameMateManager.swift

@@ -87,6 +87,19 @@ extension LNGameMateManager {
             }
         }
     }
+    
+    func getSkillCommentList(id: String, next: String? = nil,
+                             queue: DispatchQueue = .main,
+                             handler: @escaping (LNSkillCommentListResponse?) -> Void) {
+        LNHttpManager.shared.getSkillCommentList(id: id, size: 30, next: next ?? "") { res, err in
+            queue.asyncIfNotGlobal {
+                handler(res)
+            }
+            if let err {
+                showToast(err.errorDesc)
+            }
+        }
+    }
 }
 
 extension LNGameMateManager {

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

@@ -448,3 +448,20 @@ class LNSkillFilterConfig: Decodable {
 class LNSkillFilterConfigList: Decodable {
     var list: [LNSkillFilterConfig] = []
 }
+
+@AutoCodable
+class LNSkillCommentVO: Decodable {
+    var time: Int = 0
+    var avatar: String = ""
+    var nickname: String = ""
+    var star: Double = 0
+    var userNo: String = ""
+    var comment: String = ""
+}
+
+@AutoCodable
+class LNSkillCommentListResponse: Decodable {
+    var list: [LNSkillCommentVO] = []
+    var next: String = ""
+    var total: Int = 0
+}

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

@@ -16,6 +16,7 @@ private let kNetPath_GameMate_Skills = "/skill/user/goods"
 private let kNetPath_GameMate_Info = "/user/playmate/info"
 private let kNetPath_GameMate_Skill_Detail = "/skill/detail"
 private let kNetPath_GameMate_Score = "/user/playmate/charmStar"
+private let kNetPath_GameMate_Skill_Comment = "/skill/goods/comments"
 
 private let kNetPath_GameMate_Search = "/playmate/search"
 
@@ -147,7 +148,7 @@ extension LNHttpManager {
     }
 }
 
-// MARK: 获取陪玩师各种信息
+// MARK: 陪玩师信息查询
 extension LNHttpManager {
     func getUserSkills(uid: String, completion: @escaping (LNGameMateSkillListResponse?, LNHttpError?) -> Void) {
         post(path: kNetPath_GameMate_Skills, params: ["id": uid], completion: completion)
@@ -179,6 +180,17 @@ extension LNHttpManager {
             "star": score
         ], completion: completion)
     }
+    
+    func getSkillCommentList(id: String, size: Int, next: String,
+                             completion: @escaping (LNSkillCommentListResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_GameMate_Skill_Comment, params: [
+            "skillId": id,
+            "page": [
+                "size": size,
+                "next": next
+            ]
+        ], completion: completion)
+    }
 }
 
 // MARK: 陪玩师申请

+ 35 - 4
Lanu/Manager/IM/LNIMManager.swift

@@ -93,6 +93,7 @@ class LNIMManager: NSObject {
     
     static let maxOfficialId = 10000
     static let maxMessageInput = 200
+    static let maxRemarkLength = 16
     
     private(set) var conversationList: [V2TIMConversation] = []
     private(set) var userStatus: [String: V2TIMUserStatusType] = [:]
@@ -135,10 +136,7 @@ extension LNIMManager {
             }
             
             for item in list {
-                if let old = conversationList.first(where: { $0.conversationID == item.conversationID }),
-                   let userInfo = old.userInfo {
-                    item.userInfo = userInfo
-                }
+                item.extraInfo = conversationList.first(where: { $0.conversationID == item.conversationID })?.extraInfo ?? LNIMConversationExtraInfo()
             }
             
             conversationList = list
@@ -147,6 +145,14 @@ extension LNIMManager {
             
             V2TIMManager.sharedInstance().subscribeUserStatus(userIDList: list.compactMap({ $0.userID }), succ: nil)
             loadUsersOnlineStatus()
+            getUsersRemark(uids: list.compactMap({ $0.userID })) { [weak self] remarks in
+                guard let remarks else { return }
+                guard let self else { return }
+                for item in remarks {
+                    list.first { $0.userID == item.userNo }?.extraInfo?.remark = item.note
+                }
+                notifyConversationListChanged()
+            }
         } fail: { code, err in
             handler?(false)
         }
@@ -413,6 +419,31 @@ extension LNIMManager {
     }
 }
 
+extension LNIMManager {
+    func setUserRemark(uid: String, remark: String, queue: DispatchQueue = .main, handler: @escaping (Bool) -> Void) {
+        LNHttpManager.shared.setUserRemark(uid: uid, remark: remark) { [weak self] err in
+            queue.asyncIfNotGlobal {
+                handler(err == nil)
+            }
+            if let err {
+                showToast(err.errorDesc)
+            } else {
+                guard let self else { return }
+                conversationList.first { $0.userID == uid }?.extraInfo?.remark = remark
+                notifyConversationListChanged()
+            }
+        }
+    }
+    
+    func getUsersRemark(uids: [String], queue: DispatchQueue = .main, handler: @escaping ([LNIMUserRemarkVO]?) -> Void) {
+        LNHttpManager.shared.getUsersRemark(uids: uids) { res, err in
+            queue.asyncIfNotGlobal {
+                handler(res?.list)
+            }
+        }
+    }
+}
+
 extension LNIMManager: LNAccountManagerNotify {
     func onUserLogin() {
         // 初始化 SDK

+ 17 - 0
Lanu/Manager/IM/Network/LNHttpManager+IM.swift

@@ -9,6 +9,8 @@ import Foundation
 
 
 private let kNetPath_IM_Sign = "/im/userSign"
+private let kNetPath_IM_Remark = "/im/set/usernameNote"
+private let kNetPath_IM_Remark_Query = "/im/get/usernameNotes"
 private let kNetPath_IM_Call_Check = "/im/callCheck"
 
 
@@ -23,3 +25,18 @@ extension LNHttpManager {
         ], completion: completion)
     }
 }
+
+extension LNHttpManager {
+    func setUserRemark(uid: String, remark: String, completion: @escaping (LNHttpError?) -> Void) {
+        post(path: kNetPath_IM_Remark, params: [
+            "userNo": uid,
+            "note": remark
+        ], completion: completion)
+    }
+    
+    func getUsersRemark(uids: [String], completion: @escaping (LNIMUsersRemarkResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_IM_Remark_Query, params: [
+            "list": uids
+        ], completion: completion)
+    }
+}

+ 14 - 1
Lanu/Manager/IM/Network/LNIMResponse.swift

@@ -2,7 +2,20 @@
 //  LNIMResponse.swift
 //  Gami
 //
-//  Created by OneeChan on 2026/2/6.
+//  Created by OneeChan on 2026/2/8.
 //
 
 import Foundation
+import AutoCodable
+
+
+@AutoCodable
+class LNIMUserRemarkVO: Decodable {
+    var userNo: String = ""
+    var note: String = ""
+}
+
+@AutoCodable
+class LNIMUsersRemarkResponse: Decodable {
+    var list: [LNIMUserRemarkVO] = []
+}

+ 16 - 4
Lanu/Manager/IM/TUIUtils/V2TIMConversation+Extension.swift

@@ -121,14 +121,26 @@ extension V2TIMConversation {
     }
 }
 
-private var userInfoKey: UInt8 = 0
+private var extraInfoKey: UInt8 = 0
+class LNIMConversationExtraInfo {
+    var userInfo: LNUserProfileVO?
+    var remark: String?
+}
 extension V2TIMConversation {
-    var userInfo: LNUserProfileVO? {
+    var extraInfo: LNIMConversationExtraInfo? {
         get {
-            objc_getAssociatedObject(self, &userInfoKey) as? LNUserProfileVO
+            objc_getAssociatedObject(self, &extraInfoKey) as? LNIMConversationExtraInfo
         }
         set {
-            objc_setAssociatedObject(self, &userInfoKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+            objc_setAssociatedObject(self, &extraInfoKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+        }
+    }
+    
+    var displayName: String? {
+        return if let remark = extraInfo?.remark, !remark.isEmpty {
+            remark
+        } else {
+            extraInfo?.userInfo?.nickname
         }
     }
 }

+ 8 - 4
Lanu/Manager/Location/LNLocationManager.swift

@@ -31,11 +31,16 @@ class LNLocationManager: NSObject {
             LNUserDefaults[.location] = curLocation
         }
     }
-    private var lastTime: TimeInterval? = LNUserDefaults[.locationTime] {
+    private var lastTime: TimeInterval? = LNUserDefaults[.reportLocationTime] {
         didSet {
-            LNUserDefaults[.locationTime] = lastTime
+            LNUserDefaults[.reportLocationTime] = lastTime
         }
     }
+    private var shouldReportLocation: Bool {
+        guard let curLocation, let lastTime else { return true }
+        
+        return curTime - lastTime > 3 * 60 * 60  // 3 小时内不拉取
+    }
     
     private override init() {
         super.init()
@@ -80,8 +85,7 @@ extension LNLocationManager: LNAccountManagerNotify {
 
 extension LNLocationManager {
     private func getLocationIfNeed() {
-        if curLocation == nil
-            || curTime - (lastTime ?? 0) > 3 * 60 * 60 { // 3 小时内不拉取
+        if shouldReportLocation {
             locationManager.requestWhenInUseAuthorization()  // 请求使用时的定位权限
             locationManager.startUpdatingLocation()  // 开始更新位置
         }

+ 163 - 0
Lanu/Views/Game/Skill/LNSkillCommentsPanel.swift

@@ -0,0 +1,163 @@
+//
+//  LNSkillCommentsPanel.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/2/3.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+import MJRefresh
+
+
+class LNSkillCommentsPanel: LNPopupView {
+    private let tableView = UITableView()
+    
+    private var skillId = ""
+    private var nextTag: String?
+    private var list: [LNSkillCommentVO] = []
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func update(_ skillId: String) {
+        self.skillId = skillId
+        
+        tableView.mj_header?.beginRefreshing()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNSkillCommentsPanel {
+    private func loadList() {
+        LNGameMateManager.shared.getSkillCommentList(id: skillId, next: nextTag) { [weak self] res in
+            guard let self else { return }
+            tableView.mj_header?.endRefreshing()
+            
+            if let list = res?.list {
+                if nextTag == nil {
+                    self.list = list
+                } else {
+                    self.list.append(contentsOf: list)
+                }
+                tableView.reloadData()
+            }
+            nextTag = res?.next
+            if res?.next.isEmpty != false {
+                tableView.mj_footer?.endRefreshingWithNoMoreData()
+            } else {
+                tableView.mj_footer?.endRefreshing()
+            }
+        }
+    }
+}
+
+extension LNSkillCommentsPanel: UITableViewDataSource {
+    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+        list.count
+    }
+    
+    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+        let cell = tableView.dequeueReusableCell(withIdentifier: LNSkillCommentCell.className, for: indexPath) as! LNSkillCommentCell
+        
+        cell.update(list[indexPath.row])
+        
+        return cell
+    }
+}
+
+extension LNSkillCommentsPanel {
+    private func setupViews() {
+        containerHeight = .percent(0.8)
+        
+        let header = buildHeader()
+        container.addSubview(header)
+        header.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
+        }
+        
+        let listView = buildListView()
+        container.addSubview(listView)
+        listView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalTo(header.snp.bottom)
+            make.bottom.equalToSuperview()
+        }
+    }
+    
+    private func buildHeader() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(52)
+        }
+        
+        let titleLabel = UILabel()
+        titleLabel.text = .init(key: "B00111")
+        titleLabel.font = .heading_h3
+        titleLabel.textColor = .text_5
+        container.addSubview(titleLabel)
+        titleLabel.snp.makeConstraints { make in
+            make.center.equalToSuperview()
+        }
+        
+        return container
+    }
+    
+    private func buildListView() -> UIView {
+        let header = MJRefreshNormalHeader { [weak self] in
+            guard let self else { return }
+            self.nextTag = nil
+            self.loadList()
+        }
+        header.lastUpdatedTimeLabel?.isHidden = true
+        header.stateLabel?.isHidden = true
+        tableView.mj_header = header
+        
+        let footer = MJRefreshAutoNormalFooter { [weak self] in
+            guard let self else { return }
+            self.loadList()
+        }
+        footer.setTitle("", for: .noMoreData)
+        footer.setTitle(.init(key: "A00046"), for: .idle)
+        tableView.mj_footer = footer
+        
+        tableView.register(LNSkillCommentCell.self, forCellReuseIdentifier: LNSkillCommentCell.className)
+        tableView.dataSource = self
+        tableView.allowsSelection = false
+        tableView.separatorStyle = .singleLine
+        tableView.separatorInset = .init(top: 0, left: 16, bottom: 0, right: 16)
+        tableView.backgroundColor = .clear
+        
+        return tableView
+    }
+}
+
+private class LNSkillCommentCell: UITableViewCell {
+    private let commentView = LNSkillCommentItemView()
+    
+    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+        super.init(style: style, reuseIdentifier: reuseIdentifier)
+        
+        contentView.addSubview(commentView)
+        commentView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.verticalEdges.equalToSuperview()
+        }
+    }
+    
+    func update(_ comment: LNSkillCommentVO) {
+        commentView.update(comment)
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}

+ 235 - 0
Lanu/Views/Game/Skill/LNSkillCommentsView.swift

@@ -0,0 +1,235 @@
+//
+//  LNSkillCommentsView.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/2/3.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNSkillCommentsView: UIView {
+    private let starLabel = UILabel()
+    private let titleLabel = UILabel()
+    private let stackView = UIStackView()
+    
+    private var curSkill: LNGameMateSkillDetailVO?
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func update(_ detail: LNGameMateSkillDetailVO) {
+        curSkill = detail
+        starLabel.text = "\(detail.star)"
+        
+        LNGameMateManager.shared.getSkillCommentList(id: detail.id) { [weak self] res in
+            guard let self else { return }
+            guard let res else { return }
+            isHidden = res.list.isEmpty
+            titleLabel.text = .init(key: "A00163", res.total)
+            reloadList(res.list)
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNSkillCommentsView {
+    private func reloadList(_ list: [LNSkillCommentVO]) {
+        stackView.arrangedSubviews.forEach {
+            stackView.removeArrangedSubview($0)
+            $0.removeFromSuperview()
+        }
+        
+        let comments = list.prefix(3)
+        comments.forEach {
+            let itemView = LNSkillCommentItemView()
+            itemView.update($0)
+            stackView.addArrangedSubview(itemView)
+            if $0.userNo != comments.last?.userNo {
+                let line = UIView()
+                line.backgroundColor = .fill_2
+                line.snp.makeConstraints { make in
+                    make.height.equalTo(1)
+                }
+                stackView.addArrangedSubview(line)
+            }
+        }
+    }
+    
+    private func setupViews() {
+        let header = buildHeader()
+        addSubview(header)
+        header.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview().offset(18)
+        }
+        
+        stackView.axis = .vertical
+        stackView.spacing = 0
+        addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalTo(header.snp.bottom)
+            make.bottom.equalToSuperview()
+        }
+    }
+    
+    private func buildHeader() -> UIView {
+        let container = UIView()
+        container.onTap { [weak self] in
+            guard let self else { return }
+            guard let curSkill else { return }
+            let panel = LNSkillCommentsPanel()
+            panel.update(curSkill.id)
+            panel.popup(self)
+        }
+        
+        let starIc = UIImageView(image: .icStarFill)
+        container.addSubview(starIc)
+        starIc.snp.makeConstraints { make in
+            make.leading.equalToSuperview()
+            make.centerY.equalToSuperview()
+            make.width.height.equalTo(16)
+        }
+        
+        starLabel.font = .heading_h3
+        starLabel.textColor = .text_5
+        container.addSubview(starLabel)
+        starLabel.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview()
+            make.leading.equalTo(starIc.snp.trailing).offset(4)
+        }
+        
+        let dotView = UIView()
+        dotView.backgroundColor = .text_3
+        dotView.layer.cornerRadius = 2
+        container.addSubview(dotView)
+        dotView.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(starLabel.snp.trailing).offset(8)
+            make.width.height.equalTo(4)
+        }
+        
+        titleLabel.font = .heading_h3
+        titleLabel.textColor = .text_5
+        container.addSubview(titleLabel)
+        titleLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.leading.equalTo(dotView.snp.trailing).offset(8)
+        }
+        
+        let arrow = UIImageView.arrowImageView(size: 10)
+        arrow.tintColor = .text_4
+        container.addSubview(arrow)
+        arrow.snp.makeConstraints { make in
+            make.trailing.equalToSuperview()
+            make.centerY.equalToSuperview()
+        }
+        
+        let moreLabel = UILabel()
+        moreLabel.text = .init(key: "A00048")
+        moreLabel.font = .body_s
+        moreLabel.textColor = .text_5
+        container.addSubview(moreLabel)
+        moreLabel.snp.makeConstraints { make in
+            make.centerY.equalToSuperview()
+            make.trailing.equalTo(arrow.snp.leading).offset(-4)
+        }
+        
+        return container
+    }
+}
+
+
+class LNSkillCommentItemView: UIView {
+    private let avatar = UIImageView()
+    private let nameLabel = UILabel()
+    private let starView = LNFiveStarScoreView()
+    private let commentLabel = UILabel()
+    private let timeLabel = UILabel()
+    private var comment: LNSkillCommentVO?
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func update(_ comment: LNSkillCommentVO) {
+        avatar.sd_setImage(with: URL(string: comment.avatar))
+        nameLabel.text = comment.nickname
+        starView.score = comment.star
+        commentLabel.text = comment.comment
+        timeLabel.text = TimeInterval(comment.time / 1_000).tencentIMTimeDesc
+        
+        self.comment = comment
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNSkillCommentItemView {
+    private func setupViews() {
+        avatar.layer.cornerRadius = 21
+        avatar.clipsToBounds = true
+        addSubview(avatar)
+        avatar.snp.makeConstraints { make in
+            make.leading.equalToSuperview()
+            make.top.equalToSuperview().offset(18)
+            make.width.height.equalTo(42)
+        }
+        
+        nameLabel.font = .heading_h4
+        nameLabel.textColor = .text_5
+        addSubview(nameLabel)
+        nameLabel.snp.makeConstraints { make in
+            make.leading.equalTo(avatar.snp.trailing).offset(10)
+            make.top.equalTo(avatar)
+        }
+        
+        starView.icSize = 13
+        starView.spacing = 2
+        addSubview(starView)
+        starView.snp.makeConstraints { make in
+            make.trailing.equalToSuperview()
+            make.centerY.equalTo(nameLabel)
+            make.leading.greaterThanOrEqualTo(nameLabel.snp.trailing).offset(16)
+        }
+        
+        commentLabel.font = .body_m
+        commentLabel.textColor = .text_5
+        commentLabel.numberOfLines = 0
+        addSubview(commentLabel)
+        commentLabel.snp.makeConstraints { make in
+            make.leading.equalTo(nameLabel)
+            make.top.equalTo(nameLabel.snp.bottom).offset(4)
+            make.trailing.equalToSuperview()
+        }
+        
+        timeLabel.font = .body_xs
+        timeLabel.textColor = .text_3
+        addSubview(timeLabel)
+        timeLabel.snp.makeConstraints { make in
+            make.leading.equalTo(nameLabel)
+            make.top.equalTo(commentLabel.snp.bottom).offset(4)
+            make.bottom.equalToSuperview().offset(-18)
+        }
+        
+        onTap { [weak self] in
+            guard let self else { return }
+            guard let comment else { return }
+            
+            pushToProfile(uid: comment.userNo)
+        }
+    }
+}

+ 32 - 22
Lanu/Views/Game/Skill/LNSkillDetailViewController.swift

@@ -35,6 +35,7 @@ class LNSkillDetailViewController: LNViewController {
     private let descLabel = UILabel()
     private let tagView = LNSkillTagView()
     private let photosView = LNSkillPhotosView()
+    private let commentsView = LNSkillCommentsView()
     private let bottomMenu = LNSkillBottomMenuView()
     
     private var detail: LNGameMateSkillDetailVO?
@@ -81,9 +82,15 @@ extension LNSkillDetailViewController {
             cover.sd_setImage(with: URL(string: info.cover.isEmpty ? info.avatar : info.cover))
             userInfoView.update(info)
             gameNameLabel.text = info.categoryName
+            
+            descLabel.isHidden = info.summary.isEmpty
             descLabel.text = info.summary
+            
             tagView.update(info.labels)
             photosView.update(info)
+            
+            commentsView.update(info)
+            
             bottomMenu.update(info)
             
             avatar.sd_setImage(with: URL(string: info.avatar))
@@ -169,12 +176,13 @@ extension LNSkillDetailViewController {
         }
         
         let scrollView = UIScrollView()
+        scrollView.delegate = self
+        scrollView.clipsToBounds = false
+        scrollView.backgroundColor = .fill
         scrollView.contentInsetAdjustmentBehavior = .never
         scrollView.showsVerticalScrollIndicator = false
         scrollView.showsHorizontalScrollIndicator = false
-        scrollView.backgroundColor = .fill
-        scrollView.delegate = self
-        scrollView.clipsToBounds = false
+        scrollView.contentInset = .init(top: 0, left: 0, bottom: -view.commonBottomInset + 47, right: 0)
         stackView.addArrangedSubview(scrollView)
         
         let fakeView = UIView()
@@ -315,10 +323,10 @@ extension LNSkillDetailViewController {
         
         cover.contentMode = .scaleAspectFill
         cover.isUserInteractionEnabled = true
+        cover.clipsToBounds = true
         cover.onTap { [weak self] in
             guard let self else { return }
-            guard let cover = detail?.cover,
-                  !cover.isEmpty else { return }
+            guard let cover = detail?.cover.isEmpty != false ? detail?.avatar : detail?.cover else { return }
             view.presentImagePreview([cover], 0)
         }
         cover.snp.makeConstraints { make in
@@ -356,28 +364,30 @@ extension LNSkillDetailViewController {
             make.height.equalTo(1)
         }
         
-        descLabel.font = .body_m
-        descLabel.textColor = .text_4
-        descLabel.numberOfLines = 0
-        container.addSubview(descLabel)
-        descLabel.snp.makeConstraints { make in
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.spacing = 12
+        container.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
             make.horizontalEdges.equalToSuperview().inset(16)
             make.top.equalTo(line.snp.bottom).offset(10)
+            make.bottom.equalToSuperview()
         }
         
-        container.addSubview(tagView)
-        tagView.snp.makeConstraints { make in
-            make.horizontalEdges.equalToSuperview().inset(16)
-            make.top.equalTo(descLabel.snp.bottom).offset(14)
-            make.height.equalTo(0).priority(.low)
-        }
+        descLabel.font = .body_m
+        descLabel.textColor = .text_4
+        descLabel.numberOfLines = 0
+        descLabel.isHidden = true
+        stackView.addArrangedSubview(descLabel)
         
-        container.addSubview(photosView)
-        photosView.snp.makeConstraints { make in
-            make.horizontalEdges.equalToSuperview().inset(16)
-            make.top.equalTo(tagView.snp.bottom).offset(16)
-            make.bottom.equalToSuperview()
-        }
+        tagView.isHidden = true
+        stackView.addArrangedSubview(tagView)
+        
+        photosView.isHidden = true
+        stackView.addArrangedSubview(photosView)
+        
+        commentsView.isHidden = true
+        stackView.addArrangedSubview(commentsView)
         
         return container
     }

+ 1 - 0
Lanu/Views/Game/Skill/LNSkillPhotosView.swift

@@ -26,6 +26,7 @@ class LNSkillPhotosView: UIView {
             stackView.removeArrangedSubview($0)
             $0.removeFromSuperview()
         }
+        isHidden = detail.images.isEmpty
         let urls = detail.images
         for (index, url) in urls.enumerated() {
             let container = UIView()

+ 6 - 0
Lanu/Views/Game/Skill/LNSkillTagView.swift

@@ -20,6 +20,8 @@ class LNSkillTagView: UIView {
     }
     
     func update(_ tags: [String]) {
+        isHidden = tags.isEmpty
+        
         var itemViews: [UIView] = []
         tags.forEach {
             let container = UIView()
@@ -52,6 +54,10 @@ class LNSkillTagView: UIView {
 
 extension LNSkillTagView {
     private func setupViews() {
+        snp.makeConstraints { make in
+            make.height.equalTo(0).priority(.low)
+        }
+        
         stackView.spacing = 8
         stackView.itemSpacing = 10
         addSubview(stackView)

+ 2 - 2
Lanu/Views/Game/Skill/LNSkillUserInfoView.swift

@@ -184,7 +184,7 @@ extension LNSkillUserInfoView {
         orderView.snp.makeConstraints { make in
             make.centerX.equalToSuperview()
             make.bottom.equalToSuperview()
-            make.top.equalTo(scoreLabel.snp.bottom).offset(4)
+            make.top.equalTo(scoreLabel.snp.bottom)
         }
         
         let orderLabel = UILabel()
@@ -202,7 +202,7 @@ extension LNSkillUserInfoView {
         orderView.addSubview(orderCountLabel)
         orderCountLabel.snp.makeConstraints { make in
             make.leading.equalTo(orderLabel.snp.trailing).offset(2)
-            make.verticalEdges.equalTo(orderLabel)
+            make.verticalEdges.equalToSuperview()
             make.trailing.equalToSuperview()
         }
         

+ 7 - 1
Lanu/Views/IM/Chat/LNIMChatViewController.swift

@@ -109,12 +109,18 @@ extension LNIMChatViewController {
         loadRelation()
         loadUnreadCount()
         loadUserOnlineStatus()
+        
         viewModel.$userInfo.sink { [weak self] newInfo in
             guard let self else { return }
             guard let newInfo else { return }
-            nameLabel.text = newInfo.nickname
+            nameLabel.text = viewModel.remark?.isEmpty == false ? viewModel.remark : newInfo.nickname
             avatar.sd_setImage(with: URL(string: newInfo.avatar))
         }.store(in: &bag)
+        viewModel.$remark.sink { [weak self] newValue in
+            guard let self else { return }
+            guard let newValue else { return }
+            nameLabel.text = !newValue.isEmpty ? newValue : viewModel.userInfo?.nickname
+        }.store(in: &bag)
     }
     
     private func loadUnreadCount() {

+ 62 - 113
Lanu/Views/IM/Chat/UserMenu/LNIMChatUserMenuView.swift

@@ -51,8 +51,8 @@ extension LNIMChatUserMenuView {
         let menus = [
             buildMute(),
             
-//            buildLine(),
-//            buildRemark(),
+            buildLine(),
+            buildRemark(),
             
             buildLine(),
             buildBlack(),
@@ -65,110 +65,40 @@ extension LNIMChatUserMenuView {
     }
     
     private func buildMute() -> UIView {
-        let container = UIView()
-        container.snp.makeConstraints { make in
-            make.height.equalTo(52)
-        }
-        
-        let ic = UIImageView()
-        ic.image = .icImChatMenuMute
-        container.addSubview(ic)
-        ic.snp.makeConstraints { make in
-            make.leading.equalToSuperview().offset(16)
-            make.centerY.equalToSuperview()
-        }
-        
-        let titleLabel = UILabel()
-        titleLabel.text = .init(key: "A00085")
-        titleLabel.font = .body_m
-        titleLabel.textColor = .text_5
-        container.addSubview(titleLabel)
-        titleLabel.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.leading.equalTo(ic.snp.trailing).offset(10)
-        }
-
+        let scaleX: CGFloat = 40.0 / 51.0
+        let scaleY: CGFloat = 24.5 / 31.0
+        muteSwitch.onTintColor = .primary_5
+        muteSwitch.transform = CGAffineTransform(scaleX: scaleX, y: scaleY)
         muteSwitch.addAction(UIAction(handler: { [weak self] _ in
             guard let self else { return }
             viewModel?.updateMessageOpt(opt: muteSwitch.isOn ? .RECEIVE_NOT_NOTIFY_MESSAGE : .RECEIVE_MESSAGE)
         }), for: .valueChanged)
-        container.addSubview(muteSwitch)
-        muteSwitch.snp.makeConstraints { make in
-            make.trailing.equalToSuperview().offset(-16)
-            make.centerY.equalToSuperview()
-        }
-        
-        return container
+        return buildMenuItem(icon: .icImChatMenuMute, title: .init(key: "A00085"), contentView: muteSwitch)
     }
     
     private func buildRemark() -> UIView {
-        let container = UIView()
-        container.snp.makeConstraints { make in
-            make.height.equalTo(52)
-        }
-        
-        let ic = UIImageView()
-        ic.image = .icImChatMenuRemark
-        container.addSubview(ic)
-        ic.snp.makeConstraints { make in
-            make.leading.equalToSuperview().offset(16)
-            make.centerY.equalToSuperview()
-        }
-        
-        let titleLabel = UILabel()
-        titleLabel.text = .init(key: "A00086")
-        titleLabel.font = .body_m
-        titleLabel.textColor = .text_5
-        container.addSubview(titleLabel)
-        titleLabel.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.leading.equalTo(ic.snp.trailing).offset(10)
-        }
-        
-        let arrow = UIImageView()
-        arrow.image = .init(systemName: "chevron.forward")
-        arrow.tintColor = .text_4
-        container.addSubview(arrow)
-        arrow.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.trailing.equalToSuperview().offset(-16)
+        let menu = buildMenuItem(icon: .icImChatMenuRemark, title: .init(key: "A00086"), contentView: nil)
+        menu.onTap { [weak self] in
+            guard let self else { return }
+            let panel = LNIMChatUserRemarkPanel()
+            panel.update(viewModel?.remark ?? "")
+            panel.handler = { [weak self] remark in
+                guard let self else { return }
+                dismiss()
+                viewModel?.updateRemark(remark)
+            }
+            panel.popup()
         }
         
-        return container
+        return menu
     }
     
     private func buildBlack() -> UIView {
-        let container = UIView()
-        container.snp.makeConstraints { make in
-            make.height.equalTo(52)
-        }
-        
-        let ic = UIImageView()
-        ic.image = .icImChatMenuBlack
-        container.addSubview(ic)
-        ic.snp.makeConstraints { make in
-            make.leading.equalToSuperview().offset(16)
-            make.centerY.equalToSuperview()
-        }
-        
         blackLabel.font = .body_m
         blackLabel.textColor = .text_5
-        container.addSubview(blackLabel)
-        blackLabel.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.leading.equalTo(ic.snp.trailing).offset(10)
-        }
-        
-        let arrow = UIImageView()
-        arrow.image = .init(systemName: "chevron.forward")
-        arrow.tintColor = .text_4
-        container.addSubview(arrow)
-        arrow.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.trailing.equalToSuperview().offset(-16)
-        }
+        let menu = buildMenuItem(icon: .icImChatMenuBlack, titleView: blackLabel, contentView: nil)
         
-        container.onTap { [weak self] in
+        menu.onTap { [weak self] in
             guard let self else { return }
             guard let uid = viewModel?.userId else { return }
             dismiss()
@@ -179,47 +109,66 @@ extension LNIMChatUserMenuView {
             }
         }
         
-        return container
+        return menu
     }
     
     private func buildReport() -> UIView {
+        let menu = buildMenuItem(icon: .icImChatMenuRemark, title: .init(key: "A00043"), contentView: nil)
+        
+        menu.onTap { [weak self] in
+            guard let self else { return }
+            guard let uid = viewModel?.userId else { return }
+            dismiss()
+            pushToReport(uid: uid)
+        }
+        
+        return menu
+    }
+    
+    private func buildMenuItem(icon: UIImage, title: String, contentView: UIView?) -> UIView {
+        let titleLabel = UILabel()
+        titleLabel.text = title
+        titleLabel.font = .body_m
+        titleLabel.textColor = .text_5
+        
+        return buildMenuItem(icon: icon, titleView: titleLabel, contentView: contentView)
+    }
+    
+    private func buildMenuItem(icon: UIImage, titleView: UIView, contentView: UIView?) -> UIView {
         let container = UIView()
         container.snp.makeConstraints { make in
             make.height.equalTo(52)
         }
         
         let ic = UIImageView()
-        ic.image = .icImChatMenuRemark
+        ic.image = icon
         container.addSubview(ic)
         ic.snp.makeConstraints { make in
             make.leading.equalToSuperview().offset(16)
             make.centerY.equalToSuperview()
         }
         
-        let titleLabel = UILabel()
-        titleLabel.text = .init(key: "A00043")
-        titleLabel.font = .body_m
-        titleLabel.textColor = .text_5
-        container.addSubview(titleLabel)
-        titleLabel.snp.makeConstraints { make in
+        container.addSubview(titleView)
+        titleView.snp.makeConstraints { make in
             make.centerY.equalToSuperview()
             make.leading.equalTo(ic.snp.trailing).offset(10)
         }
         
-        let arrow = UIImageView()
-        arrow.image = .init(systemName: "chevron.forward")
-        arrow.tintColor = .text_4
-        container.addSubview(arrow)
-        arrow.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.trailing.equalToSuperview().offset(-16)
-        }
-        
-        container.onTap { [weak self] in
-            guard let self else { return }
-            guard let uid = viewModel?.userId else { return }
-            dismiss()
-            pushToReport(uid: uid)
+        if let contentView {
+            container.addSubview(contentView)
+            contentView.snp.makeConstraints { make in
+                make.centerY.equalToSuperview()
+                make.trailing.equalToSuperview().offset(-16)
+            }
+        } else {
+            let arrow = UIImageView()
+            arrow.image = .init(systemName: "chevron.forward")
+            arrow.tintColor = .text_4
+            container.addSubview(arrow)
+            arrow.snp.makeConstraints { make in
+                make.centerY.equalToSuperview()
+                make.trailing.equalToSuperview().offset(-16)
+            }
         }
         
         return container

+ 155 - 0
Lanu/Views/IM/Chat/UserMenu/LNIMChatUserRemarkPanel.swift

@@ -0,0 +1,155 @@
+//
+//  LNIMChatUserRemarkPanel.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/2/8.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNIMChatUserRemarkPanel: LNPopupView {
+    private let countLabel = UILabel()
+    private let inputField = UITextField()
+    private let confirmButton = UIButton()
+    
+    var handler: ((String) -> Void)?
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func update(_ remark: String) {
+        inputField.text = remark
+        updateCount()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNIMChatUserRemarkPanel: UITextFieldDelegate {
+    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
+        let currentText = textField.text ?? ""
+                
+        guard let range = Range(range, in: currentText) else { return false }
+        let newText = currentText.replacingCharacters(in: range, with: string)
+        if newText.count < currentText.count {
+            return true
+        }
+        
+        return newText.count <= LNIMManager.maxRemarkLength
+    }
+    
+    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+        textField.resignFirstResponder()
+        
+        return true
+    }
+}
+
+extension LNIMChatUserRemarkPanel {
+    private func updateCount() {
+        let count = inputField.text?.count ?? 0
+        countLabel.text = "\(count)/\(LNIMManager.maxRemarkLength)"
+    }
+    
+    private func setupViews() {
+        let header = buildHeader()
+        container.addSubview(header)
+        header.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
+        }
+        
+        let tipsLabel = UILabel()
+        tipsLabel.font = .heading_h4
+        tipsLabel.text = .init(key: "A00290")
+        tipsLabel.textColor = .text_5
+        container.addSubview(tipsLabel)
+        tipsLabel.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(16)
+            make.top.equalTo(header.snp.bottom)
+        }
+        
+        updateCount()
+        countLabel.font = .body_m
+        countLabel.textColor = .text_5
+        container.addSubview(countLabel)
+        countLabel.snp.makeConstraints { make in
+            make.trailing.equalToSuperview().offset(-16)
+            make.centerY.equalTo(tipsLabel)
+        }
+        
+        let inputHolder = UIView()
+        inputHolder.backgroundColor = .fill_1
+        inputHolder.layer.cornerRadius = 8
+        container.addSubview(inputHolder)
+        inputHolder.snp.makeConstraints { make in
+            make.top.equalTo(tipsLabel.snp.bottom).offset(8)
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.height.equalTo(46)
+        }
+        
+        inputField.font = .body_m
+        inputField.textColor = .text_5
+        inputField.clearButtonMode = .whileEditing
+        inputField.delegate = self
+        inputField.returnKeyType = .done
+        inputField.visibleView = inputHolder
+        inputField.placeholder = .init(key: "A00006")
+        inputField.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            updateCount()
+        }), for: .editingChanged)
+        inputHolder.addSubview(inputField)
+        inputField.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(12)
+            make.centerY.equalToSuperview()
+        }
+        
+        confirmButton.setTitle(.init(key: "A00185"), for: .normal)
+        confirmButton.setTitleColor(.text_1, for: .normal)
+        confirmButton.titleLabel?.font = .heading_h3
+        confirmButton.setBackgroundImage(.primary_8, for: .normal)
+        confirmButton.layer.cornerRadius = 23.5
+        confirmButton.clipsToBounds = true
+        confirmButton.backgroundColor = .fill_4
+        confirmButton.addAction(UIAction(handler: { [weak self] _ in
+            guard let self else { return }
+            guard let text = inputField.text else { return }
+            dismiss()
+            handler?(text)
+        }), for: .touchUpInside)
+        container.addSubview(confirmButton)
+        confirmButton.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(12)
+            make.top.equalTo(inputHolder.snp.bottom).offset(66)
+            make.bottom.equalToSuperview().offset(commonBottomInset)
+            make.height.equalTo(47)
+        }
+    }
+    
+    private func buildHeader() -> UIView {
+        let container = UIView()
+        container.snp.makeConstraints { make in
+            make.height.equalTo(50)
+        }
+        
+        let titleLabel = UILabel()
+        titleLabel.font = .heading_h3
+        titleLabel.textColor = .text_5
+        titleLabel.text = .init(key: "A00086")
+        container.addSubview(titleLabel)
+        titleLabel.snp.makeConstraints { make in
+            make.center.equalToSuperview()
+        }
+        
+        return container
+    }
+}

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

@@ -26,6 +26,8 @@ class LNIMChatViewModel: NSObject {
     let userId: String
     @Published
     private(set) var userInfo: LNUserProfileVO?
+    @Published
+    private(set) var remark: String?
     
     // 消息
     private var loading = false
@@ -52,6 +54,7 @@ class LNIMChatViewModel: NSObject {
         
         if !userId.isImOfficialId {
             loadUserInfo()
+            getUserRemark()
             getUnfinishOrder()
         }
         LNEventDeliver.addObserver(self)
@@ -111,6 +114,14 @@ extension LNIMChatViewModel {
             peerSkills = skills
         }
     }
+    
+    private func getUserRemark() {
+        LNIMManager.shared.getUsersRemark(uids: [userId]) { [weak self] remarks in
+            guard let self else { return }
+            guard let remarks, !remarks.isEmpty else { return }
+            remark = remarks.first(where: { $0.userNo == self.userId })?.note
+        }
+    }
 }
 
 // MARK: 消息管理
@@ -122,6 +133,14 @@ extension LNIMChatViewModel {
         }
     }
     
+    func updateRemark(_ remark: String) {
+        LNIMManager.shared.setUserRemark(uid: userId, remark: remark) { [weak self] success in
+            guard let self else { return }
+            guard success else { return }
+            self.remark = remark
+        }
+    }
+    
     private func getMessageOpt() {
         V2TIMManager.sharedInstance().getC2CReceiveMessageOpt(userIDList: [userId]) { [weak self] infos in
             guard let self else { return }

+ 1 - 1
Lanu/Views/IM/Chat/VoiceCall/LNVoiceCallPanel.swift

@@ -498,7 +498,7 @@ private class LNVoiceCallSpeakerSelectPopoverMenu: UIView {
         
         let device = LNIMManager.shared.curCallInfo?.deviceType
         
-        let bluetooth = buildMenuItem(ic: .icCallDeviceBluetooth, title: .init(key: "A00163"), isCheck: device == .bluetooth)
+        let bluetooth = buildMenuItem(ic: .icCallDeviceBluetooth, title: .init(key: "C00008"), isCheck: device == .bluetooth)
         bluetooth.onTap { [weak self] in
             guard let self else { return }
             dismiss()

+ 5 - 4
Lanu/Views/IM/ConversationList/LNIMConversationCell.swift

@@ -62,19 +62,20 @@ class LNIMConversationCell: UITableViewCell {
         }
         curItem = item
         
-        if let userInfo = item.userInfo {
+        titleLabel.text = item.displayName
+        
+        if let userInfo = item.extraInfo?.userInfo {
             avatar.sd_setImage(with: URL(string: userInfo.avatar))
-            titleLabel.text = userInfo.nickname
         } else if let userId = item.userID, !userId.isImOfficialId {
             LNProfileManager.shared.getUserProfile(uid: userId) { [weak self] info in
                 guard let self else { return }
                 guard let info else { return }
-                item.userInfo = info
+                item.extraInfo?.userInfo = info
                 
                 guard info.userNo == curItem?.userID else { return }
                 
                 avatar.sd_setImage(with: URL(string: info.avatar))
-                titleLabel.text = info.nickname
+                titleLabel.text = item.displayName
             }
         }
     }

+ 1 - 1
Lanu/Views/Settings/LNAboutViewController.swift

@@ -53,7 +53,7 @@ extension LNAboutViewController {
         var versionStr: String = .init(key: "A00248", curAppVersion)
         
 #if DEBUG
-        versionStr += "(\(curBuildVersion)"
+        versionStr += "(\(curBuildVersion))"
 #endif
         
         let versionLabel = UILabel()

+ 2 - 0
Podfile

@@ -16,6 +16,8 @@ target 'Gami' do
   pod 'TIMPush'
   
   pod 'DoraemonKit', :configurations => ['Debug']
+  
+  pod 'Adjust'
 
 end
 

+ 11 - 1
Podfile.lock

@@ -1,4 +1,9 @@
 PODS:
+  - Adjust (5.4.5):
+    - Adjust/Adjust (= 5.4.5)
+  - Adjust/Adjust (5.4.5):
+    - AdjustSignature (= 3.47.0)
+  - AdjustSignature (3.47.0)
   - DoraemonKit (3.1.7):
     - DoraemonKit/Core (= 3.1.7)
   - DoraemonKit/Core (3.1.7):
@@ -37,6 +42,7 @@ PODS:
   - TXLiteAVSDK_TRTC/TRTC (12.8.19666)
 
 DEPENDENCIES:
+  - Adjust
   - DoraemonKit
   - TIMCommon (from `./ThirdParty/TUIKit/TIMCommon`)
   - TIMPush
@@ -45,6 +51,8 @@ DEPENDENCIES:
 
 SPEC REPOS:
   https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git:
+    - Adjust
+    - AdjustSignature
     - DoraemonKit
     - FMDB
     - GCDWebServer
@@ -60,6 +68,8 @@ EXTERNAL SOURCES:
     :path: "./ThirdParty/TUIKit/TUIChat"
 
 SPEC CHECKSUMS:
+  Adjust: 010c8b2b582add6ba200469c82c4d8c9e5ddb198
+  AdjustSignature: d634fc6b66295c38807f3b4e50978c1f72355950
   DoraemonKit: 0b45c9dc6ab34bd426a2782ee1bf7ab13492a60b
   FMDB: 728731dd336af3936ce00f91d9d8495f5718a0e6
   GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4
@@ -70,6 +80,6 @@ SPEC CHECKSUMS:
   TXIMSDK_Plus_iOS_XCFramework: 3b435eae84c639f35ae8dc9c8b92c399a8b0a67f
   TXLiteAVSDK_TRTC: b576b0c6a477fa98b5d2b33be63fa9aa7c41f0eb
 
-PODFILE CHECKSUM: 4cc71672f79b31706491fcec8cd887e0a7f87d52
+PODFILE CHECKSUM: 86f86efa83be453f2392b4fadfc195ca9ac216c8
 
 COCOAPODS: 1.16.2