diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 8309ee6..468e269 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -13,6 +13,7 @@ final class NetworkServices { let apiClient: APIClient let authAPI: AuthAPI let profileAPI: ProfileAPI + let statisticsAPI: StatisticsAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -21,6 +22,7 @@ final class NetworkServices { apiClient = client authAPI = AuthAPI(client: client) profileAPI = ProfileAPI(client: client) + statisticsAPI = StatisticsAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -34,6 +36,7 @@ final class NetworkServices { self.apiClient = apiClient authAPI = AuthAPI(client: apiClient) profileAPI = ProfileAPI(client: apiClient) + statisticsAPI = StatisticsAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/Contents.json new file mode 100644 index 0000000..61e3ae7 --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images": [ + { "filename": "icon_data_statistics_average_amount.png", "idiom": "universal", "scale": "1x" }, + { "filename": "icon_data_statistics_average_amount@2x.png", "idiom": "universal", "scale": "2x" }, + { "filename": "icon_data_statistics_average_amount@3x.png", "idiom": "universal", "scale": "3x" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount.png b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount.png new file mode 100644 index 0000000..4438a99 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount@2x.png b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount@2x.png new file mode 100644 index 0000000..4438a99 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount@2x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount@3x.png b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount@3x.png new file mode 100644 index 0000000..4438a99 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/icon_data_statistics_average_amount@3x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/Contents.json new file mode 100644 index 0000000..162838d --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images": [ + { "filename": "icon_data_statistics_get_amount.png", "idiom": "universal", "scale": "1x" }, + { "filename": "icon_data_statistics_get_amount@2x.png", "idiom": "universal", "scale": "2x" }, + { "filename": "icon_data_statistics_get_amount@3x.png", "idiom": "universal", "scale": "3x" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount.png b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount.png new file mode 100644 index 0000000..fac0fda Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount@2x.png b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount@2x.png new file mode 100644 index 0000000..fac0fda Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount@2x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount@3x.png b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount@3x.png new file mode 100644 index 0000000..fac0fda Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/icon_data_statistics_get_amount@3x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/Contents.json new file mode 100644 index 0000000..7ab1007 --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images": [ + { "filename": "icon_data_statistics_refund_amount.png", "idiom": "universal", "scale": "1x" }, + { "filename": "icon_data_statistics_refund_amount@2x.png", "idiom": "universal", "scale": "2x" }, + { "filename": "icon_data_statistics_refund_amount@3x.png", "idiom": "universal", "scale": "3x" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount.png b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount.png new file mode 100644 index 0000000..47b08ca Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount@2x.png b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount@2x.png new file mode 100644 index 0000000..47b08ca Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount@2x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount@3x.png b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount@3x.png new file mode 100644 index 0000000..47b08ca Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/icon_data_statistics_refund_amount@3x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/Contents.json new file mode 100644 index 0000000..4ff8d37 --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images": [ + { "filename": "icon_data_statistics_total_amount.png", "idiom": "universal", "scale": "1x" }, + { "filename": "icon_data_statistics_total_amount@2x.png", "idiom": "universal", "scale": "2x" }, + { "filename": "icon_data_statistics_total_amount@3x.png", "idiom": "universal", "scale": "3x" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount.png b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount.png new file mode 100644 index 0000000..8df2b53 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount@2x.png b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount@2x.png new file mode 100644 index 0000000..8df2b53 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount@2x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount@3x.png b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount@3x.png new file mode 100644 index 0000000..8df2b53 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/icon_data_statistics_total_amount@3x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/Contents.json b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/Contents.json new file mode 100644 index 0000000..e043860 --- /dev/null +++ b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images": [ + { "filename": "icon_data_statistics_total_order.png", "idiom": "universal", "scale": "1x" }, + { "filename": "icon_data_statistics_total_order@2x.png", "idiom": "universal", "scale": "2x" }, + { "filename": "icon_data_statistics_total_order@3x.png", "idiom": "universal", "scale": "3x" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order.png b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order.png new file mode 100644 index 0000000..6ee6008 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order@2x.png b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order@2x.png new file mode 100644 index 0000000..6ee6008 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order@2x.png differ diff --git a/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order@3x.png b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order@3x.png new file mode 100644 index 0000000..6ee6008 Binary files /dev/null and b/suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/icon_data_statistics_total_order@3x.png differ diff --git a/suixinkan/Features/Statistics/API/StatisticsAPI.swift b/suixinkan/Features/Statistics/API/StatisticsAPI.swift new file mode 100644 index 0000000..8bf8ef5 --- /dev/null +++ b/suixinkan/Features/Statistics/API/StatisticsAPI.swift @@ -0,0 +1,141 @@ +// +// StatisticsAPI.swift +// suixinkan +// + +import Foundation + +@MainActor +/// 数据统计 API,封装摄影师/景区管理员/店铺管理员三套统计接口。 +final class StatisticsAPI { + private let client: APIClient + private let pageSize = 15 + + init(client: APIClient) { + self.client = client + } + + /// 摄影师汇总统计。 + func photographerSummary(scenicId: Int, range: String) async throws -> StatisticsSummaryResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/analyse/user", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "range", value: range), + ] + ) + ) + } + + /// 摄影师日明细分页。 + func photographerDaily( + scenicId: Int, + startTime: String, + endTime: String, + page: Int + ) async throws -> ListDataResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/analyse/user/daily", + queryItems: dailyQueryItems( + idName: "scenic_id", + idValue: scenicId, + startTime: startTime, + endTime: endTime, + page: page + ) + ) + ) + } + + /// 景区管理员汇总统计。 + func scenicAdminSummary(scenicId: Int, range: String) async throws -> StatisticsSummaryResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-admin/analyse", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "range", value: range), + ] + ) + ) + } + + /// 景区管理员日明细分页。 + func scenicAdminDaily( + scenicId: Int, + startTime: String, + endTime: String, + page: Int + ) async throws -> ListDataResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-admin/analyse/daily", + queryItems: dailyQueryItems( + idName: "scenic_id", + idValue: scenicId, + startTime: startTime, + endTime: endTime, + page: page + ) + ) + ) + } + + /// 店铺管理员汇总统计。 + func storeSummary(storeId: Int, range: String) async throws -> StatisticsSummaryResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/analyse", + queryItems: [ + URLQueryItem(name: "store_id", value: String(storeId)), + URLQueryItem(name: "range", value: range), + ] + ) + ) + } + + /// 店铺管理员日明细分页。 + func storeDaily( + storeId: Int, + startTime: String, + endTime: String, + page: Int + ) async throws -> ListDataResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/analyse/daily", + queryItems: dailyQueryItems( + idName: "store_id", + idValue: storeId, + startTime: startTime, + endTime: endTime, + page: page + ) + ) + ) + } + + private func dailyQueryItems( + idName: String, + idValue: Int, + startTime: String, + endTime: String, + page: Int + ) -> [URLQueryItem] { + [ + URLQueryItem(name: idName, value: String(idValue)), + URLQueryItem(name: "start_time", value: startTime), + URLQueryItem(name: "end_time", value: endTime), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "paeg_size", value: String(pageSize)), + ] + } +} diff --git a/suixinkan/Features/Statistics/Models/StatisticsModels.swift b/suixinkan/Features/Statistics/Models/StatisticsModels.swift new file mode 100644 index 0000000..497113b --- /dev/null +++ b/suixinkan/Features/Statistics/Models/StatisticsModels.swift @@ -0,0 +1,134 @@ +// +// StatisticsModels.swift +// suixinkan +// + +import Foundation + +/// 数据统计汇总响应,对齐 Android `StatisticsResponse`。 +struct StatisticsSummaryResponse: Decodable, Equatable { + let orderAmountSum: String + let orderCount: Int + let orderPriceAvg: String + let receivedAmountSum: String + let refundAmountSum: String + + enum CodingKeys: String, CodingKey { + case orderAmountSum = "order_amount_sum" + case orderCount = "order_count" + case orderPriceAvg = "order_price_avg" + case receivedAmountSum = "received_amount_sum" + case refundAmountSum = "refund_amount_sum" + } + + init( + orderAmountSum: String = "", + orderCount: Int = 0, + orderPriceAvg: String = "", + receivedAmountSum: String = "", + refundAmountSum: String = "" + ) { + self.orderAmountSum = orderAmountSum + self.orderCount = orderCount + self.orderPriceAvg = orderPriceAvg + self.receivedAmountSum = receivedAmountSum + self.refundAmountSum = refundAmountSum + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderAmountSum = try container.decodeIfPresent(String.self, forKey: .orderAmountSum) ?? "" + orderCount = try container.decodeIfPresent(Int.self, forKey: .orderCount) ?? 0 + orderPriceAvg = try container.decodeIfPresent(String.self, forKey: .orderPriceAvg) ?? "" + receivedAmountSum = try container.decodeIfPresent(String.self, forKey: .receivedAmountSum) ?? "" + refundAmountSum = Self.decodeAmountString(from: container, forKey: .refundAmountSum) + } + + private static func decodeAmountString( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) -> String { + if let value = try? container.decode(String.self, forKey: key) { + return value + } + if let value = try? container.decode(Double.self, forKey: key) { + return String(value) + } + if let value = try? container.decode(Int.self, forKey: key) { + return String(value) + } + return "" + } +} + +/// 数据统计日明细行,对齐 Android `StatisticsPageEntity`。 +struct StatisticsDailyItem: Decodable, Hashable { + let date: String + let orderCount: Int + let orderPrice: String + let refund: String + let received: String + + enum CodingKeys: String, CodingKey { + case date + case orderCount = "order_count" + case orderPrice = "order_price" + case refund + case received + } + + init( + date: String = "", + orderCount: Int = 0, + orderPrice: String = "", + refund: String = "", + received: String = "" + ) { + self.date = date + self.orderCount = orderCount + self.orderPrice = orderPrice + self.refund = refund + self.received = received + } +} + +/// 分页列表响应,对齐 Android `ListDataResponse`。 +struct ListDataResponse: Decodable { + let total: Int + let data: [T] + + init(total: Int = 0, data: [T] = []) { + self.total = total + self.data = data + } +} + +/// 汇总区周期选项,对齐 Android 顶部 chip。 +enum StatisticsSummaryPeriod: String, CaseIterable { + case today = "今日" + case yesterday = "昨日" + case last7Days = "7日" + case thisMonth = "本月" + + /// 汇总 API 的 `range` 参数值。 + var rangeValue: String { + switch self { + case .today: "1" + case .yesterday: "2" + case .last7Days: "3" + case .thisMonth: "4" + } + } + + static func fromDisplayName(_ name: String) -> StatisticsSummaryPeriod? { + StatisticsSummaryPeriod(rawValue: name) + } +} + +/// 列表区日期快捷选项,对齐 Android `DateRangeBottomSheet` 快捷按钮。 +enum StatisticsRangeShortcut: String, CaseIterable { + case today = "今日" + case yesterday = "昨日" + case last7Days = "7日" + case all = "全部" +} diff --git a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift new file mode 100644 index 0000000..644c53e --- /dev/null +++ b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift @@ -0,0 +1,290 @@ +// +// StatisticsViewModel.swift +// suixinkan +// + +import Foundation + +/// 数据 Tab ViewModel,对齐 Android `StatisticsViewModel`。 +final class StatisticsViewModel { + + private(set) var statistics: StatisticsSummaryResponse? + private(set) var selectedPeriod = StatisticsSummaryPeriod.today.rawValue + private(set) var selectedPeriodTime = "" + private(set) var statisticsList: [StatisticsDailyItem] = [] + private(set) var statisticsRangeText = "今日" + private(set) var rangeStartDate: Date? + private(set) var rangeEndDate: Date? + private(set) var isRefreshing = false + private(set) var canLoadMore = false + private(set) var isLoadingSummary = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private var currentPage = 1 + private var totalCount = 0 + private var startTime = "" + private var endTime = "" + + private let apiDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private let uiDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy/MM/dd" + return formatter + }() + + private var rangeMap: [String: String] { + Dictionary(uniqueKeysWithValues: StatisticsSummaryPeriod.allCases.map { ($0.rawValue, $0.rangeValue) }) + } + + /// 首次进入 Tab 时初始化汇总与列表。 + func initStatistics(api: StatisticsAPI) async { + await loadStatistics(StatisticsSummaryPeriod.today.rawValue, api: api) + await updateRangeByShortcut(StatisticsRangeShortcut.today.rawValue, api: api) + } + + /// 切换汇总周期 chip。 + func selectPeriod(_ period: String, api: StatisticsAPI) async { + await loadStatistics(period, api: api) + } + + /// 加载汇总数据。 + func loadStatistics(_ period: String, api: StatisticsAPI) async { + isLoadingSummary = true + selectedPeriod = period + updateSelectedPeriodTime(period) + notifyStateChange() + + let range = rangeMap[period] ?? StatisticsSummaryPeriod.today.rangeValue + defer { + isLoadingSummary = false + notifyStateChange() + } + + do { + statistics = try await fetchSummary(range: range, api: api) + notifyStateChange() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription) + } + } + + /// 下拉刷新列表。 + func refreshStatistics(api: StatisticsAPI) async { + currentPage = 1 + isRefreshing = true + notifyStateChange() + await fetchStatisticsList(api: api) + } + + /// 滚动加载更多。 + func loadMore(api: StatisticsAPI) async { + guard statisticsList.count < totalCount else { return } + currentPage += 1 + await fetchStatisticsList(api: api) + } + + /// 列表区快捷日期。 + func updateRangeByShortcut(_ shortcut: String, api: StatisticsAPI) async { + let today = startOfToday() + + switch shortcut { + case StatisticsRangeShortcut.today.rawValue: + await applyRange(start: today, end: today, label: StatisticsRangeShortcut.today.rawValue, api: api) + case StatisticsRangeShortcut.yesterday.rawValue: + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + await applyRange(start: yesterday, end: yesterday, label: StatisticsRangeShortcut.yesterday.rawValue, api: api) + case StatisticsRangeShortcut.last7Days.rawValue: + let start = calendar.date(byAdding: .day, value: -6, to: today) ?? today + await applyRange(start: start, end: today, label: StatisticsRangeShortcut.last7Days.rawValue, api: api) + case StatisticsRangeShortcut.all.rawValue: + startTime = "" + endTime = "" + rangeStartDate = nil + rangeEndDate = nil + statisticsRangeText = StatisticsRangeShortcut.all.rawValue + notifyStateChange() + await refreshStatistics(api: api) + default: + break + } + } + + /// 日历自定义区间。 + func updateRangeByCalendar(start: Date, end: Date, api: StatisticsAPI) async { + await applyRange(start: startOfDay(start), end: startOfDay(end), label: nil, api: api) + } + + /// 供测试读取当前列表查询起始时间。 + var listStartTime: String { startTime } + + /// 供测试读取当前列表查询结束时间。 + var listEndTime: String { endTime } + + /// 供测试读取当前页码。 + var listCurrentPage: Int { currentPage } + + /// 供测试读取 range 映射。 + func rangeValue(for period: String) -> String { + rangeMap[period] ?? StatisticsSummaryPeriod.today.rangeValue + } + + /// 供测试生成周期日期文案。 + func formattedSelectedPeriodTime(for period: String) -> String { + let previous = selectedPeriod + selectedPeriod = period + updateSelectedPeriodTime(period) + let result = selectedPeriodTime + selectedPeriod = previous + return result + } + + private func fetchStatisticsList(api: StatisticsAPI) async { + defer { + isRefreshing = false + notifyStateChange() + } + + do { + let response = try await fetchDailyList(api: api) + totalCount = response.total + if currentPage == 1 { + statisticsList = response.data + } else { + statisticsList.append(contentsOf: response.data) + } + canLoadMore = statisticsList.count < totalCount + notifyStateChange() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription) + } + } + + private func applyRange(start: Date, end: Date, label: String?, api: StatisticsAPI) async { + startTime = apiDateFormatter.string(from: start) + endTime = apiDateFormatter.string(from: end) + rangeStartDate = start + rangeEndDate = end + statisticsRangeText = label ?? "\(uiDateFormatter.string(from: start))至\(uiDateFormatter.string(from: end))" + notifyStateChange() + await refreshStatistics(api: api) + } + + private func updateSelectedPeriodTime(_ period: String) { + let today = startOfToday() + let (start, end): (Date, Date) + + switch period { + case StatisticsSummaryPeriod.today.rawValue: + (start, end) = (today, today) + case StatisticsSummaryPeriod.yesterday.rawValue: + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + (start, end) = (yesterday, yesterday) + case StatisticsSummaryPeriod.last7Days.rawValue: + let startDate = calendar.date(byAdding: .day, value: -6, to: today) ?? today + (start, end) = (startDate, today) + case StatisticsSummaryPeriod.thisMonth.rawValue: + let components = calendar.dateComponents([.year, .month], from: today) + let firstDay = calendar.date(from: components) ?? today + (start, end) = (firstDay, today) + default: + (start, end) = (today, today) + } + + selectedPeriodTime = "\(uiDateFormatter.string(from: start))至\(uiDateFormatter.string(from: end))" + } + + private func fetchSummary(range: String, api: StatisticsAPI) async throws -> StatisticsSummaryResponse { + switch currentRole { + case .storeAdmin: + let storeId = try resolveStoreId() + return try await api.storeSummary(storeId: storeId, range: range) + case .scenicAdmin: + return try await api.scenicAdminSummary(scenicId: AppStore.shared.currentScenicId, range: range) + default: + return try await api.photographerSummary(scenicId: AppStore.shared.currentScenicId, range: range) + } + } + + private func fetchDailyList(api: StatisticsAPI) async throws -> ListDataResponse { + switch currentRole { + case .storeAdmin: + let storeId = try resolveStoreId() + return try await api.storeDaily( + storeId: storeId, + startTime: startTime, + endTime: endTime, + page: currentPage + ) + case .scenicAdmin: + return try await api.scenicAdminDaily( + scenicId: AppStore.shared.currentScenicId, + startTime: startTime, + endTime: endTime, + page: currentPage + ) + default: + return try await api.photographerDaily( + scenicId: AppStore.shared.currentScenicId, + startTime: startTime, + endTime: endTime, + page: currentPage + ) + } + } + + private var currentRole: AppRoleCode { + AppStore.shared.currentAppRole ?? .photographer + } + + private func resolveStoreId() throws -> Int { + let storeId = AppStore.shared.currentStoreId + guard storeId > 0 else { + throw StatisticsFlowError.missingStore + } + return storeId + } + + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "zh_CN") + calendar.timeZone = TimeZone.current + return calendar + } + + private func startOfToday() -> Date { + startOfDay(Date()) + } + + private func startOfDay(_ date: Date) -> Date { + calendar.startOfDay(for: date) + } + + private func notifyStateChange() { + onStateChange?() + } +} + +enum StatisticsFlowError: LocalizedError { + case missingStore + + var errorDescription: String? { + switch self { + case .missingStore: "请先选择店铺" + } + } +} diff --git a/suixinkan/UI/Statistics/StatisticsDailyCell.swift b/suixinkan/UI/Statistics/StatisticsDailyCell.swift new file mode 100644 index 0000000..fd1baec --- /dev/null +++ b/suixinkan/UI/Statistics/StatisticsDailyCell.swift @@ -0,0 +1,108 @@ +// +// StatisticsDailyCell.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 数据统计日明细行,对齐 Android `DailyDataBox`。 +final class StatisticsDailyCell: UITableViewCell { + + static let reuseIdentifier = "StatisticsDailyCell" + + private let dateLabel = UILabel() + private let orderCountTitle = UILabel() + private let orderCountValue = UILabel() + private let averageTitle = UILabel() + private let averageValue = UILabel() + private let refundTitle = UILabel() + private let refundValue = UILabel() + private let receivedTitle = UILabel() + private let receivedValue = UILabel() + private let divider = UIView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with item: StatisticsDailyItem, showsDivider: Bool) { + dateLabel.text = item.date + orderCountValue.text = "\(item.orderCount)" + averageValue.text = "¥\(item.orderPrice)" + refundValue.text = "¥\(item.refund)" + receivedValue.text = "¥\(item.received)" + divider.isHidden = !showsDivider + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = .clear + + dateLabel.font = .systemFont(ofSize: 14) + dateLabel.textColor = .black + + let metricColor = UIColor(hex: 0x4B5563) + [orderCountTitle, averageTitle, refundTitle, receivedTitle].forEach { + $0.font = .systemFont(ofSize: 14) + $0.textColor = metricColor + } + orderCountTitle.text = "订单数" + averageTitle.text = "客单价" + refundTitle.text = "退款" + receivedTitle.text = "实收" + + [orderCountValue, averageValue].forEach { + $0.font = .systemFont(ofSize: 14, weight: .semibold) + $0.textColor = .black + } + refundValue.font = .systemFont(ofSize: 14, weight: .semibold) + refundValue.textColor = UIColor(hex: 0xEF4444) + receivedValue.font = .systemFont(ofSize: 14, weight: .semibold) + receivedValue.textColor = UIColor(hex: 0x22C55E) + + divider.backgroundColor = UIColor(hex: 0xF4F4F4) + + let metricsRow = UIStackView() + metricsRow.axis = .horizontal + metricsRow.distribution = .fillEqually + metricsRow.alignment = .fill + metricsRow.spacing = 8 + + metricsRow.addArrangedSubview(metricColumn(title: orderCountTitle, value: orderCountValue)) + metricsRow.addArrangedSubview(metricColumn(title: averageTitle, value: averageValue)) + metricsRow.addArrangedSubview(metricColumn(title: refundTitle, value: refundValue)) + metricsRow.addArrangedSubview(metricColumn(title: receivedTitle, value: receivedValue)) + + contentView.addSubview(dateLabel) + contentView.addSubview(metricsRow) + contentView.addSubview(divider) + + dateLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 0, right: 0)) + } + metricsRow.snp.makeConstraints { make in + make.top.equalTo(dateLabel.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().inset(12) + } + divider.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(1) + } + } + + private func metricColumn(title: UILabel, value: UILabel) -> UIStackView { + let stack = UIStackView(arrangedSubviews: [title, value]) + stack.axis = .vertical + stack.spacing = 4 + stack.alignment = .leading + return stack + } +} diff --git a/suixinkan/UI/Statistics/StatisticsDailyListView.swift b/suixinkan/UI/Statistics/StatisticsDailyListView.swift new file mode 100644 index 0000000..b8894e0 --- /dev/null +++ b/suixinkan/UI/Statistics/StatisticsDailyListView.swift @@ -0,0 +1,174 @@ +// +// StatisticsDailyListView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 数据统计日明细列表区域,含时间范围选择与分页列表。 +final class StatisticsDailyListView: UIView { + + var onRangeTap: (() -> Void)? + var onRefresh: (() -> Void)? + var onLoadMore: (() -> Void)? + + private let titleLabel = UILabel() + private let rangeButton = UIControl() + private let rangeLabel = UILabel() + private let arrowView = UIImageView() + private let divider = UIView() + private let tableView = UITableView(frame: .zero, style: .plain) + private let refreshControl = UIRefreshControl() + private let footerIndicator = UIActivityIndicatorView(style: .medium) + + private var items: [StatisticsDailyItem] = [] + private var showsLoadMoreFooter = false + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(rangeText: String, items: [StatisticsDailyItem], isRefreshing: Bool, canLoadMore: Bool) { + rangeLabel.text = rangeText + self.items = items + showsLoadMoreFooter = canLoadMore + tableView.reloadData() + updateFooterVisibility() + if !isRefreshing { + refreshControl.endRefreshing() + } + } + + private func setupUI() { + backgroundColor = .white + layer.cornerRadius = 12 + clipsToBounds = true + + titleLabel.text = "时间范围" + titleLabel.font = .systemFont(ofSize: 14, weight: .bold) + titleLabel.textColor = .black + + rangeButton.backgroundColor = UIColor(hex: 0xF4F4F4) + rangeButton.layer.cornerRadius = 4 + rangeButton.addTarget(self, action: #selector(rangeTapped), for: .touchUpInside) + + rangeLabel.font = .systemFont(ofSize: 14) + rangeLabel.textColor = UIColor(hex: 0x4B5563) + rangeLabel.text = "今日" + + arrowView.image = UIImage(systemName: "chevron.down") + arrowView.tintColor = UIColor(hex: 0x4B5563) + arrowView.contentMode = .scaleAspectFit + + divider.backgroundColor = UIColor(hex: 0xF4F4F4) + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseIdentifier) + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + + footerIndicator.hidesWhenStopped = true + + addSubview(titleLabel) + addSubview(rangeButton) + rangeButton.addSubview(rangeLabel) + rangeButton.addSubview(arrowView) + addSubview(divider) + addSubview(tableView) + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(16) + } + rangeButton.snp.makeConstraints { make in + make.centerY.equalTo(titleLabel) + make.trailing.equalToSuperview().inset(16) + make.height.equalTo(36) + make.width.greaterThanOrEqualTo(166) + } + rangeLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(arrowView.snp.leading).offset(-8) + } + arrowView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + make.size.equalTo(16) + } + divider.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(1) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview().inset(16) + } + } + + @objc private func rangeTapped() { + onRangeTap?() + } + + @objc private func refreshPulled() { + onRefresh?() + } + + private func updateFooterVisibility() { + if showsLoadMoreFooter { + footerIndicator.startAnimating() + tableView.tableFooterView = footerContainer() + } else { + footerIndicator.stopAnimating() + tableView.tableFooterView = UIView(frame: .zero) + } + } + + private func footerContainer() -> UIView { + let container = UIView(frame: CGRect(x: 0, y: 0, width: bounds.width, height: 56)) + container.addSubview(footerIndicator) + footerIndicator.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY) + footerIndicator.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin] + return container + } +} + +extension StatisticsDailyListView: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard + let cell = tableView.dequeueReusableCell( + withIdentifier: StatisticsDailyCell.reuseIdentifier, + for: indexPath + ) as? StatisticsDailyCell + else { + return UITableViewCell() + } + cell.configure(with: items[indexPath.row], showsDivider: indexPath.row < items.count - 1) + return cell + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard showsLoadMoreFooter else { return } + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let frameHeight = scrollView.frame.size.height + guard contentHeight > frameHeight else { return } + if offsetY > contentHeight - frameHeight - 120 { + onLoadMore?() + } + } +} diff --git a/suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift b/suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift new file mode 100644 index 0000000..4441db3 --- /dev/null +++ b/suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift @@ -0,0 +1,303 @@ +// +// StatisticsDateRangeViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 数据统计日期范围选择 Sheet,对齐 Android `DateRangeBottomSheet`。 +final class StatisticsDateRangeViewController: UIViewController { + + var onShortcutSelected: ((String) -> Void)? + var onRangeSelected: ((Date, Date) -> Void)? + + private var currentMonth: Date + private var tempStart: Date? + private var tempEnd: Date? + private var selectedShortcut: String? + + private let shortcutStack = UIStackView() + private let monthTitleLabel = UILabel() + private let weekdayStack = UIStackView() + private let daysCollectionView: UICollectionView + private var shortcutButtons: [StatisticsPeriodButton] = [] + + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "zh_CN") + calendar.firstWeekday = 2 + return calendar + }() + + init(rangeStart: Date?, rangeEnd: Date?) { + self.tempStart = rangeStart + self.tempEnd = rangeEnd + self.currentMonth = rangeStart ?? Date() + + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = 8 + daysCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + + let today = Calendar.current.startOfDay(for: Date()) + let initCalendar = Calendar(identifier: .gregorian) + if rangeStart == nil, rangeEnd == nil { + selectedShortcut = StatisticsRangeShortcut.all.rawValue + } else if rangeStart == today, rangeEnd == today { + selectedShortcut = StatisticsRangeShortcut.today.rawValue + } else if + let start = rangeStart, + let end = rangeEnd, + start == initCalendar.date(byAdding: .day, value: -1, to: today), + end == initCalendar.date(byAdding: .day, value: -1, to: today) { + selectedShortcut = StatisticsRangeShortcut.yesterday.rawValue + } else if + let start = rangeStart, + let end = rangeEnd, + start == initCalendar.date(byAdding: .day, value: -6, to: today), + end == today { + selectedShortcut = StatisticsRangeShortcut.last7Days.rawValue + } + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + setupUI() + updateMonthTitle() + } + + private func setupUI() { + shortcutStack.axis = .horizontal + shortcutStack.distribution = .equalSpacing + + StatisticsRangeShortcut.allCases.forEach { shortcut in + let button = StatisticsPeriodButton() + button.periodTitle = shortcut.rawValue + button.isSelected = shortcut.rawValue == selectedShortcut + button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside) + shortcutButtons.append(button) + shortcutStack.addArrangedSubview(button) + } + + let previousButton = UIButton(type: .system) + previousButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) + previousButton.tintColor = UIColor(hex: 0x9CA3AF) + previousButton.addTarget(self, action: #selector(previousMonth), for: .touchUpInside) + + let nextButton = UIButton(type: .system) + nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) + nextButton.tintColor = UIColor(hex: 0x9CA3AF) + nextButton.addTarget(self, action: #selector(nextMonth), for: .touchUpInside) + + monthTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + monthTitleLabel.textAlignment = .center + + let monthRow = UIStackView(arrangedSubviews: [previousButton, monthTitleLabel, nextButton]) + monthRow.axis = .horizontal + monthRow.alignment = .center + monthRow.distribution = .equalCentering + + weekdayStack.axis = .horizontal + weekdayStack.distribution = .fillEqually + ["一", "二", "三", "四", "五", "六", "日"].forEach { title in + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hex: 0x9CA3AF) + label.textAlignment = .center + weekdayStack.addArrangedSubview(label) + } + + daysCollectionView.backgroundColor = .clear + daysCollectionView.dataSource = self + daysCollectionView.delegate = self + daysCollectionView.register(DateCell.self, forCellWithReuseIdentifier: DateCell.reuseIdentifier) + + view.addSubview(shortcutStack) + view.addSubview(monthRow) + view.addSubview(weekdayStack) + view.addSubview(daysCollectionView) + + shortcutStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + } + monthRow.snp.makeConstraints { make in + make.top.equalTo(shortcutStack.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(32) + } + previousButton.snp.makeConstraints { make in + make.size.equalTo(24) + } + nextButton.snp.makeConstraints { make in + make.size.equalTo(24) + } + weekdayStack.snp.makeConstraints { make in + make.top.equalTo(monthRow.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(16) + } + daysCollectionView.snp.makeConstraints { make in + make.top.equalTo(weekdayStack.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + make.height.equalTo(240) + } + } + + @objc private func shortcutTapped(_ sender: StatisticsPeriodButton) { + onShortcutSelected?(sender.periodTitle) + } + + @objc private func previousMonth() { + currentMonth = calendar.date(byAdding: .month, value: -1, to: currentMonth) ?? currentMonth + updateMonthTitle() + daysCollectionView.reloadData() + } + + @objc private func nextMonth() { + currentMonth = calendar.date(byAdding: .month, value: 1, to: currentMonth) ?? currentMonth + updateMonthTitle() + daysCollectionView.reloadData() + } + + private func updateMonthTitle() { + let components = calendar.dateComponents([.year, .month], from: currentMonth) + monthTitleLabel.text = "\(components.year ?? 0)年\(components.month ?? 0)月" + } + + private func monthDates() -> [Date?] { + guard + let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: currentMonth)), + let dayRange = calendar.range(of: .day, in: .month, for: monthStart) + else { + return [] + } + + let firstWeekday = calendar.component(.weekday, from: monthStart) + let leadingEmpty = (firstWeekday + 5) % 7 + var dates: [Date?] = Array(repeating: nil, count: leadingEmpty) + for day in dayRange { + if let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) { + dates.append(date) + } + } + while dates.count % 7 != 0 { + dates.append(nil) + } + return dates + } + + private func handleDateTap(_ date: Date) { + selectedShortcut = nil + shortcutButtons.forEach { $0.isSelected = false } + + if tempStart == nil || (tempStart != nil && tempEnd != nil) { + tempStart = date + tempEnd = nil + } else if let start = tempStart { + if date < start { + tempEnd = start + tempStart = date + } else { + tempEnd = date + } + if let start = tempStart, let end = tempEnd { + onRangeSelected?(start, end) + } + } + daysCollectionView.reloadData() + } + + private func isInRange(_ date: Date) -> Bool { + guard let start = tempStart else { return false } + if let end = tempEnd { + return date >= min(start, end) && date <= max(start, end) + } + return calendar.isDate(date, inSameDayAs: start) + } + + private final class DateCell: UICollectionViewCell { + static let reuseIdentifier = "StatisticsDateCell" + + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + label.textAlignment = .center + label.font = .systemFont(ofSize: 14) + contentView.addSubview(label) + label.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + contentView.layer.cornerRadius = 16 + contentView.clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(text: String?, selected: Bool, inRange: Bool) { + label.text = text + if selected { + contentView.backgroundColor = AppColor.primary + label.textColor = .white + } else if inRange { + contentView.backgroundColor = UIColor(hex: 0xEFF6FF) + label.textColor = AppColor.primary + } else { + contentView.backgroundColor = .clear + label.textColor = .black + } + } + } +} + +extension StatisticsDateRangeViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + monthDates().count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: DateCell.reuseIdentifier, + for: indexPath + ) as! DateCell + let dates = monthDates() + guard indexPath.item < dates.count, let date = dates[indexPath.item] else { + cell.configure(text: nil, selected: false, inRange: false) + return cell + } + let day = calendar.component(.day, from: date) + let selected = (tempStart != nil && calendar.isDate(date, inSameDayAs: tempStart!)) + || (tempEnd != nil && calendar.isDate(date, inSameDayAs: tempEnd!)) + cell.configure(text: "\(day)", selected: selected, inRange: isInRange(date)) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let dates = monthDates() + guard indexPath.item < dates.count, let date = dates[indexPath.item] else { return } + handleDateTap(date) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let width = collectionView.bounds.width / 7 + return CGSize(width: width, height: 32) + } +} diff --git a/suixinkan/UI/Statistics/StatisticsPeriodButton.swift b/suixinkan/UI/Statistics/StatisticsPeriodButton.swift new file mode 100644 index 0000000..f58225f --- /dev/null +++ b/suixinkan/UI/Statistics/StatisticsPeriodButton.swift @@ -0,0 +1,43 @@ +// +// StatisticsPeriodButton.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 数据统计周期 pill 按钮,对齐 Android `PeriodButton`。 +final class StatisticsPeriodButton: UIButton { + + var periodTitle: String = "" { + didSet { setTitle(periodTitle, for: .normal) } + } + + override var isSelected: Bool { + didSet { updateAppearance() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + layer.cornerRadius = 18 + clipsToBounds = true + contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20) + updateAppearance() + } + + private func updateAppearance() { + backgroundColor = isSelected ? UIColor(hex: 0x3B82F6) : UIColor(hex: 0xF3F4F6) + setTitleColor(isSelected ? .white : UIColor(hex: 0x4B5563), for: .normal) + titleLabel?.font = .systemFont(ofSize: 14, weight: isSelected ? .semibold : .medium) + } +} diff --git a/suixinkan/UI/Statistics/StatisticsStatCardView.swift b/suixinkan/UI/Statistics/StatisticsStatCardView.swift new file mode 100644 index 0000000..710e22c --- /dev/null +++ b/suixinkan/UI/Statistics/StatisticsStatCardView.swift @@ -0,0 +1,67 @@ +// +// StatisticsStatCardView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 数据统计指标卡,对齐 Android `StatCard`。 +final class StatisticsStatCardView: UIView { + + private let titleLabel = UILabel() + private let valueLabel = UILabel() + private let iconView = UIImageView() + + init(title: String, valueColor: UIColor, backgroundColor: UIColor, iconName: String) { + super.init(frame: .zero) + titleLabel.text = title + valueLabel.textColor = valueColor + self.backgroundColor = backgroundColor + iconView.image = UIImage(named: iconName) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(value: String) { + valueLabel.text = value + } + + private func setupUI() { + layer.cornerRadius = 12 + clipsToBounds = true + + titleLabel.font = .systemFont(ofSize: 14) + titleLabel.textColor = UIColor.black.withAlphaComponent(0.6) + + valueLabel.font = .systemFont(ofSize: 18, weight: .bold) + valueLabel.numberOfLines = 1 + valueLabel.adjustsFontSizeToFitWidth = true + valueLabel.minimumScaleFactor = 0.8 + + iconView.contentMode = .scaleAspectFit + + addSubview(titleLabel) + addSubview(valueLabel) + addSubview(iconView) + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(12) + make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8) + } + valueLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(3) + make.leading.bottom.equalToSuperview().inset(12) + make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8) + } + iconView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(8) + make.centerY.equalToSuperview() + make.size.equalTo(48) + } + } +} diff --git a/suixinkan/UI/Statistics/StatisticsSummaryCardView.swift b/suixinkan/UI/Statistics/StatisticsSummaryCardView.swift new file mode 100644 index 0000000..ad13826 --- /dev/null +++ b/suixinkan/UI/Statistics/StatisticsSummaryCardView.swift @@ -0,0 +1,128 @@ +// +// StatisticsSummaryCardView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 数据统计汇总卡片,包含日期行、周期 chip 与 5 张指标卡。 +final class StatisticsSummaryCardView: UIView { + + var onPeriodSelected: ((String) -> Void)? + + private let datePrefixLabel = UILabel() + private let dateValueLabel = UILabel() + private let periodStack = UIStackView() + private var periodButtons: [StatisticsPeriodButton] = [] + + private let totalAmountCard = StatisticsStatCardView( + title: "订单总金额", + valueColor: UIColor(hex: 0x22C55E), + backgroundColor: UIColor(hex: 0xF0FDF4), + iconName: "icon_data_statistics_total_amount" + ) + private let totalOrderCard = StatisticsStatCardView( + title: "订单总数", + valueColor: UIColor(hex: 0x7F00FF), + backgroundColor: UIColor(hex: 0xEBD8FF), + iconName: "icon_data_statistics_total_order" + ) + private let receivedCard = StatisticsStatCardView( + title: "实收金额", + valueColor: UIColor(hex: 0x22C55E), + backgroundColor: UIColor(hex: 0xFFF0E2), + iconName: "icon_data_statistics_get_amount" + ) + private let averageCard = StatisticsStatCardView( + title: "客单价", + valueColor: UIColor(hex: 0x0073FF), + backgroundColor: UIColor(hex: 0xEFF6FF), + iconName: "icon_data_statistics_average_amount" + ) + private let refundCard = StatisticsStatCardView( + title: "退款金额", + valueColor: UIColor(hex: 0xEF4444), + backgroundColor: UIColor(hex: 0xFFE7E7), + iconName: "icon_data_statistics_refund_amount" + ) + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(selectedPeriod: String, selectedPeriodTime: String, summary: StatisticsSummaryResponse?) { + dateValueLabel.text = selectedPeriodTime + periodButtons.forEach { $0.isSelected = $0.periodTitle == selectedPeriod } + + totalAmountCard.configure(value: "¥\(summary?.orderAmountSum ?? "0")") + totalOrderCard.configure(value: "\(summary?.orderCount ?? 0)单") + receivedCard.configure(value: "¥\(summary?.receivedAmountSum ?? "0")") + averageCard.configure(value: "¥\(summary?.orderPriceAvg ?? "0")") + refundCard.configure(value: "¥\(summary?.refundAmountSum ?? "0")") + } + + private func setupUI() { + backgroundColor = .white + layer.cornerRadius = 12 + clipsToBounds = true + + datePrefixLabel.text = "已选日期:" + datePrefixLabel.font = .systemFont(ofSize: 14) + datePrefixLabel.textColor = AppColor.text333 + + dateValueLabel.font = .systemFont(ofSize: 14) + dateValueLabel.textColor = AppColor.primary + dateValueLabel.numberOfLines = 1 + + periodStack.axis = .horizontal + periodStack.distribution = .equalSpacing + periodStack.alignment = .fill + + StatisticsSummaryPeriod.allCases.forEach { period in + let button = StatisticsPeriodButton() + button.periodTitle = period.rawValue + button.addTarget(self, action: #selector(periodTapped(_:)), for: .touchUpInside) + periodButtons.append(button) + periodStack.addArrangedSubview(button) + } + + let row1 = UIStackView(arrangedSubviews: [totalOrderCard, receivedCard]) + row1.axis = .horizontal + row1.spacing = 12 + row1.distribution = .fillEqually + + let row2 = UIStackView(arrangedSubviews: [averageCard, refundCard]) + row2.axis = .horizontal + row2.spacing = 12 + row2.distribution = .fillEqually + + let cardStack = UIStackView(arrangedSubviews: [totalAmountCard, row1, row2]) + cardStack.axis = .vertical + cardStack.spacing = 12 + + let dateRow = UIStackView(arrangedSubviews: [datePrefixLabel, dateValueLabel, UIView()]) + dateRow.axis = .horizontal + dateRow.alignment = .center + dateRow.spacing = 0 + + let contentStack = UIStackView(arrangedSubviews: [dateRow, periodStack, cardStack]) + contentStack.axis = .vertical + contentStack.spacing = 12 + + addSubview(contentStack) + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + } + + @objc private func periodTapped(_ sender: StatisticsPeriodButton) { + onPeriodSelected?(sender.periodTitle) + } +} diff --git a/suixinkan/UI/Statistics/StatisticsViewController.swift b/suixinkan/UI/Statistics/StatisticsViewController.swift index fcf9d49..8016a92 100644 --- a/suixinkan/UI/Statistics/StatisticsViewController.swift +++ b/suixinkan/UI/Statistics/StatisticsViewController.swift @@ -6,23 +6,141 @@ import SnapKit import UIKit -/// 数据 Tab 根页面。 +/// 数据 Tab 根页面,展示汇总指标与日明细列表。 final class StatisticsViewController: BaseViewController { - private let titleLabel = UILabel() + private let viewModel = StatisticsViewModel() + private let statisticsAPI = NetworkServices.shared.statisticsAPI + + private let summaryCardView = StatisticsSummaryCardView() + private let dailyListView = StatisticsDailyListView() + + private var hasInitialized = false + private var isLoadingMore = false + + override func setupNavigationBar() { + title = "数据" + } override func setupUI() { - title = "数据" - titleLabel.text = "数据" - titleLabel.font = .systemFont(ofSize: 24, weight: .medium) - titleLabel.textAlignment = .center - titleLabel.textColor = AppColor.text333 - view.addSubview(titleLabel) + view.backgroundColor = UIColor(hex: 0xF7FAFF) + + view.addSubview(summaryCardView) + view.addSubview(dailyListView) } override func setupConstraints() { - titleLabel.snp.makeConstraints { make in - make.center.equalToSuperview() + summaryCardView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + } + dailyListView.snp.makeConstraints { make in + make.top.equalTo(summaryCardView.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + + summaryCardView.onPeriodSelected = { [weak self] period in + self?.selectPeriod(period) + } + dailyListView.onRangeTap = { [weak self] in + self?.presentDateRangeSheet() + } + dailyListView.onRefresh = { [weak self] in + self?.refreshList() + } + dailyListView.onLoadMore = { [weak self] in + self?.loadMoreIfNeeded() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !hasInitialized else { return } + hasInitialized = true + Task { await initializeStatistics() } + } + + private func applyViewModel() { + summaryCardView.apply( + selectedPeriod: viewModel.selectedPeriod, + selectedPeriodTime: viewModel.selectedPeriodTime, + summary: viewModel.statistics + ) + dailyListView.apply( + rangeText: viewModel.statisticsRangeText, + items: viewModel.statisticsList, + isRefreshing: viewModel.isRefreshing, + canLoadMore: viewModel.canLoadMore + ) + + if viewModel.isLoadingSummary { + showLoading() + } else { + hideLoading() + } + } + + private func initializeStatistics() async { + showLoading() + await viewModel.initStatistics(api: statisticsAPI) + hideLoading() + } + + private func selectPeriod(_ period: String) { + Task { + showLoading() + await viewModel.selectPeriod(period, api: statisticsAPI) + hideLoading() + } + } + + private func refreshList() { + Task { await viewModel.refreshStatistics(api: statisticsAPI) } + } + + private func loadMoreIfNeeded() { + guard viewModel.canLoadMore, !isLoadingMore else { return } + isLoadingMore = true + Task { + await viewModel.loadMore(api: statisticsAPI) + isLoadingMore = false + } + } + + private func presentDateRangeSheet() { + let controller = StatisticsDateRangeViewController( + rangeStart: viewModel.rangeStartDate, + rangeEnd: viewModel.rangeEndDate + ) + controller.onShortcutSelected = { [weak self, weak controller] shortcut in + guard let self else { return } + Task { + await self.viewModel.updateRangeByShortcut(shortcut, api: self.statisticsAPI) + controller?.dismiss(animated: true) + } + } + controller.onRangeSelected = { [weak self, weak controller] start, end in + guard let self else { return } + Task { + await self.viewModel.updateRangeByCalendar(start: start, end: end, api: self.statisticsAPI) + controller?.dismiss(animated: true) + } + } + + if let sheet = controller.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + present(controller, animated: true) + } } diff --git a/suixinkanTests/StatisticsViewModelTests.swift b/suixinkanTests/StatisticsViewModelTests.swift new file mode 100644 index 0000000..3252d1e --- /dev/null +++ b/suixinkanTests/StatisticsViewModelTests.swift @@ -0,0 +1,210 @@ +// +// StatisticsViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 数据统计 ViewModel 测试。 +final class StatisticsViewModelTests: XCTestCase { + + override func tearDown() { + AppStore.shared.roleCode = "" + AppStore.shared.roleName = "" + AppStore.shared.currentScenicId = 0 + AppStore.shared.currentStoreId = 0 + super.tearDown() + } + + func testStatisticsSummaryDecodesRefundAmountAsString() throws { + let json = """ + { + "code": 100000, + "msg": "success", + "data": { + "order_amount_sum": "0.00", + "order_count": 0, + "order_price_avg": "0.00", + "received_amount_sum": "0.00", + "refund_amount_sum": "0.00" + } + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: json) + XCTAssertEqual(envelope.data?.refundAmountSum, "0.00") + XCTAssertEqual(envelope.data?.orderAmountSum, "0.00") + } + + func testRangeValueMapping() { + let viewModel = StatisticsViewModel() + XCTAssertEqual(viewModel.rangeValue(for: "今日"), "1") + XCTAssertEqual(viewModel.rangeValue(for: "昨日"), "2") + XCTAssertEqual(viewModel.rangeValue(for: "7日"), "3") + XCTAssertEqual(viewModel.rangeValue(for: "本月"), "4") + } + + func testFormattedSelectedPeriodTimeForToday() { + let viewModel = StatisticsViewModel() + let text = viewModel.formattedSelectedPeriodTime(for: "今日") + let today = formattedToday() + XCTAssertEqual(text, "\(today)至\(today)") + } + + func testFormattedSelectedPeriodTimeForThisMonth() { + let viewModel = StatisticsViewModel() + let text = viewModel.formattedSelectedPeriodTime(for: "本月") + XCTAssertTrue(text.contains("至")) + XCTAssertTrue(text.hasPrefix(firstDayOfMonth())) + } + + func testUpdateRangeByShortcutAllClearsTimes() async { + let summaryJSON = """ + {"code":100000,"msg":"success","data":{"order_amount_sum":"0","order_count":0,"order_price_avg":"0","received_amount_sum":"0","refund_amount_sum":0}} + """.data(using: .utf8)! + let listJSON = """ + {"code":100000,"msg":"success","data":{"total":0,"data":[]}} + """.data(using: .utf8)! + + AppStore.shared.currentScenicId = 10 + let session = MockURLSession(responses: [listJSON]) + let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = StatisticsViewModel() + + await viewModel.updateRangeByShortcut("全部", api: api) + + XCTAssertEqual(viewModel.statisticsRangeText, "全部") + XCTAssertEqual(viewModel.listStartTime, "") + XCTAssertEqual(viewModel.listEndTime, "") + XCTAssertEqual(session.requests.count, 1) + XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/analyse/user/daily") + let queryItems = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(queryItems.first(where: { $0.name == "start_time" })?.value, "") + XCTAssertEqual(queryItems.first(where: { $0.name == "end_time" })?.value, "") + } + + func testPhotographerSummaryAndDailyRequests() async throws { + let summaryJSON = """ + {"code":100000,"msg":"success","data":{"order_amount_sum":"100","order_count":2,"order_price_avg":"50","received_amount_sum":"90","refund_amount_sum":10}} + """.data(using: .utf8)! + let listJSON = """ + {"code":100000,"msg":"success","data":{"total":1,"data":[{"date":"2025-12-09","order_count":2,"order_price":"50","refund":"10","received":"90"}]}} + """.data(using: .utf8)! + + AppStore.shared.currentScenicId = 10 + let session = MockURLSession(responses: [summaryJSON, listJSON]) + let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = StatisticsViewModel() + + await viewModel.loadStatistics("7日", api: api) + await viewModel.updateRangeByShortcut("今日", api: api) + + XCTAssertEqual(viewModel.statistics?.orderAmountSum, "100") + XCTAssertEqual(viewModel.statisticsList.count, 1) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/analyse/user", + "/api/yf-handset-app/photog/analyse/user/daily", + ]) + XCTAssertTrue(session.requests[0].url?.query?.contains("range=3") == true) + XCTAssertTrue(session.requests[1].url?.query?.contains("paeg_size=15") == true) + XCTAssertTrue(session.requests[1].url?.query?.contains("scenic_id=10") == true) + } + + func testScenicAdminUsesScenicAdminEndpoints() async { + let summaryJSON = """ + {"code":100000,"msg":"success","data":{"order_amount_sum":"0","order_count":0,"order_price_avg":"0","received_amount_sum":"0","refund_amount_sum":0}} + """.data(using: .utf8)! + let listJSON = """ + {"code":100000,"msg":"success","data":{"total":0,"data":[]}} + """.data(using: .utf8)! + + AppStore.shared.roleCode = AppRoleCode.scenicAdmin.rawValue + AppStore.shared.currentScenicId = 20 + let session = MockURLSession(responses: [summaryJSON, listJSON]) + let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = StatisticsViewModel() + + await viewModel.loadStatistics("今日", api: api) + await viewModel.updateRangeByShortcut("今日", api: api) + + XCTAssertEqual(session.requests[0].url?.path, "/api/app/scenic-admin/analyse") + XCTAssertEqual(session.requests[1].url?.path, "/api/app/scenic-admin/analyse/daily") + } + + func testStoreAdminUsesStoreEndpoints() async { + let summaryJSON = """ + {"code":100000,"msg":"success","data":{"order_amount_sum":"0","order_count":0,"order_price_avg":"0","received_amount_sum":"0","refund_amount_sum":0}} + """.data(using: .utf8)! + let listJSON = """ + {"code":100000,"msg":"success","data":{"total":0,"data":[]}} + """.data(using: .utf8)! + + AppStore.shared.roleCode = AppRoleCode.storeAdmin.rawValue + AppStore.shared.currentStoreId = 30 + let session = MockURLSession(responses: [summaryJSON, listJSON]) + let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = StatisticsViewModel() + + await viewModel.loadStatistics("今日", api: api) + await viewModel.updateRangeByShortcut("今日", api: api) + + XCTAssertEqual(session.requests[0].url?.path, "/api/app/store/analyse") + XCTAssertTrue(session.requests[0].url?.query?.contains("store_id=30") == true) + XCTAssertEqual(session.requests[1].url?.path, "/api/app/store/analyse/daily") + } + + func testStoreAdminWithoutStoreIdDoesNotRequest() async { + AppStore.shared.roleCode = AppRoleCode.storeAdmin.rawValue + AppStore.shared.currentStoreId = 0 + let session = MockURLSession(responses: []) + let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = StatisticsViewModel() + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.loadStatistics("今日", api: api) + + XCTAssertEqual(message, "请先选择店铺") + XCTAssertEqual(session.requests.count, 0) + } + + func testLoadMoreStopsWhenReachedTotal() async { + let page1JSON = """ + {"code":100000,"msg":"success","data":{"total":1,"data":[{"date":"2025-12-09","order_count":1,"order_price":"10","refund":"0","received":"10"}]}} + """.data(using: .utf8)! + + AppStore.shared.currentScenicId = 10 + let session = MockURLSession(responses: [page1JSON]) + let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = StatisticsViewModel() + + await viewModel.updateRangeByShortcut("今日", api: api) + XCTAssertEqual(viewModel.statisticsList.count, 1) + XCTAssertFalse(viewModel.canLoadMore) + + await viewModel.loadMore(api: api) + XCTAssertEqual(viewModel.listCurrentPage, 1) + XCTAssertEqual(session.requests.count, 1) + } + + private func formattedToday() -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy/MM/dd" + return formatter.string(from: Date()) + } + + private func firstDayOfMonth() -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy/MM/dd" + var calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents([.year, .month], from: Date()) + let firstDay = calendar.date(from: components) ?? Date() + return formatter.string(from: firstDay) + } +}