feat: update app workflows and permissions

This commit is contained in:
2026-07-09 17:34:00 +08:00
parent 43e6133c21
commit 8e356973bd
44 changed files with 2944 additions and 307 deletions

View File

@ -17,6 +17,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
private let refreshControl = UIRefreshControl()
private let emptyLabel = UILabel()
private var shouldRefreshOnAppear = false
private var commissionDialogView: CooperationCommissionRateDialogView?
override func setupNavigationBar() {
title = "合作获客员"
@ -32,6 +33,10 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
override func setupUI() {
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 142
tableView.contentInset.top = AppSpacing.sm
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm
tableView.dataSource = self
tableView.delegate = self
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
@ -145,6 +150,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
}
emptyLabel.isHidden = !viewModel.acquirers.isEmpty || viewModel.isRefreshing
tableView.reloadData()
applyCommissionDialogState()
}
private func presentRemarkDialog(for acquirer: CooperativeSalerEntity) {
@ -168,44 +174,56 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
private func presentCommissionDialog(for acquirer: CooperativeSalerEntity) {
viewModel.startEditCommissionRate(acquirer)
let alert = UIAlertController(title: "修改分成比例", message: nil, preferredStyle: .alert)
alert.addTextField { [weak self] field in
field.placeholder = "分成比例 0-100"
field.keyboardType = .numberPad
field.text = self?.viewModel.commissionRateDraft
}
alert.addTextField { [weak self] field in
field.placeholder = "验证码"
field.keyboardType = .numberPad
field.text = self?.viewModel.commissionSmsCodeDraft
}
let phone = acquirer.salerPhone.isEmpty ? acquirer.displayPhone : acquirer.salerPhone
alert.message = "验证码将发送至 \(CooperationOrderPhoneMask.mask(phone))"
commissionDialogView?.dismiss()
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.dismissCommissionRateDialog()
})
alert.addAction(UIAlertAction(title: "获取验证码", style: .default) { [weak self] _ in
let dialog = CooperationCommissionRateDialogView()
dialog.onCommissionRateChange = { [weak self] text in
self?.viewModel.updateCommissionRateDraft(text)
}
dialog.onSmsCodeChange = { [weak self] text in
self?.viewModel.updateCommissionSmsCodeDraft(text)
}
dialog.onSendSmsCode = { [weak self, weak dialog] in
guard let self else { return }
if let rateText = alert.textFields?.first?.text {
self.viewModel.updateCommissionRateDraft(rateText)
}
if let codeText = alert.textFields?.last?.text {
self.viewModel.updateCommissionSmsCodeDraft(codeText)
if let dialog {
self.syncCommissionDialogDrafts(dialog)
}
Task { await self.viewModel.requestCommissionSmsCode(api: self.orderAPI) }
})
alert.addAction(UIAlertAction(title: "确认修改", style: .default) { [weak self] _ in
}
dialog.onCancel = { [weak self] in
self?.viewModel.dismissCommissionRateDialog()
}
dialog.onConfirm = { [weak self, weak dialog] in
guard let self else { return }
if let rateText = alert.textFields?.first?.text {
self.viewModel.updateCommissionRateDraft(rateText)
}
if let codeText = alert.textFields?.last?.text {
self.viewModel.updateCommissionSmsCodeDraft(codeText)
if let dialog {
self.syncCommissionDialogDrafts(dialog)
}
Task { await self.viewModel.saveCommissionRate(api: self.orderAPI) }
})
present(alert, animated: true)
}
commissionDialogView = dialog
applyCommissionDialogState()
dialog.show(in: view)
}
private func syncCommissionDialogDrafts(_ dialog: CooperationCommissionRateDialogView) {
viewModel.updateCommissionRateDraft(dialog.currentCommissionRate)
viewModel.updateCommissionSmsCodeDraft(dialog.currentSmsCode)
}
private func applyCommissionDialogState() {
guard let dialog = commissionDialogView else { return }
guard let acquirer = viewModel.commissionEditingAcquirer else {
dialog.dismiss()
commissionDialogView = nil
return
}
let phone = acquirer.salerPhone.isEmpty ? acquirer.displayPhone : acquirer.salerPhone
dialog.apply(
commissionRate: viewModel.commissionRateDraft,
smsCode: viewModel.commissionSmsCodeDraft,
phone: CooperationOrderPhoneMask.mask(phone),
countdown: viewModel.commissionSmsCountdown
)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

View File

@ -21,6 +21,8 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
private let footerLabel = UILabel()
private let emptyLabel = UILabel()
private var isApplyingTab = false
private var isSearchBarVisible = false
private var searchBarHeightConstraint: Constraint?
override func setupNavigationBar() {
title = "合作订单"
@ -39,6 +41,8 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.contentInset.top = AppSpacing.sm
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm
tableView.dataSource = self
tableView.delegate = self
tableView.register(ReferralLeadCell.self, forCellReuseIdentifier: ReferralLeadCell.reuseIdentifier)
@ -75,6 +79,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
searchBar.snp.makeConstraints { make in
make.top.equalTo(tabBarView.snp.bottom)
make.leading.trailing.equalToSuperview()
searchBarHeightConstraint = make.height.equalTo(0).constraint
}
tableView.snp.makeConstraints { make in
make.top.equalTo(searchBar.snp.bottom)
@ -139,7 +144,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
tabBarView.setSelectedTab(viewModel.selectedTab)
isApplyingTab = false
}
searchBar.isHidden = viewModel.selectedTab != 1
updateSearchBarVisibility(viewModel.selectedTab == 1)
searchBar.textField.text = viewModel.searchText
let isEmpty = viewModel.selectedTab == 0 ? viewModel.leads.isEmpty : viewModel.orders.isEmpty
@ -163,6 +168,24 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
tableView.reloadData()
}
private func updateSearchBarVisibility(_ isVisible: Bool) {
guard isSearchBarVisible != isVisible else { return }
isSearchBarVisible = isVisible
searchBarHeightConstraint?.update(offset: isVisible ? 62 : 0)
if isVisible {
searchBar.isHidden = false
} else {
searchBar.textField.resignFirstResponder()
}
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) {
self.view.layoutIfNeeded()
} completion: { _ in
if self.isSearchBarVisible == isVisible {
self.searchBar.isHidden = !isVisible
}
}
}
private func tableFooterView(isEmpty: Bool) -> UIView? {
guard !isEmpty else { return nil }
if viewModel.isLoadingMore {

View File

@ -120,9 +120,6 @@ final class CooperationOrderSearchBar: UIView, UITextFieldDelegate {
container.addSubview(icon)
container.addSubview(textField)
snp.makeConstraints { make in
make.height.equalTo(62)
}
container.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
@ -451,12 +448,18 @@ final class CooperationAcquirerCell: UITableViewCell {
var onCall: (() -> Void)?
private let cardView = UIView()
private let nameLabel = UILabel()
private let avatarContainer = UIView()
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
private let nameTitleLabel = UILabel()
private let nameValueLabel = UILabel()
private let editRemarkButton = UIButton(type: .system)
private let phoneLabel = UILabel()
private let phoneTitleLabel = UILabel()
private let phoneValueLabel = UILabel()
private let callButton = UIButton(type: .system)
private let bindTimeRow = CooperationInfoRowView(label: "绑定时间")
private let commissionRow = CooperationInfoRowView(label: "分成比例")
private let bindTimeTitleLabel = UILabel()
private let bindTimeValueLabel = UILabel()
private let commissionTitleLabel = UILabel()
private let commissionValueLabel = UILabel()
private let editCommissionButton = UIButton(type: .system)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
@ -466,70 +469,131 @@ final class CooperationAcquirerCell: UITableViewCell {
contentView.backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.lg
cardView.layer.cornerRadius = AppRadius.md
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.04
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
cardView.layer.shadowRadius = 2
nameLabel.font = .systemFont(ofSize: 16, weight: .bold)
nameLabel.textColor = AppColor.textPrimary
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
avatarContainer.layer.cornerRadius = AppRadius.sm
avatarContainer.clipsToBounds = true
avatarIconView.tintColor = AppColor.primary
avatarIconView.contentMode = .scaleAspectFit
configureTitleLabel(nameTitleLabel, text: "获客员名称")
configureTitleLabel(phoneTitleLabel, text: "手机号")
configureTitleLabel(bindTimeTitleLabel, text: "绑定时间")
configureTitleLabel(commissionTitleLabel, text: "分成比例")
nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold)
nameValueLabel.textColor = AppColor.textPrimary
nameValueLabel.numberOfLines = 1
nameValueLabel.lineBreakMode = .byTruncatingTail
[phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in
label.font = .systemFont(ofSize: 13)
label.textColor = AppColor.textSecondary
label.numberOfLines = 1
label.lineBreakMode = .byTruncatingTail
}
commissionValueLabel.font = .systemFont(ofSize: 13, weight: .medium)
editRemarkButton.setTitle("修改备注", for: .normal)
editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13)
editRemarkButton.setTitleColor(AppColor.primary, for: .normal)
editRemarkButton.contentEdgeInsets = UIEdgeInsets(top: 16, left: 14, bottom: 16, right: 14)
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
phoneLabel.font = .systemFont(ofSize: 13)
phoneLabel.textColor = AppColor.textSecondary
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary
callButton.backgroundColor = AppColor.primaryLight
callButton.layer.cornerRadius = 15
callButton.imageView?.contentMode = .scaleAspectFit
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
editCommissionButton.setTitle("修改", for: .normal)
editCommissionButton.setTitle("修改比例", for: .normal)
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13)
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
editCommissionButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside)
contentView.addSubview(cardView)
cardView.addSubview(nameLabel)
cardView.addSubview(avatarContainer)
avatarContainer.addSubview(avatarIconView)
cardView.addSubview(nameTitleLabel)
cardView.addSubview(nameValueLabel)
cardView.addSubview(editRemarkButton)
cardView.addSubview(phoneLabel)
cardView.addSubview(phoneTitleLabel)
cardView.addSubview(phoneValueLabel)
cardView.addSubview(callButton)
cardView.addSubview(bindTimeRow)
cardView.addSubview(commissionRow)
cardView.addSubview(bindTimeTitleLabel)
cardView.addSubview(bindTimeValueLabel)
cardView.addSubview(commissionTitleLabel)
cardView.addSubview(commissionValueLabel)
cardView.addSubview(editCommissionButton)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md))
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 14, bottom: AppSpacing.sm, right: 14))
}
nameLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(14)
avatarContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
make.size.equalTo(44)
}
avatarIconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(22)
}
nameTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.md)
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
}
nameValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(nameTitleLabel)
make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs)
}
editRemarkButton.snp.makeConstraints { make in
make.centerY.equalTo(nameLabel)
make.leading.equalTo(nameLabel.snp.trailing).offset(AppSpacing.xs)
make.top.trailing.equalToSuperview()
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(14)
phoneTitleLabel.snp.makeConstraints { make in
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.equalTo(nameTitleLabel)
}
phoneValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(phoneTitleLabel)
make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14)
make.centerY.equalTo(phoneLabel)
make.centerY.equalTo(phoneTitleLabel)
make.size.equalTo(30)
}
bindTimeRow.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(14)
bindTimeTitleLabel.snp.makeConstraints { make in
make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.equalTo(nameTitleLabel)
}
commissionRow.snp.makeConstraints { make in
make.top.equalTo(bindTimeRow.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(14)
make.bottom.equalToSuperview().inset(14)
bindTimeValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(bindTimeTitleLabel)
make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualToSuperview().inset(14)
}
commissionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.equalTo(nameTitleLabel)
make.bottom.equalToSuperview().inset(AppSpacing.md)
}
commissionValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(commissionTitleLabel)
make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs)
}
editCommissionButton.snp.makeConstraints { make in
make.centerY.equalTo(commissionRow)
make.leading.equalTo(commissionRow.snp.trailing).offset(AppSpacing.xs)
make.centerY.equalTo(commissionTitleLabel)
make.trailing.equalToSuperview().inset(AppSpacing.xs)
}
}
@ -538,11 +602,26 @@ final class CooperationAcquirerCell: UITableViewCell {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
onEditRemark = nil
onEditCommission = nil
onCall = nil
}
func configure(with acquirer: CooperativeSalerEntity) {
nameLabel.text = acquirer.displayName.isEmpty ? "" : acquirer.displayName
phoneLabel.text = "手机号 \(CooperationOrderPhoneMask.mask(acquirer.displayPhone))"
bindTimeRow.setValue(acquirer.displayBindTime.isEmpty ? "" : acquirer.displayBindTime)
commissionRow.setValue(acquirer.displayCommissionRate)
nameValueLabel.text = acquirer.displayName.isEmpty ? "" : acquirer.displayName
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "" : CooperationOrderPhoneMask.mask(acquirer.displayPhone)
bindTimeValueLabel.text = acquirer.displayBindTime.isEmpty ? "" : acquirer.displayBindTime
commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "" : acquirer.displayCommissionRate
}
private func configureTitleLabel(_ label: UILabel, text: String) {
label.text = text
label.font = .systemFont(ofSize: 13)
label.textColor = AppColor.textTertiary
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
}
@objc private func editRemarkTapped() { onEditRemark?() }
@ -550,6 +629,364 @@ final class CooperationAcquirerCell: UITableViewCell {
@objc private func callTapped() { onCall?() }
}
/// Android `CommissionRateEditDialog`
final class CooperationCommissionRateDialogView: UIView {
var onCommissionRateChange: ((String) -> Void)?
var onSmsCodeChange: ((String) -> Void)?
var onSendSmsCode: (() -> Void)?
var onCancel: (() -> Void)?
var onConfirm: (() -> Void)?
var currentCommissionRate: String { rateField.text }
var currentSmsCode: String { smsCodeField.text }
private let dimView = UIView()
private let cardView = UIView()
private let titleLabel = UILabel()
private let infoBox = UIView()
private let infoLabel = UILabel()
private let contentStack = UIStackView()
private let rateField = CooperationOutlinedTextFieldView(title: "分成比例", placeholder: "请输入0到100", suffix: "%")
private let phoneField = CooperationOutlinedTextFieldView(title: "验证码发送手机号", placeholder: "")
private let smsCodeField = CooperationOutlinedTextFieldView(title: "验证码", placeholder: "请输入验证码")
private let sendCodeButton = UIButton(type: .system)
private let buttonStack = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var cardCenterYConstraint: Constraint?
private var keyboardObservers: [NSObjectProtocol] = []
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
unregisterKeyboardObservers()
}
func apply(commissionRate: String, smsCode: String, phone: String, countdown: Int) {
rateField.text = commissionRate
smsCodeField.text = smsCode
phoneField.text = phone.isEmpty ? "" : phone
let isCountingDown = countdown > 0
sendCodeButton.setTitle(isCountingDown ? "\(countdown)" : "获取验证码", for: .normal)
sendCodeButton.isEnabled = !isCountingDown
sendCodeButton.backgroundColor = AppColor.primary.withAlphaComponent(isCountingDown ? 0.7 : 1)
sendCodeButton.setTitleColor(.white.withAlphaComponent(isCountingDown ? 0.7 : 1), for: .normal)
sendCodeButton.setTitleColor(.white.withAlphaComponent(0.7), for: .disabled)
}
func show(in parent: UIView) {
frame = parent.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0
parent.addSubview(self)
layoutIfNeeded()
registerKeyboardObservers()
rateField.focus()
UIView.animate(withDuration: 0.2) {
self.alpha = 1
}
}
func dismiss() {
endEditing(true)
unregisterKeyboardObservers()
UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0
}, completion: { _ in
self.removeFromSuperview()
})
}
private func setupUI() {
dimView.backgroundColor = AppColor.overlayScrim
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.xl
cardView.clipsToBounds = true
titleLabel.text = "修改分成比例"
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
infoBox.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
infoBox.layer.cornerRadius = AppRadius.sm
infoLabel.text = "修改分成比例需验证手机号"
infoLabel.font = .systemFont(ofSize: 13)
infoLabel.textColor = AppColor.primary
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.sm
rateField.keyboardType = .numberPad
smsCodeField.keyboardType = .numberPad
phoneField.isReadOnly = true
rateField.onTextChange = { [weak self] text in
self?.onCommissionRateChange?(text)
}
smsCodeField.onTextChange = { [weak self] text in
self?.onSmsCodeChange?(text)
}
sendCodeButton.setTitle("获取验证码", for: .normal)
sendCodeButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
sendCodeButton.setTitleColor(.white, for: .normal)
sendCodeButton.backgroundColor = AppColor.primary
sendCodeButton.layer.cornerRadius = AppRadius.sm
sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
buttonStack.axis = .horizontal
buttonStack.alignment = .center
buttonStack.distribution = .fillEqually
buttonStack.spacing = AppSpacing.sm
cancelButton.setTitle("取消", for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.setTitle("确认修改", for: .normal)
confirmButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
confirmButton.setTitleColor(AppColor.primary, for: .normal)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
addSubview(dimView)
addSubview(cardView)
cardView.addSubview(titleLabel)
cardView.addSubview(infoBox)
infoBox.addSubview(infoLabel)
cardView.addSubview(contentStack)
contentStack.addArrangedSubview(rateField)
contentStack.addArrangedSubview(phoneField)
contentStack.addArrangedSubview(makeSmsRow())
cardView.addSubview(buttonStack)
buttonStack.addArrangedSubview(cancelButton)
buttonStack.addArrangedSubview(confirmButton)
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
cardCenterYConstraint = make.centerY.equalToSuperview().constraint
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
infoBox.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
infoLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12))
}
contentStack.snp.makeConstraints { make in
make.top.equalTo(infoBox.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
buttonStack.snp.makeConstraints { make in
make.top.equalTo(contentStack.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(44)
}
}
private func makeSmsRow() -> UIView {
let row = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
row.axis = .horizontal
row.alignment = .fill
row.spacing = 10
sendCodeButton.snp.makeConstraints { make in
make.width.equalTo(104)
make.height.equalTo(46)
}
return row
}
private func registerKeyboardObservers() {
unregisterKeyboardObservers()
let center = NotificationCenter.default
keyboardObservers = [
center.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleKeyboardNotification(notification)
},
center.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleKeyboardNotification(notification)
}
]
}
private func unregisterKeyboardObservers() {
keyboardObservers.forEach(NotificationCenter.default.removeObserver)
keyboardObservers.removeAll()
}
private func handleKeyboardNotification(_ notification: Notification) {
layoutIfNeeded()
guard let userInfo = notification.userInfo else { return }
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
?? UInt(UIView.AnimationCurve.easeInOut.rawValue)
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
let offset = keyboardAvoidanceOffset(from: userInfo)
cardCenterYConstraint?.update(offset: offset)
UIView.animate(withDuration: duration, delay: 0, options: options) {
self.layoutIfNeeded()
}
}
private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat {
guard
let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
else {
return 0
}
let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil)
guard keyboardFrame.minY < bounds.height else { return 0 }
let cardHeight = cardView.bounds.height
let baseMinY = bounds.midY - cardHeight / 2
let baseMaxY = bounds.midY + cardHeight / 2
let desiredBottom = keyboardFrame.minY - AppSpacing.md
let neededShift = max(0, baseMaxY - desiredBottom)
let maxShift = max(0, baseMinY - AppSpacing.md)
return -min(neededShift, maxShift)
}
@objc private func sendCodeTapped() {
onSendSmsCode?()
}
@objc private func cancelTapped() {
onCancel?()
}
@objc private func confirmTapped() {
onConfirm?()
}
}
///
private final class CooperationOutlinedTextFieldView: UIView {
var onTextChange: ((String) -> Void)?
var text: String {
get { textField.text ?? "" }
set {
if textField.text != newValue {
textField.text = newValue
}
}
}
var keyboardType: UIKeyboardType {
get { textField.keyboardType }
set { textField.keyboardType = newValue }
}
var isReadOnly = false {
didSet {
textField.isUserInteractionEnabled = !isReadOnly
textField.textColor = isReadOnly ? AppColor.textSecondary : AppColor.textPrimary
}
}
private let titleLabel = UILabel()
private let containerView = UIView()
private let textField = UITextField()
private let suffixLabel = UILabel()
private let suffix: String?
init(title: String, placeholder: String, suffix: String? = nil) {
self.suffix = suffix
super.init(frame: .zero)
setupUI(title: title, placeholder: placeholder)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func focus() {
textField.becomeFirstResponder()
}
private func setupUI(title: String, placeholder: String) {
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textColor = AppColor.textSecondary
titleLabel.backgroundColor = .white
containerView.layer.borderWidth = 1
containerView.layer.borderColor = UIColor(hex: 0xD9DDE4).cgColor
containerView.layer.cornerRadius = AppRadius.sm
textField.placeholder = placeholder
textField.font = .systemFont(ofSize: 15)
textField.textColor = AppColor.textPrimary
textField.borderStyle = .none
textField.backgroundColor = .clear
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
suffixLabel.text = suffix
suffixLabel.font = .systemFont(ofSize: 15)
suffixLabel.textColor = AppColor.textSecondary
suffixLabel.isHidden = suffix == nil
addSubview(containerView)
addSubview(titleLabel)
containerView.addSubview(textField)
containerView.addSubview(suffixLabel)
snp.makeConstraints { make in
make.height.equalTo(56)
}
containerView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.equalToSuperview().offset(6)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.top.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.sm)
make.trailing.equalTo(suffixLabel.snp.leading).offset(-AppSpacing.xs)
make.centerY.equalTo(containerView)
}
suffixLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalTo(containerView)
}
}
@objc private func textChanged() {
onTextChange?(textField.text ?? "")
}
}
/// -
final class CooperationInfoRowView: UIView {
private let titleLabel = UILabel()

View File

@ -167,7 +167,7 @@ final class AllFunctionsViewController: BaseViewController {
private static func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { sectionIndex, environment in
let spacing: CGFloat = 12
let spacing: CGFloat = 15
let columns = 3
let horizontalInset = AppSpacing.md
let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2

View File

@ -28,8 +28,7 @@ enum HomeCollectionItem: Hashable {
enum HomeCollectionLayoutBuilder {
private static let menuColumns = 3
private static let menuSpacing: CGFloat = 15
private static let menuAspectRatio: CGFloat = 1.3
private static let menuSpacing = AppSpacing.sm
private static let sectionSpacing = AppSpacing.sm
/// Compositional Layout section
@ -42,7 +41,7 @@ enum HomeCollectionLayoutBuilder {
case .locationReport:
return fullWidthSection(height: 132)
case .quickActions:
return fullWidthSection(height: 72)
return quickActionsSection(environment: environment)
case .store:
return fullWidthSection(height: 96)
case .commonApps:
@ -120,19 +119,16 @@ enum HomeCollectionLayoutBuilder {
}
private static func menuGridSection(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2
let totalSpacing = menuSpacing * CGFloat(menuColumns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(menuColumns))
let itemHeight = itemWidth / menuAspectRatio
let itemWidth = menuItemWidth(environment: environment)
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemHeight)
heightDimension: .absolute(itemWidth)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemHeight)
heightDimension: .absolute(itemWidth)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
@ -162,4 +158,14 @@ enum HomeCollectionLayoutBuilder {
section.boundarySupplementaryItems = [header]
return section
}
private static func quickActionsSection(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
fullWidthSection(height: menuItemWidth(environment: environment))
}
private static func menuItemWidth(environment: NSCollectionLayoutEnvironment) -> CGFloat {
let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2
let totalSpacing = menuSpacing * CGFloat(menuColumns - 1)
return max(0, (availableWidth - totalSpacing) / CGFloat(menuColumns))
}
}

View File

@ -207,8 +207,8 @@ final class HomeViewController: BaseViewController {
)
}
cell.cardView.onSubmitTask = { [weak self] in
self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
}
self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
}
cell.cardView.onToggleOnline = { [weak self] in
self?.viewModel.showOnlineStatusSwitchDialog()
}
@ -301,10 +301,17 @@ final class HomeViewController: BaseViewController {
storeItem: viewModel.storeItem,
commonMenus: viewModel.commonMenus
)
dataSource.apply(snapshot, animatingDifferences: false)
dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
self?.refreshStatusVisibleCells()
}
presentDialogsIfNeeded()
}
private func refreshStatusVisibleCells() {
refreshWorkStatusVisibleCell()
refreshQuickActionsVisibleCell()
}
private func refreshWorkStatusVisibleCell() {
guard !viewModel.isMinimalTopRole,
let sectionIndex = currentSections.firstIndex(of: .workStatus) else {
@ -321,6 +328,18 @@ final class HomeViewController: BaseViewController {
)
}
private func refreshQuickActionsVisibleCell() {
guard !viewModel.isMinimalTopRole,
let sectionIndex = currentSections.firstIndex(of: .quickActions) else {
return
}
let indexPath = IndexPath(item: 0, section: sectionIndex)
guard let cell = collectionView.cellForItem(at: indexPath) as? HomeQuickActionsCell else {
return
}
cell.apply(isOnline: viewModel.isOnline)
}
private func presentDialogsIfNeeded() {
if viewModel.showPermissionDialog {
presentDialog(.permission)

View File

@ -20,6 +20,7 @@ final class AllFunctionMenuCell: UICollectionViewCell {
var onActionTap: (() -> Void)?
private let cardView = UIView()
private let contentStackView = UIStackView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let actionButton = UIButton(type: .system)
@ -67,30 +68,33 @@ final class AllFunctionMenuCell: UICollectionViewCell {
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.font = .app(.caption)
titleLabel.textColor = AppColor.textPrimary
contentStackView.axis = .vertical
contentStackView.alignment = .center
contentStackView.spacing = AppSpacing.sm
contentStackView.isUserInteractionEnabled = false
titleLabel.font = .app(.body)
titleLabel.textColor = AppColor.textSecondary
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
contentView.addSubview(cardView)
cardView.addSubview(iconView)
cardView.addSubview(titleLabel)
cardView.addSubview(contentStackView)
cardView.addSubview(actionButton)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(titleLabel)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.sm)
make.centerX.equalToSuperview()
make.width.height.equalTo(32)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.xs)
contentStackView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.bottom.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(26)
}
actionButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)

View File

@ -112,6 +112,8 @@ final class HomeMenuCell: UICollectionViewCell {
static let reuseIdentifier = "HomeMenuCell"
private let cardView = UIView()
private let contentStackView = UIStackView()
private let iconContainerView = UIView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
@ -127,40 +129,54 @@ final class HomeMenuCell: UICollectionViewCell {
func apply(menu: HomeMenuItem) {
iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName)
iconView.accessibilityIdentifier = menu.iconName
titleLabel.text = menu.title
}
private func setupUI() {
contentView.backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.md
cardView.backgroundColor = AppColor.cardBackground
cardView.layer.cornerRadius = AppRadius.lg
cardView.layer.borderWidth = 1
cardView.layer.borderColor = AppColor.cardOutline.cgColor
cardView.clipsToBounds = true
iconContainerView.backgroundColor = AppColor.iconBackground
iconContainerView.layer.cornerRadius = 20
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
contentStackView.axis = .vertical
contentStackView.alignment = .center
contentStackView.spacing = AppSpacing.xs
contentStackView.isUserInteractionEnabled = false
titleLabel.font = .app(.body)
titleLabel.textColor = AppColor.textSecondary
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
contentView.addSubview(cardView)
cardView.addSubview(iconView)
cardView.addSubview(titleLabel)
cardView.addSubview(contentStackView)
iconContainerView.addSubview(iconView)
contentStackView.addArrangedSubview(iconContainerView)
contentStackView.addArrangedSubview(titleLabel)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.sm)
make.centerX.equalToSuperview()
make.width.height.equalTo(24)
contentStackView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppSpacing.xs)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.bottom.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
iconContainerView.snp.makeConstraints { make in
make.width.height.equalTo(40)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(24)
}
}
}

