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:
@ -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