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>
This commit is contained in:
141
suixinkan/Features/Statistics/API/StatisticsAPI.swift
Normal file
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
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: "请先选择店铺"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user