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,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)),
]
}
}

View 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 = "全部"
}

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