// // PunchPointListViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 打卡点列表页,对齐 Android `PunchPointListScreen`。 final class PunchPointListViewController: BaseViewController { private let viewModel: PunchPointListViewModel private let api: any PunchPointAPIProtocol 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 bottomBar = UIView() private let addButton = UIButton(type: .system) private let emptyView = PunchPointEmptyView() private var dataSource: UITableViewDiffableDataSource! private var filterButtons: [(PunchPointFilterType, UIButton)] = [] private var needsRefreshOnAppear = false /// 初始化打卡点列表页。 init( viewModel: PunchPointListViewModel = PunchPointListViewModel(), api: (any PunchPointAPIProtocol)? = nil ) { self.viewModel = viewModel self.api = api ?? NetworkServices.shared.punchPointAPI 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 filterContainer.backgroundColor = .white filterButton.backgroundColor = UIColor(hex: 0xF5F5F5) filterButton.layer.cornerRadius = 8 filterTitleLabel.text = PunchPointFilterType.all.title filterTitleLabel.font = .systemFont(ofSize: 14) filterTitleLabel.textColor = AppColor.textPrimary filterChevronView.tintColor = AppColor.textSecondary filterChevronView.contentMode = .scaleAspectFit filterDropdownView.backgroundColor = .white filterDropdownView.layer.cornerRadius = 8 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.delegate = self tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 210 tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) tableView.refreshControl = UIRefreshControl() tableView.register(PunchPointListCell.self, forCellReuseIdentifier: PunchPointListCell.reuseIdentifier) dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in let cell = tableView.dequeueReusableCell( withIdentifier: PunchPointListCell.reuseIdentifier, for: indexPath ) as! PunchPointListCell cell.apply(item: item) cell.onQRCode = { [weak self] in self?.viewModel.showQRCode(for: item) } cell.onDelete = { [weak self] in self?.confirmDelete(item) } 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 emptyView.isHidden = true emptyView.onAction = { [weak self] in self?.addTapped() } view.addSubview(filterContainer) filterContainer.addSubview(filterButton) filterButton.addSubview(filterTitleLabel) filterButton.addSubview(filterChevronView) view.addSubview(tableView) view.addSubview(emptyView) view.addSubview(bottomBar) bottomBar.addSubview(addButton) view.addSubview(filterDropdownView) filterDropdownView.addSubview(filterDropdownStack) configureFilterDropdown() } override func setupConstraints() { filterContainer.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(64) } filterButton.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(15) make.centerY.equalToSuperview() make.height.equalTo(48) } filterTitleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() } filterChevronView.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() make.size.equalTo(22) } 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) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } emptyView.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalTo(tableView) make.leading.trailing.equalToSuperview().inset(24) } 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) } } viewModel.onShowQRCode = { [weak self] url in Task { @MainActor in self?.showQRCodeDialog(url: url) } } tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) } 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.filterType.title filterButtons.forEach { type, button in let selected = type == viewModel.filterType button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular) } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.items) dataSource.apply(snapshot, animatingDifferences: true) tableView.refreshControl?.endRefreshing() emptyView.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading() } private func configureFilterDropdown() { filterDropdownStack.arrangedSubviews.forEach { $0.removeFromSuperview() } filterButtons = PunchPointFilterType.allCases.map { type in let button = UIButton(type: .system) button.contentHorizontalAlignment = .left button.setTitle(type.title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14) var configuration = UIButton.Configuration.plain() configuration.contentInsets = NSDirectionalEdgeInsets(top: 13, leading: 12, bottom: 13, trailing: 12) button.configuration = configuration button.addAction(UIAction { [weak self] _ in guard let self else { return } self.filterDropdownView.isHidden = true Task { await self.viewModel.selectFilter(type, api: self.api) } }, for: .touchUpInside) filterDropdownStack.addArrangedSubview(button) return (type, button) } } private func confirmDelete(_ item: PunchPointItem) { let alert = UIAlertController( title: "删除打卡点", message: "确定删除\(item.name)打卡点吗?", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "确定", style: .destructive) { [weak self] _ in guard let self else { return } Task { await self.viewModel.delete(item: item, api: self.api) } }) present(alert, animated: true) } private func showQRCodeDialog(url: String) { let controller = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .alert) let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 12 if let imageURL = URL(string: url) { imageView.kf.setImage(with: imageURL) } controller.view.addSubview(imageView) imageView.snp.makeConstraints { make in make.centerX.equalToSuperview() make.top.equalToSuperview().offset(52) make.size.equalTo(180) } controller.addAction(UIAlertAction(title: "确定", style: .default)) present(controller, animated: true) } @objc private func filterTapped() { filterDropdownView.isHidden.toggle() } @objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } } @objc private func addTapped() { let controller = PunchPointFormViewController(mode: .create) controller.onSubmitSuccess = { [weak self] in self?.needsRefreshOnAppear = true } navigationController?.pushViewController(controller, animated: true) } } extension PunchPointListViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard let item = dataSource.itemIdentifier(for: indexPath) else { return } let controller = PunchPointDetailViewController(punchPointId: item.id) controller.onChanged = { [weak self] in self?.needsRefreshOnAppear = true } navigationController?.pushViewController(controller, animated: true) } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api) } } } /// 打卡点列表卡片。 /// 打卡点列表卡片,复刻 Android 卡片中的封面、状态、负责人、时间、地址与操作区。 private final class PunchPointListCell: UITableViewCell { static let reuseIdentifier = "PunchPointListCell" private let cardView = UIView() private let coverImageView = UIImageView() private let nameLabel = UILabel() private let operatingTag = PunchPointStatusChip() private let creatorLabel = UILabel() private let createdAtLabel = UILabel() private let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse")) private let addressLabel = UILabel() private let reviewTag = PunchPointStatusChip() private let auditTimeLabel = UILabel() private let qrButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system) var onQRCode: (() -> Void)? var onDelete: (() -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) backgroundColor = .clear contentView.backgroundColor = .clear selectionStyle = .none cardView.backgroundColor = .white cardView.layer.cornerRadius = 16 cardView.clipsToBounds = true coverImageView.contentMode = .scaleAspectFill coverImageView.clipsToBounds = true coverImageView.layer.cornerRadius = 12 coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5) nameLabel.font = .systemFont(ofSize: 16, weight: .semibold) nameLabel.textColor = .black nameLabel.lineBreakMode = .byTruncatingTail [creatorLabel, createdAtLabel, auditTimeLabel].forEach { $0.font = .systemFont(ofSize: 12) $0.textColor = AppColor.textSecondary } locationIcon.tintColor = UIColor(hex: 0x4B5563) addressLabel.font = .systemFont(ofSize: 12) addressLabel.textColor = UIColor(hex: 0x4B5563) addressLabel.numberOfLines = 2 qrButton.setImage(UIImage(systemName: "qrcode"), for: .normal) qrButton.tintColor = AppColor.textPrimary deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) deleteButton.tintColor = AppColor.textPrimary contentView.addSubview(cardView) [coverImageView, nameLabel, operatingTag, creatorLabel, createdAtLabel, locationIcon, addressLabel, reviewTag, auditTimeLabel, qrButton, deleteButton].forEach(cardView.addSubview) cardView.snp.makeConstraints { make in make.top.bottom.equalToSuperview().inset(6) make.leading.trailing.equalToSuperview().inset(15) } coverImageView.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(16) make.size.equalTo(128) } nameLabel.snp.makeConstraints { make in make.top.equalTo(coverImageView) make.leading.equalTo(coverImageView.snp.trailing).offset(12) make.trailing.lessThanOrEqualTo(operatingTag.snp.leading).offset(-8) } operatingTag.snp.makeConstraints { make in make.centerY.equalTo(nameLabel) make.trailing.equalToSuperview().inset(16) } creatorLabel.snp.makeConstraints { make in make.top.equalTo(nameLabel.snp.bottom).offset(8) make.leading.equalTo(nameLabel) make.trailing.equalToSuperview().inset(16) } createdAtLabel.snp.makeConstraints { make in make.top.equalTo(creatorLabel.snp.bottom).offset(6) make.leading.trailing.equalTo(creatorLabel) } locationIcon.snp.makeConstraints { make in make.top.equalTo(createdAtLabel.snp.bottom).offset(8) make.leading.equalTo(nameLabel) make.size.equalTo(14) } addressLabel.snp.makeConstraints { make in make.top.equalTo(locationIcon).offset(-1) make.leading.equalTo(locationIcon.snp.trailing).offset(4) make.trailing.equalToSuperview().inset(16) } reviewTag.snp.makeConstraints { make in make.top.equalTo(coverImageView.snp.bottom).offset(14) make.leading.equalToSuperview().offset(16) make.bottom.equalToSuperview().inset(16) } auditTimeLabel.snp.makeConstraints { make in make.centerY.equalTo(reviewTag) make.leading.equalTo(reviewTag.snp.trailing).offset(8) } deleteButton.snp.makeConstraints { make in make.centerY.equalTo(reviewTag) make.trailing.equalToSuperview().inset(16) make.size.equalTo(32) } qrButton.snp.makeConstraints { make in make.centerY.equalTo(reviewTag) make.trailing.equalTo(deleteButton.snp.leading).offset(-4) make.size.equalTo(32) } qrButton.addTarget(self, action: #selector(qrTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(item: PunchPointItem) { nameLabel.text = item.name creatorLabel.text = "负责人:\(item.creator?.nonEmptyTrimmed ?? "--")" createdAtLabel.text = "创建时间:\(item.createdAt)" addressLabel.text = item.displayAddress.nonEmptyTrimmed ?? "--" auditTimeLabel.text = "审核时间:\(item.auditTime?.nonEmptyTrimmed ?? "--")" operatingTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status) reviewTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status) if let url = item.guideImages.first.flatMap(URL.init(string:)) { coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) } else { coverImageView.image = UIImage(systemName: "photo") } } @objc private func qrTapped() { onQRCode?() } @objc private func deleteTapped() { onDelete?() } } /// 打卡点状态标签。 /// 打卡点状态标签视图。 final class PunchPointStatusChip: UIView { private let label = UILabel() override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 10 clipsToBounds = true label.font = .systemFont(ofSize: 12, weight: .medium) label.textAlignment = .center addSubview(label) label.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8)) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 应用标签文案和状态色。 func apply(text: String, status: Int) { label.text = text let colors = PunchPointDisplayFormatter.statusColors(status) backgroundColor = UIColor(hex: colors.background) label.textColor = UIColor(hex: colors.text) } } /// 打卡点列表空状态。 /// 打卡点空列表视图,提供空状态文案与新增入口。 private final class PunchPointEmptyView: UIView { private let titleLabel = UILabel() private let button = UIButton(type: .system) var onAction: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) titleLabel.text = "暂无打卡点" titleLabel.font = .systemFont(ofSize: 15) titleLabel.textColor = AppColor.textSecondary titleLabel.textAlignment = .center button.setTitle("添加打卡点", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) button.backgroundColor = AppColor.primary button.layer.cornerRadius = 8 addSubview(titleLabel) addSubview(button) titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } button.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(12) make.centerX.bottom.equalToSuperview() make.width.equalTo(128) make.height.equalTo(40) } button.addTarget(self, action: #selector(actionTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func actionTapped() { onAction?() } } private extension String { var nonEmptyTrimmed: String? { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? nil : text } }