// // WildReportCommonViews.swift // suixinkan // import AVFoundation import PhotosUI import SnapKit import UIKit import UniformTypeIdentifiers /// 举报摄影师模块 UI 工具,统一卡片、标签和按钮样式。 enum WildReportUI { static func label(_ text: String, font: UIFont, color: UIColor, lines: Int = 1) -> UILabel { let label = UILabel() label.text = text label.font = font label.textColor = color label.numberOfLines = lines return label } static func iconButton(title: String, imageName: String, style: AppButton.Style = .primary) -> UIButton { let button = UIButton(type: .system) button.titleLabel?.font = .app(.subtitle) button.setTitle(title, for: .normal) button.setImage(UIImage(systemName: imageName), for: .normal) button.tintColor = style == .primary ? .white : AppColor.primary button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal) button.backgroundColor = style == .primary ? AppColor.primary : AppColor.primaryLight button.layer.cornerRadius = AppRadius.md button.setConfigurationContentInsets( NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) ) button.configuration?.imagePlacement = .leading button.configuration?.imagePadding = 4 return button } } /// 举报模块白底卡片容器。 final class WildReportCardView: UIView { let stack = UIStackView() init(spacing: CGFloat = AppSpacing.sm, inset: CGFloat = AppSpacing.md) { super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = AppRadius.lg layer.borderWidth = 1 layer.borderColor = AppColor.cardOutline.cgColor stack.axis = .vertical stack.spacing = max(0, spacing - AppSpacing.xxs) addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(inset) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 举报模块状态标签。 final class WildReportStatusView: UILabel { override init(frame: CGRect) { super.init(frame: frame) font = .app(.captionMedium) textAlignment = .center layer.cornerRadius = 12 clipsToBounds = true } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(status: WildReportStatus) { text = status.rawValue textColor = status.displayColor backgroundColor = status.backgroundColor } } /// 举报模块左右信息行。 final class WildReportInfoRowView: UIView { private let titleLabel = UILabel() private let valueLabel = UILabel() init(title: String, value: String) { super.init(frame: .zero) titleLabel.font = .app(.body) titleLabel.textColor = AppColor.textSecondary titleLabel.text = title valueLabel.font = .app(.bodyMedium) valueLabel.textColor = AppColor.textPrimary valueLabel.textAlignment = .right valueLabel.numberOfLines = 0 valueLabel.text = value addSubview(titleLabel) addSubview(valueLabel) titleLabel.snp.makeConstraints { make in make.leading.top.equalToSuperview() make.width.greaterThanOrEqualTo(76) make.bottom.lessThanOrEqualToSuperview() } valueLabel.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(AppSpacing.sm) make.trailing.top.bottom.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 举报模块附件占位 chip。 final class WildReportAttachmentChipView: UIView { private let titleLabel = UILabel() private let iconView = UIImageView() var onRemove: (() -> Void)? init(attachment: WildReportAttachment, removable: Bool = true) { super.init(frame: .zero) backgroundColor = AppColor.pageBackgroundSoft layer.cornerRadius = AppRadius.md layer.borderWidth = 1 layer.borderColor = AppColor.cardOutline.cgColor iconView.image = UIImage(systemName: iconName(for: attachment.kind)) iconView.tintColor = AppColor.primary titleLabel.text = attachment.title titleLabel.font = .app(.captionMedium) titleLabel.textColor = AppColor.textPrimary addSubview(iconView) addSubview(titleLabel) iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(AppSpacing.sm) make.centerY.equalToSuperview() make.size.equalTo(18) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.xs) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualToSuperview().inset(removable ? 34 : AppSpacing.sm) } if removable { let button = UIButton(type: .system) button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) button.tintColor = AppColor.textTertiary button.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) addSubview(button) button.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.xs) make.centerY.equalToSuperview() make.size.equalTo(28) } } snp.makeConstraints { make in make.height.equalTo(40) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func iconName(for kind: WildReportAttachmentKind) -> String { switch kind { case .image: return "photo.fill" case .video: return "video.fill" case .payment: return "creditcard.fill" } } @objc private func removeTapped() { onRemove?() } } /// 举报模块本地媒体选择目标。 enum WildReportPickerTarget: Equatable { case image case video case payment init(kind: WildReportAttachmentKind) { switch kind { case .image: self = .image case .video: self = .video case .payment: self = .payment } } var attachmentKind: WildReportAttachmentKind { switch self { case .image: return .image case .video: return .video case .payment: return .payment } } var titlePrefix: String { switch self { case .image: return "现场图片" case .video: return "现场视频" case .payment: return "支付截图" } } var limitMessage: String { switch self { case .image: return "现场证据最多上传9项" case .video: return "现场视频最多上传3段" case .payment: return "支付截图最多上传3张" } } } /// 举报模块 PHPicker 结果加载器,将图片/视频保存为可预览和待上传的本地文件。 enum WildReportLocalMediaLoader { static func load( results: [PHPickerResult], target: WildReportPickerTarget, titlePrefix: String? = nil ) async throws -> [WildReportAttachment] { var attachments: [WildReportAttachment] = [] for result in results { let attachment = target == .video ? try await loadVideo(from: result.itemProvider, target: target, titlePrefix: titlePrefix) : try await loadImage(from: result.itemProvider, target: target, titlePrefix: titlePrefix) attachments.append(attachment) } return attachments } private static func loadImage( from provider: NSItemProvider, target: WildReportPickerTarget, titlePrefix: String? ) async throws -> WildReportAttachment { try await withCheckedThrowingContinuation { continuation in guard provider.canLoadObject(ofClass: UIImage.self) else { continuation.resume(throwing: OSSUploadError.unsupportedFileType) return } provider.loadObject(ofClass: UIImage.self) { object, error in if let error { continuation.resume(throwing: error) return } guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { continuation.resume(throwing: OSSUploadError.emptyFile) return } do { let fileName = "report_image_\(UUID().uuidString).jpg" let url = try write(data: data, fileName: fileName) continuation.resume(returning: WildReportAttachment( kind: target.attachmentKind, title: titlePrefix ?? target.titlePrefix, localFileURL: url, fileName: fileName )) } catch { continuation.resume(throwing: error) } } } } private static func loadVideo( from provider: NSItemProvider, target: WildReportPickerTarget, titlePrefix: String? ) async throws -> WildReportAttachment { try await withCheckedThrowingContinuation { continuation in provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in if let error { continuation.resume(throwing: error) return } guard let url else { continuation.resume(throwing: OSSUploadError.emptyFile) return } do { let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension let fileName = "report_video_\(UUID().uuidString).\(ext)" let targetURL = try temporaryFileURL(fileName: fileName) if FileManager.default.fileExists(atPath: targetURL.path) { try FileManager.default.removeItem(at: targetURL) } try FileManager.default.copyItem(at: url, to: targetURL) continuation.resume(returning: WildReportAttachment( kind: target.attachmentKind, title: titlePrefix ?? target.titlePrefix, localFileURL: targetURL, fileName: fileName )) } catch { continuation.resume(throwing: error) } } } } private static func write(data: Data, fileName: String) throws -> URL { let url = try temporaryFileURL(fileName: fileName) try data.write(to: url, options: .atomic) return url } private static func temporaryFileURL(fileName: String) throws -> URL { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true) if !FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } return directory.appendingPathComponent(fileName) } } extension MediaPreviewItem { /// 从举报本地附件创建媒体预览资源项。 init?(wildReportAttachment attachment: WildReportAttachment) { guard let url = attachment.localFileURL else { return nil } switch attachment.kind { case .image, .payment: self = .localImage(url: url) case .video: self = .localVideo(url: url) } } } /// 举报模块附件缩略 tile,优先展示本地图片或视频封面。 final class WildReportAttachmentTileView: UIControl { private let gradientLayer = CAGradientLayer() private let previewAction: () -> Void private let removeAction: () -> Void private let tileSize: CGFloat init( attachment: WildReportAttachment, tileSize: CGFloat = 58, onPreview: @escaping () -> Void, onRemove: @escaping () -> Void ) { self.previewAction = onPreview self.removeAction = onRemove self.tileSize = tileSize super.init(frame: .zero) layer.cornerRadius = 10 clipsToBounds = false gradientLayer.cornerRadius = 10 let colors: [UIColor] = attachment.kind == .video ? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)] : [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)] gradientLayer.colors = colors.map(\.cgColor) gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradientLayer, at: 0) let thumbnailView = UIImageView() thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment) thumbnailView.contentMode = .scaleAspectFill thumbnailView.clipsToBounds = true thumbnailView.layer.cornerRadius = 10 thumbnailView.isUserInteractionEnabled = false thumbnailView.isHidden = thumbnailView.image == nil addSubview(thumbnailView) thumbnailView.snp.makeConstraints { make in make.edges.equalToSuperview() } let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill")) icon.tintColor = UIColor.white.withAlphaComponent(0.95) icon.contentMode = .scaleAspectFit icon.isHidden = attachment.kind != .video && thumbnailView.image != nil addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(attachment.kind == .video ? 30 : 26) } let removeButton = UIButton(type: .system) removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal) removeButton.tintColor = UIColor(hex: 0x8D93A1) removeButton.backgroundColor = .white removeButton.layer.cornerRadius = 14 removeButton.layer.shadowColor = UIColor.black.cgColor removeButton.layer.shadowOpacity = 0.10 removeButton.layer.shadowRadius = 4 removeButton.layer.shadowOffset = CGSize(width: 0, height: 2) removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) addSubview(removeButton) removeButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(-2) make.size.equalTo(34) } addTarget(self, action: #selector(previewTapped), for: .touchUpInside) snp.makeConstraints { make in make.size.equalTo(tileSize) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds } @objc private func previewTapped() { previewAction() } @objc private func removeTapped() { removeAction() } } /// 举报模块附件封面生成器,负责从本地图片或视频文件提取缩略图。 enum WildReportAttachmentThumbnailFactory { static func thumbnail(for attachment: WildReportAttachment) -> UIImage? { guard let url = attachment.localFileURL else { return nil } switch attachment.kind { case .image, .payment: return UIImage(contentsOfFile: url.path) case .video: return videoThumbnail(url: url) } } private static func videoThumbnail(url: URL) -> UIImage? { let asset = AVURLAsset(url: url) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true generator.maximumSize = CGSize(width: 160, height: 160) do { let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) return UIImage(cgImage: cgImage) } catch { return nil } } } /// 举报模块添加附件 tile,白底虚线加号。 final class WildReportAddAttachmentTileView: UIControl { private let tapAction: () -> Void private let borderLayer = CAShapeLayer() private let tileSize: CGFloat init( tileSize: CGFloat = 58, borderColor: UIColor = UIColor(hex: 0xCDD5E1), iconColor: UIColor = UIColor(hex: 0x8D93A1), action: @escaping () -> Void ) { self.tapAction = action self.tileSize = tileSize super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = 10 borderLayer.strokeColor = borderColor.cgColor borderLayer.fillColor = UIColor.clear.cgColor borderLayer.lineWidth = 1 borderLayer.lineDashPattern = [5, 4] layer.addSublayer(borderLayer) let icon = UIImageView(image: UIImage(systemName: "plus")) icon.tintColor = iconColor icon.contentMode = .scaleAspectFit addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(30) } addTarget(self, action: #selector(tapped), for: .touchUpInside) snp.makeConstraints { make in make.size.equalTo(tileSize) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath } @objc private func tapped() { tapAction() } } /// 举报模块空状态视图。 final class WildReportEmptyView: UIView { init(text: String) { super.init(frame: .zero) let icon = UIImageView(image: UIImage(systemName: "tray")) icon.tintColor = AppColor.textTertiary let label = WildReportUI.label(text, font: .app(.body), color: AppColor.textSecondary, lines: 0) label.textAlignment = .center addSubview(icon) addSubview(label) icon.snp.makeConstraints { make in make.top.centerX.equalToSuperview() make.size.equalTo(42) } label.snp.makeConstraints { make in make.top.equalTo(icon.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.bottom.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }