完善样片管理列表、详情与上传页 UI 交互。
对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/Contents.json
vendored
Normal file
5
suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "sample_img_error.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
9
suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/sample_img_error.svg
vendored
Normal file
9
suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/sample_img_error.svg
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="594" height="594" viewBox="0 0 594 594">
|
||||||
|
<rect width="594" height="594" fill="#F7F7F7"/>
|
||||||
|
<g fill="none" stroke="#DEDEDE" stroke-width="8" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<path d="M242 201l58-4-18 21 22 21-25 26 22 22-47 5-12-91Z"/>
|
||||||
|
<path d="M306 197l55 5-6 91-65-5 18-19-21-21 24-26-18-18 13-7Z"/>
|
||||||
|
<circle cx="334" cy="226" r="9" fill="#DEDEDE" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<text x="297" y="410" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="38" fill="#C9C9C9">加载失败...</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 590 B |
5
suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/Contents.json
vendored
Normal file
5
suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "sample_img_loading.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="594" height="594" viewBox="0 0 594 594">
|
||||||
|
<rect width="594" height="594" fill="#F7F7F7"/>
|
||||||
|
<g fill="none" stroke="#DEDEDE" stroke-width="7" stroke-linecap="round">
|
||||||
|
<path d="M250 188h94M244 314h106"/>
|
||||||
|
<path d="M252 192c0 45 13 67 45 82 32-15 45-37 45-82M252 310c0-45 13-67 45-82 32 15 45 37 45 82"/>
|
||||||
|
<path d="M297 274v35"/>
|
||||||
|
</g>
|
||||||
|
<text x="297" y="410" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="38" fill="#C9C9C9">加载中...</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 531 B |
@ -20,7 +20,7 @@ enum HomeMenuCatalog {
|
|||||||
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "gearshape.fill"),
|
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "gearshape.fill"),
|
||||||
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell.fill"),
|
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell.fill"),
|
||||||
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"),
|
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"),
|
||||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "point.3.connected.trianglepath.dotted"),
|
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"),
|
||||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
|
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
|
||||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"),
|
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"),
|
||||||
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),
|
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),
|
||||||
|
|||||||
@ -335,6 +335,11 @@ struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifi
|
|||||||
?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||||
?? ""
|
?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回可展示的媒体类型;服务端媒体项为 0 时按 Android 规则回退到详情顶层类型。
|
||||||
|
func resolvedType(fallback: Int) -> Int {
|
||||||
|
type == 0 ? fallback : type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 样片关联项目项,对齐 Android `ProjectItem`。
|
/// 样片关联项目项,对齐 Android `ProjectItem`。
|
||||||
|
|||||||
@ -26,6 +26,7 @@ final class SampleListViewModel {
|
|||||||
private(set) var canLoadMore = false
|
private(set) var canLoadMore = false
|
||||||
private(set) var filterStatus: SampleFilterStatus = .all
|
private(set) var filterStatus: SampleFilterStatus = .all
|
||||||
private(set) var searchText = ""
|
private(set) var searchText = ""
|
||||||
|
private(set) var operatingSampleIDs: Set<Int> = []
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@ -33,6 +34,11 @@ final class SampleListViewModel {
|
|||||||
private var currentPage = 1
|
private var currentPage = 1
|
||||||
private var totalCount = 0
|
private var totalCount = 0
|
||||||
private let pageSize = 10
|
private let pageSize = 10
|
||||||
|
private var isRequestingList = false
|
||||||
|
private var isLoadingMore = false
|
||||||
|
|
||||||
|
/// 是否正在执行样片上下架操作。
|
||||||
|
var isOperating: Bool { !operatingSampleIDs.isEmpty }
|
||||||
|
|
||||||
/// 首次加载样片列表。
|
/// 首次加载样片列表。
|
||||||
func loadInitial(api: any SampleManagementServing) async {
|
func loadInitial(api: any SampleManagementServing) async {
|
||||||
@ -67,15 +73,26 @@ final class SampleListViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 切换样片上下架状态。
|
/// 切换样片上下架状态。
|
||||||
func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async {
|
@discardableResult
|
||||||
guard let currentItem = items.first(where: { $0.id == sampleId }) else { return }
|
func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async -> Bool {
|
||||||
|
guard let currentItem = items.first(where: { $0.id == sampleId }) else { return !checked }
|
||||||
|
let previousState = currentItem.isListed
|
||||||
guard currentItem.status == 1 else {
|
guard currentItem.status == 1 else {
|
||||||
onShowMessage?("审核通过才可以操作上下架")
|
onShowMessage?("审核通过才可以操作上下架")
|
||||||
return
|
notifyStateChange()
|
||||||
|
return previousState
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetStatus = checked ? 1 : 0
|
let targetStatus = checked ? 1 : 0
|
||||||
guard currentItem.listingStatus != targetStatus else { return }
|
guard currentItem.listingStatus != targetStatus else { return previousState }
|
||||||
|
|
||||||
|
guard !operatingSampleIDs.contains(sampleId) else { return previousState }
|
||||||
|
operatingSampleIDs.insert(sampleId)
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
operatingSampleIDs.remove(sampleId)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await api.setListingStatus(id: sampleId, listingStatus: targetStatus)
|
try await api.setListingStatus(id: sampleId, listingStatus: targetStatus)
|
||||||
@ -97,25 +114,38 @@ final class SampleListViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
|
return checked
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return previousState
|
||||||
} catch {
|
} catch {
|
||||||
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
|
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
|
||||||
|
return previousState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async {
|
private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async {
|
||||||
|
guard !isRequestingList else {
|
||||||
|
if isRefreshing {
|
||||||
|
isRefreshing = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if reset {
|
if reset {
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
canLoadMore = false
|
canLoadMore = false
|
||||||
} else {
|
} else {
|
||||||
guard canLoadMore, !isLoading else { return }
|
guard canLoadMore, !isLoading, !isLoadingMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
currentPage += 1
|
currentPage += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isRequestingList = true
|
||||||
isLoading = showLoading
|
isLoading = showLoading
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
defer {
|
defer {
|
||||||
|
isRequestingList = false
|
||||||
|
if !reset { isLoadingMore = false }
|
||||||
isLoading = false
|
isLoading = false
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@ -276,7 +306,7 @@ final class UploadSampleViewModel {
|
|||||||
onShowMessage?("请先选择景区")
|
onShowMessage?("请先选择景区")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if projectIsLoading && !refresh { return }
|
guard !projectIsLoading else { return }
|
||||||
if refresh {
|
if refresh {
|
||||||
projectCurrentPage = 1
|
projectCurrentPage = 1
|
||||||
projectList = []
|
projectList = []
|
||||||
|
|||||||
@ -564,7 +564,7 @@ private final class MaterialListCell: UITableViewCell {
|
|||||||
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
||||||
}
|
}
|
||||||
statsStack.snp.makeConstraints { make in
|
statsStack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(statusBadge.snp.bottom).offset(8)
|
make.top.equalTo(statusBadge.snp.bottom).offset(12)
|
||||||
make.leading.equalToSuperview()
|
make.leading.equalToSuperview()
|
||||||
make.trailing.lessThanOrEqualToSuperview()
|
make.trailing.lessThanOrEqualToSuperview()
|
||||||
make.bottom.equalToSuperview()
|
make.bottom.equalToSuperview()
|
||||||
|
|||||||
@ -284,10 +284,10 @@ final class PunchPointDetailViewController: BaseViewController {
|
|||||||
title.font = .systemFont(ofSize: 14)
|
title.font = .systemFont(ofSize: 14)
|
||||||
title.textColor = UIColor(hex: 0x2E3746)
|
title.textColor = UIColor(hex: 0x2E3746)
|
||||||
title.textAlignment = .left
|
title.textAlignment = .left
|
||||||
title.snp.makeConstraints { make in
|
|
||||||
make.width.equalTo(contentStack.snp.width)
|
|
||||||
}
|
|
||||||
section.addArrangedSubview(title)
|
section.addArrangedSubview(title)
|
||||||
|
title.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(section.snp.width)
|
||||||
|
}
|
||||||
guard let qrCode = qrCode?.nonEmptyTrimmed else {
|
guard let qrCode = qrCode?.nonEmptyTrimmed else {
|
||||||
let empty = UILabel()
|
let empty = UILabel()
|
||||||
empty.text = "暂无二维码"
|
empty.text = "暂无二维码"
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import SnapKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 样片详情页,对齐 Android `SampleDetailScreen`。
|
/// 样片详情页,对齐 Android `SampleDetailScreen`。
|
||||||
final class SampleDetailViewController: BaseViewController {
|
final class SampleDetailViewController: SampleBaseViewController {
|
||||||
private let viewModel: SampleDetailViewModel
|
private let viewModel: SampleDetailViewModel
|
||||||
private let api: any SampleManagementServing
|
private let api: any SampleManagementServing
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ final class SampleDetailViewController: BaseViewController {
|
|||||||
private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点")
|
private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点")
|
||||||
private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐")
|
private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐")
|
||||||
private let reviewSection = SampleDetailReviewSection()
|
private let reviewSection = SampleDetailReviewSection()
|
||||||
|
private let bottomSpacer = UIView()
|
||||||
|
|
||||||
/// 初始化样片详情页。
|
/// 初始化样片详情页。
|
||||||
init(
|
init(
|
||||||
@ -38,14 +39,16 @@ final class SampleDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "样片详情"
|
super.setupNavigationBar()
|
||||||
|
setSampleTitle("样片详情")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
super.setupUI()
|
||||||
|
view.backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
contentStack.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
contentStack.spacing = 12
|
contentStack.spacing = 0
|
||||||
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
|
scrollView.backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
|
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
scrollView.addSubview(contentStack)
|
scrollView.addSubview(contentStack)
|
||||||
@ -55,11 +58,18 @@ final class SampleDetailViewController: BaseViewController {
|
|||||||
contentStack.addArrangedSubview(scenicSection)
|
contentStack.addArrangedSubview(scenicSection)
|
||||||
contentStack.addArrangedSubview(projectSection)
|
contentStack.addArrangedSubview(projectSection)
|
||||||
contentStack.addArrangedSubview(reviewSection)
|
contentStack.addArrangedSubview(reviewSection)
|
||||||
|
contentStack.addArrangedSubview(bottomSpacer)
|
||||||
|
contentStack.setCustomSpacing(12, after: sampleInfoSection)
|
||||||
|
contentStack.setCustomSpacing(12, after: uploaderSection)
|
||||||
|
contentStack.setCustomSpacing(12, after: scenicSection)
|
||||||
|
contentStack.setCustomSpacing(12, after: projectSection)
|
||||||
|
bottomSpacer.snp.makeConstraints { make in make.height.equalTo(16) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
scrollView.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
make.top.equalToSuperview()
|
||||||
|
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
}
|
}
|
||||||
contentStack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
@ -96,7 +106,7 @@ final class SampleDetailViewController: BaseViewController {
|
|||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
guard let detail = viewModel.detail else { return }
|
guard let detail = viewModel.detail else { return }
|
||||||
carouselView.apply(media: detail.mediaList ?? [])
|
carouselView.apply(media: detail.mediaList ?? [], fallbackType: detail.type ?? 1)
|
||||||
sampleInfoSection.apply(detail: detail)
|
sampleInfoSection.apply(detail: detail)
|
||||||
uploaderSection.apply(detail: detail)
|
uploaderSection.apply(detail: detail)
|
||||||
scenicSection.applyScenic(detail: detail)
|
scenicSection.applyScenic(detail: detail)
|
||||||
@ -108,10 +118,15 @@ final class SampleDetailViewController: BaseViewController {
|
|||||||
/// 样片详情媒体轮播,支持图片自动切换与视频播完切换。
|
/// 样片详情媒体轮播,支持图片自动切换与视频播完切换。
|
||||||
private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let pageControl = UIPageControl()
|
private let indicatorStack = UIStackView()
|
||||||
private let placeholderLabel = UILabel()
|
private let placeholderLabel = UILabel()
|
||||||
private var media: [SampleDetailMediaItem] = []
|
private var media: [SampleDetailMediaItem] = []
|
||||||
|
private var renderedMediaIndices: [Int] = []
|
||||||
|
private var currentRenderedPage = 0
|
||||||
|
private var fallbackType = 1
|
||||||
|
private var pageViews: [UIView] = []
|
||||||
private var players: [Int: AVPlayer] = [:]
|
private var players: [Int: AVPlayer] = [:]
|
||||||
|
private var playerViews: [Int: SamplePlayerLayerView] = [:]
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
@ -121,22 +136,24 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
scrollView.showsHorizontalScrollIndicator = false
|
scrollView.showsHorizontalScrollIndicator = false
|
||||||
scrollView.delegate = self
|
scrollView.delegate = self
|
||||||
scrollView.bounces = false
|
scrollView.bounces = false
|
||||||
pageControl.currentPageIndicatorTintColor = .white
|
indicatorStack.axis = .horizontal
|
||||||
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
|
indicatorStack.spacing = 6
|
||||||
|
indicatorStack.alignment = .center
|
||||||
placeholderLabel.text = "暂无媒体"
|
placeholderLabel.text = "暂无媒体"
|
||||||
placeholderLabel.font = .systemFont(ofSize: 14)
|
placeholderLabel.font = .systemFont(ofSize: 14)
|
||||||
placeholderLabel.textColor = AppColor.textSecondary
|
placeholderLabel.textColor = AppColor.textSecondary
|
||||||
placeholderLabel.textAlignment = .center
|
placeholderLabel.textAlignment = .center
|
||||||
|
|
||||||
addSubview(scrollView)
|
addSubview(scrollView)
|
||||||
addSubview(pageControl)
|
addSubview(indicatorStack)
|
||||||
addSubview(placeholderLabel)
|
addSubview(placeholderLabel)
|
||||||
scrollView.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
pageControl.snp.makeConstraints { make in
|
indicatorStack.snp.makeConstraints { make in
|
||||||
make.centerX.equalToSuperview()
|
make.centerX.equalToSuperview()
|
||||||
make.bottom.equalToSuperview().inset(8)
|
make.bottom.equalToSuperview().inset(12)
|
||||||
|
make.height.equalTo(8)
|
||||||
}
|
}
|
||||||
placeholderLabel.snp.makeConstraints { make in
|
placeholderLabel.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
@ -154,27 +171,35 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 应用媒体列表。
|
/// 应用媒体列表。
|
||||||
func apply(media: [SampleDetailMediaItem]) {
|
func apply(media: [SampleDetailMediaItem], fallbackType: Int) {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
players.values.forEach { $0.pause() }
|
players.values.forEach { $0.pause() }
|
||||||
players.removeAll()
|
players.removeAll()
|
||||||
|
playerViews.removeAll()
|
||||||
|
pageViews.removeAll()
|
||||||
self.media = media
|
self.media = media
|
||||||
|
self.fallbackType = fallbackType
|
||||||
|
renderedMediaIndices = media.count > 1
|
||||||
|
? [media.count - 1] + Array(media.indices) + [0]
|
||||||
|
: Array(media.indices)
|
||||||
|
currentRenderedPage = media.count > 1 ? 1 : 0
|
||||||
scrollView.subviews.forEach { $0.removeFromSuperview() }
|
scrollView.subviews.forEach { $0.removeFromSuperview() }
|
||||||
pageControl.numberOfPages = media.count
|
rebuildIndicators(currentIndex: 0)
|
||||||
pageControl.currentPage = 0
|
indicatorStack.isHidden = media.count <= 1
|
||||||
pageControl.isHidden = media.count <= 1
|
|
||||||
placeholderLabel.isHidden = !media.isEmpty
|
placeholderLabel.isHidden = !media.isEmpty
|
||||||
scrollView.isHidden = media.isEmpty
|
scrollView.isHidden = media.isEmpty
|
||||||
guard !media.isEmpty else { return }
|
guard !media.isEmpty else { return }
|
||||||
|
|
||||||
for (index, item) in media.enumerated() {
|
for (renderedIndex, mediaIndex) in renderedMediaIndices.enumerated() {
|
||||||
|
let item = media[mediaIndex]
|
||||||
let page = UIView()
|
let page = UIView()
|
||||||
page.backgroundColor = UIColor(hex: 0xF5F5F5)
|
page.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
page.tag = index
|
page.tag = renderedIndex
|
||||||
scrollView.addSubview(page)
|
scrollView.addSubview(page)
|
||||||
if item.type == 2 {
|
pageViews.append(page)
|
||||||
addVideo(urlText: item.displayURL, to: page, index: index)
|
if item.resolvedType(fallback: fallbackType) == 2 {
|
||||||
|
addVideo(urlText: item.displayURL, to: page, index: renderedIndex)
|
||||||
} else {
|
} else {
|
||||||
addImage(urlText: item.displayURL, to: page)
|
addImage(urlText: item.displayURL, to: page)
|
||||||
}
|
}
|
||||||
@ -198,6 +223,10 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
updateCurrentPage()
|
updateCurrentPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
|
||||||
private func addImage(urlText: String, to page: UIView) {
|
private func addImage(urlText: String, to page: UIView) {
|
||||||
let imageView = UIImageView()
|
let imageView = UIImageView()
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
@ -207,7 +236,13 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
if let url = URL(string: urlText), !urlText.isEmpty {
|
if let url = URL(string: urlText), !urlText.isEmpty {
|
||||||
imageView.kf.setImage(with: url, placeholder: UIImage(named: "img_square_loading_big"))
|
imageView.kf.setImage(with: url, placeholder: UIImage(named: "sample_img_loading")) { result in
|
||||||
|
if case .failure = result {
|
||||||
|
imageView.image = UIImage(named: "sample_img_error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imageView.image = UIImage(named: "sample_img_error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,7 +254,8 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
let player = AVPlayer(url: url)
|
let player = AVPlayer(url: url)
|
||||||
players[index] = player
|
players[index] = player
|
||||||
let playerView = SamplePlayerLayerView()
|
let playerView = SamplePlayerLayerView()
|
||||||
playerView.playerLayer.player = player
|
playerView.configure(player: player)
|
||||||
|
playerViews[index] = playerView
|
||||||
page.addSubview(playerView)
|
page.addSubview(playerView)
|
||||||
playerView.snp.makeConstraints { make in
|
playerView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
@ -235,32 +271,42 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
private func layoutPages() {
|
private func layoutPages() {
|
||||||
let size = bounds.size
|
let size = bounds.size
|
||||||
guard size.width > 0, size.height > 0 else { return }
|
guard size.width > 0, size.height > 0 else { return }
|
||||||
for (index, page) in scrollView.subviews.enumerated() {
|
for (index, page) in pageViews.enumerated() {
|
||||||
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
|
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
|
||||||
}
|
}
|
||||||
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
|
scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMediaIndices.count), height: size.height)
|
||||||
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
|
scrollView.contentOffset = CGPoint(x: CGFloat(currentRenderedPage) * size.width, y: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCurrentPage() {
|
private func updateCurrentPage() {
|
||||||
guard bounds.width > 0 else { return }
|
guard bounds.width > 0, !renderedMediaIndices.isEmpty else { return }
|
||||||
let page = Int(round(scrollView.contentOffset.x / bounds.width))
|
let page = Int(round(scrollView.contentOffset.x / bounds.width))
|
||||||
pageControl.currentPage = max(0, min(page, media.count - 1))
|
currentRenderedPage = max(0, min(page, renderedMediaIndices.count - 1))
|
||||||
|
if media.count > 1, currentRenderedPage == 0 {
|
||||||
|
currentRenderedPage = media.count
|
||||||
|
scrollView.setContentOffset(CGPoint(x: CGFloat(currentRenderedPage) * bounds.width, y: 0), animated: false)
|
||||||
|
} else if media.count > 1, currentRenderedPage == renderedMediaIndices.count - 1 {
|
||||||
|
currentRenderedPage = 1
|
||||||
|
scrollView.setContentOffset(CGPoint(x: bounds.width, y: 0), animated: false)
|
||||||
|
}
|
||||||
|
rebuildIndicators(currentIndex: currentMediaIndex)
|
||||||
playCurrentPageIfNeeded()
|
playCurrentPageIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playCurrentPageIfNeeded() {
|
private func playCurrentPageIfNeeded() {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
players.forEach { index, player in
|
players.forEach { index, player in
|
||||||
if index == pageControl.currentPage {
|
if index == currentRenderedPage {
|
||||||
player.seek(to: .zero)
|
player.seek(to: .zero)
|
||||||
player.play()
|
player.play()
|
||||||
|
playerViews[index]?.setPlaying(true)
|
||||||
} else {
|
} else {
|
||||||
player.pause()
|
player.pause()
|
||||||
|
playerViews[index]?.setPlaying(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard !media.isEmpty else { return }
|
guard !media.isEmpty else { return }
|
||||||
if media[pageControl.currentPage].type == 1 {
|
if media[currentMediaIndex].resolvedType(fallback: fallbackType) == 1 {
|
||||||
startImageTimer()
|
startImageTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -278,13 +324,15 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func videoDidEnd(_ notification: Notification) {
|
@objc private func videoDidEnd(_ notification: Notification) {
|
||||||
|
guard let item = notification.object as? AVPlayerItem,
|
||||||
|
players[currentRenderedPage]?.currentItem === item else { return }
|
||||||
advancePage()
|
advancePage()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func advancePage() {
|
private func advancePage() {
|
||||||
guard media.count > 1, bounds.width > 0 else { return }
|
guard media.count > 1, bounds.width > 0 else { return }
|
||||||
let nextPage = (pageControl.currentPage + 1) % media.count
|
let nextPage = min(currentRenderedPage + 1, renderedMediaIndices.count - 1)
|
||||||
pageControl.currentPage = nextPage
|
currentRenderedPage = nextPage
|
||||||
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
|
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
|
||||||
if !scrollView.isDragging && !scrollView.isDecelerating {
|
if !scrollView.isDragging && !scrollView.isDecelerating {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||||
@ -292,12 +340,92 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentMediaIndex: Int {
|
||||||
|
guard renderedMediaIndices.indices.contains(currentRenderedPage) else { return 0 }
|
||||||
|
return renderedMediaIndices[currentRenderedPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildIndicators(currentIndex: Int) {
|
||||||
|
indicatorStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
guard media.count > 1 else { return }
|
||||||
|
for index in media.indices {
|
||||||
|
let dot = UIView()
|
||||||
|
let size: CGFloat = index == currentIndex ? 8 : 6
|
||||||
|
dot.backgroundColor = index == currentIndex ? .white : UIColor.white.withAlphaComponent(0.5)
|
||||||
|
dot.layer.cornerRadius = size / 2
|
||||||
|
dot.snp.makeConstraints { make in make.size.equalTo(size) }
|
||||||
|
indicatorStack.addArrangedSubview(dot)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AVPlayerLayer 容器。
|
/// AVPlayerLayer 容器,提供点击显示且三秒自动隐藏的播放控制。
|
||||||
private final class SamplePlayerLayerView: UIView {
|
private final class SamplePlayerLayerView: UIView {
|
||||||
|
private let controlButton = UIButton(type: .system)
|
||||||
|
private weak var player: AVPlayer?
|
||||||
|
private var hideControlWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
controlButton.tintColor = .white
|
||||||
|
controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
||||||
|
controlButton.layer.cornerRadius = 22
|
||||||
|
controlButton.isHidden = true
|
||||||
|
controlButton.accessibilityLabel = "播放或暂停视频"
|
||||||
|
addSubview(controlButton)
|
||||||
|
controlButton.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(44)
|
||||||
|
}
|
||||||
|
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls)))
|
||||||
|
controlButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定播放器。
|
||||||
|
func configure(player: AVPlayer) {
|
||||||
|
self.player = player
|
||||||
|
playerLayer.player = player
|
||||||
|
updateControlImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步轮播切页后的播放状态。
|
||||||
|
func setPlaying(_ playing: Bool) {
|
||||||
|
if !playing { controlButton.isHidden = true }
|
||||||
|
updateControlImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showControls() {
|
||||||
|
controlButton.isHidden = false
|
||||||
|
updateControlImage()
|
||||||
|
hideControlWorkItem?.cancel()
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true }
|
||||||
|
hideControlWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func togglePlayback() {
|
||||||
|
guard let player else { return }
|
||||||
|
if player.rate == 0 {
|
||||||
|
player.play()
|
||||||
|
} else {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateControlImage() {
|
||||||
|
let name = player?.rate == 0 ? "play.fill" : "pause.fill"
|
||||||
|
controlButton.setImage(UIImage(systemName: name), for: .normal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 样片详情名称与上架状态区。
|
/// 样片详情名称与上架状态区。
|
||||||
@ -508,11 +636,16 @@ private final class SampleDetailImageInfoSection: UIView {
|
|||||||
titleLabel.text = detail.scenicName ?? ""
|
titleLabel.text = detail.scenicName ?? ""
|
||||||
firstLineLabel.attributedText = coloredValue(prefix: "无人机: ", value: "\(detail.scenicPlanesNum)架")
|
firstLineLabel.attributedText = coloredValue(prefix: "无人机: ", value: "\(detail.scenicPlanesNum)架")
|
||||||
secondLineLabel.attributedText = coloredValue(prefix: "摄影师: ", value: "\(detail.scenicPhotographerNum)人")
|
secondLineLabel.attributedText = coloredValue(prefix: "摄影师: ", value: "\(detail.scenicPhotographerNum)人")
|
||||||
|
let shouldShowCounts = detail.scenicPlanesNum > 0 || detail.scenicPhotographerNum > 0
|
||||||
|
firstLineLabel.isHidden = !shouldShowCounts
|
||||||
|
secondLineLabel.isHidden = !shouldShowCounts
|
||||||
setImage(detail.scenicCoverImg)
|
setImage(detail.scenicCoverImg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyProject(detail: SampleDetail) {
|
func applyProject(detail: SampleDetail) {
|
||||||
titleLabel.text = detail.projectName ?? ""
|
titleLabel.text = detail.projectName ?? ""
|
||||||
|
firstLineLabel.isHidden = detail.projectPrice == nil
|
||||||
|
secondLineLabel.isHidden = detail.projectPriceDeposit == nil
|
||||||
if let price = detail.projectPrice {
|
if let price = detail.projectPrice {
|
||||||
firstLineLabel.attributedText = coloredValue(prefix: "价格: ", value: "\(price)元")
|
firstLineLabel.attributedText = coloredValue(prefix: "价格: ", value: "\(price)元")
|
||||||
} else {
|
} else {
|
||||||
@ -528,9 +661,13 @@ private final class SampleDetailImageInfoSection: UIView {
|
|||||||
|
|
||||||
private func setImage(_ urlText: String?) {
|
private func setImage(_ urlText: String?) {
|
||||||
if let urlText, let url = URL(string: urlText), !urlText.isEmpty {
|
if let urlText, let url = URL(string: urlText), !urlText.isEmpty {
|
||||||
imageView.kf.setImage(with: url)
|
imageView.kf.setImage(with: url, placeholder: UIImage(named: "sample_img_loading")) { [weak imageView] result in
|
||||||
|
if case .failure = result {
|
||||||
|
imageView?.image = UIImage(named: "sample_img_error")
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
imageView.image = nil
|
imageView.image = UIImage(named: "sample_img_error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -586,6 +723,7 @@ private final class SampleDetailReviewSection: UIView {
|
|||||||
reviewTimeRow.apply(label: "审核时间", value: detail.reviewTime?.nonEmpty ?? "--")
|
reviewTimeRow.apply(label: "审核时间", value: detail.reviewTime?.nonEmpty ?? "--")
|
||||||
statusRow.apply(status: detail.status)
|
statusRow.apply(status: detail.status)
|
||||||
noteRow.apply(label: "审核备注", value: detail.reviewNotes?.nonEmpty ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12))
|
noteRow.apply(label: "审核备注", value: detail.reviewNotes?.nonEmpty ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12))
|
||||||
|
noteRow.setSeparatorHidden(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -634,6 +772,11 @@ private final class SampleDetailInfoRow: UIView {
|
|||||||
valueLabel.textColor = valueColor
|
valueLabel.textColor = valueColor
|
||||||
valueLabel.font = valueFont
|
valueLabel.font = valueFont
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 控制行底部分隔线显示。
|
||||||
|
func setSeparatorHidden(_ hidden: Bool) {
|
||||||
|
separator.isHidden = hidden
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 样片详情审核状态行。
|
/// 样片详情审核状态行。
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import SnapKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 样片相册列表页,对齐 Android `SampleListScreen`。
|
/// 样片相册列表页,对齐 Android `SampleListScreen`。
|
||||||
final class SampleListViewController: BaseViewController {
|
final class SampleListViewController: SampleBaseViewController {
|
||||||
private let viewModel: SampleListViewModel
|
private let viewModel: SampleListViewModel
|
||||||
private let api: any SampleManagementServing
|
private let api: any SampleManagementServing
|
||||||
|
|
||||||
@ -45,11 +45,13 @@ final class SampleListViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "样片相册列表"
|
super.setupNavigationBar()
|
||||||
|
setSampleTitle("样片相册列表")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackground
|
super.setupUI()
|
||||||
|
view.backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
|
|
||||||
searchContainer.backgroundColor = .white
|
searchContainer.backgroundColor = .white
|
||||||
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
|
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
@ -65,7 +67,7 @@ final class SampleListViewController: BaseViewController {
|
|||||||
filterContainer.backgroundColor = .white
|
filterContainer.backgroundColor = .white
|
||||||
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
|
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
filterButton.layer.cornerRadius = 4
|
filterButton.layer.cornerRadius = 4
|
||||||
filterTitleLabel.text = "筛选"
|
filterTitleLabel.text = SampleFilterStatus.all.title
|
||||||
filterTitleLabel.font = .systemFont(ofSize: 14)
|
filterTitleLabel.font = .systemFont(ofSize: 14)
|
||||||
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
|
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
|
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
|
||||||
@ -80,7 +82,7 @@ final class SampleListViewController: BaseViewController {
|
|||||||
filterDropdownStack.axis = .vertical
|
filterDropdownStack.axis = .vertical
|
||||||
filterDropdownStack.spacing = 0
|
filterDropdownStack.spacing = 0
|
||||||
|
|
||||||
tableView.backgroundColor = AppColor.pageBackground
|
tableView.backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.estimatedRowHeight = 152
|
tableView.estimatedRowHeight = 152
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
@ -95,10 +97,15 @@ final class SampleListViewController: BaseViewController {
|
|||||||
withIdentifier: SampleListCell.reuseIdentifier,
|
withIdentifier: SampleListCell.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
) as! SampleListCell
|
) as! SampleListCell
|
||||||
cell.apply(item: item)
|
cell.apply(item: item, isOperating: self?.viewModel.operatingSampleIDs.contains(item.id) == true)
|
||||||
cell.onToggle = { [weak self] checked in
|
cell.onToggle = { [weak self, weak cell] checked in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) }
|
Task {
|
||||||
|
let resolved = await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api)
|
||||||
|
await MainActor.run {
|
||||||
|
cell?.resolveToggle(sampleId: item.id, isOn: resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
@ -135,7 +142,7 @@ final class SampleListViewController: BaseViewController {
|
|||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
searchContainer.snp.makeConstraints { make in
|
searchContainer.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
make.top.equalToSuperview()
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(68)
|
make.height.equalTo(68)
|
||||||
}
|
}
|
||||||
@ -232,7 +239,7 @@ final class SampleListViewController: BaseViewController {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
|
filterTitleLabel.text = viewModel.filterStatus.title
|
||||||
filterOptionButtons.forEach { status, button in
|
filterOptionButtons.forEach { status, button in
|
||||||
let selected = status == viewModel.filterStatus
|
let selected = status == viewModel.filterStatus
|
||||||
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
|
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
|
||||||
@ -342,11 +349,13 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
private let scenicSpotLabel = UILabel()
|
private let scenicSpotLabel = UILabel()
|
||||||
private let statusBadge = SampleStatusBadgeView()
|
private let statusBadge = SampleStatusBadgeView()
|
||||||
private let listingSwitch = UISwitch()
|
private let listingSwitch = UISwitch()
|
||||||
|
private let listingActivityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
private let statsStack = UIStackView()
|
private let statsStack = UIStackView()
|
||||||
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
|
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
|
||||||
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
|
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
|
||||||
private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count")
|
private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count")
|
||||||
private var isApplying = false
|
private var isApplying = false
|
||||||
|
private var representedSampleID: Int?
|
||||||
|
|
||||||
var onToggle: ((Bool) -> Void)?
|
var onToggle: ((Bool) -> Void)?
|
||||||
|
|
||||||
@ -360,13 +369,23 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(item: SampleMaterialItem) {
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
representedSampleID = nil
|
||||||
|
onToggle = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(item: SampleMaterialItem, isOperating: Bool) {
|
||||||
isApplying = true
|
isApplying = true
|
||||||
|
representedSampleID = item.id
|
||||||
titleLabel.text = item.name
|
titleLabel.text = item.name
|
||||||
projectLabel.text = "项目: \(item.projectName)"
|
projectLabel.text = "项目: \(item.projectName)"
|
||||||
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
|
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
|
||||||
statusBadge.apply(status: item.status)
|
statusBadge.apply(status: item.status)
|
||||||
listingSwitch.setOn(item.listingStatus == 1, animated: false)
|
listingSwitch.setOn(item.listingStatus == 1, animated: false)
|
||||||
|
listingSwitch.isEnabled = item.status == 1 && !isOperating
|
||||||
|
listingSwitch.isHidden = isOperating
|
||||||
|
isOperating ? listingActivityIndicator.startAnimating() : listingActivityIndicator.stopAnimating()
|
||||||
shareStatView.setCount(item.shareCount)
|
shareStatView.setCount(item.shareCount)
|
||||||
likeStatView.setCount(item.likesCount)
|
likeStatView.setCount(item.likesCount)
|
||||||
collectStatView.setCount(item.collectCount)
|
collectStatView.setCount(item.collectCount)
|
||||||
@ -381,10 +400,21 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
isApplying = false
|
isApplying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 请求结束后恢复服务端确认的开关状态,避免审核拒绝或接口失败时停留在错误位置。
|
||||||
|
func resolveToggle(sampleId: Int, isOn: Bool) {
|
||||||
|
guard representedSampleID == sampleId else { return }
|
||||||
|
isApplying = true
|
||||||
|
listingSwitch.setOn(isOn, animated: true)
|
||||||
|
listingSwitch.isEnabled = true
|
||||||
|
listingSwitch.isHidden = false
|
||||||
|
listingActivityIndicator.stopAnimating()
|
||||||
|
isApplying = false
|
||||||
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
backgroundColor = AppColor.pageBackground
|
backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
contentView.backgroundColor = AppColor.pageBackground
|
contentView.backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
|
|
||||||
cardView.backgroundColor = .white
|
cardView.backgroundColor = .white
|
||||||
cardView.layer.cornerRadius = 12
|
cardView.layer.cornerRadius = 12
|
||||||
@ -411,7 +441,10 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listingSwitch.onTintColor = AppColor.primary
|
listingSwitch.onTintColor = AppColor.primary
|
||||||
|
listingSwitch.accessibilityLabel = "样片上下架"
|
||||||
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
|
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
|
||||||
|
listingActivityIndicator.color = AppColor.primary
|
||||||
|
listingActivityIndicator.hidesWhenStopped = true
|
||||||
|
|
||||||
statsStack.axis = .horizontal
|
statsStack.axis = .horizontal
|
||||||
statsStack.spacing = 16
|
statsStack.spacing = 16
|
||||||
@ -425,11 +458,13 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
cardView.addSubview(scenicSpotLabel)
|
cardView.addSubview(scenicSpotLabel)
|
||||||
cardView.addSubview(statusBadge)
|
cardView.addSubview(statusBadge)
|
||||||
cardView.addSubview(listingSwitch)
|
cardView.addSubview(listingSwitch)
|
||||||
|
cardView.addSubview(listingActivityIndicator)
|
||||||
cardView.addSubview(statsStack)
|
cardView.addSubview(statsStack)
|
||||||
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
|
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
|
||||||
|
|
||||||
cardView.snp.makeConstraints { make in
|
cardView.snp.makeConstraints { make in
|
||||||
make.top.bottom.equalToSuperview().inset(8)
|
make.top.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().inset(16)
|
||||||
make.leading.trailing.equalToSuperview().inset(16)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
}
|
}
|
||||||
coverImageView.snp.makeConstraints { make in
|
coverImageView.snp.makeConstraints { make in
|
||||||
@ -463,6 +498,9 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
make.trailing.equalToSuperview().inset(12)
|
make.trailing.equalToSuperview().inset(12)
|
||||||
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
||||||
}
|
}
|
||||||
|
listingActivityIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(listingSwitch)
|
||||||
|
}
|
||||||
statsStack.snp.makeConstraints { make in
|
statsStack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(statusBadge.snp.bottom).offset(10)
|
make.top.equalTo(statusBadge.snp.bottom).offset(10)
|
||||||
make.leading.equalTo(titleLabel)
|
make.leading.equalTo(titleLabel)
|
||||||
@ -473,6 +511,9 @@ private final class SampleListCell: UITableViewCell {
|
|||||||
|
|
||||||
@objc private func switchChanged() {
|
@objc private func switchChanged() {
|
||||||
guard !isApplying else { return }
|
guard !isApplying else { return }
|
||||||
|
listingSwitch.isEnabled = false
|
||||||
|
listingSwitch.isHidden = true
|
||||||
|
listingActivityIndicator.startAnimating()
|
||||||
onToggle?(listingSwitch.isOn)
|
onToggle?(listingSwitch.isOn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
suixinkan/UI/SampleManagement/SampleManagementUI.swift
Normal file
155
suixinkan/UI/SampleManagement/SampleManagementUI.swift
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// SampleManagementUI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 样片管理模块视觉常量,严格对应 Android Compose 页面中的 dp、颜色与圆角。
|
||||||
|
enum SampleManagementUITokens {
|
||||||
|
static let pageBackground = UIColor(hex: 0xF4F4F4)
|
||||||
|
static let inputBackground = UIColor(hex: 0xF4F4F4)
|
||||||
|
static let border = UIColor(hex: 0xEEEEEE)
|
||||||
|
static let placeholder = UIColor(hex: 0xB6BECA)
|
||||||
|
static let text563 = UIColor(hex: 0x4B5563)
|
||||||
|
static let statText = UIColor(hex: 0x6B7280)
|
||||||
|
static let screenInset: CGFloat = 16
|
||||||
|
static let sectionSpacing: CGFloat = 12
|
||||||
|
static let cardRadius: CGFloat = 12
|
||||||
|
static let fieldRadius: CGFloat = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 样片管理页面基类,复用“空间设置”的原生导航栏外观与返回行为。
|
||||||
|
class SampleBaseViewController: BaseViewController {
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
navigationItem.backButtonDisplayMode = .minimal
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||||
|
ProfileNavigationStyle.apply(to: navigationController, showsDivider: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
ProfileNavigationStyle.restore(navigationController)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置与“空间设置”一致的原生导航栏标题。
|
||||||
|
func setSampleTitle(_ title: String) {
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锚定在输入框下方的 Android 风格下拉菜单,支持滚动加载更多。
|
||||||
|
final class SampleAnchoredDropdownView: UIControl, UIScrollViewDelegate {
|
||||||
|
/// 下拉菜单选项。
|
||||||
|
struct Option {
|
||||||
|
let title: String
|
||||||
|
let isSelected: Bool
|
||||||
|
let isLoadMore: Bool
|
||||||
|
|
||||||
|
/// 创建普通或“加载更多”选项。
|
||||||
|
init(title: String, isSelected: Bool = false, isLoadMore: Bool = false) {
|
||||||
|
self.title = title
|
||||||
|
self.isSelected = isSelected
|
||||||
|
self.isLoadMore = isLoadMore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let stackView = UIStackView()
|
||||||
|
private var onSelect: ((Int) -> Void)?
|
||||||
|
private var onReachedBottom: (() -> Void)?
|
||||||
|
private var didRequestBottom = false
|
||||||
|
|
||||||
|
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)
|
||||||
|
scrollView.delegate = self
|
||||||
|
stackView.axis = .vertical
|
||||||
|
addSubview(cardView)
|
||||||
|
cardView.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(stackView)
|
||||||
|
addTarget(self, action: #selector(dismissMenu), for: .touchUpInside)
|
||||||
|
isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在锚点下方展示菜单。
|
||||||
|
func show(
|
||||||
|
anchor: UIView,
|
||||||
|
options: [Option],
|
||||||
|
maximumHeight: CGFloat = 200,
|
||||||
|
onReachedBottom: (() -> Void)? = nil,
|
||||||
|
onSelect: @escaping (Int) -> Void
|
||||||
|
) {
|
||||||
|
guard !options.isEmpty else { return }
|
||||||
|
self.onSelect = onSelect
|
||||||
|
self.onReachedBottom = onReachedBottom
|
||||||
|
didRequestBottom = false
|
||||||
|
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.title, for: .normal)
|
||||||
|
let color = option.isSelected ? AppColor.primary : (option.isLoadMore ? AppColor.textSecondary : .black)
|
||||||
|
button.setTitleColor(color, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 14, weight: option.isSelected ? .medium : .regular)
|
||||||
|
button.backgroundColor = .white
|
||||||
|
button.accessibilityLabel = option.title
|
||||||
|
button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside)
|
||||||
|
button.snp.makeConstraints { make in make.height.equalTo(28) }
|
||||||
|
stackView.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
isHidden = false
|
||||||
|
superview?.bringSubviewToFront(self)
|
||||||
|
layoutIfNeeded()
|
||||||
|
let anchorFrame = anchor.convert(anchor.bounds, to: self)
|
||||||
|
let contentHeight = CGFloat(options.count) * 28
|
||||||
|
let menuHeight = min(maximumHeight, contentHeight)
|
||||||
|
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: contentHeight)
|
||||||
|
scrollView.contentSize = stackView.frame.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏菜单。
|
||||||
|
func hide() {
|
||||||
|
isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
guard !didRequestBottom, scrollView.contentSize.height > scrollView.bounds.height else { return }
|
||||||
|
let remaining = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height
|
||||||
|
guard remaining <= 10 else { return }
|
||||||
|
didRequestBottom = true
|
||||||
|
onReachedBottom?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func optionTapped(_ sender: UIButton) {
|
||||||
|
isHidden = true
|
||||||
|
onSelect?(sender.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dismissMenu() {
|
||||||
|
isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import UniformTypeIdentifiers
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 样片相册上传页,对齐 Android `UploadSampleScreen`。
|
/// 样片相册上传页,对齐 Android `UploadSampleScreen`。
|
||||||
final class UploadSampleViewController: BaseViewController {
|
final class UploadSampleViewController: SampleBaseViewController {
|
||||||
private enum PickTarget {
|
private enum PickTarget {
|
||||||
case material
|
case material
|
||||||
case cover
|
case cover
|
||||||
@ -42,6 +42,7 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
private let cancelButton = UIButton(type: .system)
|
private let cancelButton = UIButton(type: .system)
|
||||||
private let confirmButton = UIButton(type: .system)
|
private let confirmButton = UIButton(type: .system)
|
||||||
private let progressOverlay = SampleUploadProgressOverlay()
|
private let progressOverlay = SampleUploadProgressOverlay()
|
||||||
|
private let dropdownOverlay = SampleAnchoredDropdownView()
|
||||||
|
|
||||||
private var pickTarget: PickTarget = .material
|
private var pickTarget: PickTarget = .material
|
||||||
|
|
||||||
@ -65,16 +66,18 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "样片相册"
|
super.setupNavigationBar()
|
||||||
|
setSampleTitle("样片相册")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
super.setupUI()
|
||||||
|
view.backgroundColor = SampleManagementUITokens.pageBackground
|
||||||
formCard.backgroundColor = .white
|
formCard.backgroundColor = .white
|
||||||
formCard.layer.cornerRadius = 12
|
formCard.layer.cornerRadius = 12
|
||||||
formCard.clipsToBounds = true
|
formCard.clipsToBounds = true
|
||||||
formStack.axis = .vertical
|
formStack.axis = .vertical
|
||||||
formStack.spacing = 16
|
formStack.spacing = 0
|
||||||
|
|
||||||
nameField.placeholder = "请输入相册名称"
|
nameField.placeholder = "请输入相册名称"
|
||||||
nameField.font = .systemFont(ofSize: 14)
|
nameField.font = .systemFont(ofSize: 14)
|
||||||
@ -96,6 +99,12 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
|
|
||||||
[nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection]
|
[nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection]
|
||||||
.forEach(formStack.addArrangedSubview)
|
.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
|
bottomBar.backgroundColor = .white
|
||||||
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
|
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
|
||||||
@ -107,6 +116,7 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
bottomBar.addSubview(cancelButton)
|
bottomBar.addSubview(cancelButton)
|
||||||
bottomBar.addSubview(confirmButton)
|
bottomBar.addSubview(confirmButton)
|
||||||
|
view.addSubview(dropdownOverlay)
|
||||||
view.addSubview(progressOverlay)
|
view.addSubview(progressOverlay)
|
||||||
progressOverlay.isHidden = true
|
progressOverlay.isHidden = true
|
||||||
}
|
}
|
||||||
@ -128,7 +138,8 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
make.trailing.equalToSuperview().inset(15)
|
make.trailing.equalToSuperview().inset(15)
|
||||||
}
|
}
|
||||||
scrollView.snp.makeConstraints { make in
|
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)
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
}
|
}
|
||||||
formCard.snp.makeConstraints { make in
|
formCard.snp.makeConstraints { make in
|
||||||
@ -149,6 +160,9 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
progressOverlay.snp.makeConstraints { make in
|
progressOverlay.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
|
dropdownOverlay.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
@ -169,6 +183,7 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
|
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
|
||||||
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
|
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
|
||||||
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
|
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.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
|
||||||
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
|
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
|
||||||
scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
|
scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
|
||||||
@ -229,26 +244,25 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func mediaTypeTapped() {
|
@objc private func mediaTypeTapped() {
|
||||||
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
|
let types = SampleMediaType.allCases
|
||||||
SampleMediaType.allCases.forEach { type in
|
dropdownOverlay.show(
|
||||||
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
|
anchor: mediaTypeButton,
|
||||||
self?.viewModel.selectMediaType(type)
|
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() {
|
@objc private func scenicTapped() {
|
||||||
let sheet = UIAlertController(title: "关联景区", message: nil, preferredStyle: .actionSheet)
|
let scenics = viewModel.scenicList
|
||||||
viewModel.scenicList.forEach { scenic in
|
dropdownOverlay.show(
|
||||||
sheet.addAction(UIAlertAction(title: scenic.name, style: .default) { [weak self] _ in
|
anchor: scenicButton,
|
||||||
guard let self else { return }
|
options: scenics.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenic?.id) }
|
||||||
Task { await self.viewModel.selectScenic(scenic, api: self.api) }
|
) { [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() {
|
@objc private func spotTapped() {
|
||||||
@ -256,14 +270,14 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
showToast("请先选择关联景区")
|
showToast("请先选择关联景区")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
|
let spots = viewModel.scenicSpotList
|
||||||
viewModel.scenicSpotList.forEach { spot in
|
dropdownOverlay.show(
|
||||||
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
|
anchor: spotButton,
|
||||||
self?.viewModel.selectScenicSpot(spot)
|
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() {
|
@objc private func projectTapped() {
|
||||||
@ -277,28 +291,36 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
await viewModel.loadProjects(refresh: true, api: api)
|
await viewModel.loadProjects(refresh: true, api: api)
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
presentProjectSheet()
|
await MainActor.run { self.presentProjectDropdown() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentProjectSheet() {
|
@MainActor
|
||||||
let sheet = UIAlertController(title: "关联项目", message: nil, preferredStyle: .actionSheet)
|
private func presentProjectDropdown() {
|
||||||
viewModel.projectList.forEach { project in
|
let projects = viewModel.projectList
|
||||||
sheet.addAction(UIAlertAction(title: project.name, style: .default) { [weak self] _ in
|
var options = projects.map { SampleAnchoredDropdownView.Option(title: $0.name, isSelected: $0.id == viewModel.selectedProject?.id) }
|
||||||
self?.viewModel.selectProject(project)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if viewModel.projectCanLoadMore {
|
if viewModel.projectCanLoadMore {
|
||||||
sheet.addAction(UIAlertAction(title: "加载更多...", style: .default) { [weak self] _ in
|
options.append(.init(title: "加载更多...", isLoadMore: true))
|
||||||
guard let self else { return }
|
}
|
||||||
Task {
|
dropdownOverlay.show(
|
||||||
await self.viewModel.loadProjects(refresh: false, api: self.api)
|
anchor: projectButton,
|
||||||
self.presentProjectSheet()
|
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() {
|
@objc private func cancelTapped() {
|
||||||
@ -329,6 +351,19 @@ final class UploadSampleViewController: BaseViewController {
|
|||||||
present(sheet, animated: true)
|
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() {
|
private func presentPhotoPicker() {
|
||||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||||
if pickTarget == .cover {
|
if pickTarget == .cover {
|
||||||
@ -424,16 +459,28 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate {
|
|||||||
guard let self, let url else { return }
|
guard let self, let url else { return }
|
||||||
let data = (try? Data(contentsOf: url)) ?? Data()
|
let data = (try? Data(contentsOf: url)) ?? Data()
|
||||||
guard !data.isEmpty else { return }
|
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)
|
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
group.enter()
|
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() }
|
defer { group.leave() }
|
||||||
guard let self, let image = object as? UIImage else { return }
|
guard let self, let url, let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return }
|
||||||
let fileName = "sample_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
|
let pathExtension = url.pathExtension
|
||||||
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
|
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 })
|
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 {
|
extension UploadSampleViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
@ -544,6 +603,7 @@ private final class SampleUploadGridView: UIView {
|
|||||||
private var mediaType: SampleMediaType = .image
|
private var mediaType: SampleMediaType = .image
|
||||||
var onAdd: (() -> Void)?
|
var onAdd: (() -> Void)?
|
||||||
var onDelete: ((Int) -> Void)?
|
var onDelete: ((Int) -> Void)?
|
||||||
|
var onPreview: ((Int) -> Void)?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@ -593,6 +653,7 @@ private final class SampleUploadGridView: UIView {
|
|||||||
if let item {
|
if let item {
|
||||||
cell.apply(item: item)
|
cell.apply(item: item)
|
||||||
cell.onDelete = { [weak self] in self?.onDelete?(index) }
|
cell.onDelete = { [weak self] in self?.onDelete?(index) }
|
||||||
|
cell.onPreview = { [weak self] in self?.onPreview?(index) }
|
||||||
} else {
|
} else {
|
||||||
cell.applyAdd()
|
cell.applyAdd()
|
||||||
cell.onAdd = { [weak self] in self?.onAdd?() }
|
cell.onAdd = { [weak self] in self?.onAdd?() }
|
||||||
@ -605,14 +666,17 @@ private final class SampleUploadGridView: UIView {
|
|||||||
private final class SampleUploadThumbView: UIControl {
|
private final class SampleUploadThumbView: UIControl {
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private let addCircle = UIView()
|
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 textLabel = UILabel()
|
||||||
private let deleteButton = UIButton(type: .system)
|
private let deleteButton = SampleExpandedTouchButton(type: .system)
|
||||||
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill"))
|
private let playIcon = UIImageView(image: UIImage(named: "material_ic_play"))
|
||||||
private let mediaType: SampleMediaType
|
private let mediaType: SampleMediaType
|
||||||
|
private let dashedLayer = CAShapeLayer()
|
||||||
|
private var previewEnabled = false
|
||||||
|
|
||||||
var onAdd: (() -> Void)?
|
var onAdd: (() -> Void)?
|
||||||
var onDelete: (() -> Void)?
|
var onDelete: (() -> Void)?
|
||||||
|
var onPreview: (() -> Void)?
|
||||||
|
|
||||||
init(mediaType: SampleMediaType) {
|
init(mediaType: SampleMediaType) {
|
||||||
self.mediaType = mediaType
|
self.mediaType = mediaType
|
||||||
@ -628,7 +692,7 @@ private final class SampleUploadThumbView: UIControl {
|
|||||||
textLabel.font = .systemFont(ofSize: 12)
|
textLabel.font = .systemFont(ofSize: 12)
|
||||||
textLabel.textColor = UIColor(hex: 0x4B5563)
|
textLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
textLabel.textAlignment = .center
|
textLabel.textAlignment = .center
|
||||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||||
deleteButton.tintColor = .white
|
deleteButton.tintColor = .white
|
||||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||||
deleteButton.layer.cornerRadius = 10
|
deleteButton.layer.cornerRadius = 10
|
||||||
@ -665,7 +729,12 @@ private final class SampleUploadThumbView: UIControl {
|
|||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
make.size.equalTo(32)
|
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)
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -674,11 +743,18 @@ private final class SampleUploadThumbView: UIControl {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
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() {
|
func applyAdd() {
|
||||||
imageView.image = nil
|
imageView.image = nil
|
||||||
backgroundColor = .white
|
backgroundColor = .white
|
||||||
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
layer.borderWidth = 0
|
||||||
layer.borderWidth = 1
|
dashedLayer.isHidden = false
|
||||||
|
previewEnabled = false
|
||||||
addCircle.isHidden = false
|
addCircle.isHidden = false
|
||||||
textLabel.isHidden = false
|
textLabel.isHidden = false
|
||||||
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
|
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
|
||||||
@ -688,7 +764,8 @@ private final class SampleUploadThumbView: UIControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apply(item: SampleUploadedMediaItem) {
|
func apply(item: SampleUploadedMediaItem) {
|
||||||
layer.borderWidth = 0
|
dashedLayer.isHidden = true
|
||||||
|
previewEnabled = !item.ossUrl.isEmpty && !item.isUploading
|
||||||
backgroundColor = UIColor(hex: 0xF5F5F5)
|
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
addCircle.isHidden = true
|
addCircle.isHidden = true
|
||||||
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
|
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
|
||||||
@ -704,9 +781,12 @@ private final class SampleUploadThumbView: UIControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func addTapped() {
|
@objc private func cellTapped() {
|
||||||
guard !addCircle.isHidden else { return }
|
if !addCircle.isHidden {
|
||||||
onAdd?()
|
onAdd?()
|
||||||
|
} else if previewEnabled {
|
||||||
|
onPreview?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func deleteTapped() {
|
@objc private func deleteTapped() {
|
||||||
@ -718,18 +798,22 @@ private final class SampleUploadThumbView: UIControl {
|
|||||||
private final class SampleCoverUploadView: UIControl {
|
private final class SampleCoverUploadView: UIControl {
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private let addCircle = UIView()
|
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 titleLabel = UILabel()
|
||||||
private let hintLabel = 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 onAdd: (() -> Void)?
|
||||||
var onDelete: (() -> Void)?
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
layer.cornerRadius = 8
|
layer.cornerRadius = 8
|
||||||
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||||
layer.borderWidth = 1
|
dashedLayer.fillColor = UIColor.clear.cgColor
|
||||||
|
dashedLayer.lineWidth = 1
|
||||||
|
dashedLayer.lineDashPattern = [6, 6]
|
||||||
|
layer.addSublayer(dashedLayer)
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.clipsToBounds = true
|
imageView.clipsToBounds = true
|
||||||
@ -744,7 +828,7 @@ private final class SampleCoverUploadView: UIControl {
|
|||||||
hintLabel.font = .systemFont(ofSize: 12)
|
hintLabel.font = .systemFont(ofSize: 12)
|
||||||
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||||
hintLabel.textAlignment = .center
|
hintLabel.textAlignment = .center
|
||||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||||
deleteButton.tintColor = .white
|
deleteButton.tintColor = .white
|
||||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||||
deleteButton.layer.cornerRadius = 12
|
deleteButton.layer.cornerRadius = 12
|
||||||
@ -791,13 +875,22 @@ private final class SampleCoverUploadView: UIControl {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
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?) {
|
func apply(item: SampleUploadedMediaItem?) {
|
||||||
guard let item else {
|
guard let item else {
|
||||||
imageView.image = nil
|
imageView.image = nil
|
||||||
addCircle.isHidden = false
|
addCircle.isHidden = false
|
||||||
titleLabel.isHidden = false
|
titleLabel.isHidden = false
|
||||||
|
titleLabel.text = "点击上传图片"
|
||||||
|
titleLabel.numberOfLines = 1
|
||||||
hintLabel.isHidden = false
|
hintLabel.isHidden = false
|
||||||
deleteButton.isHidden = true
|
deleteButton.isHidden = true
|
||||||
|
dashedLayer.isHidden = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if item.isUploading {
|
if item.isUploading {
|
||||||
@ -808,12 +901,14 @@ private final class SampleCoverUploadView: UIControl {
|
|||||||
titleLabel.numberOfLines = 2
|
titleLabel.numberOfLines = 2
|
||||||
hintLabel.isHidden = true
|
hintLabel.isHidden = true
|
||||||
deleteButton.isHidden = true
|
deleteButton.isHidden = true
|
||||||
|
dashedLayer.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
imageView.image = UIImage(data: item.data)
|
imageView.image = UIImage(data: item.data)
|
||||||
addCircle.isHidden = true
|
addCircle.isHidden = true
|
||||||
titleLabel.isHidden = true
|
titleLabel.isHidden = true
|
||||||
hintLabel.isHidden = true
|
hintLabel.isHidden = true
|
||||||
deleteButton.isHidden = item.ossUrl.isEmpty
|
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)))%"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
func testHomeMenuCatalogUsesSyncedSymbols() {
|
func testHomeMenuCatalogUsesSyncedSymbols() {
|
||||||
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill")
|
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill")
|
||||||
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill")
|
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill")
|
||||||
XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "point.3.connected.trianglepath.dotted")
|
XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "sample_ic_home_menu_route")
|
||||||
XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill")
|
XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill")
|
||||||
XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis")
|
XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,6 +60,16 @@ final class SampleManagementAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(operationQuery?.first { $0.name == "type" }?.value, "2")
|
XCTAssertEqual(operationQuery?.first { $0.name == "type" }?.value, "2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDetailMediaTypeZeroFallsBackToAlbumType() async throws {
|
||||||
|
let detail = envelopeJSON(#"{"id":8,"type":2,"status":1,"listing_status":1,"media_list":[{"id":1,"type":0,"oss_url":"https://cdn/video.mp4"}]}"#)
|
||||||
|
let session = MockURLSession(responses: [detail])
|
||||||
|
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.detail(id: 8)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.mediaList?.first?.resolvedType(fallback: response.type ?? 1), 2)
|
||||||
|
}
|
||||||
|
|
||||||
func testUploadEncodesAndroidBodyKeys() async throws {
|
func testUploadEncodesAndroidBodyKeys() async throws {
|
||||||
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
||||||
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
@ -97,11 +107,45 @@ final class SampleManagementAPITests: XCTestCase {
|
|||||||
let media = (body?["media_list"] as? [[String: Any]])?.first
|
let media = (body?["media_list"] as? [[String: Any]])?.first
|
||||||
XCTAssertEqual(media?["original_name"] as? String, "a.jpg")
|
XCTAssertEqual(media?["original_name"] as? String, "a.jpg")
|
||||||
XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg")
|
XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg")
|
||||||
|
XCTAssertEqual(media?["size"] as? Int, 1024)
|
||||||
let fileSize = media?["file_width_size"] as? [String: Any]
|
let fileSize = media?["file_width_size"] as? [String: Any]
|
||||||
XCTAssertEqual(fileSize?["width"] as? Int, 1920)
|
XCTAssertEqual(fileSize?["width"] as? Int, 1920)
|
||||||
XCTAssertEqual(fileSize?["height"] as? Int, 1080)
|
XCTAssertEqual(fileSize?["height"] as? Int, 1080)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUploadEncodesVideoMetadata() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
||||||
|
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
let request = SampleUploadMaterialRequest(
|
||||||
|
name: "视频样片",
|
||||||
|
mediaType: .video,
|
||||||
|
mediaList: [
|
||||||
|
SampleUploadMediaItem(
|
||||||
|
originalName: "rotated.mp4",
|
||||||
|
ossUrl: "https://cdn/rotated.mp4",
|
||||||
|
size: 4_096,
|
||||||
|
fileWidthSize: SampleUploadFileSize(width: 1080, height: 1920)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
coverUrl: "https://cdn/cover.jpg",
|
||||||
|
coverSize: "512",
|
||||||
|
scenicSpotId: 9,
|
||||||
|
projectId: 7
|
||||||
|
)
|
||||||
|
|
||||||
|
try await api.upload(request)
|
||||||
|
|
||||||
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
||||||
|
XCTAssertEqual(body?["type"] as? String, "2")
|
||||||
|
XCTAssertEqual(body?["media_type"] as? String, "2")
|
||||||
|
let media = (body?["media_list"] as? [[String: Any]])?.first
|
||||||
|
XCTAssertEqual(media?["original_name"] as? String, "rotated.mp4")
|
||||||
|
XCTAssertEqual(media?["size"] as? Int, 4_096)
|
||||||
|
let fileSize = media?["file_width_size"] as? [String: Any]
|
||||||
|
XCTAssertEqual(fileSize?["width"] as? Int, 1080)
|
||||||
|
XCTAssertEqual(fileSize?["height"] as? Int, 1920)
|
||||||
|
}
|
||||||
|
|
||||||
func testProjectAndScenicSpotPaths() async throws {
|
func testProjectAndScenicSpotPaths() async throws {
|
||||||
let spots = envelopeJSON(#"{"total":1,"list":[]}"#)
|
let spots = envelopeJSON(#"{"total":1,"list":[]}"#)
|
||||||
let projects = envelopeJSON(#"{"total":1,"list":[]}"#)
|
let projects = envelopeJSON(#"{"total":1,"list":[]}"#)
|
||||||
|
|||||||
@ -10,6 +10,13 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 样片管理 ViewModel 测试。
|
/// 样片管理 ViewModel 测试。
|
||||||
final class SampleManagementViewModelTests: XCTestCase {
|
final class SampleManagementViewModelTests: XCTestCase {
|
||||||
|
func testAllFilterStatusesMatchAndroidValues() {
|
||||||
|
XCTAssertEqual(SampleFilterStatus.all.apiStatus, 4)
|
||||||
|
XCTAssertEqual(SampleFilterStatus.approved.apiStatus, 1)
|
||||||
|
XCTAssertEqual(SampleFilterStatus.pending.apiStatus, 0)
|
||||||
|
XCTAssertEqual(SampleFilterStatus.rejected.apiStatus, 2)
|
||||||
|
}
|
||||||
|
|
||||||
func testListLoadsRefreshesAndMapsFilterStatus() async {
|
func testListLoadsRefreshesAndMapsFilterStatus() async {
|
||||||
let api = MockSampleAPI()
|
let api = MockSampleAPI()
|
||||||
api.listResponses = [
|
api.listResponses = [
|
||||||
@ -36,10 +43,12 @@ final class SampleManagementViewModelTests: XCTestCase {
|
|||||||
viewModel.onShowMessage = { messages.append($0) }
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
|
||||||
await viewModel.loadInitial(api: api)
|
await viewModel.loadInitial(api: api)
|
||||||
await viewModel.toggleListing(sampleId: 1, checked: true, api: api)
|
let resolved = await viewModel.toggleListing(sampleId: 1, checked: true, api: api)
|
||||||
|
|
||||||
XCTAssertEqual(messages.last, "审核通过才可以操作上下架")
|
XCTAssertEqual(messages.last, "审核通过才可以操作上下架")
|
||||||
XCTAssertTrue(api.operationCalls.isEmpty)
|
XCTAssertTrue(api.operationCalls.isEmpty)
|
||||||
|
XCTAssertFalse(resolved)
|
||||||
|
XCTAssertFalse(viewModel.isOperating)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testApprovedSampleToggleUpdatesLocalItem() async {
|
func testApprovedSampleToggleUpdatesLocalItem() async {
|
||||||
@ -48,11 +57,134 @@ final class SampleManagementViewModelTests: XCTestCase {
|
|||||||
let viewModel = SampleListViewModel()
|
let viewModel = SampleListViewModel()
|
||||||
|
|
||||||
await viewModel.loadInitial(api: api)
|
await viewModel.loadInitial(api: api)
|
||||||
await viewModel.toggleListing(sampleId: 2, checked: true, api: api)
|
let resolved = await viewModel.toggleListing(sampleId: 2, checked: true, api: api)
|
||||||
|
|
||||||
XCTAssertEqual(api.operationCalls.first?.id, 2)
|
XCTAssertEqual(api.operationCalls.first?.id, 2)
|
||||||
XCTAssertEqual(api.operationCalls.first?.listingStatus, 1)
|
XCTAssertEqual(api.operationCalls.first?.listingStatus, 1)
|
||||||
XCTAssertEqual(viewModel.items.first?.listingStatus, 1)
|
XCTAssertEqual(viewModel.items.first?.listingStatus, 1)
|
||||||
|
XCTAssertTrue(resolved)
|
||||||
|
XCTAssertFalse(viewModel.isOperating)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApprovedSampleExposesOperatingStateDuringRequest() async {
|
||||||
|
let api = MockSampleAPI()
|
||||||
|
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])]
|
||||||
|
api.operationDelayNanoseconds = 50_000_000
|
||||||
|
let viewModel = SampleListViewModel()
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
|
||||||
|
let operation = Task { await viewModel.toggleListing(sampleId: 2, checked: true, api: api) }
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.operatingSampleIDs.contains(2))
|
||||||
|
XCTAssertTrue(viewModel.isOperating)
|
||||||
|
let resolved = await operation.value
|
||||||
|
XCTAssertTrue(resolved)
|
||||||
|
XCTAssertFalse(viewModel.isOperating)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListingFailureReturnsAuthoritativePreviousState() async {
|
||||||
|
let api = MockSampleAPI()
|
||||||
|
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 3, status: 1, listingStatus: 0)])]
|
||||||
|
api.operationError = APIError.emptyData
|
||||||
|
let viewModel = SampleListViewModel()
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
let resolved = await viewModel.toggleListing(sampleId: 3, checked: true, api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(resolved)
|
||||||
|
XCTAssertEqual(viewModel.items.first?.listingStatus, 0)
|
||||||
|
XCTAssertFalse(viewModel.isOperating)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchAndDuplicateLoadingProtection() async {
|
||||||
|
let api = MockSampleAPI()
|
||||||
|
api.listDelayNanoseconds = 50_000_000
|
||||||
|
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 1)])]
|
||||||
|
let viewModel = SampleListViewModel()
|
||||||
|
|
||||||
|
let first = Task { await viewModel.updateSearchText(" 雪山 ", api: api) }
|
||||||
|
await Task.yield()
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
await first.value
|
||||||
|
|
||||||
|
XCTAssertEqual(api.listCalls.count, 1)
|
||||||
|
XCTAssertEqual(api.listCalls.first?.keyword, "雪山")
|
||||||
|
XCTAssertEqual(api.listCalls.first?.pageSize, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChangingMediaTypeClearsMaterialsButKeepsCover() async {
|
||||||
|
let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||||
|
let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg", "https://cdn/cover.jpg"])
|
||||||
|
|
||||||
|
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader)
|
||||||
|
await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: uploader)
|
||||||
|
viewModel.selectMediaType(.video)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.uploadedMediaList.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.coverImage?.ossUrl, "https://cdn/cover.jpg")
|
||||||
|
XCTAssertEqual(viewModel.mediaType, .video)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testScenicCascadeClearsSelectionsAndProjectPagination() async {
|
||||||
|
let firstScenic = ScenicInfo(id: 3, name: "景区A", status: 1, coverImg: nil)
|
||||||
|
let secondScenic = ScenicInfo(id: 4, name: "景区B", status: 1, coverImg: nil)
|
||||||
|
let api = MockSampleAPI()
|
||||||
|
api.projectResponses = [
|
||||||
|
SampleProjectListResponse(list: (0 ..< 10).map { SampleProjectItem(id: $0, name: "项目\($0)") }, total: 11),
|
||||||
|
SampleProjectListResponse(list: [SampleProjectItem(id: 10, name: "项目10")], total: 11),
|
||||||
|
]
|
||||||
|
let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||||
|
|
||||||
|
await viewModel.selectScenic(firstScenic, api: api)
|
||||||
|
await viewModel.loadProjects(refresh: true, api: api)
|
||||||
|
await viewModel.loadProjects(refresh: false, api: api)
|
||||||
|
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
|
||||||
|
viewModel.selectProject(SampleProjectItem(id: 10, name: "项目10"))
|
||||||
|
|
||||||
|
XCTAssertEqual(api.projectCalls.map(\.page), [1, 2])
|
||||||
|
XCTAssertEqual(viewModel.projectList.count, 11)
|
||||||
|
|
||||||
|
await viewModel.selectScenic(secondScenic, api: api)
|
||||||
|
|
||||||
|
XCTAssertNil(viewModel.selectedScenicSpot)
|
||||||
|
XCTAssertNil(viewModel.selectedProject)
|
||||||
|
XCTAssertTrue(viewModel.projectList.isEmpty)
|
||||||
|
XCTAssertFalse(viewModel.projectCanLoadMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMaterialCountLimitsFollowMediaType() async {
|
||||||
|
let imageViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||||
|
await imageViewModel.addLocalMedia(
|
||||||
|
(0 ..< 10).map { makeUploadItem(fileName: "\($0).jpg") },
|
||||||
|
uploader: MockSampleUploader(urls: (0 ..< 9).map { "https://cdn/\($0).jpg" })
|
||||||
|
)
|
||||||
|
XCTAssertEqual(imageViewModel.uploadedMediaList.count, 9)
|
||||||
|
|
||||||
|
let videoViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||||
|
videoViewModel.selectMediaType(.video)
|
||||||
|
await videoViewModel.addLocalMedia(
|
||||||
|
[makeUploadItem(fileName: "a.mp4"), makeUploadItem(fileName: "b.mp4")],
|
||||||
|
uploader: MockSampleUploader(urls: ["https://cdn/a.mp4"])
|
||||||
|
)
|
||||||
|
XCTAssertEqual(videoViewModel.uploadedMediaList.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSequentialUploadContinuesAfterOneFailure() async {
|
||||||
|
let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||||
|
let uploader = MockSampleUploader(results: [
|
||||||
|
.failure(APIError.emptyData),
|
||||||
|
.success("https://cdn/b.jpg"),
|
||||||
|
])
|
||||||
|
|
||||||
|
await viewModel.addLocalMedia(
|
||||||
|
[makeUploadItem(fileName: "a.jpg"), makeUploadItem(fileName: "b.jpg")],
|
||||||
|
uploader: uploader
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.uploadedMediaList[0].ossUrl, "")
|
||||||
|
XCTAssertEqual(viewModel.uploadedMediaList[1].ossUrl, "https://cdn/b.jpg")
|
||||||
|
XCTAssertNil(viewModel.uploadDialogState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUploadValidationMessages() async {
|
func testUploadValidationMessages() async {
|
||||||
@ -81,6 +213,34 @@ final class SampleManagementViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(api.lastUploadRequest)
|
XCTAssertNil(api.lastUploadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUploadValidationRequiresScenicSpotThenProject() async {
|
||||||
|
let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)
|
||||||
|
let api = MockSampleAPI()
|
||||||
|
let viewModel = UploadSampleViewModel(
|
||||||
|
scenicListProvider: { [scenic] },
|
||||||
|
currentScenicIdProvider: { 3 }
|
||||||
|
)
|
||||||
|
var messages: [String] = []
|
||||||
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
viewModel.updateAlbumName("样片")
|
||||||
|
await viewModel.addLocalMedia(
|
||||||
|
[makeUploadItem(fileName: "a.jpg")],
|
||||||
|
uploader: MockSampleUploader(urls: ["https://cdn/a.jpg"])
|
||||||
|
)
|
||||||
|
await viewModel.setCoverImage(
|
||||||
|
makeUploadItem(fileName: "cover.jpg"),
|
||||||
|
uploader: MockSampleUploader(urls: ["https://cdn/cover.jpg"])
|
||||||
|
)
|
||||||
|
await viewModel.selectScenic(scenic, api: api)
|
||||||
|
|
||||||
|
await viewModel.uploadSample(api: api)
|
||||||
|
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
|
||||||
|
await viewModel.uploadSample(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(Array(messages.suffix(2)), ["请选择关联打卡点", "请选择关联项目"])
|
||||||
|
XCTAssertNil(api.lastUploadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
func testUploadSuccessBuildsSampleRequest() async {
|
func testUploadSuccessBuildsSampleRequest() async {
|
||||||
let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)
|
let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)
|
||||||
let api = MockSampleAPI()
|
let api = MockSampleAPI()
|
||||||
@ -119,10 +279,17 @@ private final class MockSampleAPI: SampleManagementServing {
|
|||||||
var operationCalls: [(id: Int, listingStatus: Int)] = []
|
var operationCalls: [(id: Int, listingStatus: Int)] = []
|
||||||
var lastUploadRequest: SampleUploadMaterialRequest?
|
var lastUploadRequest: SampleUploadMaterialRequest?
|
||||||
var spotResponse = ScenicSpotListResponse(list: [], total: 0)
|
var spotResponse = ScenicSpotListResponse(list: [], total: 0)
|
||||||
var projectResponse = SampleProjectListResponse(list: [], total: 0)
|
var projectResponses: [SampleProjectListResponse] = []
|
||||||
|
var operationError: Error?
|
||||||
|
var listDelayNanoseconds: UInt64 = 0
|
||||||
|
var operationDelayNanoseconds: UInt64 = 0
|
||||||
|
var projectCalls: [(scenicId: String, page: Int, pageSize: Int)] = []
|
||||||
|
|
||||||
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse {
|
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse {
|
||||||
listCalls.append((status, keyword, page, pageSize))
|
listCalls.append((status, keyword, page, pageSize))
|
||||||
|
if listDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: listDelayNanoseconds)
|
||||||
|
}
|
||||||
guard !listResponses.isEmpty else { return SampleMaterialListResponse() }
|
guard !listResponses.isEmpty else { return SampleMaterialListResponse() }
|
||||||
return listResponses.removeFirst()
|
return listResponses.removeFirst()
|
||||||
}
|
}
|
||||||
@ -133,6 +300,10 @@ private final class MockSampleAPI: SampleManagementServing {
|
|||||||
|
|
||||||
func setListingStatus(id: Int, listingStatus: Int) async throws {
|
func setListingStatus(id: Int, listingStatus: Int) async throws {
|
||||||
operationCalls.append((id, listingStatus))
|
operationCalls.append((id, listingStatus))
|
||||||
|
if operationDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: operationDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let operationError { throw operationError }
|
||||||
}
|
}
|
||||||
|
|
||||||
func upload(_ request: SampleUploadMaterialRequest) async throws {
|
func upload(_ request: SampleUploadMaterialRequest) async throws {
|
||||||
@ -144,16 +315,22 @@ private final class MockSampleAPI: SampleManagementServing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse {
|
func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse {
|
||||||
projectResponse
|
projectCalls.append((scenicId, page, pageSize))
|
||||||
|
guard !projectResponses.isEmpty else { return SampleProjectListResponse() }
|
||||||
|
return projectResponses.removeFirst()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockSampleUploader: SampleOSSUploading {
|
private final class MockSampleUploader: SampleOSSUploading {
|
||||||
private var urls: [String]
|
private var results: [Result<String, Error>]
|
||||||
|
|
||||||
init(urls: [String]) {
|
init(urls: [String]) {
|
||||||
self.urls = urls
|
results = urls.map(Result.success)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(results: [Result<String, Error>]) {
|
||||||
|
self.results = results
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadSampleFile(
|
func uploadSampleFile(
|
||||||
@ -164,7 +341,8 @@ private final class MockSampleUploader: SampleOSSUploading {
|
|||||||
onProgress: @escaping (Int) -> Void
|
onProgress: @escaping (Int) -> Void
|
||||||
) async throws -> String {
|
) async throws -> String {
|
||||||
onProgress(100)
|
onProgress(100)
|
||||||
return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
|
guard !results.isEmpty else { return "https://cdn/default.jpg" }
|
||||||
|
return try results.removeFirst().get()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user