Add submit-task flow aligned with Android and extract profile edit page.

Implement task creation with cloud/local media, order linking, and OSS upload; wire home entry and move profile nickname/avatar editing to ProfileEditViewController.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 10:12:20 +08:00
parent 715393e78c
commit 3c4ca7eefb
23 changed files with 3186 additions and 170 deletions

View File

@ -48,6 +48,8 @@ enum HomeRouteHandler {
return
}
showDeveloping(from: viewController)
case "task_management", "task_management_editor":
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
default:
showDeveloping(from: viewController)
}

View File

@ -0,0 +1,102 @@
//
// ProfileEditViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class ProfileEditViewModel {
private let initialNickname: String
private let initialAvatarURL: String
private(set) var editingNickname = ""
private(set) var pendingAvatarData: Data?
private(set) var pendingAvatarFileName: String?
private(set) var isSaving = false
private(set) var avatarUploadProgress: Int?
var onStateChange: (() -> Void)?
/// URL使
var displayAvatarURL: String {
initialAvatarURL
}
/// 使
init(nickname: String, avatarURL: String) {
initialNickname = nickname
initialAvatarURL = avatarURL
editingNickname = nickname == "未设置昵称" ? "" : nickname
}
func updateEditingNickname(_ value: String) {
editingNickname = value
notifyStateChange()
}
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
pendingAvatarData = processed.data
pendingAvatarFileName = processed.fileName
notifyStateChange()
}
///
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !nextNickname.isEmpty else {
throw ProfileValidationError.emptyNickname
}
let nicknameChanged = nextNickname != initialNickname
let avatarChanged = pendingAvatarData != nil
guard nicknameChanged || avatarChanged else { return }
guard !isSaving else { return }
isSaving = true
avatarUploadProgress = avatarChanged ? 1 : nil
notifyStateChange()
defer {
isSaving = false
avatarUploadProgress = nil
notifyStateChange()
}
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
if let uploadedAvatarURL {
try await api.updateUserAvatarURL(uploadedAvatarURL)
}
if nicknameChanged {
try await api.updateUserInfo(nickname: nextNickname)
}
AppStore.shared.userName = nextNickname
if let uploadedAvatarURL {
AppStore.shared.avatar = uploadedAvatarURL
}
clearPendingAvatar()
NotificationCenter.default.post(name: NotificationName.userProfileDidUpdate, object: nil)
}
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
guard let pendingAvatarData else { return nil }
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
self?.avatarUploadProgress = progress
self?.notifyStateChange()
}
}
private func clearPendingAvatar() {
pendingAvatarData = nil
pendingAvatarFileName = nil
avatarUploadProgress = nil
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -5,7 +5,7 @@
import Foundation
/// ViewModel
/// ViewModel
final class ProfileViewModel {
private(set) var userInfo: UserInfoResponse?
@ -13,11 +13,6 @@ final class ProfileViewModel {
private(set) var bankCardInfo: BankCardInfo?
private(set) var isLoading = false
private(set) var isSaving = false
private(set) var isEditingProfile = false
private(set) var editingNickname = ""
private(set) var pendingAvatarData: Data?
private(set) var pendingAvatarFileName: String?
private(set) var avatarUploadProgress: Int?
var onStateChange: (() -> Void)?
@ -85,12 +80,6 @@ final class ProfileViewModel {
return label.isEmpty ? "审核中" : label
}
func updateEditingNickname(_ value: String) {
editingNickname = value
notifyStateChange()
}
///
func reload(api: ProfileAPI) async throws {
guard !isLoading else { return }
isLoading = true
@ -119,77 +108,21 @@ final class ProfileViewModel {
}
}
func beginEditing() {
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
isEditingProfile = true
notifyStateChange()
}
func cancelEditing() {
editingNickname = ""
isEditingProfile = false
clearPendingAvatar()
notifyStateChange()
}
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
pendingAvatarData = processed.data
pendingAvatarFileName = processed.fileName
if !isEditingProfile {
beginEditing()
} else {
notifyStateChange()
}
}
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !nextNickname.isEmpty else {
throw ProfileValidationError.emptyNickname
}
let nicknameChanged = nextNickname != displayNickname
let avatarChanged = pendingAvatarData != nil
guard nicknameChanged || avatarChanged else {
cancelEditing()
return
}
guard !isSaving else { return }
isSaving = true
avatarUploadProgress = avatarChanged ? 1 : nil
notifyStateChange()
defer {
isSaving = false
avatarUploadProgress = nil
notifyStateChange()
}
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
if let uploadedAvatarURL {
try await api.updateUserAvatarURL(uploadedAvatarURL)
}
if nicknameChanged {
try await api.updateUserInfo(nickname: nextNickname)
}
/// `AppStore`
func applyLocalProfileUpdate(from store: AppStore = .shared) {
let previous = userInfo ?? UserInfoResponse()
let nextNickname = store.userName.trimmingCharacters(in: .whitespacesAndNewlines)
let nextAvatar = store.avatar.trimmingCharacters(in: .whitespacesAndNewlines)
userInfo = UserInfoResponse(
avatar: uploadedAvatarURL ?? previous.avatar,
avatar: nextAvatar.isEmpty ? previous.avatar : nextAvatar,
realName: previous.realName,
phone: previous.phone,
nickname: nextNickname,
nickname: nextNickname.isEmpty ? previous.nickname : nextNickname,
roleName: previous.roleName,
status: previous.status,
statusName: previous.statusName
)
AppStore.shared.userName = nextNickname
if let uploadedAvatarURL {
AppStore.shared.avatar = uploadedAvatarURL
}
clearPendingAvatar()
cancelEditing()
notifyStateChange()
}
func updatePassword(_ password: String, api: ProfileAPI) async throws {
@ -215,21 +148,6 @@ final class ProfileViewModel {
return "**** **** **** \(digits.suffix(4))"
}
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
guard let pendingAvatarData else { return nil }
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
self?.avatarUploadProgress = progress
self?.notifyStateChange()
}
}
private func clearPendingAvatar() {
pendingAvatarData = nil
pendingAvatarFileName = nil
avatarUploadProgress = nil
}
private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text

View File

@ -0,0 +1,81 @@
//
// TaskAPI.swift
// suixinkan
//
import Foundation
@MainActor
/// API
final class TaskAPI {
private let client: APIClient
init(client: APIClient) {
self.client = client
}
///
func createTask(_ request: AddTaskRequest) async throws {
let _: EmptyResponse = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/task/create",
body: request
)
)
}
///
func availableOrders(scenicId: Int) async throws -> [AvailableOrder] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/task/available-order",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
///
func cloudFileList(
parentFolderId: Int,
name: String = "",
type: Int = 0,
orderBy: Int = 2,
page: Int = 1,
pageSize: Int = 20
) async throws -> CloudFileListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/cloud-driver/list",
queryItems: [
URLQueryItem(name: "parent_folder_id", value: String(parentFolderId)),
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "type", value: String(type)),
URLQueryItem(name: "order_by", value: String(orderBy)),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize)),
]
)
)
}
/// OSS URL
func registerUploadedFileURL(scenicId: Int, fileURL: String, folderId: Int = 0) async throws {
let _: EmptyResponse = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/album/file-upload-url",
queryItems: [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "file_url", value: fileURL),
URLQueryItem(name: "folder_id", value: String(folderId)),
]
)
)
}
}
/// data
private struct EmptyResponse: Decodable, Sendable {}

