Sync migrated iOS modules

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent ace9c94359
commit 484566d27e
48 changed files with 4455 additions and 15 deletions

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

View 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 ""
}
/// IntDouble
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
}
}

View 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则继续加载下一页。加载更多失败时保留已有数据和当前页状态。

View File

@ -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
}
}

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