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

@ -13,6 +13,7 @@ final class NetworkServices {
let apiClient: APIClient
let authAPI: AuthAPI
let profileAPI: ProfileAPI
let statisticsAPI: StatisticsAPI
let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService
@ -21,6 +22,7 @@ final class NetworkServices {
apiClient = client
authAPI = AuthAPI(client: client)
profileAPI = ProfileAPI(client: client)
statisticsAPI = StatisticsAPI(client: client)
uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI)
client.bindAuthTokenProvider {
@ -34,6 +36,7 @@ final class NetworkServices {
self.apiClient = apiClient
authAPI = AuthAPI(client: apiClient)
profileAPI = ProfileAPI(client: apiClient)
statisticsAPI = StatisticsAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI)
}

View File

@ -0,0 +1,8 @@
{
"images": [
{ "filename": "icon_data_statistics_average_amount.png", "idiom": "universal", "scale": "1x" },
{ "filename": "icon_data_statistics_average_amount@2x.png", "idiom": "universal", "scale": "2x" },
{ "filename": "icon_data_statistics_average_amount@3x.png", "idiom": "universal", "scale": "3x" }
],
"info": { "author": "xcode", "version": 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,8 @@
{
"images": [
{ "filename": "icon_data_statistics_get_amount.png", "idiom": "universal", "scale": "1x" },
{ "filename": "icon_data_statistics_get_amount@2x.png", "idiom": "universal", "scale": "2x" },
{ "filename": "icon_data_statistics_get_amount@3x.png", "idiom": "universal", "scale": "3x" }
],
"info": { "author": "xcode", "version": 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,8 @@
{
"images": [
{ "filename": "icon_data_statistics_refund_amount.png", "idiom": "universal", "scale": "1x" },
{ "filename": "icon_data_statistics_refund_amount@2x.png", "idiom": "universal", "scale": "2x" },
{ "filename": "icon_data_statistics_refund_amount@3x.png", "idiom": "universal", "scale": "3x" }
],
"info": { "author": "xcode", "version": 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,8 @@
{
"images": [
{ "filename": "icon_data_statistics_total_amount.png", "idiom": "universal", "scale": "1x" },
{ "filename": "icon_data_statistics_total_amount@2x.png", "idiom": "universal", "scale": "2x" },
{ "filename": "icon_data_statistics_total_amount@3x.png", "idiom": "universal", "scale": "3x" }
],
"info": { "author": "xcode", "version": 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,8 @@
{
"images": [
{ "filename": "icon_data_statistics_total_order.png", "idiom": "universal", "scale": "1x" },
{ "filename": "icon_data_statistics_total_order@2x.png", "idiom": "universal", "scale": "2x" },
{ "filename": "icon_data_statistics_total_order@3x.png", "idiom": "universal", "scale": "3x" }
],
"info": { "author": "xcode", "version": 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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

View File

@ -0,0 +1,108 @@
//
// StatisticsDailyCell.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `DailyDataBox`
final class StatisticsDailyCell: UITableViewCell {
static let reuseIdentifier = "StatisticsDailyCell"
private let dateLabel = UILabel()
private let orderCountTitle = UILabel()
private let orderCountValue = UILabel()
private let averageTitle = UILabel()
private let averageValue = UILabel()
private let refundTitle = UILabel()
private let refundValue = UILabel()
private let receivedTitle = UILabel()
private let receivedValue = UILabel()
private let divider = UIView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(with item: StatisticsDailyItem, showsDivider: Bool) {
dateLabel.text = item.date
orderCountValue.text = "\(item.orderCount)"
averageValue.text = "\(item.orderPrice)"
refundValue.text = "\(item.refund)"
receivedValue.text = "\(item.received)"
divider.isHidden = !showsDivider
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .clear
dateLabel.font = .systemFont(ofSize: 14)
dateLabel.textColor = .black
let metricColor = UIColor(hex: 0x4B5563)
[orderCountTitle, averageTitle, refundTitle, receivedTitle].forEach {
$0.font = .systemFont(ofSize: 14)
$0.textColor = metricColor
}
orderCountTitle.text = "订单数"
averageTitle.text = "客单价"
refundTitle.text = "退款"
receivedTitle.text = "实收"
[orderCountValue, averageValue].forEach {
$0.font = .systemFont(ofSize: 14, weight: .semibold)
$0.textColor = .black
}
refundValue.font = .systemFont(ofSize: 14, weight: .semibold)
refundValue.textColor = UIColor(hex: 0xEF4444)
receivedValue.font = .systemFont(ofSize: 14, weight: .semibold)
receivedValue.textColor = UIColor(hex: 0x22C55E)
divider.backgroundColor = UIColor(hex: 0xF4F4F4)
let metricsRow = UIStackView()
metricsRow.axis = .horizontal
metricsRow.distribution = .fillEqually
metricsRow.alignment = .fill
metricsRow.spacing = 8
metricsRow.addArrangedSubview(metricColumn(title: orderCountTitle, value: orderCountValue))
metricsRow.addArrangedSubview(metricColumn(title: averageTitle, value: averageValue))
metricsRow.addArrangedSubview(metricColumn(title: refundTitle, value: refundValue))
metricsRow.addArrangedSubview(metricColumn(title: receivedTitle, value: receivedValue))
contentView.addSubview(dateLabel)
contentView.addSubview(metricsRow)
contentView.addSubview(divider)
dateLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 0, right: 0))
}
metricsRow.snp.makeConstraints { make in
make.top.equalTo(dateLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview().inset(12)
}
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(1)
}
}
private func metricColumn(title: UILabel, value: UILabel) -> UIStackView {
let stack = UIStackView(arrangedSubviews: [title, value])
stack.axis = .vertical
stack.spacing = 4
stack.alignment = .leading
return stack
}
}

View File

@ -0,0 +1,174 @@
//
// StatisticsDailyListView.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class StatisticsDailyListView: UIView {
var onRangeTap: (() -> Void)?
var onRefresh: (() -> Void)?
var onLoadMore: (() -> Void)?
private let titleLabel = UILabel()
private let rangeButton = UIControl()
private let rangeLabel = UILabel()
private let arrowView = UIImageView()
private let divider = UIView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let footerIndicator = UIActivityIndicatorView(style: .medium)
private var items: [StatisticsDailyItem] = []
private var showsLoadMoreFooter = false
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(rangeText: String, items: [StatisticsDailyItem], isRefreshing: Bool, canLoadMore: Bool) {
rangeLabel.text = rangeText
self.items = items
showsLoadMoreFooter = canLoadMore
tableView.reloadData()
updateFooterVisibility()
if !isRefreshing {
refreshControl.endRefreshing()
}
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
titleLabel.text = "时间范围"
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
titleLabel.textColor = .black
rangeButton.backgroundColor = UIColor(hex: 0xF4F4F4)
rangeButton.layer.cornerRadius = 4
rangeButton.addTarget(self, action: #selector(rangeTapped), for: .touchUpInside)
rangeLabel.font = .systemFont(ofSize: 14)
rangeLabel.textColor = UIColor(hex: 0x4B5563)
rangeLabel.text = "今日"
arrowView.image = UIImage(systemName: "chevron.down")
arrowView.tintColor = UIColor(hex: 0x4B5563)
arrowView.contentMode = .scaleAspectFit
divider.backgroundColor = UIColor(hex: 0xF4F4F4)
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.dataSource = self
tableView.delegate = self
tableView.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseIdentifier)
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
footerIndicator.hidesWhenStopped = true
addSubview(titleLabel)
addSubview(rangeButton)
rangeButton.addSubview(rangeLabel)
rangeButton.addSubview(arrowView)
addSubview(divider)
addSubview(tableView)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(16)
}
rangeButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(36)
make.width.greaterThanOrEqualTo(166)
}
rangeLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(arrowView.snp.leading).offset(-8)
}
arrowView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(16)
}
divider.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(1)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
}
@objc private func rangeTapped() {
onRangeTap?()
}
@objc private func refreshPulled() {
onRefresh?()
}
private func updateFooterVisibility() {
if showsLoadMoreFooter {
footerIndicator.startAnimating()
tableView.tableFooterView = footerContainer()
} else {
footerIndicator.stopAnimating()
tableView.tableFooterView = UIView(frame: .zero)
}
}
private func footerContainer() -> UIView {
let container = UIView(frame: CGRect(x: 0, y: 0, width: bounds.width, height: 56))
container.addSubview(footerIndicator)
footerIndicator.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY)
footerIndicator.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin]
return container
}
}
extension StatisticsDailyListView: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard
let cell = tableView.dequeueReusableCell(
withIdentifier: StatisticsDailyCell.reuseIdentifier,
for: indexPath
) as? StatisticsDailyCell
else {
return UITableViewCell()
}
cell.configure(with: items[indexPath.row], showsDivider: indexPath.row < items.count - 1)
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard showsLoadMoreFooter else { return }
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let frameHeight = scrollView.frame.size.height
guard contentHeight > frameHeight else { return }
if offsetY > contentHeight - frameHeight - 120 {
onLoadMore?()
}
}
}

