Advance UIKit rewrite with AMap integration and core UI modules.
Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -11,6 +11,7 @@ import UIKit
|
||||
final class CloudStorageViewController: ModuleTableViewController {
|
||||
private let viewModel = CloudStorageViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "云盘"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,22 +24,27 @@ final class CloudStorageViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.files.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let file = viewModel.files[indexPath.row]
|
||||
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
/// willDisplayTableRow 回调处理。
|
||||
override func willDisplayTableRow(at indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.files.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.assetsAPI) }
|
||||
}
|
||||
|
||||
/// openTransit相关逻辑。
|
||||
@objc private func openTransit() {
|
||||
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
|
||||
}
|
||||
@ -50,22 +56,27 @@ extension CloudStorageViewModel: ViewModelBindable {}
|
||||
final class CloudStorageTransitViewController: ModuleTableViewController {
|
||||
private let store = CloudTransferStore.shared
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "传输记录"
|
||||
super.viewDidLoad()
|
||||
store.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { store.records.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = store.records[indexPath.row]
|
||||
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {}
|
||||
}
|
||||
|
||||
/// MediaLibraryKindRoute,业务类型定义。
|
||||
enum MediaLibraryKindRoute {
|
||||
case material
|
||||
case sample
|
||||
@ -76,6 +87,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
||||
private let kind: MediaLibraryKindRoute
|
||||
private let viewModel: MediaLibraryViewModel
|
||||
|
||||
/// 初始化实例。
|
||||
init(kind: MediaLibraryKindRoute = .material) {
|
||||
self.kind = kind
|
||||
switch kind {
|
||||
@ -92,6 +104,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "样片库" : "素材库"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -104,17 +117,21 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
/// open上传相关逻辑。
|
||||
@objc private func openUpload() {
|
||||
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
|
||||
}
|
||||
@ -128,6 +145,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
private let viewModel = MediaLibraryEditorViewModel()
|
||||
private let nameField = UITextField()
|
||||
|
||||
/// 初始化实例。
|
||||
init(kind: MediaLibraryKindRoute) {
|
||||
self.kind = kind
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -138,6 +156,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "上传样片" : "上传素材"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -154,17 +173,21 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.projects.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let project = viewModel.projects[indexPath.row]
|
||||
cell.configure(title: project.name, subtitle: project.statusName)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
@ -186,19 +209,23 @@ extension MediaLibraryEditorViewModel: ViewModelBindable {}
|
||||
final class AlbumListViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumListViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
@ -210,19 +237,23 @@ extension AlbumListViewModel: ViewModelBindable {}
|
||||
final class AlbumTrailerViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumTrailerViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "相册预告"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
@ -6,9 +6,14 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
private typealias AccountSelectionSection = Int
|
||||
private typealias AccountSelectionItem = String
|
||||
|
||||
@MainActor
|
||||
/// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。
|
||||
final class AccountSelectionViewController: UIViewController {
|
||||
final class AccountSelectionViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let payload: AccountSelectionPayload
|
||||
private var isLoading: Bool
|
||||
@ -17,9 +22,13 @@ final class AccountSelectionViewController: UIViewController {
|
||||
|
||||
private var selectedAccountId: String?
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
/// Diffable 数据源,驱动账号列表与选中态刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>!
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private let bottomBar = UIView()
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
payload: AccountSelectionPayload,
|
||||
isLoading: Bool,
|
||||
@ -38,6 +47,7 @@ final class AccountSelectionViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "选择账号"
|
||||
@ -47,9 +57,47 @@ final class AccountSelectionViewController: UIViewController {
|
||||
|
||||
selectedAccountId = payload.accounts.first?.id
|
||||
configureTableView()
|
||||
configureTableDataSource()
|
||||
configureBottomBar()
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>(
|
||||
tableView: tableView
|
||||
) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSelectionItem) -> UITableViewCell? in
|
||||
guard let self,
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: AccountSelectionCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? AccountSelectionCell,
|
||||
let account = self.payload.accounts.first(where: { $0.id == item }) else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
cell.configure(account: account, selected: account.id == self.selectedAccountId)
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(payload.accounts.map(\.id), toSection: 0)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
|
||||
var snapshot = buildTableSnapshot()
|
||||
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
tableDataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 更新Loading状态。
|
||||
func updateLoading(_ loading: Bool) {
|
||||
isLoading = loading
|
||||
isModalInPresentation = loading
|
||||
@ -65,10 +113,10 @@ final class AccountSelectionViewController: UIViewController {
|
||||
selectedAccount != nil && !isLoading
|
||||
}
|
||||
|
||||
/// 配置TableView展示内容。
|
||||
private func configureTableView() {
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
|
||||
view.addSubview(tableView)
|
||||
@ -78,6 +126,7 @@ final class AccountSelectionViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置BottomBar展示内容。
|
||||
private func configureBottomBar() {
|
||||
bottomBar.backgroundColor = .white
|
||||
|
||||
@ -112,37 +161,26 @@ final class AccountSelectionViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击cancel的处理逻辑。
|
||||
@objc private func cancelTapped() {
|
||||
onCancel()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
/// 点击confirm的处理逻辑。
|
||||
@objc private func confirmTapped() {
|
||||
guard let selectedAccount else { return }
|
||||
onConfirm(selectedAccount)
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
payload.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: AccountSelectionCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? AccountSelectionCell else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
let account = payload.accounts[indexPath.row]
|
||||
cell.configure(account: account, selected: account.id == selectedAccountId)
|
||||
return cell
|
||||
}
|
||||
|
||||
extension AccountSelectionViewController {
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
selectedAccountId = payload.accounts[indexPath.row].id
|
||||
tableView.reloadData()
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
selectedAccountId = item
|
||||
applyTableSnapshot(animated: false, reconfigure: true)
|
||||
confirmButton.isEnabled = canConfirm
|
||||
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
|
||||
}
|
||||
@ -161,6 +199,7 @@ private final class AccountSelectionCell: UITableViewCell {
|
||||
private let currentTag = UILabel()
|
||||
private let checkmark = UIImageView()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
@ -257,6 +296,7 @@ private final class AccountSelectionCell: UITableViewCell {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(account: AccountSwitchAccount, selected: Bool) {
|
||||
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
|
||||
subtitleLabel.text = account.subtitle
|
||||
|
||||
@ -26,6 +26,7 @@ final class LoginViewController: UIViewController {
|
||||
private let privacyPolicyButton = UIButton(type: .system)
|
||||
private let loginButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化实例。
|
||||
init(services: AppServices) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -36,6 +37,7 @@ final class LoginViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0x0B1220)
|
||||
@ -44,6 +46,7 @@ final class LoginViewController: UIViewController {
|
||||
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
|
||||
}
|
||||
|
||||
/// 配置Views展示内容。
|
||||
private func configureViews() {
|
||||
backgroundImageView.contentMode = .scaleAspectFill
|
||||
backgroundImageView.clipsToBounds = true
|
||||
@ -161,6 +164,7 @@ final class LoginViewController: UIViewController {
|
||||
view.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
/// 配置AgreementText展示内容。
|
||||
private func configureAgreementText() {
|
||||
privacyLabel.numberOfLines = 0
|
||||
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
@ -197,6 +201,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定ViewModel回调或数据。
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.renderViewModel()
|
||||
@ -204,6 +209,7 @@ final class LoginViewController: UIViewController {
|
||||
renderViewModel()
|
||||
}
|
||||
|
||||
/// render视图模型相关逻辑。
|
||||
private func renderViewModel() {
|
||||
if usernameField.textField.text != viewModel.username {
|
||||
usernameField.textField.text = viewModel.username
|
||||
@ -241,38 +247,46 @@ final class LoginViewController: UIViewController {
|
||||
|
||||
private weak var accountSelectionController: AccountSelectionViewController?
|
||||
|
||||
/// username变更相关逻辑。
|
||||
@objc private func usernameChanged() {
|
||||
viewModel.username = usernameField.textField.text ?? ""
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
services.toastCenter.dismiss()
|
||||
}
|
||||
|
||||
/// password变更相关逻辑。
|
||||
@objc private func passwordChanged() {
|
||||
viewModel.password = passwordField.textField.text ?? ""
|
||||
services.toastCenter.dismiss()
|
||||
}
|
||||
|
||||
/// togglePrivacy相关逻辑。
|
||||
@objc private func togglePrivacy() {
|
||||
viewModel.privacyChecked.toggle()
|
||||
}
|
||||
|
||||
/// openUserAgreement相关逻辑。
|
||||
@objc private func openUserAgreement() {
|
||||
showToast("用户协议页面待接入")
|
||||
}
|
||||
|
||||
/// openPrivacyPolicy相关逻辑。
|
||||
@objc private func openPrivacyPolicy() {
|
||||
showToast("隐私政策页面待接入")
|
||||
}
|
||||
|
||||
/// 点击login的处理逻辑。
|
||||
@objc private func loginTapped() {
|
||||
view.endEditing(true)
|
||||
performLogin()
|
||||
}
|
||||
|
||||
/// dismiss键盘相关逻辑。
|
||||
@objc private func dismissKeyboard() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
/// perform登录相关逻辑。
|
||||
private func performLogin() {
|
||||
if let validationError = viewModel.validateForLogin() {
|
||||
if validationError == .privacyUnchecked {
|
||||
@ -307,6 +321,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹出AgreementSheet页面。
|
||||
private func presentAgreementSheet() {
|
||||
let controller = LoginAgreementConsentViewController(
|
||||
onOpenAgreement: { [weak self] title in
|
||||
@ -324,6 +339,7 @@ final class LoginViewController: UIViewController {
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出AccountSelection页面。
|
||||
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
|
||||
let controller = AccountSelectionViewController(
|
||||
payload: payload,
|
||||
@ -342,6 +358,7 @@ final class LoginViewController: UIViewController {
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
|
||||
/// select账号相关逻辑。
|
||||
private func selectAccount(_ account: AccountSwitchAccount) {
|
||||
Task {
|
||||
do {
|
||||
@ -359,6 +376,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// complete登录相关逻辑。
|
||||
private func completeLogin(with response: V9AuthResponse) async {
|
||||
do {
|
||||
try await services.authSessionCoordinator.completeLogin(
|
||||
@ -378,6 +396,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension LoginViewController: UITextFieldDelegate {
|
||||
/// text字段ShouldReturn相关逻辑。
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
if textField === usernameField.textField {
|
||||
passwordField.textField.becomeFirstResponder()
|
||||
@ -396,6 +415,7 @@ private final class LoginInputField: UIView {
|
||||
private let toggleButton = UIButton(type: .custom)
|
||||
private var isSecure = false
|
||||
|
||||
/// 初始化实例。
|
||||
init(iconName: String, placeholder: String, isSecure: Bool) {
|
||||
self.isSecure = isSecure
|
||||
super.init(frame: .zero)
|
||||
@ -450,12 +470,14 @@ private final class LoginInputField: UIView {
|
||||
nil
|
||||
}
|
||||
|
||||
/// setSecureEntry相关逻辑。
|
||||
func setSecureEntry(_ secure: Bool) {
|
||||
textField.isSecureTextEntry = secure
|
||||
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
|
||||
toggleButton.setImage(UIImage(named: imageName), for: .normal)
|
||||
}
|
||||
|
||||
/// toggleVisibility相关逻辑。
|
||||
@objc private func toggleVisibility() {
|
||||
onToggleVisibility?()
|
||||
}
|
||||
@ -467,6 +489,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
||||
private let onOpenAgreement: (String) -> Void
|
||||
private let onAgreeAndContinue: () -> Void
|
||||
|
||||
/// 初始化实例。
|
||||
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
|
||||
self.onOpenAgreement = onOpenAgreement
|
||||
self.onAgreeAndContinue = onAgreeAndContinue
|
||||
@ -477,6 +500,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@ -524,6 +548,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击continue的处理逻辑。
|
||||
@objc private func continueTapped() {
|
||||
dismiss(animated: true) { [onAgreeAndContinue] in
|
||||
onAgreeAndContinue()
|
||||
|
||||
@ -50,6 +50,7 @@ enum HomeMenuRouting {
|
||||
viewController.navigationController?.pushViewController(target, animated: true)
|
||||
}
|
||||
|
||||
/// Push Placeholder页面。
|
||||
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
|
||||
viewController.navigationController?.pushViewController(
|
||||
FeaturePlaceholderViewController(title: title, uri: uri),
|
||||
|
||||
@ -6,6 +6,19 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
/// 全部功能页 section 标识,区分常用应用与更多功能网格。
|
||||
private enum HomeMoreSection: Hashable {
|
||||
case commonApps
|
||||
case moreFunctions
|
||||
}
|
||||
|
||||
/// 全部功能页 item 标识,以菜单 URI 作为唯一键。
|
||||
private enum HomeMoreItem: Hashable {
|
||||
case menu(uri: String)
|
||||
}
|
||||
|
||||
/// 首页全部功能页,支持常用应用增删和更多功能网格展示。
|
||||
final class HomeMoreFunctionsViewController: UIViewController {
|
||||
|
||||
@ -13,29 +26,38 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
private var commonURIs: [String] = []
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID)
|
||||
return table
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = makeCollectionLayout()
|
||||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||
collection.delegate = self
|
||||
collection.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
|
||||
collection.register(
|
||||
CollectionSectionHeaderView.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID
|
||||
)
|
||||
return collection
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动常用应用与更多功能两个网格 section。
|
||||
private var dataSource: UICollectionViewDiffableDataSource<HomeMoreSection, HomeMoreItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "全部功能"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
configureDataSource()
|
||||
view.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.applySnapshot()
|
||||
}
|
||||
rebuildMenus()
|
||||
|
||||
@ -44,6 +66,95 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 Compositional Layout,两个 section 均为带标题的三列网格。
|
||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self,
|
||||
let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex]
|
||||
else {
|
||||
return CollectionDiffableLayout.gridSection(itemHeight: 112)
|
||||
}
|
||||
let gridInsets = NSDirectionalEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
leading: AppMetrics.Spacing.mediumLarge,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
trailing: AppMetrics.Spacing.mediumLarge
|
||||
)
|
||||
let grid = CollectionDiffableLayout.gridSection(
|
||||
columns: 3,
|
||||
itemHeight: 112,
|
||||
interItemSpacing: 14,
|
||||
lineSpacing: AppMetrics.Spacing.mediumLarge,
|
||||
contentInsets: gridInsets
|
||||
)
|
||||
switch section {
|
||||
case .commonApps:
|
||||
return CollectionDiffableLayout.addHeader(to: grid, height: 36)
|
||||
case .moreFunctions:
|
||||
return CollectionDiffableLayout.addHeader(to: grid, height: 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册 Diffable 数据源与 Cell 配置闭包。
|
||||
private func configureDataSource() {
|
||||
dataSource = UICollectionViewDiffableDataSource<HomeMoreSection, HomeMoreItem>(
|
||||
collectionView: collectionView
|
||||
) { [weak self] collectionView, indexPath, item in
|
||||
guard let self,
|
||||
case .menu(let uri) = item,
|
||||
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section]
|
||||
else { return UICollectionViewCell() }
|
||||
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: HomeMoreMenuItemCell.reuseID,
|
||||
for: indexPath
|
||||
) as! HomeMoreMenuItemCell
|
||||
|
||||
let isCommon = section == .commonApps
|
||||
let items = isCommon ? self.commonItems : self.moreItems
|
||||
guard let menuItem = items.first(where: { $0.uri == uri }) else { return cell }
|
||||
|
||||
cell.configure(item: menuItem, isCommon: isCommon) { [weak self] in
|
||||
self?.toggleCommon(menuItem, isCommon: isCommon)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||||
guard let self,
|
||||
kind == UICollectionView.elementKindSectionHeader,
|
||||
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section],
|
||||
let header = collectionView.dequeueReusableSupplementaryView(
|
||||
ofKind: kind,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID,
|
||||
for: indexPath
|
||||
) as? CollectionSectionHeaderView
|
||||
else { return nil }
|
||||
let title = section == .commonApps ? "常用应用" : "更多功能"
|
||||
header.configure(title: title)
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前常用应用与权限菜单构建 snapshot 并应用 diff 更新。
|
||||
private func applySnapshot(animated: Bool = true) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<HomeMoreSection, HomeMoreItem>()
|
||||
|
||||
snapshot.appendSections([.commonApps, .moreFunctions])
|
||||
snapshot.appendItems(
|
||||
commonItems.map { HomeMoreItem.menu(uri: $0.uri) },
|
||||
toSection: .commonApps
|
||||
)
|
||||
snapshot.appendItems(
|
||||
moreItems.map { HomeMoreItem.menu(uri: $0.uri) },
|
||||
toSection: .moreFunctions
|
||||
)
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 按当前角色权限重建首页菜单与常用应用。
|
||||
private func rebuildMenus() {
|
||||
let services = appServices
|
||||
viewModel.buildMenus(
|
||||
@ -51,7 +162,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
currentRoleId: services.permissionContext.currentRole?.id
|
||||
)
|
||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
|
||||
private var commonItems: [HomeMenuItem] {
|
||||
@ -62,6 +173,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
viewModel.menuItems.filter { !isCommonURI($0.uri) }
|
||||
}
|
||||
|
||||
/// 按 URI 解析并返回可用菜单项。
|
||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
@ -73,20 +185,23 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断 URI 是否已在常用应用中。
|
||||
private func isCommonURI(_ uri: String) -> Bool {
|
||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
|
||||
}
|
||||
|
||||
/// 切换菜单项的常用应用状态。
|
||||
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
|
||||
if isCommon {
|
||||
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
|
||||
} else {
|
||||
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
|
||||
}
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
|
||||
/// 打开菜单对应页面。
|
||||
private func openMenu(_ item: HomeMenuItem) {
|
||||
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
|
||||
@ -94,108 +209,24 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "常用应用" : "更多功能"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell
|
||||
let isCommon = indexPath.section == 0
|
||||
let items = isCommon ? commonItems : moreItems
|
||||
cell.configure(items: items, isCommon: isCommon) { [weak self] item in
|
||||
self?.openMenu(item)
|
||||
} onToggle: { [weak self] item in
|
||||
self?.toggleCommon(item, isCommon: isCommon)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let count = indexPath.section == 0 ? commonItems.count : moreItems.count
|
||||
let rows = max(1, Int(ceil(Double(count) / 3.0)))
|
||||
return CGFloat(rows) * 124 + 8
|
||||
}
|
||||
}
|
||||
|
||||
private final class HomeMoreMenuGridCell: UITableViewCell {
|
||||
static let reuseID = "HomeMoreMenuGridCell"
|
||||
|
||||
private var items: [HomeMenuItem] = []
|
||||
private var isCommon = false
|
||||
private var onSelect: ((HomeMenuItem) -> Void)?
|
||||
private var onToggle: ((HomeMenuItem) -> Void)?
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 14
|
||||
layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = .clear
|
||||
view.isScrollEnabled = false
|
||||
view.dataSource = self
|
||||
view.delegate = self
|
||||
view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(
|
||||
items: [HomeMenuItem],
|
||||
isCommon: Bool,
|
||||
onSelect: @escaping (HomeMenuItem) -> Void,
|
||||
onToggle: @escaping (HomeMenuItem) -> Void
|
||||
) {
|
||||
self.items = items
|
||||
self.isCommon = isCommon
|
||||
self.onSelect = onSelect
|
||||
self.onToggle = onToggle
|
||||
collectionView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell
|
||||
let item = items[indexPath.item]
|
||||
cell.configure(item: item, isCommon: isCommon) { [weak self] in
|
||||
self?.onToggle?(item)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
extension HomeMoreFunctionsViewController: UICollectionViewDelegate {
|
||||
/// 点击网格项打开对应功能页(加减按钮由 Cell 内部处理)。
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
onSelect?(items[indexPath.item])
|
||||
}
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case .menu(let uri) = item
|
||||
else { return }
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let width = (collectionView.bounds.width - 28) / 3
|
||||
return CGSize(width: width, height: 112)
|
||||
let allItems = commonItems + moreItems
|
||||
guard let menuItem = allItems.first(where: { $0.uri == uri }) else { return }
|
||||
openMenu(menuItem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Item Cell
|
||||
|
||||
/// 全部功能页菜单网格单项 Cell,支持常用应用加减操作。
|
||||
private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeMoreMenuItemCell"
|
||||
|
||||
@ -204,6 +235,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
private let toggleButton = UIButton(type: .system)
|
||||
private var onToggle: (() -> Void)?
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
@ -243,6 +275,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置菜单项展示内容与常用应用切换按钮。
|
||||
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
|
||||
self.onToggle = onToggle
|
||||
titleLabel.text = item.title
|
||||
@ -254,7 +287,15 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
|
||||
}
|
||||
|
||||
/// 点击加减按钮切换常用应用状态。
|
||||
@objc private func toggleTapped() {
|
||||
onToggle?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ final class HomePlaceholderViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let uri: String?
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String, uri: String? = nil) {
|
||||
pageTitle = title
|
||||
self.uri = uri
|
||||
@ -24,6 +25,7 @@ final class HomePlaceholderViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = pageTitle
|
||||
|
||||
@ -6,6 +6,26 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
/// 首页 section 标识,按角色动态展示工作状态、门店与常用应用网格。
|
||||
private enum HomeSection: Hashable {
|
||||
case workStatus
|
||||
case locationReport
|
||||
case storeInfo
|
||||
case quickActions
|
||||
case commonMenus
|
||||
}
|
||||
|
||||
/// 首页 item 标识;工作状态类 item 携带展示数据以支持倒计时 diff 刷新。
|
||||
private enum HomeItem: Hashable {
|
||||
case workStatus(isOnline: Bool, secondsUntilReport: Int, reminderMinutes: Int)
|
||||
case locationReport
|
||||
case storeInfo(storeID: Int)
|
||||
case quickActions(isOnline: Bool)
|
||||
case menu(uri: String)
|
||||
}
|
||||
|
||||
/// 首页工作台,展示景区头部、工作状态、位置上报卡片和常用应用网格。
|
||||
final class HomeViewController: UIViewController {
|
||||
|
||||
@ -34,40 +54,55 @@ final class HomeViewController: UIViewController {
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.showsVerticalScrollIndicator = false
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID)
|
||||
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||
return table
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = makeCollectionLayout()
|
||||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||
collection.showsVerticalScrollIndicator = false
|
||||
collection.delegate = self
|
||||
collection.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseID)
|
||||
collection.register(HomeLocationReportCell.self, forCellWithReuseIdentifier: HomeLocationReportCell.reuseID)
|
||||
collection.register(HomeStoreInfoCell.self, forCellWithReuseIdentifier: HomeStoreInfoCell.reuseID)
|
||||
collection.register(HomeQuickActionsCell.self, forCellWithReuseIdentifier: HomeQuickActionsCell.reuseID)
|
||||
collection.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
|
||||
collection.register(
|
||||
CollectionSectionHeaderView.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID
|
||||
)
|
||||
return collection
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动多 section 卡片与常用应用网格。
|
||||
private var dataSource: UICollectionViewDiffableDataSource<HomeSection, HomeItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
setupTopBar()
|
||||
setupTableView()
|
||||
configureDataSource()
|
||||
setupCollectionView()
|
||||
bindViewModel()
|
||||
rebuildMenus()
|
||||
observeContextChanges()
|
||||
}
|
||||
|
||||
/// 视图即将展示,刷新可见状态。
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||
startCountdownTimerIfNeeded()
|
||||
}
|
||||
|
||||
/// 视图即将消失,保存或清理临时状态。
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = nil
|
||||
}
|
||||
|
||||
/// 初始化 TopBar 相关 UI 或状态。
|
||||
private func setupTopBar() {
|
||||
let topBar = UIView()
|
||||
topBar.backgroundColor = .white
|
||||
@ -85,20 +120,160 @@ final class HomeViewController: UIViewController {
|
||||
updateScenicTitle()
|
||||
}
|
||||
|
||||
private func setupTableView() {
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
/// 初始化 CollectionView 布局约束。
|
||||
private func setupCollectionView() {
|
||||
view.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
/// 构建 Compositional Layout,按 section 类型分配全宽卡片或三列网格。
|
||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self,
|
||||
let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex]
|
||||
else {
|
||||
return CollectionDiffableLayout.fullWidthSection()
|
||||
}
|
||||
switch section {
|
||||
case .workStatus:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 100)
|
||||
case .locationReport:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 148)
|
||||
case .storeInfo:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 88)
|
||||
case .quickActions:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 118)
|
||||
case .commonMenus:
|
||||
return CollectionDiffableLayout.addHeader(
|
||||
to: CollectionDiffableLayout.gridSection(itemHeight: 102),
|
||||
height: 36
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册 Diffable 数据源与 Cell 配置闭包。
|
||||
private func configureDataSource() {
|
||||
dataSource = UICollectionViewDiffableDataSource<HomeSection, HomeItem>(
|
||||
collectionView: collectionView
|
||||
) { [weak self] collectionView, indexPath, item in
|
||||
guard let self else { return UICollectionViewCell() }
|
||||
switch item {
|
||||
case .workStatus(let online, let seconds, let reminder):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: HomeWorkStatusCell.reuseID,
|
||||
for: indexPath
|
||||
) as! HomeWorkStatusCell
|
||||
cell.configure(
|
||||
isOnline: online,
|
||||
countdownText: self.countdownText(seconds: seconds),
|
||||
reminderText: self.reminderText(minutes: reminder),
|
||||
onOnlineTap: { [weak self] in self?.onlineTapped() },
|
||||
onReminderTap: { [weak self] in self?.reminderTapped() }
|
||||
)
|
||||
return cell
|
||||
case .locationReport:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: HomeLocationReportCell.reuseID,
|
||||
for: indexPath
|
||||
) as! HomeLocationReportCell
|
||||
cell.configure(onReportTap: { [weak self] in self?.locationReportTapped() })
|
||||
return cell
|
||||
case .storeInfo(let storeID):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: HomeStoreInfoCell.reuseID,
|
||||
for: indexPath
|
||||
) as! HomeStoreInfoCell
|
||||
if let store = self.appServices.accountContext.currentStore, store.id == storeID {
|
||||
cell.configure(
|
||||
storeName: store.name,
|
||||
scenicName: self.appServices.accountContext.currentScenic?.name
|
||||
)
|
||||
}
|
||||
return cell
|
||||
case .quickActions(let online):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: HomeQuickActionsCell.reuseID,
|
||||
for: indexPath
|
||||
) as! HomeQuickActionsCell
|
||||
cell.configure(
|
||||
isOnline: online,
|
||||
onPaymentTap: { [weak self] in self?.paymentTapped() },
|
||||
onTaskCreateTap: { [weak self] in self?.taskCreateTapped() },
|
||||
onOnlineTap: { [weak self] in self?.onlineTapped() }
|
||||
)
|
||||
return cell
|
||||
case .menu(let uri):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: HomeMenuItemCell.reuseID,
|
||||
for: indexPath
|
||||
) as! HomeMenuItemCell
|
||||
if let menuItem = self.menuItem(for: uri) ?? self.displayMenuItems.first(where: { $0.uri == uri }) {
|
||||
cell.configure(item: menuItem)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||||
guard let self,
|
||||
kind == UICollectionView.elementKindSectionHeader,
|
||||
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section],
|
||||
section == .commonMenus,
|
||||
let header = collectionView.dequeueReusableSupplementaryView(
|
||||
ofKind: kind,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID,
|
||||
for: indexPath
|
||||
) as? CollectionSectionHeaderView
|
||||
else { return nil }
|
||||
header.configure(title: "常用应用")
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前角色与状态构建 snapshot 并应用 diff 更新。
|
||||
private func applySnapshot(animated: Bool = true) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<HomeSection, HomeItem>()
|
||||
|
||||
if shouldShowWorkStatus {
|
||||
snapshot.appendSections([.workStatus, .locationReport])
|
||||
snapshot.appendItems(
|
||||
[.workStatus(isOnline: isOnline, secondsUntilReport: secondsUntilReport, reminderMinutes: reminderMinutes)],
|
||||
toSection: .workStatus
|
||||
)
|
||||
snapshot.appendItems([.locationReport], toSection: .locationReport)
|
||||
}
|
||||
|
||||
if isStoreManager, let store = appServices.accountContext.currentStore {
|
||||
snapshot.appendSections([.storeInfo])
|
||||
snapshot.appendItems([.storeInfo(storeID: store.id)], toSection: .storeInfo)
|
||||
}
|
||||
|
||||
if shouldShowWorkStatus {
|
||||
snapshot.appendSections([.quickActions])
|
||||
snapshot.appendItems([.quickActions(isOnline: isOnline)], toSection: .quickActions)
|
||||
}
|
||||
|
||||
snapshot.appendSections([.commonMenus])
|
||||
snapshot.appendItems(
|
||||
displayMenuItems.map { HomeItem.menu(uri: $0.uri) },
|
||||
toSection: .commonMenus
|
||||
)
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 绑定 ViewModel 回调或数据。
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.applySnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/// 监听权限与账号上下文变化并刷新菜单。
|
||||
private func observeContextChanges() {
|
||||
let services = appServices
|
||||
services.permissionContext.onChange = { [weak self] in
|
||||
@ -106,10 +281,11 @@ final class HomeViewController: UIViewController {
|
||||
}
|
||||
services.accountContext.onChange = { [weak self] in
|
||||
self?.updateScenicTitle()
|
||||
self?.tableView.reloadData()
|
||||
self?.applySnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/// 按当前角色权限重建首页菜单与常用应用。
|
||||
private func rebuildMenus() {
|
||||
let services = appServices
|
||||
viewModel.buildMenus(
|
||||
@ -117,9 +293,10 @@ final class HomeViewController: UIViewController {
|
||||
currentRoleId: services.permissionContext.currentRole?.id
|
||||
)
|
||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
|
||||
/// 刷新顶部景区名称展示。
|
||||
private func updateScenicTitle() {
|
||||
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
|
||||
scenicButton.configuration?.attributedTitle = AttributedString(
|
||||
@ -130,25 +307,30 @@ final class HomeViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前登录角色 ID。
|
||||
private var currentRoleId: Int? {
|
||||
appServices.permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
/// 部分精简角色不展示工作状态与位置上报区块。
|
||||
private var shouldShowWorkStatus: Bool {
|
||||
guard let currentRoleId else { return true }
|
||||
return !minimalTopRoleIds.contains(currentRoleId)
|
||||
}
|
||||
|
||||
/// 是否为门店管理员角色(roleId = 46)。
|
||||
private var isStoreManager: Bool {
|
||||
currentRoleId == 46
|
||||
}
|
||||
|
||||
/// 常用应用展示项:优先用户自定义,不足 3 个时取权限菜单前 3 项,末尾固定「更多功能」。
|
||||
private var displayMenuItems: [HomeMenuItem] {
|
||||
let selected = commonURIs.compactMap { menuItem(for: $0) }
|
||||
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
||||
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
}
|
||||
|
||||
/// 按 URI 解析并返回可用菜单项。
|
||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
@ -160,31 +342,36 @@ final class HomeViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
private var countdownDisplay: String {
|
||||
let hours = secondsUntilReport / 3_600
|
||||
let minutes = (secondsUntilReport % 3_600) / 60
|
||||
let seconds = secondsUntilReport % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
/// 格式化倒计时展示文本。
|
||||
private func countdownText(seconds: Int) -> String {
|
||||
let hours = seconds / 3_600
|
||||
let minutes = (seconds % 3_600) / 60
|
||||
let secs = seconds % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secs))"
|
||||
}
|
||||
|
||||
private var reminderText: String {
|
||||
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
|
||||
/// 格式化提前提醒展示文本。
|
||||
private func reminderText(minutes: Int) -> String {
|
||||
minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
|
||||
}
|
||||
|
||||
/// 在线状态下启动位置上报倒计时。
|
||||
private func startCountdownTimerIfNeeded() {
|
||||
countdownTimer?.invalidate()
|
||||
guard isOnline else { return }
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
|
||||
self.secondsUntilReport -= 1
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
self.applySnapshot(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击景区名称,跳转景区选择页。
|
||||
@objc private func scenicTapped() {
|
||||
HomeMenuRouting.push(.scenicSelection, from: self)
|
||||
}
|
||||
|
||||
/// 切换在线 / 离线状态。
|
||||
@objc private func onlineTapped() {
|
||||
let message = isOnline
|
||||
? "是否确认切换为离线状态?离线后将暂停位置上报。"
|
||||
@ -200,147 +387,137 @@ final class HomeViewController: UIViewController {
|
||||
} else {
|
||||
self.countdownTimer?.invalidate()
|
||||
}
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
self.applySnapshot()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 选择位置上报提前提醒时间。
|
||||
@objc private func reminderTapped() {
|
||||
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
|
||||
for minute in [0, 5, 10, 15, 30] {
|
||||
let title = minute == 0 ? "不提醒" : "\(minute)分钟"
|
||||
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.reminderMinutes = minute
|
||||
self?.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
self?.applySnapshot()
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
/// 跳转位置上报页面。
|
||||
@objc private func locationReportTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
|
||||
}
|
||||
|
||||
/// 跳转立即收款页面。
|
||||
@objc private func paymentTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
|
||||
}
|
||||
|
||||
/// 跳转提交任务页面。
|
||||
@objc private func taskCreateTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableView
|
||||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
var count = 1
|
||||
if shouldShowWorkStatus { count += 2 }
|
||||
if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 }
|
||||
return count
|
||||
extension HomeViewController: UICollectionViewDelegate {
|
||||
/// 点击常用应用网格项,执行菜单路由。
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case .menu(let uri) = item,
|
||||
let menuItem = displayMenuItems.first(where: { $0.uri == uri })
|
||||
else { return }
|
||||
HomeMenuRouting.openMenu(menuItem, from: self)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
1
|
||||
}
|
||||
// MARK: - Card Cells
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
if section == tableView.numberOfSections - 1 {
|
||||
return "常用应用"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
/// 在线状态卡片 Cell,展示在线切换、倒计时与提醒设置。
|
||||
private final class HomeWorkStatusCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeWorkStatusCell"
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.section == tableView.numberOfSections - 1 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell
|
||||
cell.configure(items: displayMenuItems) { [weak self] item in
|
||||
self.flatMap { HomeMenuRouting.openMenu(item, from: $0) }
|
||||
}
|
||||
return cell
|
||||
}
|
||||
private let cardView = UIView()
|
||||
private let onlineButton = UIButton(type: .system)
|
||||
private let clockLabel = UILabel()
|
||||
private let reminderButton = UIButton(type: .system)
|
||||
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
||||
cell.selectionStyle = .none
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
if shouldShowWorkStatus {
|
||||
if indexPath.section == 0 {
|
||||
cell.contentView.addSubview(makeStatusCard())
|
||||
} else if indexPath.section == 1 {
|
||||
cell.contentView.addSubview(makeLocationCard())
|
||||
} else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore {
|
||||
cell.contentView.addSubview(makeStoreCard(store))
|
||||
} else {
|
||||
cell.contentView.addSubview(makeQuickActionsRow())
|
||||
}
|
||||
} else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 {
|
||||
cell.contentView.addSubview(makeStoreCard(store))
|
||||
}
|
||||
|
||||
cell.contentView.subviews.first?.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
left: AppMetrics.Spacing.pageHorizontal,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
right: AppMetrics.Spacing.pageHorizontal
|
||||
))
|
||||
}
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if indexPath.section == tableView.numberOfSections - 1 { return 240 }
|
||||
if shouldShowWorkStatus {
|
||||
switch indexPath.section {
|
||||
case 0: return 100
|
||||
case 1: return 148
|
||||
case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88
|
||||
default: return 118
|
||||
}
|
||||
}
|
||||
if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 }
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
private func makeStatusCard() -> UIView {
|
||||
let card = makeCardView()
|
||||
|
||||
let onlineButton = UIButton(type: .system)
|
||||
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
|
||||
onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
|
||||
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
|
||||
onlineButton.layer.cornerRadius = 4
|
||||
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
|
||||
|
||||
let clockLabel = UILabel()
|
||||
clockLabel.text = " \(countdownDisplay)"
|
||||
clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
clockLabel.textColor = AppDesignUIKit.primary
|
||||
|
||||
let reminderButton = UIButton(type: .system)
|
||||
reminderButton.setTitle(" \(reminderText)", for: .normal)
|
||||
reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
|
||||
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
|
||||
stack.axis = .horizontal
|
||||
stack.distribution = .equalSpacing
|
||||
stack.alignment = .center
|
||||
card.addSubview(stack)
|
||||
cardView.addSubview(stack)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(15)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeLocationCard() -> UIView {
|
||||
let card = makeCardView()
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置在线状态、倒计时与提醒,并绑定点击回调。
|
||||
func configure(
|
||||
isOnline: Bool,
|
||||
countdownText: String,
|
||||
reminderText: String,
|
||||
onOnlineTap: @escaping () -> Void,
|
||||
onReminderTap: @escaping () -> Void
|
||||
) {
|
||||
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
|
||||
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
|
||||
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
|
||||
clockLabel.text = " \(countdownText)"
|
||||
reminderButton.setTitle(" \(reminderText)", for: .normal)
|
||||
|
||||
onlineButton.removeAction(identifiedBy: UIAction.Identifier("online"), for: .touchUpInside)
|
||||
reminderButton.removeAction(identifiedBy: UIAction.Identifier("reminder"), for: .touchUpInside)
|
||||
onlineButton.addAction(UIAction(identifier: UIAction.Identifier("online")) { _ in onOnlineTap() }, for: .touchUpInside)
|
||||
reminderButton.addAction(UIAction(identifier: UIAction.Identifier("reminder")) { _ in onReminderTap() }, for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报卡片 Cell,展示打卡范围提示与立即上报入口。
|
||||
private final class HomeLocationReportCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeLocationReportCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let actionButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "立即上报"
|
||||
@ -356,15 +533,16 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = AppMetrics.Spacing.xxSmall
|
||||
|
||||
let actionButton = UIButton(type: .system)
|
||||
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
|
||||
actionButton.tintColor = .white
|
||||
actionButton.backgroundColor = AppDesignUIKit.primary
|
||||
actionButton.layer.cornerRadius = 46
|
||||
actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside)
|
||||
|
||||
card.addSubview(textStack)
|
||||
card.addSubview(actionButton)
|
||||
cardView.addSubview(textStack)
|
||||
cardView.addSubview(actionButton)
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
textStack.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(15)
|
||||
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
|
||||
@ -374,72 +552,38 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(92)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeQuickActionsRow() -> UIView {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
stack.distribution = .fillEqually
|
||||
|
||||
stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped)))
|
||||
stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped)))
|
||||
stack.addArrangedSubview(quickActionButton(
|
||||
icon: isOnline ? "wifi" : "wifi.slash",
|
||||
title: isOnline ? "在线" : "离线",
|
||||
action: #selector(onlineTapped),
|
||||
active: isOnline
|
||||
))
|
||||
return stack
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView {
|
||||
let card = makeCardView()
|
||||
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
|
||||
|
||||
let iconView = UIImageView(image: UIImage(systemName: icon))
|
||||
iconView.tintColor = AppDesignUIKit.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
label.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, label])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.xSmall
|
||||
stack.alignment = .center
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
card.addSubview(button)
|
||||
button.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
card.snp.makeConstraints { make in
|
||||
make.height.equalTo(102)
|
||||
}
|
||||
return card
|
||||
/// 配置立即上报按钮点击回调。
|
||||
func configure(onReportTap: @escaping () -> Void) {
|
||||
actionButton.removeAction(identifiedBy: UIAction.Identifier("report"), for: .touchUpInside)
|
||||
actionButton.addAction(UIAction(identifier: UIAction.Identifier("report")) { _ in onReportTap() }, for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeStoreCard(_ store: BusinessScope) -> UIView {
|
||||
let card = makeCardView()
|
||||
/// 门店信息卡片 Cell,展示当前门店名称与营业状态。
|
||||
private final class HomeStoreInfoCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeStoreInfoCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let nameLabel = UILabel()
|
||||
private let scenicLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
let nameLabel = UILabel()
|
||||
nameLabel.text = store.name
|
||||
nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold)
|
||||
|
||||
let scenicLabel = UILabel()
|
||||
scenicLabel.text = appServices.accountContext.currentScenic?.name
|
||||
scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
scenicLabel.numberOfLines = 2
|
||||
@ -457,8 +601,11 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = AppMetrics.Spacing.xSmall
|
||||
|
||||
card.addSubview(textStack)
|
||||
card.addSubview(statusLabel)
|
||||
cardView.addSubview(textStack)
|
||||
cardView.addSubview(statusLabel)
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
textStack.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
|
||||
@ -468,39 +615,36 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
make.width.greaterThanOrEqualTo(52)
|
||||
make.height.equalTo(22)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置门店名称与所属景区。
|
||||
func configure(storeName: String, scenicName: String?) {
|
||||
nameLabel.text = storeName
|
||||
scenicLabel.text = scenicName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Grid Cell
|
||||
/// 快捷操作行 Cell,展示收款、任务与在线状态入口。
|
||||
private final class HomeQuickActionsCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeQuickActionsCell"
|
||||
|
||||
private final class HomeMenuGridCell: UITableViewCell {
|
||||
static let reuseID = "HomeMenuGridCell"
|
||||
private let stackView = UIStackView()
|
||||
|
||||
private var onSelect: ((HomeMenuItem) -> Void)?
|
||||
private var items: [HomeMenuItem] = []
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 15
|
||||
layout.minimumLineSpacing = 15
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = .clear
|
||||
view.isScrollEnabled = false
|
||||
view.dataSource = self
|
||||
view.delegate = self
|
||||
view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
make.height.equalTo(220)
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = AppMetrics.Spacing.small
|
||||
stackView.distribution = .fillEqually
|
||||
contentView.addSubview(stackView)
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@ -509,40 +653,87 @@ private final class HomeMenuGridCell: UITableViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) {
|
||||
self.items = items
|
||||
self.onSelect = onSelect
|
||||
collectionView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell
|
||||
cell.configure(item: items[indexPath.item])
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
onSelect?(items[indexPath.item])
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let width = (collectionView.bounds.width - 30) / 3
|
||||
return CGSize(width: width, height: 102)
|
||||
/// 配置三个快捷操作按钮与点击回调。
|
||||
func configure(
|
||||
isOnline: Bool,
|
||||
onPaymentTap: @escaping () -> Void,
|
||||
onTaskCreateTap: @escaping () -> Void,
|
||||
onOnlineTap: @escaping () -> Void
|
||||
) {
|
||||
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
stackView.addArrangedSubview(quickActionCard(
|
||||
icon: "qrcode",
|
||||
title: "立即收款",
|
||||
active: false,
|
||||
action: onPaymentTap
|
||||
))
|
||||
stackView.addArrangedSubview(quickActionCard(
|
||||
icon: "checklist.checked",
|
||||
title: "提交任务",
|
||||
active: false,
|
||||
action: onTaskCreateTap
|
||||
))
|
||||
stackView.addArrangedSubview(quickActionCard(
|
||||
icon: isOnline ? "wifi" : "wifi.slash",
|
||||
title: isOnline ? "在线" : "离线",
|
||||
active: isOnline,
|
||||
action: onOnlineTap
|
||||
))
|
||||
}
|
||||
|
||||
/// 构建单个快捷操作卡片。
|
||||
private func quickActionCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
active: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> UIView {
|
||||
let card = UIView()
|
||||
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
|
||||
card.layer.cornerRadius = 8
|
||||
|
||||
let iconView = UIImageView(image: UIImage(systemName: icon))
|
||||
iconView.tintColor = AppDesignUIKit.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
label.textAlignment = .center
|
||||
|
||||
let innerStack = UIStackView(arrangedSubviews: [iconView, label])
|
||||
innerStack.axis = .vertical
|
||||
innerStack.spacing = AppMetrics.Spacing.xSmall
|
||||
innerStack.alignment = .center
|
||||
card.addSubview(innerStack)
|
||||
innerStack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
||||
card.addSubview(button)
|
||||
button.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
card.snp.makeConstraints { make in
|
||||
make.height.equalTo(102)
|
||||
}
|
||||
return card
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页菜单网格单项 Cell,负责图标与标题展示。
|
||||
private final class HomeMenuItemCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeMenuItemCell"
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
@ -574,6 +765,7 @@ private final class HomeMenuItemCell: UICollectionViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置菜单项图标与标题。
|
||||
func configure(item: HomeMenuItem) {
|
||||
titleLabel.text = item.title
|
||||
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
|
||||
@ -581,3 +773,10 @@ private final class HomeMenuItemCell: UICollectionViewCell {
|
||||
iconView.tintColor = AppDesign.primary
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
private let rulesLabel = UILabel()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "邀请摄影师"
|
||||
@ -29,6 +30,7 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
Task { await viewModel.reload(api: services.inviteAPI) }
|
||||
}
|
||||
|
||||
/// 初始化UI相关 UI 或状态。
|
||||
private func setupUI() {
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)),
|
||||
@ -64,6 +66,7 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
contentStack.addArrangedSubview(rulesLabel)
|
||||
}
|
||||
|
||||
/// render 业务逻辑。
|
||||
private func render() {
|
||||
activityIndicator.isHidden = !viewModel.loading
|
||||
if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||
@ -76,7 +79,9 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制验证码。
|
||||
@objc private func copyCode() { viewModel.copyInviteCode() }
|
||||
/// 复制链接。
|
||||
@objc private func copyURL() { viewModel.copyInviteUrl() }
|
||||
}
|
||||
|
||||
@ -87,6 +92,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
private let viewModel = InviteRecordViewModel()
|
||||
private let summaryLabel = UILabel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "邀请记录"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -103,6 +109,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
summaryLabel.font = .systemFont(ofSize: 14)
|
||||
summaryLabel.textColor = AppDesign.textSecondary
|
||||
@ -112,10 +119,12 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = summaryLabel
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.displayRows.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let row = viewModel.displayRows[indexPath.row]
|
||||
cell.configure(
|
||||
@ -125,6 +134,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(
|
||||
inviteAPI: services.inviteAPI,
|
||||
@ -134,11 +144,13 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
updateSummary()
|
||||
}
|
||||
|
||||
/// 更新Summary状态。
|
||||
private func updateSummary() {
|
||||
summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)"
|
||||
navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户"
|
||||
}
|
||||
|
||||
/// toggleTab相关逻辑。
|
||||
@objc private func toggleTab() {
|
||||
Task {
|
||||
let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite
|
||||
|
||||
@ -61,6 +61,7 @@ final class PhotographerInviteViewModel {
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
/// 清空。
|
||||
private func clear() {
|
||||
inviteCode = ""
|
||||
inviteUrl = ""
|
||||
@ -128,6 +129,7 @@ final class InviteRecordViewModel {
|
||||
await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
|
||||
}
|
||||
|
||||
/// 加载InviteRows数据。
|
||||
private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws {
|
||||
let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize)
|
||||
let mapped = users.map {
|
||||
@ -152,6 +154,7 @@ final class InviteRecordViewModel {
|
||||
hasMore = users.count >= invitePageSize
|
||||
}
|
||||
|
||||
/// 加载RewardRows数据。
|
||||
private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws {
|
||||
let response = try await api.walletEarningDetail(
|
||||
startDate: "2025-01-01",
|
||||
@ -183,6 +186,7 @@ final class InviteRecordViewModel {
|
||||
hasMore = rewardRows.count < response.total && !mapped.isEmpty
|
||||
}
|
||||
|
||||
/// 清空。
|
||||
private func clear() {
|
||||
totalRewardText = "¥ 0.00"
|
||||
withdrawableText = "¥ 0.00"
|
||||
|
||||
@ -10,17 +10,29 @@ import Foundation
|
||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||
@MainActor
|
||||
protocol LiveServing {
|
||||
/// 直播列表相关逻辑。
|
||||
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
|
||||
/// 直播详情相关逻辑。
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity
|
||||
/// 直播创建相关逻辑。
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws
|
||||
/// 直播启动相关逻辑。
|
||||
func liveStart(liveId: Int) async throws
|
||||
/// 直播停止相关逻辑。
|
||||
func liveStop(liveId: Int) async throws
|
||||
/// 直播Finish相关逻辑。
|
||||
func liveFinish(liveId: Int) async throws
|
||||
/// 直播Set推送Mode相关逻辑。
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws
|
||||
/// 直播相册列表相关逻辑。
|
||||
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
||||
/// 直播相册创建文件夹相关逻辑。
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
|
||||
/// 直播相册删除文件夹相关逻辑。
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws
|
||||
/// 直播相册文件夹详情相关逻辑。
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
|
||||
/// 直播相册删除Files相关逻辑。
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ struct LiveListResponse: Decodable {
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case total
|
||||
@ -21,6 +22,7 @@ struct LiveListResponse: Decodable {
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
|
||||
self.items = items
|
||||
self.total = total
|
||||
@ -28,6 +30,7 @@ struct LiveListResponse: Decodable {
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
|
||||
@ -57,6 +60,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
let manualPushState: Int
|
||||
let viewsCount: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
@ -77,6 +81,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
case viewsCount = "views_count"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
title: String = "",
|
||||
@ -115,6 +120,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
self.viewsCount = viewsCount
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -151,6 +157,7 @@ struct LiveCreateRequest: Encodable, Equatable {
|
||||
let title: String
|
||||
let coverImg: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case title
|
||||
@ -162,6 +169,7 @@ struct LiveCreateRequest: Encodable, Equatable {
|
||||
struct LiveControlRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
}
|
||||
@ -172,6 +180,7 @@ struct LivePushModeRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
let manualPushMode: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
case manualPushMode = "manual_push_mode"
|
||||
@ -186,6 +195,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
||||
let total: Int
|
||||
let totalPages: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case page
|
||||
@ -194,6 +204,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
||||
case totalPages = "total_pages"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
|
||||
self.items = items
|
||||
self.page = page
|
||||
@ -202,6 +213,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
||||
self.totalPages = totalPages
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
|
||||
@ -220,6 +232,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
let creator: String
|
||||
let items: [LiveAlbumFileItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case albumId = "album_id"
|
||||
@ -228,6 +241,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
case items
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
|
||||
self.id = id
|
||||
self.albumId = albumId
|
||||
@ -236,6 +250,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
self.items = items
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -254,6 +269,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
@ -262,6 +278,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
@ -270,6 +287,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
self.coverImg = coverImg
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -295,6 +313,7 @@ struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
|
||||
let name: String
|
||||
let items: [LiveAlbumCreateFileItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
@ -309,6 +328,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case type
|
||||
@ -321,6 +341,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
||||
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
}
|
||||
@ -331,6 +352,7 @@ struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
let fileIds: [Int]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
case fileIds = "file_ids"
|
||||
@ -346,6 +368,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
||||
let size: Int64
|
||||
var uploadedURL: String?
|
||||
|
||||
/// 初始化实例。
|
||||
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
|
||||
self.id = id
|
||||
self.data = data
|
||||
@ -359,6 +382,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 直播解码宽松字符串相关逻辑。
|
||||
func liveDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -378,6 +402,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 直播解码宽松整数相关逻辑。
|
||||
func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
|
||||
@ -11,14 +11,17 @@ import UIKit
|
||||
final class LiveManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "直播管理"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(
|
||||
@ -28,10 +31,12 @@ final class LiveManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
/// table视图相关逻辑。
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
|
||||
@ -44,19 +49,23 @@ extension LiveManagementViewModel: ViewModelBindable {}
|
||||
final class LiveAlbumViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveAlbumViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "直播相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
@ -10,10 +10,12 @@ import Foundation
|
||||
|
||||
/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。
|
||||
enum LivePlaybackURLResolver {
|
||||
/// playable链接相关逻辑。
|
||||
static func playableURL(from live: LiveEntity) -> URL? {
|
||||
playableURL(from: live.playbackURLCandidates)
|
||||
}
|
||||
|
||||
/// playable链接相关逻辑。
|
||||
static func playableURL(from candidates: [String]) -> URL? {
|
||||
for candidate in candidates {
|
||||
if let url = playableURL(from: candidate) {
|
||||
@ -23,6 +25,7 @@ enum LivePlaybackURLResolver {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// playable链接相关逻辑。
|
||||
static func playableURL(from rawValue: String) -> URL? {
|
||||
let value = rawValue.liveTrimmed
|
||||
guard !value.isEmpty, let url = URL(string: value) else { return nil }
|
||||
@ -65,6 +68,7 @@ final class LivePlaybackViewModel {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(live: LiveEntity? = nil, urlString: String? = nil) {
|
||||
if let live {
|
||||
load(live: live)
|
||||
@ -73,14 +77,17 @@ final class LivePlaybackViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载数据。
|
||||
func load(live: LiveEntity) {
|
||||
load(url: LivePlaybackURLResolver.playableURL(from: live))
|
||||
}
|
||||
|
||||
/// 加载数据。
|
||||
func load(urlString: String) {
|
||||
load(url: LivePlaybackURLResolver.playableURL(from: urlString))
|
||||
}
|
||||
|
||||
/// play 业务逻辑。
|
||||
func play() {
|
||||
guard let url = playableURL else { return }
|
||||
if player == nil {
|
||||
@ -90,12 +97,14 @@ final class LivePlaybackViewModel {
|
||||
state = .playing(url)
|
||||
}
|
||||
|
||||
/// pause 业务逻辑。
|
||||
func pause() {
|
||||
guard let url = playableURL else { return }
|
||||
player?.pause()
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
/// 刷新。
|
||||
func reload() {
|
||||
guard let url = playableURL else { return }
|
||||
releasePlayer()
|
||||
@ -103,6 +112,7 @@ final class LivePlaybackViewModel {
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
/// release 业务逻辑。
|
||||
func release() {
|
||||
releasePlayer()
|
||||
if let url = playableURL {
|
||||
@ -112,6 +122,7 @@ final class LivePlaybackViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载数据。
|
||||
private func load(url: URL?) {
|
||||
releasePlayer()
|
||||
guard let url else {
|
||||
@ -124,6 +135,7 @@ final class LivePlaybackViewModel {
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
/// releasePlayer相关逻辑。
|
||||
private func releasePlayer() {
|
||||
player?.pause()
|
||||
player = nil
|
||||
|
||||
@ -47,25 +47,34 @@ enum LivePushNetworkState: Equatable {
|
||||
protocol LivePushAdapter {
|
||||
var name: String { get }
|
||||
var isAvailable: Bool { get }
|
||||
/// prepare 业务逻辑。
|
||||
func prepare(pushURL: URL) async throws
|
||||
/// start 业务逻辑。
|
||||
func start() async throws
|
||||
/// stop 业务逻辑。
|
||||
func stop() async throws
|
||||
/// dispose 业务逻辑。
|
||||
func dispose() async
|
||||
}
|
||||
|
||||
/// 权限提供者协议,隔离 AVFoundation 便于测试。
|
||||
protocol LivePermissionProviding {
|
||||
/// camera权限相关逻辑。
|
||||
func cameraPermission() async -> LivePushPermissionState
|
||||
/// microphone权限相关逻辑。
|
||||
func microphonePermission() async -> LivePushPermissionState
|
||||
}
|
||||
|
||||
/// 网络监听协议,隔离 NWPathMonitor 便于测试。
|
||||
protocol LiveNetworkMonitoring: AnyObject {
|
||||
var currentState: LivePushNetworkState { get }
|
||||
/// start 业务逻辑。
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
|
||||
/// stop 业务逻辑。
|
||||
func stop()
|
||||
}
|
||||
|
||||
/// LivePushReadiness错误类型定义。
|
||||
enum LivePushReadinessError: LocalizedError, Equatable {
|
||||
case missingPushURL
|
||||
case invalidPushURL
|
||||
@ -89,32 +98,41 @@ enum LivePushReadinessError: LocalizedError, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// UnsupportedLivePushAdapter,业务类型定义。
|
||||
struct UnsupportedLivePushAdapter: LivePushAdapter {
|
||||
let name = "未接入推流 SDK"
|
||||
let isAvailable = false
|
||||
|
||||
/// prepare 业务逻辑。
|
||||
func prepare(pushURL: URL) async throws {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
|
||||
/// start 业务逻辑。
|
||||
func start() async throws {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
|
||||
/// stop 业务逻辑。
|
||||
func stop() async throws {}
|
||||
|
||||
/// dispose 业务逻辑。
|
||||
func dispose() async {}
|
||||
}
|
||||
|
||||
/// SystemLivePermission能力提供者,封装外部依赖。
|
||||
struct SystemLivePermissionProvider: LivePermissionProviding {
|
||||
/// camera权限相关逻辑。
|
||||
func cameraPermission() async -> LivePushPermissionState {
|
||||
await permission(for: .video)
|
||||
}
|
||||
|
||||
/// microphone权限相关逻辑。
|
||||
func microphonePermission() async -> LivePushPermissionState {
|
||||
await permission(for: .audio)
|
||||
}
|
||||
|
||||
/// permission 业务逻辑。
|
||||
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
|
||||
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
||||
case .authorized:
|
||||
@ -130,6 +148,7 @@ struct SystemLivePermissionProvider: LivePermissionProviding {
|
||||
}
|
||||
}
|
||||
|
||||
/// SystemLiveNetworkMonitor 类型定义。
|
||||
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
@ -137,6 +156,7 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
private let queue = DispatchQueue(label: "com.suixinkan.live.network")
|
||||
private(set) var currentState: LivePushNetworkState = .unknown
|
||||
|
||||
/// start 业务逻辑。
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let state = Self.state(from: path)
|
||||
@ -146,10 +166,12 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
/// stop 业务逻辑。
|
||||
func stop() {
|
||||
monitor.cancel()
|
||||
}
|
||||
|
||||
/// state 业务逻辑。
|
||||
private static func state(from path: NWPath) -> LivePushNetworkState {
|
||||
guard path.status == .satisfied else { return .unavailable }
|
||||
if path.usesInterfaceType(.wifi) { return .wifi }
|
||||
@ -177,6 +199,7 @@ final class LivePushReadinessViewModel {
|
||||
private let adapter: any LivePushAdapter
|
||||
private var pushURL: URL?
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
|
||||
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
|
||||
@ -190,6 +213,7 @@ final class LivePushReadinessViewModel {
|
||||
self.networkState = networkMonitor.currentState
|
||||
}
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(pushURL rawValue: String) {
|
||||
let value = rawValue.liveTrimmed
|
||||
guard !value.isEmpty else {
|
||||
@ -206,6 +230,7 @@ final class LivePushReadinessViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// startMonitoring相关逻辑。
|
||||
func startMonitoring() {
|
||||
networkState = networkMonitor.currentState
|
||||
networkMonitor.start { [weak self] state in
|
||||
@ -215,10 +240,12 @@ final class LivePushReadinessViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// stopMonitoring相关逻辑。
|
||||
func stopMonitoring() {
|
||||
networkMonitor.stop()
|
||||
}
|
||||
|
||||
/// refreshPermissions相关逻辑。
|
||||
func refreshPermissions() async {
|
||||
async let camera = permissionProvider.cameraPermission()
|
||||
async let microphone = permissionProvider.microphonePermission()
|
||||
@ -226,6 +253,7 @@ final class LivePushReadinessViewModel {
|
||||
microphonePermission = await microphone
|
||||
}
|
||||
|
||||
/// runDiagnostics相关逻辑。
|
||||
func runDiagnostics() throws {
|
||||
do {
|
||||
try validateReadiness()
|
||||
@ -240,6 +268,7 @@ final class LivePushReadinessViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// prepare 业务逻辑。
|
||||
func prepare() async throws {
|
||||
try validateReadiness()
|
||||
guard let pushURL else { throw LivePushReadinessError.missingPushURL }
|
||||
@ -253,6 +282,7 @@ final class LivePushReadinessViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// start推送相关逻辑。
|
||||
func startPush() async throws {
|
||||
try validateReadiness()
|
||||
guard adapter.isAvailable else {
|
||||
@ -265,11 +295,13 @@ final class LivePushReadinessViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// stop推送相关逻辑。
|
||||
func stopPush() async {
|
||||
try? await adapter.stop()
|
||||
running = false
|
||||
}
|
||||
|
||||
/// dispose 业务逻辑。
|
||||
func dispose() async {
|
||||
stopMonitoring()
|
||||
await adapter.dispose()
|
||||
@ -277,6 +309,7 @@ final class LivePushReadinessViewModel {
|
||||
prepared = false
|
||||
}
|
||||
|
||||
/// 校验Readiness输入或状态。
|
||||
private func validateReadiness() throws {
|
||||
guard pushURL != nil else {
|
||||
throw LivePushReadinessError.missingPushURL
|
||||
|
||||
@ -104,6 +104,7 @@ final class LiveManagementViewModel {
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 加载Page数据。
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||
if page == 1 {
|
||||
@ -118,6 +119,7 @@ final class LiveManagementViewModel {
|
||||
hasMore = items.count < total
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset() {
|
||||
clearListAndDetail()
|
||||
loading = false
|
||||
@ -125,6 +127,7 @@ final class LiveManagementViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 清空列表并详情。
|
||||
private func clearListAndDetail() {
|
||||
items = []
|
||||
detail = nil
|
||||
@ -143,6 +146,7 @@ final class LiveDetailViewModel {
|
||||
var actionInFlight = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 初始化实例。
|
||||
init(detail: LiveEntity) {
|
||||
self.detail = detail
|
||||
}
|
||||
@ -265,6 +269,7 @@ final class LiveAlbumViewModel {
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 加载Page数据。
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveAlbumList(
|
||||
scenicId: scenicId,
|
||||
@ -284,6 +289,7 @@ final class LiveAlbumViewModel {
|
||||
hasMore = folders.count < total
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset() {
|
||||
clearFolders()
|
||||
loading = false
|
||||
@ -291,6 +297,7 @@ final class LiveAlbumViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 清空Folders。
|
||||
private func clearFolders() {
|
||||
folders = []
|
||||
page = 1
|
||||
@ -377,6 +384,7 @@ final class LiveAlbumPreviewViewModel {
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 初始化实例。
|
||||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
currentIndex = max(startIndex, 0)
|
||||
|
||||
@ -39,6 +39,7 @@ struct LocationReportSubmitResponse: Decodable, Equatable {
|
||||
let expired: Int
|
||||
let status: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case staffId = "staff_id"
|
||||
case expired
|
||||
@ -73,6 +74,7 @@ struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case staffId = "staff_id"
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class LocationReportViewController: ModuleTableViewController {
|
||||
private let viewModel = LocationReportViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "位置上报"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,17 +24,21 @@ final class LocationReportViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
/// 返回列表 section 数量。
|
||||
override func numberOfTableSections() -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
/// 返回指定 section 行数。
|
||||
override func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? 3 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
/// section 标题。
|
||||
override func tableSectionTitle(for section: Int) -> String? {
|
||||
section == 0 ? "状态" : "操作"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
/// 提供自定义 Cell。
|
||||
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -55,8 +60,8 @@ final class LocationReportViewController: ModuleTableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
/// 处理行选中。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
if indexPath.section == 1 {
|
||||
Task {
|
||||
_ = await viewModel.setOnline(
|
||||
@ -69,10 +74,12 @@ final class LocationReportViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入")
|
||||
}
|
||||
|
||||
/// 提交Report。
|
||||
@objc private func submitReport() {
|
||||
Task {
|
||||
_ = await viewModel.submit(
|
||||
@ -91,25 +98,30 @@ extension LocationReportViewModel: ViewModelBindable {}
|
||||
final class LocationReportHistoryViewController: ModuleTableViewController {
|
||||
private let viewModel = LocationReportHistoryViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "上报历史"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.items.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = viewModel.items[indexPath.row]
|
||||
cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI)
|
||||
}
|
||||
|
||||
/// table视图相关逻辑。
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) }
|
||||
|
||||
@ -2,14 +2,9 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
|
||||
Main 模块负责登录后的主界面 Tab 容器。
|
||||
|
||||
主界面由 `MainTabsView` 承载:
|
||||
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。
|
||||
- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。
|
||||
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
|
||||
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
|
||||
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
|
||||
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。
|
||||
|
||||
## Tab 结构
|
||||
|
||||
@ -19,41 +14,33 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
|
||||
- `statistics`:数据
|
||||
- `profile`:我的
|
||||
|
||||
`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。
|
||||
各 Tab 根页面已接入真实业务页面:`HomeViewController`、`OrdersViewController`、`StatisticsViewController`、`ProfileViewController`。
|
||||
|
||||
## 导航流程
|
||||
|
||||
1. `MainTabsView` 从 Environment 读取 `AppRouter`。
|
||||
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。
|
||||
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。
|
||||
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。
|
||||
5. 路径绑定来自 `appRouter.binding(for:)`。
|
||||
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。
|
||||
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
|
||||
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
|
||||
1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源。
|
||||
2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步。
|
||||
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。
|
||||
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar,默认隐藏。
|
||||
5. 跨 Tab 跳转通过 `AppRouter.select` 或 `AppRouter.selectOrders(entry:)` 处理。
|
||||
|
||||
## 自定义 TabBar
|
||||
## 订单角标
|
||||
|
||||
自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成:
|
||||
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
|
||||
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
|
||||
- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。
|
||||
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
|
||||
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。
|
||||
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。
|
||||
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。
|
||||
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
|
||||
- 角标展示在订单 Tab 的 `tabBarItem.badgeValue` 上。
|
||||
- 账号景区或门店变化、切换到订单 Tab 时会刷新角标。
|
||||
|
||||
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。
|
||||
## 扫码核销
|
||||
|
||||
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
|
||||
|
||||
## 后续迁移规则
|
||||
|
||||
迁移新页面时:
|
||||
- 优先替换对应 Tab 的 RootView。
|
||||
- 保持每个 Tab 自己的 `NavigationStack`。
|
||||
- 优先替换对应 Tab 的根 ViewController。
|
||||
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。
|
||||
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
|
||||
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。
|
||||
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
|
||||
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
||||
- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。
|
||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||
|
||||
@ -3,24 +3,22 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。
|
||||
final class MainTabBarController: UIViewController {
|
||||
/// 主 Tab 容器,使用系统 `UITabBarController` 展示四个一级入口。
|
||||
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
|
||||
|
||||
private let services: AppServices
|
||||
private let badgeViewModel = MainTabBadgeViewModel()
|
||||
private let contentContainer = UIView()
|
||||
private let customTabBar = CustomMainTabBarView()
|
||||
|
||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||
private var loadedTabs: Set<AppTab> = [.home]
|
||||
private var isSyncingTabSelection = false
|
||||
|
||||
/// 初始化实例。
|
||||
init(services: AppServices) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
delegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .white
|
||||
configureLayout()
|
||||
configureTabBarAppearance()
|
||||
configureTabs()
|
||||
bindRouter()
|
||||
bindBadgeViewModel()
|
||||
bindAccountContext()
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
syncSelectedTabFromRouter(animated: false)
|
||||
Task { await refreshOrderBadge() }
|
||||
#if DEBUG
|
||||
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 控制自定义 TabBar 显隐,push 子页面时隐藏。
|
||||
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) {
|
||||
let updates = {
|
||||
self.customTabBar.alpha = hidden ? 0 : 1
|
||||
self.customTabBar.isUserInteractionEnabled = !hidden
|
||||
}
|
||||
|
||||
guard animated else {
|
||||
updates()
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates)
|
||||
/// 配置系统 TabBar 外观。
|
||||
private func configureTabBarAppearance() {
|
||||
tabBar.tintColor = AppDesign.primary
|
||||
tabBar.backgroundColor = .white
|
||||
}
|
||||
|
||||
private func configureLayout() {
|
||||
view.addSubview(contentContainer)
|
||||
view.addSubview(customTabBar)
|
||||
|
||||
customTabBar.onTabSelected = { [weak self] tab in
|
||||
self?.services.appRouter.select(tab)
|
||||
}
|
||||
customTabBar.onScanTapped = { [weak self] in
|
||||
self?.presentScanner()
|
||||
}
|
||||
|
||||
contentContainer.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(customTabBar.snp.top)
|
||||
}
|
||||
|
||||
customTabBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
make.height.equalTo(72)
|
||||
/// 为每个 Tab 创建独立导航栈并绑定 TabBarItem。
|
||||
private func configureTabs() {
|
||||
viewControllers = AppTab.allCases.map { tab in
|
||||
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||
navigationController.tabBarItem = tab.makeTabBarItem()
|
||||
tabNavigationControllers[tab] = navigationController
|
||||
return navigationController
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定 Router 回调,同步程序化 Tab 切换。
|
||||
private func bindRouter() {
|
||||
services.appRouter.onChange = { [weak self] in
|
||||
self?.handleRouterChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定角标 ViewModel 回调。
|
||||
private func bindBadgeViewModel() {
|
||||
badgeViewModel.onChange = { [weak self] in
|
||||
self?.updateOrderBadge()
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定账号上下文,景区或门店变化时刷新角标。
|
||||
private func bindAccountContext() {
|
||||
services.accountContext.onChange = { [weak self] in
|
||||
Task { await self?.refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 Router 变更,保持选中 Tab 与 `AppRouter` 一致。
|
||||
private func handleRouterChange() {
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
syncSelectedTabFromRouter(animated: false)
|
||||
if services.appRouter.selectedTab == .orders {
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToTab(_ tab: AppTab, animated: Bool) {
|
||||
loadedTabs.insert(tab)
|
||||
customTabBar.selectedTab = tab
|
||||
/// 将 `AppRouter.selectedTab` 同步到系统 TabBar 选中项。
|
||||
private func syncSelectedTabFromRouter(animated: Bool) {
|
||||
guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return }
|
||||
guard selectedIndex != index else { return }
|
||||
|
||||
let navigationController = navigationController(for: tab)
|
||||
for child in children where child !== navigationController {
|
||||
child.willMove(toParent: nil)
|
||||
child.view.removeFromSuperview()
|
||||
child.removeFromParent()
|
||||
}
|
||||
|
||||
addChild(navigationController)
|
||||
contentContainer.addSubview(navigationController.view)
|
||||
navigationController.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
navigationController.didMove(toParent: self)
|
||||
|
||||
let isRoot = navigationController.viewControllers.count <= 1
|
||||
setCustomTabBarHidden(!isRoot, animated: animated)
|
||||
}
|
||||
|
||||
private func navigationController(for tab: AppTab) -> TabNavigationController {
|
||||
if let existing = tabNavigationControllers[tab] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||
navigationController.tabBarHost = self
|
||||
tabNavigationControllers[tab] = navigationController
|
||||
return navigationController
|
||||
isSyncingTabSelection = true
|
||||
selectedIndex = index
|
||||
isSyncingTabSelection = false
|
||||
}
|
||||
|
||||
/// 更新订单 Tab 待核销角标。
|
||||
private func updateOrderBadge() {
|
||||
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
|
||||
customTabBar.orderBadgeText = nil
|
||||
return
|
||||
let badgeValue: String?
|
||||
if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
|
||||
badgeValue = "\(count)"
|
||||
} else {
|
||||
badgeValue = nil
|
||||
}
|
||||
customTabBar.orderBadgeText = "\(count)"
|
||||
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
||||
}
|
||||
|
||||
/// 刷新订单 Tab 待核销角标数据。
|
||||
private func refreshOrderBadge() async {
|
||||
await badgeViewModel.refreshPendingWriteOffCount(
|
||||
api: services.ordersAPI,
|
||||
@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let rawCode):
|
||||
dismiss(animated: true) {
|
||||
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode)
|
||||
}
|
||||
case .failure(let error):
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
/// 用户点击 TabBar 时同步选中状态到 `AppRouter`。
|
||||
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
|
||||
guard !isSyncingTabSelection,
|
||||
let navigationController = viewController as? TabNavigationController else { return }
|
||||
|
||||
services.appRouter.select(navigationController.appTab)
|
||||
if navigationController.appTab == .orders {
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
let navigation = UINavigationController(rootViewController: scanner)
|
||||
navigation.modalPresentationStyle = .fullScreen
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。
|
||||
private final class CustomMainTabBarView: UIView {
|
||||
|
||||
var selectedTab: AppTab = .home {
|
||||
didSet { refreshSelection() }
|
||||
}
|
||||
|
||||
var orderBadgeText: String? {
|
||||
didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil }
|
||||
}
|
||||
|
||||
var onTabSelected: ((AppTab) -> Void)?
|
||||
var onScanTapped: (() -> Void)?
|
||||
|
||||
private struct TabItem {
|
||||
let tab: AppTab
|
||||
let title: String
|
||||
let selectedImage: String
|
||||
let unselectedImage: String
|
||||
}
|
||||
|
||||
private let items: [TabItem] = [
|
||||
.init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"),
|
||||
.init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"),
|
||||
.init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"),
|
||||
.init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected")
|
||||
]
|
||||
|
||||
private var tabButtons: [AppTab: UIButton] = [:]
|
||||
private var tabTitleLabels: [AppTab: UILabel] = [:]
|
||||
private let ordersBadgeLabel = UILabel()
|
||||
private let topDivider = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
configureViews()
|
||||
refreshSelection()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04)
|
||||
addSubview(topDivider)
|
||||
topDivider.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
let leftStack = UIStackView()
|
||||
leftStack.axis = .horizontal
|
||||
leftStack.distribution = .fillEqually
|
||||
leftStack.spacing = 0
|
||||
|
||||
let rightStack = UIStackView()
|
||||
rightStack.axis = .horizontal
|
||||
rightStack.distribution = .fillEqually
|
||||
rightStack.spacing = 0
|
||||
|
||||
for (index, item) in items.enumerated() {
|
||||
let buttonContainer = makeTabButton(for: item)
|
||||
if index < 2 {
|
||||
leftStack.addArrangedSubview(buttonContainer)
|
||||
} else {
|
||||
rightStack.addArrangedSubview(buttonContainer)
|
||||
}
|
||||
}
|
||||
|
||||
let scanButton = UIButton(type: .system)
|
||||
scanButton.backgroundColor = AppDesign.primary
|
||||
scanButton.layer.cornerRadius = 29
|
||||
scanButton.tintColor = .white
|
||||
scanButton.setImage(
|
||||
UIImage(systemName: "qrcode.viewfinder")?.withConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
|
||||
),
|
||||
for: .normal
|
||||
)
|
||||
scanButton.accessibilityLabel = "扫码核销"
|
||||
scanButton.accessibilityIdentifier = "main.scan"
|
||||
scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 0
|
||||
addSubview(row)
|
||||
|
||||
scanButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(74)
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
|
||||
row.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(22)
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
leftStack.snp.makeConstraints { make in
|
||||
make.width.equalTo(rightStack)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTabButton(for item: TabItem) -> UIView {
|
||||
let container = UIView()
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.accessibilityLabel = item.title
|
||||
button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)"
|
||||
button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0
|
||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||
tabButtons[item.tab] = button
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = item.title
|
||||
titleLabel.font = .systemFont(ofSize: 12)
|
||||
titleLabel.textAlignment = .center
|
||||
tabTitleLabels[item.tab] = titleLabel
|
||||
|
||||
container.addSubview(button)
|
||||
container.addSubview(titleLabel)
|
||||
|
||||
button.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button.snp.bottom).offset(3)
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.lessThanOrEqualToSuperview()
|
||||
}
|
||||
|
||||
if item.tab == .orders {
|
||||
ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
|
||||
ordersBadgeLabel.textColor = .white
|
||||
ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444)
|
||||
ordersBadgeLabel.textAlignment = .center
|
||||
ordersBadgeLabel.layer.cornerRadius = 7.5
|
||||
ordersBadgeLabel.clipsToBounds = true
|
||||
ordersBadgeLabel.isHidden = true
|
||||
container.addSubview(ordersBadgeLabel)
|
||||
ordersBadgeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button).offset(-4)
|
||||
make.leading.equalTo(button.snp.trailing).offset(-6)
|
||||
make.height.equalTo(15)
|
||||
make.width.greaterThanOrEqualTo(15)
|
||||
}
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
private func refreshSelection() {
|
||||
for item in items {
|
||||
let isSelected = item.tab == selectedTab
|
||||
let imageName = isSelected ? item.selectedImage : item.unselectedImage
|
||||
tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal)
|
||||
tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3)
|
||||
tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func tabTapped(_ sender: UIButton) {
|
||||
guard sender.tag >= 0, sender.tag < items.count else { return }
|
||||
onTabSelected?(items[sender.tag].tab)
|
||||
}
|
||||
|
||||
@objc private func scanTapped() {
|
||||
onScanTapped?()
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ final class PlaceholderViewController: UIViewController {
|
||||
|
||||
private let pageTitle: String
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String) {
|
||||
pageTitle = title
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
|
||||
@ -14,6 +14,7 @@ struct MessageListResponse: Decodable, Equatable {
|
||||
let lastId: Int
|
||||
let items: [MessageEntity]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hasMore = "has_more"
|
||||
case lastId = "last_id"
|
||||
@ -47,6 +48,7 @@ struct MessageEntity: Decodable, Equatable, Identifiable {
|
||||
let createdAt: String
|
||||
let isRead: Bool
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class MessageCenterViewController: ModuleTableViewController {
|
||||
private let viewModel = MessageCenterViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "消息中心"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,10 +24,12 @@ final class MessageCenterViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.messages.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.messages[indexPath.row]
|
||||
cell.configure(
|
||||
@ -37,6 +40,7 @@ final class MessageCenterViewController: ModuleTableViewController {
|
||||
cell.accessoryType = item.isRead ? .none : .disclosureIndicator
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.messages[indexPath.row]
|
||||
Task {
|
||||
@ -45,20 +49,24 @@ final class MessageCenterViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
isLoading = viewModel.messages.isEmpty
|
||||
await viewModel.reloadFirstPage(api: services.messageCenterAPI)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
/// mark全部Read相关逻辑。
|
||||
@objc private func markAllRead() {
|
||||
Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) }
|
||||
}
|
||||
|
||||
/// 更新BarButtons状态。
|
||||
private func updateBarButtons() {
|
||||
navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty
|
||||
}
|
||||
|
||||
/// 展示Alert。
|
||||
private func showAlert(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "知道了", style: .default))
|
||||
|
||||
@ -146,6 +146,7 @@ final class MessageCenterViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// deduplicated并排序相关逻辑。
|
||||
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
|
||||
var unique: [String: MessageItem] = [:]
|
||||
for item in source {
|
||||
@ -154,6 +155,7 @@ final class MessageCenterViewModel {
|
||||
return unique.values.sorted { $0.time > $1.time }
|
||||
}
|
||||
|
||||
/// 清空Messages。
|
||||
private func clearMessages() {
|
||||
messages = []
|
||||
hasMoreMessages = false
|
||||
|
||||
@ -10,7 +10,9 @@ import Foundation
|
||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||
@MainActor
|
||||
protocol OperatingAreaServing {
|
||||
/// store营业Area相关逻辑。
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
/// scenicAdmin营业Area相关逻辑。
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
let typeText: String
|
||||
let auditStatusText: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -40,6 +41,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
case auditStatusText = "audit_status_text"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
@ -56,6 +58,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
self.auditStatusText = auditStatusText
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -76,6 +79,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
||||
case array([OperatingMapArea])
|
||||
case object([String: OperatingMapArea])
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
@ -106,6 +110,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
||||
return fromJSONObject(raw)
|
||||
}
|
||||
|
||||
/// fromJSONObject相关逻辑。
|
||||
private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
|
||||
switch value {
|
||||
case is NSNull:
|
||||
@ -140,6 +145,7 @@ struct OperatingFenceRing: Identifiable, Equatable {
|
||||
let points: [OperatingGeoPoint]
|
||||
let isCurrentStore: Bool
|
||||
|
||||
/// 初始化实例。
|
||||
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
|
||||
self.id = "\(itemId)-\(ringIndex)"
|
||||
self.itemId = itemId
|
||||
@ -184,6 +190,7 @@ enum OperatingAreaParser {
|
||||
return parseElement(area)
|
||||
}
|
||||
|
||||
/// 解析Element数据。
|
||||
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
|
||||
switch value {
|
||||
case .null, .bool, .number:
|
||||
@ -199,6 +206,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析Object数据。
|
||||
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
let type: String?
|
||||
if case let .string(rawType)? = object["type"] {
|
||||
@ -231,6 +239,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析ArrayRoot数据。
|
||||
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
guard let first = array.first else { return [] }
|
||||
switch first {
|
||||
@ -257,6 +266,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析ObjectRing数据。
|
||||
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
guard case let .object(object) = element else { return nil }
|
||||
@ -265,6 +275,7 @@ enum OperatingAreaParser {
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
/// 解析Ring数据。
|
||||
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
switch element {
|
||||
@ -283,6 +294,7 @@ enum OperatingAreaParser {
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
/// point从对象相关逻辑。
|
||||
private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? {
|
||||
let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue
|
||||
let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue
|
||||
@ -290,6 +302,7 @@ enum OperatingAreaParser {
|
||||
return OperatingGeoPoint(latitude: lat, longitude: lng)
|
||||
}
|
||||
|
||||
/// 规范化Pair格式。
|
||||
private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint {
|
||||
if looksLikeLngLatPair(lng: a, lat: b) {
|
||||
return OperatingGeoPoint(latitude: b, longitude: a)
|
||||
@ -303,24 +316,29 @@ enum OperatingAreaParser {
|
||||
return OperatingGeoPoint(latitude: a, longitude: b)
|
||||
}
|
||||
|
||||
/// looksLikeLngLat坐标对相关逻辑。
|
||||
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
|
||||
}
|
||||
|
||||
/// looksLikeLatLng坐标对相关逻辑。
|
||||
private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng)
|
||||
}
|
||||
}
|
||||
|
||||
/// 结构体,封装数据实体。
|
||||
private struct OperatingDynamicCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
let intValue: Int?
|
||||
|
||||
/// 初始化实例。
|
||||
init?(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
intValue = nil
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init?(intValue: Int) {
|
||||
stringValue = String(intValue)
|
||||
self.intValue = intValue
|
||||
@ -341,6 +359,7 @@ private extension OperatingMapArea {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// operating解码宽松字符串相关逻辑。
|
||||
func operatingDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -357,6 +376,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// operating解码宽松整数相关逻辑。
|
||||
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
|
||||
@ -1,37 +1,146 @@
|
||||
//
|
||||
// OperatingAreaViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
// suixinkan_ios
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 运营区域列表页。
|
||||
final class OperatingAreaViewController: ModuleTableViewController {
|
||||
/// 运营区域页:汇总信息、围栏地图与区域列表。
|
||||
final class OperatingAreaViewController: UIViewController {
|
||||
private let viewModel = OperatingAreaViewModel()
|
||||
private let mapView = OperatingAreaMapView()
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private let summaryLabel = UILabel()
|
||||
private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash")
|
||||
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, String>!
|
||||
private var services: AppServices { AppServices.shared }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "运营区域"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
title = "运营区域"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupLayout()
|
||||
wireViewModel()
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.items.count
|
||||
private func setupLayout() {
|
||||
summaryLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
summaryLabel.textColor = AppDesign.textSecondary
|
||||
summaryLabel.numberOfLines = 0
|
||||
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.height.equalTo(320)
|
||||
}
|
||||
|
||||
blockReasonView.isHidden = true
|
||||
|
||||
tableView.register(TitleSubtitleTableViewCell.self, forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(onPullRefresh), for: .valueChanged)
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, String>(tableView: tableView) { [weak self] tableView, indexPath, itemID in
|
||||
guard let self else { return UITableViewCell() }
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TitleSubtitleTableViewCell
|
||||
guard let item = self.viewModel.items.first(where: { String($0.id) == itemID }) else {
|
||||
return cell
|
||||
}
|
||||
let ringCount = self.viewModel.fenceRings.filter { $0.itemId == item.id }.count
|
||||
cell.configure(
|
||||
title: item.name.isEmpty ? "未命名区域" : item.name,
|
||||
subtitle: [item.typeText, item.statusText].filter { !$0.isEmpty }.joined(separator: " · "),
|
||||
detail: "围栏 \(ringCount) 个"
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [summaryLabel, blockReasonView, mapView])
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = AppMetrics.Spacing.medium
|
||||
|
||||
let headerContainer = UIView()
|
||||
headerContainer.addSubview(headerStack)
|
||||
headerStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
headerContainer.layoutIfNeeded()
|
||||
let height = headerStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height
|
||||
headerContainer.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: height + 24)
|
||||
tableView.tableHeaderView = headerContainer
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count) 组")
|
||||
private func wireViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.render()
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
private func render() {
|
||||
summaryLabel.text = "\(viewModel.title) · \(scopeText()) · 区域 \(viewModel.summary.areaCount) · 围栏 \(viewModel.summary.fenceCount)"
|
||||
|
||||
if let reason = viewModel.blockReason {
|
||||
blockReasonView.isHidden = false
|
||||
blockReasonView.update(title: reason.message, systemImage: "mappin.slash")
|
||||
mapView.isHidden = true
|
||||
} else {
|
||||
blockReasonView.isHidden = true
|
||||
mapView.isHidden = false
|
||||
mapView.rings = viewModel.fenceRings
|
||||
}
|
||||
applyTableSnapshot()
|
||||
}
|
||||
|
||||
/// 通过 Diffable snapshot 刷新区域列表。
|
||||
private func applyTableSnapshot(animated: Bool = true) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.items.map { String($0.id) }, toSection: 0)
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
private func scopeText() -> String {
|
||||
switch viewModel.mode {
|
||||
case .storeAdmin:
|
||||
return services.accountContext.currentStore?.name ?? "当前店铺"
|
||||
case .scenicAdmin:
|
||||
return services.accountContext.currentScenic?.name ?? "当前景区"
|
||||
case nil:
|
||||
return services.accountContext.currentScenic?.name
|
||||
?? services.accountContext.currentStore?.name
|
||||
?? "未选择业务范围"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func onPullRefresh() {
|
||||
Task {
|
||||
await reload(showLoading: false)
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
if showLoading { services.globalLoading.show() }
|
||||
defer { if showLoading { services.globalLoading.hide() } }
|
||||
|
||||
await viewModel.reload(
|
||||
api: services.operatingAreaAPI,
|
||||
accountContext: services.accountContext,
|
||||
permissionContext: services.permissionContext
|
||||
)
|
||||
if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -110,6 +110,7 @@ final class OperatingAreaViewModel {
|
||||
mode?.title ?? "运营区域"
|
||||
}
|
||||
|
||||
/// apply 业务逻辑。
|
||||
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
|
||||
self.items = items
|
||||
if items.isEmpty {
|
||||
@ -139,6 +140,7 @@ final class OperatingAreaViewModel {
|
||||
blockReason = parsed.isEmpty ? .noParsableFenceData : nil
|
||||
}
|
||||
|
||||
/// setBlocked相关逻辑。
|
||||
private func setBlocked(_ reason: OperatingAreaBlockReason) {
|
||||
resetLoadedData()
|
||||
blockReason = reason
|
||||
@ -146,6 +148,7 @@ final class OperatingAreaViewModel {
|
||||
loading = false
|
||||
}
|
||||
|
||||
/// 重置LoadedData状态。
|
||||
private func resetLoadedData() {
|
||||
items = []
|
||||
fenceRings = []
|
||||
|
||||
@ -62,6 +62,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
case orderNumber = "order_number"
|
||||
@ -131,6 +132,7 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
||||
let retouchGiftPhotoNum: Int
|
||||
let retouchGiftVideoNum: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderPhotoNum = "order_photo_num"
|
||||
case orderVideoNum = "order_video_num"
|
||||
@ -163,6 +165,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let orderStatusName: String
|
||||
let orderVerificationTime: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderVerificationStatus = "order_verification_status"
|
||||
@ -192,6 +195,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
struct WriteOffRequest: Encodable {
|
||||
let orderNumber: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
@ -207,6 +211,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let statusName: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNumber = "order_number"
|
||||
@ -234,6 +239,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
struct DepositOrderWriteOffRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
@ -244,6 +250,7 @@ struct DepositOrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundReason: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundReason = "refund_reason"
|
||||
@ -275,6 +282,7 @@ struct OrderRefundRequest: Encodable, Equatable {
|
||||
let refundAmount: String
|
||||
let refundReason: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundType = "refund_type"
|
||||
@ -314,6 +322,7 @@ struct StoreOrderDetailResponse: Decodable, Equatable, Hashable {
|
||||
let remark: String
|
||||
let multiTravel: StoreOrderMultiTravelInfo?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderType = "order_type"
|
||||
@ -362,6 +371,7 @@ struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable {
|
||||
let projectInfo: StoreOrderProjectInfo?
|
||||
let shootingList: [StoreOrderShootingListItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectInfo = "project_info"
|
||||
case shootingList = "shooting_list"
|
||||
@ -382,6 +392,7 @@ struct StoreOrderProjectInfo: Decodable, Equatable, Hashable {
|
||||
let singleSpotPhotoNum: Int
|
||||
let singleSpotVideoNum: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case settleSpotNum = "settle_spot_num"
|
||||
case singleSpotMaterialNum = "single_spot_material_num"
|
||||
@ -412,6 +423,7 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable
|
||||
let startAvg: Double
|
||||
let start: Double
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
@ -447,6 +459,7 @@ struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
|
||||
let materialList: [OrderMediaFile]
|
||||
let completeList: [OrderMediaFile]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
@ -480,6 +493,7 @@ struct StoreOrderComment: Decodable, Equatable, Hashable {
|
||||
let content: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case starShooting = "star_shooting"
|
||||
case starRetouching = "star_retouching"
|
||||
@ -522,6 +536,7 @@ struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
|
||||
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
@ -550,6 +565,7 @@ struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
|
||||
let projectTypeName: String
|
||||
let photogSpotList: [PhotogSpotItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case projectType = "project_type"
|
||||
@ -583,6 +599,7 @@ struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
return nickname.isEmpty ? photogName : nickname
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
@ -609,6 +626,7 @@ struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Ha
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -629,6 +647,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
||||
let cloudFile: [MultiTravelCloudFileItem]
|
||||
let uploadFile: [MultiTravelUploadFileItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
@ -641,6 +660,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
||||
struct MultiTravelCloudFileItem: Encodable, Equatable {
|
||||
let fileId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileId = "file_id"
|
||||
}
|
||||
@ -651,6 +671,7 @@ struct MultiTravelUploadFileItem: Encodable, Equatable {
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
|
||||
@ -6,32 +6,127 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Deposit List Diffable 标识
|
||||
|
||||
private typealias DepositListSection = Int
|
||||
private typealias DepositListItem = String
|
||||
|
||||
/// 押金列表空态 / 加载态标识。
|
||||
private enum DepositListItemID {
|
||||
static let placeholder = "depositList:placeholder"
|
||||
}
|
||||
|
||||
// MARK: - Deposit Detail Diffable 标识
|
||||
|
||||
private typealias DepositDetailSection = Int
|
||||
private typealias DepositDetailRow = String
|
||||
|
||||
/// 押金详情行标识。
|
||||
private enum DepositDetailRowID {
|
||||
static let placeholder = "depositDetail:placeholder"
|
||||
static func field(_ index: Int) -> String { "depositDetail:field:\(index)" }
|
||||
}
|
||||
|
||||
// MARK: - Deposit Shooting Diffable 标识
|
||||
|
||||
private typealias DepositShootingSection = Int
|
||||
private typealias DepositShootingRow = String
|
||||
|
||||
/// 押金拍摄信息 section 索引。
|
||||
private enum DepositShootingSectionID {
|
||||
static let summary = 0
|
||||
static let material = 1
|
||||
static let complete = 2
|
||||
}
|
||||
|
||||
/// 押金拍摄信息行标识。
|
||||
private enum DepositShootingRowID {
|
||||
static let placeholder = "depositShooting:placeholder"
|
||||
static let scenicSpot = "depositShooting:scenicSpot"
|
||||
static let rating = "depositShooting:rating"
|
||||
static func material(_ index: Int) -> String { "depositShooting:material:\(index)" }
|
||||
static func complete(_ index: Int) -> String { "depositShooting:complete:\(index)" }
|
||||
}
|
||||
|
||||
/// 押金订单列表页,支持查询、分页、核销和退款。
|
||||
final class DepositOrderListViewController: UIViewController {
|
||||
final class DepositOrderListViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let viewModel = DepositOrderListViewModel()
|
||||
private var orderNumberField = UITextField()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动押金订单列表与分页刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<DepositListSection, DepositListItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
|
||||
setupHeader()
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<DepositListSection, DepositListItem>(
|
||||
tableView: tableView
|
||||
) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: DepositListItem) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
if item == DepositListItemID.placeholder {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = self.viewModel.loading ? "加载中..." : "暂无押金订单"
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
guard let orderItem = self.viewModel.orders.first(where: { $0.orderNumber == item }) else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
|
||||
cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber)
|
||||
cell.onDetail = { [weak self] in
|
||||
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: orderItem.orderNumber), from: $0) }
|
||||
}
|
||||
cell.onWriteOff = { [weak self] in self?.writeOff(orderItem) }
|
||||
cell.onRefund = { [weak self] in self?.refund(orderItem) }
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositListSection, DepositListItem> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<DepositListSection, DepositListItem>()
|
||||
snapshot.appendSections([0])
|
||||
if viewModel.orders.isEmpty {
|
||||
snapshot.appendItems([DepositListItemID.placeholder], toSection: 0)
|
||||
} else {
|
||||
snapshot.appendItems(viewModel.orders.map(\.orderNumber), toSection: 0)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
|
||||
var snapshot = buildTableSnapshot()
|
||||
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
tableDataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
|
||||
orderNumberField.placeholder = "请输入押金订单号"
|
||||
@ -55,12 +150,14 @@ final class DepositOrderListViewController: UIViewController {
|
||||
tableView.tableHeaderView = header
|
||||
}
|
||||
|
||||
/// open详情相关逻辑。
|
||||
@objc private func openDetail() {
|
||||
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !text.isEmpty else { return }
|
||||
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
|
||||
}
|
||||
|
||||
/// 刷新。
|
||||
private func reload(showLoading: Bool) async {
|
||||
if showLoading {
|
||||
await appServices.globalLoading.withLoading {
|
||||
@ -72,6 +169,7 @@ final class DepositOrderListViewController: UIViewController {
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
|
||||
/// writeOff相关逻辑。
|
||||
private func writeOff(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
@ -86,6 +184,7 @@ final class DepositOrderListViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// refund 业务逻辑。
|
||||
private func refund(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
@ -101,43 +200,25 @@ final class DepositOrderListViewController: UIViewController {
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
max(viewModel.orders.count, 1)
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.orders.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单"
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
|
||||
let item = viewModel.orders[indexPath.row]
|
||||
cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber)
|
||||
cell.onDetail = { [weak self] in
|
||||
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) }
|
||||
}
|
||||
cell.onWriteOff = { [weak self] in self?.writeOff(item) }
|
||||
cell.onRefund = { [weak self] in self?.refund(item) }
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:分页加载。
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return }
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath),
|
||||
item != DepositListItemID.placeholder,
|
||||
item == viewModel.orders.last?.orderNumber,
|
||||
viewModel.hasMore else { return }
|
||||
Task {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DepositOrder列表或网格 Cell,负责单项内容展示。
|
||||
private final class DepositOrderCell: UITableViewCell {
|
||||
static let reuseID = "DepositOrderCell"
|
||||
var onDetail: (() -> Void)?
|
||||
@ -148,6 +229,7 @@ private final class DepositOrderCell: UITableViewCell {
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
@ -182,14 +264,18 @@ private final class DepositOrderCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(item: DepositOrderListItem, isOperating: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = isOperating ? "处理中" : item.statusName
|
||||
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
|
||||
}
|
||||
|
||||
/// 点击detail的处理逻辑。
|
||||
@objc private func detailTapped() { onDetail?() }
|
||||
/// 点击writeOff的处理逻辑。
|
||||
@objc private func writeOffTapped() { onWriteOff?() }
|
||||
/// 点击refund的处理逻辑。
|
||||
@objc private func refundTapped() { onRefund?() }
|
||||
}
|
||||
|
||||
@ -201,11 +287,14 @@ final class DepositOrderDetailViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动详情字段展示。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -214,14 +303,66 @@ final class DepositOrderDetailViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单详情"
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>(
|
||||
tableView: tableView
|
||||
) { [weak self] (_: UITableView, _: IndexPath, row: DepositDetailRow) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
guard let detail = self.viewModel.detail else {
|
||||
cell.textLabel?.text = self.viewModel.errorMessage ?? (self.viewModel.loading ? "加载中..." : "暂无详情")
|
||||
return cell
|
||||
}
|
||||
let rows: [(String, String)] = [
|
||||
("订单号", detail.orderNumber),
|
||||
("状态", detail.orderStatusName),
|
||||
("类型", detail.orderTypeLabel),
|
||||
("付款金额", detail.actualPayAmount),
|
||||
("手机号", detail.phone),
|
||||
("项目", detail.projectName),
|
||||
("创建时间", detail.createdAt),
|
||||
("备注", detail.remark)
|
||||
]
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? 0
|
||||
if index < rows.count {
|
||||
cell.textLabel?.text = rows[index].0
|
||||
cell.detailTextLabel?.text = rows[index].1
|
||||
}
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositDetailSection, DepositDetailRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<DepositDetailSection, DepositDetailRow>()
|
||||
snapshot.appendSections([0])
|
||||
if viewModel.detail == nil {
|
||||
snapshot.appendItems([DepositDetailRowID.placeholder], toSection: 0)
|
||||
} else {
|
||||
snapshot.appendItems((0..<8).map { DepositDetailRowID.field($0) }, toSection: 0)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
private func applyTableSnapshot(animated: Bool = true) {
|
||||
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 加载Detail数据。
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
@ -230,41 +371,13 @@ final class DepositOrderDetailViewController: UIViewController {
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
applyTableSnapshot()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderDetailViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.detail == nil ? 1 : 8
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
guard let detail = viewModel.detail else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情")
|
||||
return cell
|
||||
}
|
||||
let rows: [(String, String)] = [
|
||||
("订单号", detail.orderNumber),
|
||||
("状态", detail.orderStatusName),
|
||||
("类型", detail.orderTypeLabel),
|
||||
("付款金额", detail.actualPayAmount),
|
||||
("手机号", detail.phone),
|
||||
("项目", detail.projectName),
|
||||
("创建时间", detail.createdAt),
|
||||
("备注", detail.remark)
|
||||
]
|
||||
cell.textLabel?.text = rows[indexPath.row].0
|
||||
cell.detailTextLabel?.text = rows[indexPath.row].1
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金拍摄信息页。
|
||||
final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
final class DepositOrderShootingInfoViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let orderNumber: String
|
||||
private let scenicSpotId: Int
|
||||
@ -273,10 +386,14 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动拍摄摘要与素材列表刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
|
||||
self.orderNumber = orderNumber
|
||||
self.scenicSpotId = scenicSpotId
|
||||
@ -287,14 +404,75 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金拍摄信息"
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>(
|
||||
tableView: tableView
|
||||
) { [weak self] (_: UITableView, indexPath: IndexPath, row: DepositShootingRow) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
guard let detail = self.viewModel.detail else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = self.viewModel.errorMessage ?? "加载中..."
|
||||
return cell
|
||||
}
|
||||
if row == DepositShootingRowID.scenicSpot || row == DepositShootingRowID.rating {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if row == DepositShootingRowID.scenicSpot {
|
||||
cell.textLabel?.text = "打卡点"
|
||||
cell.detailTextLabel?.text = detail.scenicSpotName
|
||||
} else {
|
||||
cell.textLabel?.text = "拍摄评分"
|
||||
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--"
|
||||
}
|
||||
return cell
|
||||
}
|
||||
let files: [OrderMediaFile]
|
||||
if row.hasPrefix("depositShooting:material:") {
|
||||
files = detail.materialList
|
||||
} else {
|
||||
files = detail.completeList
|
||||
}
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? 0
|
||||
let media = files[index]
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
cell.textLabel?.text = media.fileName
|
||||
cell.detailTextLabel?.text = media.fileUrl
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositShootingSection, DepositShootingRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<DepositShootingSection, DepositShootingRow>()
|
||||
guard let detail = viewModel.detail else {
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems([DepositShootingRowID.placeholder], toSection: 0)
|
||||
return snapshot
|
||||
}
|
||||
snapshot.appendSections([DepositShootingSectionID.summary, DepositShootingSectionID.material, DepositShootingSectionID.complete])
|
||||
snapshot.appendItems([DepositShootingRowID.scenicSpot, DepositShootingRowID.rating], toSection: DepositShootingSectionID.summary)
|
||||
snapshot.appendItems(detail.materialList.indices.map { DepositShootingRowID.material($0) }, toSection: DepositShootingSectionID.material)
|
||||
snapshot.appendItems(detail.completeList.indices.map { DepositShootingRowID.complete($0) }, toSection: DepositShootingSectionID.complete)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
private func applyTableSnapshot(animated: Bool = true) {
|
||||
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 加载Detail数据。
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
@ -305,56 +483,25 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
photogUid: photogUid
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
applyTableSnapshot()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderShootingInfoViewController: UITableViewDataSource {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
viewModel.detail == nil ? 1 : 3
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
guard let detail = viewModel.detail else { return 1 }
|
||||
switch section {
|
||||
case 0: return 2
|
||||
case 1: return detail.materialList.count
|
||||
default: return detail.completeList.count
|
||||
}
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard viewModel.detail != nil else { return nil }
|
||||
switch section {
|
||||
case 1: return "素材"
|
||||
case 2: return "成片"
|
||||
guard viewModel.detail != nil,
|
||||
let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||
switch sectionID {
|
||||
case DepositShootingSectionID.material: return "素材"
|
||||
case DepositShootingSectionID.complete: return "成片"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let detail = viewModel.detail else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "加载中..."
|
||||
return cell
|
||||
}
|
||||
if indexPath.section == 0 {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.row == 0 {
|
||||
cell.textLabel?.text = "打卡点"
|
||||
cell.detailTextLabel?.text = detail.scenicSpotName
|
||||
} else {
|
||||
cell.textLabel?.text = "拍摄评分"
|
||||
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--"
|
||||
}
|
||||
return cell
|
||||
}
|
||||
let files = indexPath.section == 1 ? detail.materialList : detail.completeList
|
||||
let media = files[indexPath.row]
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
cell.textLabel?.text = media.fileName
|
||||
cell.detailTextLabel?.text = media.fileUrl
|
||||
return cell
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
|
||||
private let scannerController = OrderScannerCaptureViewController()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
@ -49,6 +50,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// embed扫码相关逻辑。
|
||||
private func embedScanner() {
|
||||
scannerController.onScanResult = { [weak self] result in
|
||||
self?.onScanResult?(result)
|
||||
@ -77,6 +79,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示UnavailablePlaceholder。
|
||||
private func showUnavailablePlaceholder() {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
@ -109,6 +112,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击close的处理逻辑。
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
@ -145,6 +149,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
return button
|
||||
}()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
@ -152,17 +157,20 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
checkPermissionAndStart()
|
||||
}
|
||||
|
||||
/// 子视图布局完成后调整依赖 frame 的 UI。
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.layer.bounds
|
||||
}
|
||||
|
||||
/// 视图即将消失,保存或清理临时状态。
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
updateTorch(enabled: false)
|
||||
stopSession()
|
||||
}
|
||||
|
||||
/// check权限并启动相关逻辑。
|
||||
private func checkPermissionAndStart() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
@ -185,6 +193,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动Session流程。
|
||||
private func startSession() {
|
||||
hasFinishedScan = false
|
||||
guard !session.isRunning else { return }
|
||||
@ -199,11 +208,13 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止Session流程。
|
||||
private func stopSession() {
|
||||
guard session.isRunning else { return }
|
||||
session.stopRunning()
|
||||
}
|
||||
|
||||
/// 创建ScannerButton实例。
|
||||
private static func makeScannerButton(title: String) -> UIButton {
|
||||
var config = UIButton.Configuration.filled()
|
||||
config.title = title
|
||||
@ -213,6 +224,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
return UIButton(configuration: config)
|
||||
}
|
||||
|
||||
/// 配置Session展示内容。
|
||||
private func configureSession() throws {
|
||||
if previewLayer != nil { return }
|
||||
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
||||
@ -242,6 +254,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
torchButton.isEnabled = videoDevice.hasTorch
|
||||
}
|
||||
|
||||
/// metadataOutput相关逻辑。
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard !hasFinishedScan else { return }
|
||||
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
@ -253,6 +266,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
onScanResult?(.success(code))
|
||||
}
|
||||
|
||||
/// 初始化Controls相关 UI 或状态。
|
||||
private func setupControls() {
|
||||
view.addSubview(scanFrameView)
|
||||
view.addSubview(torchButton)
|
||||
@ -273,10 +287,12 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// toggleTorch相关逻辑。
|
||||
@objc private func toggleTorch() {
|
||||
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
|
||||
}
|
||||
|
||||
/// 更新Torch状态。
|
||||
private func updateTorch(enabled: Bool) {
|
||||
guard let device = videoDevice, device.hasTorch else { return }
|
||||
do {
|
||||
@ -293,6 +309,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// restart扫码相关逻辑。
|
||||
@objc private func restartScan() {
|
||||
hasFinishedScan = false
|
||||
updateTorch(enabled: false)
|
||||
|
||||
@ -6,8 +6,45 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Store Order Diffable 标识
|
||||
|
||||
private typealias StoreOrderDetailSection = Int
|
||||
private typealias StoreOrderDetailRow = String
|
||||
|
||||
/// 门店订单详情 section 索引。
|
||||
private enum StoreOrderDetailSectionID {
|
||||
static let context = 0
|
||||
static let orderInfo = 1
|
||||
static let payment = 2
|
||||
static let customer = 3
|
||||
static let shooting = 4
|
||||
static let actions = 5
|
||||
}
|
||||
|
||||
/// 门店订单详情行标识。
|
||||
private enum StoreOrderDetailRowID {
|
||||
static let context = "storeDetail:context"
|
||||
static func orderInfo(_ index: Int) -> String { "storeDetail:orderInfo:\(index)" }
|
||||
static func payment(_ index: Int) -> String { "storeDetail:payment:\(index)" }
|
||||
static func customer(_ index: Int) -> String { "storeDetail:customer:\(index)" }
|
||||
static func shooting(_ id: String) -> String { "storeDetail:shooting:\(id)" }
|
||||
static func action(_ index: Int) -> String { "storeDetail:action:\(index)" }
|
||||
}
|
||||
|
||||
// MARK: - Write-Off Diffable 标识
|
||||
|
||||
/// 核销详情行标识。
|
||||
private enum WriteOffDetailRowID {
|
||||
static let orderNumber = "writeOff:orderNumber"
|
||||
static let project = "writeOff:project"
|
||||
static let phone = "writeOff:phone"
|
||||
static let amount = "writeOff:amount"
|
||||
static let status = "writeOff:status"
|
||||
static let verificationTime = "writeOff:verificationTime"
|
||||
}
|
||||
|
||||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||||
final class StoreOrderDetailViewController: UIViewController {
|
||||
final class StoreOrderDetailViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let item: OrderEntity
|
||||
private let viewModel: OrderDetailViewModel
|
||||
@ -15,12 +52,15 @@ final class StoreOrderDetailViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动多 section 订单详情刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
self.viewModel = OrderDetailViewModel(item: item)
|
||||
@ -30,31 +70,176 @@ final class StoreOrderDetailViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单详情"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>(
|
||||
tableView: tableView
|
||||
) { [weak self] (_: UITableView, indexPath: IndexPath, row: StoreOrderDetailRow) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
return self.configureDetailCell(row: row)
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<StoreOrderDetailSection, StoreOrderDetailRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<StoreOrderDetailSection, StoreOrderDetailRow>()
|
||||
|
||||
if viewModel.contextMessage != nil {
|
||||
snapshot.appendSections([StoreOrderDetailSectionID.context])
|
||||
snapshot.appendItems([StoreOrderDetailRowID.context], toSection: StoreOrderDetailSectionID.context)
|
||||
}
|
||||
|
||||
snapshot.appendSections([StoreOrderDetailSectionID.orderInfo])
|
||||
snapshot.appendItems((0..<7).map { StoreOrderDetailRowID.orderInfo($0) }, toSection: StoreOrderDetailSectionID.orderInfo)
|
||||
|
||||
snapshot.appendSections([StoreOrderDetailSectionID.payment])
|
||||
snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.payment($0) }, toSection: StoreOrderDetailSectionID.payment)
|
||||
|
||||
snapshot.appendSections([StoreOrderDetailSectionID.customer])
|
||||
snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.customer($0) }, toSection: StoreOrderDetailSectionID.customer)
|
||||
|
||||
if !viewModel.shootingList.isEmpty {
|
||||
snapshot.appendSections([StoreOrderDetailSectionID.shooting])
|
||||
let shootingRows = viewModel.shootingList.enumerated().map { index, shooting in
|
||||
StoreOrderDetailRowID.shooting("\(shooting.id)-\(index)")
|
||||
}
|
||||
snapshot.appendItems(shootingRows, toSection: StoreOrderDetailSectionID.shooting)
|
||||
}
|
||||
|
||||
snapshot.appendSections([StoreOrderDetailSectionID.actions])
|
||||
snapshot.appendItems((0..<5).map { StoreOrderDetailRowID.action($0) }, toSection: StoreOrderDetailSectionID.actions)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
|
||||
var snapshot = buildTableSnapshot()
|
||||
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
tableDataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 配置详情 Cell 内容。
|
||||
private func configureDetailCell(row: StoreOrderDetailRow) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
cell.textLabel?.numberOfLines = 1
|
||||
cell.textLabel?.textColor = nil
|
||||
cell.accessoryType = .none
|
||||
cell.isUserInteractionEnabled = true
|
||||
let display = viewModel.display
|
||||
|
||||
if row == StoreOrderDetailRowID.context {
|
||||
cell.textLabel?.text = viewModel.contextMessage
|
||||
cell.textLabel?.numberOfLines = 0
|
||||
return cell
|
||||
}
|
||||
|
||||
if row.hasPrefix("storeDetail:orderInfo:") {
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? 0
|
||||
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
|
||||
cell.textLabel?.text = rows[index]
|
||||
switch index {
|
||||
case 0: cell.detailTextLabel?.text = display.orderNumber
|
||||
case 1: cell.detailTextLabel?.text = display.orderStatusName
|
||||
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
|
||||
case 3: cell.detailTextLabel?.text = display.createdAt
|
||||
case 4: cell.detailTextLabel?.text = display.payTime
|
||||
case 5: cell.detailTextLabel?.text = display.completeTime
|
||||
default:
|
||||
cell.textLabel?.textColor = AppDesignUIKit.primary
|
||||
cell.selectionStyle = .default
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if row.hasPrefix("storeDetail:payment:") {
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? 0
|
||||
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
|
||||
cell.textLabel?.text = rows[index]
|
||||
switch index {
|
||||
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
|
||||
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
|
||||
case 2: cell.detailTextLabel?.text = display.payTypeName
|
||||
default: cell.detailTextLabel?.text = "\(display.userId)"
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if row.hasPrefix("storeDetail:customer:") {
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? 0
|
||||
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
|
||||
cell.textLabel?.text = rows[index]
|
||||
switch index {
|
||||
case 0: cell.detailTextLabel?.text = display.phone
|
||||
case 1: cell.detailTextLabel?.text = display.projectName
|
||||
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
|
||||
default: cell.detailTextLabel?.text = display.remark
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if row.hasPrefix("storeDetail:shooting:") {
|
||||
let suffix = row.replacingOccurrences(of: "storeDetail:shooting:", with: "")
|
||||
let indexPart = suffix.split(separator: "-").last.flatMap { Int($0) } ?? 0
|
||||
if indexPart < viewModel.shootingList.count {
|
||||
let shooting = viewModel.shootingList[indexPart]
|
||||
cell.textLabel?.text = shooting.scenicSpotName
|
||||
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if row.hasPrefix("storeDetail:action:") {
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? 0
|
||||
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
|
||||
cell.textLabel?.text = actions[index]
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
if index == 4, !refundViewModel.canRefund(item) {
|
||||
cell.isUserInteractionEnabled = false
|
||||
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
/// 加载Detail数据。
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading(message: "加载详情中...") {
|
||||
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
|
||||
}
|
||||
applyTableSnapshot(reconfigure: true)
|
||||
if let error = viewModel.errorMessage {
|
||||
showToast(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制订单Number。
|
||||
private func copyOrderNumber() {
|
||||
UIPasteboard.general.string = viewModel.display.orderNumber
|
||||
showToast("订单号已复制")
|
||||
}
|
||||
|
||||
/// 弹出Refund页面。
|
||||
private func presentRefund() {
|
||||
refundViewModel.begin(item: item)
|
||||
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
|
||||
@ -75,99 +260,34 @@ final class StoreOrderDetailViewController: UIViewController {
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 6 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0: return viewModel.contextMessage == nil ? 0 : 1
|
||||
case 1: return 7
|
||||
case 2: return 4
|
||||
case 3: return 4
|
||||
case 4: return viewModel.shootingList.count
|
||||
case 5: return 5
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
switch section {
|
||||
case 1: "订单信息"
|
||||
case 2: "支付信息"
|
||||
case 3: "客户与项目"
|
||||
case 4 where !viewModel.shootingList.isEmpty: "拍摄点"
|
||||
case 5: "后续功能"
|
||||
default: nil
|
||||
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||
switch sectionID {
|
||||
case StoreOrderDetailSectionID.orderInfo: return "订单信息"
|
||||
case StoreOrderDetailSectionID.payment: return "支付信息"
|
||||
case StoreOrderDetailSectionID.customer: return "客户与项目"
|
||||
case StoreOrderDetailSectionID.shooting: return "拍摄点"
|
||||
case StoreOrderDetailSectionID.actions: return "后续功能"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
let display = viewModel.display
|
||||
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
cell.textLabel?.text = viewModel.contextMessage
|
||||
cell.textLabel?.numberOfLines = 0
|
||||
case 1:
|
||||
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.orderNumber
|
||||
case 1: cell.detailTextLabel?.text = display.orderStatusName
|
||||
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
|
||||
case 3: cell.detailTextLabel?.text = display.createdAt
|
||||
case 4: cell.detailTextLabel?.text = displayPayTime
|
||||
case 5: cell.detailTextLabel?.text = display.completeTime
|
||||
default:
|
||||
cell.textLabel?.textColor = AppDesignUIKit.primary
|
||||
cell.selectionStyle = .default
|
||||
}
|
||||
case 2:
|
||||
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
|
||||
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
|
||||
case 2: cell.detailTextLabel?.text = display.payTypeName
|
||||
default: cell.detailTextLabel?.text = "\(display.userId)"
|
||||
}
|
||||
case 3:
|
||||
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.phone
|
||||
case 1: cell.detailTextLabel?.text = display.projectName
|
||||
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
|
||||
default: cell.detailTextLabel?.text = display.remark
|
||||
}
|
||||
case 4:
|
||||
let shooting = viewModel.shootingList[indexPath.row]
|
||||
cell.textLabel?.text = shooting.scenicSpotName
|
||||
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
|
||||
case 5:
|
||||
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
|
||||
cell.textLabel?.text = actions[indexPath.row]
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
if indexPath.row == 4, !refundViewModel.canRefund(item) {
|
||||
cell.isUserInteractionEnabled = false
|
||||
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
|
||||
}
|
||||
default: break
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return }
|
||||
guard indexPath.section == 5 else { return }
|
||||
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
if row == StoreOrderDetailRowID.orderInfo(6) {
|
||||
copyOrderNumber()
|
||||
return
|
||||
}
|
||||
|
||||
guard row.hasPrefix("storeDetail:action:") else { return }
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? -1
|
||||
let orderNumber = viewModel.display.orderNumber
|
||||
switch indexPath.row {
|
||||
switch index {
|
||||
case 0:
|
||||
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
|
||||
case 1 where item.orderType == 19:
|
||||
@ -178,7 +298,8 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
|
||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
|
||||
case 4:
|
||||
presentRefund()
|
||||
default: break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,65 +309,79 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
|
||||
return payTime
|
||||
}
|
||||
|
||||
/// empty至Zero相关逻辑。
|
||||
private func emptyToZero(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单详情页,展示核销列表项摘要信息。
|
||||
final class WriteOffOrderDetailViewController: UIViewController {
|
||||
final class WriteOffOrderDetailViewController: SimpleTableDiffableViewController {
|
||||
|
||||
private let item: WriteOffOrderItem
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
/// 初始化实例。
|
||||
init(item: WriteOffOrderItem) {
|
||||
self.item = item
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
super.init(style: .insetGrouped)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "核销详情"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
tableView.backgroundColor = AppDesignUIKit.pageBackground
|
||||
}
|
||||
}
|
||||
|
||||
extension WriteOffOrderDetailViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 }
|
||||
/// 构建 Diffable snapshot。
|
||||
override func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems([
|
||||
WriteOffDetailRowID.orderNumber,
|
||||
WriteOffDetailRowID.project,
|
||||
WriteOffDetailRowID.phone,
|
||||
WriteOffDetailRowID.amount,
|
||||
WriteOffDetailRowID.status,
|
||||
WriteOffDetailRowID.verificationTime
|
||||
], toSection: 0)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
/// 配置 Cell 展示内容。
|
||||
override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
|
||||
cell.selectionStyle = .none
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
switch row {
|
||||
case WriteOffDetailRowID.orderNumber:
|
||||
cell.textLabel?.text = "订单号"
|
||||
cell.detailTextLabel?.text = item.orderNumber
|
||||
case 1:
|
||||
case WriteOffDetailRowID.project:
|
||||
cell.textLabel?.text = "项目"
|
||||
cell.detailTextLabel?.text = item.projectName
|
||||
case 2:
|
||||
case WriteOffDetailRowID.phone:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = item.userPhone
|
||||
case 3:
|
||||
case WriteOffDetailRowID.amount:
|
||||
cell.textLabel?.text = "金额"
|
||||
cell.detailTextLabel?.text = "¥\(item.orderAmount)"
|
||||
case 4:
|
||||
case WriteOffDetailRowID.status:
|
||||
cell.textLabel?.text = "状态"
|
||||
cell.detailTextLabel?.text = item.orderStatusName
|
||||
default:
|
||||
case WriteOffDetailRowID.verificationTime:
|
||||
cell.textLabel?.text = "核销时间"
|
||||
cell.detailTextLabel?.text = item.orderVerificationTime
|
||||
default:
|
||||
break
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
private let localListLabel = UILabel()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化实例。
|
||||
init(initialOrderNumber: String) {
|
||||
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -27,6 +28,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "任务上传"
|
||||
@ -36,12 +38,14 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
/// 绑定ViewModel回调或数据。
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.applyViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化Form相关 UI 或状态。
|
||||
private func setupForm() {
|
||||
orderField.borderStyle = .roundedRect
|
||||
orderField.placeholder = "关联订单号"
|
||||
@ -90,6 +94,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
/// labeled行相关逻辑。
|
||||
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
@ -100,6 +105,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
return row
|
||||
}
|
||||
|
||||
/// apply视图模型相关逻辑。
|
||||
private func applyViewModel() {
|
||||
orderField.text = viewModel.orderNumber
|
||||
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
|
||||
@ -123,14 +129,17 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
|
||||
}
|
||||
|
||||
/// order变更相关逻辑。
|
||||
@objc private func orderChanged() {
|
||||
viewModel.orderNumber = orderField.text ?? ""
|
||||
}
|
||||
|
||||
/// 刷新Spots展示。
|
||||
@objc private func refreshSpots() {
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
/// select打卡点相关逻辑。
|
||||
@objc private func selectSpot() {
|
||||
guard !viewModel.spots.isEmpty else { return }
|
||||
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
|
||||
@ -144,6 +153,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
/// pick本地Files相关逻辑。
|
||||
@objc private func pickLocalFiles() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.selectionLimit = 9
|
||||
@ -153,6 +163,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
/// 提交Tapped。
|
||||
@objc private func submitTapped() {
|
||||
Task {
|
||||
let success = await viewModel.submit(
|
||||
@ -172,6 +183,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||||
/// picker 业务逻辑。
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
@ -199,19 +211,41 @@ extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Historical Shooting Diffable 标识
|
||||
|
||||
private typealias HistoricalShootingSection = Int
|
||||
private typealias HistoricalShootingRow = String
|
||||
|
||||
/// 历史拍摄 section 索引。
|
||||
private enum HistoricalShootingSectionID {
|
||||
static let summary = 0
|
||||
static let spots = 1
|
||||
}
|
||||
|
||||
/// 历史拍摄行标识。
|
||||
private enum HistoricalShootingRowID {
|
||||
static let project = "historicalShooting:project"
|
||||
static let projectType = "historicalShooting:projectType"
|
||||
static let empty = "historicalShooting:empty"
|
||||
static func spot(_ id: String) -> String { "historicalShooting:spot:\(id)" }
|
||||
}
|
||||
|
||||
/// 历史拍摄信息页。
|
||||
final class HistoricalShootingInfoViewController: UIViewController {
|
||||
final class HistoricalShootingInfoViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let orderNumber: String
|
||||
private let viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动历史拍摄摘要与点位列表刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -220,49 +254,89 @@ final class HistoricalShootingInfoViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "历史拍摄"
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
|
||||
Task { await loadData() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>(
|
||||
tableView: tableView
|
||||
) { [weak self] (_: UITableView, _: IndexPath, row: HistoricalShootingRow) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
switch row {
|
||||
case HistoricalShootingRowID.project:
|
||||
cell.textLabel?.text = "项目"
|
||||
cell.detailTextLabel?.text = self.viewModel.projectName
|
||||
case HistoricalShootingRowID.projectType:
|
||||
cell.textLabel?.text = "项目类型"
|
||||
cell.detailTextLabel?.text = self.viewModel.projectTypeName
|
||||
case HistoricalShootingRowID.empty:
|
||||
cell.textLabel?.text = self.viewModel.errorMessage ?? "暂无历史拍摄"
|
||||
default:
|
||||
if row.hasPrefix("historicalShooting:spot:") {
|
||||
let id = row.replacingOccurrences(of: "historicalShooting:spot:", with: "")
|
||||
if let spot = self.viewModel.spots.first(where: { $0.id == id }) {
|
||||
cell.textLabel?.text = spot.scenicSpotName
|
||||
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<HistoricalShootingSection, HistoricalShootingRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<HistoricalShootingSection, HistoricalShootingRow>()
|
||||
snapshot.appendSections([HistoricalShootingSectionID.summary, HistoricalShootingSectionID.spots])
|
||||
snapshot.appendItems([HistoricalShootingRowID.project, HistoricalShootingRowID.projectType], toSection: HistoricalShootingSectionID.summary)
|
||||
if viewModel.spots.isEmpty {
|
||||
snapshot.appendItems([HistoricalShootingRowID.empty], toSection: HistoricalShootingSectionID.spots)
|
||||
} else {
|
||||
snapshot.appendItems(viewModel.spots.map { HistoricalShootingRowID.spot($0.id) }, toSection: HistoricalShootingSectionID.spots)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
|
||||
var snapshot = buildTableSnapshot()
|
||||
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
tableDataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 加载Data数据。
|
||||
private func loadData() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
|
||||
}
|
||||
applyTableSnapshot()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 2 : max(viewModel.spots.count, 1)
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 1 ? "拍摄点位" : nil
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
if indexPath.section == 0 {
|
||||
cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型"
|
||||
cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName
|
||||
return cell
|
||||
}
|
||||
guard !viewModel.spots.isEmpty else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄"
|
||||
return cell
|
||||
}
|
||||
let spot = viewModel.spots[indexPath.row]
|
||||
cell.textLabel?.text = spot.scenicSpotName
|
||||
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
|
||||
return cell
|
||||
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||
return sectionID == HistoricalShootingSectionID.spots ? "拍摄点位" : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,31 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识(Int / String 避免 MainActor 默认隔离下 Hashable 冲突)
|
||||
|
||||
/// 订单列表 section 索引。
|
||||
private enum OrdersSectionID {
|
||||
static let header = 0
|
||||
static let toolbar = 1
|
||||
static let content = 2
|
||||
}
|
||||
|
||||
private typealias OrdersSection = Int
|
||||
|
||||
/// 订单列表 item 字符串标识。
|
||||
private enum OrdersItemID {
|
||||
static let header = "orders:header"
|
||||
static let filter = "orders:filter"
|
||||
static let writeOffAction = "orders:writeOffAction"
|
||||
static let missingContext = "orders:missingContext"
|
||||
static let emptyStore = "orders:empty:store"
|
||||
static let emptyWriteOff = "orders:empty:writeOff"
|
||||
static func storeOrder(_ orderNumber: String) -> String { "orders:store:\(orderNumber)" }
|
||||
static func writeOffOrder(_ orderNumber: String) -> String { "orders:writeoff:\(orderNumber)" }
|
||||
}
|
||||
|
||||
private typealias OrdersItem = String
|
||||
|
||||
/// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。
|
||||
final class OrdersViewController: UIViewController {
|
||||
|
||||
@ -13,36 +38,43 @@ final class OrdersViewController: UIViewController {
|
||||
private var manualOrderNumber = ""
|
||||
private var scanHintMessage: String?
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID)
|
||||
table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID)
|
||||
table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID)
|
||||
table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID)
|
||||
table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
||||
return table
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = makeCollectionLayout()
|
||||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||
collection.delegate = self
|
||||
collection.register(OrdersHeaderCell.self, forCellWithReuseIdentifier: OrdersHeaderCell.reuseID)
|
||||
collection.register(OrdersFilterCell.self, forCellWithReuseIdentifier: OrdersFilterCell.reuseID)
|
||||
collection.register(OrdersWriteOffActionCell.self, forCellWithReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
||||
collection.register(OrderEntityCell.self, forCellWithReuseIdentifier: OrderEntityCell.reuseID)
|
||||
collection.register(WriteOffOrderCell.self, forCellWithReuseIdentifier: WriteOffOrderCell.reuseID)
|
||||
collection.register(OrdersEmptyStateCell.self, forCellWithReuseIdentifier: OrdersEmptyStateCell.reuseID)
|
||||
return collection
|
||||
}()
|
||||
|
||||
private lazy var dataSource: UICollectionViewDiffableDataSource<OrdersSection, OrdersItem> = {
|
||||
UICollectionViewDiffableDataSource<OrdersSection, OrdersItem>(collectionView: collectionView) { [weak self] collectionView, indexPath, item in
|
||||
self?.cell(for: collectionView, at: indexPath, item: item) ?? UICollectionViewCell()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
view.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
collectionView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.applySnapshot()
|
||||
}
|
||||
|
||||
appServices.appRouter.onChange = { [weak self] in
|
||||
@ -64,6 +96,202 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 Compositional Layout,按 section 类型选用全宽布局。
|
||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self else {
|
||||
return CollectionDiffableLayout.fullWidthSection()
|
||||
}
|
||||
let section = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]
|
||||
switch section {
|
||||
case OrdersSectionID.header:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 120)
|
||||
case OrdersSectionID.toolbar:
|
||||
let height: CGFloat = self.viewModel.selectedEntry == .storeOrders ? 130 : 150
|
||||
return CollectionDiffableLayout.fullWidthSection(height: height)
|
||||
case OrdersSectionID.content:
|
||||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 88)
|
||||
default:
|
||||
return CollectionDiffableLayout.fullWidthSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前 ViewModel 与页面状态构建并应用 Diffable 快照。
|
||||
private func applySnapshot(animatingDifferences: Bool = true) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>()
|
||||
snapshot.appendSections([OrdersSectionID.header])
|
||||
snapshot.appendItems([OrdersItemID.header], toSection: OrdersSectionID.header)
|
||||
|
||||
guard currentScenicId != nil else {
|
||||
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
|
||||
return
|
||||
}
|
||||
|
||||
snapshot.appendSections([OrdersSectionID.toolbar, OrdersSectionID.content])
|
||||
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
snapshot.appendItems([OrdersItemID.filter], toSection: OrdersSectionID.toolbar)
|
||||
appendStoreOrderItems(to: &snapshot)
|
||||
} else {
|
||||
snapshot.appendItems([OrdersItemID.writeOffAction], toSection: OrdersSectionID.toolbar)
|
||||
appendWriteOffOrderItems(to: &snapshot)
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
|
||||
}
|
||||
|
||||
/// 向快照追加订单管理列表或空态 item。
|
||||
private func appendStoreOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>) {
|
||||
if viewModel.storeOrders.isEmpty {
|
||||
guard !(viewModel.loading && viewModel.storeOrders.isEmpty) else { return }
|
||||
snapshot.appendItems([OrdersItemID.emptyStore], toSection: OrdersSectionID.content)
|
||||
return
|
||||
}
|
||||
snapshot.appendItems(
|
||||
viewModel.storeOrders.map { OrdersItemID.storeOrder($0.orderNumber) },
|
||||
toSection: OrdersSectionID.content
|
||||
)
|
||||
}
|
||||
|
||||
/// 向快照追加核销订单列表或空态 item。
|
||||
private func appendWriteOffOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>) {
|
||||
if viewModel.writeOffOrders.isEmpty {
|
||||
guard !(viewModel.loading && viewModel.writeOffOrders.isEmpty) else { return }
|
||||
snapshot.appendItems([OrdersItemID.emptyWriteOff], toSection: OrdersSectionID.content)
|
||||
return
|
||||
}
|
||||
snapshot.appendItems(
|
||||
viewModel.writeOffOrders.map { OrdersItemID.writeOffOrder($0.orderNumber) },
|
||||
toSection: OrdersSectionID.content
|
||||
)
|
||||
}
|
||||
|
||||
/// 按 item 类型 dequeue 并配置对应 Cell。
|
||||
private func cell(
|
||||
for collectionView: UICollectionView,
|
||||
at indexPath: IndexPath,
|
||||
item: OrdersItem
|
||||
) -> UICollectionViewCell {
|
||||
switch item {
|
||||
case OrdersItemID.header:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: OrdersHeaderCell.reuseID,
|
||||
for: indexPath
|
||||
) as! OrdersHeaderCell
|
||||
cell.configure(
|
||||
selectedEntry: viewModel.selectedEntry,
|
||||
scenicName: appServices.accountContext.currentScenic?.name ?? "--",
|
||||
storeTotal: viewModel.storeTotal,
|
||||
writeOffTotal: viewModel.writeOffTotal,
|
||||
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
|
||||
)
|
||||
return cell
|
||||
|
||||
case OrdersItemID.filter:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: OrdersFilterCell.reuseID,
|
||||
for: indexPath
|
||||
) as! OrdersFilterCell
|
||||
let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
|
||||
cell.configure(
|
||||
statusTitle: statusTitle,
|
||||
phone: viewModel.searchPhone,
|
||||
onStatus: { [weak self] in self?.presentStatusFilter() },
|
||||
onDate: { [weak self] in self?.presentDateFilter() },
|
||||
onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text },
|
||||
onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||||
)
|
||||
return cell
|
||||
|
||||
case OrdersItemID.writeOffAction:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: OrdersWriteOffActionCell.reuseID,
|
||||
for: indexPath
|
||||
) as! OrdersWriteOffActionCell
|
||||
cell.configure(
|
||||
manualOrderNumber: manualOrderNumber,
|
||||
isVerifying: viewModel.isVerifying,
|
||||
hint: scanHintMessage,
|
||||
onScan: { [weak self] in self?.presentScanner() },
|
||||
onManualChange: { [weak self] text in self?.manualOrderNumber = text },
|
||||
onVerify: { [weak self] in
|
||||
guard let self, !self.manualOrderNumber.isEmpty else { return }
|
||||
self.confirmVerify(orderNumber: self.manualOrderNumber)
|
||||
}
|
||||
)
|
||||
return cell
|
||||
|
||||
case OrdersItemID.missingContext:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
|
||||
for: indexPath
|
||||
) as! OrdersEmptyStateCell
|
||||
cell.embed(
|
||||
makeEmptyStateView(
|
||||
title: "缺少经营上下文",
|
||||
message: "请先在首页选择景区后查看订单。",
|
||||
systemImage: "mountain.2"
|
||||
),
|
||||
preferredHeight: 360
|
||||
)
|
||||
return cell
|
||||
|
||||
case OrdersItemID.emptyStore:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
|
||||
for: indexPath
|
||||
) as! OrdersEmptyStateCell
|
||||
cell.embed(
|
||||
makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray"),
|
||||
preferredHeight: 260
|
||||
)
|
||||
return cell
|
||||
|
||||
case OrdersItemID.emptyWriteOff:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
|
||||
for: indexPath
|
||||
) as! OrdersEmptyStateCell
|
||||
cell.embed(
|
||||
makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray"),
|
||||
preferredHeight: 260
|
||||
)
|
||||
return cell
|
||||
|
||||
default:
|
||||
if item.hasPrefix("orders:store:") {
|
||||
let orderNumber = String(item.dropFirst("orders:store:".count))
|
||||
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else {
|
||||
return UICollectionViewCell()
|
||||
}
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: OrderEntityCell.reuseID,
|
||||
for: indexPath
|
||||
) as! OrderEntityCell
|
||||
cell.configure(item: order)
|
||||
return cell
|
||||
}
|
||||
if item.hasPrefix("orders:writeoff:") {
|
||||
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
||||
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else {
|
||||
return UICollectionViewCell()
|
||||
}
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: WriteOffOrderCell.reuseID,
|
||||
for: indexPath
|
||||
) as! WriteOffOrderCell
|
||||
cell.configure(
|
||||
item: order,
|
||||
isVerifying: viewModel.currentVerifyingOrderNumber == order.orderNumber
|
||||
)
|
||||
return cell
|
||||
}
|
||||
return UICollectionViewCell()
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉刷新触发重新加载。
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reload(showLoading: false)
|
||||
@ -75,6 +303,7 @@ final class OrdersViewController: UIViewController {
|
||||
private var currentStoreId: Int? { appServices.accountContext.currentStore?.id }
|
||||
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
||||
|
||||
/// 重新加载当前子入口对应的订单数据。
|
||||
private func reload(showLoading: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") {
|
||||
@ -91,6 +320,7 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动到底部时加载下一页。
|
||||
private func loadMore() async {
|
||||
do {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
@ -111,6 +341,7 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换订单管理 / 核销订单子入口。
|
||||
private func switchEntry(_ entry: OrdersEntry) {
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
@ -118,6 +349,7 @@ final class OrdersViewController: UIViewController {
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
/// 提交订单核销请求。
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
@ -135,12 +367,14 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 消费路由中暂存的扫码结果(核销入口)。
|
||||
private func consumePendingScanCodeIfNeeded() async {
|
||||
guard viewModel.selectedEntry == .verificationOrders,
|
||||
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
|
||||
handleScanResult(code)
|
||||
}
|
||||
|
||||
/// 解析扫码结果并引导核销或提示未匹配。
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||
showToast("未识别到有效订单号")
|
||||
@ -150,10 +384,11 @@ final class OrdersViewController: UIViewController {
|
||||
confirmVerify(orderNumber: parsed.orderNumber)
|
||||
} else {
|
||||
scanHintMessage = "扫码成功,当前列表未找到该订单"
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹出核销确认对话框。
|
||||
private func confirmVerify(orderNumber: String) {
|
||||
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
@ -163,6 +398,7 @@ final class OrdersViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 打开订单号扫码页。
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self, weak scanner] result in
|
||||
@ -180,6 +416,7 @@ final class OrdersViewController: UIViewController {
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出时间筛选快捷选项。
|
||||
private func presentDateFilter() {
|
||||
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
|
||||
@ -198,6 +435,7 @@ final class OrdersViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出订单状态筛选 ActionSheet。
|
||||
private func presentStatusFilter() {
|
||||
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
|
||||
for filter in OrderFilters.statusFilters {
|
||||
@ -211,143 +449,51 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
extension OrdersViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
guard currentScenicId != nil else { return 1 }
|
||||
return viewModel.selectedEntry == .storeOrders ? 3 : 3
|
||||
}
|
||||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == 0 { return 1 }
|
||||
if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 }
|
||||
if currentScenicId == nil { return 1 }
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.section == 0 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell
|
||||
let services = appServices
|
||||
cell.configure(
|
||||
selectedEntry: viewModel.selectedEntry,
|
||||
scenicName: services.accountContext.currentScenic?.name ?? "--",
|
||||
storeTotal: viewModel.storeTotal,
|
||||
writeOffTotal: viewModel.writeOffTotal,
|
||||
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if currentScenicId == nil {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(360)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if indexPath.section == 1 {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell
|
||||
let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
|
||||
cell.configure(
|
||||
statusTitle: statusTitle,
|
||||
phone: viewModel.searchPhone,
|
||||
onStatus: { [weak self] in self?.presentStatusFilter() },
|
||||
onDate: { [weak self] in self?.presentDateFilter() },
|
||||
onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text },
|
||||
onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell
|
||||
cell.configure(
|
||||
manualOrderNumber: manualOrderNumber,
|
||||
isVerifying: viewModel.isVerifying,
|
||||
hint: scanHintMessage,
|
||||
onScan: { [weak self] in self?.presentScanner() },
|
||||
onManualChange: { [weak self] text in self?.manualOrderNumber = text },
|
||||
onVerify: { [weak self] in
|
||||
guard let self, !self.manualOrderNumber.isEmpty else { return }
|
||||
self.confirmVerify(orderNumber: self.manualOrderNumber)
|
||||
}
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
if viewModel.storeOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.storeOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell
|
||||
cell.configure(item: item)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.writeOffOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.writeOffOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell
|
||||
cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 2 else { return }
|
||||
if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count {
|
||||
HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self)
|
||||
} else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count {
|
||||
HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self)
|
||||
extension OrdersViewController: UICollectionViewDelegate {
|
||||
/// 点击订单卡片进入详情。
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
if item.hasPrefix("orders:store:") {
|
||||
let orderNumber = String(item.dropFirst("orders:store:".count))
|
||||
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
||||
HomeMenuRouting.pushOrders(.storeDetail(order), from: self)
|
||||
} else if item.hasPrefix("orders:writeoff:") {
|
||||
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
||||
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
||||
HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 2 else { return }
|
||||
/// 列表即将展示最后一项时触发分页加载。
|
||||
func collectionView(
|
||||
_ collectionView: UICollectionView,
|
||||
willDisplay cell: UICollectionViewCell,
|
||||
forItemAt indexPath: IndexPath
|
||||
) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
let isLast: Bool
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
isLast = indexPath.row == viewModel.storeOrders.count - 1
|
||||
if item.hasPrefix("orders:store:") {
|
||||
let orderNumber = String(item.dropFirst("orders:store:".count))
|
||||
isLast = orderNumber == viewModel.storeOrders.last?.orderNumber
|
||||
} else if item.hasPrefix("orders:writeoff:") {
|
||||
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
||||
isLast = orderNumber == viewModel.writeOffOrders.last?.orderNumber
|
||||
} else {
|
||||
isLast = indexPath.row == viewModel.writeOffOrders.count - 1
|
||||
isLast = false
|
||||
}
|
||||
if isLast {
|
||||
Task { await loadMore() }
|
||||
}
|
||||
if isLast { Task { await loadMore() } }
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if indexPath.section == 0 { return 120 }
|
||||
if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 }
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cells
|
||||
|
||||
private final class OrdersHeaderCell: UITableViewCell {
|
||||
/// 订单页头部 Cell,展示子入口切换与统计摘要。
|
||||
private final class OrdersHeaderCell: UICollectionViewCell {
|
||||
static let reuseID = "OrdersHeaderCell"
|
||||
private var onSelectEntry: ((OrdersEntry) -> Void)?
|
||||
|
||||
@ -356,16 +502,16 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
private let leftPill = UILabel()
|
||||
private let rightPill = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
|
||||
let segmentBackground = UIView()
|
||||
@ -410,7 +556,14 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) {
|
||||
/// 配置头部展示内容与入口切换回调。
|
||||
func configure(
|
||||
selectedEntry: OrdersEntry,
|
||||
scenicName: String,
|
||||
storeTotal: Int,
|
||||
writeOffTotal: Int,
|
||||
onSelectEntry: @escaping (OrdersEntry) -> Void
|
||||
) {
|
||||
self.onSelectEntry = onSelectEntry
|
||||
updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders)
|
||||
updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders)
|
||||
@ -423,6 +576,7 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新分段按钮选中样式。
|
||||
private func updateSegment(_ button: UIButton, title: String, selected: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)
|
||||
@ -431,26 +585,29 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
button.layer.cornerRadius = 6
|
||||
}
|
||||
|
||||
/// 切换到订单管理入口。
|
||||
@objc private func storeTapped() { onSelectEntry?(.storeOrders) }
|
||||
/// 切换到核销订单入口。
|
||||
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
|
||||
}
|
||||
|
||||
private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
||||
/// 订单筛选栏 Cell,承载状态 / 时间与手机号搜索。
|
||||
private final class OrdersFilterCell: UICollectionViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersFilterCell"
|
||||
private let phoneField = UITextField()
|
||||
private var onPhoneChange: ((String) -> Void)?
|
||||
private var onSearch: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
phoneField.delegate = self
|
||||
phoneField.keyboardType = .phonePad
|
||||
@ -470,32 +627,44 @@ private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) {
|
||||
/// 配置筛选栏展示内容与交互回调。
|
||||
func configure(
|
||||
statusTitle: String,
|
||||
phone: String,
|
||||
onStatus: @escaping () -> Void,
|
||||
onDate: @escaping () -> Void,
|
||||
onPhoneChange: @escaping (String) -> Void,
|
||||
onSearch: @escaping () -> Void
|
||||
) {
|
||||
phoneField.text = phone
|
||||
self.onPhoneChange = onPhoneChange
|
||||
self.onSearch = onSearch
|
||||
}
|
||||
|
||||
/// 手机号输入变化时同步到 ViewModel。
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onPhoneChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate {
|
||||
/// 核销操作栏 Cell,承载扫码与手动输入核销。
|
||||
private final class OrdersWriteOffActionCell: UICollectionViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersWriteOffActionCell"
|
||||
private let manualField = UITextField()
|
||||
private var onManualChange: ((String) -> Void)?
|
||||
private var onVerify: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
|
||||
manualField.delegate = self
|
||||
manualField.placeholder = "手动输入订单号"
|
||||
@ -516,33 +685,45 @@ private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelega
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) {
|
||||
/// 配置核销操作栏展示内容与交互回调。
|
||||
func configure(
|
||||
manualOrderNumber: String,
|
||||
isVerifying: Bool,
|
||||
hint: String?,
|
||||
onScan: @escaping () -> Void,
|
||||
onManualChange: @escaping (String) -> Void,
|
||||
onVerify: @escaping () -> Void
|
||||
) {
|
||||
manualField.text = manualOrderNumber
|
||||
self.onManualChange = onManualChange
|
||||
self.onVerify = onVerify
|
||||
}
|
||||
|
||||
/// 手动订单号输入变化时同步到页面状态。
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onManualChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrderEntityCell: UITableViewCell {
|
||||
/// 订单管理列表 Cell,展示单笔订单摘要。
|
||||
private final class OrderEntityCell: UICollectionViewCell {
|
||||
static let reuseID = "OrderEntityCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .default
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||
@ -563,6 +744,7 @@ private final class OrderEntityCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置订单卡片展示内容。
|
||||
func configure(item: OrderEntity) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = item.orderStatusName
|
||||
@ -571,19 +753,23 @@ private final class OrderEntityCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private final class WriteOffOrderCell: UITableViewCell {
|
||||
/// 核销订单列表 Cell,展示单笔核销订单摘要。
|
||||
private final class WriteOffOrderCell: UICollectionViewCell {
|
||||
static let reuseID = "WriteOffOrderCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
||||
@ -597,8 +783,34 @@ private final class WriteOffOrderCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置核销订单卡片展示内容。
|
||||
func configure(item: WriteOffOrderItem, isVerifying: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单列表空态 Cell,复用通用空态视图。
|
||||
private final class OrdersEmptyStateCell: UICollectionViewCell {
|
||||
static let reuseID = "OrdersEmptyStateCell"
|
||||
private var heightConstraint: Constraint?
|
||||
|
||||
/// 初始化 Cell。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 嵌入空态视图并设置占位高度。
|
||||
func embed(_ emptyView: UIView, preferredHeight: CGFloat) {
|
||||
contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
contentView.addSubview(emptyView)
|
||||
emptyView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
heightConstraint = make.height.equalTo(preferredHeight).constraint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +120,7 @@ final class DepositOrderListViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置State状态。
|
||||
private func resetState() {
|
||||
orders = []
|
||||
total = 0
|
||||
@ -170,6 +171,7 @@ final class DepositOrderDetailViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
@ -220,6 +222,7 @@ final class DepositOrderShootingInfoViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
@ -324,10 +327,12 @@ final class OrderRefundViewModel {
|
||||
return moneyText(value)
|
||||
}
|
||||
|
||||
/// decimal值相关逻辑。
|
||||
private static func decimalValue(_ text: String) -> Decimal {
|
||||
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
}
|
||||
|
||||
/// money文本相关逻辑。
|
||||
private static func moneyText(_ value: Decimal) -> String {
|
||||
let number = NSDecimalNumber(decimal: value)
|
||||
let handler = NSDecimalNumberHandler(
|
||||
@ -379,6 +384,7 @@ final class HistoricalShootingInfoViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset(message: String) {
|
||||
projectName = ""
|
||||
projectTypeName = ""
|
||||
@ -520,6 +526,7 @@ final class MultiTravelTaskUploadViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 校验BeforeSubmit输入或状态。
|
||||
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
@ -549,6 +556,7 @@ final class MultiTravelTaskUploadViewModel {
|
||||
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
|
||||
}
|
||||
|
||||
/// upload本地Files相关逻辑。
|
||||
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
|
||||
var uploadFiles: [MultiTravelUploadFileItem] = []
|
||||
for index in selectedLocalFiles.indices {
|
||||
@ -583,11 +591,13 @@ final class MultiTravelTaskUploadViewModel {
|
||||
return uploadFiles
|
||||
}
|
||||
|
||||
/// 更新LocalFileProgress状态。
|
||||
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
||||
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
||||
selectedLocalFiles[index].progress = progress
|
||||
}
|
||||
|
||||
/// 重置SpotSelection状态。
|
||||
private func resetSpotSelection() {
|
||||
spots = []
|
||||
selectedSpotId = nil
|
||||
@ -595,11 +605,13 @@ final class MultiTravelTaskUploadViewModel {
|
||||
isLoadingSpots = false
|
||||
}
|
||||
|
||||
/// fileType相关逻辑。
|
||||
private static func fileType(for fileName: String) -> Int {
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
||||
}
|
||||
|
||||
/// Submit上下文,持有跨页面共享状态。
|
||||
private struct SubmitContext {
|
||||
let scenicId: Int
|
||||
let orderNumber: String
|
||||
|
||||
@ -18,6 +18,7 @@ final class PaymentCollectionViewController: UIViewController {
|
||||
private let remarkField = UITextField()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "收款"
|
||||
@ -27,6 +28,7 @@ final class PaymentCollectionViewController: UIViewController {
|
||||
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
/// 初始化UI相关 UI 或状态。
|
||||
private func setupUI() {
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
statusLabel.numberOfLines = 0
|
||||
@ -56,18 +58,21 @@ final class PaymentCollectionViewController: UIViewController {
|
||||
]
|
||||
}
|
||||
|
||||
/// render 业务逻辑。
|
||||
private func render() {
|
||||
qrImageView.image = viewModel.qrImage
|
||||
statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status)
|
||||
if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||
}
|
||||
|
||||
/// apply金额相关逻辑。
|
||||
@objc private func applyAmount() {
|
||||
viewModel.amountText = amountField.text ?? ""
|
||||
viewModel.remarkText = remarkField.text ?? ""
|
||||
_ = viewModel.applyDynamicAmount()
|
||||
}
|
||||
|
||||
/// togglePolling相关逻辑。
|
||||
@objc private func togglePolling() {
|
||||
Task {
|
||||
await viewModel.pollUntilPaymentDetected(
|
||||
|
||||
@ -10,9 +10,13 @@ import Foundation
|
||||
/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。
|
||||
@MainActor
|
||||
protocol PilotCertificationServing {
|
||||
/// 飞手详情相关逻辑。
|
||||
func flyerDetail() async throws -> FlyerDetailResponse
|
||||
/// 飞手Send验证码相关逻辑。
|
||||
func flyerSendCode(phone: String) async throws
|
||||
/// 飞手提交相关逻辑。
|
||||
func flyerApply(_ request: FlyerApplyRequest) async throws
|
||||
/// 飞手编辑相关逻辑。
|
||||
func flyerEdit(_ request: FlyerEditRequest) async throws
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +48,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
||||
let realnameStatusText: String
|
||||
let certificationLogs: [FlyerCertificationLogItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name = "flyer_nickname"
|
||||
@ -72,6 +73,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
||||
case certificationLogs = "flyers_certification_logs"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
@ -118,6 +120,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
||||
self.certificationLogs = certificationLogs
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -155,6 +158,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case flyerId = "flyer_id"
|
||||
@ -166,6 +170,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
flyerId: Int = 0,
|
||||
@ -186,6 +191,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -218,6 +224,7 @@ struct FlyerApplyRequest: Encodable, Equatable {
|
||||
let contactPhone: String
|
||||
let code: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case realnameStatus = "realname_status"
|
||||
@ -248,6 +255,7 @@ struct FlyerEditRequest: Encodable, Equatable {
|
||||
let contactPhone: String
|
||||
let code: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -277,6 +285,7 @@ enum PilotCertificationValidationError: LocalizedError, Equatable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// pilot解码宽松字符串相关逻辑。
|
||||
func pilotDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -293,6 +302,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// pilot解码宽松整数相关逻辑。
|
||||
func pilotDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
|
||||
@ -13,6 +13,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
private let viewModel = PilotCertificationViewModel()
|
||||
private let statusLabel = UILabel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "飞手认证"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -26,6 +27,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateHeader() }
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.font = .systemFont(ofSize: 14)
|
||||
@ -34,17 +36,21 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = statusLabel
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
/// 返回列表 section 数量。
|
||||
override func numberOfTableSections() -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
/// 返回指定 section 行数。
|
||||
override func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? formRows.count : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
/// section 标题。
|
||||
override func tableSectionTitle(for section: Int) -> String? {
|
||||
section == 0 ? "认证信息" : "操作"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
/// 提供自定义 Cell。
|
||||
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -58,8 +64,8 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
/// 处理行选中。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
if indexPath.section == 1 {
|
||||
Task {
|
||||
do {
|
||||
@ -72,11 +78,13 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI)
|
||||
updateHeader()
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
Task {
|
||||
do {
|
||||
@ -102,6 +110,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
]
|
||||
}
|
||||
|
||||
/// 更新Header状态。
|
||||
private func updateHeader() {
|
||||
let status = viewModel.auditStatusText
|
||||
statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")"
|
||||
|
||||
@ -10,6 +10,7 @@ import Foundation
|
||||
@MainActor
|
||||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||||
protocol PilotRealNameServing {
|
||||
/// realNameInfo相关逻辑。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse
|
||||
}
|
||||
|
||||
@ -167,6 +168,7 @@ final class PilotCertificationViewModel {
|
||||
flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 }
|
||||
}
|
||||
|
||||
/// apply 业务逻辑。
|
||||
private func apply(_ flyer: FlyerDetailResponse) {
|
||||
self.flyer = flyer
|
||||
name = flyer.name
|
||||
@ -183,6 +185,7 @@ final class PilotCertificationViewModel {
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
/// upload待处理Certificate图片相关逻辑。
|
||||
private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||||
guard let data = pendingCertificateImageData else { return }
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
@ -203,6 +206,7 @@ final class PilotCertificationViewModel {
|
||||
pendingCertificateFileName = nil
|
||||
}
|
||||
|
||||
/// 创建ApplyRequest实例。
|
||||
private func makeApplyRequest() -> FlyerApplyRequest {
|
||||
FlyerApplyRequest(
|
||||
name: name.trimmedForPilot,
|
||||
@ -219,6 +223,7 @@ final class PilotCertificationViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建EditRequest实例。
|
||||
private func makeEditRequest() -> FlyerEditRequest {
|
||||
let apply = makeApplyRequest()
|
||||
return FlyerEditRequest(
|
||||
@ -237,6 +242,7 @@ final class PilotCertificationViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// statusName相关逻辑。
|
||||
private static func statusName(for status: Int) -> String {
|
||||
switch status {
|
||||
case 1: "审核中"
|
||||
@ -254,6 +260,7 @@ final class PilotCertificationViewModel {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// date 业务逻辑。
|
||||
private static func date(from value: String) -> Date? {
|
||||
let text = String(value.prefix(10))
|
||||
guard !text.isEmpty else { return nil }
|
||||
@ -261,9 +268,11 @@ final class PilotCertificationViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// PilotEmptyRealName服务协议,定义模块对外能力。
|
||||
private struct PilotEmptyRealNameServing: PilotRealNameServing {
|
||||
let info: RealNameInfo?
|
||||
|
||||
/// realNameInfo相关逻辑。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
RealNameInfoResponse(realNameInfo: info)
|
||||
}
|
||||
|
||||
@ -6,8 +6,18 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
private typealias AccountSwitchSection = Int
|
||||
private typealias AccountSwitchItem = String
|
||||
|
||||
/// 账号切换列表空态标识。
|
||||
private enum AccountSwitchItemID {
|
||||
static let empty = "accountSwitch:empty"
|
||||
}
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
final class AccountSwitchViewController: UIViewController {
|
||||
final class AccountSwitchViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let viewModel = AccountSwitchViewModel()
|
||||
|
||||
@ -15,7 +25,6 @@ final class AccountSwitchViewController: UIViewController {
|
||||
let table = UITableView(frame: .zero, style: .plain)
|
||||
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
||||
return table
|
||||
@ -27,11 +36,16 @@ final class AccountSwitchViewController: UIViewController {
|
||||
return button
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动账号列表与选中态刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "账号切换"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(confirmButton)
|
||||
tableView.snp.makeConstraints { make in
|
||||
@ -45,18 +59,75 @@ final class AccountSwitchViewController: UIViewController {
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.applyTableSnapshot(reconfigure: true)
|
||||
self?.updateConfirmButton()
|
||||
}
|
||||
Task { await loadAccounts() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>(
|
||||
tableView: tableView
|
||||
) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSwitchItem) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
if item == AccountSwitchItemID.empty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(
|
||||
title: "暂无可切换账号",
|
||||
message: "当前登录账号下没有其他可切换账号。",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark"
|
||||
)
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
|
||||
return cell
|
||||
}
|
||||
guard let account = self.viewModel.accounts.first(where: { $0.id == item }) else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
|
||||
cell.configure(
|
||||
account: account,
|
||||
selected: self.viewModel.selectedAccountId == account.id,
|
||||
isCurrent: account.isCurrent || self.isCurrentAccount(account)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem>()
|
||||
snapshot.appendSections([0])
|
||||
if viewModel.accounts.isEmpty, !viewModel.loading {
|
||||
snapshot.appendItems([AccountSwitchItemID.empty], toSection: 0)
|
||||
} else {
|
||||
snapshot.appendItems(viewModel.accounts.map(\.id), toSection: 0)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表。
|
||||
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
|
||||
var snapshot = buildTableSnapshot()
|
||||
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
tableDataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 更新ConfirmButton状态。
|
||||
private func updateConfirmButton() {
|
||||
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||
confirmButton.isEnabled = enabled
|
||||
confirmButton.alpha = enabled ? 1 : 0.5
|
||||
}
|
||||
|
||||
/// 加载Accounts数据。
|
||||
private func loadAccounts(force: Bool = false) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
|
||||
@ -82,10 +153,12 @@ final class AccountSwitchViewController: UIViewController {
|
||||
return current.userId.isEmpty ? nil : current.userId
|
||||
}
|
||||
|
||||
/// 判断isCurrentAccount条件。
|
||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||
}
|
||||
|
||||
/// 点击confirm的处理逻辑。
|
||||
@objc private func confirmTapped() {
|
||||
guard let account = viewModel.selectedAccount else { return }
|
||||
if account.isCurrent || isCurrentAccount(account) {
|
||||
@ -95,6 +168,7 @@ final class AccountSwitchViewController: UIViewController {
|
||||
Task { await switchAccount(account) }
|
||||
}
|
||||
|
||||
/// switch账号相关逻辑。
|
||||
private func switchAccount(_ account: AccountSwitchAccount) async {
|
||||
do {
|
||||
let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
|
||||
@ -121,53 +195,29 @@ final class AccountSwitchViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// non空相关逻辑。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.accounts.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(
|
||||
title: "暂无可切换账号",
|
||||
message: "当前登录账号下没有其他可切换账号。",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark"
|
||||
)
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
|
||||
let account = viewModel.accounts[indexPath.row]
|
||||
cell.configure(
|
||||
account: account,
|
||||
selected: viewModel.selectedAccountId == account.id,
|
||||
isCurrent: account.isCurrent || isCurrentAccount(account)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.row < viewModel.accounts.count else { return }
|
||||
viewModel.select(viewModel.accounts[indexPath.row])
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath),
|
||||
item != AccountSwitchItemID.empty,
|
||||
let account = viewModel.accounts.first(where: { $0.id == item }) else { return }
|
||||
viewModel.select(account)
|
||||
}
|
||||
|
||||
/// UITableView 代理:行高。
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
viewModel.accounts.isEmpty ? 280 : 92
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return 92 }
|
||||
return item == AccountSwitchItemID.empty ? 280 : 92
|
||||
}
|
||||
}
|
||||
|
||||
/// AccountSwitch列表或网格 Cell,负责单项内容展示。
|
||||
private final class AccountSwitchCell: UITableViewCell {
|
||||
static let reuseID = "AccountSwitchCell"
|
||||
|
||||
@ -178,6 +228,7 @@ private final class AccountSwitchCell: UITableViewCell {
|
||||
private let tagLabel = UILabel()
|
||||
private let checkView = UIImageView()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
@ -245,6 +296,7 @@ private final class AccountSwitchCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
||||
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
titleLabel.text = title.isEmpty ? account.accountTypeLabel : title
|
||||
|
||||
@ -7,8 +7,29 @@ import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
private typealias ProfileTableSection = Int
|
||||
private typealias ProfileTableRow = String
|
||||
|
||||
/// 个人信息页 section 索引。
|
||||
private enum ProfileSectionID {
|
||||
static let info = 0
|
||||
static let logout = 1
|
||||
}
|
||||
|
||||
/// 个人信息页行标识。
|
||||
private enum ProfileRowID {
|
||||
static let name = "profile:name"
|
||||
static let account = "profile:account"
|
||||
static let phone = "profile:phone"
|
||||
static let realName = "profile:realName"
|
||||
static let settings = "profile:settings"
|
||||
static let logout = "profile:logout"
|
||||
}
|
||||
|
||||
/// 个人信息页,展示用户资料、账号状态和设置入口。
|
||||
final class ProfileViewController: UIViewController {
|
||||
final class ProfileViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let viewModel = ProfileViewModel()
|
||||
|
||||
@ -19,18 +40,22 @@ final class ProfileViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
/// Diffable 数据源,驱动个人信息列表刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "个人信息"
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
setupHeader()
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
|
||||
@ -49,6 +74,74 @@ final class ProfileViewController: UIViewController {
|
||||
Task { await reloadProfile(showToast: false) }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
|
||||
tableView: tableView
|
||||
) { [weak self] (_: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in
|
||||
guard let self else { return UITableViewCell() }
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
switch row {
|
||||
case ProfileRowID.logout:
|
||||
cell.textLabel?.text = "退出登录"
|
||||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||||
cell.textLabel?.textAlignment = .center
|
||||
cell.selectionStyle = .default
|
||||
case ProfileRowID.name:
|
||||
cell.textLabel?.text = "姓名"
|
||||
cell.detailTextLabel?.text = self.viewModel.displayRealName
|
||||
cell.selectionStyle = .none
|
||||
case ProfileRowID.account:
|
||||
cell.textLabel?.text = "当前账号"
|
||||
cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
case ProfileRowID.phone:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = self.viewModel.displayPhone
|
||||
cell.selectionStyle = .none
|
||||
case ProfileRowID.realName:
|
||||
cell.textLabel?.text = "实名认证"
|
||||
cell.detailTextLabel?.text = self.viewModel.realNameStatusText
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
case ProfileRowID.settings:
|
||||
cell.textLabel?.text = "设置"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
default:
|
||||
break
|
||||
}
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
/// 构建 Diffable snapshot。
|
||||
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<ProfileTableSection, ProfileTableRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<ProfileTableSection, ProfileTableRow>()
|
||||
snapshot.appendSections([ProfileSectionID.info, ProfileSectionID.logout])
|
||||
snapshot.appendItems([
|
||||
ProfileRowID.name,
|
||||
ProfileRowID.account,
|
||||
ProfileRowID.phone,
|
||||
ProfileRowID.realName,
|
||||
ProfileRowID.settings
|
||||
], toSection: ProfileSectionID.info)
|
||||
snapshot.appendItems([ProfileRowID.logout], toSection: ProfileSectionID.logout)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 应用 snapshot 刷新列表,必要时重配 Cell 内容。
|
||||
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
|
||||
var snapshot = buildTableSnapshot()
|
||||
if reconfigure {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
tableDataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
let header = UIView()
|
||||
header.backgroundColor = .white
|
||||
@ -101,6 +194,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// apply视图模型相关逻辑。
|
||||
private func applyViewModel() {
|
||||
nicknameLabel.text = viewModel.displayNickname
|
||||
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
|
||||
@ -111,9 +205,10 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
|
||||
editButton.isEnabled = !viewModel.isSaving
|
||||
tableView.reloadData()
|
||||
applyTableSnapshot(animated: false, reconfigure: true)
|
||||
}
|
||||
|
||||
/// 刷新Pulled展示。
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reloadProfile(showToast: true)
|
||||
@ -121,6 +216,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Profile。
|
||||
private func reloadProfile(showToast: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
|
||||
@ -131,6 +227,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击edit的处理逻辑。
|
||||
@objc private func editTapped() {
|
||||
if viewModel.isEditingProfile {
|
||||
Task { await saveProfileEdits() }
|
||||
@ -140,6 +237,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹出NicknameEditor页面。
|
||||
private func presentNicknameEditor() {
|
||||
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { [weak self] field in
|
||||
@ -157,6 +255,7 @@ final class ProfileViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// save个人中心Edits相关逻辑。
|
||||
private func saveProfileEdits() async {
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
@ -176,6 +275,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// pick头像相关逻辑。
|
||||
@objc private func pickAvatar() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
@ -185,6 +285,7 @@ final class ProfileViewController: UIViewController {
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
/// 点击logout的处理逻辑。
|
||||
@objc private func logoutTapped() {
|
||||
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
@ -201,58 +302,19 @@ final class ProfileViewController: UIViewController {
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 5 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = "退出登录"
|
||||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||||
cell.textLabel?.textAlignment = .center
|
||||
return cell
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.textLabel?.text = "姓名"
|
||||
cell.detailTextLabel?.text = viewModel.displayRealName
|
||||
case 1:
|
||||
cell.textLabel?.text = "当前账号"
|
||||
cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
case 2:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = viewModel.displayPhone
|
||||
case 3:
|
||||
cell.textLabel?.text = "实名认证"
|
||||
cell.detailTextLabel?.text = viewModel.realNameStatusText
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
default:
|
||||
cell.textLabel?.text = "设置"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1 {
|
||||
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch row {
|
||||
case ProfileRowID.logout:
|
||||
logoutTapped()
|
||||
return
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 1:
|
||||
case ProfileRowID.account:
|
||||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
||||
case 3:
|
||||
case ProfileRowID.realName:
|
||||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
||||
case 4:
|
||||
case ProfileRowID.settings:
|
||||
HomeMenuRouting.pushProfile(.settings, from: self)
|
||||
default:
|
||||
break
|
||||
@ -261,6 +323,7 @@ extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
}
|
||||
|
||||
extension ProfileViewController: PHPickerViewControllerDelegate {
|
||||
/// picker 业务逻辑。
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
|
||||
@ -25,6 +25,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
private let startDatePicker = UIDatePicker()
|
||||
private let endDatePicker = UIDatePicker()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "实名认证"
|
||||
@ -34,6 +35,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
Task { await loadInfo() }
|
||||
}
|
||||
|
||||
/// 初始化Form相关 UI 或状态。
|
||||
private func setupForm() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
@ -107,6 +109,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
contentStack.addArrangedSubview(submitButton)
|
||||
}
|
||||
|
||||
/// 创建SectionTitle实例。
|
||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
@ -114,6 +117,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
return label
|
||||
}
|
||||
|
||||
/// labeled字段相关逻辑。
|
||||
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
@ -125,6 +129,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
return stack
|
||||
}
|
||||
|
||||
/// apply视图模型相关逻辑。
|
||||
private func applyViewModel() {
|
||||
realNameField.text = viewModel.realName
|
||||
idCardField.text = viewModel.idCardNo
|
||||
@ -163,6 +168,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载Info数据。
|
||||
private func loadInfo() async {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "加载中...") {
|
||||
@ -173,10 +179,12 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// longValid变更相关逻辑。
|
||||
@objc private func longValidChanged() {
|
||||
viewModel.isLongValid = longValidSwitch.isOn
|
||||
}
|
||||
|
||||
/// 点击send验证码的处理逻辑。
|
||||
@objc private func sendCodeTapped() {
|
||||
Task {
|
||||
do {
|
||||
@ -187,6 +195,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交Tapped。
|
||||
@objc private func submitTapped() {
|
||||
viewModel.realName = realNameField.text ?? ""
|
||||
viewModel.idCardNo = idCardField.text ?? ""
|
||||
@ -215,16 +224,19 @@ final class RealNameAuthViewController: UIViewController {
|
||||
|
||||
private var pendingSide: RealNameImageSide = .front
|
||||
|
||||
/// pickFront相关逻辑。
|
||||
@objc private func pickFront() {
|
||||
pendingSide = .front
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
/// pickBack相关逻辑。
|
||||
@objc private func pickBack() {
|
||||
pendingSide = .back
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
/// 弹出ImagePicker页面。
|
||||
private func presentImagePicker() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
@ -236,6 +248,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||
/// picker 业务逻辑。
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
@ -253,6 +266,7 @@ extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
/// 初始化实例。
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
|
||||
@ -7,64 +7,81 @@ import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
/// 设置页行标识。
|
||||
private enum SettingsRowID {
|
||||
static let about = "settings:about"
|
||||
static let version = "settings:version"
|
||||
static let download = "settings:download"
|
||||
static let userAgreement = "settings:userAgreement"
|
||||
static let privacyPolicy = "settings:privacyPolicy"
|
||||
}
|
||||
|
||||
/// 设置页行操作类型。
|
||||
private enum SettingsRowAction {
|
||||
case agreement(AgreementPage)
|
||||
case version
|
||||
case download
|
||||
}
|
||||
|
||||
/// 设置中心页面。
|
||||
final class SettingsViewController: UIViewController {
|
||||
final class SettingsViewController: SimpleTableDiffableViewController {
|
||||
|
||||
private var copiedDownloadLink = false
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private let rows: [(title: String, action: SettingsRowAction)] = [
|
||||
("关于我们", .agreement(.about)),
|
||||
("系统版本", .version),
|
||||
("App下载", .download),
|
||||
("用户协议", .agreement(.userAgreement)),
|
||||
("隐私政策", .agreement(.privacyPolicy))
|
||||
private let rowActions: [String: SettingsRowAction] = [
|
||||
SettingsRowID.about: .agreement(.about),
|
||||
SettingsRowID.version: .version,
|
||||
SettingsRowID.download: .download,
|
||||
SettingsRowID.userAgreement: .agreement(.userAgreement),
|
||||
SettingsRowID.privacyPolicy: .agreement(.privacyPolicy)
|
||||
]
|
||||
|
||||
private enum SettingsRowAction {
|
||||
case agreement(AgreementPage)
|
||||
case version
|
||||
case download
|
||||
}
|
||||
private let rowTitles: [String: String] = [
|
||||
SettingsRowID.about: "关于我们",
|
||||
SettingsRowID.version: "系统版本",
|
||||
SettingsRowID.download: "App下载",
|
||||
SettingsRowID.userAgreement: "用户协议",
|
||||
SettingsRowID.privacyPolicy: "隐私政策"
|
||||
]
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "设置"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
/// 构建 Diffable snapshot。
|
||||
override func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems([
|
||||
SettingsRowID.about,
|
||||
SettingsRowID.version,
|
||||
SettingsRowID.download,
|
||||
SettingsRowID.userAgreement,
|
||||
SettingsRowID.privacyPolicy
|
||||
], toSection: 0)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func copyDownloadLink() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
showToast("下载链接已复制")
|
||||
tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.copiedDownloadLink = false
|
||||
self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
}
|
||||
/// section 页脚文案。
|
||||
override func sectionFooter(for section: SimpleTableSection) -> String? {
|
||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
let row = rows[indexPath.row]
|
||||
cell.textLabel?.text = row.title
|
||||
/// 配置 Cell 展示内容。
|
||||
override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
|
||||
cell.textLabel?.text = rowTitles[row]
|
||||
cell.textLabel?.textColor = UIColor(hex: 0x4B5563)
|
||||
switch row.action {
|
||||
cell.detailTextLabel?.text = nil
|
||||
cell.detailTextLabel?.textColor = nil
|
||||
cell.accessoryType = .none
|
||||
cell.selectionStyle = .default
|
||||
|
||||
guard let action = rowActions[row] else { return }
|
||||
switch action {
|
||||
case .version:
|
||||
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
|
||||
cell.selectionStyle = .none
|
||||
@ -74,12 +91,12 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
case .agreement:
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch rows[indexPath.row].action {
|
||||
/// 处理行选中。
|
||||
override func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {
|
||||
guard let action = rowActions[row] else { return }
|
||||
switch action {
|
||||
case .agreement(let page):
|
||||
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
||||
case .download:
|
||||
@ -89,8 +106,25 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
}
|
||||
|
||||
/// 复制下载链接并刷新对应行展示。
|
||||
private func copyDownloadLink() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
showToast("下载链接已复制")
|
||||
var snapshot = tableDataSource.snapshot()
|
||||
snapshot.reconfigureItems([SettingsRowID.download])
|
||||
tableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.copiedDownloadLink = false
|
||||
var snapshot = self.tableDataSource.snapshot()
|
||||
snapshot.reconfigureItems([SettingsRowID.download])
|
||||
self.tableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,6 +135,7 @@ final class AgreementViewController: UIViewController {
|
||||
private let webView = WKWebView(frame: .zero)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
/// 初始化实例。
|
||||
init(page: AgreementPage) {
|
||||
self.page = page
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -109,6 +144,7 @@ final class AgreementViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = page.title
|
||||
@ -124,10 +160,12 @@ final class AgreementViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension AgreementViewController: WKNavigationDelegate {
|
||||
/// web视图相关逻辑。
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
/// web视图相关逻辑。
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadingIndicator.stopAnimating()
|
||||
showToast(error.localizedDescription)
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class ProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = ProjectManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "项目管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,13 +24,16 @@ final class ProjectManagementViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
@ -38,10 +42,12 @@ final class ProjectManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
/// 创建项目。
|
||||
@objc private func createProject() {
|
||||
navigationController?.pushViewController(
|
||||
ProjectEditorViewController(projectId: nil),
|
||||
@ -56,19 +62,23 @@ extension ProjectManagementViewModel: ViewModelBindable {}
|
||||
final class StoreProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.filteredItems.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
@ -77,6 +87,7 @@ final class StoreProjectManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, userId: services.userId)
|
||||
}
|
||||
@ -91,6 +102,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
private let photographerViewModel = ProjectManagementViewModel()
|
||||
private let storeViewModel = StoreProjectManagementViewModel()
|
||||
|
||||
/// 初始化实例。
|
||||
init(projectId: Int, storeMode: Bool) {
|
||||
self.projectId = projectId
|
||||
self.storeMode = storeMode
|
||||
@ -102,6 +114,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "项目详情"
|
||||
super.viewDidLoad()
|
||||
@ -116,10 +129,12 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
detail == nil ? 0 : 4
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
guard let detail else { return }
|
||||
switch indexPath.row {
|
||||
@ -130,6 +145,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
if storeMode {
|
||||
await storeViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
||||
@ -147,6 +163,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
private let priceField = UITextField()
|
||||
private let projectId: Int?
|
||||
|
||||
/// 初始化实例。
|
||||
init(projectId: Int?) {
|
||||
self.projectId = projectId
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -157,6 +174,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = projectId == nil ? "新建项目" : "编辑项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -170,6 +188,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() }
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
nameField.placeholder = "项目名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
@ -187,8 +206,10 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { 0 }
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
guard let projectId else { return }
|
||||
if let detail = try? await services.projectAPI.projectDetail(id: projectId) {
|
||||
@ -197,12 +218,14 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需fill表单。
|
||||
private func fillFormIfNeeded() {
|
||||
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
||||
if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText }
|
||||
if priceField.text?.isEmpty != false { priceField.text = viewModel.price }
|
||||
}
|
||||
|
||||
/// save 业务逻辑。
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
viewModel.descriptionText = descriptionField.text ?? ""
|
||||
@ -231,6 +254,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
private let nameField = UITextField()
|
||||
|
||||
/// 初始化实例。
|
||||
init(projectId: Int?) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
@ -240,6 +264,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -256,18 +281,22 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.scenicList.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let scenic = viewModel.scenicList[indexPath.row]
|
||||
cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
guard let userId = Int(services.userId ?? "") else { return }
|
||||
await viewModel.loadScenicList(api: services.projectAPI, userId: userId)
|
||||
}
|
||||
|
||||
/// save 业务逻辑。
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
|
||||
@ -130,6 +130,7 @@ final class ProjectManagementViewModel {
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
/// 重置List状态。
|
||||
private func resetList() {
|
||||
items = []
|
||||
loading = false
|
||||
@ -139,6 +140,7 @@ final class ProjectManagementViewModel {
|
||||
total = 0
|
||||
}
|
||||
|
||||
/// sorted 业务逻辑。
|
||||
private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||
values.sorted { lhs, rhs in
|
||||
if lhs.status != rhs.status { return lhs.status < rhs.status }
|
||||
@ -146,6 +148,7 @@ final class ProjectManagementViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// deduplicated 业务逻辑。
|
||||
private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||
var result: [Int: PhotographerProjectItem] = [:]
|
||||
values.forEach { result[$0.id] = $0 }
|
||||
@ -278,6 +281,7 @@ final class ProjectEditorViewModel {
|
||||
return labels.isEmpty ? nil : labels
|
||||
}
|
||||
|
||||
/// resolveCover链接相关逻辑。
|
||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||
if let coverImage {
|
||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in
|
||||
@ -287,6 +291,7 @@ final class ProjectEditorViewModel {
|
||||
return existingCoverURL
|
||||
}
|
||||
|
||||
/// resolveCarouselURLs相关逻辑。
|
||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||
var urls = existingCarouselURLs
|
||||
for image in carouselImages {
|
||||
@ -298,11 +303,13 @@ final class ProjectEditorViewModel {
|
||||
return urls
|
||||
}
|
||||
|
||||
/// 规范化dMoney格式。
|
||||
private func normalizedMoney(_ value: String) -> String? {
|
||||
let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||
return amount > 0 ? String(format: "%.2f", amount) : nil
|
||||
}
|
||||
|
||||
/// 记录校验失败并返回 false。
|
||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
@ -511,6 +518,7 @@ final class StoreProjectEditorViewModel {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// resolveCover链接相关逻辑。
|
||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||
if let coverImage {
|
||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in }
|
||||
@ -518,6 +526,7 @@ final class StoreProjectEditorViewModel {
|
||||
return existingCoverURL
|
||||
}
|
||||
|
||||
/// resolveCarouselURLs相关逻辑。
|
||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||
var urls = existingCarouselURLs
|
||||
for image in carouselImages {
|
||||
@ -527,6 +536,7 @@ final class StoreProjectEditorViewModel {
|
||||
return urls
|
||||
}
|
||||
|
||||
/// 提交MultiPointProject。
|
||||
private func submitMultiPointProject(
|
||||
userId: Int,
|
||||
api: any ProjectServing,
|
||||
@ -586,6 +596,7 @@ final class StoreProjectEditorViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交OfflineProject。
|
||||
private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws {
|
||||
let scenicId = selectedScenicIds.first ?? 0
|
||||
let storeId = selectedStoreId ?? 0
|
||||
@ -617,6 +628,7 @@ final class StoreProjectEditorViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录校验失败并返回 false。
|
||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
|
||||
@ -36,6 +36,7 @@ struct PunchPointRegion: Codable, Hashable {
|
||||
var address: String
|
||||
var scenicSpotStr: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lat
|
||||
case lot
|
||||
@ -80,6 +81,7 @@ struct PunchPointItem: Decodable, Hashable, Identifiable {
|
||||
let auditTime: String
|
||||
let auditRemark: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
@ -167,6 +169,7 @@ struct AddPunchPointRequest: Encodable, Equatable {
|
||||
let scenicSpotStr: String
|
||||
let guideImages: [String]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case name
|
||||
@ -187,6 +190,7 @@ struct EditPunchPointRequest: Encodable, Equatable {
|
||||
let scenicSpotStr: String
|
||||
let guideImages: [String]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -18,6 +19,7 @@ private extension PunchPointItem {
|
||||
final class PunchPointListViewController: ModuleTableViewController {
|
||||
private let viewModel = PunchPointListViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "打卡点"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -30,13 +32,16 @@ final class PunchPointListViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
@ -45,10 +50,12 @@ final class PunchPointListViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
|
||||
}
|
||||
|
||||
/// 创建打卡Point。
|
||||
@objc private func createPunchPoint() {
|
||||
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
|
||||
}
|
||||
@ -62,6 +69,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
private let summary: PunchPointItem?
|
||||
private let viewModel = PunchPointListViewModel()
|
||||
|
||||
/// 初始化实例。
|
||||
init(punchPointId: Int, summary: PunchPointItem?) {
|
||||
self.punchPointId = punchPointId
|
||||
self.summary = summary
|
||||
@ -73,6 +81,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = summary?.name ?? "打卡点详情"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -88,11 +97,13 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
|
||||
return 4
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let detail = viewModel.selectedDetail ?? summary
|
||||
guard let detail else { return }
|
||||
@ -104,10 +115,12 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
|
||||
}
|
||||
|
||||
/// 展示QR。
|
||||
@objc private func showQR() {
|
||||
guard let detail = viewModel.selectedDetail ?? summary else { return }
|
||||
navigationController?.pushViewController(
|
||||
@ -118,10 +131,17 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
|
||||
/// 打卡点编辑页。
|
||||
final class PunchPointEditorViewController: ModuleTableViewController {
|
||||
final class PunchPointEditorViewController: UIViewController {
|
||||
private let viewModel = PunchPointEditorViewModel()
|
||||
private let nameField = UITextField()
|
||||
private let punchPointId: Int?
|
||||
private let nameField = UITextField()
|
||||
private let addressField = UITextField()
|
||||
private let latitudeField = UITextField()
|
||||
private let longitudeField = UITextField()
|
||||
private let mapPicker = PunchPointMapPickerView()
|
||||
private let locationProvider = ForegroundLocationProvider()
|
||||
|
||||
private var services: AppServices { AppServices.shared }
|
||||
|
||||
init(punchPointId: Int?) {
|
||||
self.punchPointId = punchPointId
|
||||
@ -134,48 +154,126 @@ final class PunchPointEditorViewController: ModuleTableViewController {
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "保存",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "打卡点名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { [weak self] in
|
||||
if self?.nameField.text?.isEmpty != false {
|
||||
self?.nameField.text = self?.viewModel.name
|
||||
|
||||
configureFields()
|
||||
layoutForm()
|
||||
wireViewModel()
|
||||
|
||||
mapPicker.onLocationPicked = { [weak self] lat, lng, address in
|
||||
self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address)
|
||||
self?.syncFieldsFromViewModel()
|
||||
}
|
||||
|
||||
Task {
|
||||
guard let punchPointId else { return }
|
||||
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
|
||||
viewModel.apply(detail)
|
||||
syncFieldsFromViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { 1 }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
cell.configure(title: "地址", subtitle: viewModel.address)
|
||||
private func configureFields() {
|
||||
[nameField, addressField, latitudeField, longitudeField].forEach {
|
||||
$0.borderStyle = .roundedRect
|
||||
$0.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
}
|
||||
nameField.placeholder = "打卡点名称"
|
||||
addressField.placeholder = "地址"
|
||||
latitudeField.placeholder = "纬度"
|
||||
longitudeField.placeholder = "经度"
|
||||
latitudeField.keyboardType = .decimalPad
|
||||
longitudeField.keyboardType = .decimalPad
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
guard let punchPointId else { return }
|
||||
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
|
||||
viewModel.apply(detail)
|
||||
nameField.text = viewModel.name
|
||||
private func layoutForm() {
|
||||
let locateButton = UIButton(type: .system)
|
||||
locateButton.setTitle("使用当前位置", for: .normal)
|
||||
locateButton.addTarget(self, action: #selector(useCurrentLocation), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
nameField,
|
||||
mapPicker,
|
||||
addressField,
|
||||
latitudeField,
|
||||
longitudeField,
|
||||
locateButton
|
||||
])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
|
||||
view.addSubview(stack)
|
||||
nameField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
mapPicker.snp.makeConstraints { make in make.height.equalTo(220) }
|
||||
addressField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
latitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
longitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
locateButton.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.medium)
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func wireViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.syncFieldsFromViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
private func syncFieldsFromViewModel() {
|
||||
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
||||
addressField.text = viewModel.address
|
||||
latitudeField.text = viewModel.latitudeText
|
||||
longitudeField.text = viewModel.longitudeText
|
||||
if let lat = Double(viewModel.latitudeText), let lng = Double(viewModel.longitudeText) {
|
||||
mapPicker.setCoordinate(latitude: lat, longitude: lng)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func useCurrentLocation() {
|
||||
Task {
|
||||
do {
|
||||
let result = try await locationProvider.requestCurrentLocation()
|
||||
viewModel.applyLocation(
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
address: result.address
|
||||
)
|
||||
syncFieldsFromViewModel()
|
||||
} catch {
|
||||
services.toastCenter.show("定位失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
viewModel.address = addressField.text ?? ""
|
||||
viewModel.latitudeText = latitudeField.text ?? ""
|
||||
viewModel.longitudeText = longitudeField.text ?? ""
|
||||
Task {
|
||||
let success = await viewModel.submit(
|
||||
scenicId: services.currentScenicId,
|
||||
api: services.punchPointAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success { navigationController?.popViewController(animated: true) }
|
||||
if success {
|
||||
services.toastCenter.show("保存成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -187,6 +285,7 @@ final class PunchPointQRViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let qrURL: String
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String, qrURL: String) {
|
||||
pageTitle = title
|
||||
self.qrURL = qrURL
|
||||
@ -198,6 +297,7 @@ final class PunchPointQRViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = pageTitle
|
||||
|
||||
@ -10,17 +10,29 @@ import Foundation
|
||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||
@MainActor
|
||||
protocol ScenicQueueServing: AnyObject {
|
||||
/// scenic排队统计相关逻辑。
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData
|
||||
/// scenic排队Home相关逻辑。
|
||||
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
|
||||
/// socket令牌相关逻辑。
|
||||
func socketToken() async throws -> SocketTokenResponse
|
||||
/// scenic排队Call相关逻辑。
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
|
||||
/// scenic排队Pass相关逻辑。
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
|
||||
/// scenic排队Finish相关逻辑。
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
|
||||
/// scenic排队RequeueInsertBefore相关逻辑。
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
|
||||
/// scenic排队UserMark相关逻辑。
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
|
||||
/// scenic排队Setting相关逻辑。
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
|
||||
/// scenic排队保存Setting相关逻辑。
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
|
||||
/// scenic排队Shoot排队QRCode相关逻辑。
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
|
||||
/// scenic排队SettingChangeLog相关逻辑。
|
||||
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ struct ScenicQueueStatsData: Decodable, Equatable {
|
||||
let avgWaitMin: Double
|
||||
let time: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case queueCount = "queue_count"
|
||||
case avgWaitMin = "avg_wait_min"
|
||||
@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
|
||||
let list: ScenicQueueHomeListBlock?
|
||||
let time: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case stats
|
||||
case list
|
||||
@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
|
||||
struct ScenicQueueHomeStats: Decodable, Equatable {
|
||||
let type: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
}
|
||||
@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable {
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case list
|
||||
case total
|
||||
@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
||||
isMissRequeue == 1
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case queueCode = "queue_code"
|
||||
@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
||||
case missRequeueText = "is_miss_requeue_text"
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum AlternateCodingKeys: String, CodingKey {
|
||||
case queueNo = "queue_no"
|
||||
case queueNumber = "queue_number"
|
||||
@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
||||
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 格式化排队时间。
|
||||
private static func formatQueueTime(_ raw: String) -> String {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return "--" }
|
||||
@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable {
|
||||
struct SocketTokenResponse: Decodable, Equatable {
|
||||
let socketToken: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case socketToken = "socket_token"
|
||||
}
|
||||
@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable {
|
||||
let statusText: String
|
||||
let calledAt: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case status
|
||||
@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
|
||||
let recordId: Int64
|
||||
let operatorId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case recordId = "record_id"
|
||||
case operatorId = "operator_id"
|
||||
@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable {
|
||||
let queueBanDays: Int?
|
||||
let operatorId: Int?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid
|
||||
case scenicId = "scenic_id"
|
||||
@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 解码宽松字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -449,6 +461,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 解码宽松整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
@ -467,6 +480,7 @@ private extension KeyedDecodingContainer {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 解码宽松Double。
|
||||
func decodeLossyDouble(forKey key: Key) throws -> Double? {
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return value
|
||||
@ -484,6 +498,7 @@ private extension KeyedDecodingContainer {
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// non空或默认相关逻辑。
|
||||
func nonEmptyOrDefault(_ fallback: String) -> String {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? fallback : text
|
||||
|
||||
@ -13,6 +13,7 @@ struct ScenicQueueSettingData: Decodable, Equatable {
|
||||
let setting: ScenicQueueSettingItem?
|
||||
let time: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case exists
|
||||
case setting
|
||||
@ -64,6 +65,7 @@ struct ScenicQueueSettingItem: Decodable, Equatable {
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicId = "scenic_id"
|
||||
@ -131,6 +133,7 @@ struct ScenicQueueVoiceBroadcastItem: Codable, Identifiable, Equatable {
|
||||
let content: String
|
||||
let sortOrder: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case content
|
||||
case sortOrder = "sort_order"
|
||||
@ -175,6 +178,7 @@ struct ScenicQueueSaveSettingRequest: Encodable, Equatable {
|
||||
let status: Int
|
||||
let remark: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
@ -208,6 +212,7 @@ struct ScenicQueueSettingChangeLogData: Decodable, Equatable {
|
||||
let pageSize: Int
|
||||
let list: [ScenicQueueSettingChangeLogItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case page
|
||||
@ -243,6 +248,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable {
|
||||
let displayText: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
@ -270,6 +276,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable {
|
||||
struct ScenicQueueShootQueueQRCodeData: Decodable, Equatable {
|
||||
let qrcodeUrl: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case qrcodeUrl = "qrcode_url"
|
||||
}
|
||||
@ -309,6 +316,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable {
|
||||
var businessStartTime: String = "10:00"
|
||||
var businessEndTime: String = "20:00"
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case shootMinute = "shoot_minute"
|
||||
case shootSecond = "shoot_second"
|
||||
@ -334,6 +342,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 解码宽松字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) { return value }
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) }
|
||||
@ -342,6 +351,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 解码宽松整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value }
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) }
|
||||
@ -354,6 +364,7 @@ private extension KeyedDecodingContainer {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 解码宽松布尔。
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value }
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 }
|
||||
|
||||
@ -155,17 +155,20 @@ enum ScenicQueueSettingsStore {
|
||||
UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)")
|
||||
}
|
||||
|
||||
/// 规范化dUserId格式。
|
||||
private static func normalizedUserId(_ userId: String?) -> String {
|
||||
let text = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? "anonymous" : text
|
||||
}
|
||||
|
||||
/// positive整数相关逻辑。
|
||||
private static func positiveInt(forKey key: String) -> Int? {
|
||||
guard UserDefaults.standard.object(forKey: key) != nil else { return nil }
|
||||
let value = UserDefaults.standard.integer(forKey: key)
|
||||
return value > 0 ? value : nil
|
||||
}
|
||||
|
||||
/// legacy正数整数相关逻辑。
|
||||
private static func legacyPositiveInt(forKey key: String) -> Int? {
|
||||
positiveInt(forKey: key)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class QueueManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = QueueManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "排队管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,13 +24,16 @@ final class QueueManagementViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateTitle() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.phoneMasked, subtitle: item.statusText, detail: item.queueCode)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(
|
||||
api: services.scenicQueueAPI,
|
||||
@ -40,10 +44,12 @@ final class QueueManagementViewController: ModuleTableViewController {
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
/// 更新Title状态。
|
||||
private func updateTitle() {
|
||||
title = "排队 \(viewModel.queueCount)"
|
||||
}
|
||||
|
||||
/// open设置相关逻辑。
|
||||
@objc private func openSettings() {
|
||||
navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true)
|
||||
}
|
||||
@ -55,14 +61,17 @@ extension QueueManagementViewModel: ViewModelBindable {}
|
||||
final class ScenicQueueSettingsViewController: ModuleTableViewController {
|
||||
private let viewModel = ScenicQueueSettingsViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "排队设置"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.scenicSpots.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let spot = viewModel.scenicSpots[indexPath.row]
|
||||
let selected = viewModel.selectedSpotId == spot.id
|
||||
@ -70,10 +79,12 @@ final class ScenicQueueSettingsViewController: ModuleTableViewController {
|
||||
cell.accessoryType = selected ? .checkmark : .none
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
viewModel.selectedSpotId = viewModel.scenicSpots[indexPath.row].id
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(
|
||||
api: services.scenicQueueAPI,
|
||||
|
||||
@ -209,6 +209,7 @@ final class QueueManagementViewModel {
|
||||
return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0)
|
||||
}
|
||||
|
||||
/// 处理SocketMessage相关事件。
|
||||
private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
|
||||
guard message.isScenicQueueEvent,
|
||||
let params = message.data?.params,
|
||||
@ -227,6 +228,7 @@ final class QueueManagementViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理RemoteTicketCalled相关事件。
|
||||
private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
|
||||
guard let recordId = params.recordId, recordId > 0 else { return }
|
||||
if let operatorUid = params.operatorUid,
|
||||
@ -251,6 +253,7 @@ final class QueueManagementViewModel {
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
|
||||
/// 加载QueueingTickets数据。
|
||||
private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] {
|
||||
let home = try await api.scenicQueueHome(
|
||||
scenicId: scenicId,
|
||||
@ -262,6 +265,7 @@ final class QueueManagementViewModel {
|
||||
return home.list?.list ?? []
|
||||
}
|
||||
|
||||
/// consume待处理远程CalledTickets相关逻辑。
|
||||
private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) {
|
||||
guard let ticket = tickets.first(where: {
|
||||
pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号"))
|
||||
@ -272,6 +276,7 @@ final class QueueManagementViewModel {
|
||||
remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode)
|
||||
}
|
||||
|
||||
/// 加载Page数据。
|
||||
private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws {
|
||||
guard let scenicSpotId = selectedSpotId else { return }
|
||||
let home = try await api.scenicQueueHome(
|
||||
@ -300,6 +305,7 @@ final class QueueManagementViewModel {
|
||||
refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId)
|
||||
}
|
||||
|
||||
/// mark排队Called相关逻辑。
|
||||
private func markQueueCalled(id: Int64, statusText: String) {
|
||||
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
items = items.map { item in
|
||||
@ -330,6 +336,7 @@ final class QueueManagementViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset() {
|
||||
stopRealtime()
|
||||
loading = false
|
||||
@ -343,6 +350,7 @@ final class QueueManagementViewModel {
|
||||
clearQueueData()
|
||||
}
|
||||
|
||||
/// 清空排队数据。
|
||||
private func clearQueueData() {
|
||||
loadingMore = false
|
||||
items = []
|
||||
@ -354,6 +362,7 @@ final class QueueManagementViewModel {
|
||||
lastSyncTimeText = "--"
|
||||
}
|
||||
|
||||
/// 刷新ShootingCallConfig展示。
|
||||
private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) {
|
||||
let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
|
||||
showStartShootingButton = snapshot.showStartShootingButton
|
||||
@ -362,6 +371,7 @@ final class QueueManagementViewModel {
|
||||
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
|
||||
}
|
||||
|
||||
/// 同步QueueSettingFromServerIfMatchesLocal状态。
|
||||
private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async {
|
||||
guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId),
|
||||
data.exists,
|
||||
@ -648,6 +658,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
presetVoices.remove(at: index)
|
||||
}
|
||||
|
||||
/// 重置SettingsState状态。
|
||||
private func resetSettingsState() {
|
||||
loading = false
|
||||
scenicSpots = []
|
||||
@ -678,6 +689,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
qrcodeURL = ""
|
||||
}
|
||||
|
||||
/// apply 业务逻辑。
|
||||
private func apply(_ setting: ScenicQueueSettingItem?) {
|
||||
guard let setting else { return }
|
||||
if let spotId = setting.scenicSpotId { selectedSpotId = spotId }
|
||||
@ -707,6 +719,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) }
|
||||
}
|
||||
|
||||
/// apply 业务逻辑。
|
||||
private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
|
||||
photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))"
|
||||
photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))"
|
||||
@ -730,6 +743,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
|
||||
}
|
||||
|
||||
/// 规范化dMaxQueueRangeForDisplay格式。
|
||||
private func normalizedMaxQueueRangeForDisplay() throws -> String {
|
||||
let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm)
|
||||
guard !normalized.isEmpty,
|
||||
@ -745,12 +759,14 @@ final class ScenicQueueSettingsViewModel {
|
||||
return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
|
||||
}
|
||||
|
||||
/// queueDistanceMeter用于提交相关逻辑。
|
||||
private func queueDistanceMeterForSubmit() throws -> Int {
|
||||
_ = try normalizedMaxQueueRangeForDisplay()
|
||||
let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0
|
||||
return NSDecimalNumber(decimal: value * Decimal(1000)).intValue
|
||||
}
|
||||
|
||||
/// 加载QRCodeImage数据。
|
||||
private func loadQRCodeImage(from text: String) async throws -> UIImage {
|
||||
if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
@ -760,6 +776,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
throw APIError.networkFailed("二维码图片生成失败")
|
||||
}
|
||||
|
||||
/// generateQRCode相关逻辑。
|
||||
private static func generateQRCode(from string: String) -> UIImage? {
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(string.utf8)
|
||||
@ -770,12 +787,14 @@ final class ScenicQueueSettingsViewModel {
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
/// saveTiming快照相关逻辑。
|
||||
private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) {
|
||||
UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey)
|
||||
UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey)
|
||||
UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey)
|
||||
}
|
||||
|
||||
/// saveShootingCall设置相关逻辑。
|
||||
private func saveShootingCallSettings(autoCallAhead: Int) {
|
||||
UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey)
|
||||
UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey)
|
||||
@ -783,10 +802,12 @@ final class ScenicQueueSettingsViewModel {
|
||||
UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey)
|
||||
}
|
||||
|
||||
/// save自定义文本Locally相关逻辑。
|
||||
private func saveCustomTextLocally() {
|
||||
ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId)
|
||||
}
|
||||
|
||||
/// voiceBroadcasts用于保存载荷相关逻辑。
|
||||
private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] {
|
||||
Array(presetVoices
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
@ -796,6 +817,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
.map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) })
|
||||
}
|
||||
|
||||
/// 创建Snapshot实例。
|
||||
private func makeSnapshot(
|
||||
min: Int? = nil,
|
||||
sec: Int? = nil,
|
||||
@ -832,18 +854,22 @@ final class ScenicQueueSettingsViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// coerced播报间隔相关逻辑。
|
||||
static func coercedBroadcastInterval(_ raw: Int) -> Int {
|
||||
(40...60).contains(raw) ? raw : 50
|
||||
}
|
||||
|
||||
/// coerced倒计时阈值相关逻辑。
|
||||
static func coercedCountdownThreshold(_ raw: Int) -> Int {
|
||||
(10...30).contains(raw) ? raw : 15
|
||||
}
|
||||
|
||||
/// business时间相关逻辑。
|
||||
static func businessTime(hour: Int, minute: Int) -> Date {
|
||||
Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date()
|
||||
}
|
||||
|
||||
/// 解析BusinessTime数据。
|
||||
static func parseBusinessTime(_ raw: String, fallback: Date) -> Date {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return fallback }
|
||||
@ -856,6 +882,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// business时间载荷相关逻辑。
|
||||
static func businessTimePayload(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
@ -863,6 +890,7 @@ final class ScenicQueueSettingsViewModel {
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
/// queueDistanceDisplay相关逻辑。
|
||||
private static func queueDistanceDisplay(meters: Int) -> String {
|
||||
let clamped = min(max(meters, 0), 9_999_990)
|
||||
return String(format: "%.2f", Double(clamped) / 1000)
|
||||
@ -875,6 +903,7 @@ private extension String {
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// non空或默认相关逻辑。
|
||||
func nonEmptyOrDefault(_ fallback: String) -> String {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? fallback : text
|
||||
|
||||
@ -14,6 +14,7 @@ struct ScenicAreaNode: Decodable, Equatable, Identifiable {
|
||||
let name: String
|
||||
let children: [ScenicAreaNode]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case code
|
||||
@ -44,6 +45,7 @@ struct ScenicAreaNode: Decodable, Equatable, Identifiable {
|
||||
struct ScenicApplicationPendingsResponse: Decodable, Equatable {
|
||||
let items: [ScenicApplicationPendingResponse]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case list
|
||||
@ -83,6 +85,7 @@ struct ScenicApplicationPendingResponse: Decodable, Equatable, Identifiable {
|
||||
let auditNote: String?
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case code
|
||||
@ -171,6 +174,7 @@ struct ScenicApplicationSubmitRequest: Encodable, Equatable {
|
||||
let remark: String
|
||||
let scenicId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicName = "scenic_name"
|
||||
case scenicImages = "scenic_images"
|
||||
@ -196,6 +200,7 @@ struct RoleApplyPendingResponse: Decodable, Equatable, Hashable, Identifiable {
|
||||
let auditedAt: String?
|
||||
let auditNote: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case code
|
||||
@ -259,6 +264,7 @@ struct RoleApplyScenicItem: Decodable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -283,6 +289,7 @@ struct RoleApplySubmitRequest: Encodable, Equatable {
|
||||
let scenicId: [Int]
|
||||
let roleId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case roleId = "role_id"
|
||||
@ -295,6 +302,7 @@ struct ScenicApplicationUploadPlaceholder: Encodable, Equatable {
|
||||
let fileType: String
|
||||
let fileSize: Int64
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileType = "file_type"
|
||||
|
||||
@ -5,20 +5,44 @@
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
/// 景区选择页。
|
||||
final class ScenicSelectionViewController: ModuleTableViewController {
|
||||
private let viewModel = ScenicSelectionViewModel()
|
||||
private let searchBar = UISearchBar()
|
||||
private let locationProvider = ScenicSelectionLocationProvider()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "选择景区"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "定位",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(requestLocation)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
setupSearchBar()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
@objc private func requestLocation() {
|
||||
viewModel.currentLocationText = "定位中..."
|
||||
locationProvider.onLocation = { [weak self] location in
|
||||
self?.viewModel.applyCurrentLocation(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude
|
||||
)
|
||||
}
|
||||
locationProvider.onFailure = { [weak self] message in
|
||||
self?.services.toastCenter.show(message)
|
||||
}
|
||||
locationProvider.request()
|
||||
}
|
||||
|
||||
/// 初始化SearchBar相关 UI 或状态。
|
||||
private func setupSearchBar() {
|
||||
searchBar.placeholder = "搜索景区"
|
||||
searchBar.delegate = self
|
||||
@ -26,15 +50,18 @@ final class ScenicSelectionViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = searchBar
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.filteredItems.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.address, detail: item.distanceText)
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
viewModel.select(
|
||||
@ -46,6 +73,7 @@ final class ScenicSelectionViewController: ModuleTableViewController {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
viewModel.reload(from: services.accountContext)
|
||||
}
|
||||
@ -54,6 +82,7 @@ final class ScenicSelectionViewController: ModuleTableViewController {
|
||||
extension ScenicSelectionViewModel: ViewModelBindable {}
|
||||
|
||||
extension ScenicSelectionViewController: UISearchBarDelegate {
|
||||
/// searchBar相关逻辑。
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
viewModel.searchQuery = searchText
|
||||
}
|
||||
@ -63,6 +92,7 @@ extension ScenicSelectionViewController: UISearchBarDelegate {
|
||||
final class PermissionApplyViewController: ModuleTableViewController {
|
||||
private let viewModel = PermissionApplyViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "权限申请"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -75,17 +105,21 @@ final class PermissionApplyViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
/// 返回列表 section 数量。
|
||||
override func numberOfTableSections() -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
/// 返回指定 section 行数。
|
||||
override func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? viewModel.roleOptions.count : viewModel.scenicOptions.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
/// section 标题。
|
||||
override func tableSectionTitle(for section: Int) -> String? {
|
||||
section == 0 ? "选择角色" : "选择景区"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
/// 提供自定义 Cell。
|
||||
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -102,8 +136,8 @@ final class PermissionApplyViewController: ModuleTableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
/// 处理行选中。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
if indexPath.section == 0 {
|
||||
viewModel.selectRole(id: viewModel.roleOptions[indexPath.row].id)
|
||||
Task { await viewModel.loadScenicListIfNeeded(api: services.scenicPermissionAPI, force: true) }
|
||||
@ -112,6 +146,7 @@ final class PermissionApplyViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
viewModel.bootstrap(rolePermissions: services.permissionContext.rolePermissions)
|
||||
if viewModel.selectedRoleId != nil {
|
||||
@ -119,6 +154,7 @@ final class PermissionApplyViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
Task {
|
||||
await viewModel.submit(api: services.scenicPermissionAPI)
|
||||
@ -138,16 +174,19 @@ extension PermissionApplyViewModel: ViewModelBindable {}
|
||||
final class PermissionApplyStatusViewController: ModuleTableViewController {
|
||||
private let viewModel = PermissionApplyStatusViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "申请状态"
|
||||
super.viewDidLoad()
|
||||
viewModel.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.pending == nil ? 0 : 4
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
guard let pending = viewModel.pending else { return }
|
||||
switch indexPath.row {
|
||||
@ -158,6 +197,7 @@ final class PermissionApplyStatusViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.scenicPermissionAPI, applyCode: nil)
|
||||
}
|
||||
@ -168,6 +208,7 @@ final class ScenicApplicationViewController: ModuleTableViewController {
|
||||
private let viewModel = ScenicApplicationViewModel()
|
||||
private let nameField = UITextField()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "景区申请"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -188,8 +229,10 @@ final class ScenicApplicationViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { 3 }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "省份", subtitle: viewModel.selectedProvince)
|
||||
@ -198,10 +241,12 @@ final class ScenicApplicationViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadInitial(api: services.scenicPermissionAPI)
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
viewModel.scenicName = nameField.text ?? ""
|
||||
Task {
|
||||
|
||||
@ -20,6 +20,7 @@ struct ScenicSettlementSubmitRequest: Encodable, Equatable {
|
||||
let applyAmount: String
|
||||
let applyRemark: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case applyAmount = "apply_amount"
|
||||
|
||||
@ -13,6 +13,7 @@ final class ScenicSettlementViewController: ModuleTableViewController {
|
||||
private let amountField = UITextField()
|
||||
private let remarkField = UITextField()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "景区结算"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -26,6 +27,7 @@ final class ScenicSettlementViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
amountField.placeholder = "结算金额"
|
||||
amountField.borderStyle = .roundedRect
|
||||
@ -41,20 +43,24 @@ final class ScenicSettlementViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.options.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let option = viewModel.options[indexPath.row]
|
||||
cell.configure(title: option.name, subtitle: option.selected ? "已选择" : nil)
|
||||
cell.accessoryType = option.selected ? .checkmark : .none
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
viewModel.toggleScenic(id: viewModel.options[indexPath.row].id)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(
|
||||
api: services.scenicPermissionAPI,
|
||||
@ -62,6 +68,7 @@ final class ScenicSettlementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
viewModel.amountText = amountField.text ?? ""
|
||||
viewModel.remarkText = remarkField.text ?? ""
|
||||
@ -83,23 +90,28 @@ extension ScenicSettlementViewModel: ViewModelBindable {}
|
||||
final class ScenicSettlementReviewViewController: ModuleTableViewController {
|
||||
private let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "结算审核"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
/// 返回列表 section 数量。
|
||||
override func numberOfTableSections() -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
/// 返回指定 section 行数。
|
||||
override func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? viewModel.scenicApplications.count : viewModel.roleApplications.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
/// section 标题。
|
||||
override func tableSectionTitle(for section: Int) -> String? {
|
||||
section == 0 ? "景区申请" : "权限申请"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
/// 提供自定义 Cell。
|
||||
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -122,6 +134,7 @@ final class ScenicSettlementReviewViewController: ModuleTableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.scenicPermissionAPI)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class ScheduleManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = ScheduleManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "排班管理"
|
||||
navigationItem.rightBarButtonItems = [
|
||||
@ -27,10 +28,12 @@ final class ScheduleManagementViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateTitle() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.items.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(
|
||||
@ -40,6 +43,7 @@ final class ScheduleManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
let alert = UIAlertController(title: item.name, message: "删除该排班?", preferredStyle: .alert)
|
||||
@ -51,24 +55,29 @@ final class ScheduleManagementViewController: ModuleTableViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.scheduleAPI, scenicId: services.currentScenicId)
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
/// 更新Title状态。
|
||||
private func updateTitle() {
|
||||
let month = viewModel.monthDate.scheduleYearMonthText
|
||||
title = "排班 \(month)"
|
||||
}
|
||||
|
||||
/// previousMonth相关逻辑。
|
||||
@objc private func previousMonth() {
|
||||
Task { await viewModel.previousMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
/// nextMonth相关逻辑。
|
||||
@objc private func nextMonth() {
|
||||
Task { await viewModel.nextMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
/// add排班相关逻辑。
|
||||
@objc private func addSchedule() {
|
||||
navigationController?.pushViewController(ScheduleAddViewController(), animated: true)
|
||||
}
|
||||
@ -82,6 +91,7 @@ final class ScheduleAddViewController: ModuleTableViewController {
|
||||
private let nameField = UITextField()
|
||||
private let remarkField = UITextField()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "新增排班"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -95,6 +105,7 @@ final class ScheduleAddViewController: ModuleTableViewController {
|
||||
setupFormHeader()
|
||||
}
|
||||
|
||||
/// 初始化FormHeader相关 UI 或状态。
|
||||
private func setupFormHeader() {
|
||||
nameField.placeholder = "日程名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
@ -109,10 +120,12 @@ final class ScheduleAddViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.availableOrders.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let order = viewModel.availableOrders[indexPath.row]
|
||||
let selected = viewModel.draft.selectedOrder?.orderNumber == order.orderNumber
|
||||
@ -120,15 +133,18 @@ final class ScheduleAddViewController: ModuleTableViewController {
|
||||
cell.accessoryType = selected ? .checkmark : .none
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
viewModel.draft.selectedOrder = viewModel.availableOrders[indexPath.row]
|
||||
reloadTable()
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadOrders(api: services.scheduleAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
viewModel.draft.name = nameField.text ?? ""
|
||||
viewModel.draft.remark = remarkField.text ?? ""
|
||||
|
||||
@ -99,6 +99,7 @@ final class ScheduleManagementViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset() {
|
||||
markedDays = []
|
||||
items = []
|
||||
@ -174,6 +175,7 @@ final class ScheduleAddViewModel {
|
||||
return text
|
||||
}
|
||||
|
||||
/// 记录校验失败并返回 false。
|
||||
private func fail(_ error: ScheduleValidationError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
|
||||
@ -80,7 +80,7 @@ enum StatisticsPeriod: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
/// 数据统计汇总实体,表示订单金额、数量、客单价、实收和退款。
|
||||
struct StatisticsSummaryResponse: Decodable, Equatable {
|
||||
struct StatisticsSummaryResponse: Decodable, Equatable, Hashable {
|
||||
let orderAmountSum: String
|
||||
let orderCount: Int
|
||||
let orderPriceAverage: String
|
||||
@ -92,6 +92,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable {
|
||||
var receivedAmountValue: Double { Self.parseAmount(receivedAmountSum) }
|
||||
var refundAmountValue: Double { Self.parseAmount(refundAmountSum) }
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderAmountSum = "order_amount_sum"
|
||||
case orderCount = "order_count"
|
||||
@ -125,6 +126,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable {
|
||||
refundAmountSum = try container.decodeLossyString(forKey: .refundAmountSum)
|
||||
}
|
||||
|
||||
/// 解析Amount数据。
|
||||
private static func parseAmount(_ text: String) -> Double {
|
||||
let normalized = text.filter { "0123456789.-".contains($0) }
|
||||
return Double(normalized) ?? 0
|
||||
@ -132,7 +134,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable {
|
||||
}
|
||||
|
||||
/// 每日统计实体,表示某天订单数、客单价、退款和实收。
|
||||
struct StatisticsDailyItem: Decodable, Identifiable, Equatable {
|
||||
struct StatisticsDailyItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { date }
|
||||
|
||||
let date: String
|
||||
@ -145,6 +147,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable {
|
||||
var refundValue: Double { Self.parseAmount(refund) }
|
||||
var receivedValue: Double { Self.parseAmount(received) }
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case orderCount = "order_count"
|
||||
@ -163,6 +166,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable {
|
||||
received = try container.decodeLossyString(forKey: .received)
|
||||
}
|
||||
|
||||
/// 解析Amount数据。
|
||||
private static func parseAmount(_ text: String) -> Double {
|
||||
let normalized = text.filter { "0123456789.-".contains($0) }
|
||||
return Double(normalized) ?? 0
|
||||
|
||||
@ -8,7 +8,7 @@ Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `StatisticsView`:数据 Tab 根视图,读取当前景区、当前角色和统计 API。
|
||||
- `StatisticsViewController`:数据 Tab 根页面,使用 `UICollectionView` + Diffable Data Source 展示多 section 看板。
|
||||
- `StatisticsViewModel`:管理时间段、汇总数据、每日明细、分页状态和加载状态。
|
||||
- `StatisticsAPI`:封装摄影师和景区管理员两套统计接口。
|
||||
- `StatisticsPeriod`:表示今日、昨日、7日、本月四个快捷时间段。
|
||||
@ -24,3 +24,9 @@ Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单
|
||||
## 分页规则
|
||||
|
||||
每日明细第一页随刷新或时间段切换加载。列表滚动到底部后,如果当前数量小于 total,则继续加载下一页。加载更多失败时保留已有数据和当前页状态。
|
||||
|
||||
## 列表实现
|
||||
|
||||
- Section:`empty`(无景区)、`period`(周期切换)、`summary`(汇总卡)、`daily`(每日明细,带 section 头「每日明细」)
|
||||
- 使用 `UICollectionViewDiffableDataSource` + `NSDiffableDataSourceSnapshot` 驱动刷新,分页追加时保留插入动画
|
||||
- 布局复用 `CollectionDiffableLayout` 全宽 section;最后一项日明细 `willDisplay` 时触发 `loadMore`
|
||||
|
||||
@ -6,39 +6,191 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
/// 统计页 section 标识,区分空状态、周期切换、汇总与每日明细。
|
||||
private enum StatisticsSection: Hashable {
|
||||
case empty
|
||||
case period
|
||||
case summary
|
||||
case daily
|
||||
}
|
||||
|
||||
/// 统计页 item 标识,携带展示数据以保证 diff 后 Cell 内容同步更新。
|
||||
private enum StatisticsItem: Hashable {
|
||||
case emptyContext
|
||||
case period(StatisticsPeriod)
|
||||
case summary(StatisticsSummaryResponse)
|
||||
case dailyEmpty
|
||||
case daily(StatisticsDailyItem)
|
||||
}
|
||||
|
||||
/// 数据 Tab 根页面,展示订单统计汇总和每日明细。
|
||||
final class StatisticsViewController: UIViewController {
|
||||
|
||||
private let viewModel = StatisticsViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(StatisticsSummaryCell.self, forCellReuseIdentifier: StatisticsSummaryCell.reuseID)
|
||||
table.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseID)
|
||||
table.register(StatisticsPeriodCell.self, forCellReuseIdentifier: StatisticsPeriodCell.reuseID)
|
||||
return table
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = makeCollectionLayout()
|
||||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||
collection.delegate = self
|
||||
collection.register(StatisticsPeriodCell.self, forCellWithReuseIdentifier: StatisticsPeriodCell.reuseID)
|
||||
collection.register(StatisticsSummaryCell.self, forCellWithReuseIdentifier: StatisticsSummaryCell.reuseID)
|
||||
collection.register(StatisticsDailyCell.self, forCellWithReuseIdentifier: StatisticsDailyCell.reuseID)
|
||||
collection.register(StatisticsEmptyCell.self, forCellWithReuseIdentifier: StatisticsEmptyCell.reuseID)
|
||||
collection.register(
|
||||
CollectionSectionHeaderView.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID
|
||||
)
|
||||
return collection
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
/// Diffable 数据源,驱动多 section 列表与分页插入动画。
|
||||
private var dataSource: UICollectionViewDiffableDataSource<StatisticsSection, StatisticsItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "数据"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
configureDataSource()
|
||||
view.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
collectionView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
viewModel.onChange = { [weak self] in self?.applySnapshot() }
|
||||
appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||||
appServices.permissionContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||||
applySnapshot(animated: false)
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
/// 构建 Compositional Layout,按 section 类型分配全宽卡片布局。
|
||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self else { return CollectionDiffableLayout.fullWidthSection() }
|
||||
let snapshot = self.dataSource?.snapshot()
|
||||
let section = snapshot?.sectionIdentifiers[safe: sectionIndex]
|
||||
switch section {
|
||||
case .empty:
|
||||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 360)
|
||||
case .period:
|
||||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 56)
|
||||
case .summary:
|
||||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 280)
|
||||
case .daily:
|
||||
return CollectionDiffableLayout.addHeader(
|
||||
to: CollectionDiffableLayout.fullWidthSection(estimatedHeight: 52),
|
||||
height: 36
|
||||
)
|
||||
case .none:
|
||||
return CollectionDiffableLayout.fullWidthSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册 Diffable 数据源与 Cell 配置闭包。
|
||||
private func configureDataSource() {
|
||||
dataSource = UICollectionViewDiffableDataSource<StatisticsSection, StatisticsItem>(
|
||||
collectionView: collectionView
|
||||
) { [weak self] collectionView, indexPath, item in
|
||||
guard let self else { return UICollectionViewCell() }
|
||||
switch item {
|
||||
case .emptyContext:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: StatisticsEmptyCell.reuseID,
|
||||
for: indexPath
|
||||
) as! StatisticsEmptyCell
|
||||
let empty = self.makeEmptyStateView(
|
||||
title: "缺少经营上下文",
|
||||
message: "请先在首页选择景区后查看数据看板。",
|
||||
systemImage: "chart.bar.doc.horizontal"
|
||||
)
|
||||
cell.setHostedView(empty, height: 360)
|
||||
return cell
|
||||
case .period(let selectedPeriod):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: StatisticsPeriodCell.reuseID,
|
||||
for: indexPath
|
||||
) as! StatisticsPeriodCell
|
||||
cell.configure(selectedPeriod: selectedPeriod) { [weak self] period in
|
||||
self?.selectPeriod(period)
|
||||
}
|
||||
return cell
|
||||
case .summary(let summary):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: StatisticsSummaryCell.reuseID,
|
||||
for: indexPath
|
||||
) as! StatisticsSummaryCell
|
||||
cell.configure(
|
||||
periodText: self.viewModel.selectedPeriod.selectedTimeText,
|
||||
summary: summary,
|
||||
amountText: self.amountText
|
||||
)
|
||||
return cell
|
||||
case .dailyEmpty:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: StatisticsEmptyCell.reuseID,
|
||||
for: indexPath
|
||||
) as! StatisticsEmptyCell
|
||||
cell.configurePlainText("暂无数据", height: 52)
|
||||
return cell
|
||||
case .daily(let dailyItem):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: StatisticsDailyCell.reuseID,
|
||||
for: indexPath
|
||||
) as! StatisticsDailyCell
|
||||
cell.configure(item: dailyItem, amountText: self.amountText)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
|
||||
guard kind == UICollectionView.elementKindSectionHeader,
|
||||
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section],
|
||||
section == .daily,
|
||||
let header = collectionView.dequeueReusableSupplementaryView(
|
||||
ofKind: kind,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID,
|
||||
for: indexPath
|
||||
) as? CollectionSectionHeaderView
|
||||
else { return nil }
|
||||
header.configure(title: "每日明细")
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 ViewModel 状态构建 snapshot 并应用 diff 更新。
|
||||
private func applySnapshot(animated: Bool = true) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<StatisticsSection, StatisticsItem>()
|
||||
|
||||
if currentScenicId == nil {
|
||||
snapshot.appendSections([.empty])
|
||||
snapshot.appendItems([.emptyContext], toSection: .empty)
|
||||
} else {
|
||||
snapshot.appendSections([.period, .summary, .daily])
|
||||
snapshot.appendItems([.period(viewModel.selectedPeriod)], toSection: .period)
|
||||
snapshot.appendItems([.summary(viewModel.summary)], toSection: .summary)
|
||||
|
||||
if viewModel.loading, viewModel.dailyItems.isEmpty {
|
||||
// 首次加载中不展示占位行,避免闪烁。
|
||||
} else if viewModel.dailyItems.isEmpty {
|
||||
snapshot.appendItems([.dailyEmpty], toSection: .daily)
|
||||
} else {
|
||||
let items = viewModel.dailyItems.map { StatisticsItem.daily($0) }
|
||||
snapshot.appendItems(items, toSection: .daily)
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 下拉刷新触发。
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reload(showLoading: false)
|
||||
@ -49,9 +201,10 @@ final class StatisticsViewController: UIViewController {
|
||||
private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id }
|
||||
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
||||
|
||||
/// 重新加载汇总与第一页日数据。
|
||||
private func reload(showLoading: Bool) async {
|
||||
guard currentScenicId != nil else {
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
return
|
||||
}
|
||||
do {
|
||||
@ -68,6 +221,7 @@ final class StatisticsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换统计周期并重新加载数据。
|
||||
private func selectPeriod(_ period: StatisticsPeriod) {
|
||||
Task {
|
||||
do {
|
||||
@ -85,6 +239,7 @@ final class StatisticsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载每日明细下一页。
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMore(
|
||||
@ -97,85 +252,37 @@ final class StatisticsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化金额展示文本。
|
||||
private func amountText(_ value: Double) -> String {
|
||||
"¥\(String(format: "%.2f", value))"
|
||||
}
|
||||
}
|
||||
|
||||
extension StatisticsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
currentScenicId == nil ? 1 : 3
|
||||
}
|
||||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if currentScenicId == nil { return 1 }
|
||||
switch section {
|
||||
case 0: return 1
|
||||
case 1: return 1
|
||||
default:
|
||||
if viewModel.loading && viewModel.dailyItems.isEmpty { return 0 }
|
||||
return max(viewModel.dailyItems.count, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if currentScenicId == nil {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看数据看板。", systemImage: "chart.bar.doc.horizontal")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(360) }
|
||||
return cell
|
||||
}
|
||||
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsPeriodCell.reuseID, for: indexPath) as! StatisticsPeriodCell
|
||||
cell.configure(selectedPeriod: viewModel.selectedPeriod) { [weak self] period in
|
||||
self?.selectPeriod(period)
|
||||
}
|
||||
return cell
|
||||
case 1:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsSummaryCell.reuseID, for: indexPath) as! StatisticsSummaryCell
|
||||
cell.configure(
|
||||
periodText: viewModel.selectedPeriod.selectedTimeText,
|
||||
summary: viewModel.summary,
|
||||
amountText: amountText
|
||||
)
|
||||
return cell
|
||||
default:
|
||||
if viewModel.dailyItems.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = "暂无数据"
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsDailyCell.reuseID, for: indexPath) as! StatisticsDailyCell
|
||||
cell.configure(item: viewModel.dailyItems[indexPath.row], amountText: amountText)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 2 ? "每日明细" : nil
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 2, indexPath.row == viewModel.dailyItems.count - 1 else { return }
|
||||
extension StatisticsViewController: UICollectionViewDelegate {
|
||||
/// 最后一项日明细即将展示时触发分页加载。
|
||||
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case .daily(let dailyItem) = item,
|
||||
dailyItem.date == viewModel.dailyItems.last?.date
|
||||
else { return }
|
||||
Task { await loadMore() }
|
||||
}
|
||||
}
|
||||
|
||||
private final class StatisticsPeriodCell: UITableViewCell {
|
||||
// MARK: - Cells
|
||||
|
||||
/// 统计周期切换 Cell,横向展示快捷时间段按钮。
|
||||
private final class StatisticsPeriodCell: UICollectionViewCell {
|
||||
static let reuseID = "StatisticsPeriodCell"
|
||||
private var onSelect: ((StatisticsPeriod) -> Void)?
|
||||
private let stack = UIStackView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 8
|
||||
stack.distribution = .fillEqually
|
||||
@ -186,6 +293,7 @@ private final class StatisticsPeriodCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(selectedPeriod: StatisticsPeriod, onSelect: @escaping (StatisticsPeriod) -> Void) {
|
||||
self.onSelect = onSelect
|
||||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
@ -202,19 +310,22 @@ private final class StatisticsPeriodCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击周期按钮后回调选中项。
|
||||
@objc private func periodTapped(_ sender: UIButton) {
|
||||
let period = StatisticsPeriod.allCases[sender.tag]
|
||||
onSelect?(period)
|
||||
}
|
||||
}
|
||||
|
||||
private final class StatisticsSummaryCell: UITableViewCell {
|
||||
/// 统计汇总 Cell,展示订单金额、数量、客单价等指标卡片。
|
||||
private final class StatisticsSummaryCell: UICollectionViewCell {
|
||||
static let reuseID = "StatisticsSummaryCell"
|
||||
private let stack = UIStackView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
contentView.addSubview(stack)
|
||||
@ -224,6 +335,7 @@ private final class StatisticsSummaryCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(periodText: String, summary: StatisticsSummaryResponse, amountText: (Double) -> String) {
|
||||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
let dateLabel = UILabel()
|
||||
@ -249,6 +361,7 @@ private final class StatisticsSummaryCell: UITableViewCell {
|
||||
stack.addArrangedSubview(row2)
|
||||
}
|
||||
|
||||
/// 创建统计摘要卡片视图。
|
||||
private func summaryCard(_ title: String, _ value: String, _ color: UIColor) -> UIView {
|
||||
let card = UIView()
|
||||
card.backgroundColor = color.withAlphaComponent(0.08)
|
||||
@ -270,21 +383,94 @@ private final class StatisticsSummaryCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private final class StatisticsDailyCell: UITableViewCell {
|
||||
/// 每日统计明细 Cell,展示日期、订单数与实收金额。
|
||||
private final class StatisticsDailyCell: UICollectionViewCell {
|
||||
static let reuseID = "StatisticsDailyCell"
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .secondarySystemGroupedBackground
|
||||
contentView.backgroundColor = .secondarySystemGroupedBackground
|
||||
contentView.layer.cornerRadius = 8
|
||||
contentView.clipsToBounds = true
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
detailLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
detailLabel.textColor = AppDesignUIKit.textSecondary
|
||||
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(detailLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.top.equalToSuperview().inset(12)
|
||||
}
|
||||
detailLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
make.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(item: StatisticsDailyItem, amountText: (Double) -> String) {
|
||||
textLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
detailTextLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
textLabel?.text = item.date
|
||||
detailTextLabel?.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))"
|
||||
titleLabel.text = item.date
|
||||
detailLabel.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))"
|
||||
}
|
||||
}
|
||||
|
||||
/// 空状态 Cell,用于缺少景区上下文或日明细为空时的占位展示。
|
||||
private final class StatisticsEmptyCell: UICollectionViewCell {
|
||||
static let reuseID = "StatisticsEmptyCell"
|
||||
|
||||
private var heightConstraint: Constraint?
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 嵌入外部构建的空状态视图并固定高度。
|
||||
func setHostedView(_ view: UIView, height: CGFloat) {
|
||||
contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
heightConstraint?.deactivate()
|
||||
contentView.addSubview(view)
|
||||
view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
heightConstraint = make.height.equalTo(height).constraint
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置纯文案占位,用于日明细为空场景。
|
||||
func configurePlainText(_ title: String, height: CGFloat) {
|
||||
contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
heightConstraint?.deactivate()
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
label.textColor = AppDesignUIKit.textSecondary
|
||||
label.textAlignment = .center
|
||||
contentView.addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
heightConstraint = make.height.equalTo(height).constraint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class TaskManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = TaskManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "任务管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,15 +24,18 @@ final class TaskManagementViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.tasks.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let task = viewModel.tasks[indexPath.row]
|
||||
cell.configure(title: task.name, subtitle: task.statusName, detail: task.createdAt)
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let task = viewModel.tasks[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
@ -40,15 +44,18 @@ final class TaskManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
try? await viewModel.reload(api: services.taskAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
/// table视图相关逻辑。
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.tasks.count - 2 else { return }
|
||||
Task { try? await viewModel.loadMore(api: services.taskAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
/// 创建任务。
|
||||
@objc private func createTask() {
|
||||
navigationController?.pushViewController(TaskCreateViewController(), animated: true)
|
||||
}
|
||||
@ -62,6 +69,7 @@ final class TaskDetailViewController: ModuleTableViewController {
|
||||
private let summary: PhotographerTaskItem?
|
||||
private let viewModel = TaskDetailViewModel()
|
||||
|
||||
/// 初始化实例。
|
||||
init(taskId: Int, summary: PhotographerTaskItem?) {
|
||||
self.taskId = taskId
|
||||
self.summary = summary
|
||||
@ -73,6 +81,7 @@ final class TaskDetailViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = summary?.name ?? "任务详情"
|
||||
super.viewDidLoad()
|
||||
@ -81,11 +90,13 @@ final class TaskDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
guard let detail = viewModel.detail else { return summary == nil ? 0 : 4 }
|
||||
return 6
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
if let detail = viewModel.detail {
|
||||
switch indexPath.row {
|
||||
@ -106,6 +117,7 @@ final class TaskDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.taskAPI, taskId: taskId)
|
||||
}
|
||||
@ -119,6 +131,7 @@ final class TaskCreateViewController: ModuleTableViewController {
|
||||
private let nameField = UITextField()
|
||||
private let remarkField = UITextField()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "发布任务"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -132,6 +145,7 @@ final class TaskCreateViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
nameField.placeholder = "任务名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
@ -146,10 +160,12 @@ final class TaskCreateViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.availableOrders.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let order = viewModel.availableOrders[indexPath.row]
|
||||
let selected = viewModel.selectedOrder?.orderNumber == order.orderNumber
|
||||
@ -157,14 +173,17 @@ final class TaskCreateViewController: ModuleTableViewController {
|
||||
cell.accessoryType = selected ? .checkmark : .none
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
viewModel.selectedOrder = viewModel.availableOrders[indexPath.row]
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadAvailableOrders(api: services.taskAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
viewModel.taskName = nameField.text ?? ""
|
||||
viewModel.remark = remarkField.text ?? ""
|
||||
|
||||
@ -166,6 +166,7 @@ final class TaskCreateViewModel {
|
||||
|
||||
/// 校验表单并按需写入错误提示。
|
||||
private func validateForm(scenicId: Int?, shouldSetError: Bool) -> Bool {
|
||||
/// 记录校验失败并返回 false。
|
||||
func fail(_ message: String) -> Bool {
|
||||
if shouldSetError { errorMessage = message }
|
||||
return false
|
||||
|
||||
@ -13,6 +13,7 @@ struct WalletSummaryResponse: Decodable, Equatable {
|
||||
let amountCurrentBalance: String
|
||||
let amountWithdrawable: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case amountTotal = "amount_total"
|
||||
case amountCurrentBalance = "amount_current_balance"
|
||||
@ -42,6 +43,7 @@ struct WalletEarningDetailResponse: Decodable, Equatable {
|
||||
let total: Int
|
||||
let list: [WalletEarningDetailGroup]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalAmount = "total_amount"
|
||||
case totalPoints = "total_points"
|
||||
@ -76,6 +78,7 @@ struct WalletEarningDetailGroup: Decodable, Equatable, Identifiable {
|
||||
|
||||
var id: String { date }
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case dayAmount = "day_amount"
|
||||
@ -113,6 +116,7 @@ struct WalletEarningDetailItem: Decodable, Equatable, Identifiable {
|
||||
let withdrawLabel: String?
|
||||
let source: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case amount
|
||||
@ -168,6 +172,7 @@ struct WalletWithdrawListResponse: Decodable, Equatable {
|
||||
let total: Int
|
||||
let item: [WalletWithdrawRecord]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case item
|
||||
@ -197,6 +202,7 @@ struct WalletWithdrawRecord: Decodable, Equatable, Identifiable {
|
||||
let auditTime: String?
|
||||
let completedAt: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case amount
|
||||
@ -224,6 +230,7 @@ struct WalletWithdrawRecord: Decodable, Equatable, Identifiable {
|
||||
struct BankCardInfoResponse: Decodable, Equatable {
|
||||
let bankCard: WalletBankCardInfo?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bankCard = "bank_card"
|
||||
}
|
||||
@ -244,6 +251,7 @@ struct WalletBankCardInfo: Decodable, Equatable, Identifiable {
|
||||
let auditStatus: Int
|
||||
let auditStatusLabel: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case bankName = "bank_name"
|
||||
@ -279,6 +287,7 @@ struct WalletBankCardInfo: Decodable, Equatable, Identifiable {
|
||||
struct BankListResponse: Decodable, Equatable {
|
||||
let banks: [String]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case banks
|
||||
}
|
||||
@ -296,6 +305,7 @@ struct AreaNode: Decodable, Equatable, Identifiable {
|
||||
let name: String
|
||||
let children: [AreaNode]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case code
|
||||
@ -324,6 +334,7 @@ struct WithdrawInfoResponse: Decodable, Equatable {
|
||||
let bankCard: WithdrawBankCardInfo
|
||||
let withdrawInfo: [String]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case amountWithdrawable = "amount_withdrawable"
|
||||
case minWithdrawAmount = "min_withdraw_amount"
|
||||
@ -353,6 +364,7 @@ struct WithdrawBankCardInfo: Decodable, Equatable {
|
||||
let bankName: String
|
||||
let cardNumber: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case bankName = "bank_name"
|
||||
@ -380,6 +392,7 @@ struct WithdrawApplyRequest: Encodable {
|
||||
let amount: String
|
||||
let smsCode: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case amount
|
||||
case smsCode = "sms_code"
|
||||
@ -398,6 +411,7 @@ struct UpdateBankInfoRequest: Encodable, Equatable {
|
||||
let cityCode: String
|
||||
let smsVerifyCode: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case cardNumber = "card_number"
|
||||
@ -419,6 +433,7 @@ struct PointOverviewResponse: Decodable, Equatable {
|
||||
let pendingPoints: Int
|
||||
let time: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalPoints = "total_points"
|
||||
case availablePoints = "available_points"
|
||||
@ -459,6 +474,7 @@ struct PointWithdrawListRequest: Encodable {
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case page
|
||||
@ -471,6 +487,7 @@ struct PointWithdrawListResponse: Decodable, Equatable {
|
||||
let total: Int
|
||||
let list: [PointWithdrawItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case list
|
||||
@ -498,6 +515,7 @@ struct PointWithdrawItem: Decodable, Equatable, Identifiable {
|
||||
let status: Int
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case points
|
||||
|
||||
@ -12,6 +12,7 @@ final class WalletViewController: ModuleTableViewController {
|
||||
private let viewModel = WalletViewModel()
|
||||
private let summaryLabel = UILabel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "我的钱包"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -25,6 +26,7 @@ final class WalletViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateSummary() }
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
summaryLabel.numberOfLines = 0
|
||||
summaryLabel.font = .systemFont(ofSize: 14)
|
||||
@ -34,6 +36,7 @@ final class WalletViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = summaryLabel
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
switch viewModel.selectedTab {
|
||||
case .earnings:
|
||||
@ -43,6 +46,7 @@ final class WalletViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
switch viewModel.selectedTab {
|
||||
case .earnings:
|
||||
@ -55,16 +59,19 @@ final class WalletViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadInitial(api: services.walletAPI, staffId: services.staffId)
|
||||
updateSummary()
|
||||
}
|
||||
|
||||
/// 更新Summary状态。
|
||||
private func updateSummary() {
|
||||
summaryLabel.text = "\(viewModel.withdrawableText) · \(viewModel.totalAmountText)"
|
||||
navigationItem.rightBarButtonItem?.title = viewModel.selectedTab == .earnings ? "提现记录" : "收益明细"
|
||||
}
|
||||
|
||||
/// openWithdraw相关逻辑。
|
||||
@objc private func openWithdraw() {
|
||||
navigationController?.pushViewController(WalletWithdrawViewController(), animated: true)
|
||||
}
|
||||
@ -78,6 +85,7 @@ final class WalletWithdrawViewController: ModuleTableViewController {
|
||||
private let amountField = UITextField()
|
||||
private let smsField = UITextField()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "申请提现"
|
||||
navigationItem.rightBarButtonItems = [
|
||||
@ -100,21 +108,26 @@ final class WalletWithdrawViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { 1 }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let amount = viewModel.info?.amountWithdrawable ?? "0"
|
||||
cell.configure(title: "可提现", subtitle: "¥ \(amount)")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.walletAPI)
|
||||
}
|
||||
|
||||
/// sendSms相关逻辑。
|
||||
@objc private func sendSms() {
|
||||
Task { await viewModel.sendSms(api: services.walletAPI) }
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
viewModel.amountText = amountField.text ?? ""
|
||||
viewModel.smsCode = smsField.text ?? ""
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class WithdrawalAuditViewController: ModuleTableViewController {
|
||||
private let viewModel = WithdrawalAuditViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "提现审核"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,25 +24,30 @@ final class WithdrawalAuditViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateFilterTitle() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.filteredRecords.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = viewModel.filteredRecords[indexPath.row]
|
||||
cell.configure(title: "¥\(record.amount)", subtitle: record.statusLabel, detail: record.createdAt)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.walletAPI)
|
||||
updateFilterTitle()
|
||||
}
|
||||
|
||||
/// willDisplayTableRow 回调处理。
|
||||
override func willDisplayTableRow(at indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.filteredRecords.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.walletAPI) }
|
||||
}
|
||||
|
||||
/// cycle筛选相关逻辑。
|
||||
@objc private func cycleFilter() {
|
||||
let filters = WithdrawalAuditFilter.allCases
|
||||
guard let index = filters.firstIndex(of: viewModel.selectedFilter) else { return }
|
||||
@ -52,6 +58,7 @@ final class WithdrawalAuditViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新FilterTitle状态。
|
||||
private func updateFilterTitle() {
|
||||
navigationItem.rightBarButtonItem?.title = viewModel.selectedFilter.title
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user