Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,80 @@
//
// StatisticsAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
@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
/// API
final class StatisticsAPI: StatisticsServing {
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,290 @@
//
// StatisticsViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Tab
final class StatisticsViewController: UIViewController {
private let viewModel = StatisticsViewModel()
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .grouped)
table.backgroundColor = AppDesignUIKit.pageBackground
table.dataSource = self
table.delegate = self
table.register(StatisticsSummaryCell.self, forCellReuseIdentifier: StatisticsSummaryCell.reuseID)
table.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseID)
table.register(StatisticsPeriodCell.self, forCellReuseIdentifier: StatisticsPeriodCell.reuseID)
return table
}()
private lazy var refreshControl = UIRefreshControl()
override func viewDidLoad() {
super.viewDidLoad()
title = "数据"
view.backgroundColor = AppDesignUIKit.pageBackground
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
tableView.refreshControl = refreshControl
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } }
appServices.permissionContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } }
Task { await reload(showLoading: true) }
}
@objc private func refreshPulled() {
Task {
await reload(showLoading: false)
refreshControl.endRefreshing()
}
}
private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id }
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
private func reload(showLoading: Bool) async {
guard currentScenicId != nil else {
tableView.reloadData()
return
}
do {
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载数据...") {
try await self.viewModel.reload(
api: self.appServices.statisticsAPI,
scenicId: self.currentScenicId,
roleId: self.currentRoleId,
showLoading: false
)
}
} catch {
showToast(error.localizedDescription)
}
}
private func selectPeriod(_ period: StatisticsPeriod) {
Task {
do {
try await appServices.globalLoading.withLoading(message: "加载数据...") {
try await viewModel.selectPeriod(
period,
api: appServices.statisticsAPI,
scenicId: currentScenicId,
roleId: currentRoleId
)
}
} catch {
showToast(error.localizedDescription)
}
}
}
private func loadMore() async {
do {
try await viewModel.loadMore(
api: appServices.statisticsAPI,
scenicId: currentScenicId,
roleId: currentRoleId
)
} catch {
showToast(error.localizedDescription)
}
}
private func amountText(_ value: Double) -> String {
"¥\(String(format: "%.2f", value))"
}
}
extension StatisticsViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
currentScenicId == nil ? 1 : 3
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if currentScenicId == nil { return 1 }
switch section {
case 0: return 1
case 1: return 1
default:
if viewModel.loading && viewModel.dailyItems.isEmpty { return 0 }
return max(viewModel.dailyItems.count, 1)
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if currentScenicId == nil {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看数据看板。", systemImage: "chart.bar.doc.horizontal")
cell.contentView.addSubview(empty)
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(360) }
return cell
}
switch indexPath.section {
case 0:
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsPeriodCell.reuseID, for: indexPath) as! StatisticsPeriodCell
cell.configure(selectedPeriod: viewModel.selectedPeriod) { [weak self] period in
self?.selectPeriod(period)
}
return cell
case 1:
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsSummaryCell.reuseID, for: indexPath) as! StatisticsSummaryCell
cell.configure(
periodText: viewModel.selectedPeriod.selectedTimeText,
summary: viewModel.summary,
amountText: amountText
)
return cell
default:
if viewModel.dailyItems.isEmpty {
let cell = UITableViewCell()
cell.textLabel?.text = "暂无数据"
cell.selectionStyle = .none
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsDailyCell.reuseID, for: indexPath) as! StatisticsDailyCell
cell.configure(item: viewModel.dailyItems[indexPath.row], amountText: amountText)
return cell
}
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 2 ? "每日明细" : nil
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.section == 2, indexPath.row == viewModel.dailyItems.count - 1 else { return }
Task { await loadMore() }
}
}
private final class StatisticsPeriodCell: UITableViewCell {
static let reuseID = "StatisticsPeriodCell"
private var onSelect: ((StatisticsPeriod) -> Void)?
private let stack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
stack.axis = .horizontal
stack.spacing = 8
stack.distribution = .fillEqually
contentView.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(selectedPeriod: StatisticsPeriod, onSelect: @escaping (StatisticsPeriod) -> Void) {
self.onSelect = onSelect
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for period in StatisticsPeriod.allCases {
let button = UIButton(type: .system)
button.setTitle(period.rawValue, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: period == selectedPeriod ? .semibold : .regular)
button.setTitleColor(period == selectedPeriod ? AppDesignUIKit.primary : AppDesignUIKit.textSecondary, for: .normal)
button.backgroundColor = period == selectedPeriod ? AppDesignUIKit.primarySoft : UIColor(hex: 0xF4F4F4)
button.layer.cornerRadius = 6
button.tag = StatisticsPeriod.allCases.firstIndex(of: period) ?? 0
button.addTarget(self, action: #selector(periodTapped(_:)), for: .touchUpInside)
stack.addArrangedSubview(button)
}
}
@objc private func periodTapped(_ sender: UIButton) {
let period = StatisticsPeriod.allCases[sender.tag]
onSelect?(period)
}
}
private final class StatisticsSummaryCell: UITableViewCell {
static let reuseID = "StatisticsSummaryCell"
private let stack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
stack.axis = .vertical
stack.spacing = 8
contentView.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(periodText: String, summary: StatisticsSummaryResponse, amountText: (Double) -> String) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let dateLabel = UILabel()
dateLabel.text = "已选日期:\(periodText)"
dateLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
stack.addArrangedSubview(dateLabel)
stack.addArrangedSubview(summaryCard("订单总金额", amountText(summary.orderAmountValue), UIColor(hex: 0x22C55E)))
let row = UIStackView()
row.axis = .horizontal
row.spacing = 8
row.distribution = .fillEqually
row.addArrangedSubview(summaryCard("订单总数", "\(summary.orderCount)", UIColor(hex: 0x7F00FF)))
row.addArrangedSubview(summaryCard("实收金额", amountText(summary.receivedAmountValue), UIColor(hex: 0x22C55E)))
stack.addArrangedSubview(row)
let row2 = UIStackView()
row2.axis = .horizontal
row2.spacing = 8
row2.distribution = .fillEqually
row2.addArrangedSubview(summaryCard("客单价", amountText(summary.orderPriceAverageValue), AppDesignUIKit.primary))
row2.addArrangedSubview(summaryCard("退款金额", amountText(summary.refundAmountValue), UIColor(hex: 0xEF4444)))
stack.addArrangedSubview(row2)
}
private func summaryCard(_ title: String, _ value: String, _ color: UIColor) -> UIView {
let card = UIView()
card.backgroundColor = color.withAlphaComponent(0.08)
card.layer.cornerRadius = 8
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
let valueLabel = UILabel()
valueLabel.text = value
valueLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .bold)
valueLabel.textColor = color
let inner = UIStackView(arrangedSubviews: [titleLabel, valueLabel])
inner.axis = .vertical
inner.spacing = 4
card.addSubview(inner)
inner.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
card.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) }
return card
}
}
private final class StatisticsDailyCell: UITableViewCell {
static let reuseID = "StatisticsDailyCell"
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(item: StatisticsDailyItem, amountText: (Double) -> String) {
textLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
detailTextLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
textLabel?.text = item.date
detailTextLabel?.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))"
}
}

View File

@ -0,0 +1,99 @@
//
// StatisticsViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
@MainActor
/// ViewModel
final class StatisticsViewModel {
var onChange: (() -> Void)?
var selectedPeriod: StatisticsPeriod = .today { didSet { onChange?() } }
private(set) var loading = false { didSet { onChange?() } }
private(set) var loadingMore = false { didSet { onChange?() } }
private(set) var summary = StatisticsSummaryResponse() { didSet { onChange?() } }
private(set) var dailyItems: [StatisticsDailyItem] = [] { didSet { onChange?() } }
private(set) var totalDailyCount = 0 { didSet { onChange?() } }
private(set) var lastRefreshAt: Date? { didSet { onChange?() } }
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
}
}