View File

@ -0,0 +1,303 @@
//
// StatisticsDateRangeViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Sheet Android `DateRangeBottomSheet`
final class StatisticsDateRangeViewController: UIViewController {
var onShortcutSelected: ((String) -> Void)?
var onRangeSelected: ((Date, Date) -> Void)?
private var currentMonth: Date
private var tempStart: Date?
private var tempEnd: Date?
private var selectedShortcut: String?
private let shortcutStack = UIStackView()
private let monthTitleLabel = UILabel()
private let weekdayStack = UIStackView()
private let daysCollectionView: UICollectionView
private var shortcutButtons: [StatisticsPeriodButton] = []
private let calendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "zh_CN")
calendar.firstWeekday = 2
return calendar
}()
init(rangeStart: Date?, rangeEnd: Date?) {
self.tempStart = rangeStart
self.tempEnd = rangeEnd
self.currentMonth = rangeStart ?? Date()
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 8
daysCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
let today = Calendar.current.startOfDay(for: Date())
let initCalendar = Calendar(identifier: .gregorian)
if rangeStart == nil, rangeEnd == nil {
selectedShortcut = StatisticsRangeShortcut.all.rawValue
} else if rangeStart == today, rangeEnd == today {
selectedShortcut = StatisticsRangeShortcut.today.rawValue
} else if
let start = rangeStart,
let end = rangeEnd,
start == initCalendar.date(byAdding: .day, value: -1, to: today),
end == initCalendar.date(byAdding: .day, value: -1, to: today) {
selectedShortcut = StatisticsRangeShortcut.yesterday.rawValue
} else if
let start = rangeStart,
let end = rangeEnd,
start == initCalendar.date(byAdding: .day, value: -6, to: today),
end == today {
selectedShortcut = StatisticsRangeShortcut.last7Days.rawValue
}
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setupUI()
updateMonthTitle()
}
private func setupUI() {
shortcutStack.axis = .horizontal
shortcutStack.distribution = .equalSpacing
StatisticsRangeShortcut.allCases.forEach { shortcut in
let button = StatisticsPeriodButton()
button.periodTitle = shortcut.rawValue
button.isSelected = shortcut.rawValue == selectedShortcut
button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside)
shortcutButtons.append(button)
shortcutStack.addArrangedSubview(button)
}
let previousButton = UIButton(type: .system)
previousButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
previousButton.tintColor = UIColor(hex: 0x9CA3AF)
previousButton.addTarget(self, action: #selector(previousMonth), for: .touchUpInside)
let nextButton = UIButton(type: .system)
nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal)
nextButton.tintColor = UIColor(hex: 0x9CA3AF)
nextButton.addTarget(self, action: #selector(nextMonth), for: .touchUpInside)
monthTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
monthTitleLabel.textAlignment = .center
let monthRow = UIStackView(arrangedSubviews: [previousButton, monthTitleLabel, nextButton])
monthRow.axis = .horizontal
monthRow.alignment = .center
monthRow.distribution = .equalCentering
weekdayStack.axis = .horizontal
weekdayStack.distribution = .fillEqually
["", "", "", "", "", "", ""].forEach { title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hex: 0x9CA3AF)
label.textAlignment = .center
weekdayStack.addArrangedSubview(label)
}
daysCollectionView.backgroundColor = .clear
daysCollectionView.dataSource = self
daysCollectionView.delegate = self
daysCollectionView.register(DateCell.self, forCellWithReuseIdentifier: DateCell.reuseIdentifier)
view.addSubview(shortcutStack)
view.addSubview(monthRow)
view.addSubview(weekdayStack)
view.addSubview(daysCollectionView)
shortcutStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
}
monthRow.snp.makeConstraints { make in
make.top.equalTo(shortcutStack.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(32)
}
previousButton.snp.makeConstraints { make in
make.size.equalTo(24)
}
nextButton.snp.makeConstraints { make in
make.size.equalTo(24)
}
weekdayStack.snp.makeConstraints { make in
make.top.equalTo(monthRow.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
daysCollectionView.snp.makeConstraints { make in
make.top.equalTo(weekdayStack.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
make.height.equalTo(240)
}
}
@objc private func shortcutTapped(_ sender: StatisticsPeriodButton) {
onShortcutSelected?(sender.periodTitle)
}
@objc private func previousMonth() {
currentMonth = calendar.date(byAdding: .month, value: -1, to: currentMonth) ?? currentMonth
updateMonthTitle()
daysCollectionView.reloadData()
}
@objc private func nextMonth() {
currentMonth = calendar.date(byAdding: .month, value: 1, to: currentMonth) ?? currentMonth
updateMonthTitle()
daysCollectionView.reloadData()
}
private func updateMonthTitle() {
let components = calendar.dateComponents([.year, .month], from: currentMonth)
monthTitleLabel.text = "\(components.year ?? 0)\(components.month ?? 0)"
}
private func monthDates() -> [Date?] {
guard
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: currentMonth)),
let dayRange = calendar.range(of: .day, in: .month, for: monthStart)
else {
return []
}
let firstWeekday = calendar.component(.weekday, from: monthStart)
let leadingEmpty = (firstWeekday + 5) % 7
var dates: [Date?] = Array(repeating: nil, count: leadingEmpty)
for day in dayRange {
if let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) {
dates.append(date)
}
}
while dates.count % 7 != 0 {
dates.append(nil)
}
return dates
}
private func handleDateTap(_ date: Date) {
selectedShortcut = nil
shortcutButtons.forEach { $0.isSelected = false }
if tempStart == nil || (tempStart != nil && tempEnd != nil) {
tempStart = date
tempEnd = nil
} else if let start = tempStart {
if date < start {
tempEnd = start
tempStart = date
} else {
tempEnd = date
}
if let start = tempStart, let end = tempEnd {
onRangeSelected?(start, end)
}
}
daysCollectionView.reloadData()
}
private func isInRange(_ date: Date) -> Bool {
guard let start = tempStart else { return false }
if let end = tempEnd {
return date >= min(start, end) && date <= max(start, end)
}
return calendar.isDate(date, inSameDayAs: start)
}
private final class DateCell: UICollectionViewCell {
static let reuseIdentifier = "StatisticsDateCell"
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.textAlignment = .center
label.font = .systemFont(ofSize: 14)
contentView.addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentView.layer.cornerRadius = 16
contentView.clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(text: String?, selected: Bool, inRange: Bool) {
label.text = text
if selected {
contentView.backgroundColor = AppColor.primary
label.textColor = .white
} else if inRange {
contentView.backgroundColor = UIColor(hex: 0xEFF6FF)
label.textColor = AppColor.primary
} else {
contentView.backgroundColor = .clear
label.textColor = .black
}
}
}
}
extension StatisticsDateRangeViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
monthDates().count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: DateCell.reuseIdentifier,
for: indexPath
) as! DateCell
let dates = monthDates()
guard indexPath.item < dates.count, let date = dates[indexPath.item] else {
cell.configure(text: nil, selected: false, inRange: false)
return cell
}
let day = calendar.component(.day, from: date)
let selected = (tempStart != nil && calendar.isDate(date, inSameDayAs: tempStart!))
|| (tempEnd != nil && calendar.isDate(date, inSameDayAs: tempEnd!))
cell.configure(text: "\(day)", selected: selected, inRange: isInRange(date))
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let dates = monthDates()
guard indexPath.item < dates.count, let date = dates[indexPath.item] else { return }
handleDateTap(date)
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let width = collectionView.bounds.width / 7
return CGSize(width: width, height: 32)
}
}

