feat: add live module and optimize report list

This commit is contained in:
2026-07-08 14:25:14 +08:00
parent 8ca8bcf948
commit 4a78a0c21a
14 changed files with 3712 additions and 102 deletions

View File

@ -9,8 +9,9 @@ import UIKit
///
final class WildPhotographerReportListViewController: BaseViewController {
private let viewModel: WildPhotographerReportListViewModel
private let segmentedControl = UISegmentedControl(items: WildReportListFilter.allCases.map(\.rawValue))
private let tableView = UITableView(frame: .zero, style: .plain)
private let scrollView = UIScrollView()
private let stack = UIStackView()
private let filterTabBar = WildReportFilterTabBarView()
init(homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
@ -28,132 +29,134 @@ final class WildPhotographerReportListViewController: BaseViewController {
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
segmentedControl.selectedSegmentIndex = 0
segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = true
stack.axis = .vertical
stack.spacing = 14
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.delegate = self
tableView.dataSource = self
tableView.register(WildReportListCell.self, forCellReuseIdentifier: WildReportListCell.reuseIdentifier)
tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.lg, right: 0)
filterTabBar.onSelect = { [weak self] filter in
self?.viewModel.selectFilter(filter)
}
view.addSubview(segmentedControl)
view.addSubview(tableView)
view.addSubview(scrollView)
scrollView.addSubview(stack)
reloadContent(animated: false)
}
override func setupConstraints() {
segmentedControl.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
stack.snp.makeConstraints { make in
make.top.equalTo(scrollView.contentLayoutGuide).offset(14)
make.leading.equalTo(scrollView.contentLayoutGuide).offset(18)
make.trailing.equalTo(scrollView.contentLayoutGuide).inset(18)
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24)
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.tableView.reloadData() }
Task { @MainActor in self?.reloadContent(animated: true) }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
@objc private func filterChanged() {
let filter = WildReportListFilter.allCases[segmentedControl.selectedSegmentIndex]
viewModel.selectFilter(filter)
}
}
extension WildPhotographerReportListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let count = viewModel.filteredReports.count
tableView.backgroundView = count == 0 ? WildReportEmptyView(text: "暂无相关举报记录") : nil
return count
@MainActor
private func reloadContent(animated: Bool) {
let changes: () -> Void = { [weak self] in
self?.rebuildContent()
}
guard animated else {
changes()
return
}
UIView.transition(
with: stack,
duration: 0.22,
options: [.transitionCrossDissolve, .allowUserInteraction],
animations: changes
)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: WildReportListCell.reuseIdentifier, for: indexPath) as! WildReportListCell
let record = viewModel.filteredReports[indexPath.row]
cell.apply(record: record)
cell.onShare = { [weak self] in self?.viewModel.shareReport(record) }
return cell
private func rebuildContent() {
stack.arrangedSubviews.forEach { view in
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
filterTabBar.apply(selectedFilter: viewModel.selectedFilter)
stack.addArrangedSubview(filterTabBar)
let reports = viewModel.filteredReports
if reports.isEmpty {
stack.addArrangedSubview(WildReportListEmptyView())
} else {
reports.forEach { record in
let card = WildReportListCardView(record: record)
card.onOpen = { [weak self] in self?.openDetail(record) }
card.onShare = { [weak self] in self?.viewModel.shareReport(record) }
stack.addArrangedSubview(card)
}
}
let footer = WildReportUI.label(viewModel.footerTip, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0)
footer.textAlignment = .center
let footerWrap = UIView()
footerWrap.addSubview(footer)
footer.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
stack.addArrangedSubview(footerWrap)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let record = viewModel.filteredReports[indexPath.row]
private func openDetail(_ record: WildReportRecord) {
navigationController?.pushViewController(
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel),
animated: true
)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
}
/// Cell
final class WildReportListCell: UITableViewCell {
static let reuseIdentifier = "WildReportListCell"
/// 使
private final class WildReportFilterTabBarView: UIView {
var onSelect: ((WildReportListFilter) -> Void)?
private let stack = UIStackView()
private var buttons: [WildReportListFilter: UIButton] = [:]
private let card = WildReportCardView(spacing: AppSpacing.sm)
private let titleLabel = UILabel()
private let statusView = WildReportStatusView()
private let typeLabel = UILabel()
private let locationLabel = UILabel()
private let timeLabel = UILabel()
private let descLabel = UILabel()
private let materialLabel = UILabel()
var onShare: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(card)
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.xs, left: AppSpacing.md, bottom: AppSpacing.xs, right: AppSpacing.md))
stack.axis = .horizontal
stack.spacing = 0
stack.distribution = .fillEqually
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(4)
}
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
header.spacing = AppSpacing.sm
titleLabel.font = .app(.title)
titleLabel.textColor = AppColor.textPrimary
header.addArrangedSubview(titleLabel)
header.addArrangedSubview(UIView())
statusView.snp.makeConstraints { make in
make.width.equalTo(64)
make.height.equalTo(24)
}
header.addArrangedSubview(statusView)
[typeLabel, locationLabel, timeLabel, descLabel, materialLabel].forEach { label in
label.font = .app(.body)
label.textColor = AppColor.textSecondary
label.numberOfLines = 0
WildReportListFilter.allCases.forEach { filter in
let button = UIButton(type: .system)
button.setTitle(filter.rawValue, for: .normal)
button.titleLabel?.adjustsFontSizeToFitWidth = true
button.titleLabel?.minimumScaleFactor = 0.85
button.layer.cornerRadius = 8
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
buttons[filter] = button
stack.addArrangedSubview(button)
}
let shareButton = UIButton(type: .system)
shareButton.setTitle("分享", for: .normal)
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
shareButton.titleLabel?.font = .app(.captionMedium)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
card.stack.addArrangedSubview(header)
card.stack.addArrangedSubview(typeLabel)
card.stack.addArrangedSubview(locationLabel)
card.stack.addArrangedSubview(timeLabel)
card.stack.addArrangedSubview(descLabel)
card.stack.addArrangedSubview(materialLabel)
card.stack.addArrangedSubview(shareButton)
snp.makeConstraints { make in
make.height.equalTo(48)
}
}
@available(*, unavailable)
@ -161,14 +164,202 @@ final class WildReportListCell: UITableViewCell {
fatalError("init(coder:) has not been implemented")
}
func apply(record: WildReportRecord) {
titleLabel.text = record.title
statusView.apply(status: record.status)
typeLabel.text = "举报类型:\(record.reportType.rawValue)"
locationLabel.text = "位置:\(record.location)"
timeLabel.text = "提交时间:\(record.submitTime)"
descLabel.text = record.description
materialLabel.text = "材料:\(record.imageCount)张图片 \(record.videoCount)段视频"
func apply(selectedFilter: WildReportListFilter) {
buttons.forEach { filter, button in
let selected = filter == selectedFilter
button.backgroundColor = selected ? UIColor(hex: 0xEAF3FF) : .clear
button.setTitleColor(selected ? AppColor.primary : UIColor(hex: 0x6B7280), for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 13, weight: selected ? .bold : .medium)
}
}
@objc private func filterTapped(_ sender: UIButton) {
guard let filter = buttons.first(where: { $0.value == sender })?.key else { return }
onSelect?(filter)
}
}
///
private final class WildReportListCardView: UIControl {
var onOpen: (() -> Void)?
var onShare: (() -> Void)?
private let record: WildReportRecord
private let scenicName: String
private let detailAddress: String
private let statusColor: UIColor
init(record: WildReportRecord) {
self.record = record
let location = WildReportLocationParser.split(record.location)
self.scenicName = location.scenic
self.detailAddress = location.detail
self.statusColor = record.status.displayColor
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isHighlighted: Bool {
didSet {
alpha = isHighlighted ? 0.82 : 1
}
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 16
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
addTarget(self, action: #selector(openTapped), for: .touchUpInside)
let contentStack = UIStackView()
contentStack.axis = .vertical
contentStack.spacing = 13
addSubview(contentStack)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
contentStack.addArrangedSubview(makeHeader())
contentStack.addArrangedSubview(makeMetaRows())
contentStack.addArrangedSubview(makeLocationRow())
}
private func makeHeader() -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
row.spacing = 12
let iconBox = UIView()
iconBox.backgroundColor = statusColor.withAlphaComponent(0.12)
iconBox.layer.cornerRadius = 12
let icon = UIImageView(image: UIImage(systemName: "camera.fill"))
icon.tintColor = statusColor
icon.contentMode = .scaleAspectFit
iconBox.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
iconBox.snp.makeConstraints { make in
make.size.equalTo(44)
}
row.addArrangedSubview(iconBox)
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 8
let titleStatusRow = UIStackView()
titleStatusRow.axis = .horizontal
titleStatusRow.alignment = .top
titleStatusRow.spacing = 8
let titleLabel = WildReportUI.label(record.title, font: .app(.title), color: AppColor.textPrimary, lines: 2)
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
titleStatusRow.addArrangedSubview(titleLabel)
titleStatusRow.addArrangedSubview(makeStatusAndShareGroup())
textStack.addArrangedSubview(titleStatusRow)
textStack.addArrangedSubview(makeScenicPill())
row.addArrangedSubview(textStack)
return row
}
private func makeStatusAndShareGroup() -> UIView {
let group = UIStackView()
group.axis = .horizontal
group.alignment = .center
group.spacing = 6
group.setContentHuggingPriority(.required, for: .horizontal)
let status = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8))
status.text = record.status.rawValue
status.font = .systemFont(ofSize: 11, weight: .semibold)
status.textColor = statusColor
status.textAlignment = .center
status.backgroundColor = statusColor.withAlphaComponent(0.12)
status.layer.cornerRadius = 12
status.clipsToBounds = true
status.snp.makeConstraints { make in
make.height.equalTo(24)
}
let shareButton = UIButton(type: .system)
shareButton.backgroundColor = AppColor.primaryLight
shareButton.layer.cornerRadius = 14
shareButton.tintColor = AppColor.primary
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
shareButton.accessibilityLabel = "分享举报"
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
shareButton.snp.makeConstraints { make in
make.size.equalTo(28)
}
group.addArrangedSubview(status)
group.addArrangedSubview(shareButton)
return group
}
private func makeScenicPill() -> UIView {
let label = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8))
label.text = scenicName
label.font = .systemFont(ofSize: 12, weight: .semibold)
label.textColor = AppColor.primary
label.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
label.layer.cornerRadius = 11
label.clipsToBounds = true
label.snp.makeConstraints { make in
make.height.equalTo(22)
}
return label
}
private func makeMetaRows() -> UIView {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
stack.addArrangedSubview(WildReportListMetaRowView(title: "举报编号", value: record.id))
stack.addArrangedSubview(WildReportListMetaRowView(title: "举报时间", value: record.submitTime))
return stack
}
private func makeLocationRow() -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 6
row.layoutMargins = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
row.isLayoutMarginsRelativeArrangement = true
let icon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
icon.tintColor = UIColor(hex: 0x9CA3AF)
icon.contentMode = .scaleAspectFit
icon.snp.makeConstraints { make in
make.size.equalTo(14)
}
let addressLabel = WildReportUI.label(detailAddress, font: .app(.caption), color: AppColor.textSecondary)
addressLabel.lineBreakMode = .byTruncatingTail
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
chevron.tintColor = UIColor(hex: 0xC4CAD4)
chevron.contentMode = .scaleAspectFit
chevron.snp.makeConstraints { make in
make.size.equalTo(13)
}
row.addArrangedSubview(icon)
row.addArrangedSubview(addressLabel)
row.addArrangedSubview(UIView())
row.addArrangedSubview(chevron)
return row
}
@objc private func openTapped() {
onOpen?()
}
@objc private func shareTapped() {
@ -176,6 +367,68 @@ final class WildReportListCell: UITableViewCell {
}
}
///
private final class WildReportListMetaRowView: UIView {
init(title: String, value: String) {
super.init(frame: .zero)
let titleLabel = WildReportUI.label(title, font: .app(.caption), color: UIColor(hex: 0x9CA3AF))
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 13, weight: .medium), color: AppColor.textPrimary)
valueLabel.textAlignment = .right
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.8
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
make.trailing.top.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportListEmptyView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 16
let stack = UIStackView()
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 10
let icon = UIImageView(image: UIImage(systemName: "doc.text.magnifyingglass"))
icon.tintColor = UIColor(hex: 0xB6BECA)
icon.contentMode = .scaleAspectFit
icon.snp.makeConstraints { make in
make.size.equalTo(36)
}
let label = WildReportUI.label("暂无相关举报记录", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary)
stack.addArrangedSubview(icon)
stack.addArrangedSubview(label)
addSubview(stack)
stack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(48)
make.leading.trailing.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class WildPhotographerReportDetailViewController: BaseViewController {
private let viewModel: WildPhotographerReportDetailViewModel