Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,39 @@
//
// ScenicSettlementAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
@MainActor
protocol ScenicSettlementServing {
///
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws
}
@MainActor
/// API
final class ScenicSettlementAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/scenic-settlement/submit",
body: request
)
)
}
}
extension ScenicSettlementAPI: ScenicSettlementServing {}

View File

@ -0,0 +1,28 @@
//
// ScenicSettlementModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct ScenicSettlementOption: Equatable, Identifiable {
let id: Int
let name: String
var selected: Bool
}
///
struct ScenicSettlementSubmitRequest: Encodable, Equatable {
let scenicId: Int
let applyAmount: String
let applyRemark: String
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case applyAmount = "apply_amount"
case applyRemark = "apply_remark"
}
}

View File

@ -0,0 +1,23 @@
# 景区结算模块
## 模块职责
`Features/ScenicSettlement` 承接首页 `scenic_settlement``scenic_settlement_review` 权限入口,负责景区结算申请提交和结算相关审核记录展示。
## 结算申请
`ScenicSettlementViewModel` 从账号上下文读取已有景区,并通过 `ScenicPermissionAPI.scenicListAll` 加载全部可申请景区,排除已开通景区后供用户多选。
提交时校验金额必填、正数且最多两位小数,再按景区 ID 升序逐条调用 `/api/yf-handset-app/photog/scenic-settlement/submit`。全部成功后清空已选景区、金额和备注;任一请求失败时保留当前表单并提示错误。
## 结算审核
`ScenicSettlementReviewViewModel` 复用景区申请记录和权限申请记录接口:
- `scenicApplicationPendingAll`
- `roleApplyAll`
两个通道独立容错。单通道失败时展示另一通道数据并提示失败;双通道失败时展示整页失败和重新加载入口。
## 边界
旧 Android 工程中的景区结算仍是本地 mock 数据。本模块以旧 iOS 已接入的真实结算提交接口和审核记录聚合方式为迁移依据,不新增后端未体现的详情审批操作。

View File

@ -0,0 +1,130 @@
//
// ScenicSettlementViewControllers.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
///
final class ScenicSettlementViewController: ModuleTableViewController {
private let viewModel = ScenicSettlementViewModel()
private let amountField = UITextField()
private let remarkField = UITextField()
override func viewDidLoad() {
title = "景区结算"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "提交",
style: .done,
target: self,
action: #selector(submit)
)
super.viewDidLoad()
setupHeader()
wireViewModel(viewModel) { }
}
private func setupHeader() {
amountField.placeholder = "结算金额"
amountField.borderStyle = .roundedRect
amountField.keyboardType = .decimalPad
remarkField.placeholder = "备注"
remarkField.borderStyle = .roundedRect
let stack = UIStackView(arrangedSubviews: [amountField, remarkField])
stack.axis = .vertical
stack.spacing = 8
stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96)
stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
stack.isLayoutMarginsRelativeArrangement = true
tableView.tableHeaderView = stack
}
override func tableRowCount() -> Int {
viewModel.options.count
}
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
}
override func didSelectTableRow(at indexPath: IndexPath) {
viewModel.toggleScenic(id: viewModel.options[indexPath.row].id)
}
override func reloadContent() async {
await viewModel.load(
api: services.scenicPermissionAPI,
existingScenics: services.accountContext.scenicScopes
)
}
@objc private func submit() {
viewModel.amountText = amountField.text ?? ""
viewModel.remarkText = remarkField.text ?? ""
Task {
let success = await viewModel.submit(api: services.scenicSettlementAPI)
if success {
services.toastCenter.show("提交成功")
navigationController?.popViewController(animated: true)
} else if let message = viewModel.message {
services.toastCenter.show(message)
}
}
}
}
extension ScenicSettlementViewModel: ViewModelBindable {}
///
final class ScenicSettlementReviewViewController: ModuleTableViewController {
private let viewModel = ScenicSettlementReviewViewModel()
override func viewDidLoad() {
title = "结算审核"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? viewModel.scenicApplications.count : viewModel.roleApplications.count
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "景区申请" : "权限申请"
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath
) as! TitleSubtitleTableViewCell
if indexPath.section == 0 {
let item = viewModel.scenicApplications[indexPath.row]
cell.configure(
title: item.scenicName,
subtitle: viewModel.statusText(item.status),
detail: item.createdAt
)
} else {
let item = viewModel.roleApplications[indexPath.row]
cell.configure(
title: item.roleName,
subtitle: viewModel.statusText(item.status, fallback: item.statusLabel),
detail: item.createdAt
)
}
return cell
}
override func reloadContent() async {
await viewModel.load(api: services.scenicPermissionAPI)
}
}
extension ScenicSettlementReviewViewModel: ViewModelBindable {}

View File