View File

@ -0,0 +1,43 @@
//
// StatisticsPeriodButton.swift
// suixinkan
//
import SnapKit
import UIKit
/// pill Android `PeriodButton`
final class StatisticsPeriodButton: UIButton {
var periodTitle: String = "" {
didSet { setTitle(periodTitle, for: .normal) }
}
override var isSelected: Bool {
didSet { updateAppearance() }
}
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
layer.cornerRadius = 18
clipsToBounds = true
contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20)
updateAppearance()
}
private func updateAppearance() {
backgroundColor = isSelected ? UIColor(hex: 0x3B82F6) : UIColor(hex: 0xF3F4F6)
setTitleColor(isSelected ? .white : UIColor(hex: 0x4B5563), for: .normal)
titleLabel?.font = .systemFont(ofSize: 14, weight: isSelected ? .semibold : .medium)
}
}

View File

@ -0,0 +1,67 @@
//
// StatisticsStatCardView.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `StatCard`
final class StatisticsStatCardView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let iconView = UIImageView()
init(title: String, valueColor: UIColor, backgroundColor: UIColor, iconName: String) {
super.init(frame: .zero)
titleLabel.text = title
valueLabel.textColor = valueColor
self.backgroundColor = backgroundColor
iconView.image = UIImage(named: iconName)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(value: String) {
valueLabel.text = value
}
private func setupUI() {
layer.cornerRadius = 12
clipsToBounds = true
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor.black.withAlphaComponent(0.6)
valueLabel.font = .systemFont(ofSize: 18, weight: .bold)
valueLabel.numberOfLines = 1
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.8
iconView.contentMode = .scaleAspectFit
addSubview(titleLabel)
addSubview(valueLabel)
addSubview(iconView)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(12)
make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8)
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(3)
make.leading.bottom.equalToSuperview().inset(12)
make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8)
}
iconView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(8)
make.centerY.equalToSuperview()
make.size.equalTo(48)
}
}
}

