将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
290 lines
9.9 KiB
Swift
290 lines
9.9 KiB
Swift
//
|
||
// 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)
|
||
} 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.session.currentScenicId, range: range)
|
||
default:
|
||
return try await api.photographerSummary(scenicId: AppStore.shared.session.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.session.currentScenicId,
|
||
startTime: startTime,
|
||
endTime: endTime,
|
||
page: currentPage
|
||
)
|
||
default:
|
||
return try await api.photographerDaily(
|
||
scenicId: AppStore.shared.session.currentScenicId,
|
||
startTime: startTime,
|
||
endTime: endTime,
|
||
page: currentPage
|
||
)
|
||
}
|
||
}
|
||
|
||
private var currentRole: AppRoleCode {
|
||
AppStore.shared.session.currentAppRole ?? .photographer
|
||
}
|
||
|
||
private func resolveStoreId() throws -> Int {
|
||
let storeId = AppStore.shared.session.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: "请先选择店铺"
|
||
}
|
||
}
|
||
}
|