Add scenic application flow aligned with Android.

Implement scenic apply models, API, upload, UI, and tests; wire entry from permission apply pages and include related permission/scenic selection UI fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 11:09:27 +08:00
parent 2bea05b1d9
commit 77fec21563
22 changed files with 2202 additions and 216 deletions

View File

@ -33,7 +33,6 @@ final class CooperationAcquirerViewModel {
}
func loadAcquirers(api: OrderAPI, initial: Bool = false) async {
if initial { isRefreshing = false }
let storeId = appStore.currentStoreId
let storeIdParam = storeId > 0 ? String(storeId) : nil
do {

View File

@ -88,4 +88,22 @@ final class HomeAPI {
)
)
}
///
func scenicApplicationPending() async throws -> ScenicApplicationPendingsResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/scenic-apply/all")
)
}
///
func submitScenicApplication(_ request: ScenicApplicationSubmitRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/scenic-apply/submit",
body: request
)
)
}
}

View File

@ -163,9 +163,23 @@ final class PermissionApplyViewModel {
.flatMap(\.scenic)
}
func clearRoleSelection() {
selectedRoleId = nil
selectedRoleName = ""
selectedScenicIds = []
selectedScenicNames = []
availableScenics = []
availableRoles = availableRoles.map { role in
var copy = role
copy.isSelected = false
return copy
}
notifyStateChange()
}
func submit(api: HomeAPI) async {
guard let roleId = selectedRoleId else {
onShowMessage?("选择角色")
onShowMessage?("请选择角色")
return
}
let newScenicIds = selectedScenicIds.filter { id in

View File

@ -17,6 +17,7 @@ final class ScenicSelectionViewModel {
private(set) var spots: [ScenicSpotDisplayItem] = []
private(set) var searchQuery = ""
private(set) var currentLocationText = "最近的景区"
private(set) var isLocating = false
private(set) var isLoading = false
var onStateChange: (() -> Void)?
@ -53,7 +54,7 @@ final class ScenicSelectionViewModel {
}
func startLocation() async {
isLoading = true
isLocating = true
notifyStateChange()
do {
let snapshot = try await locationProvider.requestSnapshot()
@ -67,7 +68,7 @@ final class ScenicSelectionViewModel {
} catch {
onShowMessage?("定位失败: \(error.localizedDescription)")
}
isLoading = false
isLocating = false
notifyStateChange()
}

View File

@ -373,7 +373,7 @@ final class OrderListViewModel {
}
func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async {
guard loadingOrderSourceLeadsFor == nil else { return }
guard loadingOrderSourceLeadsFor != person.key else { return }
loadingOrderSourceLeadsFor = person.key
notifyStateChange()
defer {

View File

@ -0,0 +1,199 @@
//
// ScenicApplicationModels.swift
// suixinkan
//
import Foundation
/// Android `CooperationType`
enum ScenicCooperationType: Int, CaseIterable, Equatable {
case photographer = 1
case resourceProvider = 2
case teamLeader = 3
case photoShop = 4
case efficiencyImprovement = 5
var displayName: String {
switch self {
case .photographer:
"我是摄影师,我想入驻"
case .resourceProvider:
"我是景区资源方,可以合作"
case .teamLeader:
"我有摄影师团队,带队入住"
case .photoShop:
"我是旅拍店,想合作"
case .efficiencyImprovement:
"我已经在景区运营旅拍了,想使用软件,提高效率"
}
}
static func from(rawValue: Int) -> ScenicCooperationType {
ScenicCooperationType(rawValue: rawValue) ?? .photographer
}
}
///
struct ScenicApplicationPendingResponse: Decodable, Equatable {
let id: Int
let code: String
let staffId: Int
let scenicId: Int
let scenicName: String
let scenicImages: String
let scenicProvince: String
let scenicCity: String
let coopType: Int
let remark: String?
let status: Int
let rejectReason: String?
let auditedBy: String?
let auditedAt: String?
let auditNote: String?
enum CodingKeys: String, CodingKey {
case id
case code
case staffId = "staff_id"
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case scenicImages = "scenic_images"
case scenicProvince = "scenic_province"
case scenicCity = "scenic_city"
case coopType = "coop_type"
case remark
case status
case rejectReason = "reject_reason"
case auditedBy = "audited_by"
case auditedAt = "audited_at"
case auditNote = "audit_note"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
code = try container.decodeIfPresent(String.self, forKey: .code) ?? ""
staffId = try container.decodeIfPresent(Int.self, forKey: .staffId) ?? 0
scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0
scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? ""
scenicImages = try container.decodeIfPresent(String.self, forKey: .scenicImages) ?? ""
scenicProvince = try container.decodeIfPresent(String.self, forKey: .scenicProvince) ?? ""
scenicCity = try container.decodeIfPresent(String.self, forKey: .scenicCity) ?? ""
coopType = try container.decodeIfPresent(Int.self, forKey: .coopType) ?? 0
remark = try container.decodeIfPresent(String.self, forKey: .remark)
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
auditedBy = try container.decodeIfPresent(String.self, forKey: .auditedBy)
auditedAt = try container.decodeIfPresent(String.self, forKey: .auditedAt)
auditNote = try container.decodeIfPresent(String.self, forKey: .auditNote)
}
init(
id: Int = 0,
code: String = "",
staffId: Int = 0,
scenicId: Int = 0,
scenicName: String = "",
scenicImages: String = "",
scenicProvince: String = "",
scenicCity: String = "",
coopType: Int = 0,
remark: String? = nil,
status: Int = 0,
rejectReason: String? = nil,
auditedBy: String? = nil,
auditedAt: String? = nil,
auditNote: String? = nil
) {
self.id = id
self.code = code
self.staffId = staffId
self.scenicId = scenicId
self.scenicName = scenicName
self.scenicImages = scenicImages
self.scenicProvince = scenicProvince
self.scenicCity = scenicCity
self.coopType = coopType
self.remark = remark
self.status = status
self.rejectReason = rejectReason
self.auditedBy = auditedBy
self.auditedAt = auditedAt
self.auditNote = auditNote
}
}
///
struct ScenicApplicationPendingsResponse: Decodable, Equatable {
let items: [ScenicApplicationPendingResponse]
init(items: [ScenicApplicationPendingResponse] = []) {
self.items = items
}
}
///
struct ScenicApplicationSubmitRequest: Encodable {
let scenicName: String?
let scenicImages: [String]?
let scenicProvince: String
let scenicCity: String
let coopType: Int
let remark: String
let scenicId: Int
enum CodingKeys: String, CodingKey {
case scenicName = "scenic_name"
case scenicImages = "scenic_images"
case scenicProvince = "scenic_province"
case scenicCity = "scenic_city"
case coopType = "coop_type"
case remark
case scenicId = "scenic_id"
}
}
///
struct ScenicApplicationImageItem: Equatable, Identifiable {
let id: UUID
var localData: Data?
var fileName: String
var remoteURL: String
var isUploading: Bool
var uploadProgress: Int
var errorMessage: String?
var isUploaded: Bool { !remoteURL.isEmpty }
static func fromRemoteURL(_ url: String) -> ScenicApplicationImageItem {
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
let name = URL(string: trimmed)?.lastPathComponent ?? "image.jpg"
return ScenicApplicationImageItem(
id: UUID(),
localData: nil,
fileName: name,
remoteURL: trimmed,
isUploading: false,
uploadProgress: 100,
errorMessage: nil
)
}
static func fromLocal(data: Data, fileName: String) -> ScenicApplicationImageItem {
ScenicApplicationImageItem(
id: UUID(),
localData: data,
fileName: fileName,
remoteURL: "",
isUploading: false,
uploadProgress: 0,
errorMessage: nil
)
}
}
///
struct ScenicApplicationUploadDialogState: Equatable {
let title: String
let progress: Int
}

View File

@ -0,0 +1,444 @@
//
// ScenicApplicationViewModel.swift
// suixinkan
//
import Foundation
/// 便 mock
@MainActor
protocol ScenicApplicationImageUploading: AnyObject {
func uploadScenicApplyImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
extension OSSUploadService: ScenicApplicationImageUploading {}
/// ViewModel Android `ScenicApplicationViewModel`
final class ScenicApplicationViewModel {
private struct UploadBatch {
let id: UUID
var indices: [Int]
var position: Int = 0
var totalCount: Int
var completedCount: Int = 0
}
private(set) var scenicName = ""
private(set) var selectedImages: [ScenicApplicationImageItem] = []
private(set) var selectedProvince = ""
private(set) var selectedCity = ""
private(set) var selectedCooperationType: ScenicCooperationType = .photographer
private(set) var remarks = ""
private(set) var isAgreed = false
private(set) var isSubmitting = false
private(set) var provinceList: [AreasResponse] = []
private(set) var cityList: [AreasResponse] = []
private(set) var pendingApplication: ScenicApplicationPendingResponse?
private(set) var uploadDialogState: ScenicApplicationUploadDialogState?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: (() -> Void)?
private var uploadBatchQueue: [UploadBatch] = []
private var currentBatch: UploadBatch?
private var uploadingIndex: Int?
private var isProcessingUploadQueue = false
private var lastProgressUpdateTime: TimeInterval = 0
private let progressUpdateInterval: TimeInterval = 0.2
/// Android `ScenicApplicationUiState.canSubmit`
var canSubmit: Bool {
!selectedProvince.isEmpty
&& !selectedCity.isEmpty
&& !remarks.isEmpty
&& remarks.count <= 50
&& isAgreed
&& !isSubmitting
}
/// status == 1
var isReadOnly: Bool {
pendingApplication?.status == 1
}
///
var isEditable: Bool {
guard let pending = pendingApplication else { return true }
return pending.status == 2 || pending.status == 3 || pending.status == 9
}
/// status 1/3/9
var shouldShowAuditCard: Bool {
guard let pending = pendingApplication else { return false }
return pending.status == 1 || pending.status == 3 || pending.status == 9
}
///
func loadInitial(profileAPI: ProfileAPI, homeAPI: HomeAPI) async {
async let areasTask: Void = loadAreas(profileAPI: profileAPI)
async let pendingTask: Void = loadPendingApplication(homeAPI: homeAPI)
_ = await (areasTask, pendingTask)
}
func loadAreas(profileAPI: ProfileAPI) async {
do {
provinceList = try await profileAPI.areas()
notifyStateChange()
} catch {
onShowMessage?("获取省市数据失败: \(error.localizedDescription)")
}
}
func loadPendingApplication(homeAPI: HomeAPI) async {
do {
let response = try await homeAPI.scenicApplicationPending()
guard let pending = response.items.first else {
pendingApplication = nil
notifyStateChange()
return
}
if pending.status == 1 || pending.status == 3 {
pendingApplication = pending
await waitForProvinceListIfNeeded()
initWithPendingApplication(pending)
} else {
pendingApplication = nil
}
notifyStateChange()
} catch {
pendingApplication = nil
notifyStateChange()
}
}
func onScenicNameChange(_ name: String) {
scenicName = name
notifyStateChange()
}
func onProvinceSelected(_ provinceName: String) {
let province = provinceList.first { $0.name == provinceName }
selectedProvince = provinceName
selectedCity = ""
cityList = province?.children ?? []
notifyStateChange()
}
func onCitySelected(_ cityName: String) {
selectedCity = cityName
notifyStateChange()
}
func onCooperationTypeSelected(_ type: ScenicCooperationType) {
selectedCooperationType = type
notifyStateChange()
}
func onRemarksChange(_ text: String) {
if text.count <= 50 {
remarks = text
notifyStateChange()
} else {
onShowMessage?("备注信息最多50字")
}
}
func toggleAgreement() {
isAgreed.toggle()
notifyStateChange()
}
///
func addLocalImages(_ items: [ScenicApplicationImageItem]) {
guard !items.isEmpty else { return }
let startIndex = selectedImages.count
selectedImages.append(contentsOf: items)
let indices = (0..<items.count).map { startIndex + $0 }
uploadBatchQueue.append(
UploadBatch(id: UUID(), indices: indices, totalCount: indices.count)
)
notifyStateChange()
}
func deleteImage(at index: Int) {
guard index >= 0, index < selectedImages.count else { return }
selectedImages.remove(at: index)
adjustBatchAfterDeletion(deletedIndex: index)
if uploadingIndex == index {
uploadingIndex = nil
uploadDialogState = nil
} else if let uploadingIndex, index < uploadingIndex {
self.uploadingIndex = uploadingIndex - 1
}
notifyStateChange()
}
func retryUpload(at index: Int) {
guard index >= 0, index < selectedImages.count else { return }
selectedImages[index].errorMessage = nil
uploadBatchQueue.insert(
UploadBatch(id: UUID(), indices: [index], totalCount: 1),
at: 0
)
notifyStateChange()
}
///
func processPendingUploads(uploader: ScenicApplicationImageUploading, scenicId: Int) async {
guard !isProcessingUploadQueue else { return }
isProcessingUploadQueue = true
defer { isProcessingUploadQueue = false }
while true {
if currentBatch == nil || (currentBatch?.position ?? 0) >= (currentBatch?.indices.count ?? 0) {
currentBatch = uploadBatchQueue.isEmpty ? nil : uploadBatchQueue.removeFirst()
}
guard var batch = currentBatch else {
uploadDialogState = nil
notifyStateChange()
return
}
while batch.position < batch.indices.count {
let index = batch.indices[batch.position]
guard index < selectedImages.count else {
batch.indices.remove(at: batch.position)
batch.totalCount = max(batch.completedCount, batch.totalCount - 1)
continue
}
let image = selectedImages[index]
if image.isUploaded {
batch.position += 1
batch.completedCount = min(batch.totalCount, batch.completedCount + 1)
continue
}
if image.errorMessage != nil {
batch.position += 1
continue
}
guard let data = image.localData else {
batch.position += 1
continue
}
currentBatch = batch
uploadingIndex = index
updateImage(at: index) { item in
var copy = item
copy.isUploading = true
copy.uploadProgress = 0
copy.errorMessage = nil
return copy
}
let total = max(batch.totalCount, 1)
let currentPosition = min(batch.completedCount + 1, total)
uploadDialogState = ScenicApplicationUploadDialogState(
title: "正在上传(\(currentPosition)/\(total))",
progress: 0
)
notifyStateChange()
do {
let url = try await uploader.uploadScenicApplyImage(
data: data,
fileName: image.fileName,
scenicId: scenicId,
onProgress: { [weak self] progress in
Task { @MainActor in
self?.handleUploadProgress(
index: index,
batch: batch,
currentPosition: currentPosition,
total: total,
progress: progress
)
}
}
)
updateImage(at: index) { item in
var copy = item
copy.isUploading = false
copy.uploadProgress = 100
copy.remoteURL = url
copy.errorMessage = nil
return copy
}
batch.completedCount = min(batch.totalCount, batch.completedCount + 1)
} catch {
updateImage(at: index) { item in
var copy = item
copy.isUploading = false
copy.uploadProgress = 0
copy.errorMessage = error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription
return copy
}
onShowMessage?("图片上传失败,请重试")
}
batch.position += 1
uploadingIndex = nil
currentBatch = batch
}
currentBatch = nil
uploadDialogState = nil
notifyStateChange()
}
}
func submit(homeAPI: HomeAPI) async {
guard canSubmit else {
onShowMessage?("请完善必填信息")
return
}
if selectedImages.contains(where: \.isUploading) {
onShowMessage?("图片正在上传,请稍后提交")
return
}
if selectedImages.contains(where: { !$0.isUploaded && $0.errorMessage == nil && $0.localData != nil }) {
onShowMessage?("请先完成图片上传")
return
}
isSubmitting = true
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
let uploadedURLs = selectedImages.compactMap { item -> String? in
item.isUploaded ? item.remoteURL : nil
}
let request = ScenicApplicationSubmitRequest(
scenicName: scenicName.isEmpty ? nil : scenicName,
scenicImages: uploadedURLs.isEmpty ? nil : uploadedURLs,
scenicProvince: selectedProvince,
scenicCity: selectedCity,
coopType: selectedCooperationType.rawValue,
remark: remarks,
scenicId: 0
)
do {
try await homeAPI.submitScenicApplication(request)
onShowMessage?("提交成功")
await loadPendingApplication(homeAPI: homeAPI)
onSubmitSuccess?()
} catch {
onShowMessage?("提交失败: \(error.localizedDescription)")
}
}
private func initWithPendingApplication(_ pending: ScenicApplicationPendingResponse) {
let imageStates = pending.scenicImages
.split(separator: ",")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { ScenicApplicationImageItem.fromRemoteURL($0) }
let province = provinceList.first { $0.name == pending.scenicProvince }
scenicName = pending.scenicName
selectedImages = imageStates
selectedProvince = pending.scenicProvince
selectedCity = pending.scenicCity
selectedCooperationType = ScenicCooperationType.from(rawValue: pending.coopType)
remarks = pending.remark ?? ""
cityList = province?.children ?? []
}
private func waitForProvinceListIfNeeded() async {
var waitCount = 0
while provinceList.isEmpty && waitCount < 30 {
try? await Task.sleep(nanoseconds: 100_000_000)
waitCount += 1
}
}
private func handleUploadProgress(
index: Int,
batch: UploadBatch,
currentPosition: Int,
total: Int,
progress: Int
) {
let now = Date().timeIntervalSince1970
let shouldUpdate = now - lastProgressUpdateTime >= progressUpdateInterval
|| progress == 0
|| progress == 100
guard shouldUpdate else { return }
lastProgressUpdateTime = now
updateImage(at: index) { item in
var copy = item
copy.uploadProgress = min(max(progress, 0), 100)
return copy
}
uploadDialogState = ScenicApplicationUploadDialogState(
title: "正在上传(\(currentPosition)/\(total))",
progress: min(max(progress, 0), 100)
)
notifyStateChange()
}
private func updateImage(at index: Int, transform: (ScenicApplicationImageItem) -> ScenicApplicationImageItem) {
guard index >= 0, index < selectedImages.count else { return }
selectedImages[index] = transform(selectedImages[index])
}
private func adjustBatchAfterDeletion(deletedIndex: Int) {
func adjust(_ batch: inout UploadBatch) {
var removedBefore = 0
var index = 0
while index < batch.indices.count {
let value = batch.indices[index]
if value == deletedIndex {
if index < batch.position {
removedBefore += 1
}
batch.indices.remove(at: index)
if batch.totalCount > batch.completedCount {
batch.totalCount = max(batch.completedCount, batch.totalCount - 1)
}
} else if value > deletedIndex {
batch.indices[index] = value - 1
index += 1
} else {
index += 1
}
}
batch.position = max(0, batch.position - removedBefore)
if batch.position > batch.indices.count {
batch.position = batch.indices.count
}
}
if var batch = currentBatch {
adjust(&batch)
if batch.indices.isEmpty {
currentBatch = nil
uploadDialogState = nil
} else {
currentBatch = batch
}
}
uploadBatchQueue = uploadBatchQueue.compactMap { batch in
var copy = batch
adjust(&copy)
return copy.indices.isEmpty ? nil : copy
}
}
private func notifyStateChange() {
onStateChange?()
}
}