View File

@ -11,6 +11,7 @@ final class HomeLocationReportCardView: UIView {
var onReportTap: (() -> Void)?
private let contentStackView = UIStackView()
private let titleStackView = UIStackView()
private let titleIconView = UIImageView(image: UIImage(systemName: "arrow.up.square.fill"))
private let titleLabel = UILabel()
@ -22,9 +23,15 @@ final class HomeLocationReportCardView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.cardBackground
layer.cornerRadius = AppRadius.md
layer.cornerRadius = AppRadius.lg
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
clipsToBounds = true
contentStackView.axis = .vertical
contentStackView.alignment = .fill
contentStackView.spacing = AppSpacing.xxs
titleStackView.axis = .horizontal
titleStackView.alignment = .center
titleStackView.spacing = AppSpacing.xs
@ -33,14 +40,14 @@ final class HomeLocationReportCardView: UIView {
titleIconView.contentMode = .scaleAspectFit
titleLabel.text = "立即上报"
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
addressLabel.font = .systemFont(ofSize: 15, weight: .regular)
addressLabel.font = .app(.subtitle)
addressLabel.textColor = AppColor.textTertiary
addressLabel.numberOfLines = 2
reportGradientLayer.colors = [AppColor.primary.cgColor, UIColor(hex: 0x5CA8FF).cgColor]
reportGradientLayer.colors = [AppColor.primary.cgColor, AppColor.primaryGradientEnd.cgColor]
reportGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
reportGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
reportButtonContainer.layer.insertSublayer(reportGradientLayer, at: 0)
@ -55,33 +62,34 @@ final class HomeLocationReportCardView: UIView {
forImageIn: .normal
)
reportButton.imageView?.contentMode = .scaleAspectFit
reportButton.addTarget(self, action: #selector(reportTapped), for: .touchUpInside)
reportButton.isExclusiveTouch = true
reportButton.addTarget(self, action: #selector(reportTouchDown), for: .touchDown)
reportButton.addTarget(self, action: #selector(reportTouchUpInside), for: .touchUpInside)
reportButton.addTarget(self, action: #selector(reportTouchEnded), for: [.touchUpOutside, .touchCancel, .touchDragExit])
reportButton.accessibilityLabel = "上报位置"
titleStackView.addArrangedSubview(titleIconView)
titleStackView.addArrangedSubview(titleLabel)
addSubview(titleStackView)
addSubview(addressLabel)
contentStackView.addArrangedSubview(titleStackView)
contentStackView.addArrangedSubview(addressLabel)
addSubview(contentStackView)
addSubview(reportButtonContainer)
reportButtonContainer.addSubview(reportButton)
titleIconView.snp.makeConstraints { make in
make.width.height.equalTo(25)
}
titleStackView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(15)
make.top.equalToSuperview().offset(15)
make.trailing.lessThanOrEqualTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm)
}
addressLabel.snp.makeConstraints { make in
make.top.equalTo(titleStackView.snp.bottom).offset(AppSpacing.xxs)
make.leading.equalTo(titleStackView)
make.trailing.lessThanOrEqualTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm)
contentStackView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.md)
make.centerY.equalTo(reportButtonContainer)
make.trailing.equalTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm)
make.top.greaterThanOrEqualToSuperview().offset(AppSpacing.md)
make.bottom.lessThanOrEqualToSuperview().offset(-AppSpacing.md)
}
reportButtonContainer.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-15)
make.trailing.equalToSuperview().offset(-AppSpacing.md)
make.centerY.equalToSuperview()
make.width.height.equalTo(92)
make.width.height.equalTo(96)
}
reportButton.snp.makeConstraints { make in
make.edges.equalToSuperview()
@ -103,10 +111,81 @@ final class HomeLocationReportCardView: UIView {
addressLabel.text = address
reportButton.isEnabled = !isReporting
reportButton.alpha = 1
if isReporting {
resetReportButtonScale()
}
reportGradientLayer.colors = isReporting
? [UIColor(hex: 0xC9CED6).cgColor, UIColor(hex: 0xD9DDE4).cgColor]
: [AppColor.primary.cgColor, UIColor(hex: 0x5CA8FF).cgColor]
? [AppColor.controlDisabledStart.cgColor, AppColor.controlDisabledEnd.cgColor]
: [AppColor.primary.cgColor, AppColor.primaryGradientEnd.cgColor]
}
@objc private func reportTapped() { onReportTap?() }
@objc private func reportTouchDown() {
animateReportButtonScale(isPressed: true)
playCenteredRipple()
}
@objc private func reportTouchUpInside() {
animateReportButtonScale(isPressed: false)
onReportTap?()
}
@objc private func reportTouchEnded() {
animateReportButtonScale(isPressed: false)
}
private func playCenteredRipple() {
reportButtonContainer.layer.sublayers?
.filter { $0.name == "HomeLocationReportRippleLayer" }
.forEach { $0.removeFromSuperlayer() }
let diameter = max(reportButtonContainer.bounds.width, reportButtonContainer.bounds.height) * 1.45
let rippleLayer = CALayer()
rippleLayer.name = "HomeLocationReportRippleLayer"
rippleLayer.backgroundColor = UIColor.white.withAlphaComponent(0.28).cgColor
rippleLayer.bounds = CGRect(x: 0, y: 0, width: diameter, height: diameter)
rippleLayer.cornerRadius = diameter / 2
rippleLayer.position = CGPoint(x: reportButtonContainer.bounds.midX, y: reportButtonContainer.bounds.midY)
rippleLayer.transform = CATransform3DMakeScale(0.18, 0.18, 1)
rippleLayer.opacity = 0.32
reportButtonContainer.layer.insertSublayer(rippleLayer, below: reportButton.layer)
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 0.18
scaleAnimation.toValue = 1
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = 0.32
opacityAnimation.toValue = 0
let group = CAAnimationGroup()
group.animations = [scaleAnimation, opacityAnimation]
group.duration = 0.55
group.timingFunction = CAMediaTimingFunction(name: .easeOut)
group.isRemovedOnCompletion = false
group.fillMode = .forwards
CATransaction.begin()
CATransaction.setCompletionBlock { [weak rippleLayer] in
rippleLayer?.removeFromSuperlayer()
}
rippleLayer.add(group, forKey: "centeredRipple")
CATransaction.commit()
}
private func animateReportButtonScale(isPressed: Bool) {
UIView.animate(
withDuration: isPressed ? 0.12 : 0.18,
delay: 0,
options: [.beginFromCurrentState, .curveEaseOut]
) {
self.reportButtonContainer.transform = isPressed
? CGAffineTransform(scaleX: 0.96, y: 0.96)
: .identity
}
}
private func resetReportButtonScale() {
reportButtonContainer.layer.removeAllAnimations()
reportButtonContainer.transform = .identity
}
}

