Pārlūkot izejas kodu

feat: 补充搜索功能逻辑

陈文艺 2 nedēļas atpakaļ
vecāks
revīzija
a4e5744a7c

+ 7 - 2
Lanu.xcodeproj/project.pbxproj

@@ -357,8 +357,8 @@
 				Views/Room/Bottom/Join/LNRoomJoinMenuView.swift,
 				Views/Room/Bottom/Join/Manage/LNRoomManageSeatCell.swift,
 				Views/Room/Bottom/Join/Manage/LNRoomManageSeatFilterPanel.swift,
-				Views/Room/Bottom/Join/Manage/LNRoomManageSeatListView.swift,
 				Views/Room/Bottom/Join/Manage/LNRoomManageSeatListPanel.swift,
+				Views/Room/Bottom/Join/Manage/LNRoomManageSeatListView.swift,
 				Views/Room/Bottom/Join/Manage/LNRoomManageSeatTabView.swift,
 				Views/Room/Bottom/LNRoomBottomMenuView.swift,
 				Views/Room/Bottom/Mic/LNRoomBottomMicView.swift,
@@ -388,7 +388,12 @@
 				Views/Room/ViewModel/LNRoomSeatItem.swift,
 				Views/Room/ViewModel/LNRoomViewModel.swift,
 				Views/Search/LNUserSearchHistoryView.swift,
-				Views/Search/LNUserSearchItemCell.swift,
+				Views/Search/LNUserSearchOverviewListView.swift,
+				Views/Search/LNUserSearchRoomCardView.swift,
+				Views/Search/LNUserSearchRoomListView.swift,
+				Views/Search/LNUserSearchTabsView.swift,
+				Views/Search/LNUserSearchUserCardView.swift,
+				Views/Search/LNUserSearchUserListView.swift,
 				Views/Search/LNUserSearchViewController.swift,
 				Views/Settings/LNAboutViewController.swift,
 				"Views/Settings/LNCommonAlertView+Account.swift",

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

@@ -8,43 +8,6 @@
 import Foundation
 import Photos
 
-
-extension UIImage {
-    /// 生成线性渐变图片
-    /// - Parameters:
-    ///   - colors: 渐变颜色数组(至少两种)
-    ///   - size: 图片尺寸
-    ///   - startPoint: 渐变起点(坐标系:左上角(0,0),右下角(1,1))
-    ///   - endPoint: 渐变终点
-    /// - Returns: 生成的渐变图片(nil 表示参数无效)
-    static func generateLinearGradientImage(
-        colors: [UIColor],
-        size: CGSize,
-        location: [NSNumber],
-        startPoint: CGPoint = CGPoint(x: 0, y: 0),
-        endPoint: CGPoint = CGPoint(x: 1, y: 0)
-    ) -> UIImage? {
-        // 校验参数有效性
-        guard colors.count >= 2, size.width > 0, size.height > 0 else {
-            return nil
-        }
-        
-        // 创建渐变图层
-        let gradientLayer = CAGradientLayer()
-        gradientLayer.frame = CGRect(origin: .zero, size: size)
-        gradientLayer.colors = colors.map { $0.cgColor } // 转换为 CGColor
-        gradientLayer.locations = location
-        gradientLayer.startPoint = startPoint
-        gradientLayer.endPoint = endPoint
-        
-        // 渲染图层为图片
-        let renderer = UIGraphicsImageRenderer(size: size)
-        return renderer.image { _ in
-            gradientLayer.render(in: UIGraphicsGetCurrentContext()!)
-        }
-    }
-}
-
 enum LNImageCompressType {
     case none
     case avatar

+ 29 - 3
Lanu/Common/Theme/UIImage+Theme.swift

@@ -7,11 +7,37 @@
 
 import Foundation
 
-private var customColorImage: [UIColor: UIImage] = [:]
+private var customColorImage: [String: UIImage] = [:]
 
 extension UIImage {
+    static func generateLinearGradientImage(
+        colors: [UIColor],
+        size: CGSize,
+        location: [NSNumber] = [0, 1],
+        startPoint: CGPoint = CGPoint(x: 0, y: 0),
+        endPoint: CGPoint = CGPoint(x: 1, y: 0)
+    ) -> UIImage? {
+        guard colors.count >= 2, size.width > 0, size.height > 0 else {
+            return nil
+        }
+        // 创建渐变图层
+        let gradientLayer = CAGradientLayer()
+        gradientLayer.frame = CGRect(origin: .zero, size: size)
+        gradientLayer.colors = colors.map { $0.cgColor } // 转换为 CGColor
+        gradientLayer.locations = location
+        gradientLayer.startPoint = startPoint
+        gradientLayer.endPoint = endPoint
+        
+        // 渲染图层为图片
+        let renderer = UIGraphicsImageRenderer(size: size)
+        return renderer.image { _ in
+            gradientLayer.render(in: UIGraphicsGetCurrentContext()!)
+        }
+    }
+    
     static func image(for color: UIColor, size: CGFloat = 10, cornerRadius: CGFloat? = nil) -> UIImage {
-        if let cache = customColorImage[color] {
+        let key = "fill_\(color.hash)_\(size)_\(cornerRadius ?? 0)"
+        if let cache = customColorImage[key] {
             return cache
         }
         let imageSize = CGSize(width: size, height: size)
@@ -29,7 +55,7 @@ extension UIImage {
                 context.fill(CGRect(origin: .zero, size: imageSize))
             }
         }
-        customColorImage[color] = newImage
+        customColorImage[key] = newImage
         return newImage
     }
     

+ 138 - 0
Lanu/Localizable.xcstrings

@@ -8304,6 +8304,144 @@
         }
       }
     },
+    "A00363" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Utuh"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Utuh"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "综合"
+          }
+        }
+      }
+    },
+    "A00364" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "User"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Pengguna"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "用户"
+          }
+        }
+      }
+    },
+    "A00365" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Rooms"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Ruangan"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "房间"
+          }
+        }
+      }
+    },
+    "A00366" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Related Contacts"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Kontak Terkait"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "相关联系人"
+          }
+        }
+      }
+    },
+    "A00367" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Related Rooms"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Ruangan Terkait"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "相关房间"
+          }
+        }
+      }
+    },
+    "A00368" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Popular Rooms"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Ruangan Populer"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "热门房间"
+          }
+        }
+      }
+    },
     "B00001" : {
       "extractionState" : "manual",
       "localizations" : {

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

@@ -140,6 +140,30 @@ extension LNGameMateManager {
             }
         }
     }
+    
+    func mixSearch(keyword: String, queue: DispatchQueue = .main,
+                   handler: @escaping (LNMixSearchResponse?) -> Void) {
+        LNHttpManager.shared.mixSearch(keyword: keyword) { res, err in
+            queue.asyncIfNotGlobal {
+                handler(res)
+            }
+            if let err {
+                showToast(err.errorDesc)
+            }
+        }
+    }
+    
+    func searchRoom(keyword: String, next: String?, queue: DispatchQueue = .main,
+                    handler: @escaping (LNSearchRoomResponse?) -> Void) {
+        LNHttpManager.shared.searchRoom(keyword: keyword, size: 30, next: next ?? "") { res, err in
+            queue.asyncIfNotGlobal {
+                handler(res)
+            }
+            if let err {
+                showToast(err.errorDesc)
+            }
+        }
+    }
 }
 
 // MARK: 陪玩师类别

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

@@ -530,3 +530,22 @@ class LNPotentialUsersResponse: Decodable {
     var list: [LNPotentialUserVO] = []
 }
 
