Implement statistics tab aligned with Android and fix summary decode.
Add role-based analyse APIs, summary/daily UI with date range sheet, and flexible parsing for string refund amounts from the backend. Co-authored-by: Cursor <cursoragent@cursor.com>
@ -13,6 +13,7 @@ final class NetworkServices {
|
|||||||
let apiClient: APIClient
|
let apiClient: APIClient
|
||||||
let authAPI: AuthAPI
|
let authAPI: AuthAPI
|
||||||
let profileAPI: ProfileAPI
|
let profileAPI: ProfileAPI
|
||||||
|
let statisticsAPI: StatisticsAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
let ossUploadService: OSSUploadService
|
let ossUploadService: OSSUploadService
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ final class NetworkServices {
|
|||||||
apiClient = client
|
apiClient = client
|
||||||
authAPI = AuthAPI(client: client)
|
authAPI = AuthAPI(client: client)
|
||||||
profileAPI = ProfileAPI(client: client)
|
profileAPI = ProfileAPI(client: client)
|
||||||
|
statisticsAPI = StatisticsAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
client.bindAuthTokenProvider {
|
client.bindAuthTokenProvider {
|
||||||
@ -34,6 +36,7 @@ final class NetworkServices {
|
|||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
authAPI = AuthAPI(client: apiClient)
|
authAPI = AuthAPI(client: apiClient)
|
||||||
profileAPI = ProfileAPI(client: apiClient)
|
profileAPI = ProfileAPI(client: apiClient)
|
||||||
|
statisticsAPI = StatisticsAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
}
|
}
|
||||||
|
|||||||
8
suixinkan/Assets.xcassets/icon_data_statistics_average_amount.imageset/Contents.json
vendored
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
8
suixinkan/Assets.xcassets/icon_data_statistics_get_amount.imageset/Contents.json
vendored
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
8
suixinkan/Assets.xcassets/icon_data_statistics_refund_amount.imageset/Contents.json
vendored
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
8
suixinkan/Assets.xcassets/icon_data_statistics_total_amount.imageset/Contents.json
vendored
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
8
suixinkan/Assets.xcassets/icon_data_statistics_total_order.imageset/Contents.json
vendored
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
141
suixinkan/Features/Statistics/API/StatisticsAPI.swift
Normal file
@ -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<StatisticsDailyItem> {
|
||||||
|
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<StatisticsDailyItem> {
|
||||||
|
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<StatisticsDailyItem> {
|
||||||
|
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)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
134
suixinkan/Features/Statistics/Models/StatisticsModels.swift
Normal file
@ -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<CodingKeys>,
|
||||||
|
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<T: Decodable>: 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 = "全部"
|
||||||
|
}
|
||||||
@ -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<StatisticsDailyItem> {
|
||||||
|
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: "请先选择店铺"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
suixinkan/UI/Statistics/StatisticsDailyCell.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
174
suixinkan/UI/Statistics/StatisticsDailyListView.swift
Normal file
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
303
suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
suixinkan/UI/Statistics/StatisticsPeriodButton.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
suixinkan/UI/Statistics/StatisticsStatCardView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
suixinkan/UI/Statistics/StatisticsSummaryCardView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,23 +6,141 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 数据 Tab 根页面。
|
/// 数据 Tab 根页面,展示汇总指标与日明细列表。
|
||||||
final class StatisticsViewController: BaseViewController {
|
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() {
|
override func setupUI() {
|
||||||
title = "数据"
|
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||||
titleLabel.text = "数据"
|
|
||||||
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
view.addSubview(summaryCardView)
|
||||||
titleLabel.textAlignment = .center
|
view.addSubview(dailyListView)
|
||||||
titleLabel.textColor = AppColor.text333
|
|
||||||
view.addSubview(titleLabel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
titleLabel.snp.makeConstraints { make in
|
summaryCardView.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
210
suixinkanTests/StatisticsViewModelTests.swift
Normal file
@ -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<StatisticsSummaryResponse>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||