Files
lujiuyinandCursor efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

545 lines
19 KiB
Swift

//
// 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")
}
}