模块化 AppStore 并完善素材管理与个人空间设置。

将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 11:01:08 +08:00
parent ceca780ab3
commit 005349f8e6
128 changed files with 2953 additions and 1123 deletions

View File

@ -41,6 +41,7 @@ final class MaterialFormViewController: BaseViewController {
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)?
@ -106,7 +107,9 @@ final class MaterialFormViewController: BaseViewController {
bottomBar.addSubview(cancelButton)
bottomBar.addSubview(confirmButton)
view.addSubview(progressOverlay)
view.addSubview(dropdownOverlay)
progressOverlay.isHidden = true
dropdownOverlay.isHidden = true
}
override func setupConstraints() {
@ -147,6 +150,9 @@ final class MaterialFormViewController: BaseViewController {
progressOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
dropdownOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
override func bindActions() {
@ -167,6 +173,7 @@ final class MaterialFormViewController: 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() }
tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) }
@ -174,7 +181,7 @@ final class MaterialFormViewController: BaseViewController {
guard let self else { return }
Task { await self.viewModel.addTag(api: self.api) }
}
tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) }
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()
@ -207,6 +214,7 @@ final class MaterialFormViewController: BaseViewController {
} else {
progressOverlay.isHidden = true
}
presentTagDeleteDialogIfNeeded()
}
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
@ -222,25 +230,30 @@ final class MaterialFormViewController: BaseViewController {
}
@objc private func spotTapped() {
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(
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])
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func mediaTypeTapped() {
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
MaterialMediaType.allCases.forEach { type in
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
self?.viewModel.selectMediaType(type)
})
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])
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func cancelTapped() {
@ -267,24 +280,29 @@ final class MaterialFormViewController: BaseViewController {
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
self?.presentPhotoPicker()
})
if target == .material {
sheet.addAction(UIAlertAction(title: "从云盘导入", style: .default) { [weak self] _ in
self?.openCloudPicker()
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func openCloudPicker() {
let controller = CloudStoragePickForTaskViewController(
importedFileIDs: viewModel.importedCloudFileIDs,
allowedFileType: viewModel.mediaType.cloudFileType
)
controller.onConfirmed = { [weak self] files in
self?.viewModel.addCloudMediaFiles(files)
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)
}
navigationController?.pushViewController(controller, animated: true)
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() {
@ -462,7 +480,7 @@ private final class MaterialFormFieldView: UIView {
///
private final class MaterialSelectButton: UIControl {
private let titleLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let chevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down"))
override init(frame: CGRect) {
super.init(frame: frame)
@ -497,12 +515,92 @@ private final class MaterialSelectButton: UIControl {
}
}
/// 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.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 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)
@ -550,6 +648,7 @@ private final class MaterialUploadGridView: 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?() }
@ -563,14 +662,17 @@ private final class MaterialUploadGridView: UIView {
private final class MaterialUploadThumbView: 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 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
@ -586,7 +688,7 @@ private final class MaterialUploadThumbView: 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
@ -622,7 +724,12 @@ private final class MaterialUploadThumbView: 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)
}
@ -631,12 +738,19 @@ private final class MaterialUploadThumbView: 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
imageView.kf.cancelDownloadTask()
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 ? "点击上传图片" : "点击上传视频"
@ -646,7 +760,8 @@ private final class MaterialUploadThumbView: UIControl {
}
func apply(item: MaterialUploadedMediaItem) {
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
@ -658,7 +773,14 @@ private final class MaterialUploadThumbView: UIControl {
textLabel.numberOfLines = 2
} else {
textLabel.isHidden = true
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
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)
@ -666,14 +788,27 @@ private final class MaterialUploadThumbView: 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() {
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) }
}
}
}
///
@ -681,18 +816,22 @@ private final class MaterialUploadThumbView: UIControl {
private final class MaterialCoverUploadView: 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 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
@ -707,7 +846,7 @@ private final class MaterialCoverUploadView: 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
@ -753,6 +892,12 @@ private final class MaterialCoverUploadView: 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: MaterialUploadedMediaItem?) {
guard let item else {
imageView.image = nil
@ -763,6 +908,7 @@ private final class MaterialCoverUploadView: UIControl {
titleLabel.numberOfLines = 1
hintLabel.isHidden = false
deleteButton.isHidden = true
dashedLayer.isHidden = false
return
}
if item.isUploading {
@ -773,6 +919,7 @@ private final class MaterialCoverUploadView: UIControl {
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)
@ -783,6 +930,7 @@ private final class MaterialCoverUploadView: UIControl {
titleLabel.isHidden = true
hintLabel.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty
dashedLayer.isHidden = true
}
}
@ -819,17 +967,17 @@ private final class MaterialTagEditorView: UIView {
tagsStack.alignment = .leading
inputRow.axis = .horizontal
inputRow.spacing = 8
textField.placeholder = "请输入标签"
textField.font = .systemFont(ofSize: 14)
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
textField.layer.borderWidth = 1
textField.layer.cornerRadius = 8
textField.layer.cornerRadius = 4
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
textField.leftViewMode = .always
addButton.setTitle("添加", for: .normal)
textField.placeholder = "请输入标签,最低5个字符"
addButton.setTitle("添加标签", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
addButton.layer.cornerRadius = 4
addSubview(stack)
stack.addArrangedSubview(tagsStack)
stack.addArrangedSubview(inputRow)
@ -839,11 +987,11 @@ private final class MaterialTagEditorView: UIView {
make.edges.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.height.equalTo(44)
make.height.equalTo(36)
}
addButton.snp.makeConstraints { make in
make.width.equalTo(72)
make.height.equalTo(44)
make.width.equalTo(80)
make.height.equalTo(36)
}
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
@ -859,10 +1007,13 @@ private final class MaterialTagEditorView: UIView {
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.setTitle(tag.name, for: .normal)
button.setTitleColor(AppColor.primary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 12)
button.backgroundColor = UIColor(hex: 0xEFF6FF)