View File

@ -0,0 +1,260 @@
//
// TaskModels.swift
// suixinkan
//
import Foundation
/// Android `AddTaskRequest`
struct AddTaskRequest: Encodable, Sendable, Equatable {
let scenicId: Int
let name: String
let orderNumber: String?
let remark: String
let urgentHour: Int
let cloudFile: [TaskCloudFileItem]
let uploadFile: [TaskUploadFileItem]
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case name
case orderNumber = "order_number"
case remark = "photog_remark"
case urgentHour = "urgent_hour"
case cloudFile = "cloud_file"
case uploadFile = "upload_file"
}
}
/// Android `CloudFileItem`
struct TaskCloudFileItem: Encodable, Sendable, Equatable {
let fileId: Int
let remark: String
enum CodingKeys: String, CodingKey {
case fileId = "file_id"
case remark
}
}
/// Android `UploadFileItem`
struct TaskUploadFileItem: Encodable, Sendable, Equatable {
let fileUrl: String
let fileName: String
let remark: String
enum CodingKeys: String, CodingKey {
case fileUrl = "file_url"
case fileName = "file_name"
case remark
}
}
/// Android `AvailableOrderResponse`
struct AvailableOrder: Decodable, Sendable, Equatable, Hashable {
let projectName: String
let orderNumber: String
let orderStatus: Int
let orderStatusLabel: String
let payTime: String
let userId: Int
let userPhone: String
enum CodingKeys: String, CodingKey {
case projectName = "project_name"
case orderNumber = "order_number"
case orderStatus = "order_status"
case orderStatusLabel = "order_status_label"
case payTime = "pay_time"
case userId = "user_id"
case userPhone = "user_phone"
}
init(
projectName: String = "",
orderNumber: String = "",
orderStatus: Int = 0,
orderStatusLabel: String = "",
payTime: String = "",
userId: Int = 0,
userPhone: String = ""
) {
self.projectName = projectName
self.orderNumber = orderNumber
self.orderStatus = orderStatus
self.orderStatusLabel = orderStatusLabel
self.payTime = payTime
self.userId = userId
self.userPhone = userPhone
}
}
/// / Android `CloudFileEntity`
struct CloudFile: Decodable, Sendable, Equatable, Hashable {
let id: Int
let parentFolderId: Int
let fileUrl: String
let coverUrl: String
let updatedAt: String
let name: String
let createdAt: String
let childNum: Int
let type: Int
let fileSize: Int64
enum CodingKeys: String, CodingKey {
case id
case parentFolderId = "parent_folder_id"
case fileUrl = "file_url"
case coverUrl = "cover_url"
case updatedAt = "updated_at"
case name
case createdAt = "created_at"
case childNum = "child_num"
case type
case fileSize = "file_size"
}
init(
id: Int = 0,
parentFolderId: Int = 0,
fileUrl: String = "",
coverUrl: String = "",
updatedAt: String = "",
name: String = "",
createdAt: String = "",
childNum: Int = 0,
type: Int = 0,
fileSize: Int64 = 0
) {
self.id = id
self.parentFolderId = parentFolderId
self.fileUrl = fileUrl
self.coverUrl = coverUrl
self.updatedAt = updatedAt
self.name = name
self.createdAt = createdAt
self.childNum = childNum
self.type = type
self.fileSize = fileSize
}
///
var isFolder: Bool { type == 99 }
///
var isImage: Bool { type == 2 }
///
var isVideo: Bool { type == 1 }
///
var isSelectableMedia: Bool { isImage || isVideo }
}
///
struct CloudFileListResponse: Decodable, Sendable, Equatable {
let total: Int
let list: [CloudFile]
init(total: Int = 0, list: [CloudFile] = []) {
self.total = total
self.list = list
}
}
///
struct CloudPathItem: Sendable, Equatable, Hashable {
let id: Int
let name: String
}
///
enum TaskFileSource: String, Sendable, Equatable {
case cloud
case upload
}
/// Android `TaskFileItem`
struct TaskFileItem: Sendable, Equatable, Hashable, Identifiable {
let id: String
let source: TaskFileSource
let cloudFile: CloudFile?
let uploadFileName: String?
let uploadFileType: Int
let uploadTaskId: String?
var uploadFileUrl: String?
var uploadCoverUrl: String?
var remark: String
var isUploading: Bool
var uploadProgress: Int
init(
id: String? = nil,
source: TaskFileSource,
cloudFile: CloudFile? = nil,
uploadFileUrl: String? = nil,
uploadFileName: String? = nil,
uploadFileType: Int = 0,
uploadCoverUrl: String? = nil,
remark: String = "",
uploadTaskId: String? = nil,
isUploading: Bool = false,
uploadProgress: Int = 0
) {
if let id {
self.id = id
} else if source == .cloud, let cloudFile {
self.id = "cloud_\(cloudFile.id)"
} else if let uploadTaskId {
self.id = "upload_\(uploadTaskId)"
} else {
self.id = UUID().uuidString
}
self.source = source
self.cloudFile = cloudFile
self.uploadFileUrl = uploadFileUrl
self.uploadFileName = uploadFileName
self.uploadFileType = uploadFileType
self.uploadCoverUrl = uploadCoverUrl
self.remark = remark
self.uploadTaskId = uploadTaskId
self.isUploading = isUploading
self.uploadProgress = uploadProgress
}
/// URL
var previewURLString: String {
switch source {
case .cloud:
let cover = cloudFile?.coverUrl ?? ""
if !cover.isEmpty { return cover }
return cloudFile?.fileUrl ?? ""
case .upload:
if uploadFileType == 1 {
return uploadCoverUrl ?? uploadFileUrl ?? ""
}
return uploadFileUrl ?? ""
}
}
/// 1=2=
var fileType: Int {
switch source {
case .cloud:
return cloudFile?.type ?? 0
case .upload:
return uploadFileType
}
}
///
var displayName: String {
switch source {
case .cloud:
return cloudFile?.name ?? ""
case .upload:
return uploadFileName ?? ""
}
}
}

