同步已迁移的 iOS 模块
This commit is contained in:
82
suixinkan/Features/Statistics/API/StatisticsAPI.swift
Normal file
82
suixinkan/Features/Statistics/API/StatisticsAPI.swift
Normal file
@ -0,0 +1,82 @@
|
||||
//
|
||||
// StatisticsAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
/// 数据统计服务协议,抽象汇总和每日明细读取能力以便测试替换。
|
||||
protocol StatisticsServing {
|
||||
/// 获取统计汇总。
|
||||
func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse
|
||||
|
||||
/// 获取每日统计分页列表。
|
||||
func dailyList(
|
||||
scenicId: Int,
|
||||
startTime: String,
|
||||
endTime: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> DataListPayload<StatisticsDailyItem>
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 数据统计 API,封装摄影师和景区管理员两套统计接口。
|
||||
final class StatisticsAPI: StatisticsServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化数据统计 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取统计汇总。
|
||||
func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: isScenicAdmin ? "/api/app/scenic-admin/analyse" : "/api/yf-handset-app/photog/analyse/user",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "range", value: range)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取每日统计分页列表。
|
||||
func dailyList(
|
||||
scenicId: Int,
|
||||
startTime: String,
|
||||
endTime: String,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 15,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> DataListPayload<StatisticsDailyItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: isScenicAdmin ? "/api/app/scenic-admin/analyse/daily" : "/api/yf-handset-app/photog/analyse/user/daily",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
206
suixinkan/Features/Statistics/Models/StatisticsModels.swift
Normal file
206
suixinkan/Features/Statistics/Models/StatisticsModels.swift
Normal file
@ -0,0 +1,206 @@
|
||||
//
|
||||
// StatisticsModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 数据统计时间段实体,表示看板顶部的快捷日期范围。
|
||||
enum StatisticsPeriod: String, CaseIterable, Identifiable {
|
||||
case today = "今日"
|
||||
case yesterday = "昨日"
|
||||
case sevenDays = "7日"
|
||||
case thisMonth = "本月"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// 后端汇总接口需要的范围标识。
|
||||
var apiRange: String {
|
||||
switch self {
|
||||
case .today:
|
||||
return "1"
|
||||
case .yesterday:
|
||||
return "2"
|
||||
case .sevenDays:
|
||||
return "3"
|
||||
case .thisMonth:
|
||||
return "4"
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前时间段对应的本地日期区间。
|
||||
var interval: (start: Date, end: Date) {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
switch self {
|
||||
case .today:
|
||||
return (today, today)
|
||||
case .yesterday:
|
||||
let day = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
||||
return (day, day)
|
||||
case .sevenDays:
|
||||
return (calendar.date(byAdding: .day, value: -6, to: today) ?? today, today)
|
||||
case .thisMonth:
|
||||
let components = calendar.dateComponents([.year, .month], from: today)
|
||||
return (calendar.date(from: components) ?? today, today)
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面展示的时间范围文案。
|
||||
var selectedTimeText: String {
|
||||
let dates = interval
|
||||
return "\(Self.uiDateFormatter.string(from: dates.start))至\(Self.uiDateFormatter.string(from: dates.end))"
|
||||
}
|
||||
|
||||
/// 后端列表接口开始日期。
|
||||
var apiStartTime: String {
|
||||
Self.apiDateFormatter.string(from: interval.start)
|
||||
}
|
||||
|
||||
/// 后端列表接口结束日期。
|
||||
var apiEndTime: String {
|
||||
Self.apiDateFormatter.string(from: interval.end)
|
||||
}
|
||||
|
||||
private static let uiDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy/MM/dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let apiDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 数据统计汇总实体,表示订单金额、数量、客单价、实收和退款。
|
||||
struct StatisticsSummaryResponse: Decodable, Equatable {
|
||||
let orderAmountSum: String
|
||||
let orderCount: Int
|
||||
let orderPriceAverage: String
|
||||
let receivedAmountSum: String
|
||||
let refundAmountSum: String
|
||||
|
||||
var orderAmountValue: Double { Self.parseAmount(orderAmountSum) }
|
||||
var orderPriceAverageValue: Double { Self.parseAmount(orderPriceAverage) }
|
||||
var receivedAmountValue: Double { Self.parseAmount(receivedAmountSum) }
|
||||
var refundAmountValue: Double { Self.parseAmount(refundAmountSum) }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderAmountSum = "order_amount_sum"
|
||||
case orderCount = "order_count"
|
||||
case orderPriceAverage = "order_price_avg"
|
||||
case receivedAmountSum = "received_amount_sum"
|
||||
case refundAmountSum = "refund_amount_sum"
|
||||
}
|
||||
|
||||
/// 创建统计汇总实体,主要用于初始状态和测试。
|
||||
init(
|
||||
orderAmountSum: String = "",
|
||||
orderCount: Int = 0,
|
||||
orderPriceAverage: String = "",
|
||||
receivedAmountSum: String = "",
|
||||
refundAmountSum: String = ""
|
||||
) {
|
||||
self.orderAmountSum = orderAmountSum
|
||||
self.orderCount = orderCount
|
||||
self.orderPriceAverage = orderPriceAverage
|
||||
self.receivedAmountSum = receivedAmountSum
|
||||
self.refundAmountSum = refundAmountSum
|
||||
}
|
||||
|
||||
/// 宽松解码统计汇总字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum)
|
||||
orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0
|
||||
orderPriceAverage = try container.decodeLossyString(forKey: .orderPriceAverage)
|
||||
receivedAmountSum = try container.decodeLossyString(forKey: .receivedAmountSum)
|
||||
refundAmountSum = try container.decodeLossyString(forKey: .refundAmountSum)
|
||||
}
|
||||
|
||||
private static func parseAmount(_ text: String) -> Double {
|
||||
let normalized = text.filter { "0123456789.-".contains($0) }
|
||||
return Double(normalized) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 每日统计实体,表示某天订单数、客单价、退款和实收。
|
||||
struct StatisticsDailyItem: Decodable, Identifiable, Equatable {
|
||||
var id: String { date }
|
||||
|
||||
let date: String
|
||||
let orderCount: Int
|
||||
let orderPrice: String
|
||||
let refund: String
|
||||
let received: String
|
||||
|
||||
var orderPriceValue: Double { Self.parseAmount(orderPrice) }
|
||||
var refundValue: Double { Self.parseAmount(refund) }
|
||||
var receivedValue: Double { Self.parseAmount(received) }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case orderCount = "order_count"
|
||||
case orderPrice = "order_price"
|
||||
case refund
|
||||
case received
|
||||
}
|
||||
|
||||
/// 宽松解码每日统计字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try container.decodeLossyString(forKey: .date)
|
||||
orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0
|
||||
orderPrice = try container.decodeLossyString(forKey: .orderPrice)
|
||||
refund = try container.decodeLossyString(forKey: .refund)
|
||||
received = try container.decodeLossyString(forKey: .received)
|
||||
}
|
||||
|
||||
private static func parseAmount(_ text: String) -> Double {
|
||||
let normalized = text.filter { "0123456789.-".contains($0) }
|
||||
return Double(normalized) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将任意常见 JSON 值宽松解码成字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码成整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
26
suixinkan/Features/Statistics/Statistics.md
Normal file
26
suixinkan/Features/Statistics/Statistics.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Statistics 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单统计看板。
|
||||
|
||||
当前同步旧工程数据页的主要能力:时间段切换、统计汇总卡、每日明细、分页加载和下拉刷新。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `StatisticsView`:数据 Tab 根视图,读取当前景区、当前角色和统计 API。
|
||||
- `StatisticsViewModel`:管理时间段、汇总数据、每日明细、分页状态和加载状态。
|
||||
- `StatisticsAPI`:封装摄影师和景区管理员两套统计接口。
|
||||
- `StatisticsPeriod`:表示今日、昨日、7日、本月四个快捷时间段。
|
||||
|
||||
## 数据流程
|
||||
|
||||
页面从 `AccountContext.currentScenic` 获取当前景区 ID。缺少景区时显示空状态,不请求接口。
|
||||
|
||||
切换时间段时同时请求汇总接口和第一页日数据接口。汇总接口使用 `range` 参数,日数据接口使用 `start_time`、`end_time`、`page` 和 `page_size`。
|
||||
|
||||
`roleId == 53` 时使用景区管理员接口,其他角色使用摄影师接口。日数据响应使用 `DataListPayload` 解码,兼容后端返回 `data` 或 `list` 字段。
|
||||
|
||||
## 分页规则
|
||||
|
||||
每日明细第一页随刷新或时间段切换加载。列表滚动到底部后,如果当前数量小于 total,则继续加载下一页。加载更多失败时保留已有数据和当前页状态。
|
||||
@ -0,0 +1,100 @@
|
||||
//
|
||||
// StatisticsViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 数据统计页面 ViewModel,管理时间段、汇总数据、每日明细和分页状态。
|
||||
final class StatisticsViewModel {
|
||||
var selectedPeriod: StatisticsPeriod = .today
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var summary = StatisticsSummaryResponse()
|
||||
private(set) var dailyItems: [StatisticsDailyItem] = []
|
||||
private(set) var totalDailyCount = 0
|
||||
private(set) var lastRefreshAt: Date?
|
||||
|
||||
private var page = 1
|
||||
private let pageSize = 15
|
||||
|
||||
/// 当前日数据是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
dailyItems.count < totalDailyCount
|
||||
}
|
||||
|
||||
/// 切换统计时间段,并重置分页重新加载。
|
||||
func selectPeriod(_ period: StatisticsPeriod, api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
selectedPeriod = period
|
||||
try await reload(api: api, scenicId: scenicId, roleId: roleId)
|
||||
}
|
||||
|
||||
/// 刷新统计汇总和第一页日数据,缺少景区时清空旧状态。
|
||||
func reload(api: StatisticsServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let isScenicAdmin = roleId == 53
|
||||
async let summaryResult = api.summary(
|
||||
scenicId: scenicId,
|
||||
range: selectedPeriod.apiRange,
|
||||
isScenicAdmin: isScenicAdmin
|
||||
)
|
||||
async let dailyResult = api.dailyList(
|
||||
scenicId: scenicId,
|
||||
startTime: selectedPeriod.apiStartTime,
|
||||
endTime: selectedPeriod.apiEndTime,
|
||||
page: 1,
|
||||
pageSize: pageSize,
|
||||
isScenicAdmin: isScenicAdmin
|
||||
)
|
||||
let (summaryData, dailyData) = try await (summaryResult, dailyResult)
|
||||
summary = summaryData
|
||||
dailyItems = dailyData.data
|
||||
totalDailyCount = dailyData.total
|
||||
page = 1
|
||||
lastRefreshAt = Date()
|
||||
}
|
||||
|
||||
/// 加载每日统计下一页。
|
||||
func loadMore(api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
guard !loadingMore, hasMore, let scenicId else { return }
|
||||
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
|
||||
let nextPage = page + 1
|
||||
let result = try await api.dailyList(
|
||||
scenicId: scenicId,
|
||||
startTime: selectedPeriod.apiStartTime,
|
||||
endTime: selectedPeriod.apiEndTime,
|
||||
page: nextPage,
|
||||
pageSize: pageSize,
|
||||
isScenicAdmin: roleId == 53
|
||||
)
|
||||
dailyItems.append(contentsOf: result.data)
|
||||
totalDailyCount = result.total
|
||||
page = nextPage
|
||||
}
|
||||
|
||||
/// 清空统计状态。
|
||||
private func reset() {
|
||||
summary = StatisticsSummaryResponse()
|
||||
dailyItems = []
|
||||
totalDailyCount = 0
|
||||
page = 1
|
||||
lastRefreshAt = nil
|
||||
loading = false
|
||||
loadingMore = false
|
||||
}
|
||||
}
|
||||
321
suixinkan/Features/Statistics/Views/StatisticsView.swift
Normal file
321
suixinkan/Features/Statistics/Views/StatisticsView.swift
Normal file
@ -0,0 +1,321 @@
|
||||
//
|
||||
// StatisticsView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 数据 Tab 根视图,展示订单统计汇总和每日明细。
|
||||
struct StatisticsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(StatisticsAPI.self) private var statisticsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = StatisticsViewModel()
|
||||
|
||||
private var currentScenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 900 : .infinity
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView(
|
||||
"缺少经营上下文",
|
||||
systemImage: "chart.bar.doc.horizontal",
|
||||
description: Text("请先在首页选择景区后查看数据看板。")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
overviewCard
|
||||
detailCard
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.top, AppMetrics.Spacing.medium)
|
||||
.padding(.bottom, AppMetrics.Spacing.pageVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
.navigationTitle("数据")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await reload() }
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewCard: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: 0) {
|
||||
Text("已选日期:")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(viewModel.selectedPeriod.selectedTimeText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(StatisticsPeriod.allCases) { period in
|
||||
periodButton(period)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
statCard(
|
||||
title: "订单总金额",
|
||||
value: amountText(viewModel.summary.orderAmountValue),
|
||||
textColor: Color(hex: 0x22C55E),
|
||||
backgroundColor: Color(hex: 0xF0FDF4),
|
||||
icon: "doc.text.fill"
|
||||
)
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
statCard(
|
||||
title: "订单总数",
|
||||
value: "\(viewModel.summary.orderCount)单",
|
||||
textColor: Color(hex: 0x7F00FF),
|
||||
backgroundColor: Color(hex: 0xEBD8FF),
|
||||
icon: "chart.bar.fill"
|
||||
)
|
||||
statCard(
|
||||
title: "实收金额",
|
||||
value: amountText(viewModel.summary.receivedAmountValue),
|
||||
textColor: Color(hex: 0x22C55E),
|
||||
backgroundColor: Color(hex: 0xFFF0E2),
|
||||
icon: "yensign.circle.fill"
|
||||
)
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
statCard(
|
||||
title: "客单价",
|
||||
value: amountText(viewModel.summary.orderPriceAverageValue),
|
||||
textColor: AppDesign.primary,
|
||||
backgroundColor: Color(hex: 0xEFF6FF),
|
||||
icon: "tag.fill"
|
||||
)
|
||||
statCard(
|
||||
title: "退款金额",
|
||||
value: amountText(viewModel.summary.refundAmountValue),
|
||||
textColor: Color(hex: 0xEF4444),
|
||||
backgroundColor: Color(hex: 0xFFE7E7),
|
||||
icon: "arrow.down.doc.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var detailCard: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text("时间范围")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(StatisticsPeriod.allCases) { period in
|
||||
Button(period.rawValue) {
|
||||
Task { await selectPeriod(period) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(viewModel.selectedPeriod.rawValue)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
Spacer(minLength: AppMetrics.Spacing.small)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(width: 166, height: 36)
|
||||
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.loading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 280)
|
||||
} else if viewModel.dailyItems.isEmpty {
|
||||
ContentUnavailableView("暂无数据", systemImage: "tray", description: Text("可切换时间范围或下拉刷新。"))
|
||||
.frame(minHeight: 280)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(viewModel.dailyItems) { item in
|
||||
dailyDataRow(item)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.dailyItems.last?.id else { return }
|
||||
Task { await loadMore() }
|
||||
}
|
||||
if item.id != viewModel.dailyItems.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
if viewModel.loadingMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(minHeight: 420, alignment: .top)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func periodButton(_ period: StatisticsPeriod) -> some View {
|
||||
let isSelected = period == viewModel.selectedPeriod
|
||||
return Button {
|
||||
Task { await selectPeriod(period) }
|
||||
} label: {
|
||||
Text(period.rawValue)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: isSelected ? .semibold : .medium))
|
||||
.foregroundStyle(isSelected ? .white : Color(hex: 0x4B5563))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
.background(isSelected ? AppDesign.primary : Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 17))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func statCard(title: String, value: String, textColor: Color, backgroundColor: Color, icon: String) -> some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(.black.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||||
.foregroundStyle(textColor)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
}
|
||||
Spacer(minLength: AppMetrics.Spacing.xxSmall)
|
||||
statisticIcon(symbol: icon, color: textColor)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func statisticIcon(symbol: String, color: Color) -> some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(color.opacity(0.22))
|
||||
.frame(width: 30, height: 30)
|
||||
.offset(x: 6, y: 6)
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(color.opacity(0.9))
|
||||
.frame(width: 30, height: 30)
|
||||
.offset(x: -6, y: -6)
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
}
|
||||
|
||||
private func dailyDataRow(_ item: StatisticsDailyItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text(item.date)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(.black)
|
||||
|
||||
HStack(alignment: .top) {
|
||||
dataColumn("订单数", "\(item.orderCount)", .black)
|
||||
dataColumn("客单价", amountText(item.orderPriceValue), .black)
|
||||
dataColumn("退款", amountText(item.refundValue), Color(hex: 0xEF4444))
|
||||
dataColumn("实收", amountText(item.receivedValue), Color(hex: 0x22C55E))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
|
||||
private func dataColumn(_ title: String, _ value: String, _ color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.65)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func amountText(_ value: Double) -> String {
|
||||
"¥\(String(format: "%.2f", value))"
|
||||
}
|
||||
|
||||
private func selectPeriod(_ period: StatisticsPeriod) async {
|
||||
do {
|
||||
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
do {
|
||||
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
StatisticsView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(StatisticsAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user