View File

@ -0,0 +1,128 @@
//
// StatisticsSummaryCardView.swift
// suixinkan
//
import SnapKit
import UIKit
/// chip 5
final class StatisticsSummaryCardView: UIView {
var onPeriodSelected: ((String) -> Void)?
private let datePrefixLabel = UILabel()
private let dateValueLabel = UILabel()
private let periodStack = UIStackView()
private var periodButtons: [StatisticsPeriodButton] = []
private let totalAmountCard = StatisticsStatCardView(
title: "订单总金额",
valueColor: UIColor(hex: 0x22C55E),
backgroundColor: UIColor(hex: 0xF0FDF4),
iconName: "icon_data_statistics_total_amount"
)
private let totalOrderCard = StatisticsStatCardView(
title: "订单总数",
valueColor: UIColor(hex: 0x7F00FF),
backgroundColor: UIColor(hex: 0xEBD8FF),
iconName: "icon_data_statistics_total_order"
)
private let receivedCard = StatisticsStatCardView(
title: "实收金额",
valueColor: UIColor(hex: 0x22C55E),
backgroundColor: UIColor(hex: 0xFFF0E2),
iconName: "icon_data_statistics_get_amount"
)
private let averageCard = StatisticsStatCardView(
title: "客单价",
valueColor: UIColor(hex: 0x0073FF),
backgroundColor: UIColor(hex: 0xEFF6FF),
iconName: "icon_data_statistics_average_amount"
)
private let refundCard = StatisticsStatCardView(
title: "退款金额",
valueColor: UIColor(hex: 0xEF4444),
backgroundColor: UIColor(hex: 0xFFE7E7),
iconName: "icon_data_statistics_refund_amount"
)
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(selectedPeriod: String, selectedPeriodTime: String, summary: StatisticsSummaryResponse?) {
dateValueLabel.text = selectedPeriodTime
periodButtons.forEach { $0.isSelected = $0.periodTitle == selectedPeriod }
totalAmountCard.configure(value: "¥\(summary?.orderAmountSum ?? "0")")
totalOrderCard.configure(value: "\(summary?.orderCount ?? 0)")
receivedCard.configure(value: "¥\(summary?.receivedAmountSum ?? "0")")
averageCard.configure(value: "¥\(summary?.orderPriceAvg ?? "0")")
refundCard.configure(value: "¥\(summary?.refundAmountSum ?? "0")")
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
datePrefixLabel.text = "已选日期:"
datePrefixLabel.font = .systemFont(ofSize: 14)
datePrefixLabel.textColor = AppColor.text333
dateValueLabel.font = .systemFont(ofSize: 14)
dateValueLabel.textColor = AppColor.primary
dateValueLabel.numberOfLines = 1
periodStack.axis = .horizontal
periodStack.distribution = .equalSpacing
periodStack.alignment = .fill
StatisticsSummaryPeriod.allCases.forEach { period in
let button = StatisticsPeriodButton()
button.periodTitle = period.rawValue
button.addTarget(self, action: #selector(periodTapped(_:)), for: .touchUpInside)
periodButtons.append(button)
periodStack.addArrangedSubview(button)
}
let row1 = UIStackView(arrangedSubviews: [totalOrderCard, receivedCard])
row1.axis = .horizontal
row1.spacing = 12
row1.distribution = .fillEqually
let row2 = UIStackView(arrangedSubviews: [averageCard, refundCard])
row2.axis = .horizontal
row2.spacing = 12
row2.distribution = .fillEqually
let cardStack = UIStackView(arrangedSubviews: [totalAmountCard, row1, row2])
cardStack.axis = .vertical
cardStack.spacing = 12
let dateRow = UIStackView(arrangedSubviews: [datePrefixLabel, dateValueLabel, UIView()])
dateRow.axis = .horizontal
dateRow.alignment = .center
dateRow.spacing = 0
let contentStack = UIStackView(arrangedSubviews: [dateRow, periodStack, cardStack])
contentStack.axis = .vertical
contentStack.spacing = 12
addSubview(contentStack)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
}
@objc private func periodTapped(_ sender: StatisticsPeriodButton) {
onPeriodSelected?(sender.periodTitle)
}
}

View File

@ -6,23 +6,141 @@
import SnapKit
import UIKit
/// Tab
/// Tab
final class StatisticsViewController: BaseViewController {
private let titleLabel = UILabel()
private let viewModel = StatisticsViewModel()
private let statisticsAPI = NetworkServices.shared.statisticsAPI
private let summaryCardView = StatisticsSummaryCardView()
private let dailyListView = StatisticsDailyListView()
private var hasInitialized = false
private var isLoadingMore = false
override func setupNavigationBar() {
title = "数据"
}
override func setupUI() {
title = "数据"
titleLabel.text = "数据"
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
titleLabel.textAlignment = .center
titleLabel.textColor = AppColor.text333
view.addSubview(titleLabel)
view.backgroundColor = UIColor(hex: 0xF7FAFF)
view.addSubview(summaryCardView)
view.addSubview(dailyListView)
}
override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
summaryCardView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
dailyListView.snp.makeConstraints { make in
make.top.equalTo(summaryCardView.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
summaryCardView.onPeriodSelected = { [weak self] period in
self?.selectPeriod(period)
}
dailyListView.onRangeTap = { [weak self] in
self?.presentDateRangeSheet()
}
dailyListView.onRefresh = { [weak self] in
self?.refreshList()
}
dailyListView.onLoadMore = { [weak self] in
self?.loadMoreIfNeeded()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasInitialized else { return }
hasInitialized = true
Task { await initializeStatistics() }
}
private func applyViewModel() {
summaryCardView.apply(
selectedPeriod: viewModel.selectedPeriod,
selectedPeriodTime: viewModel.selectedPeriodTime,
summary: viewModel.statistics
)
dailyListView.apply(
rangeText: viewModel.statisticsRangeText,
items: viewModel.statisticsList,
isRefreshing: viewModel.isRefreshing,
canLoadMore: viewModel.canLoadMore
)
if viewModel.isLoadingSummary {
showLoading()
} else {
hideLoading()
}
}
private func initializeStatistics() async {
showLoading()
await viewModel.initStatistics(api: statisticsAPI)
hideLoading()
}
private func selectPeriod(_ period: String) {
Task {
showLoading()
await viewModel.selectPeriod(period, api: statisticsAPI)
hideLoading()
}
}
private func refreshList() {
Task { await viewModel.refreshStatistics(api: statisticsAPI) }
}
private func loadMoreIfNeeded() {
guard viewModel.canLoadMore, !isLoadingMore else { return }
isLoadingMore = true
Task {
await viewModel.loadMore(api: statisticsAPI)
isLoadingMore = false
}
}
private func presentDateRangeSheet() {
let controller = StatisticsDateRangeViewController(
rangeStart: viewModel.rangeStartDate,
rangeEnd: viewModel.rangeEndDate
)
controller.onShortcutSelected = { [weak self, weak controller] shortcut in
guard let self else { return }
Task {
await self.viewModel.updateRangeByShortcut(shortcut, api: self.statisticsAPI)
controller?.dismiss(animated: true)
}
}
controller.onRangeSelected = { [weak self, weak controller] start, end in
guard let self else { return }
Task {
await self.viewModel.updateRangeByCalendar(start: start, end: end, api: self.statisticsAPI)
controller?.dismiss(animated: true)
}
}
if let sheet = controller.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
present(controller, animated: true)
}
}

View File

@ -0,0 +1,210 @@
//
// StatisticsViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class StatisticsViewModelTests: XCTestCase {
override func tearDown() {
AppStore.shared.roleCode = ""
AppStore.shared.roleName = ""
AppStore.shared.currentScenicId = 0
AppStore.shared.currentStoreId = 0
super.tearDown()
}
func testStatisticsSummaryDecodesRefundAmountAsString() throws {
let json = """
{
"code": 100000,
"msg": "success",
"data": {
"order_amount_sum": "0.00",
"order_count": 0,
"order_price_avg": "0.00",
"received_amount_sum": "0.00",
"refund_amount_sum": "0.00"
}
}
""".data(using: .utf8)!
let envelope = try JSONDecoder().decode(APIEnvelope<StatisticsSummaryResponse>.self, from: json)
XCTAssertEqual(envelope.data?.refundAmountSum, "0.00")
XCTAssertEqual(envelope.data?.orderAmountSum, "0.00")
}
func testRangeValueMapping() {
let viewModel = StatisticsViewModel()
XCTAssertEqual(viewModel.rangeValue(for: "今日"), "1")
XCTAssertEqual(viewModel.rangeValue(for: "昨日"), "2")
XCTAssertEqual(viewModel.rangeValue(for: "7日"), "3")
XCTAssertEqual(viewModel.rangeValue(for: "本月"), "4")
}
func testFormattedSelectedPeriodTimeForToday() {
let viewModel = StatisticsViewModel()
let text = viewModel.formattedSelectedPeriodTime(for: "今日")
let today = formattedToday()
XCTAssertEqual(text, "\(today)\(today)")
}
func testFormattedSelectedPeriodTimeForThisMonth() {
let viewModel = StatisticsViewModel()
let text = viewModel.formattedSelectedPeriodTime(for: "本月")
XCTAssertTrue(text.contains(""))
XCTAssertTrue(text.hasPrefix(firstDayOfMonth()))
}
func testUpdateRangeByShortcutAllClearsTimes() async {
let summaryJSON = """
{"code":100000,"msg":"success","data":{"order_amount_sum":"0","order_count":0,"order_price_avg":"0","received_amount_sum":"0","refund_amount_sum":0}}
""".data(using: .utf8)!
let listJSON = """
{"code":100000,"msg":"success","data":{"total":0,"data":[]}}
""".data(using: .utf8)!
AppStore.shared.currentScenicId = 10
let session = MockURLSession(responses: [listJSON])
let api = StatisticsAPI(client: APIClient(environment: .testing, session: session))
let viewModel = StatisticsViewModel()
await viewModel.updateRangeByShortcut("全部", api: api)
XCTAssertEqual(viewModel.statisticsRangeText, "全部")
XCTAssertEqual(viewModel.listStartTime, "")
XCTAssertEqual(viewModel.listEndTime, "")
XCTAssertEqual(session.requests.count, 1)
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/analyse/user/daily")
let queryItems = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(queryItems.first(where: { $0.name == "start_time" })?.value, "")
XCTAssertEqual(queryItems.first(where: { $0.name == "end_time" })?.value, "")
}
func testPhotographerSummaryAndDailyRequests() async throws {
let summaryJSON = """
{"code":100000,"msg":"success","data":{"order_amount_sum":"100","order_count":2,"order_price_avg":"50","received_amount_sum":"90","refund_amount_sum":10}}
""".data(using: .utf8)!
let listJSON = """
{"code":100000,"msg":"success","data":{"total":1,"data":[{"date":"2025-12-09","order_count":2,"order_price":"50","refund":"10","received":"90"}]}}
""".data(using: .utf8)!
AppStore.shared.currentScenicId = 10
let session = MockURLSession(responses: [summaryJSON, listJSON])
let api = StatisticsAPI(client: APIClient(environment: .testing, session: session))
let viewModel = StatisticsViewModel()
await viewModel.loadStatistics("7日", api: api)
await viewModel.updateRangeByShortcut("今日", api: api)
XCTAssertEqual(viewModel.statistics?.orderAmountSum, "100")
XCTAssertEqual(viewModel.statisticsList.count, 1)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/yf-handset-app/photog/analyse/user",
"/api/yf-handset-app/photog/analyse/user/daily",
])
XCTAssertTrue(session.requests[0].url?.query?.contains("range=3") == true)
XCTAssertTrue(session.requests[1].url?.query?.contains("paeg_size=15") == true)
XCTAssertTrue(session.requests[1].url?.query?.contains("scenic_id=10") == true)
}
func testScenicAdminUsesScenicAdminEndpoints() async {
let summaryJSON = """
{"code":100000,"msg":"success","data":{"order_amount_sum":"0","order_count":0,"order_price_avg":"0","received_amount_sum":"0","refund_amount_sum":0}}
""".data(using: .utf8)!
let listJSON = """
{"code":100000,"msg":"success","data":{"total":0,"data":[]}}
""".data(using: .utf8)!
AppStore.shared.roleCode = AppRoleCode.scenicAdmin.rawValue
AppStore.shared.currentScenicId = 20
let session = MockURLSession(responses: [summaryJSON, listJSON])
let api = StatisticsAPI(client: APIClient(environment: .testing, session: session))
let viewModel = StatisticsViewModel()
await viewModel.loadStatistics("今日", api: api)
await viewModel.updateRangeByShortcut("今日", api: api)
XCTAssertEqual(session.requests[0].url?.path, "/api/app/scenic-admin/analyse")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/scenic-admin/analyse/daily")
}
func testStoreAdminUsesStoreEndpoints() async {
let summaryJSON = """
{"code":100000,"msg":"success","data":{"order_amount_sum":"0","order_count":0,"order_price_avg":"0","received_amount_sum":"0","refund_amount_sum":0}}
""".data(using: .utf8)!
let listJSON = """
{"code":100000,"msg":"success","data":{"total":0,"data":[]}}
""".data(using: .utf8)!
AppStore.shared.roleCode = AppRoleCode.storeAdmin.rawValue
AppStore.shared.currentStoreId = 30
let session = MockURLSession(responses: [summaryJSON, listJSON])
let api = StatisticsAPI(client: APIClient(environment: .testing, session: session))
let viewModel = StatisticsViewModel()
await viewModel.loadStatistics("今日", api: api)
await viewModel.updateRangeByShortcut("今日", api: api)
XCTAssertEqual(session.requests[0].url?.path, "/api/app/store/analyse")
XCTAssertTrue(session.requests[0].url?.query?.contains("store_id=30") == true)
XCTAssertEqual(session.requests[1].url?.path, "/api/app/store/analyse/daily")
}
func testStoreAdminWithoutStoreIdDoesNotRequest() async {
AppStore.shared.roleCode = AppRoleCode.storeAdmin.rawValue
AppStore.shared.currentStoreId = 0
let session = MockURLSession(responses: [])
let api = StatisticsAPI(client: APIClient(environment: .testing, session: session))
let viewModel = StatisticsViewModel()
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.loadStatistics("今日", api: api)
XCTAssertEqual(message, "请先选择店铺")
XCTAssertEqual(session.requests.count, 0)
}
func testLoadMoreStopsWhenReachedTotal() async {
let page1JSON = """
{"code":100000,"msg":"success","data":{"total":1,"data":[{"date":"2025-12-09","order_count":1,"order_price":"10","refund":"0","received":"10"}]}}
""".data(using: .utf8)!
AppStore.shared.currentScenicId = 10
let session = MockURLSession(responses: [page1JSON])
let api = StatisticsAPI(client: APIClient(environment: .testing, session: session))
let viewModel = StatisticsViewModel()
await viewModel.updateRangeByShortcut("今日", api: api)
XCTAssertEqual(viewModel.statisticsList.count, 1)
XCTAssertFalse(viewModel.canLoadMore)
await viewModel.loadMore(api: api)
XCTAssertEqual(viewModel.listCurrentPage, 1)
XCTAssertEqual(session.requests.count, 1)
}
private func formattedToday() -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy/MM/dd"
return formatter.string(from: Date())
}
private func firstDayOfMonth() -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy/MM/dd"
var calendar = Calendar(identifier: .gregorian)
let components = calendar.dateComponents([.year, .month], from: Date())
let firstDay = calendar.date(from: components) ?? Date()
return formatter.string(from: firstDay)
}
}