Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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 {}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
23
suixinkan_ios/Features/ScenicSettlement/ScenicSettlement.md
Normal file
23
suixinkan_ios/Features/ScenicSettlement/ScenicSettlement.md
Normal 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 已接入的真实结算提交接口和审核记录聚合方式为迁移依据,不新增后端未体现的详情审批操作。
|
||||
@ -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 {}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user