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:
@ -17,6 +17,7 @@ final class NetworkServices {
|
|||||||
let orderAPI: OrderAPI
|
let orderAPI: OrderAPI
|
||||||
let homeAPI: HomeAPI
|
let homeAPI: HomeAPI
|
||||||
let paymentAPI: PaymentAPI
|
let paymentAPI: PaymentAPI
|
||||||
|
let taskAPI: TaskAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
let ossUploadService: OSSUploadService
|
let ossUploadService: OSSUploadService
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ final class NetworkServices {
|
|||||||
orderAPI = OrderAPI(client: client)
|
orderAPI = OrderAPI(client: client)
|
||||||
homeAPI = HomeAPI(client: client)
|
homeAPI = HomeAPI(client: client)
|
||||||
paymentAPI = PaymentAPI(client: client)
|
paymentAPI = PaymentAPI(client: client)
|
||||||
|
taskAPI = TaskAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
client.bindAuthTokenProvider {
|
client.bindAuthTokenProvider {
|
||||||
@ -46,6 +48,7 @@ final class NetworkServices {
|
|||||||
orderAPI = OrderAPI(client: apiClient)
|
orderAPI = OrderAPI(client: apiClient)
|
||||||
homeAPI = HomeAPI(client: apiClient)
|
homeAPI = HomeAPI(client: apiClient)
|
||||||
paymentAPI = PaymentAPI(client: apiClient)
|
paymentAPI = PaymentAPI(client: apiClient)
|
||||||
|
taskAPI = TaskAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,24 @@ final class OSSUploadService {
|
|||||||
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
|
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上传任务素材,对齐 Android `moduleType = task_upload`。
|
||||||
|
func uploadTaskFile(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
fileType: Int,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
try await uploadFile(
|
||||||
|
data: data,
|
||||||
|
fileName: fileName,
|
||||||
|
fileType: fileType,
|
||||||
|
scenicId: scenicId,
|
||||||
|
moduleType: "task_upload",
|
||||||
|
onProgress: onProgress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func uploadFile(
|
private func uploadFile(
|
||||||
data: Data,
|
data: Data,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
@ -125,6 +143,8 @@ enum OSSUploadPolicy {
|
|||||||
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "bank_card":
|
case "bank_card":
|
||||||
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "task_upload":
|
||||||
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
default:
|
default:
|
||||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,8 @@ enum HomeRouteHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
showDeveloping(from: viewController)
|
showDeveloping(from: viewController)
|
||||||
|
case "task_management", "task_management_editor":
|
||||||
|
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||||
default:
|
default:
|
||||||
showDeveloping(from: viewController)
|
showDeveloping(from: viewController)
|
||||||
}
|
}
|
||||||
|
|||||||
102
suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift
Normal file
102
suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift
Normal 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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
/// 个人信息页 ViewModel,负责资料加载与账号相关操作。
|
||||||
final class ProfileViewModel {
|
final class ProfileViewModel {
|
||||||
|
|
||||||
private(set) var userInfo: UserInfoResponse?
|
private(set) var userInfo: UserInfoResponse?
|
||||||
@ -13,11 +13,6 @@ final class ProfileViewModel {
|
|||||||
private(set) var bankCardInfo: BankCardInfo?
|
private(set) var bankCardInfo: BankCardInfo?
|
||||||
private(set) var isLoading = false
|
private(set) var isLoading = false
|
||||||
private(set) var isSaving = 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)?
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
@ -85,12 +80,6 @@ final class ProfileViewModel {
|
|||||||
return label.isEmpty ? "审核中" : label
|
return label.isEmpty ? "审核中" : label
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateEditingNickname(_ value: String) {
|
|
||||||
editingNickname = value
|
|
||||||
notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新加载用户资料,摄影师额外拉取实名与银行卡信息。
|
|
||||||
func reload(api: ProfileAPI) async throws {
|
func reload(api: ProfileAPI) async throws {
|
||||||
guard !isLoading else { return }
|
guard !isLoading else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@ -119,77 +108,21 @@ final class ProfileViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginEditing() {
|
/// 从 `AppStore` 同步头像与昵称,用于资料保存后的本地即时刷新。
|
||||||
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
|
func applyLocalProfileUpdate(from store: AppStore = .shared) {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
let previous = userInfo ?? UserInfoResponse()
|
let previous = userInfo ?? UserInfoResponse()
|
||||||
|
let nextNickname = store.userName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let nextAvatar = store.avatar.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
userInfo = UserInfoResponse(
|
userInfo = UserInfoResponse(
|
||||||
avatar: uploadedAvatarURL ?? previous.avatar,
|
avatar: nextAvatar.isEmpty ? previous.avatar : nextAvatar,
|
||||||
realName: previous.realName,
|
realName: previous.realName,
|
||||||
phone: previous.phone,
|
phone: previous.phone,
|
||||||
nickname: nextNickname,
|
nickname: nextNickname.isEmpty ? previous.nickname : nextNickname,
|
||||||
roleName: previous.roleName,
|
roleName: previous.roleName,
|
||||||
status: previous.status,
|
status: previous.status,
|
||||||
statusName: previous.statusName
|
statusName: previous.statusName
|
||||||
)
|
)
|
||||||
AppStore.shared.userName = nextNickname
|
notifyStateChange()
|
||||||
if let uploadedAvatarURL {
|
|
||||||
AppStore.shared.avatar = uploadedAvatarURL
|
|
||||||
}
|
|
||||||
clearPendingAvatar()
|
|
||||||
cancelEditing()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
||||||
@ -215,21 +148,6 @@ final class ProfileViewModel {
|
|||||||
return "**** **** **** \(digits.suffix(4))"
|
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? {
|
private func nonEmpty(_ value: String?) -> String? {
|
||||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return text.isEmpty ? nil : text
|
return text.isEmpty ? nil : text
|
||||||
|
|||||||
81
suixinkan/Features/Task/API/TaskAPI.swift
Normal file
81
suixinkan/Features/Task/API/TaskAPI.swift
Normal 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 {}
|
||||||
260
suixinkan/Features/Task/Models/TaskModels.swift
Normal file
260
suixinkan/Features/Task/Models/TaskModels.swift
Normal 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 ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
287
suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift
Normal file
287
suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift
Normal 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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -185,8 +185,8 @@ final class HomeViewController: BaseViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
cell.cardView.onSubmitTask = { [weak self] in
|
cell.cardView.onSubmitTask = { [weak self] in
|
||||||
self?.showToast("功能开发中")
|
self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||||
}
|
}
|
||||||
cell.cardView.onToggleOnline = { [weak self] in
|
cell.cardView.onToggleOnline = { [weak self] in
|
||||||
self?.viewModel.showOnlineStatusSwitchDialog()
|
self?.viewModel.showOnlineStatusSwitchDialog()
|
||||||
}
|
}
|
||||||
|
|||||||
195
suixinkan/UI/Profile/ProfileEditViewController.swift
Normal file
195
suixinkan/UI/Profile/ProfileEditViewController.swift
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// ProfileEditViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 资料编辑页,支持修改头像与昵称并保存到服务端。
|
||||||
|
final class ProfileEditViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let viewModel: ProfileEditViewModel
|
||||||
|
private let profileAPI = NetworkServices.shared.profileAPI
|
||||||
|
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||||
|
|
||||||
|
private let avatarContainer = UIView()
|
||||||
|
private let avatarImageView = UIImageView()
|
||||||
|
private let avatarHintLabel = UILabel()
|
||||||
|
private let nicknameCard = UIView()
|
||||||
|
private let nicknameTitleLabel = UILabel()
|
||||||
|
private let nicknameField = UITextField()
|
||||||
|
private let saveButton = AppButton(title: "保存", style: .primary)
|
||||||
|
|
||||||
|
/// 使用「我的」页当前展示的资料快照进入编辑。
|
||||||
|
init(nickname: String, avatarURL: String) {
|
||||||
|
viewModel = ProfileEditViewModel(nickname: nickname, avatarURL: avatarURL)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "编辑资料"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||||
|
|
||||||
|
avatarImageView.layer.cornerRadius = 48
|
||||||
|
avatarImageView.clipsToBounds = true
|
||||||
|
avatarImageView.contentMode = .scaleAspectFill
|
||||||
|
avatarImageView.isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
avatarHintLabel.text = "点击更换头像"
|
||||||
|
avatarHintLabel.font = .systemFont(ofSize: 14)
|
||||||
|
avatarHintLabel.textColor = AppColor.textTertiary
|
||||||
|
avatarHintLabel.textAlignment = .center
|
||||||
|
|
||||||
|
avatarContainer.addSubview(avatarImageView)
|
||||||
|
avatarContainer.addSubview(avatarHintLabel)
|
||||||
|
view.addSubview(avatarContainer)
|
||||||
|
|
||||||
|
nicknameCard.backgroundColor = .white
|
||||||
|
nicknameCard.layer.cornerRadius = AppRadius.sm
|
||||||
|
view.addSubview(nicknameCard)
|
||||||
|
|
||||||
|
nicknameTitleLabel.text = "昵称"
|
||||||
|
nicknameTitleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
nicknameTitleLabel.textColor = AppColor.textTertiary
|
||||||
|
|
||||||
|
nicknameField.font = .systemFont(ofSize: 16)
|
||||||
|
nicknameField.textColor = AppColor.text333
|
||||||
|
nicknameField.placeholder = "请输入昵称"
|
||||||
|
nicknameField.clearButtonMode = .whileEditing
|
||||||
|
nicknameField.returnKeyType = .done
|
||||||
|
|
||||||
|
nicknameCard.addSubview(nicknameTitleLabel)
|
||||||
|
nicknameCard.addSubview(nicknameField)
|
||||||
|
|
||||||
|
view.addSubview(saveButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
avatarContainer.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(32)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
}
|
||||||
|
avatarImageView.snp.makeConstraints { make in
|
||||||
|
make.top.centerX.equalToSuperview()
|
||||||
|
make.width.height.equalTo(96)
|
||||||
|
}
|
||||||
|
avatarHintLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(avatarImageView.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
nicknameCard.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(avatarContainer.snp.bottom).offset(32)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
}
|
||||||
|
nicknameTitleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
nicknameField.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(nicknameTitleLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveButton.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarImageView.addGestureRecognizer(
|
||||||
|
UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
|
||||||
|
)
|
||||||
|
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
||||||
|
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyViewModel() {
|
||||||
|
nicknameField.text = viewModel.editingNickname
|
||||||
|
|
||||||
|
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
|
||||||
|
avatarImageView.image = image
|
||||||
|
} else {
|
||||||
|
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveButton.isEnabled = !viewModel.isSaving
|
||||||
|
avatarImageView.alpha = viewModel.isSaving ? 0.6 : 1
|
||||||
|
nicknameField.isEnabled = !viewModel.isSaving
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func nicknameChanged() {
|
||||||
|
viewModel.updateEditingNickname(nicknameField.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func pickAvatar() {
|
||||||
|
guard !viewModel.isSaving else { return }
|
||||||
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||||
|
configuration.filter = .images
|
||||||
|
configuration.selectionLimit = 1
|
||||||
|
let picker = PHPickerViewController(configuration: configuration)
|
||||||
|
picker.delegate = self
|
||||||
|
present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func saveTapped() {
|
||||||
|
nicknameField.resignFirstResponder()
|
||||||
|
Task { await saveProfile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProfile() async {
|
||||||
|
let scenicId = AppStore.shared.currentScenicId
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
showToast("当前景区信息缺失")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showLoading()
|
||||||
|
defer { hideLoading() }
|
||||||
|
do {
|
||||||
|
try await viewModel.saveProfile(
|
||||||
|
api: profileAPI,
|
||||||
|
uploader: ossUploadService,
|
||||||
|
scenicId: scenicId
|
||||||
|
)
|
||||||
|
showToast("保存成功")
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
} catch {
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfileEditViewController: PHPickerViewControllerDelegate {
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||||
|
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||||
|
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try self.viewModel.prepareAvatarImage(data: data)
|
||||||
|
} catch {
|
||||||
|
self.showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,6 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
import PhotosUI
|
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -12,7 +11,6 @@ final class ProfileViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel = ProfileViewModel()
|
private let viewModel = ProfileViewModel()
|
||||||
private let profileAPI = NetworkServices.shared.profileAPI
|
private let profileAPI = NetworkServices.shared.profileAPI
|
||||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
@ -35,7 +33,7 @@ final class ProfileViewController: BaseViewController {
|
|||||||
private let logoutButton = UIButton(type: .system)
|
private let logoutButton = UIButton(type: .system)
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "个人信息"
|
title = "我的"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
@ -87,18 +85,18 @@ final class ProfileViewController: BaseViewController {
|
|||||||
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
|
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
|
||||||
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
|
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
|
||||||
|
|
||||||
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
|
||||||
|
|
||||||
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
|
|
||||||
avatarImageView.isUserInteractionEnabled = true
|
|
||||||
avatarImageView.addGestureRecognizer(avatarTap)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleAccountDidSwitch),
|
selector: #selector(handleAccountDidSwitch),
|
||||||
name: NotificationName.accountDidSwitch,
|
name: NotificationName.accountDidSwitch,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleProfileDidUpdate),
|
||||||
|
name: NotificationName.userProfileDidUpdate,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
@ -191,19 +189,9 @@ final class ProfileViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : viewModel.displayNickname
|
nicknameField.text = viewModel.displayNickname
|
||||||
nicknameField.isEnabled = viewModel.isEditingProfile
|
|
||||||
uidLabel.text = "UID:\(viewModel.displayUID)"
|
uidLabel.text = "UID:\(viewModel.displayUID)"
|
||||||
|
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||||
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
|
|
||||||
avatarImageView.image = image
|
|
||||||
} else {
|
|
||||||
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
editButton.setTitle(viewModel.isEditingProfile ? "完成" : "编辑", for: .normal)
|
|
||||||
editButton.isEnabled = !viewModel.isSaving
|
|
||||||
avatarImageView.alpha = viewModel.isEditingProfile ? 1 : 1
|
|
||||||
|
|
||||||
nameRow.configure(value: viewModel.displayRealName)
|
nameRow.configure(value: viewModel.displayRealName)
|
||||||
accountRow.configure(
|
accountRow.configure(
|
||||||
@ -289,26 +277,17 @@ final class ProfileViewController: BaseViewController {
|
|||||||
Task { await reloadProfile(showGlobalLoading: true) }
|
Task { await reloadProfile(showGlobalLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func nicknameChanged() {
|
@objc private func handleProfileDidUpdate() {
|
||||||
viewModel.updateEditingNickname(nicknameField.text ?? "")
|
viewModel.applyLocalProfileUpdate()
|
||||||
|
applyViewModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func editTapped() {
|
@objc private func editTapped() {
|
||||||
if viewModel.isEditingProfile {
|
let editVC = ProfileEditViewController(
|
||||||
Task { await saveProfile() }
|
nickname: viewModel.displayNickname,
|
||||||
} else {
|
avatarURL: viewModel.displayAvatarURL
|
||||||
viewModel.beginEditing()
|
)
|
||||||
}
|
navigationController?.pushViewController(editVC, animated: true)
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func pickAvatar() {
|
|
||||||
guard viewModel.isEditingProfile else { return }
|
|
||||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
|
||||||
configuration.filter = .images
|
|
||||||
configuration.selectionLimit = 1
|
|
||||||
let picker = PHPickerViewController(configuration: configuration)
|
|
||||||
picker.delegate = self
|
|
||||||
present(picker, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func accountSwitchTapped() {
|
@objc private func accountSwitchTapped() {
|
||||||
@ -382,26 +361,6 @@ final class ProfileViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveProfile() async {
|
|
||||||
let scenicId = AppStore.shared.currentScenicId
|
|
||||||
guard scenicId > 0 else {
|
|
||||||
showToast("当前景区信息缺失")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showLoading()
|
|
||||||
defer { hideLoading() }
|
|
||||||
do {
|
|
||||||
try await viewModel.saveProfile(
|
|
||||||
api: profileAPI,
|
|
||||||
uploader: ossUploadService,
|
|
||||||
scenicId: scenicId
|
|
||||||
)
|
|
||||||
showToast("保存成功")
|
|
||||||
} catch {
|
|
||||||
showToast(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePassword(_ password: String) async {
|
private func updatePassword(_ password: String) async {
|
||||||
showLoading()
|
showLoading()
|
||||||
defer { hideLoading() }
|
defer { hideLoading() }
|
||||||
@ -413,20 +372,3 @@ final class ProfileViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController: PHPickerViewControllerDelegate {
|
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
||||||
picker.dismiss(animated: true)
|
|
||||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
|
||||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
|
||||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
do {
|
|
||||||
try self.viewModel.prepareAvatarImage(data: data)
|
|
||||||
} catch {
|
|
||||||
self.showToast(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
323
suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift
Normal file
323
suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
//
|
||||||
|
// CloudStoragePickForTaskViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 任务云盘多选页,对齐 Android `CloudStorageListForTaskScreen`。
|
||||||
|
final class CloudStoragePickForTaskViewController: BaseViewController {
|
||||||
|
|
||||||
|
var onConfirmed: (([CloudFile]) -> Void)?
|
||||||
|
|
||||||
|
private let viewModel: CloudStoragePickForTaskViewModel
|
||||||
|
private let taskAPI = NetworkServices.shared.taskAPI
|
||||||
|
|
||||||
|
private let searchField = UITextField()
|
||||||
|
private let pathScrollView = UIScrollView()
|
||||||
|
private let pathStack = UIStackView()
|
||||||
|
private var collectionView: UICollectionView!
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Int, Int>!
|
||||||
|
private let confirmButton = AppButton(title: "确定")
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
|
||||||
|
init(importedFileIDs: Set<Int> = []) {
|
||||||
|
viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "云盘导入"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
searchField.placeholder = "搜索文件"
|
||||||
|
searchField.borderStyle = .roundedRect
|
||||||
|
searchField.font = .app(.body)
|
||||||
|
searchField.returnKeyType = .search
|
||||||
|
searchField.delegate = self
|
||||||
|
|
||||||
|
pathStack.axis = .horizontal
|
||||||
|
pathStack.spacing = AppSpacing.xs
|
||||||
|
pathScrollView.showsHorizontalScrollIndicator = false
|
||||||
|
pathScrollView.addSubview(pathStack)
|
||||||
|
pathStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
make.height.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
|
collectionView.backgroundColor = AppColor.pageBackground
|
||||||
|
collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier)
|
||||||
|
collectionView.delegate = self
|
||||||
|
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
|
||||||
|
[weak self] collectionView, indexPath, fileID in
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: CloudPickCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! CloudPickCell
|
||||||
|
if let file = self?.viewModel.files.first(where: { $0.id == fileID }) {
|
||||||
|
cell.apply(
|
||||||
|
file: file,
|
||||||
|
isSelected: self?.viewModel.isSelected(fileID) == true,
|
||||||
|
isImported: self?.viewModel.isImported(fileID) == true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
view.addSubview(searchField)
|
||||||
|
view.addSubview(pathScrollView)
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(confirmButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
searchField.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(40)
|
||||||
|
}
|
||||||
|
pathScrollView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(searchField.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(28)
|
||||||
|
}
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.refresh(api: taskAPI) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
rebuildPathButtons()
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems(viewModel.files.map(\.id))
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
if viewModel.isLoading && viewModel.files.isEmpty {
|
||||||
|
showLoading()
|
||||||
|
} else {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildPathButtons() {
|
||||||
|
pathStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
for (index, item) in viewModel.pathStack.enumerated() {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.setTitle(item.name, for: .normal)
|
||||||
|
button.titleLabel?.font = .app(.caption)
|
||||||
|
button.tag = index
|
||||||
|
button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside)
|
||||||
|
pathStack.addArrangedSubview(button)
|
||||||
|
if index < viewModel.pathStack.count - 1 {
|
||||||
|
let arrow = UILabel()
|
||||||
|
arrow.text = ">"
|
||||||
|
arrow.font = .app(.caption)
|
||||||
|
arrow.textColor = AppColor.textTertiary
|
||||||
|
pathStack.addArrangedSubview(arrow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func pathTapped(_ sender: UIButton) {
|
||||||
|
Task { await viewModel.navigateToPathIndex(sender.tag, api: taskAPI) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
onConfirmed?(viewModel.selectedFileList)
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||||
|
UICollectionViewCompositionalLayout { _, environment in
|
||||||
|
let columns = 3
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let inset = AppSpacing.md * 2
|
||||||
|
let availableWidth = environment.container.effectiveContentSize.width - inset
|
||||||
|
let totalSpacing = spacing * CGFloat(columns - 1)
|
||||||
|
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
|
||||||
|
let itemSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .absolute(itemWidth),
|
||||||
|
heightDimension: .absolute(itemWidth)
|
||||||
|
)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let groupSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(itemWidth)
|
||||||
|
)
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(
|
||||||
|
layoutSize: groupSize,
|
||||||
|
repeatingSubitem: item,
|
||||||
|
count: columns
|
||||||
|
)
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = spacing
|
||||||
|
section.contentInsets = NSDirectionalEdgeInsets(
|
||||||
|
top: 0,
|
||||||
|
leading: AppSpacing.md,
|
||||||
|
bottom: AppSpacing.md,
|
||||||
|
trailing: AppSpacing.md
|
||||||
|
)
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CloudStoragePickForTaskViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let fileID = dataSource.itemIdentifier(for: indexPath),
|
||||||
|
let file = viewModel.files.first(where: { $0.id == fileID }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { await viewModel.handleItemTap(file, api: taskAPI) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
let offsetY = scrollView.contentOffset.y
|
||||||
|
let contentHeight = scrollView.contentSize.height
|
||||||
|
let frameHeight = scrollView.frame.size.height
|
||||||
|
if offsetY > contentHeight - frameHeight - 120 {
|
||||||
|
Task { await viewModel.loadMore(api: taskAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CloudStoragePickForTaskViewController: UITextFieldDelegate {
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
viewModel.updateSearchText(textField.text ?? "")
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
Task { await viewModel.refresh(api: taskAPI) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘选择 cell。
|
||||||
|
private final class CloudPickCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
static let reuseIdentifier = "CloudPickCell"
|
||||||
|
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let nameLabel = UILabel()
|
||||||
|
private let badgeView = UIView()
|
||||||
|
private let folderIconView = UIImageView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = .white
|
||||||
|
contentView.layer.cornerRadius = AppRadius.sm
|
||||||
|
contentView.clipsToBounds = true
|
||||||
|
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
|
||||||
|
folderIconView.image = UIImage(systemName: "folder.fill")
|
||||||
|
folderIconView.tintColor = AppColor.primary
|
||||||
|
folderIconView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
nameLabel.font = .app(.caption)
|
||||||
|
nameLabel.textColor = AppColor.textPrimary
|
||||||
|
nameLabel.numberOfLines = 2
|
||||||
|
nameLabel.textAlignment = .center
|
||||||
|
|
||||||
|
badgeView.backgroundColor = AppColor.primary
|
||||||
|
badgeView.layer.cornerRadius = 10
|
||||||
|
badgeView.isHidden = true
|
||||||
|
|
||||||
|
contentView.addSubview(imageView)
|
||||||
|
contentView.addSubview(folderIconView)
|
||||||
|
contentView.addSubview(nameLabel)
|
||||||
|
contentView.addSubview(badgeView)
|
||||||
|
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(contentView.snp.width)
|
||||||
|
}
|
||||||
|
folderIconView.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(imageView)
|
||||||
|
make.width.height.equalTo(36)
|
||||||
|
}
|
||||||
|
nameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
|
||||||
|
}
|
||||||
|
badgeView.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
|
||||||
|
make.width.height.equalTo(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
imageView.kf.cancelDownloadTask()
|
||||||
|
imageView.image = nil
|
||||||
|
folderIconView.isHidden = true
|
||||||
|
badgeView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(file: CloudFile, isSelected: Bool, isImported: Bool) {
|
||||||
|
nameLabel.text = file.name
|
||||||
|
contentView.layer.borderWidth = isSelected ? 2 : 0
|
||||||
|
contentView.layer.borderColor = AppColor.primary.cgColor
|
||||||
|
badgeView.isHidden = !isSelected
|
||||||
|
|
||||||
|
if file.isFolder {
|
||||||
|
imageView.image = nil
|
||||||
|
folderIconView.isHidden = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folderIconView.isHidden = true
|
||||||
|
let preview = file.coverUrl.isEmpty ? file.fileUrl : file.coverUrl
|
||||||
|
if let url = URL(string: preview), !preview.isEmpty {
|
||||||
|
imageView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
imageView.image = UIImage(systemName: file.isVideo ? "video" : "photo")
|
||||||
|
imageView.tintColor = AppColor.textTertiary
|
||||||
|
}
|
||||||
|
if isImported {
|
||||||
|
contentView.alpha = 0.5
|
||||||
|
} else {
|
||||||
|
contentView.alpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
366
suixinkan/UI/Task/TaskAddViewController.swift
Normal file
366
suixinkan/UI/Task/TaskAddViewController.swift
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
//
|
||||||
|
// TaskAddViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// 添加任务页,对齐 Android `TaskAddScreen`。
|
||||||
|
final class TaskAddViewController: BaseViewController {
|
||||||
|
|
||||||
|
private let viewModel = TaskAddViewModel()
|
||||||
|
private let taskAPI = NetworkServices.shared.taskAPI
|
||||||
|
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||||
|
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
private let mediaSection = TaskAddMediaSectionView()
|
||||||
|
private let prioritySection = TaskPrioritySectionView()
|
||||||
|
private let orderButton = TaskOrderSelectButton()
|
||||||
|
private let nameField = TaskBorderTextField(placeholder: "请输入任务标题")
|
||||||
|
private let detailsField = TaskBorderTextView(placeholder: "请输入任务描述(必填)")
|
||||||
|
private let saveButton = AppButton(title: "保存")
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
|
||||||
|
private var remarkDialog: TaskFileRemarkDialogView?
|
||||||
|
private var pickingMediaType: Int = 2
|
||||||
|
private var detailsPlaceholder = "请输入任务描述(必填)"
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "添加任务"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = AppSpacing.sm
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
scrollView.addSubview(contentStack)
|
||||||
|
|
||||||
|
contentStack.addArrangedSubview(mediaSection)
|
||||||
|
contentStack.addArrangedSubview(prioritySection)
|
||||||
|
contentStack.addArrangedSubview(orderButton)
|
||||||
|
contentStack.addArrangedSubview(nameField)
|
||||||
|
contentStack.addArrangedSubview(detailsField)
|
||||||
|
bottomBar.addSubview(saveButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
saveButton.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
scrollView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
contentStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSection.onCloudImport = { [weak self] in self?.openCloudPicker() }
|
||||||
|
mediaSection.onLocalImport = { [weak self] in self?.presentLocalImportSheet() }
|
||||||
|
mediaSection.onFileTap = { [weak self] file in self?.viewModel.presentRemarkDialog(for: file.id) }
|
||||||
|
mediaSection.onDeleteFile = { [weak self] file in self?.confirmDelete(file: file) }
|
||||||
|
|
||||||
|
prioritySection.onUrgentHourChange = { [weak self] hour in
|
||||||
|
self?.viewModel.setUrgentHour(hour)
|
||||||
|
}
|
||||||
|
prioritySection.onCustomHourTextChange = { [weak self] text in
|
||||||
|
self?.viewModel.updateCustomUrgentHourText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderButton.addTarget(self, action: #selector(openOrderSelect), for: .touchUpInside)
|
||||||
|
nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
|
||||||
|
detailsField.textView.delegate = self
|
||||||
|
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
if let fileID = viewModel.consumePendingRemarkFileID() {
|
||||||
|
viewModel.presentRemarkDialog(for: fileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
mediaSection.apply(files: viewModel.selectedFiles)
|
||||||
|
prioritySection.apply(urgentHour: viewModel.urgentHour)
|
||||||
|
orderButton.apply(orderNumber: viewModel.selectedOrderNumber)
|
||||||
|
if nameField.textField.text != viewModel.taskName {
|
||||||
|
nameField.textField.text = viewModel.taskName
|
||||||
|
}
|
||||||
|
if detailsField.textView.textColor != AppColor.textTertiary,
|
||||||
|
detailsField.textView.text != viewModel.taskDetails {
|
||||||
|
detailsField.textView.text = viewModel.taskDetails
|
||||||
|
}
|
||||||
|
saveButton.isEnabled = !viewModel.isUploading
|
||||||
|
|
||||||
|
if viewModel.showRemarkDialog, let file = viewModel.remarkTargetFile {
|
||||||
|
showRemarkDialog(file: file)
|
||||||
|
} else {
|
||||||
|
hideRemarkDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.showSuccessDialog {
|
||||||
|
presentSuccessDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func nameChanged() {
|
||||||
|
viewModel.updateTaskName(nameField.textField.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openOrderSelect() {
|
||||||
|
let controller = TaskOrderSelectViewController(initialOrderNumber: viewModel.selectedOrderNumber)
|
||||||
|
controller.onConfirmed = { [weak self] orderNumber in
|
||||||
|
self?.viewModel.setSelectedOrderNumber(orderNumber)
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openCloudPicker() {
|
||||||
|
let controller = CloudStoragePickForTaskViewController(
|
||||||
|
importedFileIDs: viewModel.importedCloudFileIDs
|
||||||
|
)
|
||||||
|
controller.onConfirmed = { [weak self] files in
|
||||||
|
self?.viewModel.addCloudFiles(files)
|
||||||
|
if let fileID = self?.viewModel.consumePendingRemarkFileID() {
|
||||||
|
self?.viewModel.presentRemarkDialog(for: fileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentLocalImportSheet() {
|
||||||
|
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||||
|
sheet.addAction(UIAlertAction(title: "图片", style: .default) { [weak self] _ in
|
||||||
|
self?.presentMediaPicker(isImage: true)
|
||||||
|
})
|
||||||
|
sheet.addAction(UIAlertAction(title: "视频", style: .default) { [weak self] _ in
|
||||||
|
self?.presentMediaPicker(isImage: false)
|
||||||
|
})
|
||||||
|
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentMediaPicker(isImage: Bool) {
|
||||||
|
pickingMediaType = isImage ? 2 : 1
|
||||||
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||||
|
configuration.filter = isImage ? .images : .videos
|
||||||
|
configuration.selectionLimit = 50
|
||||||
|
let picker = PHPickerViewController(configuration: configuration)
|
||||||
|
picker.delegate = self
|
||||||
|
present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDelete(file: TaskFileItem) {
|
||||||
|
let alert = UIAlertController(title: "删除确认", message: "确定要删除此文件吗?", preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
||||||
|
self?.viewModel.removeTaskFile(id: file.id)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func saveTapped() {
|
||||||
|
showLoading()
|
||||||
|
Task {
|
||||||
|
await viewModel.validateAndSubmit(api: taskAPI)
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentSuccessDialog() {
|
||||||
|
guard viewModel.showSuccessDialog else { return }
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "添加成功",
|
||||||
|
message: "任务已成功添加。是否继续添加新任务?",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "继续添加", style: .default) { [weak self] _ in
|
||||||
|
self?.viewModel.handleContinueAdding()
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "返回", style: .cancel) { [weak self] _ in
|
||||||
|
self?.viewModel.dismissSuccessDialog()
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
viewModel.dismissSuccessDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showRemarkDialog(file: TaskFileItem) {
|
||||||
|
if remarkDialog == nil {
|
||||||
|
let dialog = TaskFileRemarkDialogView(frame: view.bounds)
|
||||||
|
dialog.onCancel = { [weak self] in
|
||||||
|
self?.viewModel.hideRemarkDialog()
|
||||||
|
}
|
||||||
|
dialog.onConfirm = { [weak self] in
|
||||||
|
guard let self, let dialog = self.remarkDialog else { return }
|
||||||
|
self.viewModel.updateRemarkDialogText(dialog.inputText)
|
||||||
|
self.viewModel.saveRemarkDialog()
|
||||||
|
}
|
||||||
|
remarkDialog = dialog
|
||||||
|
view.addSubview(dialog)
|
||||||
|
dialog.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
|
}
|
||||||
|
remarkDialog?.apply(file: file, text: viewModel.remarkDialogText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideRemarkDialog() {
|
||||||
|
remarkDialog?.removeFromSuperview()
|
||||||
|
remarkDialog = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadPickedResults(_ results: [PHPickerResult], isImage: Bool) {
|
||||||
|
let scenicId = AppStore.shared.currentScenicId
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
showToast("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let totalCount = results.count
|
||||||
|
for (index, result) in results.enumerated() {
|
||||||
|
let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||||
|
let placeholder = viewModel.addUploadPlaceholder(
|
||||||
|
fileName: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")",
|
||||||
|
fileType: isImage ? 2 : 1,
|
||||||
|
uploadTaskId: uploadTaskId
|
||||||
|
)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let payload = try await TaskMediaLoader.load(from: result, isImage: isImage)
|
||||||
|
let fileURL = try await ossUploadService.uploadTaskFile(
|
||||||
|
data: payload.data,
|
||||||
|
fileName: payload.fileName,
|
||||||
|
fileType: isImage ? 2 : 1,
|
||||||
|
scenicId: scenicId
|
||||||
|
) { [weak self] progress in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await taskAPI.registerUploadedFileURL(scenicId: scenicId, fileURL: fileURL, folderId: 0)
|
||||||
|
await MainActor.run {
|
||||||
|
self.viewModel.markUploadSucceeded(
|
||||||
|
uploadTaskId: uploadTaskId,
|
||||||
|
fileURL: fileURL,
|
||||||
|
autoRemarkWhenSingle: totalCount == 1
|
||||||
|
)
|
||||||
|
if totalCount == 1, let fileID = self.viewModel.consumePendingRemarkFileID() {
|
||||||
|
self.viewModel.presentRemarkDialog(for: fileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.viewModel.markUploadFailed(uploadTaskId: uploadTaskId)
|
||||||
|
self.showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = placeholder
|
||||||
|
_ = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TaskAddViewController: PHPickerViewControllerDelegate {
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
guard !results.isEmpty else { return }
|
||||||
|
uploadPickedResults(results, isImage: pickingMediaType == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TaskAddViewController: UITextViewDelegate {
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
if textView.textColor == AppColor.textTertiary {
|
||||||
|
textView.text = ""
|
||||||
|
textView.textColor = AppColor.textPrimary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
textView.text = detailsPlaceholder
|
||||||
|
textView.textColor = AppColor.textTertiary
|
||||||
|
viewModel.updateTaskDetails("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
guard textView.textColor != AppColor.textTertiary else { return }
|
||||||
|
viewModel.updateTaskDetails(textView.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 PHPicker 结果加载上传数据。
|
||||||
|
enum TaskMediaLoader {
|
||||||
|
struct Payload {
|
||||||
|
let data: Data
|
||||||
|
let fileName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(from result: PHPickerResult, isImage: Bool) async throws -> Payload {
|
||||||
|
let provider = result.itemProvider
|
||||||
|
if isImage {
|
||||||
|
return try await loadImage(from: provider)
|
||||||
|
}
|
||||||
|
return try await loadVideo(from: provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadImage(from provider: NSItemProvider) async throws -> Payload {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
if provider.canLoadObject(ofClass: UIImage.self) {
|
||||||
|
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else {
|
||||||
|
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: Payload(data: data, fileName: "image_\(UUID().uuidString).jpg"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadVideo(from provider: NSItemProvider) async throws -> Payload {
|
||||||
|
let typeIdentifier = UTType.movie.identifier
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let url, let data = try? Data(contentsOf: url) else {
|
||||||
|
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||||
|
continuation.resume(returning: Payload(data: data, fileName: "video_\(UUID().uuidString).\(ext)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
225
suixinkan/UI/Task/TaskOrderSelectViewController.swift
Normal file
225
suixinkan/UI/Task/TaskOrderSelectViewController.swift
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
//
|
||||||
|
// TaskOrderSelectViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 任务关联订单选择页,对齐 Android `OrderSelectScreen`。
|
||||||
|
final class TaskOrderSelectViewController: BaseViewController {
|
||||||
|
|
||||||
|
var onConfirmed: ((String?) -> Void)?
|
||||||
|
|
||||||
|
private let viewModel: TaskOrderSelectViewModel
|
||||||
|
private let taskAPI = NetworkServices.shared.taskAPI
|
||||||
|
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let confirmButton = AppButton(title: "确定")
|
||||||
|
private let emptyLabel = UILabel()
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
|
||||||
|
init(initialOrderNumber: String? = nil) {
|
||||||
|
viewModel = TaskOrderSelectViewModel(initialOrderNumber: initialOrderNumber)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "订单列表"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
tableView.backgroundColor = AppColor.pageBackground
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.register(TaskOrderCell.self, forCellReuseIdentifier: TaskOrderCell.reuseIdentifier)
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.delegate = self
|
||||||
|
|
||||||
|
emptyLabel.text = "无有效订单"
|
||||||
|
emptyLabel.font = .app(.body)
|
||||||
|
emptyLabel.textColor = AppColor.textPrimary
|
||||||
|
emptyLabel.textAlignment = .center
|
||||||
|
emptyLabel.isHidden = true
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(emptyLabel)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(confirmButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
emptyLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(tableView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.loadOrders(api: taskAPI) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
emptyLabel.isHidden = viewModel.isLoading || !viewModel.orders.isEmpty
|
||||||
|
tableView.reloadData()
|
||||||
|
if viewModel.isLoading {
|
||||||
|
showLoading()
|
||||||
|
} else {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
onConfirmed?(viewModel.confirmedOrderNumber())
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TaskOrderSelectViewController: UITableViewDataSource, UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
viewModel.orders.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: TaskOrderCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TaskOrderCell
|
||||||
|
let order = viewModel.orders[indexPath.row]
|
||||||
|
cell.apply(
|
||||||
|
order: order,
|
||||||
|
isSelected: viewModel.selectedOrderNumber == order.orderNumber
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
viewModel.toggleSelection(orderNumber: viewModel.orders[indexPath.row].orderNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单选择卡片 cell。
|
||||||
|
private final class TaskOrderCell: UITableViewCell {
|
||||||
|
|
||||||
|
static let reuseIdentifier = "TaskOrderCell"
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let projectRow = TaskInfoRowView(label: "项目名称:")
|
||||||
|
private let statusRow = TaskInfoRowView(label: "订单状态:")
|
||||||
|
private let numberRow = TaskInfoRowView(label: "订单编号:")
|
||||||
|
private let payTimeRow = TaskInfoRowView(label: "付款时间:")
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = AppRadius.sm
|
||||||
|
cardView.layer.borderWidth = 1
|
||||||
|
cardView.layer.borderColor = AppColor.border.cgColor
|
||||||
|
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(projectRow)
|
||||||
|
cardView.addSubview(statusRow)
|
||||||
|
cardView.addSubview(numberRow)
|
||||||
|
cardView.addSubview(payTimeRow)
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md))
|
||||||
|
}
|
||||||
|
projectRow.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
statusRow.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(projectRow.snp.bottom).offset(AppSpacing.xs)
|
||||||
|
make.leading.trailing.equalTo(projectRow)
|
||||||
|
}
|
||||||
|
numberRow.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusRow.snp.bottom).offset(AppSpacing.xs)
|
||||||
|
make.leading.trailing.equalTo(projectRow)
|
||||||
|
}
|
||||||
|
payTimeRow.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(numberRow.snp.bottom).offset(AppSpacing.xs)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(order: AvailableOrder, isSelected: Bool) {
|
||||||
|
projectRow.apply(value: order.projectName.isEmpty ? "--" : order.projectName)
|
||||||
|
statusRow.apply(value: order.orderStatusLabel.isEmpty ? "--" : order.orderStatusLabel)
|
||||||
|
numberRow.apply(value: order.orderNumber)
|
||||||
|
payTimeRow.apply(value: order.payTime.isEmpty ? "--" : order.payTime)
|
||||||
|
cardView.layer.borderColor = isSelected ? AppColor.danger.cgColor : AppColor.border.cgColor
|
||||||
|
cardView.layer.borderWidth = isSelected ? 2 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 订单信息行。
|
||||||
|
private final class TaskInfoRowView: UIView {
|
||||||
|
private let labelView = UILabel()
|
||||||
|
private let valueView = UILabel()
|
||||||
|
|
||||||
|
init(label: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
labelView.text = label
|
||||||
|
labelView.font = .app(.body)
|
||||||
|
labelView.textColor = AppColor.textSecondary
|
||||||
|
valueView.font = .app(.body)
|
||||||
|
valueView.textColor = AppColor.textPrimary
|
||||||
|
valueView.numberOfLines = 0
|
||||||
|
|
||||||
|
addSubview(labelView)
|
||||||
|
addSubview(valueView)
|
||||||
|
labelView.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview()
|
||||||
|
make.width.equalTo(80)
|
||||||
|
}
|
||||||
|
valueView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(labelView.snp.trailing).offset(AppSpacing.xs)
|
||||||
|
make.trailing.top.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(value: String) {
|
||||||
|
valueView.text = value
|
||||||
|
}
|
||||||
|
}
|
||||||
627
suixinkan/UI/Task/Views/TaskAddViews.swift
Normal file
627
suixinkan/UI/Task/Views/TaskAddViews.swift
Normal file
@ -0,0 +1,627 @@
|
|||||||
|
//
|
||||||
|
// TaskAddViews.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 添加任务素材网格区。
|
||||||
|
final class TaskAddMediaSectionView: UIView {
|
||||||
|
|
||||||
|
var onCloudImport: (() -> Void)?
|
||||||
|
var onLocalImport: (() -> Void)?
|
||||||
|
var onFileTap: ((TaskFileItem) -> Void)?
|
||||||
|
var onDeleteFile: ((TaskFileItem) -> Void)?
|
||||||
|
|
||||||
|
private var files: [TaskFileItem] = []
|
||||||
|
private var collectionHeightConstraint: Constraint?
|
||||||
|
|
||||||
|
private enum Section { case main }
|
||||||
|
private enum Item: Hashable { case add; case file(String) }
|
||||||
|
|
||||||
|
private lazy var collectionView: UICollectionView = {
|
||||||
|
let layout = UICollectionViewCompositionalLayout { _, environment in
|
||||||
|
let columns = 3
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let inset = AppSpacing.md * 2
|
||||||
|
let availableWidth = environment.container.effectiveContentSize.width - inset
|
||||||
|
let totalSpacing = spacing * CGFloat(columns - 1)
|
||||||
|
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
|
||||||
|
let itemHeight = itemWidth / 1.77
|
||||||
|
let itemSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .absolute(itemWidth),
|
||||||
|
heightDimension: .absolute(itemHeight)
|
||||||
|
)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let groupSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(itemHeight)
|
||||||
|
)
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(
|
||||||
|
layoutSize: groupSize,
|
||||||
|
repeatingSubitem: item,
|
||||||
|
count: columns
|
||||||
|
)
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = spacing
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.isScrollEnabled = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||||||
|
[weak self] collectionView, indexPath, item in
|
||||||
|
switch item {
|
||||||
|
case .add:
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TaskAddAddMediaCell
|
||||||
|
return cell
|
||||||
|
case .file(let id):
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TaskAddMediaCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TaskAddMediaCell
|
||||||
|
if let file = self?.files.first(where: { $0.id == id }) {
|
||||||
|
cell.apply(file: file)
|
||||||
|
cell.onDelete = { self?.onDeleteFile?(file) }
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let tipsLabel = UILabel()
|
||||||
|
private let cloudButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = AppRadius.md
|
||||||
|
|
||||||
|
titleLabel.text = "添加图片"
|
||||||
|
titleLabel.font = .app(.bodyMedium)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
|
tipsLabel.text = "(点击图片或视频可添加备注)"
|
||||||
|
tipsLabel.font = .app(.caption)
|
||||||
|
tipsLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
|
cloudButton.setTitle("云盘导入", for: .normal)
|
||||||
|
cloudButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
cloudButton.titleLabel?.font = .app(.caption)
|
||||||
|
cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier)
|
||||||
|
collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier)
|
||||||
|
collectionView.delegate = self
|
||||||
|
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(tipsLabel)
|
||||||
|
addSubview(cloudButton)
|
||||||
|
addSubview(collectionView)
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().offset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
tipsLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs)
|
||||||
|
make.centerY.equalTo(titleLabel)
|
||||||
|
}
|
||||||
|
cloudButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.centerY.equalTo(titleLabel)
|
||||||
|
}
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
collectionHeightConstraint = make.height.equalTo(88).constraint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(files: [TaskFileItem]) {
|
||||||
|
self.files = files
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(files.map { Item.file($0.id) } + [.add])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
collectionView.layoutIfNeeded()
|
||||||
|
let height = max(88, collectionView.collectionViewLayout.collectionViewContentSize.height)
|
||||||
|
collectionHeightConstraint?.update(offset: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cloudTapped() {
|
||||||
|
onCloudImport?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TaskAddMediaSectionView: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
switch item {
|
||||||
|
case .add:
|
||||||
|
onLocalImport?()
|
||||||
|
case .file(let id):
|
||||||
|
guard let file = files.first(where: { $0.id == id }) else { return }
|
||||||
|
onFileTap?(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材缩略图 cell。
|
||||||
|
final class TaskAddMediaCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
static let reuseIdentifier = "TaskAddMediaCell"
|
||||||
|
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let overlayView = UIView()
|
||||||
|
private let progressLabel = UILabel()
|
||||||
|
private let deleteButton = UIButton(type: .system)
|
||||||
|
private let playIconView = UIImageView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = AppColor.inputBackground
|
||||||
|
contentView.layer.cornerRadius = AppRadius.sm
|
||||||
|
contentView.clipsToBounds = true
|
||||||
|
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
|
||||||
|
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
|
overlayView.isHidden = true
|
||||||
|
|
||||||
|
progressLabel.font = .app(.captionMedium)
|
||||||
|
progressLabel.textColor = .white
|
||||||
|
progressLabel.textAlignment = .center
|
||||||
|
|
||||||
|
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||||||
|
deleteButton.tintColor = .white
|
||||||
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
playIconView.image = UIImage(systemName: "play.circle.fill")
|
||||||
|
playIconView.tintColor = .white
|
||||||
|
playIconView.isHidden = true
|
||||||
|
|
||||||
|
contentView.addSubview(imageView)
|
||||||
|
contentView.addSubview(overlayView)
|
||||||
|
contentView.addSubview(playIconView)
|
||||||
|
contentView.addSubview(progressLabel)
|
||||||
|
contentView.addSubview(deleteButton)
|
||||||
|
|
||||||
|
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
|
overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
|
playIconView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.width.height.equalTo(28)
|
||||||
|
}
|
||||||
|
progressLabel.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||||
|
deleteButton.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
|
||||||
|
make.width.height.equalTo(22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
onDelete = nil
|
||||||
|
imageView.kf.cancelDownloadTask()
|
||||||
|
imageView.image = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(file: TaskFileItem) {
|
||||||
|
let urlString = file.previewURLString
|
||||||
|
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||||
|
imageView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
imageView.image = UIImage(systemName: "photo")
|
||||||
|
imageView.tintColor = AppColor.textTertiary
|
||||||
|
}
|
||||||
|
playIconView.isHidden = file.fileType != 1
|
||||||
|
if file.isUploading {
|
||||||
|
overlayView.isHidden = false
|
||||||
|
progressLabel.text = "\(file.uploadProgress)%"
|
||||||
|
} else {
|
||||||
|
overlayView.isHidden = true
|
||||||
|
progressLabel.text = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deleteTapped() {
|
||||||
|
onDelete?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地导入占位 cell。
|
||||||
|
final class TaskAddAddMediaCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
static let reuseIdentifier = "TaskAddAddMediaCell"
|
||||||
|
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = AppColor.inputBackground
|
||||||
|
contentView.layer.cornerRadius = AppRadius.sm
|
||||||
|
|
||||||
|
iconView.image = UIImage(systemName: "plus")
|
||||||
|
iconView.tintColor = AppColor.textTabInactive
|
||||||
|
|
||||||
|
titleLabel.text = "本地导入"
|
||||||
|
titleLabel.font = .app(.caption)
|
||||||
|
titleLabel.textColor = AppColor.textTabInactive
|
||||||
|
|
||||||
|
contentView.addSubview(iconView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview().offset(-8)
|
||||||
|
make.width.height.equalTo(24)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(iconView.snp.bottom).offset(2)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务优先级选择区。
|
||||||
|
final class TaskPrioritySectionView: UIView {
|
||||||
|
|
||||||
|
var onUrgentHourChange: ((Int) -> Void)?
|
||||||
|
var onCustomHourTextChange: ((String) -> Void)?
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let noneButton = UIButton(type: .system)
|
||||||
|
private let twoHourButton = UIButton(type: .system)
|
||||||
|
private let customButton = UIButton(type: .system)
|
||||||
|
private let customField = UITextField()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = AppRadius.md
|
||||||
|
|
||||||
|
titleLabel.text = "优先级"
|
||||||
|
titleLabel.font = .app(.bodyMedium)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
|
configureOptionButton(noneButton, title: "不加急")
|
||||||
|
configureOptionButton(twoHourButton, title: "加急(2小时)")
|
||||||
|
configureOptionButton(customButton, title: "自定义")
|
||||||
|
|
||||||
|
noneButton.addTarget(self, action: #selector(noneTapped), for: .touchUpInside)
|
||||||
|
twoHourButton.addTarget(self, action: #selector(twoHourTapped), for: .touchUpInside)
|
||||||
|
customButton.addTarget(self, action: #selector(customTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
customField.placeholder = "请输入小时数"
|
||||||
|
customField.font = .app(.body)
|
||||||
|
customField.textColor = AppColor.textPrimary
|
||||||
|
customField.keyboardType = .numberPad
|
||||||
|
customField.borderStyle = .roundedRect
|
||||||
|
customField.isHidden = true
|
||||||
|
customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged)
|
||||||
|
|
||||||
|
let row = UIStackView(arrangedSubviews: [noneButton, twoHourButton, customButton])
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.spacing = AppSpacing.xs
|
||||||
|
row.distribution = .fillEqually
|
||||||
|
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(row)
|
||||||
|
addSubview(customField)
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().offset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
row.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(42)
|
||||||
|
}
|
||||||
|
customField.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(row.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(urgentHour: Int) {
|
||||||
|
let isCustom = urgentHour > 0 && urgentHour != 2
|
||||||
|
styleOption(noneButton, selected: urgentHour == 0)
|
||||||
|
styleOption(twoHourButton, selected: urgentHour == 2)
|
||||||
|
styleOption(customButton, selected: isCustom)
|
||||||
|
customField.isHidden = !isCustom
|
||||||
|
if isCustom {
|
||||||
|
customField.text = "\(urgentHour)"
|
||||||
|
} else {
|
||||||
|
customField.text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureOptionButton(_ button: UIButton, title: String) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.titleLabel?.font = .app(.caption)
|
||||||
|
button.layer.cornerRadius = AppRadius.xs
|
||||||
|
button.clipsToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func styleOption(_ button: UIButton, selected: Bool) {
|
||||||
|
button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
|
||||||
|
button.setTitleColor(selected ? .white : AppColor.textSecondary, for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func noneTapped() { onUrgentHourChange?(0) }
|
||||||
|
@objc private func twoHourTapped() { onUrgentHourChange?(2) }
|
||||||
|
@objc private func customTapped() {
|
||||||
|
onUrgentHourChange?(1)
|
||||||
|
onCustomHourTextChange?("1")
|
||||||
|
}
|
||||||
|
@objc private func customFieldChanged() {
|
||||||
|
onCustomHourTextChange?(customField.text ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关联订单选择按钮。
|
||||||
|
final class TaskOrderSelectButton: UIControl {
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = AppRadius.sm
|
||||||
|
layer.borderWidth = 1
|
||||||
|
layer.borderColor = AppColor.border.cgColor
|
||||||
|
|
||||||
|
titleLabel.font = .app(.bodyMedium)
|
||||||
|
titleLabel.textColor = AppColor.textTabInactive
|
||||||
|
titleLabel.text = "关联订单(可选)"
|
||||||
|
|
||||||
|
arrowView.tintColor = AppColor.textTabInactive
|
||||||
|
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(arrowView)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(AppSpacing.sm)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
arrowView.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
snp.makeConstraints { make in make.height.equalTo(44) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(orderNumber: String?) {
|
||||||
|
if let orderNumber, !orderNumber.isEmpty {
|
||||||
|
titleLabel.text = orderNumber
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
} else {
|
||||||
|
titleLabel.text = "关联订单(可选)"
|
||||||
|
titleLabel.textColor = AppColor.textTabInactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务素材备注弹窗。
|
||||||
|
final class TaskFileRemarkDialogView: UIView {
|
||||||
|
|
||||||
|
var onCancel: (() -> Void)?
|
||||||
|
var onConfirm: (() -> Void)?
|
||||||
|
|
||||||
|
private let containerView = UIView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let previewView = UIImageView()
|
||||||
|
private let textView = UITextView()
|
||||||
|
private let countLabel = UILabel()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = AppColor.overlayScrim
|
||||||
|
|
||||||
|
containerView.backgroundColor = .white
|
||||||
|
containerView.layer.cornerRadius = AppRadius.md
|
||||||
|
|
||||||
|
titleLabel.text = "备注信息"
|
||||||
|
titleLabel.font = .app(.title)
|
||||||
|
|
||||||
|
previewView.contentMode = .scaleAspectFill
|
||||||
|
previewView.clipsToBounds = true
|
||||||
|
previewView.layer.cornerRadius = AppRadius.sm
|
||||||
|
previewView.backgroundColor = AppColor.inputBackground
|
||||||
|
|
||||||
|
textView.font = .app(.body)
|
||||||
|
textView.layer.borderWidth = 1
|
||||||
|
textView.layer.borderColor = AppColor.border.cgColor
|
||||||
|
textView.layer.cornerRadius = AppRadius.sm
|
||||||
|
textView.delegate = self
|
||||||
|
|
||||||
|
countLabel.font = .app(.caption)
|
||||||
|
countLabel.textColor = AppColor.textTertiary
|
||||||
|
countLabel.text = "0/50"
|
||||||
|
|
||||||
|
cancelButton.setTitle("取消", for: .normal)
|
||||||
|
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
|
||||||
|
cancelButton.backgroundColor = AppColor.inputBackground
|
||||||
|
cancelButton.layer.cornerRadius = AppRadius.xs
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
confirmButton.setTitle("确定", for: .normal)
|
||||||
|
confirmButton.setTitleColor(.white, for: .normal)
|
||||||
|
confirmButton.backgroundColor = AppColor.primary
|
||||||
|
confirmButton.layer.cornerRadius = AppRadius.xs
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
addSubview(containerView)
|
||||||
|
containerView.addSubview(titleLabel)
|
||||||
|
containerView.addSubview(previewView)
|
||||||
|
containerView.addSubview(textView)
|
||||||
|
containerView.addSubview(countLabel)
|
||||||
|
containerView.addSubview(cancelButton)
|
||||||
|
containerView.addSubview(confirmButton)
|
||||||
|
|
||||||
|
containerView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().offset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
previewView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(160)
|
||||||
|
}
|
||||||
|
textView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(80)
|
||||||
|
}
|
||||||
|
countLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(textView.snp.bottom).offset(AppSpacing.xxs)
|
||||||
|
make.trailing.equalTo(textView)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(countLabel.snp.bottom).offset(AppSpacing.md)
|
||||||
|
make.leading.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(36)
|
||||||
|
make.width.equalTo(72)
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(cancelButton)
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(36)
|
||||||
|
make.width.equalTo(72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(file: TaskFileItem, text: String) {
|
||||||
|
textView.text = text
|
||||||
|
countLabel.text = "\(text.count)/50"
|
||||||
|
let urlString = file.previewURLString
|
||||||
|
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||||
|
previewView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
previewView.image = UIImage(systemName: "photo")
|
||||||
|
previewView.tintColor = AppColor.textTertiary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() { onCancel?() }
|
||||||
|
@objc private func confirmTapped() { onConfirm?() }
|
||||||
|
|
||||||
|
/// 当前输入的备注文本。
|
||||||
|
var inputText: String { textView.text ?? "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TaskFileRemarkDialogView: UITextViewDelegate {
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
if textView.text.count > 50 {
|
||||||
|
textView.text = String(textView.text.prefix(50))
|
||||||
|
}
|
||||||
|
countLabel.text = "\(textView.text.count)/50"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带边框的单行输入框。
|
||||||
|
final class TaskBorderTextField: UIView {
|
||||||
|
|
||||||
|
let textField = UITextField()
|
||||||
|
|
||||||
|
init(placeholder: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = AppRadius.sm
|
||||||
|
layer.borderWidth = 1
|
||||||
|
layer.borderColor = AppColor.border.cgColor
|
||||||
|
|
||||||
|
textField.placeholder = placeholder
|
||||||
|
textField.font = .app(.body)
|
||||||
|
textField.textColor = AppColor.textPrimary
|
||||||
|
addSubview(textField)
|
||||||
|
textField.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12))
|
||||||
|
}
|
||||||
|
snp.makeConstraints { make in make.height.equalTo(44) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带边框的多行输入框。
|
||||||
|
final class TaskBorderTextView: UIView {
|
||||||
|
|
||||||
|
let textView = UITextView()
|
||||||
|
|
||||||
|
init(placeholder: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = AppRadius.sm
|
||||||
|
layer.borderWidth = 1
|
||||||
|
layer.borderColor = AppColor.border.cgColor
|
||||||
|
|
||||||
|
textView.font = .app(.body)
|
||||||
|
textView.textColor = AppColor.textPrimary
|
||||||
|
textView.text = placeholder
|
||||||
|
textView.textColor = AppColor.textTertiary
|
||||||
|
addSubview(textView)
|
||||||
|
textView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.xs)
|
||||||
|
}
|
||||||
|
snp.makeConstraints { make in make.height.equalTo(80) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
108
suixinkanTests/CloudStoragePickForTaskViewModelTests.swift
Normal file
108
suixinkanTests/CloudStoragePickForTaskViewModelTests.swift
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// CloudStoragePickForTaskViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 任务云盘多选 ViewModel 测试。
|
||||||
|
final class CloudStoragePickForTaskViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
func testHandleItemTapTogglesMediaSelection() async throws {
|
||||||
|
let listJSON = try makeListJSON(files: [
|
||||||
|
makeFileJSON(id: 5, name: "图片", type: 2),
|
||||||
|
])
|
||||||
|
let api = makeAPI(responses: [listJSON, listJSON])
|
||||||
|
let viewModel = CloudStoragePickForTaskViewModel()
|
||||||
|
await viewModel.refresh(api: api)
|
||||||
|
let file = try XCTUnwrap(viewModel.files.first)
|
||||||
|
|
||||||
|
await viewModel.handleItemTap(file, api: api)
|
||||||
|
XCTAssertTrue(viewModel.isSelected(5))
|
||||||
|
|
||||||
|
await viewModel.handleItemTap(file, api: api)
|
||||||
|
XCTAssertFalse(viewModel.isSelected(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHandleItemTapShowsImportedMessage() async throws {
|
||||||
|
let listJSON = try makeListJSON(files: [
|
||||||
|
makeFileJSON(id: 5, name: "图片", type: 2),
|
||||||
|
])
|
||||||
|
let api = makeAPI(responses: [listJSON])
|
||||||
|
let viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: [5])
|
||||||
|
var message: String?
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
await viewModel.refresh(api: api)
|
||||||
|
let file = try XCTUnwrap(viewModel.files.first)
|
||||||
|
|
||||||
|
await viewModel.handleItemTap(file, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(message, "已选择")
|
||||||
|
XCTAssertFalse(viewModel.isSelected(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNavigateToFolderUpdatesPathStack() async throws {
|
||||||
|
let rootJSON = try makeListJSON(files: [
|
||||||
|
makeFileJSON(id: 8, name: "文件夹", type: 99),
|
||||||
|
])
|
||||||
|
let childJSON = try makeListJSON(files: [])
|
||||||
|
let api = makeAPI(responses: [rootJSON, childJSON])
|
||||||
|
let viewModel = CloudStoragePickForTaskViewModel()
|
||||||
|
await viewModel.refresh(api: api)
|
||||||
|
let folder = try XCTUnwrap(viewModel.files.first)
|
||||||
|
|
||||||
|
await viewModel.handleItemTap(folder, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 8])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectedFileListReturnsAllChosenFiles() async throws {
|
||||||
|
let listJSON = try makeListJSON(files: [
|
||||||
|
makeFileJSON(id: 1, name: "A", type: 2),
|
||||||
|
makeFileJSON(id: 2, name: "B", type: 2),
|
||||||
|
])
|
||||||
|
let api = makeAPI(responses: [listJSON, listJSON, listJSON])
|
||||||
|
let viewModel = CloudStoragePickForTaskViewModel()
|
||||||
|
await viewModel.refresh(api: api)
|
||||||
|
|
||||||
|
await viewModel.handleItemTap(viewModel.files[0], api: api)
|
||||||
|
await viewModel.handleItemTap(viewModel.files[1], api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(Set(viewModel.selectedFileList.map(\.id)), Set([1, 2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAPI(responses: [Data]) -> TaskAPI {
|
||||||
|
TaskAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeListJSON(files: [[String: Any]]) throws -> Data {
|
||||||
|
let listData = try JSONSerialization.data(withJSONObject: files)
|
||||||
|
let listObject = try JSONSerialization.jsonObject(with: listData)
|
||||||
|
let envelope: [String: Any] = [
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
"total": files.count,
|
||||||
|
"list": listObject,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
return try JSONSerialization.data(withJSONObject: envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeFileJSON(id: Int, name: String, type: Int) -> [String: Any] {
|
||||||
|
[
|
||||||
|
"id": id,
|
||||||
|
"parent_folder_id": 0,
|
||||||
|
"file_url": "https://cdn/\(id).jpg",
|
||||||
|
"cover_url": "",
|
||||||
|
"updated_at": "",
|
||||||
|
"name": name,
|
||||||
|
"created_at": "",
|
||||||
|
"child_num": 0,
|
||||||
|
"type": type,
|
||||||
|
"file_size": 100,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
96
suixinkanTests/ProfileEditViewModelTests.swift
Normal file
96
suixinkanTests/ProfileEditViewModelTests.swift
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// ProfileEditViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 资料编辑页 ViewModel 测试。
|
||||||
|
final class ProfileEditViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
func testInitPrefillsNicknameWhenSet() {
|
||||||
|
let viewModel = ProfileEditViewModel(nickname: "测试昵称", avatarURL: "https://example.com/a.jpg")
|
||||||
|
XCTAssertEqual(viewModel.editingNickname, "测试昵称")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitClearsPlaceholderNickname() {
|
||||||
|
let viewModel = ProfileEditViewModel(nickname: "未设置昵称", avatarURL: "")
|
||||||
|
XCTAssertEqual(viewModel.editingNickname, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveProfileRejectsEmptyNickname() async {
|
||||||
|
let viewModel = ProfileEditViewModel(nickname: "未设置昵称", avatarURL: "")
|
||||||
|
let session = MockURLSession(responses: [])
|
||||||
|
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await viewModel.saveProfile(
|
||||||
|
api: api,
|
||||||
|
uploader: MockOSSUploadService(),
|
||||||
|
scenicId: 1
|
||||||
|
)
|
||||||
|
XCTFail("空昵称应抛出校验错误")
|
||||||
|
} catch {
|
||||||
|
guard let validationError = error as? ProfileValidationError else {
|
||||||
|
return XCTFail("期望 ProfileValidationError,实际为 \(error)")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(validationError, .emptyNickname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveProfilePostsNotificationAndUpdatesAppStore() async throws {
|
||||||
|
let viewModel = ProfileEditViewModel(nickname: "旧昵称", avatarURL: "")
|
||||||
|
viewModel.updateEditingNickname("新昵称")
|
||||||
|
|
||||||
|
let expectation = expectation(forNotification: NotificationName.userProfileDidUpdate, object: nil)
|
||||||
|
|
||||||
|
let responseData = try TestJSON.envelope(data: EmptyPayload())
|
||||||
|
let session = MockURLSession(responses: [responseData])
|
||||||
|
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await viewModel.saveProfile(
|
||||||
|
api: api,
|
||||||
|
uploader: MockOSSUploadService(),
|
||||||
|
scenicId: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
await fulfillment(of: [expectation], timeout: 1)
|
||||||
|
XCTAssertEqual(AppStore.shared.userName, "新昵称")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试用 OSS 上传替身,直接返回固定 URL。
|
||||||
|
@MainActor
|
||||||
|
private struct MockOSSUploadService: OSSUploadServing {
|
||||||
|
func uploadUserAvatar(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
onProgress(100)
|
||||||
|
return "https://cdn.example.com/avatar.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadRealNameImage(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
onProgress(100)
|
||||||
|
return "https://cdn.example.com/realname.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadBankCardImage(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
onProgress(100)
|
||||||
|
return "https://cdn.example.com/bank.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,9 +44,17 @@ final class ProfileViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBeginEditingPrefillsNickname() {
|
func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() {
|
||||||
|
AppStore.shared.userName = "全局昵称"
|
||||||
|
AppStore.shared.avatar = "https://cdn.example.com/new.jpg"
|
||||||
|
|
||||||
let viewModel = ProfileViewModel()
|
let viewModel = ProfileViewModel()
|
||||||
viewModel.beginEditing()
|
viewModel.applyLocalProfileUpdate()
|
||||||
XCTAssertTrue(viewModel.isEditingProfile)
|
|
||||||
|
XCTAssertEqual(viewModel.displayNickname, "全局昵称")
|
||||||
|
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/new.jpg")
|
||||||
|
|
||||||
|
AppStore.shared.userName = ""
|
||||||
|
AppStore.shared.avatar = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
suixinkanTests/TaskAddViewModelTests.swift
Normal file
84
suixinkanTests/TaskAddViewModelTests.swift
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// TaskAddViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 添加任务 ViewModel 测试。
|
||||||
|
@MainActor
|
||||||
|
final class TaskAddViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private var appStore: AppStore!
|
||||||
|
private var defaults: UserDefaults!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
defaults = UserDefaults(suiteName: "TaskAddViewModelTests")!
|
||||||
|
defaults.removePersistentDomain(forName: "TaskAddViewModelTests")
|
||||||
|
appStore = AppStore(defaults: defaults)
|
||||||
|
appStore.currentScenicId = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidateRequiresTaskName() async {
|
||||||
|
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||||
|
var message: String?
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
await viewModel.validateAndSubmit(api: TaskAPI(client: makeClient()))
|
||||||
|
|
||||||
|
XCTAssertEqual(message, "请填写任务名称!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBuildAddTaskRequestSplitsCloudAndUploadFiles() {
|
||||||
|
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||||
|
viewModel.updateTaskName("任务A")
|
||||||
|
viewModel.updateTaskDetails("描述")
|
||||||
|
viewModel.setSelectedOrderNumber("NO001")
|
||||||
|
viewModel.setUrgentHour(2)
|
||||||
|
viewModel.addCloudFiles([CloudFile(id: 1, name: "云文件", type: 2)])
|
||||||
|
viewModel.addUploadPlaceholder(fileName: "a.jpg", fileType: 2, uploadTaskId: "task1")
|
||||||
|
viewModel.markUploadSucceeded(uploadTaskId: "task1", fileURL: "https://cdn/a.jpg", autoRemarkWhenSingle: false)
|
||||||
|
|
||||||
|
let request = viewModel.buildAddTaskRequest()
|
||||||
|
|
||||||
|
XCTAssertEqual(request.name, "任务A")
|
||||||
|
XCTAssertEqual(request.orderNumber, "NO001")
|
||||||
|
XCTAssertEqual(request.urgentHour, 2)
|
||||||
|
XCTAssertEqual(request.cloudFile.count, 1)
|
||||||
|
XCTAssertEqual(request.uploadFile.count, 1)
|
||||||
|
XCTAssertEqual(request.uploadFile[0].fileUrl, "https://cdn/a.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddCloudFilesDedupesAndAutoRemarkSingleFile() {
|
||||||
|
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||||
|
viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)])
|
||||||
|
XCTAssertEqual(viewModel.selectedFiles.count, 1)
|
||||||
|
XCTAssertEqual(viewModel.consumePendingRemarkFileID(), viewModel.selectedFiles[0].id)
|
||||||
|
|
||||||
|
viewModel.addCloudFiles([
|
||||||
|
CloudFile(id: 1, name: "A", type: 2),
|
||||||
|
CloudFile(id: 2, name: "B", type: 2),
|
||||||
|
CloudFile(id: 3, name: "C", type: 2),
|
||||||
|
])
|
||||||
|
XCTAssertEqual(viewModel.selectedFiles.count, 3)
|
||||||
|
XCTAssertNil(viewModel.consumePendingRemarkFileID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResetAllStateClearsForm() {
|
||||||
|
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||||
|
viewModel.updateTaskName("任务")
|
||||||
|
viewModel.setSelectedOrderNumber("NO001")
|
||||||
|
viewModel.addCloudFiles([CloudFile(id: 1, type: 2)])
|
||||||
|
|
||||||
|
viewModel.resetAllState()
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.taskName, "")
|
||||||
|
XCTAssertNil(viewModel.selectedOrderNumber)
|
||||||
|
XCTAssertTrue(viewModel.selectedFiles.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeClient() -> APIClient {
|
||||||
|
APIClient(environment: .testing, session: MockURLSession(responses: []))
|
||||||
|
}
|
||||||
|
}
|
||||||
77
suixinkanTests/TaskModelsTests.swift
Normal file
77
suixinkanTests/TaskModelsTests.swift
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// TaskModelsTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 任务模型编解码测试。
|
||||||
|
final class TaskModelsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testAddTaskRequestEncodesSnakeCaseFields() throws {
|
||||||
|
let request = AddTaskRequest(
|
||||||
|
scenicId: 10,
|
||||||
|
name: "测试任务",
|
||||||
|
orderNumber: "NO001",
|
||||||
|
remark: "描述",
|
||||||
|
urgentHour: 2,
|
||||||
|
cloudFile: [TaskCloudFileItem(fileId: 1, remark: "云备注")],
|
||||||
|
uploadFile: [TaskUploadFileItem(fileUrl: "https://cdn/a.jpg", fileName: "a.jpg", remark: "")]
|
||||||
|
)
|
||||||
|
let data = try JSONEncoder().encode(request)
|
||||||
|
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
|
||||||
|
XCTAssertEqual(json?["scenic_id"] as? Int, 10)
|
||||||
|
XCTAssertEqual(json?["name"] as? String, "测试任务")
|
||||||
|
XCTAssertEqual(json?["order_number"] as? String, "NO001")
|
||||||
|
XCTAssertEqual(json?["photog_remark"] as? String, "描述")
|
||||||
|
XCTAssertEqual(json?["urgent_hour"] as? Int, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAvailableOrderDecodesSnakeCaseFields() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"project_name": "航拍项目",
|
||||||
|
"order_number": "NO001",
|
||||||
|
"order_status": 1,
|
||||||
|
"order_status_label": "已付款",
|
||||||
|
"pay_time": "2024-01-01 10:00:00",
|
||||||
|
"user_id": 100,
|
||||||
|
"user_phone": "13800138000"
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let order = try JSONDecoder().decode(AvailableOrder.self, from: json)
|
||||||
|
XCTAssertEqual(order.projectName, "航拍项目")
|
||||||
|
XCTAssertEqual(order.orderNumber, "NO001")
|
||||||
|
XCTAssertEqual(order.orderStatusLabel, "已付款")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloudFileListResponseDecodesList() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"total": 1,
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"parent_folder_id": 0,
|
||||||
|
"file_url": "https://cdn/a.jpg",
|
||||||
|
"cover_url": "",
|
||||||
|
"updated_at": "",
|
||||||
|
"name": "图片1",
|
||||||
|
"created_at": "",
|
||||||
|
"child_num": 0,
|
||||||
|
"type": 2,
|
||||||
|
"file_size": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let response = try JSONDecoder().decode(CloudFileListResponse.self, from: json)
|
||||||
|
XCTAssertEqual(response.total, 1)
|
||||||
|
XCTAssertEqual(response.list[0].id, 5)
|
||||||
|
XCTAssertTrue(response.list[0].isImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
suixinkanTests/TaskOrderSelectViewModelTests.swift
Normal file
58
suixinkanTests/TaskOrderSelectViewModelTests.swift
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// TaskOrderSelectViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 任务订单选择 ViewModel 测试。
|
||||||
|
final class TaskOrderSelectViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private var appStore: AppStore!
|
||||||
|
private var defaults: UserDefaults!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
defaults = UserDefaults(suiteName: "TaskOrderSelectViewModelTests")!
|
||||||
|
defaults.removePersistentDomain(forName: "TaskOrderSelectViewModelTests")
|
||||||
|
appStore = AppStore(defaults: defaults)
|
||||||
|
appStore.currentScenicId = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToggleSelectionSelectsAndDeselects() {
|
||||||
|
let viewModel = TaskOrderSelectViewModel(appStore: appStore)
|
||||||
|
viewModel.toggleSelection(orderNumber: "NO001")
|
||||||
|
XCTAssertEqual(viewModel.selectedOrderNumber, "NO001")
|
||||||
|
viewModel.toggleSelection(orderNumber: "NO001")
|
||||||
|
XCTAssertNil(viewModel.selectedOrderNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadOrdersPopulatesList() async throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"project_name": "项目A",
|
||||||
|
"order_number": "NO001",
|
||||||
|
"order_status": 1,
|
||||||
|
"order_status_label": "已付款",
|
||||||
|
"pay_time": "2024-01-01",
|
||||||
|
"user_id": 1,
|
||||||
|
"user_phone": "13800138000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let session = MockURLSession(responses: [json])
|
||||||
|
let api = TaskAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
let viewModel = TaskOrderSelectViewModel(appStore: appStore)
|
||||||
|
|
||||||
|
await viewModel.loadOrders(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.orders.count, 1)
|
||||||
|
XCTAssertEqual(viewModel.orders[0].orderNumber, "NO001")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user