// // 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(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: [(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(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 } } 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) } } } @MainActor private func applyViewModel() { filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : 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([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item)) 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 = 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.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 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 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 titleLabel = UILabel() private let timeLabel = UILabel() private let statusBadge = MaterialStatusBadgeView() private let listingSwitch = UISwitch() private let statsStack = UIStackView() private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle") private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count") private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count") private var isApplying = 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 titleLabel.text = item.name timeLabel.text = item.createdAt statusBadge.apply(status: item.status) listingSwitch.setOn(item.listingStatus == 1, 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(titleLabel) cardView.addSubview(timeLabel) cardView.addSubview(statusBadge) cardView.addSubview(listingSwitch) cardView.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) } titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.equalTo(coverImageView.snp.trailing).offset(12) make.trailing.equalToSuperview().inset(12) } timeLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(4) 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().inset(12) make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8) } statsStack.snp.makeConstraints { make in make.top.equalTo(statusBadge.snp.bottom).offset(8) make.leading.equalTo(titleLabel) make.trailing.lessThanOrEqualToSuperview().inset(12) make.bottom.lessThanOrEqualToSuperview().inset(12) } } @objc private func switchChanged() { guard !isApplying else { return } onToggle?(listingSwitch.isOn) } } /// 素材状态 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) } }