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 homeAPI: HomeAPI
|
||||
let paymentAPI: PaymentAPI
|
||||
let taskAPI: TaskAPI
|
||||
let uploadAPI: UploadAPI
|
||||
let ossUploadService: OSSUploadService
|
||||
|
||||
@ -29,6 +30,7 @@ final class NetworkServices {
|
||||
orderAPI = OrderAPI(client: client)
|
||||
homeAPI = HomeAPI(client: client)
|
||||
paymentAPI = PaymentAPI(client: client)
|
||||
taskAPI = TaskAPI(client: client)
|
||||
uploadAPI = UploadAPI(client: client)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
client.bindAuthTokenProvider {
|
||||
@ -46,6 +48,7 @@ final class NetworkServices {
|
||||
orderAPI = OrderAPI(client: apiClient)
|
||||
homeAPI = HomeAPI(client: apiClient)
|
||||
paymentAPI = PaymentAPI(client: apiClient)
|
||||
taskAPI = TaskAPI(client: apiClient)
|
||||
uploadAPI = UploadAPI(client: apiClient)
|
||||
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)
|
||||
}
|
||||
|
||||
/// 上传任务素材,对齐 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(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
@ -125,6 +143,8 @@ enum OSSUploadPolicy {
|
||||
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "bank_card":
|
||||
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "task_upload":
|
||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
default:
|
||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
}
|
||||
|
||||
@ -48,6 +48,8 @@ enum HomeRouteHandler {
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
case "task_management", "task_management_editor":
|
||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
default:
|
||||
showDeveloping(from: viewController)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
/// 个人信息页 ViewModel,负责资料加载与账号相关操作。
|
||||
final class ProfileViewModel {
|
||||
|
||||
private(set) var userInfo: UserInfoResponse?
|
||||
@ -13,11 +13,6 @@ final class ProfileViewModel {
|
||||
private(set) var bankCardInfo: BankCardInfo?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isSaving = false
|
||||
private(set) var isEditingProfile = false
|
||||
private(set) var editingNickname = ""
|
||||
private(set) var pendingAvatarData: Data?
|
||||
private(set) var pendingAvatarFileName: String?
|
||||
private(set) var avatarUploadProgress: Int?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
@ -85,12 +80,6 @@ final class ProfileViewModel {
|
||||
return label.isEmpty ? "审核中" : label
|
||||
}
|
||||
|
||||
func updateEditingNickname(_ value: String) {
|
||||
editingNickname = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 重新加载用户资料,摄影师额外拉取实名与银行卡信息。
|
||||
func reload(api: ProfileAPI) async throws {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
@ -119,77 +108,21 @@ final class ProfileViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func beginEditing() {
|
||||
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
|
||||
isEditingProfile = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
editingNickname = ""
|
||||
isEditingProfile = false
|
||||
clearPendingAvatar()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
if !isEditingProfile {
|
||||
beginEditing()
|
||||
} else {
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
let avatarChanged = pendingAvatarData != nil
|
||||
guard nicknameChanged || avatarChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSaving = false
|
||||
avatarUploadProgress = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
if nicknameChanged {
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
}
|
||||
|
||||
/// 从 `AppStore` 同步头像与昵称,用于资料保存后的本地即时刷新。
|
||||
func applyLocalProfileUpdate(from store: AppStore = .shared) {
|
||||
let previous = userInfo ?? UserInfoResponse()
|
||||
let nextNickname = store.userName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nextAvatar = store.avatar.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
userInfo = UserInfoResponse(
|
||||
avatar: uploadedAvatarURL ?? previous.avatar,
|
||||
avatar: nextAvatar.isEmpty ? previous.avatar : nextAvatar,
|
||||
realName: previous.realName,
|
||||
phone: previous.phone,
|
||||
nickname: nextNickname,
|
||||
nickname: nextNickname.isEmpty ? previous.nickname : nextNickname,
|
||||
roleName: previous.roleName,
|
||||
status: previous.status,
|
||||
statusName: previous.statusName
|
||||
)
|
||||
AppStore.shared.userName = nextNickname
|
||||
if let uploadedAvatarURL {
|
||||
AppStore.shared.avatar = uploadedAvatarURL
|
||||
}
|
||||
clearPendingAvatar()
|
||||
cancelEditing()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
||||
@ -215,21 +148,6 @@ final class ProfileViewModel {
|
||||
return "**** **** **** \(digits.suffix(4))"
|
||||
}
|
||||
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
self?.avatarUploadProgress = progress
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearPendingAvatar() {
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
|
||||
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
|
||||
self?.showToast("功能开发中")
|
||||
}
|
||||
self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
}
|
||||
cell.cardView.onToggleOnline = { [weak self] in
|
||||
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
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -12,7 +11,6 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
private let viewModel = ProfileViewModel()
|
||||
private let profileAPI = NetworkServices.shared.profileAPI
|
||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
@ -35,7 +33,7 @@ final class ProfileViewController: BaseViewController {
|
||||
private let logoutButton = UIButton(type: .system)
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "个人信息"
|
||||
title = "我的"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
@ -87,18 +85,18 @@ final class ProfileViewController: BaseViewController {
|
||||
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), 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(
|
||||
self,
|
||||
selector: #selector(handleAccountDidSwitch),
|
||||
name: NotificationName.accountDidSwitch,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleProfileDidUpdate),
|
||||
name: NotificationName.userProfileDidUpdate,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
@ -191,19 +189,9 @@ final class ProfileViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : viewModel.displayNickname
|
||||
nicknameField.isEnabled = viewModel.isEditingProfile
|
||||
nicknameField.text = viewModel.displayNickname
|
||||
uidLabel.text = "UID:\(viewModel.displayUID)"
|
||||
|
||||
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
|
||||
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||
|
||||
nameRow.configure(value: viewModel.displayRealName)
|
||||
accountRow.configure(
|
||||
@ -289,26 +277,17 @@ final class ProfileViewController: BaseViewController {
|
||||
Task { await reloadProfile(showGlobalLoading: true) }
|
||||
}
|
||||
|
||||
@objc private func nicknameChanged() {
|
||||
viewModel.updateEditingNickname(nicknameField.text ?? "")
|
||||
@objc private func handleProfileDidUpdate() {
|
||||
viewModel.applyLocalProfileUpdate()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
if viewModel.isEditingProfile {
|
||||
Task { await saveProfile() }
|
||||
} else {
|
||||
viewModel.beginEditing()
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
let editVC = ProfileEditViewController(
|
||||
nickname: viewModel.displayNickname,
|
||||
avatarURL: viewModel.displayAvatarURL
|
||||
)
|
||||
navigationController?.pushViewController(editVC, animated: true)
|
||||
}
|
||||
|
||||
@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 {
|
||||
showLoading()
|
||||
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()
|
||||
viewModel.beginEditing()
|
||||
XCTAssertTrue(viewModel.isEditingProfile)
|
||||
viewModel.applyLocalProfileUpdate()
|
||||
|
||||
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