diff --git a/V2er.xcodeproj/project.pbxproj b/V2er.xcodeproj/project.pbxproj index bfe97a3..362ce41 100644 --- a/V2er.xcodeproj/project.pbxproj +++ b/V2er.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 28B24CA92EA3460D00F82B2A /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CA82EA3460D00F82B2A /* BalanceView.swift */; }; + 28B24CAB2EA3561400F82B2A /* OnlineStatsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */; }; 28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */; }; 4E55BE8A29D45FC00044389C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4E55BE8929D45FC00044389C /* Kingfisher */; }; 4EC32AF029D81863003A3BD4 /* WebView in Frameworks */ = {isa = PBXBuildFile; productRef = 4EC32AEF29D81863003A3BD4 /* WebView */; }; @@ -170,6 +171,7 @@ /* Begin PBXFileReference section */ 28B24CA82EA3460D00F82B2A /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = ""; }; + 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStatsInfo.swift; sourceTree = ""; }; 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = ""; }; 5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = ""; }; 5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = ""; }; @@ -581,6 +583,7 @@ 5DA2AD3A26C17EFE007FB1EF /* Model */ = { isa = PBXGroup; children = ( + 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */, 5D3CD31E26D0F5F600B3C2D3 /* BaseModel.swift */, 5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */, 5D3CD32026D0F9CC00B3C2D3 /* TabInfo.swift */, @@ -887,6 +890,7 @@ 5D3CD32126D0F9CC00B3C2D3 /* TabInfo.swift in Sources */, 5D0A513F26E36F15006F3D9B /* FeedDetailActions.swift in Sources */, 5DA2AD3726C17EB9007FB1EF /* AppState.swift in Sources */, + 28B24CAB2EA3561400F82B2A /* OnlineStatsInfo.swift in Sources */, 5D71DF59247C155400B53ED4 /* MessagePage.swift in Sources */, 5DA2AD4626C18208007FB1EF /* MeState.swift in Sources */, 5D88D5DA26C200FB00302265 /* FeedReducer.swift in Sources */, diff --git a/V2er/State/DataFlow/Model/OnlineStatsInfo.swift b/V2er/State/DataFlow/Model/OnlineStatsInfo.swift new file mode 100644 index 0000000..f1df763 --- /dev/null +++ b/V2er/State/DataFlow/Model/OnlineStatsInfo.swift @@ -0,0 +1,70 @@ +// +// OnlineStatsInfo.swift +// V2er +// +// Created by ghui on 2025/10/18. +// Copyright © 2025 lessmore.io. All rights reserved. +// + +import Foundation +import SwiftSoup + +public struct OnlineStatsInfo: BaseModel, Codable { + var rawData: String? + var onlineCount: Int = 0 + var maxRecord: Int = 0 + + init() {} + + enum CodingKeys: String, CodingKey { + case onlineCount, maxRecord + } + + init?(from html: Element?) { + guard let root = html else { + log("OnlineStatsInfo: root element is nil") + return nil + } + + // Parse from footer HTML + // Structure: ... 2576 人在线   最高记录 6679 + + // Get all text content from the page + let pageText = root.value(.text) + + // Extract online count using simple pattern matching + // Pattern: "数字 人在线" + let onlinePattern = "(\\d+)\\s*人在线" + if let regex = try? NSRegularExpression(pattern: onlinePattern) { + let nsText = pageText as NSString + let matches = regex.matches(in: pageText, range: NSRange(location: 0, length: nsText.length)) + if let match = matches.first, match.numberOfRanges > 1 { + let numberStr = nsText.substring(with: match.range(at: 1)) + onlineCount = Int(numberStr.replacingOccurrences(of: ",", with: "")) ?? 0 + log("OnlineStatsInfo: Found online count = \(onlineCount)") + } + } + + // Extract max record + let maxPattern = "最高记录\\s+(\\d+)" + if let regex = try? NSRegularExpression(pattern: maxPattern) { + let nsText = pageText as NSString + let matches = regex.matches(in: pageText, range: NSRange(location: 0, length: nsText.length)) + if let match = matches.first, match.numberOfRanges > 1 { + let numberStr = nsText.substring(with: match.range(at: 1)) + maxRecord = Int(numberStr.replacingOccurrences(of: ",", with: "")) ?? 0 + log("OnlineStatsInfo: Found max record = \(maxRecord)") + } + } + + // If we didn't find the data, return nil + if onlineCount == 0 { + log("OnlineStatsInfo: Parse failed, onlineCount = 0") + return nil + } + } + + func isValid() -> Bool { + return onlineCount > 0 + } +} diff --git a/V2er/State/DataFlow/Reducers/FeedReducer.swift b/V2er/State/DataFlow/Reducers/FeedReducer.swift index dc66a01..165e4c9 100644 --- a/V2er/State/DataFlow/Reducers/FeedReducer.swift +++ b/V2er/State/DataFlow/Reducers/FeedReducer.swift @@ -58,6 +58,13 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio followingAction = FeedActions.FetchData.Start(isFromFilterChange: true) case let action as FeedActions.ToggleFilterMenu: state.showFilterMenu.toggle() + case let action as FeedActions.FetchOnlineStats.Done: + if case .success(let onlineStats) = action.result { + state.onlineStats = onlineStats + log("FeedReducer: Received online stats: \(String(describing: onlineStats))") + } else if case .failure(let error) = action.result { + log("FeedReducer: Failed to fetch online stats: \(error)") + } default: break } @@ -138,4 +145,21 @@ struct FeedActions { var target: Reducer = reducer } + struct FetchOnlineStats { + struct Start: AwaitAction { + var target: Reducer = reducer + + func execute(in store: Store) async { + let result: APIResult = await APIService.shared + .htmlGet(endpoint: .onlineStats) + dispatch(FetchOnlineStats.Done(result: result)) + } + } + + struct Done: Action { + var target: Reducer = reducer + let result: APIResult + } + } + } diff --git a/V2er/State/DataFlow/State/FeedState.swift b/V2er/State/DataFlow/State/FeedState.swift index d572fd5..0cf1022 100644 --- a/V2er/State/DataFlow/State/FeedState.swift +++ b/V2er/State/DataFlow/State/FeedState.swift @@ -19,4 +19,5 @@ struct FeedState: FluxState { var selectedTab: Tab = Tab.getSelectedTab() var showFilterMenu: Bool = false var scrollToTop: Int = 0 // Trigger scroll to top when changed + var onlineStats: OnlineStatsInfo? = nil } diff --git a/V2er/State/Networking/Endpoint.swift b/V2er/State/Networking/Endpoint.swift index f7e3a51..c110ea5 100644 --- a/V2er/State/Networking/Endpoint.swift +++ b/V2er/State/Networking/Endpoint.swift @@ -32,6 +32,7 @@ enum Endpoint { case starNode(id: String), dailyMission case checkin, downMyTopic(id: String), pinTopic(id: String) case balance + case onlineStats case search case general(url: String) @@ -150,6 +151,9 @@ enum Endpoint { info.path = "/sticky/topic/\(id)" case .balance: info.path = "/balance" + case .onlineStats: + info.path = "/" + info.ua = .web case let .search: info.path = "https://www.sov2ex.com/api/search" case let .general(url): diff --git a/V2er/View/Feed/FeedPage.swift b/V2er/View/Feed/FeedPage.swift index dc3babe..1ef1e3e 100644 --- a/V2er/View/Feed/FeedPage.swift +++ b/V2er/View/Feed/FeedPage.swift @@ -44,8 +44,10 @@ struct FeedPage: BaseHomePageView { } } } - .updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed))) { + .updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed)), onlineStats: state.onlineStats) { if AccountState.hasSignIn() { + // Fetch online stats in parallel with feed data + Task { await run(action: FeedActions.FetchOnlineStats.Start()) } await run(action: FeedActions.FetchData.Start()) } } loadMore: { diff --git a/V2er/View/Widget/Updatable/HeadIndicatorView.swift b/V2er/View/Widget/Updatable/HeadIndicatorView.swift index de86bc1..58b56e8 100644 --- a/V2er/View/Widget/Updatable/HeadIndicatorView.swift +++ b/V2er/View/Widget/Updatable/HeadIndicatorView.swift @@ -13,26 +13,34 @@ struct HeadIndicatorView: View { var scrollY: CGFloat @Binding var progress: CGFloat @Binding var isRefreshing: Bool - + var onlineStats: OnlineStatsInfo? + var offset: CGFloat { return isRefreshing ? (0 - scrollY) : -height } - - init(threshold: CGFloat, progress: Binding, scrollY: CGFloat,isRefreshing: Binding) { + + init(threshold: CGFloat, progress: Binding, scrollY: CGFloat, isRefreshing: Binding, onlineStats: OnlineStatsInfo? = nil) { self.height = threshold self.scrollY = scrollY self._progress = progress self._isRefreshing = isRefreshing + self.onlineStats = onlineStats } - + var body: some View { - Group { + VStack(spacing: 4) { if progress == 1 || isRefreshing { ActivityIndicator() } else { Image(systemName: "arrow.down") .font(.title2.weight(.regular)) } + + if let stats = onlineStats, stats.isValid() { + Text("\(stats.onlineCount) 人在线") + .font(.caption) + .foregroundColor(.secondaryText) + } } .frame(height: height) .offset(y: offset) diff --git a/V2er/View/Widget/Updatable/UpdatableView.swift b/V2er/View/Widget/Updatable/UpdatableView.swift index ff90966..790d040 100644 --- a/V2er/View/Widget/Updatable/UpdatableView.swift +++ b/V2er/View/Widget/Updatable/UpdatableView.swift @@ -30,6 +30,7 @@ struct UpdatableView: View { let damper: Float = 1.2 @State var hapticed = false let state: UpdatableState + let onlineStats: OnlineStatsInfo? private var refreshable: Bool { return onRefresh != nil @@ -43,12 +44,14 @@ struct UpdatableView: View { onLoadMore: LoadMoreAction, onScroll: ScrollAction?, state: UpdatableState, + onlineStats: OnlineStatsInfo? = nil, @ViewBuilder content: () -> Content) { self.onRefresh = onRefresh self.onLoadMore = onLoadMore self.onScroll = onScroll self.content = content() self.state = state + self.onlineStats = onlineStats } var body: some View { @@ -68,7 +71,7 @@ struct UpdatableView: View { } .alignmentGuide(.top, computeValue: { d in (self.isRefreshing ? (-self.threshold + scrollY) : 0.0) }) if refreshable { - HeadIndicatorView(threshold: threshold, progress: $progress, scrollY: scrollY, isRefreshing: $isRefreshing) + HeadIndicatorView(threshold: threshold, progress: $progress, scrollY: scrollY, isRefreshing: $isRefreshing, onlineStats: onlineStats) } } } @@ -197,25 +200,27 @@ extension View { public func updatable(autoRefresh: Bool = false, hasMoreData: Bool = true, _ scrollToTop: Int = 0, + onlineStats: OnlineStatsInfo? = nil, refresh: RefreshAction = nil, loadMore: LoadMoreAction = nil, onScroll: ScrollAction? = nil) -> some View { let state = UpdatableState(hasMoreData: hasMoreData, showLoadingView: autoRefresh, scrollToTop: scrollToTop) - return self.modifier(UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state)) + return self.modifier(UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats)) } public func updatable(_ state: UpdatableState, + onlineStats: OnlineStatsInfo? = nil, refresh: RefreshAction = nil, loadMore: LoadMoreAction = nil, onScroll: ScrollAction? = nil) -> some View { - let modifier = UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state) + let modifier = UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats) return self.modifier(modifier) } public func loadMore(_ state: UpdatableState, _ loadMore: LoadMoreAction = nil, onScroll: ScrollAction? = nil) -> some View { - let modifier = UpdatableModifier(onRefresh: nil, onLoadMore: loadMore, onScroll: onScroll, state: state) + let modifier = UpdatableModifier(onRefresh: nil, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: nil) return self.modifier(modifier) } @@ -223,11 +228,11 @@ extension View { hasMoreData: Bool = true, _ loadMore: LoadMoreAction = nil, onScroll: ScrollAction? = nil) -> some View { - self.updatable(autoRefresh: autoRefresh, hasMoreData: hasMoreData, refresh: nil, loadMore: loadMore, onScroll: onScroll) + self.updatable(autoRefresh: autoRefresh, hasMoreData: hasMoreData, onlineStats: nil, refresh: nil, loadMore: loadMore, onScroll: onScroll) } public func onScroll(onScroll: ScrollAction?) -> some View { - self.updatable(onScroll: onScroll) + self.updatable(onlineStats: nil, onScroll: onScroll) } } @@ -236,10 +241,11 @@ struct UpdatableModifier: ViewModifier { let onLoadMore: LoadMoreAction let onScroll: ScrollAction? let state: UpdatableState - + let onlineStats: OnlineStatsInfo? + func body(content: Content) -> some View { UpdatableView(onRefresh: onRefresh, onLoadMore: onLoadMore, - onScroll: onScroll, state: state) { + onScroll: onScroll, state: state, onlineStats: onlineStats) { content } }