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

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

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

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "sample_img_error.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View 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

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "sample_img_loading.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View 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="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

View File

@ -20,7 +20,7 @@ enum HomeMenuCatalog {
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "gearshape.fill"),
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell.fill"),
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: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"),
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),

View File

@ -335,6 +335,11 @@ struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifi
?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
?? ""
}
/// 0 Android 退
func resolvedType(fallback: Int) -> Int {
type == 0 ? fallback : type
}
}
/// Android `ProjectItem`

View File

@ -26,6 +26,7 @@ final class SampleListViewModel {
private(set) var canLoadMore = false
private(set) var filterStatus: SampleFilterStatus = .all
private(set) var searchText = ""
private(set) var operatingSampleIDs: Set<Int> = []
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@ -33,6 +34,11 @@ final class SampleListViewModel {
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
private var isRequestingList = false
private var isLoadingMore = false
///
var isOperating: Bool { !operatingSampleIDs.isEmpty }
///
func loadInitial(api: any SampleManagementServing) async {
@ -67,15 +73,26 @@ final class SampleListViewModel {
}
///
func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async {
guard let currentItem = items.first(where: { $0.id == sampleId }) else { return }
@discardableResult
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 {
onShowMessage?("审核通过才可以操作上下架")
return
notifyStateChange()
return previousState
}
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 {
try await api.setListingStatus(id: sampleId, listingStatus: targetStatus)
@ -97,25 +114,38 @@ final class SampleListViewModel {
)
}
notifyStateChange()
return checked
} catch is CancellationError {
return
return previousState
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
return previousState
}
}
private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async {
guard !isRequestingList else {
if isRefreshing {
isRefreshing = false
notifyStateChange()
}
return
}
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoading else { return }
guard canLoadMore, !isLoading, !isLoadingMore else { return }
isLoadingMore = true
currentPage += 1
}
isRequestingList = true
isLoading = showLoading
notifyStateChange()
defer {
isRequestingList = false
if !reset { isLoadingMore = false }
isLoading = false
isRefreshing = false
notifyStateChange()
@ -276,7 +306,7 @@ final class UploadSampleViewModel {
onShowMessage?("请先选择景区")
return
}
if projectIsLoading && !refresh { return }
guard !projectIsLoading else { return }
if refresh {
projectCurrentPage = 1
projectList = []

View File

@ -564,7 +564,7 @@ private final class MaterialListCell: UITableViewCell {
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
}
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.trailing.lessThanOrEqualToSuperview()
make.bottom.equalToSuperview()

View File

@ -284,10 +284,10 @@ final class PunchPointDetailViewController: BaseViewController {
title.font = .systemFont(ofSize: 14)
title.textColor = UIColor(hex: 0x2E3746)
title.textAlignment = .left
title.snp.makeConstraints { make in
make.width.equalTo(contentStack.snp.width)
}
section.addArrangedSubview(title)
title.snp.makeConstraints { make in
make.width.equalTo(section.snp.width)
}
guard let qrCode = qrCode?.nonEmptyTrimmed else {
let empty = UILabel()
empty.text = "暂无二维码"

View File

@ -9,7 +9,7 @@ import SnapKit
import UIKit
/// Android `SampleDetailScreen`
final class SampleDetailViewController: BaseViewController {
final class SampleDetailViewController: SampleBaseViewController {
private let viewModel: SampleDetailViewModel
private let api: any SampleManagementServing
@ -21,6 +21,7 @@ final class SampleDetailViewController: BaseViewController {
private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点")
private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐")
private let reviewSection = SampleDetailReviewSection()
private let bottomSpacer = UIView()
///
init(
@ -38,14 +39,16 @@ final class SampleDetailViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "样片详情"
super.setupNavigationBar()
setSampleTitle("样片详情")
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
super.setupUI()
view.backgroundColor = SampleManagementUITokens.pageBackground
contentStack.axis = .vertical
contentStack.spacing = 12
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
contentStack.spacing = 0
scrollView.backgroundColor = SampleManagementUITokens.pageBackground
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
@ -55,11 +58,18 @@ final class SampleDetailViewController: BaseViewController {
contentStack.addArrangedSubview(scenicSection)
contentStack.addArrangedSubview(projectSection)
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() {
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
make.edges.equalToSuperview()
@ -96,7 +106,7 @@ final class SampleDetailViewController: BaseViewController {
hideLoading()
}
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)
uploaderSection.apply(detail: detail)
scenicSection.applyScenic(detail: detail)
@ -108,10 +118,15 @@ final class SampleDetailViewController: BaseViewController {
///
private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
private let scrollView = UIScrollView()
private let pageControl = UIPageControl()
private let indicatorStack = UIStackView()
private let placeholderLabel = UILabel()
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 playerViews: [Int: SamplePlayerLayerView] = [:]
private var timer: Timer?
override init(frame: CGRect) {
@ -121,22 +136,24 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
scrollView.showsHorizontalScrollIndicator = false
scrollView.delegate = self
scrollView.bounces = false
pageControl.currentPageIndicatorTintColor = .white
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
indicatorStack.axis = .horizontal
indicatorStack.spacing = 6
indicatorStack.alignment = .center
placeholderLabel.text = "暂无媒体"
placeholderLabel.font = .systemFont(ofSize: 14)
placeholderLabel.textColor = AppColor.textSecondary
placeholderLabel.textAlignment = .center
addSubview(scrollView)
addSubview(pageControl)
addSubview(indicatorStack)
addSubview(placeholderLabel)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
pageControl.snp.makeConstraints { make in
indicatorStack.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(8)
make.bottom.equalToSuperview().inset(12)
make.height.equalTo(8)
}
placeholderLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
@ -154,27 +171,35 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
}
///
func apply(media: [SampleDetailMediaItem]) {
func apply(media: [SampleDetailMediaItem], fallbackType: Int) {
stopTimer()
NotificationCenter.default.removeObserver(self)
players.values.forEach { $0.pause() }
players.removeAll()
playerViews.removeAll()
pageViews.removeAll()
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() }
pageControl.numberOfPages = media.count
pageControl.currentPage = 0
pageControl.isHidden = media.count <= 1
rebuildIndicators(currentIndex: 0)
indicatorStack.isHidden = media.count <= 1
placeholderLabel.isHidden = !media.isEmpty
scrollView.isHidden = media.isEmpty
guard !media.isEmpty else { return }
for (index, item) in media.enumerated() {
for (renderedIndex, mediaIndex) in renderedMediaIndices.enumerated() {
let item = media[mediaIndex]
let page = UIView()
page.backgroundColor = UIColor(hex: 0xF5F5F5)
page.tag = index
page.tag = renderedIndex
scrollView.addSubview(page)
if item.type == 2 {
addVideo(urlText: item.displayURL, to: page, index: index)
pageViews.append(page)
if item.resolvedType(fallback: fallbackType) == 2 {
addVideo(urlText: item.displayURL, to: page, index: renderedIndex)
} else {
addImage(urlText: item.displayURL, to: page)
}
@ -198,6 +223,10 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
updateCurrentPage()
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
stopTimer()
}
private func addImage(urlText: String, to page: UIView) {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
@ -207,7 +236,13 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
make.edges.equalToSuperview()
}
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)
players[index] = player
let playerView = SamplePlayerLayerView()
playerView.playerLayer.player = player
playerView.configure(player: player)
playerViews[index] = playerView
page.addSubview(playerView)
playerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
@ -235,32 +271,42 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
private func layoutPages() {
let size = bounds.size
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)
}
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMediaIndices.count), height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(currentRenderedPage) * size.width, y: 0)
}
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))
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()
}
private func playCurrentPageIfNeeded() {
stopTimer()
players.forEach { index, player in
if index == pageControl.currentPage {
if index == currentRenderedPage {
player.seek(to: .zero)
player.play()
playerViews[index]?.setPlaying(true)
} else {
player.pause()
playerViews[index]?.setPlaying(false)
}
}
guard !media.isEmpty else { return }
if media[pageControl.currentPage].type == 1 {
if media[currentMediaIndex].resolvedType(fallback: fallbackType) == 1 {
startImageTimer()
}
}
@ -278,13 +324,15 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
}
@objc private func videoDidEnd(_ notification: Notification) {
guard let item = notification.object as? AVPlayerItem,
players[currentRenderedPage]?.currentItem === item else { return }
advancePage()
}
private func advancePage() {
guard media.count > 1, bounds.width > 0 else { return }
let nextPage = (pageControl.currentPage + 1) % media.count
pageControl.currentPage = nextPage
let nextPage = min(currentRenderedPage + 1, renderedMediaIndices.count - 1)
currentRenderedPage = nextPage
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
if !scrollView.isDragging && !scrollView.isDecelerating {
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 let controlButton = UIButton(type: .system)
private weak var player: AVPlayer?
private var hideControlWorkItem: DispatchWorkItem?
override class var layerClass: AnyClass { AVPlayerLayer.self }
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 ?? ""
firstLineLabel.attributedText = coloredValue(prefix: "无人机: ", value: "\(detail.scenicPlanesNum)")
secondLineLabel.attributedText = coloredValue(prefix: "摄影师: ", value: "\(detail.scenicPhotographerNum)")
let shouldShowCounts = detail.scenicPlanesNum > 0 || detail.scenicPhotographerNum > 0
firstLineLabel.isHidden = !shouldShowCounts
secondLineLabel.isHidden = !shouldShowCounts
setImage(detail.scenicCoverImg)
}
func applyProject(detail: SampleDetail) {
titleLabel.text = detail.projectName ?? ""
firstLineLabel.isHidden = detail.projectPrice == nil
secondLineLabel.isHidden = detail.projectPriceDeposit == nil
if let price = detail.projectPrice {
firstLineLabel.attributedText = coloredValue(prefix: "价格: ", value: "\(price)")
} else {
@ -528,9 +661,13 @@ private final class SampleDetailImageInfoSection: UIView {
private func setImage(_ urlText: String?) {
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 {
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 ?? "--")
statusRow.apply(status: detail.status)
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.font = valueFont
}
/// 线
func setSeparatorHidden(_ hidden: Bool) {
separator.isHidden = hidden
}
}
///

View File

@ -8,7 +8,7 @@ import SnapKit
import UIKit
/// Android `SampleListScreen`
final class SampleListViewController: BaseViewController {
final class SampleListViewController: SampleBaseViewController {
private let viewModel: SampleListViewModel
private let api: any SampleManagementServing
@ -45,11 +45,13 @@ final class SampleListViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "样片相册列表"
super.setupNavigationBar()
setSampleTitle("样片相册列表")
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
super.setupUI()
view.backgroundColor = SampleManagementUITokens.pageBackground
searchContainer.backgroundColor = .white
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
@ -65,7 +67,7 @@ final class SampleListViewController: BaseViewController {
filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
filterButton.layer.cornerRadius = 4
filterTitleLabel.text = "筛选"
filterTitleLabel.text = SampleFilterStatus.all.title
filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
@ -80,7 +82,7 @@ final class SampleListViewController: BaseViewController {
filterDropdownStack.axis = .vertical
filterDropdownStack.spacing = 0
tableView.backgroundColor = AppColor.pageBackground
tableView.backgroundColor = SampleManagementUITokens.pageBackground
tableView.separatorStyle = .none
tableView.estimatedRowHeight = 152
tableView.rowHeight = UITableView.automaticDimension
@ -95,10 +97,15 @@ final class SampleListViewController: BaseViewController {
withIdentifier: SampleListCell.reuseIdentifier,
for: indexPath
) as! SampleListCell
cell.apply(item: item)
cell.onToggle = { [weak self] checked in
cell.apply(item: item, isOperating: self?.viewModel.operatingSampleIDs.contains(item.id) == true)
cell.onToggle = { [weak self, weak cell] checked in
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
}
@ -135,7 +142,7 @@ final class SampleListViewController: BaseViewController {
override func setupConstraints() {
searchContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview()
make.height.equalTo(68)
}
@ -232,7 +239,7 @@ final class SampleListViewController: BaseViewController {
@MainActor
private func applyViewModel() {
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
filterTitleLabel.text = viewModel.filterStatus.title
filterOptionButtons.forEach { status, button in
let selected = status == viewModel.filterStatus
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
@ -342,11 +349,13 @@ private final class SampleListCell: UITableViewCell {
private let scenicSpotLabel = UILabel()
private let statusBadge = SampleStatusBadgeView()
private let listingSwitch = UISwitch()
private let listingActivityIndicator = UIActivityIndicatorView(style: .medium)
private let statsStack = UIStackView()
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count")
private var isApplying = false
private var representedSampleID: Int?
var onToggle: ((Bool) -> Void)?
@ -360,13 +369,23 @@ private final class SampleListCell: UITableViewCell {
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
representedSampleID = item.id
titleLabel.text = item.name
projectLabel.text = "项目: \(item.projectName)"
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
statusBadge.apply(status: item.status)
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)
likeStatView.setCount(item.likesCount)
collectStatView.setCount(item.collectCount)
@ -381,10 +400,21 @@ private final class SampleListCell: UITableViewCell {
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() {
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
backgroundColor = SampleManagementUITokens.pageBackground
contentView.backgroundColor = SampleManagementUITokens.pageBackground
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
@ -411,7 +441,10 @@ private final class SampleListCell: UITableViewCell {
}
listingSwitch.onTintColor = AppColor.primary
listingSwitch.accessibilityLabel = "样片上下架"
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
listingActivityIndicator.color = AppColor.primary
listingActivityIndicator.hidesWhenStopped = true
statsStack.axis = .horizontal
statsStack.spacing = 16
@ -425,11 +458,13 @@ private final class SampleListCell: UITableViewCell {
cardView.addSubview(scenicSpotLabel)
cardView.addSubview(statusBadge)
cardView.addSubview(listingSwitch)
cardView.addSubview(listingActivityIndicator)
cardView.addSubview(statsStack)
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
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)
}
coverImageView.snp.makeConstraints { make in
@ -463,6 +498,9 @@ private final class SampleListCell: UITableViewCell {
make.trailing.equalToSuperview().inset(12)
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
}
listingActivityIndicator.snp.makeConstraints { make in
make.center.equalTo(listingSwitch)
}
statsStack.snp.makeConstraints { make in
make.top.equalTo(statusBadge.snp.bottom).offset(10)
make.leading.equalTo(titleLabel)
@ -473,6 +511,9 @@ private final class SampleListCell: UITableViewCell {
@objc private func switchChanged() {
guard !isApplying else { return }
listingSwitch.isEnabled = false
listingSwitch.isHidden = true
listingActivityIndicator.startAnimating()
onToggle?(listingSwitch.isOn)
}
}

View 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
}
}

View File

@ -10,7 +10,7 @@ import UniformTypeIdentifiers
import UIKit
/// Android `UploadSampleScreen`
final class UploadSampleViewController: BaseViewController {
final class UploadSampleViewController: SampleBaseViewController {
private enum PickTarget {
case material
case cover
@ -42,6 +42,7 @@ final class UploadSampleViewController: BaseViewController {
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let progressOverlay = SampleUploadProgressOverlay()
private let dropdownOverlay = SampleAnchoredDropdownView()
private var pickTarget: PickTarget = .material
@ -65,16 +66,18 @@ final class UploadSampleViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "样片相册"
super.setupNavigationBar()
setSampleTitle("样片相册")
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
super.setupUI()
view.backgroundColor = SampleManagementUITokens.pageBackground
formCard.backgroundColor = .white
formCard.layer.cornerRadius = 12
formCard.clipsToBounds = true
formStack.axis = .vertical
formStack.spacing = 16
formStack.spacing = 0
nameField.placeholder = "请输入相册名称"
nameField.font = .systemFont(ofSize: 14)
@ -96,6 +99,12 @@ final class UploadSampleViewController: BaseViewController {
[nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection]
.forEach(formStack.addArrangedSubview)
formStack.setCustomSpacing(8, after: nameSection)
formStack.setCustomSpacing(8, after: mediaTypeSection)
formStack.setCustomSpacing(16, after: materialSection)
formStack.setCustomSpacing(16, after: coverSection)
formStack.setCustomSpacing(8, after: scenicSection)
formStack.setCustomSpacing(8, after: spotSection)
bottomBar.backgroundColor = .white
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
@ -107,6 +116,7 @@ final class UploadSampleViewController: BaseViewController {
view.addSubview(bottomBar)
bottomBar.addSubview(cancelButton)
bottomBar.addSubview(confirmButton)
view.addSubview(dropdownOverlay)
view.addSubview(progressOverlay)
progressOverlay.isHidden = true
}
@ -128,7 +138,8 @@ final class UploadSampleViewController: BaseViewController {
make.trailing.equalToSuperview().inset(15)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.top.equalToSuperview()
make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
formCard.snp.makeConstraints { make in
@ -149,6 +160,9 @@ final class UploadSampleViewController: BaseViewController {
progressOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
dropdownOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
override func bindActions() {
@ -169,6 +183,7 @@ final class UploadSampleViewController: BaseViewController {
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
mediaGridView.onPreview = { [weak self] index in self?.presentMediaPreview(startingAt: index) }
coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
@ -229,26 +244,25 @@ final class UploadSampleViewController: BaseViewController {
}
@objc private func mediaTypeTapped() {
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
SampleMediaType.allCases.forEach { type in
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
self?.viewModel.selectMediaType(type)
})
let types = SampleMediaType.allCases
dropdownOverlay.show(
anchor: mediaTypeButton,
options: types.map { .init(title: $0.title, isSelected: $0 == viewModel.mediaType) }
) { [weak self] index in
guard types.indices.contains(index) else { return }
self?.viewModel.selectMediaType(types[index])
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func scenicTapped() {
let sheet = UIAlertController(title: "关联景区", message: nil, preferredStyle: .actionSheet)
viewModel.scenicList.forEach { scenic in
sheet.addAction(UIAlertAction(title: scenic.name, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectScenic(scenic, api: self.api) }
})
let scenics = viewModel.scenicList
dropdownOverlay.show(
anchor: scenicButton,
options: scenics.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenic?.id) }
) { [weak self] index in
guard let self, scenics.indices.contains(index) else { return }
Task { await self.viewModel.selectScenic(scenics[index], api: self.api) }
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func spotTapped() {
@ -256,14 +270,14 @@ final class UploadSampleViewController: BaseViewController {
showToast("请先选择关联景区")
return
}
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
viewModel.scenicSpotList.forEach { spot in
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
self?.viewModel.selectScenicSpot(spot)
})
let spots = viewModel.scenicSpotList
dropdownOverlay.show(
anchor: spotButton,
options: spots.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenicSpot?.id) }
) { [weak self] index in
guard spots.indices.contains(index) else { return }
self?.viewModel.selectScenicSpot(spots[index])
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func projectTapped() {
@ -277,28 +291,36 @@ final class UploadSampleViewController: BaseViewController {
await viewModel.loadProjects(refresh: true, api: api)
hideLoading()
}
presentProjectSheet()
await MainActor.run { self.presentProjectDropdown() }
}
}
private func presentProjectSheet() {
let sheet = UIAlertController(title: "关联项目", message: nil, preferredStyle: .actionSheet)
viewModel.projectList.forEach { project in
sheet.addAction(UIAlertAction(title: project.name, style: .default) { [weak self] _ in
self?.viewModel.selectProject(project)
})
}
@MainActor
private func presentProjectDropdown() {
let projects = viewModel.projectList
var options = projects.map { SampleAnchoredDropdownView.Option(title: $0.name, isSelected: $0.id == viewModel.selectedProject?.id) }
if viewModel.projectCanLoadMore {
sheet.addAction(UIAlertAction(title: "加载更多...", style: .default) { [weak self] _ in
options.append(.init(title: "加载更多...", isLoadMore: true))
}
dropdownOverlay.show(
anchor: projectButton,
options: options,
onReachedBottom: { [weak self] in self?.loadMoreProjectsAndRefreshDropdown() }
) { [weak self] index in
guard let self else { return }
if projects.indices.contains(index) {
self.viewModel.selectProject(projects[index])
} else if self.viewModel.projectCanLoadMore {
self.loadMoreProjectsAndRefreshDropdown()
}
}
}
private func loadMoreProjectsAndRefreshDropdown() {
Task {
await self.viewModel.loadProjects(refresh: false, api: self.api)
self.presentProjectSheet()
await viewModel.loadProjects(refresh: false, api: api)
await MainActor.run { self.presentProjectDropdown() }
}
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func cancelTapped() {
@ -329,6 +351,19 @@ final class UploadSampleViewController: BaseViewController {
present(sheet, animated: true)
}
private func presentMediaPreview(startingAt sourceIndex: Int) {
let completed = viewModel.uploadedMediaList.enumerated().filter { !$0.element.isUploading && !$0.element.ossUrl.isEmpty }
guard let startIndex = completed.firstIndex(where: { $0.offset == sourceIndex }) else { return }
let previewItems = completed.compactMap { entry -> MediaPreviewItem? in
switch viewModel.mediaType {
case .image: return .remoteImage(entry.element.ossUrl)
case .video: return .remoteVideo(entry.element.ossUrl)
}
}
guard !previewItems.isEmpty else { return }
presentMediaPreview(items: previewItems, startIndex: min(startIndex, previewItems.count - 1))
}
private func presentPhotoPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
if pickTarget == .cover {
@ -424,16 +459,28 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate {
guard let self, let url else { return }
let data = (try? Data(contentsOf: url)) ?? Data()
guard !data.isEmpty else { return }
let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "sample_video_\(Int(Date().timeIntervalSince1970)).mp4"
let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: "mp4")
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
}
} else {
group.enter()
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
let imageType = provider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .image) == true
} ?? UTType.image.identifier
provider.loadFileRepresentation(forTypeIdentifier: imageType) { [weak self] url, _ in
defer { group.leave() }
guard let self, let image = object as? UIImage else { return }
let fileName = "sample_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
guard let self, let url, let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return }
let pathExtension = url.pathExtension
let ext = UTType(imageType)?.preferredFilenameExtension ?? (pathExtension.isEmpty ? "jpg" : pathExtension)
let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: ext)
loadedItems[index] = SampleUploadedMediaItem(
data: data,
thumbnailData: image.jpegData(compressionQuality: 0.75),
fileName: fileName,
size: Int64(data.count),
width: Int(image.size.width * image.scale),
height: Int(image.size.height * image.scale)
)
}
}
}
@ -442,6 +489,18 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate {
self?.uploadPickedItems(loadedItems.compactMap { $0 })
}
}
private func resolvedFileName(provider: NSItemProvider, url: URL, fallbackExtension: String) -> String {
let suggested = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !suggested.isEmpty {
return URL(fileURLWithPath: suggested).pathExtension.isEmpty
? "\(suggested).\(fallbackExtension)"
: suggested
}
let urlName = url.lastPathComponent
if !urlName.isEmpty, urlName != "." { return urlName }
return "sample_\(Int(Date().timeIntervalSince1970)).\(fallbackExtension)"
}
}
extension UploadSampleViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
@ -544,6 +603,7 @@ private final class SampleUploadGridView: UIView {
private var mediaType: SampleMediaType = .image
var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)?
var onPreview: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
@ -593,6 +653,7 @@ private final class SampleUploadGridView: UIView {
if let item {
cell.apply(item: item)
cell.onDelete = { [weak self] in self?.onDelete?(index) }
cell.onPreview = { [weak self] in self?.onPreview?(index) }
} else {
cell.applyAdd()
cell.onAdd = { [weak self] in self?.onAdd?() }
@ -605,14 +666,17 @@ private final class SampleUploadGridView: UIView {
private final class SampleUploadThumbView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
private let textLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill"))
private let deleteButton = SampleExpandedTouchButton(type: .system)
private let playIcon = UIImageView(image: UIImage(named: "material_ic_play"))
private let mediaType: SampleMediaType
private let dashedLayer = CAShapeLayer()
private var previewEnabled = false
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
var onPreview: (() -> Void)?
init(mediaType: SampleMediaType) {
self.mediaType = mediaType
@ -628,7 +692,7 @@ private final class SampleUploadThumbView: UIControl {
textLabel.font = .systemFont(ofSize: 12)
textLabel.textColor = UIColor(hex: 0x4B5563)
textLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 10
@ -665,7 +729,12 @@ private final class SampleUploadThumbView: UIControl {
make.center.equalToSuperview()
make.size.equalTo(32)
}
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [6, 6]
layer.addSublayer(dashedLayer)
addTarget(self, action: #selector(cellTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@ -674,11 +743,18 @@ private final class SampleUploadThumbView: UIControl {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
}
func applyAdd() {
imageView.image = nil
backgroundColor = .white
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
layer.borderWidth = 0
dashedLayer.isHidden = false
previewEnabled = false
addCircle.isHidden = false
textLabel.isHidden = false
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
@ -688,7 +764,8 @@ private final class SampleUploadThumbView: UIControl {
}
func apply(item: SampleUploadedMediaItem) {
layer.borderWidth = 0
dashedLayer.isHidden = true
previewEnabled = !item.ossUrl.isEmpty && !item.isUploading
backgroundColor = UIColor(hex: 0xF5F5F5)
addCircle.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
@ -704,9 +781,12 @@ private final class SampleUploadThumbView: UIControl {
}
}
@objc private func addTapped() {
guard !addCircle.isHidden else { return }
@objc private func cellTapped() {
if !addCircle.isHidden {
onAdd?()
} else if previewEnabled {
onPreview?()
}
}
@objc private func deleteTapped() {
@ -718,18 +798,22 @@ private final class SampleUploadThumbView: UIControl {
private final class SampleCoverUploadView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
private let titleLabel = UILabel()
private let hintLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let deleteButton = SampleExpandedTouchButton(type: .system)
private let dashedLayer = CAShapeLayer()
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [6, 6]
layer.addSublayer(dashedLayer)
clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
@ -744,7 +828,7 @@ private final class SampleCoverUploadView: UIControl {
hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 12
@ -791,13 +875,22 @@ private final class SampleCoverUploadView: UIControl {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
}
func apply(item: SampleUploadedMediaItem?) {
guard let item else {
imageView.image = nil
addCircle.isHidden = false
titleLabel.isHidden = false
titleLabel.text = "点击上传图片"
titleLabel.numberOfLines = 1
hintLabel.isHidden = false
deleteButton.isHidden = true
dashedLayer.isHidden = false
return
}
if item.isUploading {
@ -808,12 +901,14 @@ private final class SampleCoverUploadView: UIControl {
titleLabel.numberOfLines = 2
hintLabel.isHidden = true
deleteButton.isHidden = true
dashedLayer.isHidden = true
} else {
imageView.image = UIImage(data: item.data)
addCircle.isHidden = true
titleLabel.isHidden = true
hintLabel.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty
dashedLayer.isHidden = true
}
}
@ -881,3 +976,12 @@ private final class SampleUploadProgressOverlay: UIView {
percentLabel.text = "\(max(0, min(100, state.progress)))%"
}
}
/// Android 44pt
private final class SampleExpandedTouchButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let horizontalInset = max(0, (44 - bounds.width) / 2)
let verticalInset = max(0, (44 - bounds.height) / 2)
return bounds.insetBy(dx: -horizontalInset, dy: -verticalInset).contains(point)
}
}

View File

@ -69,7 +69,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
func testHomeMenuCatalogUsesSyncedSymbols() {
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.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(HomeMenuItem.moreFunctions.iconName, "ellipsis")
}

View File

@ -60,6 +60,16 @@ final class SampleManagementAPITests: XCTestCase {
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 {
let session = MockURLSession(responses: [envelopeJSON("{}")])
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
XCTAssertEqual(media?["original_name"] as? String, "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]
XCTAssertEqual(fileSize?["width"] as? Int, 1920)
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 {
let spots = envelopeJSON(#"{"total":1,"list":[]}"#)
let projects = envelopeJSON(#"{"total":1,"list":[]}"#)

View File

@ -10,6 +10,13 @@ import XCTest
@MainActor
/// ViewModel
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 {
let api = MockSampleAPI()
api.listResponses = [
@ -36,10 +43,12 @@ final class SampleManagementViewModelTests: XCTestCase {
viewModel.onShowMessage = { messages.append($0) }
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, "审核通过才可以操作上下架")
XCTAssertTrue(api.operationCalls.isEmpty)
XCTAssertFalse(resolved)
XCTAssertFalse(viewModel.isOperating)
}
func testApprovedSampleToggleUpdatesLocalItem() async {
@ -48,11 +57,134 @@ final class SampleManagementViewModelTests: XCTestCase {
let viewModel = SampleListViewModel()
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?.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 {
@ -81,6 +213,34 @@ final class SampleManagementViewModelTests: XCTestCase {
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 {
let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)
let api = MockSampleAPI()
@ -119,10 +279,17 @@ private final class MockSampleAPI: SampleManagementServing {
var operationCalls: [(id: Int, listingStatus: Int)] = []
var lastUploadRequest: SampleUploadMaterialRequest?
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 {
listCalls.append((status, keyword, page, pageSize))
if listDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: listDelayNanoseconds)
}
guard !listResponses.isEmpty else { return SampleMaterialListResponse() }
return listResponses.removeFirst()
}
@ -133,6 +300,10 @@ private final class MockSampleAPI: SampleManagementServing {
func setListingStatus(id: Int, listingStatus: Int) async throws {
operationCalls.append((id, listingStatus))
if operationDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: operationDelayNanoseconds)
}
if let operationError { throw operationError }
}
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 {
projectResponse
projectCalls.append((scenicId, page, pageSize))
guard !projectResponses.isEmpty else { return SampleProjectListResponse() }
return projectResponses.removeFirst()
}
}
@MainActor
private final class MockSampleUploader: SampleOSSUploading {
private var urls: [String]
private var results: [Result<String, Error>]
init(urls: [String]) {
self.urls = urls
results = urls.map(Result.success)
}
init(results: [Result<String, Error>]) {
self.results = results
}
func uploadSampleFile(
@ -164,7 +341,8 @@ private final class MockSampleUploader: SampleOSSUploading {
onProgress: @escaping (Int) -> Void
) async throws -> String {
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()
}
}