Implement statistics tab aligned with Android and fix summary decode.
Add role-based analyse APIs, summary/daily UI with date range sheet, and flexible parsing for string refund amounts from the backend. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
108
suixinkan/UI/Statistics/StatisticsDailyCell.swift
Normal file
108
suixinkan/UI/Statistics/StatisticsDailyCell.swift
Normal file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// StatisticsDailyCell.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据统计日明细行,对齐 Android `DailyDataBox`。
|
||||
final class StatisticsDailyCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "StatisticsDailyCell"
|
||||
|
||||
private let dateLabel = UILabel()
|
||||
private let orderCountTitle = UILabel()
|
||||
private let orderCountValue = UILabel()
|
||||
private let averageTitle = UILabel()
|
||||
private let averageValue = UILabel()
|
||||
private let refundTitle = UILabel()
|
||||
private let refundValue = UILabel()
|
||||
private let receivedTitle = UILabel()
|
||||
private let receivedValue = UILabel()
|
||||
private let divider = UIView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(with item: StatisticsDailyItem, showsDivider: Bool) {
|
||||
dateLabel.text = item.date
|
||||
orderCountValue.text = "\(item.orderCount)"
|
||||
averageValue.text = "¥\(item.orderPrice)"
|
||||
refundValue.text = "¥\(item.refund)"
|
||||
receivedValue.text = "¥\(item.received)"
|
||||
divider.isHidden = !showsDivider
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
dateLabel.font = .systemFont(ofSize: 14)
|
||||
dateLabel.textColor = .black
|
||||
|
||||
let metricColor = UIColor(hex: 0x4B5563)
|
||||
[orderCountTitle, averageTitle, refundTitle, receivedTitle].forEach {
|
||||
$0.font = .systemFont(ofSize: 14)
|
||||
$0.textColor = metricColor
|
||||
}
|
||||
orderCountTitle.text = "订单数"
|
||||
averageTitle.text = "客单价"
|
||||
refundTitle.text = "退款"
|
||||
receivedTitle.text = "实收"
|
||||
|
||||
[orderCountValue, averageValue].forEach {
|
||||
$0.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
$0.textColor = .black
|
||||
}
|
||||
refundValue.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
refundValue.textColor = UIColor(hex: 0xEF4444)
|
||||
receivedValue.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
receivedValue.textColor = UIColor(hex: 0x22C55E)
|
||||
|
||||
divider.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
|
||||
let metricsRow = UIStackView()
|
||||
metricsRow.axis = .horizontal
|
||||
metricsRow.distribution = .fillEqually
|
||||
metricsRow.alignment = .fill
|
||||
metricsRow.spacing = 8
|
||||
|
||||
metricsRow.addArrangedSubview(metricColumn(title: orderCountTitle, value: orderCountValue))
|
||||
metricsRow.addArrangedSubview(metricColumn(title: averageTitle, value: averageValue))
|
||||
metricsRow.addArrangedSubview(metricColumn(title: refundTitle, value: refundValue))
|
||||
metricsRow.addArrangedSubview(metricColumn(title: receivedTitle, value: receivedValue))
|
||||
|
||||
contentView.addSubview(dateLabel)
|
||||
contentView.addSubview(metricsRow)
|
||||
contentView.addSubview(divider)
|
||||
|
||||
dateLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 0, right: 0))
|
||||
}
|
||||
metricsRow.snp.makeConstraints { make in
|
||||
make.top.equalTo(dateLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func metricColumn(title: UILabel, value: UILabel) -> UIStackView {
|
||||
let stack = UIStackView(arrangedSubviews: [title, value])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 4
|
||||
stack.alignment = .leading
|
||||
return stack
|
||||
}
|
||||
}
|
||||
174
suixinkan/UI/Statistics/StatisticsDailyListView.swift
Normal file
174
suixinkan/UI/Statistics/StatisticsDailyListView.swift
Normal file
@ -0,0 +1,174 @@
|
||||
//
|
||||
// StatisticsDailyListView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据统计日明细列表区域,含时间范围选择与分页列表。
|
||||
final class StatisticsDailyListView: UIView {
|
||||
|
||||
var onRangeTap: (() -> Void)?
|
||||
var onRefresh: (() -> Void)?
|
||||
var onLoadMore: (() -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let rangeButton = UIControl()
|
||||
private let rangeLabel = UILabel()
|
||||
private let arrowView = UIImageView()
|
||||
private let divider = UIView()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let footerIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
private var items: [StatisticsDailyItem] = []
|
||||
private var showsLoadMoreFooter = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(rangeText: String, items: [StatisticsDailyItem], isRefreshing: Bool, canLoadMore: Bool) {
|
||||
rangeLabel.text = rangeText
|
||||
self.items = items
|
||||
showsLoadMoreFooter = canLoadMore
|
||||
tableView.reloadData()
|
||||
updateFooterVisibility()
|
||||
if !isRefreshing {
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
clipsToBounds = true
|
||||
|
||||
titleLabel.text = "时间范围"
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
|
||||
rangeButton.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
rangeButton.layer.cornerRadius = 4
|
||||
rangeButton.addTarget(self, action: #selector(rangeTapped), for: .touchUpInside)
|
||||
|
||||
rangeLabel.font = .systemFont(ofSize: 14)
|
||||
rangeLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
rangeLabel.text = "今日"
|
||||
|
||||
arrowView.image = UIImage(systemName: "chevron.down")
|
||||
arrowView.tintColor = UIColor(hex: 0x4B5563)
|
||||
arrowView.contentMode = .scaleAspectFit
|
||||
|
||||
divider.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseIdentifier)
|
||||
tableView.refreshControl = refreshControl
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
|
||||
footerIndicator.hidesWhenStopped = true
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(rangeButton)
|
||||
rangeButton.addSubview(rangeLabel)
|
||||
rangeButton.addSubview(arrowView)
|
||||
addSubview(divider)
|
||||
addSubview(tableView)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(16)
|
||||
}
|
||||
rangeButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(36)
|
||||
make.width.greaterThanOrEqualTo(166)
|
||||
}
|
||||
rangeLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().inset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(arrowView.snp.leading).offset(-8)
|
||||
}
|
||||
arrowView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
divider.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(1)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(divider.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func rangeTapped() {
|
||||
onRangeTap?()
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
onRefresh?()
|
||||
}
|
||||
|
||||
private func updateFooterVisibility() {
|
||||
if showsLoadMoreFooter {
|
||||
footerIndicator.startAnimating()
|
||||
tableView.tableFooterView = footerContainer()
|
||||
} else {
|
||||
footerIndicator.stopAnimating()
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
}
|
||||
}
|
||||
|
||||
private func footerContainer() -> UIView {
|
||||
let container = UIView(frame: CGRect(x: 0, y: 0, width: bounds.width, height: 56))
|
||||
container.addSubview(footerIndicator)
|
||||
footerIndicator.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY)
|
||||
footerIndicator.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin]
|
||||
return container
|
||||
}
|
||||
}
|
||||
|
||||
extension StatisticsDailyListView: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: StatisticsDailyCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? StatisticsDailyCell
|
||||
else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
cell.configure(with: items[indexPath.row], showsDivider: indexPath.row < items.count - 1)
|
||||
return cell
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard showsLoadMoreFooter else { return }
|
||||
let offsetY = scrollView.contentOffset.y
|
||||
let contentHeight = scrollView.contentSize.height
|
||||
let frameHeight = scrollView.frame.size.height
|
||||
guard contentHeight > frameHeight else { return }
|
||||
if offsetY > contentHeight - frameHeight - 120 {
|
||||
onLoadMore?()
|
||||
}
|
||||
}
|
||||
}
|
||||
303
suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift
Normal file
303
suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift
Normal file
@ -0,0 +1,303 @@
|
||||
//
|
||||
// StatisticsDateRangeViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据统计日期范围选择 Sheet,对齐 Android `DateRangeBottomSheet`。
|
||||
final class StatisticsDateRangeViewController: UIViewController {
|
||||
|
||||
var onShortcutSelected: ((String) -> Void)?
|
||||
var onRangeSelected: ((Date, Date) -> Void)?
|
||||
|
||||
private var currentMonth: Date
|
||||
private var tempStart: Date?
|
||||
private var tempEnd: Date?
|
||||
private var selectedShortcut: String?
|
||||
|
||||
private let shortcutStack = UIStackView()
|
||||
private let monthTitleLabel = UILabel()
|
||||
private let weekdayStack = UIStackView()
|
||||
private let daysCollectionView: UICollectionView
|
||||
private var shortcutButtons: [StatisticsPeriodButton] = []
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.locale = Locale(identifier: "zh_CN")
|
||||
calendar.firstWeekday = 2
|
||||
return calendar
|
||||
}()
|
||||
|
||||
init(rangeStart: Date?, rangeEnd: Date?) {
|
||||
self.tempStart = rangeStart
|
||||
self.tempEnd = rangeEnd
|
||||
self.currentMonth = rangeStart ?? Date()
|
||||
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 0
|
||||
layout.minimumLineSpacing = 8
|
||||
daysCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let initCalendar = Calendar(identifier: .gregorian)
|
||||
if rangeStart == nil, rangeEnd == nil {
|
||||
selectedShortcut = StatisticsRangeShortcut.all.rawValue
|
||||
} else if rangeStart == today, rangeEnd == today {
|
||||
selectedShortcut = StatisticsRangeShortcut.today.rawValue
|
||||
} else if
|
||||
let start = rangeStart,
|
||||
let end = rangeEnd,
|
||||
start == initCalendar.date(byAdding: .day, value: -1, to: today),
|
||||
end == initCalendar.date(byAdding: .day, value: -1, to: today) {
|
||||
selectedShortcut = StatisticsRangeShortcut.yesterday.rawValue
|
||||
} else if
|
||||
let start = rangeStart,
|
||||
let end = rangeEnd,
|
||||
start == initCalendar.date(byAdding: .day, value: -6, to: today),
|
||||
end == today {
|
||||
selectedShortcut = StatisticsRangeShortcut.last7Days.rawValue
|
||||
}
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .white
|
||||
setupUI()
|
||||
updateMonthTitle()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
shortcutStack.axis = .horizontal
|
||||
shortcutStack.distribution = .equalSpacing
|
||||
|
||||
StatisticsRangeShortcut.allCases.forEach { shortcut in
|
||||
let button = StatisticsPeriodButton()
|
||||
button.periodTitle = shortcut.rawValue
|
||||
button.isSelected = shortcut.rawValue == selectedShortcut
|
||||
button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside)
|
||||
shortcutButtons.append(button)
|
||||
shortcutStack.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
let previousButton = UIButton(type: .system)
|
||||
previousButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
previousButton.tintColor = UIColor(hex: 0x9CA3AF)
|
||||
previousButton.addTarget(self, action: #selector(previousMonth), for: .touchUpInside)
|
||||
|
||||
let nextButton = UIButton(type: .system)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal)
|
||||
nextButton.tintColor = UIColor(hex: 0x9CA3AF)
|
||||
nextButton.addTarget(self, action: #selector(nextMonth), for: .touchUpInside)
|
||||
|
||||
monthTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
monthTitleLabel.textAlignment = .center
|
||||
|
||||
let monthRow = UIStackView(arrangedSubviews: [previousButton, monthTitleLabel, nextButton])
|
||||
monthRow.axis = .horizontal
|
||||
monthRow.alignment = .center
|
||||
monthRow.distribution = .equalCentering
|
||||
|
||||
weekdayStack.axis = .horizontal
|
||||
weekdayStack.distribution = .fillEqually
|
||||
["一", "二", "三", "四", "五", "六", "日"].forEach { title in
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 12)
|
||||
label.textColor = UIColor(hex: 0x9CA3AF)
|
||||
label.textAlignment = .center
|
||||
weekdayStack.addArrangedSubview(label)
|
||||
}
|
||||
|
||||
daysCollectionView.backgroundColor = .clear
|
||||
daysCollectionView.dataSource = self
|
||||
daysCollectionView.delegate = self
|
||||
daysCollectionView.register(DateCell.self, forCellWithReuseIdentifier: DateCell.reuseIdentifier)
|
||||
|
||||
view.addSubview(shortcutStack)
|
||||
view.addSubview(monthRow)
|
||||
view.addSubview(weekdayStack)
|
||||
view.addSubview(daysCollectionView)
|
||||
|
||||
shortcutStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
monthRow.snp.makeConstraints { make in
|
||||
make.top.equalTo(shortcutStack.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(32)
|
||||
}
|
||||
previousButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
nextButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
weekdayStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(monthRow.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
daysCollectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(weekdayStack.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
make.height.equalTo(240)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func shortcutTapped(_ sender: StatisticsPeriodButton) {
|
||||
onShortcutSelected?(sender.periodTitle)
|
||||
}
|
||||
|
||||
@objc private func previousMonth() {
|
||||
currentMonth = calendar.date(byAdding: .month, value: -1, to: currentMonth) ?? currentMonth
|
||||
updateMonthTitle()
|
||||
daysCollectionView.reloadData()
|
||||
}
|
||||
|
||||
@objc private func nextMonth() {
|
||||
currentMonth = calendar.date(byAdding: .month, value: 1, to: currentMonth) ?? currentMonth
|
||||
updateMonthTitle()
|
||||
daysCollectionView.reloadData()
|
||||
}
|
||||
|
||||
private func updateMonthTitle() {
|
||||
let components = calendar.dateComponents([.year, .month], from: currentMonth)
|
||||
monthTitleLabel.text = "\(components.year ?? 0)年\(components.month ?? 0)月"
|
||||
}
|
||||
|
||||
private func monthDates() -> [Date?] {
|
||||
guard
|
||||
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: currentMonth)),
|
||||
let dayRange = calendar.range(of: .day, in: .month, for: monthStart)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let firstWeekday = calendar.component(.weekday, from: monthStart)
|
||||
let leadingEmpty = (firstWeekday + 5) % 7
|
||||
var dates: [Date?] = Array(repeating: nil, count: leadingEmpty)
|
||||
for day in dayRange {
|
||||
if let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) {
|
||||
dates.append(date)
|
||||
}
|
||||
}
|
||||
while dates.count % 7 != 0 {
|
||||
dates.append(nil)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
private func handleDateTap(_ date: Date) {
|
||||
selectedShortcut = nil
|
||||
shortcutButtons.forEach { $0.isSelected = false }
|
||||
|
||||
if tempStart == nil || (tempStart != nil && tempEnd != nil) {
|
||||
tempStart = date
|
||||
tempEnd = nil
|
||||
} else if let start = tempStart {
|
||||
if date < start {
|
||||
tempEnd = start
|
||||
tempStart = date
|
||||
} else {
|
||||
tempEnd = date
|
||||
}
|
||||
if let start = tempStart, let end = tempEnd {
|
||||
onRangeSelected?(start, end)
|
||||
}
|
||||
}
|
||||
daysCollectionView.reloadData()
|
||||
}
|
||||
|
||||
private func isInRange(_ date: Date) -> Bool {
|
||||
guard let start = tempStart else { return false }
|
||||
if let end = tempEnd {
|
||||
return date >= min(start, end) && date <= max(start, end)
|
||||
}
|
||||
return calendar.isDate(date, inSameDayAs: start)
|
||||
}
|
||||
|
||||
private final class DateCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "StatisticsDateCell"
|
||||
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
label.textAlignment = .center
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
contentView.addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
contentView.layer.cornerRadius = 16
|
||||
contentView.clipsToBounds = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(text: String?, selected: Bool, inRange: Bool) {
|
||||
label.text = text
|
||||
if selected {
|
||||
contentView.backgroundColor = AppColor.primary
|
||||
label.textColor = .white
|
||||
} else if inRange {
|
||||
contentView.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
label.textColor = AppColor.primary
|
||||
} else {
|
||||
contentView.backgroundColor = .clear
|
||||
label.textColor = .black
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatisticsDateRangeViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
monthDates().count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: DateCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! DateCell
|
||||
let dates = monthDates()
|
||||
guard indexPath.item < dates.count, let date = dates[indexPath.item] else {
|
||||
cell.configure(text: nil, selected: false, inRange: false)
|
||||
return cell
|
||||
}
|
||||
let day = calendar.component(.day, from: date)
|
||||
let selected = (tempStart != nil && calendar.isDate(date, inSameDayAs: tempStart!))
|
||||
|| (tempEnd != nil && calendar.isDate(date, inSameDayAs: tempEnd!))
|
||||
cell.configure(text: "\(day)", selected: selected, inRange: isInRange(date))
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
let dates = monthDates()
|
||||
guard indexPath.item < dates.count, let date = dates[indexPath.item] else { return }
|
||||
handleDateTap(date)
|
||||
}
|
||||
|
||||
func collectionView(
|
||||
_ collectionView: UICollectionView,
|
||||
layout collectionViewLayout: UICollectionViewLayout,
|
||||
sizeForItemAt indexPath: IndexPath
|
||||
) -> CGSize {
|
||||
let width = collectionView.bounds.width / 7
|
||||
return CGSize(width: width, height: 32)
|
||||
}
|
||||
}
|
||||
43
suixinkan/UI/Statistics/StatisticsPeriodButton.swift
Normal file
43
suixinkan/UI/Statistics/StatisticsPeriodButton.swift
Normal file
@ -0,0 +1,43 @@
|
||||
//
|
||||
// StatisticsPeriodButton.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据统计周期 pill 按钮,对齐 Android `PeriodButton`。
|
||||
final class StatisticsPeriodButton: UIButton {
|
||||
|
||||
var periodTitle: String = "" {
|
||||
didSet { setTitle(periodTitle, for: .normal) }
|
||||
}
|
||||
|
||||
override var isSelected: Bool {
|
||||
didSet { updateAppearance() }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
layer.cornerRadius = 18
|
||||
clipsToBounds = true
|
||||
contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20)
|
||||
updateAppearance()
|
||||
}
|
||||
|
||||
private func updateAppearance() {
|
||||
backgroundColor = isSelected ? UIColor(hex: 0x3B82F6) : UIColor(hex: 0xF3F4F6)
|
||||
setTitleColor(isSelected ? .white : UIColor(hex: 0x4B5563), for: .normal)
|
||||
titleLabel?.font = .systemFont(ofSize: 14, weight: isSelected ? .semibold : .medium)
|
||||
}
|
||||
}
|
||||
67
suixinkan/UI/Statistics/StatisticsStatCardView.swift
Normal file
67
suixinkan/UI/Statistics/StatisticsStatCardView.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// StatisticsStatCardView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据统计指标卡,对齐 Android `StatCard`。
|
||||
final class StatisticsStatCardView: UIView {
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let valueLabel = UILabel()
|
||||
private let iconView = UIImageView()
|
||||
|
||||
init(title: String, valueColor: UIColor, backgroundColor: UIColor, iconName: String) {
|
||||
super.init(frame: .zero)
|
||||
titleLabel.text = title
|
||||
valueLabel.textColor = valueColor
|
||||
self.backgroundColor = backgroundColor
|
||||
iconView.image = UIImage(named: iconName)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(value: String) {
|
||||
valueLabel.text = value
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
layer.cornerRadius = 12
|
||||
clipsToBounds = true
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
titleLabel.textColor = UIColor.black.withAlphaComponent(0.6)
|
||||
|
||||
valueLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
valueLabel.numberOfLines = 1
|
||||
valueLabel.adjustsFontSizeToFitWidth = true
|
||||
valueLabel.minimumScaleFactor = 0.8
|
||||
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(valueLabel)
|
||||
addSubview(iconView)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(12)
|
||||
make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8)
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(3)
|
||||
make.leading.bottom.equalToSuperview().inset(12)
|
||||
make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(8)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
}
|
||||
}
|
||||
128
suixinkan/UI/Statistics/StatisticsSummaryCardView.swift
Normal file
128
suixinkan/UI/Statistics/StatisticsSummaryCardView.swift
Normal file
@ -0,0 +1,128 @@
|
||||
//
|
||||
// StatisticsSummaryCardView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据统计汇总卡片,包含日期行、周期 chip 与 5 张指标卡。
|
||||
final class StatisticsSummaryCardView: UIView {
|
||||
|
||||
var onPeriodSelected: ((String) -> Void)?
|
||||
|
||||
private let datePrefixLabel = UILabel()
|
||||
private let dateValueLabel = UILabel()
|
||||
private let periodStack = UIStackView()
|
||||
private var periodButtons: [StatisticsPeriodButton] = []
|
||||
|
||||
private let totalAmountCard = StatisticsStatCardView(
|
||||
title: "订单总金额",
|
||||
valueColor: UIColor(hex: 0x22C55E),
|
||||
backgroundColor: UIColor(hex: 0xF0FDF4),
|
||||
iconName: "icon_data_statistics_total_amount"
|
||||
)
|
||||
private let totalOrderCard = StatisticsStatCardView(
|
||||
title: "订单总数",
|
||||
valueColor: UIColor(hex: 0x7F00FF),
|
||||
backgroundColor: UIColor(hex: 0xEBD8FF),
|
||||
iconName: "icon_data_statistics_total_order"
|
||||
)
|
||||
private let receivedCard = StatisticsStatCardView(
|
||||
title: "实收金额",
|
||||
valueColor: UIColor(hex: 0x22C55E),
|
||||
backgroundColor: UIColor(hex: 0xFFF0E2),
|
||||
iconName: "icon_data_statistics_get_amount"
|
||||
)
|
||||
private let averageCard = StatisticsStatCardView(
|
||||
title: "客单价",
|
||||
valueColor: UIColor(hex: 0x0073FF),
|
||||
backgroundColor: UIColor(hex: 0xEFF6FF),
|
||||
iconName: "icon_data_statistics_average_amount"
|
||||
)
|
||||
private let refundCard = StatisticsStatCardView(
|
||||
title: "退款金额",
|
||||
valueColor: UIColor(hex: 0xEF4444),
|
||||
backgroundColor: UIColor(hex: 0xFFE7E7),
|
||||
iconName: "icon_data_statistics_refund_amount"
|
||||
)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(selectedPeriod: String, selectedPeriodTime: String, summary: StatisticsSummaryResponse?) {
|
||||
dateValueLabel.text = selectedPeriodTime
|
||||
periodButtons.forEach { $0.isSelected = $0.periodTitle == selectedPeriod }
|
||||
|
||||
totalAmountCard.configure(value: "¥\(summary?.orderAmountSum ?? "0")")
|
||||
totalOrderCard.configure(value: "\(summary?.orderCount ?? 0)单")
|
||||
receivedCard.configure(value: "¥\(summary?.receivedAmountSum ?? "0")")
|
||||
averageCard.configure(value: "¥\(summary?.orderPriceAvg ?? "0")")
|
||||
refundCard.configure(value: "¥\(summary?.refundAmountSum ?? "0")")
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
clipsToBounds = true
|
||||
|
||||
datePrefixLabel.text = "已选日期:"
|
||||
datePrefixLabel.font = .systemFont(ofSize: 14)
|
||||
datePrefixLabel.textColor = AppColor.text333
|
||||
|
||||
dateValueLabel.font = .systemFont(ofSize: 14)
|
||||
dateValueLabel.textColor = AppColor.primary
|
||||
dateValueLabel.numberOfLines = 1
|
||||
|
||||
periodStack.axis = .horizontal
|
||||
periodStack.distribution = .equalSpacing
|
||||
periodStack.alignment = .fill
|
||||
|
||||
StatisticsSummaryPeriod.allCases.forEach { period in
|
||||
let button = StatisticsPeriodButton()
|
||||
button.periodTitle = period.rawValue
|
||||
button.addTarget(self, action: #selector(periodTapped(_:)), for: .touchUpInside)
|
||||
periodButtons.append(button)
|
||||
periodStack.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
let row1 = UIStackView(arrangedSubviews: [totalOrderCard, receivedCard])
|
||||
row1.axis = .horizontal
|
||||
row1.spacing = 12
|
||||
row1.distribution = .fillEqually
|
||||
|
||||
let row2 = UIStackView(arrangedSubviews: [averageCard, refundCard])
|
||||
row2.axis = .horizontal
|
||||
row2.spacing = 12
|
||||
row2.distribution = .fillEqually
|
||||
|
||||
let cardStack = UIStackView(arrangedSubviews: [totalAmountCard, row1, row2])
|
||||
cardStack.axis = .vertical
|
||||
cardStack.spacing = 12
|
||||
|
||||
let dateRow = UIStackView(arrangedSubviews: [datePrefixLabel, dateValueLabel, UIView()])
|
||||
dateRow.axis = .horizontal
|
||||
dateRow.alignment = .center
|
||||
dateRow.spacing = 0
|
||||
|
||||
let contentStack = UIStackView(arrangedSubviews: [dateRow, periodStack, cardStack])
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
|
||||
addSubview(contentStack)
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func periodTapped(_ sender: StatisticsPeriodButton) {
|
||||
onPeriodSelected?(sender.periodTitle)
|
||||
}
|
||||
}
|
||||
@ -6,23 +6,141 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 数据 Tab 根页面。
|
||||
/// 数据 Tab 根页面,展示汇总指标与日明细列表。
|
||||
final class StatisticsViewController: BaseViewController {
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let viewModel = StatisticsViewModel()
|
||||
private let statisticsAPI = NetworkServices.shared.statisticsAPI
|
||||
|
||||
private let summaryCardView = StatisticsSummaryCardView()
|
||||
private let dailyListView = StatisticsDailyListView()
|
||||
|
||||
private var hasInitialized = false
|
||||
private var isLoadingMore = false
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "数据"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
title = "数据"
|
||||
titleLabel.text = "数据"
|
||||
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.textColor = AppColor.text333
|
||||
view.addSubview(titleLabel)
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
|
||||
view.addSubview(summaryCardView)
|
||||
view.addSubview(dailyListView)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
summaryCardView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
dailyListView.snp.makeConstraints { make in
|
||||
make.top.equalTo(summaryCardView.snp.bottom).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
|
||||
summaryCardView.onPeriodSelected = { [weak self] period in
|
||||
self?.selectPeriod(period)
|
||||
}
|
||||
dailyListView.onRangeTap = { [weak self] in
|
||||
self?.presentDateRangeSheet()
|
||||
}
|
||||
dailyListView.onRefresh = { [weak self] in
|
||||
self?.refreshList()
|
||||
}
|
||||
dailyListView.onLoadMore = { [weak self] in
|
||||
self?.loadMoreIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard !hasInitialized else { return }
|
||||
hasInitialized = true
|
||||
Task { await initializeStatistics() }
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
summaryCardView.apply(
|
||||
selectedPeriod: viewModel.selectedPeriod,
|
||||
selectedPeriodTime: viewModel.selectedPeriodTime,
|
||||
summary: viewModel.statistics
|
||||
)
|
||||
dailyListView.apply(
|
||||
rangeText: viewModel.statisticsRangeText,
|
||||
items: viewModel.statisticsList,
|
||||
isRefreshing: viewModel.isRefreshing,
|
||||
canLoadMore: viewModel.canLoadMore
|
||||
)
|
||||
|
||||
if viewModel.isLoadingSummary {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func initializeStatistics() async {
|
||||
showLoading()
|
||||
await viewModel.initStatistics(api: statisticsAPI)
|
||||
hideLoading()
|
||||
}
|
||||
|
||||
private func selectPeriod(_ period: String) {
|
||||
Task {
|
||||
showLoading()
|
||||
await viewModel.selectPeriod(period, api: statisticsAPI)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshList() {
|
||||
Task { await viewModel.refreshStatistics(api: statisticsAPI) }
|
||||
}
|
||||
|
||||
private func loadMoreIfNeeded() {
|
||||
guard viewModel.canLoadMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
Task {
|
||||
await viewModel.loadMore(api: statisticsAPI)
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
private func presentDateRangeSheet() {
|
||||
let controller = StatisticsDateRangeViewController(
|
||||
rangeStart: viewModel.rangeStartDate,
|
||||
rangeEnd: viewModel.rangeEndDate
|
||||
)
|
||||
controller.onShortcutSelected = { [weak self, weak controller] shortcut in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.viewModel.updateRangeByShortcut(shortcut, api: self.statisticsAPI)
|
||||
controller?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
controller.onRangeSelected = { [weak self, weak controller] start, end in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.viewModel.updateRangeByCalendar(start: start, end: end, api: self.statisticsAPI)
|
||||
controller?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let sheet = controller.sheetPresentationController {
|
||||
sheet.detents = [.medium(), .large()]
|
||||
sheet.prefersGrabberVisible = true
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user