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

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

563 lines
22 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.

//
// SampleListViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `SampleListScreen`
final class SampleListViewController: SampleBaseViewController {
private let viewModel: SampleListViewModel
private let api: any SampleManagementServing
private let searchContainer = UIView()
private let searchBox = UIView()
private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass"))
private let searchTextField = UITextField()
private let filterContainer = UIView()
private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
private let bottomBar = UIView()
private let addButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, SampleMaterialItem>!
private var filterOptionButtons: [(SampleFilterStatus, UIButton)] = []
///
init(
viewModel: SampleListViewModel = SampleListViewModel(),
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
) {
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() {
super.setupNavigationBar()
setSampleTitle("样片相册列表")
}
override func setupUI() {
super.setupUI()
view.backgroundColor = SampleManagementUITokens.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 = SampleFilterStatus.all.title
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
filterDropdownStack.spacing = 0
tableView.backgroundColor = SampleManagementUITokens.pageBackground
tableView.separatorStyle = .none
tableView.estimatedRowHeight = 152
tableView.rowHeight = UITableView.automaticDimension
tableView.delegate = self
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0)
tableView.register(SampleListCell.self, forCellReuseIdentifier: SampleListCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl()
dataSource = UITableViewDiffableDataSource<Int, SampleMaterialItem>(tableView: tableView) {
[weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: SampleListCell.reuseIdentifier,
for: indexPath
) as! SampleListCell
cell.apply(item: item)
cell.onToggle = { [weak self] checked in
guard let self else { return }
Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) }
}
return cell
}
emptyLabel.text = "暂无样片"
emptyLabel.font = .systemFont(ofSize: 16)
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
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(emptyLabel)
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()
}
emptyLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(tableView)
make.leading.trailing.equalToSuperview().inset(24)
}
}
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) }
}
}
private var needsRefreshOnAppear = false
@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, SampleMaterialItem>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: true)
emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
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 = UploadSampleViewController()
controller.onUploadSuccess = { [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() {
SampleFilterStatus.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 SampleListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
hideFilterDropdown()
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
navigationController?.pushViewController(SampleDetailViewController(sampleId: item.id), animated: true)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api) }
}
}
extension SampleListViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
/// cell Android `SampleItemView`
private final class SampleListCell: UITableViewCell {
static let reuseIdentifier = "SampleListCell"
private let cardView = UIView()
private let coverImageView = UIImageView()
private let placeholderLabel = UILabel()
private let titleLabel = UILabel()
private let projectLabel = UILabel()
private let scenicSpotLabel = UILabel()
private let statusBadge = SampleStatusBadgeView()
private let listingSwitch = UISwitch()
private let statsStack = UIStackView()
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
private let collectStatView = SampleStatView(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")
}
override func prepareForReuse() {
super.prepareForReuse()
onToggle = nil
}
func apply(item: SampleMaterialItem) {
isApplying = true
confirmedListingState = item.listingStatus == 1
titleLabel.text = item.name
projectLabel.text = "项目: \(item.projectName)"
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
statusBadge.apply(status: item.status)
listingSwitch.setOn(confirmedListingState, animated: false)
shareStatView.setCount(item.shareCount)
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 = SampleManagementUITokens.pageBackground
contentView.backgroundColor = SampleManagementUITokens.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
[projectLabel, scenicSpotLabel].forEach { label in
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hex: 0x4B5563)
label.numberOfLines = 1
}
listingSwitch.onTintColor = AppColor.primary
listingSwitch.accessibilityLabel = "样片上下架"
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(titleLabel)
cardView.addSubview(projectLabel)
cardView.addSubview(scenicSpotLabel)
cardView.addSubview(statusBadge)
cardView.addSubview(listingSwitch)
cardView.addSubview(statsStack)
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
cardView.snp.makeConstraints { make in
make.top.equalToSuperview()
make.bottom.equalToSuperview().inset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
coverImageView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(12)
make.size.equalTo(128)
}
placeholderLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(8)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(12)
}
projectLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalTo(titleLabel)
}
scenicSpotLabel.snp.makeConstraints { make in
make.top.equalTo(projectLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(titleLabel)
}
statusBadge.snp.makeConstraints { make in
make.top.equalTo(scenicSpotLabel.snp.bottom).offset(10)
make.leading.equalTo(titleLabel)
make.height.equalTo(24)
}
listingSwitch.snp.makeConstraints { make in
make.centerY.equalTo(statusBadge)
make.trailing.equalToSuperview().inset(12)
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
}
statsStack.snp.makeConstraints { make in
make.top.equalTo(statusBadge.snp.bottom).offset(10)
make.leading.equalTo(titleLabel)
make.trailing.lessThanOrEqualToSuperview().inset(12)
make.bottom.lessThanOrEqualToSuperview().inset(12)
}
}
@objc private func switchChanged() {
guard !isApplying else { return }
let requestedState = listingSwitch.isOn
listingSwitch.setOn(confirmedListingState, animated: true)
onToggle?(requestedState)
}
}
/// badge
final class SampleStatusBadgeView: 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 = SampleDisplayFormatter.reviewStatusText(status)
label.textColor = color
backgroundColor = color.withAlphaComponent(0.1)
}
}
///
private final class SampleStatView: UIView {
private let iconView = UIImageView()
private let countLabel = UILabel()
init(iconName: String) {
super.init(frame: .zero)
iconView.image = UIImage(named: iconName)
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 = SampleDisplayFormatter.formatCount(count)
}
}