// // MaterialFormViewController.swift // suixinkan // import AVFoundation import Kingfisher import PhotosUI import SnapKit import UniformTypeIdentifiers import UIKit /// 素材上传 / 编辑页,对齐 Android `UploadMaterialScreen` 与 `EditMaterialScreen`。 /// 素材上传与编辑页,负责字段录入、媒体选择和提交交互。 final class MaterialFormViewController: BaseViewController { private enum PickTarget { case material case cover } private let viewModel: MaterialFormViewModel private let api: any MaterialManagementServing private let uploader: any MaterialOSSUploading private let scrollView = UIScrollView() private let formCard = UIView() private let formStack = UIStackView() private let nameField = UITextField() private let nameSection = MaterialFormFieldView(title: "素材名称") private let spotButton = MaterialSelectButton() private let spotSection = MaterialFormFieldView(title: "关联打卡点") private let mediaTypeButton = MaterialSelectButton() private let mediaTypeSection = MaterialFormFieldView(title: "素材类型") private let mediaGridView = MaterialUploadGridView() private let materialSection = MaterialFormFieldView(title: "上传素材") private let coverView = MaterialCoverUploadView() private let coverSection = MaterialFormFieldView(title: "上传封面") private let tagView = MaterialTagEditorView() private let tagSection = MaterialFormFieldView(title: "标签") private let bottomBar = UIView() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) private let progressOverlay = MaterialUploadProgressOverlay() private let dropdownOverlay = MaterialAnchoredDropdownView() private var pickTarget: PickTarget = .material var onSubmitSuccess: (() -> Void)? /// 初始化素材表单页。 init( mode: MaterialFormViewModel.Mode, api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI, uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService ) { viewModel = MaterialFormViewModel(mode: mode) self.api = api self.uploader = uploader super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { switch viewModel.mode { case .create: title = "上传素材" case .edit: title = "编辑素材" } } override func setupUI() { view.backgroundColor = UIColor(hex: 0xF4F4F4) formCard.backgroundColor = .white formCard.layer.cornerRadius = 12 formCard.clipsToBounds = true formStack.axis = .vertical formStack.spacing = 16 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) spotSection.setContent(spotButton) mediaTypeSection.setContent(mediaTypeButton) materialSection.setContent(mediaGridView) coverSection.setContent(coverView) tagSection.setContent(tagView) [nameSection, spotSection, mediaTypeSection, materialSection, coverSection, tagSection].forEach(formStack.addArrangedSubview) 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(progressOverlay) view.addSubview(dropdownOverlay) progressOverlay.isHidden = true dropdownOverlay.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.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) } [spotButton, mediaTypeButton].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.onSubmitSuccess = { [weak self] in Task { @MainActor in self?.onSubmitSuccess?() self?.navigationController?.popViewController(animated: true) } } nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside) 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() } tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) } tagView.onAdd = { [weak self] in guard let self else { return } Task { await self.viewModel.addTag(api: self.api) } } tagView.onDelete = { [weak self] tag in self?.viewModel.requestDeleteTag(tag) } cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) applyViewModel() } override func viewDidLoad() { super.viewDidLoad() Task { showLoading() await viewModel.loadInitial(api: api) hideLoading() } } @MainActor private func applyViewModel() { if nameField.text != viewModel.materialName { nameField.text = viewModel.materialName } spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择打卡点") mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "") mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType) coverView.apply(item: viewModel.coverImage) tagView.apply(tags: viewModel.tagList, input: viewModel.tagInput) 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 } presentTagDeleteDialogIfNeeded() } 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 } @objc private func nameChanged() { viewModel.updateMaterialName(nameField.text ?? "") } @objc private func spotTapped() { let spots = viewModel.scenicSpotList dropdownOverlay.show( in: view, anchor: spotButton, options: spots.map(\.name), selectedIndex: spots.firstIndex { $0.id == viewModel.selectedScenicSpot?.id }, maximumHeight: 128 ) { [weak self] index in guard spots.indices.contains(index) else { return } self?.viewModel.selectScenicSpot(spots[index]) } } @objc private func mediaTypeTapped() { let types = MaterialMediaType.allCases dropdownOverlay.show( in: view, anchor: mediaTypeButton, options: types.map(\.title), selectedIndex: types.firstIndex(of: viewModel.mediaType) ) { [weak self] index in guard types.indices.contains(index) else { return } self?.viewModel.selectMediaType(types[index]) } } @objc private func cancelTapped() { navigationController?.popViewController(animated: true) } @objc private func confirmTapped() { Task { showLoading() await viewModel.submit(api: api) hideLoading() } } 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 items = completed.map { MaterialPreviewItem(url: $0.element.ossUrl, mediaType: viewModel.mediaType) } present(MaterialMediaPreviewViewController(items: items, startIndex: startIndex), animated: true) } private func presentTagDeleteDialogIfNeeded() { guard let tag = viewModel.pendingDeleteTag, presentedViewController == nil else { return } let dialog = MaterialDeleteDialogViewController( title: "删除标签", itemName: tag.name, itemSuffix: "标签吗?", onCancel: { [weak self] in self?.viewModel.cancelDeleteTag() }, onConfirm: { [weak self] in self?.viewModel.confirmDeleteTag() } ) present(dialog, animated: true) } 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: [MaterialUploadedMediaItem]) { 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) -> MaterialUploadedMediaItem? { guard let data = image.jpegData(compressionQuality: 0.9) else { return nil } return MaterialUploadedMediaItem( 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) -> MaterialUploadedMediaItem { let size = videoDisplaySize(url: sourceURL) return MaterialUploadedMediaItem( data: data, thumbnailData: videoThumbnailData(url: sourceURL), fileName: fileName, width: Int(size.width), height: Int(size.height) ) } private func videoDisplaySize(url: URL) -> CGSize { let asset = AVAsset(url: url) guard let track = asset.tracks(withMediaType: .video).first else { return .zero } let transformed = track.naturalSize.applying(track.preferredTransform) return CGSize(width: abs(transformed.width), height: abs(transformed.height)) } private func videoThumbnailData(url: URL) -> Data? { let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) generator.appliesPreferredTrackTransform = true guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75) } } extension MaterialFormViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } var loadedItems = [MaterialUploadedMediaItem?](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 = provider.suggestedName.map { "\($0).mp4" } ?? "material_video_\(Int(Date().timeIntervalSince1970)).mp4" loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url) } } else { group.enter() provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in defer { group.leave() } guard let self, let image = object as? UIImage else { return } let fileName = "material_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg" loadedItems[index] = self.makeImageItem(image: image, fileName: fileName) } } } group.notify(queue: .main) { [weak self] in self?.uploadPickedItems(loadedItems.compactMap { $0 }) } } } extension MaterialFormViewController: 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: "material_image_\(Int(Date().timeIntervalSince1970)).jpg") { uploadPickedItems([item]) } } } /// 素材表单字段容器。 /// 素材表单中的标题字段容器。 private final class MaterialFormFieldView: 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 MaterialSelectButton: UIControl { private let titleLabel = UILabel() private let chevronView = UIImageView(image: UIImage(named: "material_ic_arrow_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 } } /// 锚定在表单选择框下方的 Android 风格下拉菜单。 private final class MaterialAnchoredDropdownView: UIControl { private let cardView = UIView() private let scrollView = UIScrollView() private let stackView = UIStackView() private var onSelect: ((Int) -> Void)? override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear cardView.backgroundColor = .white cardView.layer.cornerRadius = 4 cardView.layer.shadowColor = UIColor.black.cgColor cardView.layer.shadowOpacity = 0.14 cardView.layer.shadowRadius = 8 cardView.layer.shadowOffset = CGSize(width: 0, height: 4) stackView.axis = .vertical addSubview(cardView) cardView.addSubview(scrollView) scrollView.addSubview(stackView) addTarget(self, action: #selector(dismissMenu), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 在指定控件下方展示菜单。 func show( in container: UIView, anchor: UIView, options: [String], selectedIndex: Int?, maximumHeight: CGFloat = 128, onSelect: @escaping (Int) -> Void ) { guard !options.isEmpty else { return } self.onSelect = onSelect stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } for (index, option) in options.enumerated() { let button = UIButton(type: .system) button.tag = index button.contentHorizontalAlignment = .leading button.setConfigurationContentInsets( NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) ) button.setTitle(option, for: .normal) button.setTitleColor(index == selectedIndex ? AppColor.primary : .black, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: index == selectedIndex ? .medium : .regular) button.backgroundColor = .white button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside) button.snp.makeConstraints { make in make.height.equalTo(28) } stackView.addArrangedSubview(button) } isHidden = false container.bringSubviewToFront(self) layoutIfNeeded() let anchorFrame = anchor.convert(anchor.bounds, to: self) let menuHeight = min(maximumHeight, CGFloat(options.count) * 28) cardView.frame = CGRect(x: anchorFrame.minX, y: anchorFrame.maxY, width: anchorFrame.width, height: menuHeight) scrollView.frame = cardView.bounds stackView.frame = CGRect(x: 0, y: 0, width: cardView.bounds.width, height: CGFloat(options.count) * 28) scrollView.contentSize = stackView.frame.size } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { isHidden ? false : true } @objc private func optionTapped(_ sender: UIButton) { isHidden = true onSelect?(sender.tag) } @objc private func dismissMenu() { isHidden = true } } /// 素材上传网格。 /// 素材媒体上传网格视图。 private final class MaterialUploadGridView: UIView { private let stack = UIStackView() 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: [MaterialUploadedMediaItem], mediaType: MaterialMediaType) { 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, mediaType: mediaType)) } else { rowStack.addArrangedSubview(UIView()) } } } } private func makeCell(item: MaterialUploadedMediaItem?, index: Int, mediaType: MaterialMediaType) -> UIView { let cell = MaterialUploadThumbView(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 MaterialUploadThumbView: 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 = UIButton(type: .system) private let playIcon = UIImageView(image: UIImage(named: "material_ic_play")) private let mediaType: MaterialMediaType private let dashedLayer = CAShapeLayer() private var previewEnabled = false var onAdd: (() -> Void)? var onDelete: (() -> Void)? var onPreview: (() -> Void)? init(mediaType: MaterialMediaType) { 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 addCircle.isUserInteractionEnabled = false addIcon.isUserInteractionEnabled = false textLabel.font = .systemFont(ofSize: 12) textLabel.textColor = UIColor(hex: 0x4B5563) textLabel.textAlignment = .center textLabel.isUserInteractionEnabled = false 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 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 imageView.kf.cancelDownloadTask() backgroundColor = .white layer.borderWidth = 0 dashedLayer.isHidden = false previewEnabled = false addCircle.isHidden = false textLabel.isHidden = false textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频" textLabel.numberOfLines = 1 deleteButton.isHidden = true playIcon.isHidden = true } func apply(item: MaterialUploadedMediaItem) { 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 if mediaType == .video { if let thumbnailData = item.thumbnailData, let image = UIImage(data: thumbnailData) { imageView.image = image } else if let urlText = (item.previewURL?.isEmpty == false ? item.previewURL : item.ossUrl), !urlText.isEmpty, let url = URL(string: urlText) { loadVideoThumbnail(url: url) } } else if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { imageView.kf.setImage(with: url) } else { 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 func loadVideoThumbnail(url: URL) { imageView.image = nil let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url)) generator.appliesPreferredTrackTransform = true generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: .zero)]) { [weak self] _, image, _, _, _ in guard let image else { return } DispatchQueue.main.async { self?.imageView.image = UIImage(cgImage: image) } } } } /// 素材封面上传视图。 /// 素材封面上传视图。 private final class MaterialCoverUploadView: 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 = UIButton(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 imageView.isUserInteractionEnabled = false addCircle.backgroundColor = AppColor.primary addCircle.layer.cornerRadius = 16 addIcon.tintColor = .white addCircle.isUserInteractionEnabled = false addIcon.isUserInteractionEnabled = false titleLabel.text = "点击上传图片" titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = UIColor(hex: 0x4B5563) titleLabel.textAlignment = .center titleLabel.isUserInteractionEnabled = false hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB" hintLabel.font = .systemFont(ofSize: 12) hintLabel.textColor = UIColor(hex: 0xB6BECA) hintLabel.textAlignment = .center hintLabel.isUserInteractionEnabled = false 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: MaterialUploadedMediaItem?) { guard let item else { imageView.image = nil imageView.kf.cancelDownloadTask() 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 { if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { imageView.kf.setImage(with: url) } 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 MaterialTagEditorView: UIView { private let stack = UIStackView() private let tagsStack = UIStackView() private let inputRow = UIStackView() private let textField = UITextField() private let addButton = UIButton(type: .system) private var tags: [MaterialTagItem] = [] var onTextChanged: ((String) -> Void)? var onAdd: (() -> Void)? var onDelete: ((MaterialTagItem) -> Void)? override init(frame: CGRect) { super.init(frame: frame) stack.axis = .vertical stack.spacing = 8 tagsStack.axis = .horizontal tagsStack.spacing = 8 tagsStack.alignment = .center inputRow.axis = .horizontal inputRow.spacing = 8 textField.font = .systemFont(ofSize: 14) textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor textField.layer.borderWidth = 1 textField.layer.cornerRadius = 4 textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) textField.leftViewMode = .always textField.placeholder = "请输入标签,最低5个字符" addButton.setTitle("添加标签", for: .normal) addButton.setTitleColor(.white, for: .normal) addButton.backgroundColor = AppColor.primary addButton.layer.cornerRadius = 4 addSubview(stack) stack.addArrangedSubview(tagsStack) stack.addArrangedSubview(inputRow) inputRow.addArrangedSubview(textField) inputRow.addArrangedSubview(addButton) stack.snp.makeConstraints { make in make.edges.equalToSuperview() } textField.snp.makeConstraints { make in make.height.equalTo(36) } addButton.snp.makeConstraints { make in make.width.equalTo(80) make.height.equalTo(36) } textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(tags: [MaterialTagItem], input: String) { self.tags = tags if textField.text != input { textField.text = input } let canAdd = (5 ... 15).contains(input.count) addButton.isEnabled = canAdd addButton.backgroundColor = canAdd ? AppColor.primary : AppColor.buttonDisabled tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } tags.forEach { tag in let button = UIButton(type: .system) button.setTitle(tag.name, for: .normal) button.setTitleColor(AppColor.primary, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 12) button.backgroundColor = UIColor(hex: 0xEFF6FF) button.layer.cornerRadius = 4 button.setConfigurationContentInsets( NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) ) button.setContentHuggingPriority(.required, for: .horizontal) button.setContentCompressionResistancePriority(.required, for: .horizontal) button.snp.makeConstraints { make in make.height.equalTo(28) } button.addAction(UIAction { [weak self] _ in self?.onDelete?(tag) }, for: .touchUpInside) tagsStack.addArrangedSubview(button) } if !tags.isEmpty { // Android Row 中标签按内容宽度排列;尾部弹性占位吸收剩余空间。 tagsStack.addArrangedSubview(UIView()) } tagsStack.isHidden = tags.isEmpty } @objc private func textChanged() { onTextChanged?(textField.text ?? "") } @objc private func addTapped() { onAdd?() } } /// 素材上传进度蒙层。 /// 素材上传进度遮罩视图。 private final class MaterialUploadProgressOverlay: 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: MaterialUploadDialogState) { 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)))%" } }