+@AutoCodable
+class LNSearchRoomResultVO: Decodable {
+    var roomId: String = ""
+    var roomTitle: String = ""
+    var roomCover: String = ""
+    var user: LNRoomUserVO = LNRoomUserVO()
+}
+
+@AutoCodable
+class LNSearchRoomResponse: Decodable {
+    var list: [LNSearchRoomResultVO] = []
+    var next: String = ""
+}
+
+@AutoCodable
+class LNMixSearchResponse: Decodable {
+    var playmate: [LNGameMateSearchResultVO] = []
+    var rooms: [LNSearchRoomResultVO] = []
+}

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

@@ -19,6 +19,8 @@ private let kNetPath_GameMate_Score = "/user/playmate/charmStar"
 private let kNetPath_GameMate_Skill_Comment = "/skill/goods/comments"
 
 private let kNetPath_GameMate_Search = "/playmate/search"
+private let kNetPath_GameMate_Mixed_Search = "/search/mixed/list"
+private let kNetPath_GameMate_Room_Search = "/live/room/list"
 
 private let kNetPath_GameMate_Join_Infos = "/playmate/apply/curInfo"
 private let kNetPath_GameMate_Join_Improve_BaseInfo = "/playmate/apply/improve/info"
@@ -185,6 +187,23 @@ extension LNHttpManager {
         ], completion: completion)
     }
     