View File

@ -0,0 +1,164 @@
//
// CloudStoragePickForTaskViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android
final class CloudStoragePickForTaskViewModel {
private(set) var pathStack: [CloudPathItem] = [CloudPathItem(id: 0, name: "全部文件")]
private(set) var files: [CloudFile] = []
private(set) var selectedFiles: [Int: CloudFile] = [:]
private(set) var searchText = ""
private(set) var filterType = 0
private(set) var sortType = 2
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private let pageSize = 20
private let importedFileIDs: Set<Int>
init(importedFileIDs: Set<Int> = []) {
self.importedFileIDs = importedFileIDs
}
/// ID
var currentFolderID: Int {
pathStack.last?.id ?? 0
}
///
var selectedFileList: [CloudFile] {
Array(selectedFiles.values)
}
///
func isImported(_ fileID: Int) -> Bool {
importedFileIDs.contains(fileID)
}
///
func isSelected(_ fileID: Int) -> Bool {
selectedFiles[fileID] != nil
}
///
func updateSearchText(_ text: String) {
searchText = text
notifyStateChange()
}
///
func updateFilterType(_ type: Int) {
filterType = type
notifyStateChange()
}
///
func updateSortType(_ type: Int) {
sortType = type
notifyStateChange()
}
///
func refresh(api: TaskAPI) async {
currentPage = 1
isRefreshing = true
notifyStateChange()
await loadPage(api: api, reset: true)
isRefreshing = false
notifyStateChange()
}
///
func loadMore(api: TaskAPI) async {
guard canLoadMore, !isLoading else { return }
currentPage += 1
await loadPage(api: api, reset: false)
}
///
func handleItemTap(_ file: CloudFile, api: TaskAPI) async {
if file.isFolder {
pathStack.append(CloudPathItem(id: file.id, name: file.name))
searchText = ""
await refresh(api: api)
return
}
guard file.isSelectableMedia else { return }
if isImported(file.id) {
onShowMessage?("已选择")
return
}
if selectedFiles[file.id] != nil {
selectedFiles.removeValue(forKey: file.id)
} else {
selectedFiles[file.id] = file
}
notifyStateChange()
}
///
func navigateToPathIndex(_ index: Int, api: TaskAPI) async {
guard index >= 0, index < pathStack.count else { return }
pathStack = Array(pathStack.prefix(index + 1))
searchText = ""
await refresh(api: api)
}
///
func navigateBack(api: TaskAPI) async -> Bool {
guard pathStack.count > 1 else { return false }
pathStack.removeLast()
searchText = ""
await refresh(api: api)
return true
}
private func loadPage(api: TaskAPI, reset: Bool) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let response = try await api.cloudFileList(
parentFolderId: currentFolderID,
name: searchText,
type: filterType,
orderBy: sortType,
page: currentPage,
pageSize: pageSize
)
totalCount = response.total
if reset {
files = response.list
} else {
files.append(contentsOf: response.list)
}
canLoadMore = files.count < totalCount
} catch is CancellationError {
return
} catch {
if reset {
files = []
}
canLoadMore = false
onShowMessage?(error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -0,0 +1,287 @@
//
// TaskAddViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `TaskAddViewModel`
final class TaskAddViewModel {
private(set) var taskName = ""
private(set) var taskDetails = ""
private(set) var selectedOrderNumber: String?
private(set) var urgentHour = 0
private(set) var selectedFiles: [TaskFileItem] = []
private(set) var isUploading = false
private(set) var showSuccessDialog = false
private(set) var showRemarkDialog = false
private(set) var remarkDialogText = ""
private(set) var remarkTargetFileID: String?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let appStore: AppStore
private var pendingRemarkFileID: String?
init(appStore: AppStore = .shared) {
self.appStore = appStore
}
///
func updateTaskName(_ value: String) {
taskName = String(value.prefix(20))
notifyStateChange()
}
///
func updateTaskDetails(_ value: String) {
taskDetails = String(value.prefix(200))
notifyStateChange()
}
///
func setSelectedOrderNumber(_ orderNumber: String?) {
let trimmed = orderNumber?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
selectedOrderNumber = trimmed.isEmpty ? nil : trimmed
notifyStateChange()
}
///
func setUrgentHour(_ hour: Int) {
urgentHour = max(0, hour)
notifyStateChange()
}
///
func updateCustomUrgentHourText(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let hour = Int(trimmed), hour > 0 else {
urgentHour = 1
notifyStateChange()
return
}
urgentHour = hour
notifyStateChange()
}
///
func addCloudFiles(_ files: [CloudFile]) {
let existingIDs = Set(selectedFiles.compactMap { $0.cloudFile?.id })
let newItems = files
.filter { !existingIDs.contains($0.id) }
.map {
TaskFileItem(source: .cloud, cloudFile: $0)
}
guard !newItems.isEmpty else { return }
selectedFiles.append(contentsOf: newItems)
if newItems.count == 1 {
pendingRemarkFileID = newItems[0].id
}
notifyStateChange()
}
/// ID pending
func consumePendingRemarkFileID() -> String? {
defer { pendingRemarkFileID = nil }
return pendingRemarkFileID
}
///
@discardableResult
func addUploadPlaceholder(fileName: String, fileType: Int, uploadTaskId: String) -> TaskFileItem {
let item = TaskFileItem(
source: .upload,
uploadFileName: fileName,
uploadFileType: fileType,
uploadTaskId: uploadTaskId,
isUploading: true,
uploadProgress: 0
)
selectedFiles.append(item)
isUploading = true
notifyStateChange()
return item
}
///
func updateUploadProgress(uploadTaskId: String, progress: Int) {
selectedFiles = selectedFiles.map { item in
guard item.uploadTaskId == uploadTaskId else { return item }
var updated = item
updated.uploadProgress = progress
updated.isUploading = progress < 100
return updated
}
notifyStateChange()
}
/// URL
func markUploadSucceeded(uploadTaskId: String, fileURL: String, coverURL: String? = nil, autoRemarkWhenSingle: Bool) {
selectedFiles = selectedFiles.map { item in
guard item.uploadTaskId == uploadTaskId else { return item }
var updated = item
updated.uploadFileUrl = fileURL
updated.uploadCoverUrl = coverURL
updated.isUploading = false
updated.uploadProgress = 100
return updated
}
isUploading = selectedFiles.contains(where: \.isUploading)
if autoRemarkWhenSingle, let item = selectedFiles.first(where: { $0.uploadTaskId == uploadTaskId }) {
pendingRemarkFileID = item.id
}
notifyStateChange()
}
///
func markUploadFailed(uploadTaskId: String) {
selectedFiles.removeAll { $0.uploadTaskId == uploadTaskId }
isUploading = selectedFiles.contains(where: \.isUploading)
notifyStateChange()
}
///
func removeTaskFile(id: String) {
selectedFiles.removeAll { $0.id == id }
isUploading = selectedFiles.contains(where: \.isUploading)
notifyStateChange()
}
///
func presentRemarkDialog(for fileID: String) {
guard let file = selectedFiles.first(where: { $0.id == fileID }) else { return }
remarkTargetFileID = fileID
remarkDialogText = file.remark
showRemarkDialog = true
notifyStateChange()
}
///
func updateRemarkDialogText(_ text: String) {
remarkDialogText = String(text.prefix(50))
notifyStateChange()
}
///
func saveRemarkDialog() {
guard let fileID = remarkTargetFileID else { return }
selectedFiles = selectedFiles.map { item in
guard item.id == fileID else { return item }
var updated = item
updated.remark = remarkDialogText
return updated
}
hideRemarkDialog()
}
///
func hideRemarkDialog() {
showRemarkDialog = false
remarkTargetFileID = nil
remarkDialogText = ""
notifyStateChange()
}
///
var remarkTargetFile: TaskFileItem? {
guard let fileID = remarkTargetFileID else { return nil }
return selectedFiles.first { $0.id == fileID }
}
///
func validateAndSubmit(api: TaskAPI) async {
if isUploading {
onShowMessage?("文件正在上传中,请稍后添加!")
return
}
let trimmedName = taskName.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedName.isEmpty {
onShowMessage?("请填写任务名称!")
return
}
if trimmedName.count > 20 {
onShowMessage?("任务名称不能超过20字")
return
}
if taskDetails.count > 200 {
onShowMessage?("备注信息不能超过200字")
return
}
let request = buildAddTaskRequest(name: trimmedName)
do {
try await api.createTask(request)
showSuccessDialog = true
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func buildAddTaskRequest(name: String? = nil) -> AddTaskRequest {
let cloudItems = selectedFiles
.filter { $0.source == .cloud }
.compactMap { item -> TaskCloudFileItem? in
guard let fileId = item.cloudFile?.id else { return nil }
return TaskCloudFileItem(fileId: fileId, remark: item.remark)
}
let uploadItems = selectedFiles
.filter { $0.source == .upload }
.compactMap { item -> TaskUploadFileItem? in
guard let url = item.uploadFileUrl, !url.isEmpty else { return nil }
return TaskUploadFileItem(
fileUrl: url,
fileName: item.uploadFileName ?? "",
remark: item.remark
)
}
return AddTaskRequest(
scenicId: appStore.currentScenicId,
name: name ?? taskName,
orderNumber: selectedOrderNumber,
remark: taskDetails,
urgentHour: urgentHour,
cloudFile: cloudItems,
uploadFile: uploadItems
)
}
///
func handleContinueAdding() {
resetAllState()
}
///
func dismissSuccessDialog() {
showSuccessDialog = false
notifyStateChange()
}
///
func resetAllState() {
taskName = ""
taskDetails = ""
selectedOrderNumber = nil
urgentHour = 0
selectedFiles = []
isUploading = false
showSuccessDialog = false
hideRemarkDialog()
pendingRemarkFileID = nil
notifyStateChange()
}
/// ID
var importedCloudFileIDs: Set<Int> {
Set(selectedFiles.compactMap { $0.cloudFile?.id })
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -0,0 +1,70 @@
//
// TaskOrderSelectViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `OrderSelectViewModel`
final class TaskOrderSelectViewModel {
private(set) var orders: [AvailableOrder] = []
private(set) var selectedOrderNumber: String?
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let appStore: AppStore
init(appStore: AppStore = .shared, initialOrderNumber: String? = nil) {
self.appStore = appStore
let trimmed = initialOrderNumber?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
selectedOrderNumber = trimmed.isEmpty ? nil : trimmed
}
///
func loadOrders(api: TaskAPI) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
let scenicId = appStore.currentScenicId
guard scenicId > 0 else {
orders = []
onShowMessage?("请先选择景区")
return
}
do {
orders = try await api.availableOrders(scenicId: scenicId)
} catch is CancellationError {
return
} catch {
orders = []
onShowMessage?(error.localizedDescription)
}
}
///
func toggleSelection(orderNumber: String) {
if selectedOrderNumber == orderNumber {
selectedOrderNumber = nil
} else {
selectedOrderNumber = orderNumber
}
notifyStateChange()
}
///
func confirmedOrderNumber() -> String? {
selectedOrderNumber
}
private func notifyStateChange() {
onStateChange?()
}
}