同步已迁移的 iOS 模块
This commit is contained in:
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