完善样片管理列表、详情与上传页 UI 交互。

对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 15:00:18 +08:00
parent cbf2db649e
commit afcd412f11
16 changed files with 862 additions and 134 deletions

View File

@ -10,7 +10,7 @@ import UniformTypeIdentifiers
import UIKit
/// Android `UploadSampleScreen`
final class UploadSampleViewController: BaseViewController {
final class UploadSampleViewController: SampleBaseViewController {
private enum PickTarget {
case material
case cover
@ -42,6 +42,7 @@ final class UploadSampleViewController: BaseViewController {
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
@ -65,16 +66,18 @@ final class UploadSampleViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "样片相册"
super.setupNavigationBar()
setSampleTitle("样片相册")
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
super.setupUI()
view.backgroundColor = SampleManagementUITokens.pageBackground
formCard.backgroundColor = .white
formCard.layer.cornerRadius = 12
formCard.clipsToBounds = true
formStack.axis = .vertical
formStack.spacing = 16
formStack.spacing = 0
nameField.placeholder = "请输入相册名称"
nameField.font = .systemFont(ofSize: 14)
@ -96,6 +99,12 @@ final class UploadSampleViewController: BaseViewController {
[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)
@ -107,6 +116,7 @@ final class UploadSampleViewController: BaseViewController {
view.addSubview(bottomBar)
bottomBar.addSubview(cancelButton)
bottomBar.addSubview(confirmButton)
view.addSubview(dropdownOverlay)
view.addSubview(progressOverlay)
progressOverlay.isHidden = true
}
@ -128,7 +138,8 @@ final class UploadSampleViewController: BaseViewController {
make.trailing.equalToSuperview().inset(15)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.top.equalToSuperview()
make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
formCard.snp.makeConstraints { make in
@ -149,6 +160,9 @@ final class UploadSampleViewController: BaseViewController {
progressOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
dropdownOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
override func bindActions() {
@ -169,6 +183,7 @@ final class UploadSampleViewController: BaseViewController {
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)
@ -229,26 +244,25 @@ final class UploadSampleViewController: BaseViewController {
}
@objc private func mediaTypeTapped() {
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
SampleMediaType.allCases.forEach { type in
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
self?.viewModel.selectMediaType(type)
})
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])
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func scenicTapped() {
let sheet = UIAlertController(title: "关联景区", message: nil, preferredStyle: .actionSheet)
viewModel.scenicList.forEach { scenic in
sheet.addAction(UIAlertAction(title: scenic.name, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectScenic(scenic, api: self.api) }
})
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) }
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func spotTapped() {
@ -256,14 +270,14 @@ final class UploadSampleViewController: BaseViewController {
showToast("请先选择关联景区")
return
}
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
viewModel.scenicSpotList.forEach { spot in
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
self?.viewModel.selectScenicSpot(spot)
})
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])
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func projectTapped() {
@ -277,28 +291,36 @@ final class UploadSampleViewController: BaseViewController {
await viewModel.loadProjects(refresh: true, api: api)
hideLoading()
}
presentProjectSheet()
await MainActor.run { self.presentProjectDropdown() }
}
}
private func presentProjectSheet() {
let sheet = UIAlertController(title: "关联项目", message: nil, preferredStyle: .actionSheet)
viewModel.projectList.forEach { project in
sheet.addAction(UIAlertAction(title: project.name, style: .default) { [weak self] _ in
self?.viewModel.selectProject(project)
})
}
@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 {
sheet.addAction(UIAlertAction(title: "加载更多...", style: .default) { [weak self] _ in
guard let self else { return }
Task {
await self.viewModel.loadProjects(refresh: false, api: self.api)
self.presentProjectSheet()
}
})
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() }
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func cancelTapped() {
@ -329,6 +351,19 @@ final class UploadSampleViewController: BaseViewController {
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 {
@ -424,16 +459,28 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate {
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" } ?? "sample_video_\(Int(Date().timeIntervalSince1970)).mp4"
let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: "mp4")
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
}
} else {
group.enter()
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
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 image = object as? UIImage else { return }
let fileName = "sample_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
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)
)
}
}
}
@ -442,6 +489,18 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate {
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 {
@ -544,6 +603,7 @@ private final class SampleUploadGridView: UIView {
private var mediaType: SampleMediaType = .image
var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)?
var onPreview: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
@ -593,6 +653,7 @@ private final class SampleUploadGridView: UIView {
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?() }
@ -605,14 +666,17 @@ private final class SampleUploadGridView: UIView {
private final class SampleUploadThumbView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
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(systemName: "play.fill"))
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
@ -628,7 +692,7 @@ private final class SampleUploadThumbView: UIControl {
textLabel.font = .systemFont(ofSize: 12)
textLabel.textColor = UIColor(hex: 0x4B5563)
textLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 10
@ -665,7 +729,12 @@ private final class SampleUploadThumbView: UIControl {
make.center.equalToSuperview()
make.size.equalTo(32)
}
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
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)
}
@ -674,11 +743,18 @@ private final class SampleUploadThumbView: UIControl {
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.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
layer.borderWidth = 0
dashedLayer.isHidden = false
previewEnabled = false
addCircle.isHidden = false
textLabel.isHidden = false
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
@ -688,7 +764,8 @@ private final class SampleUploadThumbView: UIControl {
}
func apply(item: SampleUploadedMediaItem) {
layer.borderWidth = 0
dashedLayer.isHidden = true
previewEnabled = !item.ossUrl.isEmpty && !item.isUploading
backgroundColor = UIColor(hex: 0xF5F5F5)
addCircle.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
@ -704,9 +781,12 @@ private final class SampleUploadThumbView: UIControl {
}
}
@objc private func addTapped() {
guard !addCircle.isHidden else { return }
onAdd?()
@objc private func cellTapped() {
if !addCircle.isHidden {
onAdd?()
} else if previewEnabled {
onPreview?()
}
}
@objc private func deleteTapped() {
@ -718,18 +798,22 @@ private final class SampleUploadThumbView: UIControl {
private final class SampleCoverUploadView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
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 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
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
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
@ -744,7 +828,7 @@ private final class SampleCoverUploadView: UIControl {
hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 12
@ -791,13 +875,22 @@ private final class SampleCoverUploadView: UIControl {
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 {
@ -808,12 +901,14 @@ private final class SampleCoverUploadView: UIControl {
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
}
}
@ -881,3 +976,12 @@ private final class SampleUploadProgressOverlay: UIView {
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)
}
}