+    func mixSearch(keyword: String, completion: @escaping (LNMixSearchResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_GameMate_Mixed_Search, params: [
+            "keyword": keyword
+        ], completion: completion)
+    }
+    
+    func searchRoom(keyword: String, size: Int,
+                    next: String, completion: @escaping (LNSearchRoomResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_GameMate_Room_Search, params: [
+            "keyword": keyword,
+            "page": [
+                "size": size,
+                "next": next
+            ]
+        ], completion: completion)
+    }
+    
     func scoreGameMate(uid: String, score: Int, completion: @escaping (LNHttpError?) -> Void) {
         post(path: kNetPath_GameMate_Score, params: [
             "userNo": uid,

+ 2 - 2
Lanu/Manager/Room/Network/LNRoomResponse.swift

@@ -35,7 +35,7 @@ class LNRoomCreateResponse: Decodable {
 }
 
 @AutoCodable
-class LNRoomMicApplyUserVO: Decodable {
+class LNRoomUserVO: Decodable {
     var id: String = ""
     var userNo: String = ""
     var avatar: String = ""
@@ -55,7 +55,7 @@ class LNRoomMicApplyUserVO: Decodable {
 class LNRoomMicApplyPageVO: Decodable {
     var applyId: String = ""
     var applyTime: Int64 = 0
-    var user: LNRoomMicApplyUserVO = LNRoomMicApplyUserVO()
+    var user: LNRoomUserVO = LNRoomUserVO()
     
     var hasAccept = false
     

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

@@ -66,33 +66,3 @@ extension LNSkillTagView {
         }
     }
 }
-
-#if DEBUG
-
-import SwiftUI
-
-struct LNSkillTagViewPreview: UIViewRepresentable {
-    func makeUIView(context: Context) -> some UIView {
-        let container = UIView()
-        container.backgroundColor = .lightGray
-        
-        let view = LNSkillTagView()
-        view.backgroundColor = .orange
-        container.addSubview(view)
-        view.snp.makeConstraints { make in
-            make.leading.trailing.equalToSuperview()
-            make.centerY.equalToSuperview()
-        }
-        view.update(["123", "123", "123", "123", "123", "123", "123", "123", "123", "123", "123", "123", "123", "123", "123", "123", "123"])
-        
-        return container
-    }
-    
-    func updateUIView(_ uiView: UIViewType, context: Context) { }
-}
-
-#Preview(body: {
-    LNSkillTagViewPreview()
-})
-#endif
-

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

@@ -223,6 +223,9 @@ extension LNIMChatViewModel {
         let datas = transUIMsgFromIMMsg(messages: [message])
         guard !datas.isEmpty else { return }
         
+        if allMessage.isEmpty {
+            topMessage = message
+        }
         allMessage.append(contentsOf: datas)
         notifyMessageChanged(index: allMessage.count - 1, type: .insert, toBottom: true)
         

+ 5 - 5
Lanu/Views/Room/Profile/LNRoomProfileBottomMenu.swift

@@ -21,11 +21,11 @@ class LNRoomProfileBottomMenu: UIView {
             if isFollow {
                 follow.setTitle(.init(key: "A00026"), for: .normal)
                 follow.setTitleColor(.text_1.withAlphaComponent(0.2), for: .normal)
-                follow.titleLabel?.font = .body_xl
+                follow.titleLabel?.font = .body_l
             } else {
                 follow.setTitle(.init(key: "A00225"), for: .normal)
                 follow.setTitleColor(.text_1, for: .normal)
-                follow.titleLabel?.font = .body_xl
+                follow.titleLabel?.font = .body_l
             }
         }
     }
@@ -47,7 +47,7 @@ class LNRoomProfileBottomMenu: UIView {
         
         follow.setTitle(.init(key: "A00225"), for: .normal)
         follow.setTitleColor(.text_1, for: .normal)
-        follow.titleLabel?.font = .body_xl
+        follow.titleLabel?.font = .body_l
         follow.addAction(UIAction(handler: { [weak self] _ in
             guard let self else { return }
             guard let curUid else { return }
@@ -62,7 +62,7 @@ class LNRoomProfileBottomMenu: UIView {
         
         chat.setTitle(.init(key: "A00042"), for: .normal)
         chat.setTitleColor(.text_1, for: .normal)
-        chat.titleLabel?.font = .body_xl
+        chat.titleLabel?.font = .body_l
         chat.addAction(UIAction(handler: { [weak self] _ in
             guard let self else { return }
             guard let curUid else { return }
@@ -73,7 +73,7 @@ class LNRoomProfileBottomMenu: UIView {
         
         gift.setTitle(.init(key: "A00336"), for: .normal)
         gift.setTitleColor(.text_6, for: .normal)
-        gift.titleLabel?.font = .body_xl
+        gift.titleLabel?.font = .body_l
         stackView.addArrangedSubview(gift)
         
         LNEventDeliver.addObserver(self)

+ 1 - 0
Lanu/Views/Room/Profile/LNRoomProfileCardPanel.swift

@@ -116,6 +116,7 @@ private extension LNRoomProfileCardPanel {
         reportButton.addAction(UIAction(handler: { [weak self] _ in
             guard let self else { return }
             guard let curDetail else { return }
+            dismiss()
             pushToReport(uid: curDetail.userNo)
         }), for: .touchUpInside)
         container.addSubview(reportButton)

+ 2 - 5
Lanu/Views/Room/Seats/LNRoomSeatViewProtocol.swift

@@ -136,11 +136,8 @@ extension LNRoomSeatViewProtocol {
                 panel.load(seat.uid)
                 panel.popup()
                 return
-            } else if seat.index == .host { // 主持人麦位
-                showToast(.init(key: "123")) // TODO: cwy
-                return
-            } else if seat.index == .guest { // 嘉宾位
-                room.applySeat(index: LNRoomSeatNum.guest.rawValue) { _ in } // 直接申请上麦
+            } else if seat.index == .host || seat.index == .guest { // 主持人 和 嘉宾位
+                room.applySeat(index: seat.index.rawValue) { _ in } // 直接申请上麦
                 return
             } else {
                 sheet.update(title: nil, views: [

+ 11 - 23
Lanu/Views/Room/ViewModel/LNRoomViewModel.swift

@@ -33,15 +33,15 @@ extension LNRoomViewModelNotify {
 enum LNRoomSeatNum: Int, CaseIterable, Comparable {
     case none = -1
     case host = 0
-    case guest = 1
-    case mic1 = 2
-    case mic2 = 3
-    case mic3 = 4
-    case mic4 = 5
-    case mic5 = 6
-    case mic6 = 7
-    case mic7 = 8
-    case mic8 = 9
+    case guest
+    case mic1
+    case mic2
+    case mic3
+    case mic4
+    case mic5
+    case mic6
+    case mic7
+    case mic8
     
     static func < (lhs: LNRoomSeatNum, rhs: LNRoomSeatNum) -> Bool {
         lhs.rawValue < rhs.rawValue
@@ -72,9 +72,6 @@ class LNRoomViewModel: NSObject {
     private(set) var seatApplyCount: Int = 0
     private let maxMessageCount = 300
     
-//    private let messageRefreshInterval: TimeInterval = 1
-//    private var messageRefreshTask: String?
-    
     init(roomId: String) {
         self.roomId = roomId
         
@@ -137,7 +134,8 @@ extension LNRoomViewModel {
     func getApplyList(type: LNRoomApplySeatType, next: String?, filter: String?,
                       handler: @escaping (LNRoomMicApplyListResponse?) -> Void) {
         LNHttpManager.shared.getApplySeatList(roomId: roomId, searchType: type, filter: filter,
-                                              size: 30, next: next ?? "") { res, err in
+                                              size: 30, next: next ?? "")
+        { res, err in
             runOnMain {
                 handler(res)
             }
@@ -383,9 +381,6 @@ extension LNRoomViewModel {
     private func setupMessageObservers() {
         messageStore.state.subscribe().receive(on: DispatchQueue.main).sink { [weak self] state in
             guard let self else { return }
-//            if messageRefreshTask != nil {
-//                return
-//            }
             let lastId = curMessage.last?.id ?? 0
             if let index = state.messageList.lastIndex(where: { $0.sequence == lastId }) {
                 curMessage.append(contentsOf: state.messageList[(index + 1)...].map({
@@ -400,13 +395,6 @@ extension LNRoomViewModel {
                 curMessage.removeFirst(Int(Double(maxMessageCount) * 0.3))
             }
             LNEventDeliver.notifyEvent { ($0 as? LNRoomViewModelNotify)?.onRoomMessageChanged() }
-            
-//            // 限制刷新频率
-//            messageRefreshTask = LNDelayTask.perform(delay: self.messageRefreshInterval, task: { [weak self] in
-//                guard let self else { return }
-//                
-//                messageRefreshTask = nil
-//            })
         }.store(in: &cancellables)
     }
     

+ 60 - 9
Lanu/Views/Search/LNUserSearchHistoryView.swift

@@ -17,7 +17,9 @@ protocol LNUserSearchHistoryViewDelegate: NSObject {
 
 class LNUserSearchHistoryView: UIView {
     private var history: [String] = LNUserDefaults[.userSearchHistory, []]
-    private let stackView = LNAutoFillStackView()
+    private let historyStackView = LNAutoFillStackView()
+    
+    private let roomViews = [LNUserSearchRoomCardView(), LNUserSearchRoomCardView()]
     
     weak var delegate: LNUserSearchHistoryViewDelegate?
     
@@ -79,27 +81,76 @@ extension LNUserSearchHistoryView {
             container.layoutIfNeeded()
             itemViews.append(container)
         }
-        stackView.update(itemViews)
+        historyStackView.update(itemViews)
     }
     
     private func setupViews() {
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        stackView.spacing = 22
+        addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+        
+        stackView.addArrangedSubview(buildHistory())
+//        stackView.addArrangedSubview(buildHotRoom())
+    }
+    
+    private func buildHistory() -> UIView {
+        let container = UIView()
+        
         let titleLabel = UILabel()
         titleLabel.text = .init(key: "A00242")
         titleLabel.font = .heading_h4
         titleLabel.textColor = .text_5
-        addSubview(titleLabel)
+        container.addSubview(titleLabel)
         titleLabel.snp.makeConstraints { make in
-            make.leading.top.equalToSuperview()
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalToSuperview().offset(12)
         }
         
-        stackView.itemSpacing = 9
-        stackView.spacing = 10
-        addSubview(stackView)
-        stackView.snp.makeConstraints { make in
-            make.horizontalEdges.equalToSuperview()
+        historyStackView.itemSpacing = 9
+        historyStackView.spacing = 10
+        container.addSubview(historyStackView)
+        historyStackView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
             make.top.equalTo(titleLabel.snp.bottom).offset(10)
             make.bottom.equalToSuperview()
             make.height.equalTo(0).priority(.low)
         }
+        
+        return container
+    }
+    
+    private func buildHotRoom() -> UIView {
+        let container = UIView()
+        
+        let titleLabel = UILabel()
+        titleLabel.text = .init(key: "A00368")
+        titleLabel.font = .heading_h5
+        titleLabel.textColor = .text_5
+        container.addSubview(titleLabel)
+        titleLabel.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(16)
+            make.top.equalToSuperview()
+        }
+        
+        let stackView = UIStackView()
+        stackView.axis = .horizontal
+        stackView.spacing = 13
+        stackView.distribution = .fillEqually
+        container.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.top.equalTo(titleLabel.snp.bottom).offset(16)
+            make.bottom.equalToSuperview()
+        }
+        
+        for roomView in roomViews {
+            stackView.addArrangedSubview(roomView)
+        }
+        
+        return container
     }
 }

+ 204 - 0
Lanu/Views/Search/LNUserSearchOverviewListView.swift

@@ -0,0 +1,204 @@
+//
+//  LNUserSearchOverviewListView.swift
+//  Lanu
+//
+//  Created by OpenAI Codex on 2026/3/19.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+protocol LNUserSearchOverviewListViewDelegate: NSObject {
+    func onUserSearchOverviewListView(view: LNUserSearchOverviewListView, didClickMore tab: LNUserSearchTab)
+}
+
+
+class LNUserSearchOverviewListView: UIView {
+    private let emptyView = LNNoMoreDataView()
+    private let scrollView = UIScrollView()
+    
+    private let userMaxShowCount = 3
+    private var userViews: [LNUserSearchUserCardView] = []
+    private let userSectionView = UIView()
+    
+    private let roomMaxShowCount = 2
+    private let roomStackView = LNMultiLineStackView()
+    private var roomViews: [LNUserSearchRoomCardView] = []
+    private let roomSectionView = UIView()
+    
+    weak var delegate: LNUserSearchOverviewListViewDelegate?
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func search(_ keyword: String) {
+        userSectionView.isHidden = true
+        roomSectionView.isHidden = true
+        
+        LNGameMateManager.shared.mixSearch(keyword: keyword) { [weak self] res in
+            guard let self else { return }
+            guard let res else { return }
+            if res.playmate.isEmpty, res.rooms.isEmpty {
+                emptyView.showNoData(tips: .init(key: "A00244"))
+                return
+            }
+            emptyView.hide()
+            
+            if res.playmate.isEmpty {
+                userSectionView.isHidden = true
+            } else {
+                userSectionView.isHidden = false
+                for (index, userView) in userViews.enumerated() {
+                    if index < res.playmate.count {
+                        userView.update(res.playmate[index])
+                        userView.isHidden = false
+                    } else {
+                        userView.isHidden = true
+                    }
+                }
+            }
+            
+            if res.rooms.isEmpty {
+                roomSectionView.isHidden = true
+            } else {
+                roomSectionView.isHidden = false
+                var views: [UIView] = []
+                for (index, roomView) in roomViews.enumerated() {
+                    if index < res.rooms.count {
+                        roomView.update(res.rooms[index])
+                        views.append(roomView)
+                    }
+                }
+                roomStackView.update(views)
+            }
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNUserSearchOverviewListView {
+    private func setupViews() {
+        backgroundColor = .white
+        
+        scrollView.showsVerticalScrollIndicator = false
+        addSubview(scrollView)
+        scrollView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+        
+        let stackView = UIStackView()
+        stackView.axis = .vertical
+        scrollView.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+            make.width.equalTo(scrollView)
+        }
+        
+        stackView.addArrangedSubview(buildUserView())
+        stackView.addArrangedSubview(buildRoomView())
+        
+        scrollView.addSubview(emptyView)
+        emptyView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.centerY.equalToSuperview().multipliedBy(0.6)
+        }
+    }
+    
+    private func buildHeader(title: String, action: @escaping () -> Void) -> UIView {
+        let container = UIView()
+        
+        let titleLabel = UILabel()
+        titleLabel.text = title
+        titleLabel.font = .heading_h5
+        titleLabel.textColor = .text_5
+        container.addSubview(titleLabel)
+        titleLabel.snp.makeConstraints { make in
+            make.verticalEdges.equalToSuperview()
+            make.leading.equalToSuperview().offset(16)
+        }
+        
+        let config = UIImage.SymbolConfiguration(pointSize: 9)
+        let button = UIButton(type: .system)
+        button.setTitle(.init(key: "A00048"), for: .normal)
+        button.setTitleColor(.text_4, for: .normal)
+        button.titleLabel?.font = .body_xs
+        button.semanticContentAttribute = .forceRightToLeft
+        button.setImage(UIImage(systemName: "chevron.backward", withConfiguration: config), for: .normal)
+        button.tintColor = .text_4
+        button.imageView?.contentMode = .scaleAspectFit
+        button.addAction(UIAction(handler: { _ in action() }), for: .touchUpInside)
+        container.addSubview(button)
+        button.snp.makeConstraints { make in
+            make.trailing.equalToSuperview().offset(-16)
+            make.centerY.equalToSuperview()
+        }
+        
+        return container
+    }
+    
+    private func buildUserView() -> UIView {
+        let userHeader = buildHeader(title: .init(key: "A00366")) { [weak self] in
+            guard let self else { return }
+            delegate?.onUserSearchOverviewListView(view: self, didClickMore: .user)
+        }
+        userSectionView.addSubview(userHeader)
+        userHeader.snp.makeConstraints { make in
+            make.horizontalEdges.top.equalToSuperview()
+        }
+        
+        let userStackView = UIStackView()
+        userStackView.axis = .vertical
+        userSectionView.addSubview(userStackView)
+        userStackView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalTo(userHeader.snp.bottom).offset(5)
+            make.bottom.equalToSuperview()
+        }
+        
+        for _ in 0..<userMaxShowCount {
+            let userView = LNUserSearchUserCardView()
+            userStackView.addArrangedSubview(userView)
+            userViews.append(userView)
+        }
+        
+        return userSectionView
+    }
+    
+    private func buildRoomView() -> UIView {
+        let roomHeader = buildHeader(title: .init(key: "A00367")) { [weak self] in
+            guard let self else { return }
+            delegate?.onUserSearchOverviewListView(view: self, didClickMore: .rooms)
+        }
+        roomSectionView.addSubview(roomHeader)
+        roomHeader.snp.makeConstraints { make in
+            make.horizontalEdges.top.equalToSuperview()
+        }
+        
+        roomStackView.axis = .horizontal
+        roomStackView.itemSpacing = 13
+        roomStackView.columns = 2
+        roomStackView.itemDistribution = .equalSpacing
+        roomSectionView.addSubview(roomStackView)
+        roomStackView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.bottom.equalToSuperview()
+            make.top.equalTo(roomHeader.snp.bottom).offset(16)
+        }
+        
+        for _ in 0..<roomMaxShowCount {
+            let roomView = LNUserSearchRoomCardView()
+            roomViews.append(roomView)
+        }
+        roomStackView.update(roomViews)
+        
+        return roomSectionView
+    }
+}

+ 122 - 0
Lanu/Views/Search/LNUserSearchRoomCardView.swift

@@ -0,0 +1,122 @@
+//
+//  LNUserSearchRoomCardView.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/3/19.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNUserSearchRoomCardView: UIView {
+    private let cover = UIImageView()
+    private let bottomCover = UIImageView()
+    private let idLabel = UILabel()
+    private let hotIconView = UIImageView()
+    private let hotCountLabel = UILabel()
+    private let titleLabel = UILabel()
+    private let subtitleLabel = UILabel()
+    
+    private var curItem: LNSearchRoomResultVO?
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func update(_ item: LNSearchRoomResultVO) {
+        idLabel.text = "ID \(item.roomId)"
+//        hotCountLabel.text = "\(item.viewers)"
+        cover.sd_setImage(with: URL(string: item.roomCover))
+        titleLabel.text = item.roomTitle
+        subtitleLabel.text = item.user.nickname
+        
+        curItem = item
+    }
+}
+
+extension LNUserSearchRoomCardView {
+    private func setupViews() {
+        backgroundColor = .clear
+        
+        onTap { [weak self] in
+            guard let self, let curItem else { return }
+            pushToRoom(curItem.roomId)
+        }
+        
+        cover.layer.cornerRadius = 12
+        cover.clipsToBounds = true
+        addSubview(cover)
+        cover.snp.makeConstraints { make in
+            make.top.equalToSuperview()
+            make.horizontalEdges.equalToSuperview()
+            make.height.equalTo(cover.snp.width)
+        }
+        
+        bottomCover.image = UIImage.generateLinearGradientImage(
+            colors: [.clear, .black.withAlphaComponent(0.65)],
+            size: .init(width: 10, height: 10),
+            endPoint: .init(x: 0, y: 1)
+        )
+        cover.addSubview(bottomCover)
+        bottomCover.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.bottom.equalToSuperview()
+            make.height.equalTo(30)
+        }
+        
+        idLabel.font = .body_xs
+        idLabel.textColor = .text_1
+        cover.addSubview(idLabel)
+        idLabel.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(8)
+            make.bottom.equalToSuperview().offset(-6)
+        }
+        
+        hotIconView.isHidden = true
+        hotIconView.image = UIImage(systemName: "flame.fill")?.withRenderingMode(.alwaysTemplate)
+        hotIconView.tintColor = .white
+        cover.addSubview(hotIconView)
+        hotIconView.snp.makeConstraints { make in
+            make.trailing.equalToSuperview().offset(-20)
+            make.bottom.equalToSuperview().offset(-8)
+            make.width.height.equalTo(10)
+        }
+        
+        hotCountLabel.isHidden = true
+        hotCountLabel.font = .body_xs
+        hotCountLabel.textColor = .text_1
+        cover.addSubview(hotCountLabel)
+        hotCountLabel.snp.makeConstraints { make in
+            make.leading.equalTo(hotIconView.snp.trailing).offset(2)
+            make.centerY.equalTo(hotIconView)
+            make.trailing.equalToSuperview().offset(-8)
+        }
+        
+        titleLabel.font = .heading_h4
+        titleLabel.textColor = .text_5
+        titleLabel.numberOfLines = 1
+        addSubview(titleLabel)
+        titleLabel.snp.makeConstraints { make in
+            make.leading.trailing.equalToSuperview()
+            make.top.equalTo(cover.snp.bottom).offset(6)
+        }
+        
+        subtitleLabel.font = .body_xs
+        subtitleLabel.textColor = .text_4
+        subtitleLabel.numberOfLines = 1
+        addSubview(subtitleLabel)
+        subtitleLabel.snp.makeConstraints { make in
+            make.leading.trailing.equalToSuperview()
+            make.top.equalTo(titleLabel.snp.bottom).offset(2)
+            make.bottom.equalToSuperview()
+        }
+    }
+}

+ 174 - 0
Lanu/Views/Search/LNUserSearchRoomListView.swift

@@ -0,0 +1,174 @@
+//
+//  LNUserSearchRoomListView.swift
+//  Lanu
+//
+//  Created by OpenAI Codex on 2026/3/19.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+import MJRefresh
+
+
+final class LNUserSearchRoomListView: UIView {
+    private let emptyView = LNNoMoreDataView()
+    private var items: [LNSearchRoomResultVO] = []
+    private var curKeyword: String? = nil
+    private var nextTag: String? = nil
+    private let collectionView: UICollectionView
+    
+    override init(frame: CGRect) {
+        let width = (UIScreen.main.bounds.width - 16 * 2 - 13) * 0.5
+        
+        let layout = UICollectionViewFlowLayout()
+        layout.scrollDirection = .vertical
+        layout.minimumLineSpacing = 16
+        layout.minimumInteritemSpacing = 13
+        layout.itemSize = .init(width: width, height: width * 204 / 165)
+        self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
+        
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func search(keyword: String) {
+        curKeyword = keyword
+        nextTag = nil
+        items.removeAll()
+        collectionView.reloadData()
+        emptyView.hide()
+        collectionView.mj_footer?.resetNoMoreData()
+        collectionView.mj_header?.beginRefreshing()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNUserSearchRoomListView {
+    private func searchRoom() {
+        guard let curKeyword, !curKeyword.isEmpty else {
+            collectionView.mj_header?.endRefreshing()
+            collectionView.mj_footer?.endRefreshingWithNoMoreData()
+            return
+        }
+        
+        collectionView.isHidden = false
+        
+        LNGameMateManager.shared.searchRoom(keyword: curKeyword, next: nextTag) { [weak self] res in
+            guard let self else { return }
+            guard let list = res?.list else {
+                collectionView.mj_header?.endRefreshing()
+                collectionView.mj_footer?.endRefreshingWithNoMoreData()
+                
+                if items.isEmpty {
+                    emptyView.showNetworkError()
+                }
+                return
+            }
+            
+            if nextTag?.isEmpty != false {
+                items = list
+            } else {
+                items.append(contentsOf: list)
+            }
+            nextTag = res?.next
+            
+            collectionView.reloadData()
+            if items.isEmpty {
+                emptyView.showNoData(tips: .init(key: "A00244"))
+            } else {
+                emptyView.hide()
+            }
+            
+            collectionView.mj_header?.endRefreshing()
+            if res?.next.isEmpty != false {
+                collectionView.mj_footer?.endRefreshingWithNoMoreData()
+            } else {
+                collectionView.mj_footer?.endRefreshing()
+            }
+        }
+    }
+}
+
+extension LNUserSearchRoomListView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
+    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+        items.count
+    }
+    
+    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+        let cell = collectionView.dequeueReusableCell(
+            withReuseIdentifier: LNUserSearchRoomCardCell.className,
+            for: indexPath
+        ) as! LNUserSearchRoomCardCell
+        cell.update(items[indexPath.item])
+        return cell
+    }
+}
+
+extension LNUserSearchRoomListView {
+    private func setupViews() {
+        backgroundColor = .white
+        
+        let header = MJRefreshNormalHeader { [weak self] in
+            guard let self else { return }
+            nextTag = nil
+            searchRoom()
+        }
+        header.lastUpdatedTimeLabel?.isHidden = true
+        header.stateLabel?.isHidden = true
+        collectionView.mj_header = header
+        
+        let footer = MJRefreshAutoNormalFooter { [weak self] in
+            guard let self else { return }
+            searchRoom()
+        }
+        footer.setTitle("", for: .noMoreData)
+        footer.setTitle(.init(key: "A00046"), for: .idle)
+        collectionView.mj_footer = footer
+        
+        collectionView.backgroundColor = .clear
+        collectionView.showsVerticalScrollIndicator = false
+        collectionView.showsHorizontalScrollIndicator = false
+        collectionView.allowsSelection = false
+        collectionView.dataSource = self
+        collectionView.delegate = self
+        collectionView.register(LNUserSearchRoomCardCell.self, forCellWithReuseIdentifier: LNUserSearchRoomCardCell.className)
+        collectionView.contentInset = .init(top: 6, left: 0, bottom: -commonBottomInset, right: 0)
+        addSubview(collectionView)
+        collectionView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview().inset(16)
+            make.verticalEdges.equalToSuperview()
+        }
+        
+        collectionView.addSubview(emptyView)
+        emptyView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.centerY.equalToSuperview().multipliedBy(0.6)
+        }
+    }
+}
+
+private final class LNUserSearchRoomCardCell: UICollectionViewCell {
+    private let cardView = LNUserSearchRoomCardView()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        contentView.addSubview(cardView)
+        cardView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func update(_ item: LNSearchRoomResultVO) {
+        cardView.update(item)
+    }
+}

+ 89 - 0
Lanu/Views/Search/LNUserSearchTabsView.swift

@@ -0,0 +1,89 @@
+//
+//  LNUserSearchTabsView.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/3/19.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+
+
+class LNUserSearchTabsView: UIView {
+    private let stackView = UIStackView()
+    private let indicator = UIImageView(image: .primary_7)
+    private var buttons: [UIButton] = []
+    
+    var onSelect: ((LNUserSearchTab) -> Void)?
+    private var selectedTab: LNUserSearchTab = .overview
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        setupViews()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func update(selected: LNUserSearchTab, animated: Bool) {
+        selectedTab = selected
+        
+        for (index, button) in buttons.enumerated() {
+            let isSelected = index == selected.rawValue
+            button.setTitleColor(isSelected ? .text_5 : .text_2, for: .normal)
+            button.titleLabel?.font = isSelected ? .heading_h3 : .heading_h4
+            if isSelected {
+                indicator.snp.remakeConstraints { make in
+                    make.centerX.equalTo(button)
+                    make.top.equalTo(button.titleLabel!.snp.bottom)
+                    make.width.equalTo(23)
+                    make.height.equalTo(2)
+                }
+            }
+        }
+        
+        let updates = { self.layoutIfNeeded() }
+        if animated {
+            UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseInOut]) {
+                updates()
+            }
+        } else {
+            updates()
+        }
+    }
+}
+
+extension LNUserSearchTabsView {
+    private func setupViews() {
+        backgroundColor = .white
+        
+        stackView.axis = .horizontal
+        stackView.spacing = 20
+        stackView.alignment = .center
+        addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.leading.equalToSuperview().offset(16)
+            make.top.equalToSuperview().offset(4)
+            make.bottom.equalToSuperview().offset(-10)
+        }
+        
+        for tab in LNUserSearchTab.allCases {
+            let button = UIButton(type: .system)
+            button.tag = tab.rawValue
+            button.tintColor = .clear
+            button.setTitle(tab.title, for: .normal)
+            button.titleLabel?.font = .heading_h4
+            button.addAction(UIAction(handler: { [weak self] _ in
+                self?.onSelect?(tab)
+            }), for: .touchUpInside)
+            buttons.append(button)
+            stackView.addArrangedSubview(button)
+        }
+        
+        indicator.layer.cornerRadius = 1
+        indicator.clipsToBounds = true
+        addSubview(indicator)
+    }
+}

+ 20 - 17
Lanu/Views/Search/LNUserSearchItemCell.swift → Lanu/Views/Search/LNUserSearchUserCardView.swift

@@ -1,15 +1,16 @@
 //
-//  LNUserSearchItemCell.swift
-//  Lanu
+//  LNUserSearchUserCardView.swift
+//  Gami
 //
-//  Created by OneeChan on 2025/12/12.
+//  Created by OneeChan on 2026/3/19.
 //
 
 import Foundation
 import UIKit
 import SnapKit
 
-class LNUserSearchItemCell: UITableViewCell {
+
+class LNUserSearchUserCardView: UIView {
     private let avatar = UIImageView()
     private let onlineView = LNOnlineView()
     private let nameLabel = UILabel()
@@ -21,8 +22,8 @@ class LNUserSearchItemCell: UITableViewCell {
     
     private var curItem: LNGameMateSearchResultVO?
     
-    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
-        super.init(style: style, reuseIdentifier: reuseIdentifier)
+    override init(frame: CGRect) {
+        super.init(frame: frame)
         
         setupViews()
         LNEventDeliver.addObserver(self)
@@ -48,7 +49,7 @@ class LNUserSearchItemCell: UITableViewCell {
     }
 }
 
-extension LNUserSearchItemCell: LNRelationManagerNotify {
+extension LNUserSearchUserCardView: LNRelationManagerNotify {
     func onUserRelationChanged(uid: String, relation: LNUserRelationShip) {
         guard uid == curItem?.userNo else { return }
         curItem?.follow = relation.contains(.followed)
@@ -56,7 +57,7 @@ extension LNUserSearchItemCell: LNRelationManagerNotify {
     }
 }
 
-extension LNUserSearchItemCell {
+extension LNUserSearchUserCardView {
     private func updateFollowButton() {
         if curItem?.follow == true {
             followButton.isEnabled = false
@@ -76,30 +77,31 @@ extension LNUserSearchItemCell {
     private func setupViews() {
         avatar.layer.cornerRadius = 20
         avatar.clipsToBounds = true
-        contentView.addSubview(avatar)
+        addSubview(avatar)
         avatar.snp.makeConstraints { make in
             make.width.height.equalTo(40)
-            make.leading.equalToSuperview().offset(2)
-            make.verticalEdges.equalToSuperview().inset(10)
+            make.leading.equalToSuperview().offset(18)
+            make.top.equalToSuperview().offset(2)
+            make.bottom.equalToSuperview().offset(-22).priority(.medium)
         }
         
-        contentView.addSubview(onlineView)
+        addSubview(onlineView)
         onlineView.snp.makeConstraints { make in
             make.edges.equalTo(avatar).inset(-2)
         }
         
         let follow = buildFollow()
-        contentView.addSubview(follow)
+        addSubview(follow)
         follow.snp.makeConstraints { make in
-            make.centerY.equalToSuperview()
-            make.trailing.equalToSuperview().offset(-2)
+            make.centerY.equalTo(avatar)
+            make.trailing.equalToSuperview().offset(-16)
         }
         
         let infoView = buildInfoView()
-        contentView.addSubview(infoView)
+        addSubview(infoView)
         infoView.snp.makeConstraints { make in
             make.leading.equalTo(avatar.snp.trailing).offset(12)
-            make.centerY.equalToSuperview()
+            make.centerY.equalTo(avatar)
             make.trailing.equalTo(follow.snp.leading).offset(-12)
         }
         
@@ -198,3 +200,4 @@ extension LNUserSearchItemCell {
         return followButton
     }
 }
+

+ 200 - 0
Lanu/Views/Search/LNUserSearchUserListView.swift

@@ -0,0 +1,200 @@
+//
+//  LNUserSearchUserListView.swift
+//  Lanu
+//
+//  Created by OpenAI Codex on 2026/3/19.
+//
+
+import Foundation
+import UIKit
+import SnapKit
+import MJRefresh
+
+
+final class LNUserSearchUserListView: UIView {
+    private let emptyView = LNNoMoreDataView()
+    private let tableView = UITableView()
+    
+    private var curKeyword: String?
+    private var nextTag: String?
+    private var curList: [LNGameMateSearchResultVO] = []
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        setupViews()
+    }
+    
+    func search(keyword: String) {
+        curKeyword = keyword
+        nextTag = nil
+        curList.removeAll()
+        tableView.reloadData()
+        emptyView.hide()
+        tableView.mj_footer?.resetNoMoreData()
+        tableView.mj_header?.beginRefreshing()
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
+extension LNUserSearchUserListView {
+    private func searchUser() {
+        guard let curKeyword, !curKeyword.isEmpty else {
+            tableView.mj_header?.endRefreshing()
+            tableView.mj_footer?.endRefreshingWithNoMoreData()
+            return
+        }
+        
+        tableView.isHidden = false
+        
+        LNGameMateManager.shared.searchGameMate(keyword: curKeyword, next: nextTag ?? "") { [weak self] list, next in
+            guard let self else { return }
+            guard let list else {
+                tableView.mj_header?.endRefreshing()
+                tableView.mj_footer?.endRefreshingWithNoMoreData()
+                
+                if curList.isEmpty {
+                    emptyView.showNetworkError()
+                }
+                return
+            }
+            
+            if nextTag?.isEmpty != false {
+                curList = list
+            } else {
+                curList.append(contentsOf: list)
+            }
+            nextTag = next
+            
+            tableView.reloadData()
+            if curList.isEmpty {
+                emptyView.showNoData(tips: .init(key: "A00244"))
+            } else {
+                emptyView.hide()
+            }
+            
+            tableView.mj_header?.endRefreshing()
+            if next?.isEmpty != false {
+                tableView.mj_footer?.endRefreshingWithNoMoreData()
+            } else {
+                tableView.mj_footer?.endRefreshing()
+            }
+        }
+    }
+    
+    private func reportExposure() {
+        guard tableView.contentOffset.y >= 0 else { return }
+        guard let indexes = tableView.indexPathsForVisibleRows, !indexes.isEmpty else { return }
+        
+        let items = indexes.map { curList[$0.row] }
+        LNStatisticManager.shared.reportExposure(uids: items.map(\.userNo)) { _ in }
+    }
+}
+
+extension LNUserSearchUserListView: UITableViewDataSource, UITableViewDelegate {
+    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+        curList.count
+    }
+    
+    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+        let cell = tableView.dequeueReusableCell(
+            withIdentifier: LNUserSearchItemCell.className,
+            for: indexPath
+        ) as! LNUserSearchItemCell
+        cell.update(curList[indexPath.row])
+        return cell
+    }
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        if scrollView.isDragging {
+            window?.endEditing(true)
+        }
+    }
+    
+    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
+        reportExposure()
+    }
+    
+    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
+        reportExposure()
+    }
+    
+    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
+        if !decelerate {
+            reportExposure()
+        }
+    }
+}
+
+extension LNUserSearchUserListView {
+    private func setupViews() {
+        backgroundColor = .white
+        
+        let header = MJRefreshNormalHeader { [weak self] in
+            self?.nextTag = nil
+            self?.searchUser()
+        }
+        header.lastUpdatedTimeLabel?.isHidden = true
+        header.stateLabel?.isHidden = true
+        header.endRefreshingCompletionBlock = { [weak self] in
+            self?.reportExposure()
+        }
+        tableView.mj_header = header
+        
+        let footer = MJRefreshAutoNormalFooter { [weak self] in
+            self?.searchUser()
+        }
+        footer.setTitle("", for: .noMoreData)
+        footer.setTitle(.init(key: "A00046"), for: .idle)
+        footer.endRefreshingCompletionBlock = { [weak self] in
+            self?.reportExposure()
+        }
+        tableView.mj_footer = footer
+        
+        tableView.isHidden = true
+        tableView.backgroundColor = .clear
+        tableView.separatorStyle = .none
+        tableView.showsVerticalScrollIndicator = false
+        tableView.showsHorizontalScrollIndicator = false
+        tableView.contentInset = .init(top: 6, left: 0, bottom: -commonBottomInset, right: 0)
+        tableView.dataSource = self
+        tableView.delegate = self
+        tableView.allowsSelection = false
+        tableView.register(LNUserSearchItemCell.self, forCellReuseIdentifier: LNUserSearchItemCell.className)
+        addSubview(tableView)
+        tableView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+        
+        tableView.addSubview(emptyView)
+        emptyView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.centerY.equalToSuperview().multipliedBy(0.6)
+        }
+    }
+}
+
+class LNUserSearchItemCell: UITableViewCell {
+    private let cardView = LNUserSearchUserCardView()
+    
+    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+        super.init(style: style, reuseIdentifier: reuseIdentifier)
+        
+        contentView.addSubview(cardView)
+        cardView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+        }
+        LNEventDeliver.addObserver(self)
+    }
+    
+    func update(_ item: LNGameMateSearchResultVO) {
+        cardView.update(item)
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}

+ 131 - 186
Lanu/Views/Search/LNUserSearchViewController.swift

@@ -8,7 +8,6 @@
 import Foundation
 import UIKit
 import SnapKit
-import MJRefresh
 
 
 extension UIView {
@@ -18,157 +17,123 @@ extension UIView {
     }
 }
 
+enum LNUserSearchTab: Int, CaseIterable {
+    case overview
+    case user
+    case rooms
+    
+    var title: String {
+        switch self {
+        case .overview:
+            .init(key: "A00363")
+        case .user:
+            .init(key: "A00364")
+        case .rooms:
+            .init(key: "A00365")
+        }
+    }
+}
 
 class LNUserSearchViewController: LNViewController {
     private let searchInput = UITextField()
     
     private let historyView = LNUserSearchHistoryView()
     
-    private let emptyView = LNNoMoreDataView()
-    private let tableView = UITableView()
-    
-    private var curKeyword: String?
-    private var nextTag: String?
-    private var curList: [LNGameMateSearchResultVO] = []
+    private let searchView = UIView()
+    private let scrollView = UIScrollView()
+    private let tabsView = LNUserSearchTabsView()
+    private let overviewListView = LNUserSearchOverviewListView()
+    private let userListView = LNUserSearchUserListView()
+    private let roomListView = LNUserSearchRoomListView()
+    private lazy var resultViews = [overviewListView, userListView, roomListView]
     
     override func viewDidLoad() {
         super.viewDidLoad()
         
         setupViews()
+        showLandingState()
     }
 }
 
-extension LNUserSearchViewController {
-    private func searchUser() {
-        guard let curKeyword else { return }
-        view.pushToRoom(curKeyword)
-        
-//        guard let curKeyword, !curKeyword.isEmpty else {
-//            tableView.mj_header?.endRefreshing()
-//            tableView.mj_footer?.endRefreshingWithNoMoreData()
-//            return
-//        }
-//        view.endEditing(true)
-//        
-        historyView.isHidden = true
-        historyView.addRecord(curKeyword)
-//        
-//        tableView.isHidden = false
-//        LNGameMateManager.shared.searchGameMate(keyword: curKeyword, next: nextTag ?? "")
-//        { [weak self] list, next in
-//            guard let self else { return }
-//            guard let list else {
-//                tableView.mj_header?.endRefreshing()
-//                tableView.mj_footer?.endRefreshingWithNoMoreData()
-//                
-//                if curList.isEmpty {
-//                    emptyView.showNetworkError()
-//                }
-//                return
-//            }
-//            if nextTag?.isEmpty != false {
-//                curList = list
-//            } else {
-//                curList.append(contentsOf: list)
-//            }
-//            nextTag = next
-//            
-//            tableView.reloadData()
-//            if curList.isEmpty {
-//                emptyView.showNoData(tips: .init(key: "A00244"))
-//            } else {
-//                emptyView.hide()
-//            }
-//            
-//            self.tableView.mj_header?.endRefreshing()
-//            if next?.isEmpty != false {
-//                tableView.mj_footer?.endRefreshingWithNoMoreData()
-//            } else {
-//                tableView.mj_footer?.endRefreshing()
-//            }
-//        }
-    }
-    
-    func reportExposure() {
-        guard tableView.contentOffset.y >= 0 else {
-            return
-        }
-        
-        guard let indexs = tableView.indexPathsForVisibleRows,
-              !indexs.isEmpty else {
-            return
-        }
-        
-        var items: [LNGameMateSearchResultVO] = []
-        for index in indexs {
-            items.append(curList[index.row])
-        }
-        
-        LNStatisticManager.shared.reportExposure(uids: items.map({ $0.userNo })) { _ in }
-    }
-}
-
-extension LNUserSearchViewController: UITableViewDataSource, UITableViewDelegate {
-    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
-        curList.count
-    }
-    
-    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
-        let cell = tableView.dequeueReusableCell(
-            withIdentifier: LNUserSearchItemCell.className,
-            for: indexPath) as! LNUserSearchItemCell
-        
-        let item = curList[indexPath.row]
-        cell.update(item)
-        
-        return cell
-    }
-    
-    func scrollViewDidScroll(_ scrollView: UIScrollView) {
-        if searchInput.isFirstResponder {
-            view.endEditing(true)
-        }
-    }
-    
+extension LNUserSearchViewController: UIScrollViewDelegate {
     func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
-        reportExposure()
+        syncTabFromPageScroll()
     }
     
     func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
-        reportExposure()
-    }
-    
-    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
-        if !decelerate {
-            reportExposure()
-        }
+        syncTabFromPageScroll()
     }
 }
 
 extension LNUserSearchViewController: LNUserSearchHistoryViewDelegate {
     func onUserSearchHistoryView(view: LNUserSearchHistoryView, didClick history: String) {
         searchInput.text = history
-        curKeyword = history
-        nextTag = nil
-        curList.removeAll()
-        tableView.reloadData()
-        tableView.mj_header?.beginRefreshing()
+        triggerSearchIfNeeded()
     }
 }
 
 extension LNUserSearchViewController: UITextFieldDelegate {
     func textFieldShouldReturn(_ textField: UITextField) -> Bool {
-        textField.resignFirstResponder()
-        guard let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines),
-              !text.isEmpty else { return true }
+        triggerSearchIfNeeded()
+        return true
+    }
+}
+
+extension LNUserSearchViewController: LNUserSearchOverviewListViewDelegate {
+    func onUserSearchOverviewListView(view: LNUserSearchOverviewListView, didClickMore tab: LNUserSearchTab) {
+        selectTab(tab, animated: true)
+    }
+}
+
+extension LNUserSearchViewController {
+    private func triggerSearchIfNeeded() {
+        searchInput.resignFirstResponder()
         
-        curKeyword = text
-        nextTag = nil
-        curList.removeAll()
-        tableView.reloadData()
-        tableView.mj_header?.beginRefreshing()
+        let keyword = searchInput.text?
+            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        if keyword.isEmpty {
+            showLandingState()
+            return
+        }
         
-        return true
+        searchInput.text = keyword
+        historyView.addRecord(keyword)
+        
+        overviewListView.search(keyword)
+        userListView.search(keyword: keyword)
+        roomListView.search(keyword: keyword)
+        
+        searchView.isHidden = false
+        historyView.isHidden = true
+        
+        userListView.search(keyword: keyword)
+        selectTab(.overview, animated: false)
+    }
+    
+    private func showLandingState() {
+        searchView.isHidden = true
+        historyView.isHidden = false
+    }
+    
+    private func selectTab(_ tab: LNUserSearchTab, animated: Bool) {
+        tabsView.update(selected: tab, animated: animated)
+        
+        guard scrollView.bounds.width > 0 else {
+            return
+        }
+        
+        let offsetX: CGFloat = scrollView.bounds.width * CGFloat(tab.rawValue)
+        scrollView.setContentOffset(.init(x: offsetX, y: 0), animated: animated)
+        if !animated {
+            syncTabFromPageScroll()
+        }
+    }
+    
+    private func syncTabFromPageScroll() {
+        guard scrollView.bounds.width > 0 else { return }
+        let page = Int(round(scrollView.contentOffset.x / scrollView.bounds.width))
+        guard let tab = LNUserSearchTab(rawValue: max(0, min(page, LNUserSearchTab.allCases.count - 1))) else { return }
+        tabsView.update(selected: tab, animated: true)
     }
 }
 
@@ -176,24 +141,21 @@ extension LNUserSearchViewController {
     private func setupViews() {
         setupNavBar()
         
-        let history = buildHistory()
-        view.addSubview(history)
-        history.snp.makeConstraints { make in
-            make.horizontalEdges.equalToSuperview().inset(16)
-            make.top.equalToSuperview().offset(12)
+        historyView.delegate = self
+        view.addSubview(historyView)
+        historyView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
         }
         
-        let list = buildList()
-        view.addSubview(list)
-        list.snp.makeConstraints { make in
-            make.horizontalEdges.equalToSuperview().inset(16)
-            make.top.equalToSuperview().offset(12)
-            make.bottom.equalToSuperview()
+        let resultView = buildResultView()
+        view.addSubview(resultView)
+        resultView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
         }
         
         view.onTap { [weak self] in
-            guard let self else { return }
-            view.endEditing(true)
+            self?.view.endEditing(true)
         }
     }
     
@@ -205,8 +167,7 @@ extension LNUserSearchViewController {
         search.setContentHuggingPriority(.defaultHigh, for: .horizontal)
         search.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
         search.addAction(UIAction(handler: { [weak self] _ in
-            guard let self else { return }
-            _ = textFieldShouldReturn(searchInput)
+            self?.triggerSearchIfNeeded()
         }), for: .touchUpInside)
         setRightButton(search)
         
@@ -223,16 +184,17 @@ extension LNUserSearchViewController {
             make.width.equalTo(view.bounds.width).priority(.medium)
         }
         
-        let ic = UIImageView()
-        ic.image = .icMagnifyingglass
-        container.addSubview(ic)
-        ic.snp.makeConstraints { make in
+        let icon = UIImageView()
+        icon.image = .icMagnifyingglass
+        container.addSubview(icon)
+        icon.snp.makeConstraints { make in
             make.leading.equalToSuperview().offset(10)
             make.centerY.equalToSuperview()
             make.width.height.equalTo(18)
         }
         
         searchInput.font = .body_s
+        searchInput.textColor = .text_5
         searchInput.placeholder = .init(key: "A00246")
         searchInput.clearButtonMode = .always
         searchInput.returnKeyType = .search
@@ -242,13 +204,12 @@ extension LNUserSearchViewController {
         searchInput.addAction(UIAction(handler: { [weak self] _ in
             guard let self else { return }
             if searchInput.text?.isEmpty != false {
-                historyView.isHidden = false
-                tableView.isHidden = true
+                showLandingState()
             }
         }), for: .editingChanged)
         container.addSubview(searchInput)
         searchInput.snp.makeConstraints { make in
-            make.leading.equalTo(ic.snp.trailing).offset(8)
+            make.leading.equalTo(icon.snp.trailing).offset(8)
             make.centerY.equalToSuperview()
             make.trailing.equalToSuperview().offset(-10)
         }
@@ -256,57 +217,41 @@ extension LNUserSearchViewController {
         return container
     }
     
-    private func buildList() -> UIView {
-        let header = MJRefreshNormalHeader { [weak self] in
-            guard let self else { return }
-            nextTag = nil
-            searchUser()
+    private func buildResultView() -> UIView {
+        tabsView.onSelect = { [weak self] tab in
+            self?.selectTab(tab, animated: true)
         }
-        header.lastUpdatedTimeLabel?.isHidden = true
-        header.stateLabel?.isHidden = true
-        header.endRefreshingCompletionBlock = { [weak self] in
-            guard let self else { return }
-            reportExposure()
+        searchView.addSubview(tabsView)
+        tabsView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.top.equalToSuperview()
         }
-        tableView.mj_header = header
         
-        let footer = MJRefreshAutoNormalFooter { [weak self] in
-            guard let self else { return }
-            searchUser()
-        }
-        footer.setTitle("", for: .noMoreData)
-        footer.setTitle(.init(key: "A00046"), for: .idle)
-        footer.endRefreshingCompletionBlock = { [weak self] in
-            guard let self else { return }
-            reportExposure()
+        scrollView.isPagingEnabled = true
+        scrollView.delegate = self
+        searchView.addSubview(scrollView)
+        scrollView.snp.makeConstraints { make in
+            make.horizontalEdges.equalToSuperview()
+            make.bottom.equalToSuperview()
+            make.top.equalTo(tabsView.snp.bottom)
         }
         
-        tableView.mj_footer = footer
-        tableView.isHidden = true
-        tableView.dataSource = self
-        tableView.delegate = self
-        tableView.separatorStyle = .none
-        tableView.showsVerticalScrollIndicator = false
-        tableView.showsHorizontalScrollIndicator = false
-        tableView.register(
-            LNUserSearchItemCell.self,
-            forCellReuseIdentifier: LNUserSearchItemCell.className
-        )
-        
-        tableView.addSubview(emptyView)
-        emptyView.snp.makeConstraints { make in
-            make.centerX.equalToSuperview()
-            make.centerY.equalToSuperview().multipliedBy(0.6)
+        let stackView = UIStackView()
+        scrollView.addSubview(stackView)
+        stackView.snp.makeConstraints { make in
+            make.edges.equalToSuperview()
+            make.height.equalToSuperview()
         }
         
-        return tableView
-    }
-    
-    private func buildHistory() -> UIView {
-        historyView.isHidden = false
-        historyView.delegate = self
+        overviewListView.delegate = self
+        for view in resultViews {
+            stackView.addArrangedSubview(view)
+            view.snp.makeConstraints { make in
+                make.width.height.equalTo(scrollView)
+            }
+        }
         
-        return historyView
+        return searchView
     }
 }