feat: update app workflows and permissions
This commit is contained in:
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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, *) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user