同步已迁移的 iOS 模块

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent 0a0d4fbd79
commit 08465fffde
48 changed files with 4455 additions and 15 deletions

View 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())
}
}