@ -0,0 +1,263 @@
//
// ScenicSettlementViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import UIKit
@MainActor
/// ViewModel
final class ScenicSettlementViewModel {
var onChange: (() -> Void)?
var existingScenics: [BusinessScope] = [] { didSet { onChange?() } }
var options: [ScenicSettlementOption] = [] { didSet { onChange?() } }
var amountText = "" { didSet { onChange?() } }
var remarkText = "" { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isSubmitting = false { didSet { onChange?() } }
var loadFailed = false { didSet { onChange?() } }
var loadFailureReason: String? { didSet { onChange?() } }
var message: String? { didSet { onChange?() } }
private var selectedIds = Set<Int>()
///
var selectedCount: Int {
selectedIds.count
}
/// ID使
var selectedScenicIds: Set<Int> {
selectedIds
}
///
var amountValidation: ScenicSettlementAmountValidation {
let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return .empty }
guard trimmed.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil else {
return .invalidFormat
}
guard let decimal = Decimal(string: trimmed), decimal > 0 else {
return .notPositive
}
return .valid
}
///
var canSubmit: Bool {
!isSubmitting && !selectedIds.isEmpty && amountValidation == .valid
}
///
func load(api: any ScenicPermissionServing, existingScenics: [BusinessScope]) async {
isLoading = true
loadFailed = false
loadFailureReason = nil
self.existingScenics = existingScenics
defer { isLoading = false }
do {
let all = try await api.scenicListAll().list
let existingIds = Set(existingScenics.map(\.id))
options = all
.filter { !existingIds.contains($0.id) }
.map { ScenicSettlementOption(id: $0.id, name: $0.name, selected: selectedIds.contains($0.id)) }
} catch {
options = []
selectedIds.removeAll()
loadFailed = true
loadFailureReason = error.localizedDescription
message = "可申请景区加载失败,请重试"
}
}
///
func toggleScenic(id: Int) {
guard let index = options.firstIndex(where: { $0.id == id }) else { return }
options[index].selected.toggle()
if options[index].selected {
selectedIds.insert(id)
} else {
selectedIds.remove(id)
}
}
/// ID
func submit(api: any ScenicSettlementServing) async -> Bool {
guard !isSubmitting else { return false }
guard canSubmit else {
message = validationMessage
return false
}
isSubmitting = true
defer { isSubmitting = false }
let amount = normalizedAmount
let remark = remarkText.trimmingCharacters(in: .whitespacesAndNewlines)
do {
for scenicId in selectedIds.sorted() {
try await api.scenicSettlementSubmit(
ScenicSettlementSubmitRequest(
scenicId: scenicId,
applyAmount: amount,
applyRemark: remark
)
)
}
selectedIds.removeAll()
options = options.map { option in
ScenicSettlementOption(id: option.id, name: option.name, selected: false)
}
amountText = ""
remarkText = ""
message = "提交成功,等待审核"
return true
} catch {
message = error.localizedDescription
return false
}
}
private var normalizedAmount: String {
let decimal = Decimal(string: amountText.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
return String(format: "%.2f", NSDecimalNumber(decimal: decimal).doubleValue)
}
private var validationMessage: String {
if selectedIds.isEmpty { return "请先选择景区" }
switch amountValidation {
case .empty:
return "请填写结算金额"
case .invalidFormat:
return "金额格式错误,最多支持两位小数"
case .notPositive:
return "金额需大于 0"
case .valid:
return ""
}
}
}
///
enum ScenicSettlementAmountValidation: Equatable {
case empty
case invalidFormat
case notPositive
case valid
///
var badgeText: String {
switch self {
case .empty: "待填写金额"
case .invalidFormat: "金额格式错误"
case .notPositive: "金额需大于 0"
case .valid: "金额有效"
}
}
///
var badgeColor: UIColor {
switch self {
case .empty, .notPositive:
return AppDesign.warning
case .invalidFormat:
return UIColor(hex: 0xDC2626)
case .valid:
return AppDesign.success
}
}
}
@MainActor
/// ViewModel
final class ScenicSettlementReviewViewModel {
var onChange: (() -> Void)?
var scenicApplications: [ScenicApplicationPendingResponse] = [] { didSet { onChange?() } }
var roleApplications: [RoleApplyPendingResponse] = [] { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var message: String? { didSet { onChange?() } }
var loadFailedAll = false { didSet { onChange?() } }
var scenicLoadFailed = false { didSet { onChange?() } }
var roleLoadFailed = false { didSet { onChange?() } }
///
var pendingCount: Int {
scenicApplications.filter { $0.status == 1 }.count + roleApplications.filter { $0.status == 1 }.count
}
///
func load(api: any ScenicPermissionServing) async {
isLoading = true
loadFailedAll = false
scenicLoadFailed = false
roleLoadFailed = false
message = nil
defer { isLoading = false }
var errors: [String] = []
var scenicLoaded = false
var roleLoaded = false
do {
scenicApplications = try await api.scenicApplicationPendingAll().items
scenicLoaded = true
} catch {
scenicApplications = []
scenicLoadFailed = true
errors.append("景区申请记录加载失败")
}
do {
roleApplications = try await api.roleApplyAll()
roleLoaded = true
} catch {
roleApplications = []
roleLoadFailed = true
errors.append("权限申请记录加载失败")
}
loadFailedAll = !scenicLoaded && !roleLoaded
if !errors.isEmpty {
message = errors.joined(separator: "")
}
}
///
func statusText(_ status: Int, fallback: String = "") -> String {
let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
switch status {
case 1: return "待审核"
case 2: return "已通过"
case 3: return "已驳回"
case 9: return "已取消"
default: return "未知"
}
}
///
func statusIcon(_ status: Int) -> String {
switch status {
case 2: return "checkmark.circle.fill"
case 3: return "xmark.octagon.fill"
case 1: return "clock.badge.exclamationmark.fill"
default: return "questionmark.circle.fill"
}
}
///
func statusColor(_ status: Int) -> UIColor {
switch status {
case 2: return AppDesign.success
case 3: return UIColor(hex: 0xDC2626)
case 1: return AppDesign.warning
default: return AppDesign.textSecondary
}
}
}