// // UploadSampleViewController.swift // suixinkan // import AVFoundation import PhotosUI import SnapKit import UniformTypeIdentifiers import UIKit /// 样片相册上传页,对齐 Android `UploadSampleScreen`。 final class UploadSampleViewController: SampleBaseViewController { private enum PickTarget { case material case cover } private let viewModel: UploadSampleViewModel private let api: any SampleManagementServing private let uploader: any SampleOSSUploading private let scrollView = UIScrollView() private let formCard = UIView() private let formStack = UIStackView() private let nameField = UITextField() private let nameSection = SampleFormFieldView(title: "相册名称") private let mediaTypeButton = SampleSelectButton() private let mediaTypeSection = SampleFormFieldView(title: "素材类型") private let mediaGridView = SampleUploadGridView() private let materialSection = SampleFormFieldView(title: "上传素材") private let materialHintLabel = UILabel() private let coverView = SampleCoverUploadView() private let coverSection = SampleFormFieldView(title: "上传封面") private let scenicButton = SampleSelectButton() private let scenicSection = SampleFormFieldView(title: "关联景区") private let spotButton = SampleSelectButton() private let spotSection = SampleFormFieldView(title: "关联打卡点") private let projectButton = SampleSelectButton() private let projectSection = SampleFormFieldView(title: "关联项目") private let bottomBar = UIView() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) private let progressOverlay = SampleUploadProgressOverlay() private let dropdownOverlay = SampleAnchoredDropdownView() private var pickTarget: PickTarget = .material var onUploadSuccess: (() -> Void)? /// 初始化样片上传页。 init( viewModel: UploadSampleViewModel = UploadSampleViewModel(), api: (any SampleManagementServing)? = nil, uploader: (any SampleOSSUploading)? = nil ) { self.viewModel = viewModel self.api = api ?? NetworkServices.shared.sampleManagementAPI self.uploader = uploader ?? NetworkServices.shared.ossUploadService super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { super.setupNavigationBar() setSampleTitle("样片相册") } override func setupUI() { super.setupUI() view.backgroundColor = SampleManagementUITokens.pageBackground formCard.backgroundColor = .white formCard.layer.cornerRadius = 12 formCard.clipsToBounds = true formStack.axis = .vertical formStack.spacing = 0 nameField.placeholder = "请输入相册名称" nameField.font = .systemFont(ofSize: 14) nameField.textColor = .black nameField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor nameField.layer.borderWidth = 1 nameField.layer.cornerRadius = 8 nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) nameField.leftViewMode = .always nameField.clearButtonMode = .whileEditing nameSection.setContent(nameField) mediaTypeSection.setContent(mediaTypeButton) materialSection.setContent(makeMaterialContent()) coverSection.setContent(coverView) scenicSection.setContent(scenicButton) spotSection.setContent(spotButton) projectSection.setContent(projectButton) [nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection] .forEach(formStack.addArrangedSubview) formStack.setCustomSpacing(8, after: nameSection) formStack.setCustomSpacing(8, after: mediaTypeSection) formStack.setCustomSpacing(16, after: materialSection) formStack.setCustomSpacing(16, after: coverSection) formStack.setCustomSpacing(8, after: scenicSection) formStack.setCustomSpacing(8, after: spotSection) bottomBar.backgroundColor = .white configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary) configureBottomButton(confirmButton, title: "确认", backgroundColor: AppColor.primary, titleColor: .white) view.addSubview(scrollView) scrollView.addSubview(formCard) formCard.addSubview(formStack) view.addSubview(bottomBar) bottomBar.addSubview(cancelButton) bottomBar.addSubview(confirmButton) view.addSubview(dropdownOverlay) view.addSubview(progressOverlay) progressOverlay.isHidden = true } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } cancelButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.equalToSuperview().offset(15) make.width.equalTo(100) make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) } confirmButton.snp.makeConstraints { make in make.top.height.equalTo(cancelButton) make.leading.equalTo(cancelButton.snp.trailing).offset(12) make.trailing.equalToSuperview().inset(15) } scrollView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomBar.snp.top) } formCard.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) make.width.equalTo(scrollView).offset(-32) } formStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } nameField.snp.makeConstraints { make in make.height.equalTo(44) } [mediaTypeButton, scenicButton, spotButton, projectButton].forEach { button in button.snp.makeConstraints { make in make.height.equalTo(44) } } progressOverlay.snp.makeConstraints { make in make.edges.equalToSuperview() } dropdownOverlay.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.onUploadSuccess = { [weak self] in Task { @MainActor in self?.onUploadSuccess?() self?.navigationController?.popViewController(animated: true) } } nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside) mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) } mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) } mediaGridView.onPreview = { [weak self] index in self?.presentMediaPreview(startingAt: index) } coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) } coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() } scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside) spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside) projectButton.addTarget(self, action: #selector(projectTapped), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) applyViewModel() } private func makeMaterialContent() -> UIView { let container = UIView() materialHintLabel.text = "建议尺寸: 1920x1080px, 大小不超过20MB" materialHintLabel.font = .systemFont(ofSize: 12) materialHintLabel.textColor = UIColor(hex: 0xB6BECA) container.addSubview(mediaGridView) container.addSubview(materialHintLabel) mediaGridView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } materialHintLabel.snp.makeConstraints { make in make.top.equalTo(mediaGridView.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() } return container } private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) { button.setTitle(title, for: .normal) button.setTitleColor(titleColor, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) button.backgroundColor = backgroundColor button.layer.cornerRadius = 8 } private func applyViewModel() { if nameField.text != viewModel.albumName { nameField.text = viewModel.albumName } mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "") scenicButton.setTitle(viewModel.selectedScenic?.name, placeholder: "请选择景区") spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择关联打卡点") projectButton.setTitle(viewModel.selectedProject?.name, placeholder: "请选择关联项目") mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType) coverView.apply(item: viewModel.coverImage) confirmButton.alpha = viewModel.isSubmitting ? 0.6 : 1 confirmButton.isEnabled = !viewModel.isSubmitting if let dialog = viewModel.uploadDialogState { progressOverlay.apply(state: dialog) progressOverlay.isHidden = false } else { progressOverlay.isHidden = true } } @objc private func nameChanged() { viewModel.updateAlbumName(nameField.text ?? "") } @objc private func mediaTypeTapped() { let types = SampleMediaType.allCases dropdownOverlay.show( anchor: mediaTypeButton, options: types.map { .init(title: $0.title, isSelected: $0 == viewModel.mediaType) } ) { [weak self] index in guard types.indices.contains(index) else { return } self?.viewModel.selectMediaType(types[index]) } } @objc private func scenicTapped() { let scenics = viewModel.scenicList dropdownOverlay.show( anchor: scenicButton, options: scenics.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenic?.id) } ) { [weak self] index in guard let self, scenics.indices.contains(index) else { return } Task { await self.viewModel.selectScenic(scenics[index], api: self.api) } } } @objc private func spotTapped() { guard viewModel.selectedScenic != nil else { showToast("请先选择关联景区") return } let spots = viewModel.scenicSpotList dropdownOverlay.show( anchor: spotButton, options: spots.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenicSpot?.id) } ) { [weak self] index in guard spots.indices.contains(index) else { return } self?.viewModel.selectScenicSpot(spots[index]) } } @objc private func projectTapped() { guard viewModel.selectedScenic != nil else { showToast("请先选择景区") return } Task { if viewModel.projectList.isEmpty { showLoading() await viewModel.loadProjects(refresh: true, api: api) hideLoading() } await MainActor.run { self.presentProjectDropdown() } } } @MainActor private func presentProjectDropdown() { let projects = viewModel.projectList var options = projects.map { SampleAnchoredDropdownView.Option(title: $0.name, isSelected: $0.id == viewModel.selectedProject?.id) } if viewModel.projectCanLoadMore { options.append(.init(title: "加载更多...", isLoadMore: true)) } dropdownOverlay.show( anchor: projectButton, options: options, onReachedBottom: { [weak self] in self?.loadMoreProjectsAndRefreshDropdown() } ) { [weak self] index in guard let self else { return } if projects.indices.contains(index) { self.viewModel.selectProject(projects[index]) } else if self.viewModel.projectCanLoadMore { self.loadMoreProjectsAndRefreshDropdown() } } } private func loadMoreProjectsAndRefreshDropdown() { Task { await viewModel.loadProjects(refresh: false, api: api) await MainActor.run { self.presentProjectDropdown() } } } @objc private func cancelTapped() { navigationController?.popViewController(animated: true) } @objc private func confirmTapped() { Task { showLoading() defer { hideLoading() } await viewModel.uploadSample(api: api) } } private func presentPickerSource(target: PickTarget) { pickTarget = target let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) if UIImagePickerController.isSourceTypeAvailable(.camera) { let cameraTitle = viewModel.mediaType == .video && target == .material ? "拍摄视频" : "拍照" sheet.addAction(UIAlertAction(title: cameraTitle, style: .default) { [weak self] _ in self?.presentCamera() }) } sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in self?.presentPhotoPicker() }) sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) present(sheet, animated: true) } private func presentMediaPreview(startingAt sourceIndex: Int) { let completed = viewModel.uploadedMediaList.enumerated().filter { !$0.element.isUploading && !$0.element.ossUrl.isEmpty } guard let startIndex = completed.firstIndex(where: { $0.offset == sourceIndex }) else { return } let previewItems = completed.compactMap { entry -> MediaPreviewItem? in switch viewModel.mediaType { case .image: return .remoteImage(entry.element.ossUrl) case .video: return .remoteVideo(entry.element.ossUrl) } } guard !previewItems.isEmpty else { return } presentMediaPreview(items: previewItems, startIndex: min(startIndex, previewItems.count - 1)) } private func presentPhotoPicker() { var configuration = PHPickerConfiguration(photoLibrary: .shared()) if pickTarget == .cover { configuration.filter = .images configuration.selectionLimit = 1 } else { configuration.filter = viewModel.mediaType == .image ? .images : .videos configuration.selectionLimit = viewModel.mediaType == .video ? 1 : max(1, viewModel.mediaType.maxCount - viewModel.uploadedMediaList.count) } let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } private func presentCamera() { let picker = UIImagePickerController() picker.sourceType = .camera picker.delegate = self if pickTarget == .material, viewModel.mediaType == .video { picker.mediaTypes = [UTType.movie.identifier] picker.cameraCaptureMode = .video } else { picker.mediaTypes = [UTType.image.identifier] picker.cameraCaptureMode = .photo } present(picker, animated: true) } private func uploadPickedItems(_ items: [SampleUploadedMediaItem]) { guard !items.isEmpty else { return } Task { switch pickTarget { case .material: await viewModel.addLocalMedia(items, uploader: uploader) case .cover: await viewModel.setCoverImage(items[0], uploader: uploader) } } } private func makeImageItem(image: UIImage, fileName: String) -> SampleUploadedMediaItem? { guard let data = image.jpegData(compressionQuality: 0.9) else { return nil } return SampleUploadedMediaItem( data: data, fileName: fileName, width: Int(image.size.width * image.scale), height: Int(image.size.height * image.scale) ) } private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> SampleUploadedMediaItem { let preview = videoPreview(url: sourceURL) return SampleUploadedMediaItem( data: data, thumbnailData: preview.thumbnailData, fileName: fileName, width: preview.width, height: preview.height ) } private func videoPreview(url: URL) -> (thumbnailData: Data?, width: Int, height: Int) { let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) generator.appliesPreferredTrackTransform = true guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return (nil, 0, 0) } return ( UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75), imageRef.width, imageRef.height ) } } extension UploadSampleViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } var loadedItems = [SampleUploadedMediaItem?](repeating: nil, count: results.count) let group = DispatchGroup() for (index, result) in results.enumerated() { let provider = result.itemProvider if pickTarget == .material, viewModel.mediaType == .video { group.enter() provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in defer { group.leave() } guard let self, let url else { return } let data = (try? Data(contentsOf: url)) ?? Data() guard !data.isEmpty else { return } let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: "mp4") loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url) } } else { group.enter() let imageType = provider.registeredTypeIdentifiers.first { UTType($0)?.conforms(to: .image) == true } ?? UTType.image.identifier provider.loadFileRepresentation(forTypeIdentifier: imageType) { [weak self] url, _ in defer { group.leave() } guard let self, let url, let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return } let pathExtension = url.pathExtension let ext = UTType(imageType)?.preferredFilenameExtension ?? (pathExtension.isEmpty ? "jpg" : pathExtension) let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: ext) loadedItems[index] = SampleUploadedMediaItem( data: data, thumbnailData: image.jpegData(compressionQuality: 0.75), fileName: fileName, size: Int64(data.count), width: Int(image.size.width * image.scale), height: Int(image.size.height * image.scale) ) } } } group.notify(queue: .main) { [weak self] in self?.uploadPickedItems(loadedItems.compactMap { $0 }) } } private func resolvedFileName(provider: NSItemProvider, url: URL, fallbackExtension: String) -> String { let suggested = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !suggested.isEmpty { return URL(fileURLWithPath: suggested).pathExtension.isEmpty ? "\(suggested).\(fallbackExtension)" : suggested } let urlName = url.lastPathComponent if !urlName.isEmpty, urlName != "." { return urlName } return "sample_\(Int(Date().timeIntervalSince1970)).\(fallbackExtension)" } } extension UploadSampleViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { picker.dismiss(animated: true) if pickTarget == .material, viewModel.mediaType == .video, let url = info[.mediaURL] as? URL, let data = try? Data(contentsOf: url) { uploadPickedItems([makeVideoItem(data: data, fileName: url.lastPathComponent, sourceURL: url)]) return } if let image = info[.originalImage] as? UIImage, let item = makeImageItem(image: image, fileName: "sample_image_\(Int(Date().timeIntervalSince1970)).jpg") { uploadPickedItems([item]) } } } /// 样片上传表单字段容器。 private final class SampleFormFieldView: UIView { private let titleLabel = UILabel() private let contentHolder = UIView() init(title: String) { super.init(frame: .zero) titleLabel.text = title titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = .black addSubview(titleLabel) addSubview(contentHolder) titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } contentHolder.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setContent(_ view: UIView) { contentHolder.subviews.forEach { $0.removeFromSuperview() } contentHolder.addSubview(view) view.snp.makeConstraints { make in make.edges.equalToSuperview() } } } /// 样片上传下拉选择按钮。 private final class SampleSelectButton: UIControl { private let titleLabel = UILabel() private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down")) override init(frame: CGRect) { super.init(frame: frame) layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor layer.borderWidth = 1 layer.cornerRadius = 8 titleLabel.font = .systemFont(ofSize: 14) chevronView.tintColor = .black addSubview(titleLabel) addSubview(chevronView) titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8) } chevronView.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() make.size.equalTo(20) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setTitle(_ title: String?, placeholder: String) { let text = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" titleLabel.text = text.isEmpty ? placeholder : text titleLabel.textColor = text.isEmpty ? UIColor(hex: 0x4B5563) : .black } } /// 样片素材上传网格。 private final class SampleUploadGridView: UIView { private let stack = UIStackView() private var items: [SampleUploadedMediaItem] = [] private var mediaType: SampleMediaType = .image var onAdd: (() -> Void)? var onDelete: ((Int) -> Void)? var onPreview: ((Int) -> Void)? override init(frame: CGRect) { super.init(frame: frame) stack.axis = .vertical stack.spacing = 8 addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(items: [SampleUploadedMediaItem], mediaType: SampleMediaType) { self.items = items self.mediaType = mediaType stack.arrangedSubviews.forEach { $0.removeFromSuperview() } let showAdd = items.count < mediaType.maxCount let cells = items.map(Optional.some) + (showAdd ? [nil] : []) let rows = Int(ceil(Double(cells.count) / 3.0)) guard rows > 0 else { return } for row in 0 ..< rows { let rowStack = UIStackView() rowStack.axis = .horizontal rowStack.spacing = 8 rowStack.distribution = .fillEqually stack.addArrangedSubview(rowStack) for column in 0 ..< 3 { let index = row * 3 + column if index < cells.count { rowStack.addArrangedSubview(makeCell(item: cells[index], index: index)) } else { rowStack.addArrangedSubview(UIView()) } } } } private func makeCell(item: SampleUploadedMediaItem?, index: Int) -> UIView { let cell = SampleUploadThumbView(mediaType: mediaType) cell.snp.makeConstraints { make in make.height.equalTo(cell.snp.width).dividedBy(1.3) } if let item { cell.apply(item: item) cell.onDelete = { [weak self] in self?.onDelete?(index) } cell.onPreview = { [weak self] in self?.onPreview?(index) } } else { cell.applyAdd() cell.onAdd = { [weak self] in self?.onAdd?() } } return cell } } /// 样片上传缩略图格子。 private final class SampleUploadThumbView: UIControl { private let imageView = UIImageView() private let addCircle = UIView() private let addIcon = UIImageView(image: UIImage(named: "material_ic_add")) private let textLabel = UILabel() private let deleteButton = SampleExpandedTouchButton(type: .system) private let playIcon = UIImageView(image: UIImage(named: "material_ic_play")) private let mediaType: SampleMediaType private let dashedLayer = CAShapeLayer() private var previewEnabled = false var onAdd: (() -> Void)? var onDelete: (() -> Void)? var onPreview: (() -> Void)? init(mediaType: SampleMediaType) { self.mediaType = mediaType super.init(frame: .zero) layer.cornerRadius = 8 clipsToBounds = true backgroundColor = .white imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true addCircle.backgroundColor = AppColor.primary addCircle.layer.cornerRadius = 14 addIcon.tintColor = .white textLabel.font = .systemFont(ofSize: 12) textLabel.textColor = UIColor(hex: 0x4B5563) textLabel.textAlignment = .center deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.tintColor = .white deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.layer.cornerRadius = 10 playIcon.tintColor = .white playIcon.isHidden = true [imageView, addCircle, addIcon, textLabel, playIcon].forEach { $0.isUserInteractionEnabled = false } accessibilityLabel = mediaType == .image ? "上传图片" : "上传视频" addSubview(imageView) addSubview(addCircle) addCircle.addSubview(addIcon) addSubview(textLabel) addSubview(deleteButton) addSubview(playIcon) imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } addCircle.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview().offset(-10) make.size.equalTo(28) } addIcon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(20) } textLabel.snp.makeConstraints { make in make.top.equalTo(addCircle.snp.bottom).offset(4) make.leading.trailing.equalToSuperview().inset(4) } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4) make.size.equalTo(20) } playIcon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(32) } dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor dashedLayer.fillColor = UIColor.clear.cgColor dashedLayer.lineWidth = 1 dashedLayer.lineDashPattern = [6, 6] layer.addSublayer(dashedLayer) addTarget(self, action: #selector(cellTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() dashedLayer.frame = bounds dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath } func applyAdd() { imageView.image = nil backgroundColor = .white layer.borderWidth = 0 dashedLayer.isHidden = false previewEnabled = false addCircle.isHidden = false textLabel.isHidden = false textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频" deleteButton.isHidden = true playIcon.isHidden = true isUserInteractionEnabled = true } func apply(item: SampleUploadedMediaItem) { dashedLayer.isHidden = true previewEnabled = !item.ossUrl.isEmpty && !item.isUploading backgroundColor = UIColor(hex: 0xF5F5F5) addCircle.isHidden = true deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading playIcon.isHidden = mediaType != .video || item.ossUrl.isEmpty || item.isUploading if item.isUploading { imageView.image = nil textLabel.isHidden = false textLabel.text = "上传中...\n\(item.uploadProgress)%" textLabel.numberOfLines = 2 } else { textLabel.isHidden = true imageView.image = UIImage(data: item.thumbnailData ?? item.data) } } @objc private func cellTapped() { if !addCircle.isHidden { onAdd?() } else if previewEnabled { onPreview?() } } @objc private func deleteTapped() { onDelete?() } } /// 样片封面上传视图。 private final class SampleCoverUploadView: UIControl { private let imageView = UIImageView() private let addCircle = UIView() private let addIcon = UIImageView(image: UIImage(named: "material_ic_add")) private let titleLabel = UILabel() private let hintLabel = UILabel() private let deleteButton = SampleExpandedTouchButton(type: .system) private let dashedLayer = CAShapeLayer() var onAdd: (() -> Void)? var onDelete: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 8 dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor dashedLayer.fillColor = UIColor.clear.cgColor dashedLayer.lineWidth = 1 dashedLayer.lineDashPattern = [6, 6] layer.addSublayer(dashedLayer) clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true addCircle.backgroundColor = AppColor.primary addCircle.layer.cornerRadius = 16 addIcon.tintColor = .white titleLabel.text = "点击上传图片" titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = UIColor(hex: 0x4B5563) titleLabel.textAlignment = .center hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB" hintLabel.font = .systemFont(ofSize: 12) hintLabel.textColor = UIColor(hex: 0xB6BECA) hintLabel.textAlignment = .center [imageView, addCircle, addIcon, titleLabel, hintLabel].forEach { $0.isUserInteractionEnabled = false } accessibilityLabel = "上传封面图片" deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.tintColor = .white deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.layer.cornerRadius = 12 addSubview(imageView) addSubview(addCircle) addCircle.addSubview(addIcon) addSubview(titleLabel) addSubview(hintLabel) addSubview(deleteButton) snp.makeConstraints { make in make.height.equalTo(snp.width).dividedBy(2.59) } imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } addCircle.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview().offset(-22) make.size.equalTo(32) } addIcon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(24) } titleLabel.snp.makeConstraints { make in make.top.equalTo(addCircle.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(12) } hintLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(12) } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(8) make.size.equalTo(24) } addTarget(self, action: #selector(addTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() dashedLayer.frame = bounds dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath } func apply(item: SampleUploadedMediaItem?) { guard let item else { imageView.image = nil addCircle.isHidden = false titleLabel.isHidden = false titleLabel.text = "点击上传图片" titleLabel.numberOfLines = 1 hintLabel.isHidden = false deleteButton.isHidden = true dashedLayer.isHidden = false return } if item.isUploading { imageView.image = nil addCircle.isHidden = true titleLabel.isHidden = false titleLabel.text = "上传中...\n\(item.uploadProgress)%" titleLabel.numberOfLines = 2 hintLabel.isHidden = true deleteButton.isHidden = true dashedLayer.isHidden = true } else { imageView.image = UIImage(data: item.data) addCircle.isHidden = true titleLabel.isHidden = true hintLabel.isHidden = true deleteButton.isHidden = item.ossUrl.isEmpty dashedLayer.isHidden = true } } @objc private func addTapped() { guard addCircle.isHidden == false else { return } onAdd?() } @objc private func deleteTapped() { onDelete?() } } /// 样片上传进度蒙层。 private final class SampleUploadProgressOverlay: UIView { private let card = UIView() private let titleLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .default) private let percentLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor.black.withAlphaComponent(0.2) card.backgroundColor = .white card.layer.cornerRadius = 12 titleLabel.font = .systemFont(ofSize: 16, weight: .medium) titleLabel.textColor = AppColor.textPrimary progressView.progressTintColor = AppColor.primary progressView.trackTintColor = UIColor(hex: 0xF3F4F6) percentLabel.font = .systemFont(ofSize: 14) percentLabel.textColor = AppColor.textSecondary percentLabel.textAlignment = .center addSubview(card) card.addSubview(titleLabel) card.addSubview(progressView) card.addSubview(percentLabel) card.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(44) make.height.equalTo(card.snp.width).dividedBy(1.7) } titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(24) } progressView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(32) make.leading.trailing.equalToSuperview().inset(24) make.height.equalTo(6) } percentLabel.snp.makeConstraints { make in make.top.equalTo(progressView.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(24) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(state: SampleUploadDialogState) { titleLabel.text = state.title let progress = Float(max(0, min(100, state.progress))) / 100 progressView.setProgress(progress, animated: true) percentLabel.text = "\(max(0, min(100, state.progress)))%" } } /// 在不改变 Android 视觉尺寸的前提下,将小图标按钮触控区域扩展到至少 44pt。 private final class SampleExpandedTouchButton: UIButton { override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let horizontalInset = max(0, (44 - bounds.width) / 2) let verticalInset = max(0, (44 - bounds.height) / 2) return bounds.insetBy(dx: -horizontalInset, dy: -verticalInset).contains(point) } }