// // 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! private var filterOptionButtons: [(SampleFilterStatus, UIButton)] = [] /// 初始化样片列表页。 init( viewModel: SampleListViewModel = SampleListViewModel(), api: (any SampleManagementServing)? = nil ) { self.viewModel = viewModel self.api = api ?? NetworkServices.shared.sampleManagementAPI 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(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() 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) } }