View File

@ -23,7 +23,6 @@ final class HomeQuickActionsView: UIView {
addSubview(stackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(72)
}
}
@ -34,19 +33,36 @@ final class HomeQuickActionsView: UIView {
func apply(isOnline: Bool) {
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
stackView.addArrangedSubview(makeActionButton(title: "立即收款", icon: "yensign.circle", action: #selector(collectPaymentTapped)))
stackView.addArrangedSubview(makeActionButton(title: "提交任务", icon: "doc.text", action: #selector(submitTaskTapped)))
stackView.addArrangedSubview(makeActionButton(title: "立即收款", icon: "qrcode", action: #selector(collectPaymentTapped)))
stackView.addArrangedSubview(makeActionButton(title: "提交任务", icon: "checklist.checked", action: #selector(submitTaskTapped)))
stackView.addArrangedSubview(makeActionButton(
title: isOnline ? "在线" : "离线",
icon: isOnline ? "dot.radiowaves.left.and.right" : "moon",
action: #selector(toggleOnlineTapped)
icon: isOnline ? "wifi" : "wifi.slash",
action: #selector(toggleOnlineTapped),
backgroundColor: isOnline ? AppColor.online : AppColor.cardBackground,
iconBackgroundColor: isOnline ? UIColor.white.withAlphaComponent(0.18) : AppColor.inputBackground,
iconColor: isOnline ? AppColor.successBackground : AppColor.textTabInactive,
titleColor: isOnline ? AppColor.successBackground : AppColor.textTabInactive,
showsBorder: !isOnline
))
}
private func makeActionButton(title: String, icon: String, action: Selector) -> UIView {
private func makeActionButton(
title: String,
icon: String,
action: Selector,
backgroundColor: UIColor = AppColor.cardBackground,
iconBackgroundColor: UIColor = AppColor.iconBackground,
iconColor: UIColor = AppColor.primary,
titleColor: UIColor = AppColor.textSecondary,
showsBorder: Bool = true
) -> UIView {
let container = UIView()
container.backgroundColor = AppColor.cardBackground
container.layer.cornerRadius = AppRadius.md
container.backgroundColor = backgroundColor
container.layer.cornerRadius = AppRadius.lg
container.layer.borderWidth = showsBorder ? 1 : 0
container.layer.borderColor = AppColor.cardOutline.cgColor
container.accessibilityLabel = title
let button = UIButton(type: .system)
button.addTarget(self, action: action, for: .touchUpInside)
@ -55,26 +71,39 @@ final class HomeQuickActionsView: UIView {
make.edges.equalToSuperview()
}
let iconContainer = UIView()
iconContainer.backgroundColor = iconBackgroundColor
iconContainer.layer.cornerRadius = 18
let imageView = UIImageView(image: UIImage(systemName: icon))
imageView.tintColor = AppColor.primary
imageView.accessibilityIdentifier = icon
imageView.tintColor = iconColor
imageView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = title
label.font = .app(.bodyMedium)
label.textColor = AppColor.textPrimary
label.font = .app(.body)
label.textColor = titleColor
label.textAlignment = .center
label.numberOfLines = 2
let column = UIStackView(arrangedSubviews: [imageView, label])
iconContainer.addSubview(imageView)
let column = UIStackView(arrangedSubviews: [iconContainer, label])
column.axis = .vertical
column.alignment = .center
column.spacing = 6
column.spacing = AppSpacing.xs
column.isUserInteractionEnabled = false
container.addSubview(column)
column.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs)
}
iconContainer.snp.makeConstraints { make in
make.width.height.equalTo(36)
}
imageView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(22)
}
return container

View File

@ -15,14 +15,16 @@ final class HomeWorkStatusCardView: UIView {
private let contentStackView = UIStackView()
private let onlineButton = UIButton(type: .system)
private let countdownStackView = UIStackView()
private let countdownIconView = UIImageView(image: UIImage(systemName: "clock"))
private let countdownIconView = UIImageView(image: UIImage(systemName: "clock.fill"))
private let countdownLabel = UILabel()
private let reminderButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.cardBackground
layer.cornerRadius = AppRadius.md
layer.cornerRadius = AppRadius.lg
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
clipsToBounds = true
contentStackView.axis = .horizontal
@ -31,8 +33,8 @@ final class HomeWorkStatusCardView: UIView {
contentStackView.spacing = AppSpacing.sm
onlineButton.titleLabel?.font = .app(.captionMedium)
onlineButton.layer.cornerRadius = AppRadius.xs
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
onlineButton.layer.cornerRadius = AppRadius.sm
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14)
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
countdownStackView.axis = .horizontal
@ -42,16 +44,17 @@ final class HomeWorkStatusCardView: UIView {
countdownIconView.contentMode = .scaleAspectFit
countdownIconView.setContentCompressionResistancePriority(.required, for: .horizontal)
countdownLabel.font = .app(.subtitle)
countdownLabel.font = .systemFont(ofSize: 18, weight: .semibold)
countdownLabel.textColor = AppColor.primary
countdownLabel.adjustsFontSizeToFitWidth = true
countdownLabel.minimumScaleFactor = 0.85
countdownLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
reminderButton.titleLabel?.font = .app(.subtitle)
reminderButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
reminderButton.setTitleColor(AppColor.primary, for: .normal)
reminderButton.tintColor = AppColor.primary
reminderButton.setImage(UIImage(systemName: "bell.fill"), for: .normal)
let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal)
reminderButton.semanticContentAttribute = .forceLeftToRight
reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6)
reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6)
@ -67,10 +70,10 @@ final class HomeWorkStatusCardView: UIView {
addSubview(contentStackView)
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
make.edges.equalToSuperview().inset(AppSpacing.md)
}
countdownIconView.snp.makeConstraints { make in
make.width.height.equalTo(15)
make.width.height.equalTo(18)
}
snp.makeConstraints { make in
make.height.equalTo(84)

View File

@ -10,10 +10,34 @@ enum MainScanTabItem {
/// TabBarItem `MainTabBarController`
static func makeTabBarItem() -> UITabBarItem {
let image = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal)
let image = makeScanImage()
let item = UITabBarItem(title: nil, image: image, selectedImage: image)
item.accessibilityLabel = "扫码核销"
item.accessibilityIdentifier = "main.scan"
return item
}
private static func makeScanImage() -> UIImage {
let diameter: CGFloat = 48
let renderer = UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter))
return renderer.image { _ in
let bounds = CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter))
AppColor.primary.setFill()
UIBezierPath(ovalIn: bounds).fill()
let configuration = UIImage.SymbolConfiguration(pointSize: 25, weight: .semibold)
guard let symbol = UIImage(systemName: "qrcode.viewfinder", withConfiguration: configuration) else {
return
}
let image = symbol.withTintColor(.white, renderingMode: .alwaysOriginal)
let symbolSize = CGSize(width: 28, height: 28)
let symbolRect = CGRect(
x: (diameter - symbolSize.width) / 2,
y: (diameter - symbolSize.height) / 2,
width: symbolSize.width,
height: symbolSize.height
)
image.draw(in: symbolRect)
}.withRenderingMode(.alwaysOriginal)
}
}

View File

@ -67,6 +67,7 @@ final class MainTabBarController: UITabBarController {
private func configureTabBarAppearance() {
tabBar.tintColor = AppColor.primary
tabBar.unselectedItemTintColor = AppColor.textTabInactive
tabBar.backgroundColor = AppColor.cardBackground
tabBar.shadowImage = UIImage()
@ -82,7 +83,7 @@ final class MainTabBarController: UITabBarController {
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = AppColor.cardBackground
appearance.shadowColor = .clear
appearance.shadowColor = AppColor.tabBarShadow
appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttributes
appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttributes
tabBar.standardAppearance = appearance

View File

@ -3,46 +3,651 @@
// suixinkan
//
import SnapKit
import UIKit
/// 退 Android `OrderRefundDialog`
enum OrderRefundAlertController {
static func present(
from presenter: UIViewController,
order: OrderEntity,
onConfirm: @escaping (OrderRefundRequest) -> Void
) {
let alert = UIAlertController(title: "订单退款", message: order.orderNumber, preferredStyle: .alert)
alert.addTextField { $0.placeholder = "退款金额"; $0.text = order.actualPayAmount }
alert.addTextField { $0.placeholder = "退款原因" }
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确认退款", style: .destructive) { _ in
let amount = alert.textFields?.first?.text ?? order.actualPayAmount
let reason = alert.textFields?.last?.text ?? ""
onConfirm(OrderRefundRequest(orderNumber: order.orderNumber, refundType: 1, refundAmount: amount, refundReason: reason))
})
presenter.present(alert, animated: true)
let fullAmount = order.orderAmount.isEmpty ? order.actualPayAmount : order.orderAmount
let dialog = OrderRefundDialogView(
configuration: .photographerOrder(
orderNumber: order.orderNumber,
fullRefundAmount: fullAmount
)
)
dialog.onMessage = { [weak presenter] message in
(presenter as? BaseViewController)?.showToast(message)
}
dialog.onConfirm = { payload in
onConfirm(
OrderRefundRequest(
orderNumber: order.orderNumber,
refundType: payload.refundType,
refundAmount: payload.refundAmount,
refundReason: payload.refundReason
)
)
}
dialog.show(in: presenter.view)
}
}
/// /退 Android `DepositOrderRefundDialog`
enum DepositOrderRefundAlertController {
static func present(
from presenter: UIViewController,
orderNumber: String,
amount: String,
refundType: Int,
onConfirm: @escaping (String, String) -> Void
onConfirm: @escaping (Int, String, String) -> Void
) {
let title = refundType == 3 ? "部分退款" : "全额退款"
let alert = UIAlertController(title: title, message: orderNumber, preferredStyle: .alert)
alert.addTextField { $0.placeholder = "退款金额"; $0.text = amount }
alert.addTextField { $0.placeholder = "退款原因" }
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确认", style: .destructive) { _ in
let refundAmount = alert.textFields?.first?.text ?? amount
let reason = alert.textFields?.last?.text ?? ""
onConfirm(refundAmount, reason)
let dialog = OrderRefundDialogView(
configuration: .storeOrder(
orderNumber: orderNumber,
actualPayAmount: amount,
initialMode: OrderRefundMode(rawValue: refundType) ?? .full
)
)
dialog.onMessage = { [weak presenter] message in
(presenter as? BaseViewController)?.showToast(message)
}
dialog.onConfirm = { payload in
onConfirm(payload.refundType, payload.refundAmount, payload.refundReason)
}
dialog.show(in: presenter.view)
}
}
/// 退 Android `1=退3=退`
enum OrderRefundMode: Int {
case full = 1
case partial = 3
}
/// 退
enum OrderRefundDialogKind {
case photographerOrder
case storeOrder
}
/// 退
struct OrderRefundDialogPayload: Equatable {
let refundType: Int
let refundAmount: String
let refundReason: String
}
/// 退
struct OrderRefundDialogValidationError: Error, Equatable {
let message: String
}
/// 退 Android 退
enum OrderRefundDialogValidation {
static func filterMoneyInput(_ text: String, maxLength: Int) -> String {
var result = ""
var hasDot = false
var decimalCount = 0
for character in text {
guard result.count < maxLength else { break }
if character.isNumber {
if hasDot {
guard decimalCount < 2 else { continue }
decimalCount += 1
}
result.append(character)
} else if character == ".", !hasDot {
hasDot = true
result.append(character)
}
}
return result
}
static func validate(
kind: OrderRefundDialogKind,
mode: OrderRefundMode,
amountText: String,
reason: String,
fullRefundAmount: String,
maxRefundAmount: String
) -> Result<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
switch kind {
case .photographerOrder:
return validatePhotographerOrder(
mode: mode,
amountText: amountText,
reason: trimmedReason,
fullRefundAmount: fullRefundAmount
)
case .storeOrder:
return validateStoreOrder(
mode: mode,
amountText: amountText,
reason: trimmedReason,
maxRefundAmount: maxRefundAmount
)
}
}
private static func validatePhotographerOrder(
mode: OrderRefundMode,
amountText: String,
reason: String,
fullRefundAmount: String
) -> Result<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
guard !reason.isEmpty else {
return .failure(OrderRefundDialogValidationError(message: "请输入退款原因"))
}
guard reason.count <= 255 else {
return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符"))
}
if mode == .partial, amountText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .failure(OrderRefundDialogValidationError(message: "请输入退款金额"))
}
let refundAmount = mode == .full ? fullRefundAmount : amountText
return .success(
OrderRefundDialogPayload(
refundType: mode.rawValue,
refundAmount: refundAmount,
refundReason: reason
)
)
}
private static func validateStoreOrder(
mode: OrderRefundMode,
amountText: String,
reason: String,
maxRefundAmount: String
) -> Result<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
guard reason.count >= 5 else {
return .failure(OrderRefundDialogValidationError(message: "退款原因至少5个字符"))
}
guard reason.count <= 255 else {
return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符"))
}
guard mode == .partial else {
return .success(
OrderRefundDialogPayload(
refundType: mode.rawValue,
refundAmount: "0",
refundReason: reason
)
)
}
let trimmedAmount = amountText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedAmount.isEmpty else {
return .failure(OrderRefundDialogValidationError(message: "请输入退款金额"))
}
guard let amount = Double(trimmedAmount), amount > 0 else {
return .failure(OrderRefundDialogValidationError(message: "请输入有效的退款金额"))
}
let maxAmount = parseAmount(maxRefundAmount)
guard maxAmount <= 0 || amount <= maxAmount else {
return .failure(OrderRefundDialogValidationError(message: "退款金额不能大于实际支付金额"))
}
return .success(
OrderRefundDialogPayload(
refundType: mode.rawValue,
refundAmount: String(format: "%.2f", amount),
refundReason: reason
)
)
}
private static func parseAmount(_ text: String) -> Double {
text
.replacingOccurrences(of: ",", with: "")
.replacingOccurrences(of: "¥", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.toDoubleOrZero
}
}
private extension String {
var toDoubleOrZero: Double {
Double(self) ?? 0
}
}
/// 退 Android `OrderRefundDialog` / `DepositOrderRefundDialog`
final class OrderRefundDialogView: UIView, UITextViewDelegate {
/// 退/
struct Configuration {
let kind: OrderRefundDialogKind
let orderNumber: String
let fullRefundAmount: String
let maxRefundAmount: String
let initialMode: OrderRefundMode
let remarkPlaceholder: String
let moneyMaxLength: Int
static func photographerOrder(orderNumber: String, fullRefundAmount: String) -> Configuration {
Configuration(
kind: .photographerOrder,
orderNumber: orderNumber,
fullRefundAmount: fullRefundAmount,
maxRefundAmount: fullRefundAmount,
initialMode: .full,
remarkPlaceholder: "请输入退款原因(必填)",
moneyMaxLength: 10
)
}
static func storeOrder(
orderNumber: String,
actualPayAmount: String,
initialMode: OrderRefundMode
) -> Configuration {
Configuration(
kind: .storeOrder,
orderNumber: orderNumber,
fullRefundAmount: "0",
maxRefundAmount: actualPayAmount,
initialMode: initialMode,
remarkPlaceholder: "请输入退款原因(必填5-255字)",
moneyMaxLength: 15
)
}
}
var onConfirm: ((OrderRefundDialogPayload) -> Void)?
var onMessage: ((String) -> Void)?
private let configuration: Configuration
private var selectedMode: OrderRefundMode
private var keyboardObservers: [NSObjectProtocol] = []
private var cardCenterYConstraint: Constraint?
private let dimView = UIView()
private let cardView = UIView()
private let titleLabel = UILabel()
private let separatorView = UIView()
private let orderTitleLabel = UILabel()
private let orderNumberLabel = UILabel()
private let refundModeTitleLabel = UILabel()
private let modeButtonStack = UIStackView()
private let fullRefundButton = UIButton(type: .system)
private let partialRefundButton = UIButton(type: .system)
private let amountGroupView = UIView()
private let amountTitleLabel = UILabel()
private let amountContainerView = UIView()
private let amountField = UITextField()
private let remarkTitleLabel = UILabel()
private let remarkContainerView = UIView()
private let remarkTextView = UITextView()
private let remarkPlaceholderLabel = UILabel()
private let actionRow = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
init(configuration: Configuration) {
self.configuration = configuration
self.selectedMode = configuration.initialMode
super.init(frame: .zero)
setupUI()
applyMode(animated: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
unregisterKeyboardObservers()
}
func show(in parent: UIView) {
frame = parent.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0
parent.addSubview(self)
registerKeyboardObservers()
UIView.animate(withDuration: 0.2) {
self.alpha = 1
}
}
func dismiss() {
endEditing(true)
unregisterKeyboardObservers()
UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0
}, completion: { _ in
self.removeFromSuperview()
})
presenter.present(alert, animated: true)
}
private func setupUI() {
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2)
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.md
cardView.clipsToBounds = true
titleLabel.text = "订单退款"
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = .black
separatorView.backgroundColor = UIColor(hex: 0xF4F4F4)
configureSectionLabel(orderTitleLabel, text: "订单号")
orderNumberLabel.text = configuration.orderNumber
orderNumberLabel.font = .systemFont(ofSize: 16)
orderNumberLabel.textColor = .black
orderNumberLabel.numberOfLines = 0
configureSectionLabel(refundModeTitleLabel, text: "退款方式")
configureModeButton(fullRefundButton, title: "全额退款")
configureModeButton(partialRefundButton, title: "部分退款")
fullRefundButton.addTarget(self, action: #selector(fullRefundTapped), for: .touchUpInside)
partialRefundButton.addTarget(self, action: #selector(partialRefundTapped), for: .touchUpInside)
modeButtonStack.axis = .horizontal
modeButtonStack.spacing = AppSpacing.md
modeButtonStack.distribution = .fillEqually
configureSectionLabel(amountTitleLabel, text: "退款金额")
amountContainerView.layer.borderWidth = 1
amountContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor
amountContainerView.layer.cornerRadius = AppRadius.sm
amountField.placeholder = "请输入退款金额"
amountField.font = .systemFont(ofSize: 14)
amountField.textColor = AppColor.textPrimary
amountField.keyboardType = .decimalPad
amountField.borderStyle = .none
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
configureSectionLabel(remarkTitleLabel, text: "退款备注")
remarkContainerView.layer.borderWidth = 1
remarkContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor
remarkContainerView.layer.cornerRadius = AppRadius.sm
remarkTextView.font = .systemFont(ofSize: 14)
remarkTextView.textColor = AppColor.textPrimary
remarkTextView.backgroundColor = .clear
remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
remarkTextView.delegate = self
remarkPlaceholderLabel.text = configuration.remarkPlaceholder
remarkPlaceholderLabel.font = .systemFont(ofSize: 14)
remarkPlaceholderLabel.textColor = AppColor.textTertiary
actionRow.axis = .horizontal
actionRow.alignment = .center
actionRow.spacing = AppSpacing.xs
configureActionButton(cancelButton, title: "取消", foreground: AppColor.primary, background: AppColor.primaryLight)
configureActionButton(confirmButton, title: "确认", foreground: .white, background: AppColor.primary)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
addSubview(dimView)
addSubview(cardView)
cardView.addSubview(titleLabel)
cardView.addSubview(separatorView)
cardView.addSubview(orderTitleLabel)
cardView.addSubview(orderNumberLabel)
cardView.addSubview(refundModeTitleLabel)
cardView.addSubview(modeButtonStack)
modeButtonStack.addArrangedSubview(fullRefundButton)
modeButtonStack.addArrangedSubview(partialRefundButton)
cardView.addSubview(amountGroupView)
amountGroupView.addSubview(amountTitleLabel)
amountGroupView.addSubview(amountContainerView)
amountContainerView.addSubview(amountField)
cardView.addSubview(remarkTitleLabel)
cardView.addSubview(remarkContainerView)
remarkContainerView.addSubview(remarkTextView)
remarkContainerView.addSubview(remarkPlaceholderLabel)
cardView.addSubview(actionRow)
actionRow.addArrangedSubview(cancelButton)
actionRow.addArrangedSubview(confirmButton)
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
cardCenterYConstraint = make.centerY.equalToSuperview().constraint
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
separatorView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(1)
}
orderTitleLabel.snp.makeConstraints { make in
make.top.equalTo(separatorView.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
orderNumberLabel.snp.makeConstraints { make in
make.top.equalTo(orderTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
refundModeTitleLabel.snp.makeConstraints { make in
make.top.equalTo(orderNumberLabel.snp.bottom).offset(AppSpacing.xl)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
modeButtonStack.snp.makeConstraints { make in
make.top.equalTo(refundModeTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(46)
}
amountGroupView.snp.makeConstraints { make in
make.top.equalTo(modeButtonStack.snp.bottom).offset(AppSpacing.xl)
make.leading.trailing.equalToSuperview()
make.height.equalTo(73)
}
amountTitleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: 0, right: AppSpacing.md))
}
amountContainerView.snp.makeConstraints { make in
make.top.equalTo(amountTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(46)
}
amountField.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
remarkTitleLabel.snp.makeConstraints { make in
make.top.equalTo(amountGroupView.snp.bottom).offset(AppSpacing.xl)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
remarkContainerView.snp.makeConstraints { make in
make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(104)
}
remarkTextView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
remarkPlaceholderLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
}
actionRow.snp.makeConstraints { make in
make.top.equalTo(remarkContainerView.snp.bottom).offset(20)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.lg)
}
[cancelButton, confirmButton].forEach { button in
button.snp.makeConstraints { make in
make.width.equalTo(84)
make.height.equalTo(38)
}
}
}
private func configureSectionLabel(_ label: UILabel, text: String) {
label.text = text
label.font = .systemFont(ofSize: 16)
label.textColor = UIColor(hex: 0x4B5563)
}
private func configureModeButton(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14)
button.layer.cornerRadius = AppRadius.sm
button.layer.borderWidth = 1
}
private func configureActionButton(_ button: UIButton, title: String, foreground: UIColor, background: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(foreground, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = background
button.layer.cornerRadius = AppRadius.sm
}
private func applyMode(animated: Bool) {
let amountHidden = selectedMode == .full
amountGroupView.isHidden = amountHidden
amountGroupView.snp.updateConstraints { make in
make.height.equalTo(amountHidden ? 0 : 73)
}
remarkTitleLabel.snp.remakeConstraints { make in
make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(AppSpacing.xl)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
updateModeButton(fullRefundButton, selected: selectedMode == .full)
updateModeButton(partialRefundButton, selected: selectedMode == .partial)
let changes = { self.layoutIfNeeded() }
if animated {
UIView.animate(withDuration: 0.2, animations: changes)
} else {
changes()
}
}
private func updateModeButton(_ button: UIButton, selected: Bool) {
button.backgroundColor = selected ? AppColor.primary : .white
button.layer.borderColor = selected ? UIColor.clear.cgColor : UIColor(hex: 0xB6BECA).cgColor
button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal)
}
private func registerKeyboardObservers() {
unregisterKeyboardObservers()
let center = NotificationCenter.default
keyboardObservers = [
center.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleKeyboardNotification(notification)
},
center.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleKeyboardNotification(notification)
}
]
}
private func unregisterKeyboardObservers() {
keyboardObservers.forEach(NotificationCenter.default.removeObserver)
keyboardObservers.removeAll()
}
private func handleKeyboardNotification(_ notification: Notification) {
layoutIfNeeded()
guard let userInfo = notification.userInfo else { return }
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
?? UInt(UIView.AnimationCurve.easeInOut.rawValue)
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
let offset = keyboardAvoidanceOffset(from: userInfo)
cardCenterYConstraint?.update(offset: offset)
UIView.animate(withDuration: duration, delay: 0, options: options) {
self.layoutIfNeeded()
}
}
private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat {
guard
let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
else {
return 0
}
let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil)
guard keyboardFrame.minY < bounds.height else { return 0 }
let desiredBottom = keyboardFrame.minY - AppSpacing.sm
let overlap = cardView.frame.maxY - desiredBottom
return overlap > 0 ? -overlap : 0
}
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 255 {
textView.text = String(textView.text.prefix(255))
}
remarkPlaceholderLabel.isHidden = !textView.text.isEmpty
}
@objc private func amountChanged() {
let filtered = OrderRefundDialogValidation.filterMoneyInput(
amountField.text ?? "",
maxLength: configuration.moneyMaxLength
)
if amountField.text != filtered {
amountField.text = filtered
}
}
@objc private func fullRefundTapped() {
selectedMode = .full
amountField.text = configuration.kind == .storeOrder ? configuration.maxRefundAmount : configuration.fullRefundAmount
applyMode(animated: true)
}
@objc private func partialRefundTapped() {
selectedMode = .partial
amountField.text = ""
applyMode(animated: true)
amountField.becomeFirstResponder()
}
@objc private func cancelTapped() {
dismiss()
}
@objc private func confirmTapped() {
let result = OrderRefundDialogValidation.validate(
kind: configuration.kind,
mode: selectedMode,
amountText: amountField.text ?? "",
reason: remarkTextView.text ?? "",
fullRefundAmount: configuration.fullRefundAmount,
maxRefundAmount: configuration.maxRefundAmount
)
switch result {
case .success(let payload):
onConfirm?(payload)
dismiss()
case .failure(let error):
onMessage?(error.message)
}
}
}

