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:
2026-07-06 16:32:14 +08:00
parent 39a9038a3b
commit 6492f80ef0
32 changed files with 1769 additions and 10 deletions

View File

@ -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: "请先选择店铺"
}
}
}