Files
suixinkan_uikit/suixinkan/UI/MaterialManagement/MaterialListViewController.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

658 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// MaterialListViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `MaterialListScreen`
///
final class MaterialListViewController: BaseViewController {
private let viewModel: MaterialListViewModel
private let api: any MaterialManagementServing
private let searchContainer = UIView()
private let searchBox = UIView()
private let searchIconView = UIImageView(image: UIImage(named: "material_ic_search"))
private let searchTextField = UITextField()
private let filterContainer = UIView()
private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down"))
private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let bottomBar = UIView()
private let addButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>!
private var filterOptionButtons: [(MaterialFilterStatus, UIButton)] = []
private var needsRefreshOnAppear = false
///
init(
viewModel: MaterialListViewModel = MaterialListViewModel(),
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "素材管理"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
searchContainer.backgroundColor = .white
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
searchBox.layer.cornerRadius = 4
searchIconView.tintColor = AppColor.textSecondary
searchIconView.contentMode = .scaleAspectFit
searchTextField.placeholder = "搜索素材名称"
searchTextField.font = .systemFont(ofSize: 13)
searchTextField.textColor = AppColor.textPrimary
searchTextField.clearButtonMode = .whileEditing
searchTextField.returnKeyType = .search
filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
filterButton.layer.cornerRadius = 4
filterTitleLabel.text = "筛选"
filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
filterChevronView.contentMode = .scaleAspectFit
filterDropdownView.backgroundColor = .white
filterDropdownView.layer.cornerRadius = 4
filterDropdownView.layer.shadowColor = UIColor.black.cgColor
filterDropdownView.layer.shadowOpacity = 0.12
filterDropdownView.layer.shadowRadius = 10
filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4)
filterDropdownView.isHidden = true
filterDropdownStack.axis = .vertical
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.estimatedRowHeight = 148
tableView.rowHeight = UITableView.automaticDimension
tableView.delegate = self
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0)
tableView.refreshControl = UIRefreshControl()
tableView.register(MaterialStatsCell.self, forCellReuseIdentifier: MaterialStatsCell.reuseIdentifier)
tableView.register(MaterialListCell.self, forCellReuseIdentifier: MaterialListCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Int, MaterialListRow>(tableView: tableView) {
[weak self] tableView, indexPath, row in
switch row {
case .stats(let info):
let cell = tableView.dequeueReusableCell(
withIdentifier: MaterialStatsCell.reuseIdentifier,
for: indexPath
) as! MaterialStatsCell
cell.apply(info: info)
return cell
case .item(let item):
let cell = tableView.dequeueReusableCell(
withIdentifier: MaterialListCell.reuseIdentifier,
for: indexPath
) as! MaterialListCell
cell.apply(item: item)
cell.onToggle = { [weak self] checked in
guard let self else { return }
Task { await self.viewModel.toggleListing(materialId: item.id, checked: checked, api: self.api) }
}
return cell
}
}
bottomBar.backgroundColor = .white
addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
view.addSubview(searchContainer)
searchContainer.addSubview(searchBox)
searchBox.addSubview(searchIconView)
searchBox.addSubview(searchTextField)
view.addSubview(filterContainer)
filterContainer.addSubview(filterButton)
filterButton.addSubview(filterTitleLabel)
filterButton.addSubview(filterChevronView)
view.addSubview(tableView)
view.addSubview(bottomBar)
bottomBar.addSubview(addButton)
view.addSubview(filterDropdownView)
filterDropdownView.addSubview(filterDropdownStack)
configureFilterDropdown()
}
override func setupConstraints() {
searchContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(68)
}
searchBox.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.height.equalTo(36)
}
searchIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(15)
}
searchTextField.snp.makeConstraints { make in
make.leading.equalTo(searchIconView.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(12)
make.top.bottom.equalToSuperview()
}
filterContainer.snp.makeConstraints { make in
make.top.equalTo(searchContainer.snp.bottom).offset(1)
make.leading.trailing.equalToSuperview()
make.height.equalTo(68)
}
filterButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.height.equalTo(36)
}
filterTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
filterChevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
addButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
filterDropdownView.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom)
make.leading.trailing.equalTo(filterButton)
}
filterDropdownStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
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) }
}
searchTextField.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged)
searchTextField.delegate = self
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadInitial(api: api) }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if needsRefreshOnAppear {
needsRefreshOnAppear = false
Task { await viewModel.refresh(api: api) }
}
}
@MainActor
private func applyViewModel() {
filterTitleLabel.text = viewModel.filterStatus.title
filterOptionButtons.forEach { status, button in
let selected = status == viewModel.filterStatus
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular)
}
if searchTextField.text != viewModel.searchText {
searchTextField.text = viewModel.searchText
}
var snapshot = NSDiffableDataSourceSnapshot<Int, MaterialListRow>()
snapshot.appendSections([0])
snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item))
dataSource.apply(snapshot, animatingDifferences: true)
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
if !viewModel.isRefreshing {
tableView.refreshControl?.endRefreshing()
}
}
@objc private func searchTextChanged() {
let text = searchTextField.text ?? ""
Task { await viewModel.updateSearchText(text, api: api) }
}
@objc private func filterTapped() {
filterDropdownView.isHidden.toggle()
view.bringSubviewToFront(filterDropdownView)
UIView.animate(withDuration: 0.2) {
self.filterChevronView.transform = self.filterDropdownView.isHidden
? .identity
: CGAffineTransform(rotationAngle: .pi)
}
}
@objc private func addTapped() {
let controller = MaterialFormViewController(mode: .create)
controller.onSubmitSuccess = { [weak self] in
self?.needsRefreshOnAppear = true
}
navigationController?.pushViewController(controller, animated: true)
}
@objc private func refreshPulled() {
hideFilterDropdown()
Task { await viewModel.refresh(api: api) }
}
private func configureFilterDropdown() {
MaterialFilterStatus.allCases.forEach { status in
let button = UIButton(type: .system)
button.contentHorizontalAlignment = .leading
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
)
button.setTitle(status.title, for: .normal)
button.backgroundColor = .white
button.snp.makeConstraints { make in
make.height.equalTo(48)
}
button.addAction(UIAction { [weak self] _ in
guard let self else { return }
self.hideFilterDropdown()
Task { await self.viewModel.selectFilter(status, api: self.api) }
}, for: .touchUpInside)
filterDropdownStack.addArrangedSubview(button)
filterOptionButtons.append((status, button))
}
}
private func hideFilterDropdown() {
guard !filterDropdownView.isHidden else { return }
filterDropdownView.isHidden = true
UIView.animate(withDuration: 0.2) {
self.filterChevronView.transform = .identity
}
}
}
extension MaterialListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
hideFilterDropdown()
tableView.deselectRow(at: indexPath, animated: true)
guard case .item(let item) = dataSource.itemIdentifier(for: indexPath) else { return }
let controller = MaterialDetailViewController(materialId: item.id)
controller.onDeleted = { [weak self] deletedId in
self?.viewModel.removeMaterial(id: deletedId)
}
controller.onEdited = { [weak self] in
self?.needsRefreshOnAppear = true
}
navigationController?.pushViewController(controller, animated: true)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard case .item = dataSource.itemIdentifier(for: indexPath) else { return }
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row - 1, api: api) }
}
}
extension MaterialListViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
/// Diffable
private enum MaterialListRow: Hashable {
case stats(MaterialOrderInfo)
case item(MaterialItem)
}
///
///
private final class MaterialStatsCell: UITableViewCell {
static let reuseIdentifier = "MaterialStatsCell"
private let cardView = UIView()
private let stack = UIStackView()
private let totalView = MaterialStatisticView(title: "订单总数量", color: UIColor(hex: 0x7F00FF), background: UIColor(hex: 0xEBD8FF))
private let priceView = MaterialStatisticView(title: "客单价", color: UIColor(hex: 0x22C55E), background: UIColor(hex: 0xF0FDF4))
private let refundView = MaterialStatisticView(title: "退款金额", color: UIColor(hex: 0xFF7B00), background: UIColor(hex: 0xFFF0E2))
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.12
cardView.layer.shadowRadius = 2
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
stack.axis = .horizontal
stack.spacing = 12
stack.distribution = .fillEqually
contentView.addSubview(cardView)
cardView.addSubview(stack)
[totalView, priceView, refundView].forEach(stack.addArrangedSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(info: MaterialOrderInfo) {
totalView.setValue(MaterialDisplayFormatter.formatNumber(info.totalNum))
priceView.setValue("+\(info.avgOrderAmount)")
refundView.setValue(info.refundTotal)
}
}
///
///
private final class MaterialStatisticView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
init(title: String, color: UIColor, background: UIColor) {
super.init(frame: .zero)
self.backgroundColor = background
layer.cornerRadius = 12
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
valueLabel.font = .systemFont(ofSize: 18, weight: .bold)
valueLabel.textColor = color
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.7
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(8)
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.leading.trailing.bottom.equalToSuperview().inset(8)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ value: String) {
valueLabel.text = value
}
}
/// cell Android `MaterialItemView`
///
private final class MaterialListCell: UITableViewCell {
static let reuseIdentifier = "MaterialListCell"
private let cardView = UIView()
private let coverImageView = UIImageView()
private let placeholderLabel = UILabel()
private let detailContentView = UIView()
private let titleLabel = UILabel()
private let timeLabel = UILabel()
private let statusBadge = MaterialStatusBadgeView()
private let listingSwitch = UISwitch()
private let statsStack = UIStackView()
private let downloadStatView = MaterialStatView(iconName: "material_ic_download_count")
private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count")
private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count")
private var isApplying = false
private var confirmedListingState = false
var onToggle: ((Bool) -> Void)?
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 apply(item: MaterialItem) {
isApplying = true
confirmedListingState = item.listingStatus == 1
titleLabel.text = item.name
timeLabel.text = item.createdAt
statusBadge.apply(status: item.status)
listingSwitch.setOn(confirmedListingState, animated: false)
downloadStatView.setCount(item.downloadCount)
likeStatView.setCount(item.likesCount)
collectStatView.setCount(item.collectCount)
if let url = URL(string: item.coverUrl), !item.coverUrl.isEmpty {
placeholderLabel.isHidden = true
coverImageView.kf.setImage(with: url)
} else {
coverImageView.image = nil
placeholderLabel.isHidden = false
}
isApplying = false
}
private func setupUI() {
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5)
coverImageView.contentMode = .scaleAspectFill
coverImageView.clipsToBounds = true
coverImageView.layer.cornerRadius = 8
placeholderLabel.text = "暂无图片"
placeholderLabel.font = .systemFont(ofSize: 12)
placeholderLabel.textColor = AppColor.textSecondary
placeholderLabel.textAlignment = .center
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
titleLabel.textColor = .black
titleLabel.numberOfLines = 1
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = UIColor(hex: 0x4B5563)
timeLabel.numberOfLines = 1
listingSwitch.onTintColor = AppColor.primary
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
statsStack.axis = .horizontal
statsStack.spacing = 16
statsStack.alignment = .center
contentView.addSubview(cardView)
cardView.addSubview(coverImageView)
coverImageView.addSubview(placeholderLabel)
cardView.addSubview(detailContentView)
detailContentView.addSubview(titleLabel)
detailContentView.addSubview(timeLabel)
detailContentView.addSubview(statusBadge)
detailContentView.addSubview(listingSwitch)
detailContentView.addSubview(statsStack)
[downloadStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
coverImageView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(12)
make.width.equalTo(109)
make.height.equalTo(100)
}
placeholderLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(8)
}
detailContentView.snp.makeConstraints { make in
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(12)
make.centerY.equalTo(coverImageView)
make.top.greaterThanOrEqualToSuperview().inset(12)
make.bottom.lessThanOrEqualToSuperview().inset(12)
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
statusBadge.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(8)
make.leading.equalTo(titleLabel)
make.height.equalTo(24)
}
listingSwitch.snp.makeConstraints { make in
make.centerY.equalTo(statusBadge)
make.trailing.equalToSuperview()
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
}
statsStack.snp.makeConstraints { make in
make.top.equalTo(statusBadge.snp.bottom).offset(12)
make.leading.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview()
make.bottom.equalToSuperview()
}
}
@objc private func switchChanged() {
guard !isApplying else { return }
let requestedState = listingSwitch.isOn
listingSwitch.setOn(confirmedListingState, animated: true)
onToggle?(requestedState)
}
}
/// badge
///
final class MaterialStatusBadgeView: UIView {
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 4
addSubview(label)
label.font = .systemFont(ofSize: 12, weight: .medium)
label.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(6)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(status: Int) {
let color: UIColor
switch status {
case 1: color = UIColor(hex: 0x22C55E)
case 0: color = UIColor(hex: 0xFF7B00)
case 2: color = UIColor(hex: 0xEF4444)
default: color = AppColor.textSecondary
}
label.text = MaterialDisplayFormatter.reviewStatusText(status)
label.textColor = color
backgroundColor = color.withAlphaComponent(0.1)
}
}
///
///
private final class MaterialStatView: UIView {
private let iconView = UIImageView()
private let countLabel = UILabel()
init(iconName: String? = nil, systemIconName: String? = nil) {
super.init(frame: .zero)
if let iconName {
iconView.image = UIImage(named: iconName)
} else if let systemIconName {
iconView.image = UIImage(systemName: systemIconName)
}
iconView.tintColor = UIColor(hex: 0x6B7280)
iconView.contentMode = .scaleAspectFit
countLabel.font = .systemFont(ofSize: 14)
countLabel.textColor = UIColor(hex: 0x6B7280)
addSubview(iconView)
addSubview(countLabel)
iconView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.size.equalTo(16)
}
countLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setCount(_ count: Int) {
countLabel.text = MaterialDisplayFormatter.formatCount(count)
}
}