View File

@ -125,13 +125,6 @@ final class OrderSourceLeadListViewController: BaseViewController, UITableViewDa
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent {
navigationController?.setNavigationBarHidden(true, animated: false)
}
}
func reloadFromViewModel() {
applyViewModel()
}

View File

@ -16,7 +16,6 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
private let onLeadBound: (OrderSourceSelection) -> Void
private let onCallPhone: (String) -> Void
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
@ -46,6 +45,8 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
override func viewDidLoad() {
super.viewDidLoad()
title = "选择获客员"
navigationItem.largeTitleDisplayMode = .never
view.backgroundColor = .white
setupUI()
applyViewModel()
@ -58,10 +59,6 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = AppColor.text333
titleLabel.text = "选择获客员"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.text666
subtitleLabel.text = "选择人员后,再选择该人员创建的带客单"
@ -82,20 +79,15 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
emptyLabel.numberOfLines = 0
emptyLabel.text = "暂无合作获客员"
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(tableView)
view.addSubview(loadingIndicator)
view.addSubview(emptyLabel)
titleLabel.snp.makeConstraints { make in
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(5)
make.leading.trailing.equalTo(titleLabel)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()

View File

@ -411,11 +411,11 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
orderNumber: item.orderNumber,
amount: item.actualPayAmount,
refundType: 1
) { amount, reason in
) { refundType, amount, reason in
Task {
await self.depositViewModel.requestRefund(
orderNumber: item.orderNumber,
refundType: 1,
refundType: refundType,
refundAmount: amount,
refundReason: reason,
api: self.orderAPI
@ -430,11 +430,11 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
orderNumber: item.orderNumber,
amount: item.actualPayAmount,
refundType: 3
) { amount, reason in
) { refundType, amount, reason in
Task {
await self.depositViewModel.requestRefund(
orderNumber: item.orderNumber,
refundType: 3,
refundType: refundType,
refundAmount: amount,
refundReason: reason,
api: self.orderAPI
@ -496,7 +496,6 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
)
self.orderSourcePicker = controller
let nav = UINavigationController(rootViewController: controller)
nav.setNavigationBarHidden(true, animated: false)
if let sheet = nav.sheetPresentationController {
sheet.prefersGrabberVisible = true
if #available(iOS 16.0, *) {

View File

@ -9,19 +9,44 @@ import UIKit
/// chip
final class OrderTypeChipView: UIView {
private enum Metrics {
static let contentInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
}
private let pillView = UIView()
private let label = UILabel()
override var intrinsicContentSize: CGSize {
let labelSize = label.intrinsicContentSize
return CGSize(
width: labelSize.width + Metrics.contentInsets.left + Metrics.contentInsets.right,
height: labelSize.height + Metrics.contentInsets.top + Metrics.contentInsets.bottom
)
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = OrderTokens.chipBackground
layer.cornerRadius = OrderTokens.pillRadius
clipsToBounds = true
backgroundColor = .clear
setContentHuggingPriority(.required, for: .horizontal)
setContentHuggingPriority(.required, for: .vertical)
pillView.backgroundColor = OrderTokens.chipBackground
pillView.layer.cornerRadius = OrderTokens.pillRadius
pillView.clipsToBounds = true
label.font = .app(.caption)
label.textColor = AppColor.primary
addSubview(label)
label.lineBreakMode = .byTruncatingTail
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
addSubview(pillView)
pillView.addSubview(label)
pillView.snp.makeConstraints { make in
make.top.bottom.leading.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview()
}
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.xxs, bottom: 0, right: AppSpacing.xxs))
make.edges.equalToSuperview().inset(Metrics.contentInsets)
}
}
@ -33,5 +58,6 @@ final class OrderTypeChipView: UIView {
func apply(text: String) {
label.text = text
isHidden = text.isEmpty
invalidateIntrinsicContentSize()
}
}

View File

@ -125,7 +125,7 @@ final class WildPhotographerReportListViewController: BaseViewController {
reports.forEach { record in
let card = WildReportListCardView(record: record)
card.onOpen = { [weak self] in self?.openDetail(record) }
card.onShare = { [weak self] in self?.viewModel.shareReport(record) }
card.onShare = { [weak self] in self?.shareReport(record) }
stack.addArrangedSubview(card)
}
}
@ -148,6 +148,21 @@ final class WildPhotographerReportListViewController: BaseViewController {
)
}
private func shareReport(_ record: WildReportRecord) {
showLoading()
Task { [weak self] in
guard let self else { return }
let payload = await self.viewModel.shareReport(record, api: self.api)
await MainActor.run {
self.hideLoading()
guard let payload else { return }
WeChatShareService.shareMiniProgram(payload) { [weak self] result in
self?.showWildReportShareResult(result)
}
}
}
}
private var currentScenicId: Int? {
let scenicId = AppStore.shared.currentScenicId
return scenicId > 0 ? scenicId : nil
@ -736,7 +751,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText))
if !viewModel.handleRemarkText.isEmpty {
if !viewModel.handleRemarkText.isEmpty, !viewModel.showHandlerInfo {
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理备注", value: viewModel.handleRemarkText, multiline: true))
}
@ -768,13 +783,16 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 18, left: 18, bottom: 10, right: 18))
}
card.stack.addArrangedSubview(titleWrap)
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理人", value: info.handlerName))
card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理人", value: info.handlerName))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill"))
let phoneRow = WildReportHandlerInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill")
phoneRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handlerPhoneTapped)))
phoneRow.accessibilityTraits.insert(.button)
card.stack.addArrangedSubview(phoneRow)
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理时间", value: info.processingAt))
card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理时间", value: info.processingAt))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理完成时间", value: info.processedAt))
card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理完成时间", value: info.processedAt))
card.stack.addArrangedSubview(makeInsetDivider())
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理意见", value: info.processOpinion, multiline: true))
@ -800,7 +818,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
header.alignment = .center
header.addArrangedSubview(WildReportUI.label(item.submitTime, font: .app(.captionMedium), color: AppColor.textSecondary))
header.addArrangedSubview(UIView())
header.addArrangedSubview(WildReportUI.label("\(items.count - index)次补充", font: .app(.captionMedium), color: AppColor.primary))
header.addArrangedSubview(WildReportUI.label(
WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: index),
font: .app(.captionMedium),
color: AppColor.primary
))
itemStack.addArrangedSubview(header)
itemStack.addArrangedSubview(WildReportUI.label(item.summaryText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0))
card.stack.addArrangedSubview(itemStack)
@ -948,12 +970,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
section.spacing = 12
section.layoutMargins = UIEdgeInsets(top: 14, left: 18, bottom: 16, right: 18)
section.isLayoutMarginsRelativeArrangement = true
section.addArrangedSubview(makeSectionHeader(
section.addArrangedSubview(makeCompactMediaSectionHeader(
iconName: "photo.on.rectangle.angled",
title: "处理凭证",
subtitle: info.completionMediaSummary,
chevron: false,
action: nil
summary: info.completionMediaSummary,
action: #selector(completionPreviewTapped)
))
let mediaScroll = UIScrollView()
@ -1199,10 +1220,33 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
return wrapper
}
@objc private func shareTapped() { viewModel.shareReport() }
@objc private func shareTapped() {
showLoading()
Task { [weak self] in
guard let self else { return }
let payload = await self.viewModel.shareReport(api: self.api)
await MainActor.run {
self.hideLoading()
guard let payload else { return }
WeChatShareService.shareMiniProgram(payload) { [weak self] result in
self?.showWildReportShareResult(result)
}
}
}
}
@objc private func previewTapped() { presentReporterMediaPreview(selectedKey: nil) }
@objc private func paymentPreviewTapped() { presentPaymentMediaPreview(selectedKey: nil) }
@objc private func completionPreviewTapped() { presentCompletionMediaPreview(selectedKey: nil) }
@objc private func locationTapped() { viewModel.showLocationPreview() }
@objc private func handlerPhoneTapped() {
guard let phone = viewModel.handlerInfo?.handlerPhone.trimmingCharacters(in: .whitespacesAndNewlines),
!phone.isEmpty,
let url = URL(string: "tel://\(phone)") else {
showToast("暂无处理人电话")
return
}
UIApplication.shared.open(url)
}
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
@ -1367,6 +1411,60 @@ private final class WildReportDetailInfoRowView: UIView {
}
}
///
private final class WildReportHandlerInfoRowView: UIView {
init(title: String, value: String, trailingIconName: String? = nil) {
super.init(frame: .zero)
let titleLabel = WildReportUI.label(title, font: .app(.captionMedium), color: AppColor.textSecondary)
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
let valueLabel = WildReportUI.label(value, font: .app(.subtitle), color: AppColor.textPrimary, lines: 1)
valueLabel.textAlignment = .right
valueLabel.lineBreakMode = .byTruncatingTail
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
addSubview(titleLabel)
addSubview(valueLabel)
if let trailingIconName {
let icon = UIImageView(image: UIImage(systemName: trailingIconName))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
addSubview(icon)
icon.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(18)
make.centerY.equalToSuperview()
make.size.equalTo(16)
}
valueLabel.snp.makeConstraints { make in
make.trailing.equalTo(icon.snp.leading).offset(-6)
}
}
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(14)
make.centerY.equalToSuperview()
if trailingIconName == nil {
make.trailing.equalToSuperview().inset(18)
}
}
snp.makeConstraints { make in
make.height.equalTo(48)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension MediaPreviewItem {
init?(wildReportReporterItem item: WildReportReporterMediaItem) {
guard let urlText = item.previewKey else { return nil }
@ -1393,6 +1491,28 @@ private extension MediaPreviewItem {
}
}
private extension BaseViewController {
@MainActor
func showWildReportShareResult(_ result: WeChatShareResult) {
switch result {
case .success:
showToast("分享成功")
case .cancelled:
showToast("已取消分享")
case .notInstalled:
showToast("请先安装微信")
case .unsupported:
showToast("当前微信版本不支持分享")
case .invalidPayload(let message):
showToast(message)
case .sendFailed:
showToast("微信分享发送失败")
case .unknown:
showToast("微信分享失败")
}
}
}
private extension WildReportReporterMediaItem {
var previewKey: String? {
guard let text = fileURL?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }

View File

@ -20,6 +20,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private let submitButton = UIButton(type: .system)
private var pickerTarget: WildReportPickerTarget = .image
private weak var activeInputView: UIView?
private var isShowingSubmitLoading = false
init(
homeViewModel: WildPhotographerReportHomeViewModel,
@ -109,7 +110,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.rebuildContent() }
Task { @MainActor in
guard let self else { return }
self.updateSubmitLoading()
self.rebuildContent()
}
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
@ -152,7 +157,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
contentStack.addArrangedSubview(paymentCard)
contentStack.setCustomSpacing(16, after: paymentCard)
contentStack.addArrangedSubview(submitButton)
submitButton.setTitle(viewModel.isSubmitting ? viewModel.submitProgressText : "提交举报", for: .normal)
submitButton.setTitle("提交举报", for: .normal)
submitButton.isEnabled = !viewModel.isSubmitting
submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1
submitButton.snp.remakeConstraints { make in
@ -561,6 +566,18 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
@objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") }
@objc private func dismissKeyboard() { view.endEditing(true) }
@MainActor
private func updateSubmitLoading() {
if viewModel.isSubmitting {
guard !isShowingSubmitLoading else { return }
isShowingSubmitLoading = true
showLoading()
} else if isShowingSubmitLoading {
isShowingSubmitLoading = false
hideLoading()
}
}
private func registerKeyboardNotifications() {
NotificationCenter.default.addObserver(
self,

View File

@ -23,6 +23,7 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
private var pickerTarget: WildReportPickerTarget = .image
private weak var activeInputView: UIView?
private var isShowingSubmitLoading = false
init(
record: WildReportRecord,
@ -111,6 +112,7 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
guard let self else { return }
self.updateSubmitLoading()
if self.textView.isFirstResponder {
self.updateTextInputState()
self.updateSubmitButton()
@ -271,14 +273,23 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
}
private func updateSubmitButton() {
let title = viewModel.isSubmitting && !viewModel.submitProgressText.isEmpty
? viewModel.submitProgressText
: "提交补充证据"
submitButton.setTitle(title, for: .normal)
submitButton.setTitle("提交补充证据", for: .normal)
submitButton.isEnabled = !viewModel.isSubmitting
submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1
}
@MainActor
private func updateSubmitLoading() {
if viewModel.isSubmitting {
guard !isShowingSubmitLoading else { return }
isShowingSubmitLoading = true
showLoading()
} else if isShowingSubmitLoading {
isShowingSubmitLoading = false
hideLoading()
}
}
private func canAddAttachment(kind: WildReportAttachmentKind, currentCount: Int, maxCount: Int) -> Bool {
let ownRemaining = max(0, maxCount - currentCount)
let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count)