添加提交任务流程并抽取资料编辑页

This commit is contained in:
2026-07-07 10:12:20 +08:00
parent af450fb4cc
commit ef1d3b4af5
23 changed files with 3186 additions and 170 deletions

View File

@ -17,6 +17,7 @@ final class NetworkServices {
let orderAPI: OrderAPI let orderAPI: OrderAPI
let homeAPI: HomeAPI let homeAPI: HomeAPI
let paymentAPI: PaymentAPI let paymentAPI: PaymentAPI
let taskAPI: TaskAPI
let uploadAPI: UploadAPI let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService let ossUploadService: OSSUploadService
@ -29,6 +30,7 @@ final class NetworkServices {
orderAPI = OrderAPI(client: client) orderAPI = OrderAPI(client: client)
homeAPI = HomeAPI(client: client) homeAPI = HomeAPI(client: client)
paymentAPI = PaymentAPI(client: client) paymentAPI = PaymentAPI(client: client)
taskAPI = TaskAPI(client: client)
uploadAPI = UploadAPI(client: client) uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
client.bindAuthTokenProvider { client.bindAuthTokenProvider {
@ -46,6 +48,7 @@ final class NetworkServices {
orderAPI = OrderAPI(client: apiClient) orderAPI = OrderAPI(client: apiClient)
homeAPI = HomeAPI(client: apiClient) homeAPI = HomeAPI(client: apiClient)
paymentAPI = PaymentAPI(client: apiClient) paymentAPI = PaymentAPI(client: apiClient)
taskAPI = TaskAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
} }

View File

@ -39,6 +39,24 @@ final class OSSUploadService {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress) try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
} }
/// Android `moduleType = task_upload`
func uploadTaskFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
try await uploadFile(
data: data,
fileName: fileName,
fileType: fileType,
scenicId: scenicId,
moduleType: "task_upload",
onProgress: onProgress
)
}
private func uploadFile( private func uploadFile(
data: Data, data: Data,
fileName: String, fileName: String,
@ -125,6 +143,8 @@ enum OSSUploadPolicy {
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "bank_card": case "bank_card":
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "task_upload":
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default: default:
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -185,8 +185,8 @@ final class HomeViewController: BaseViewController {
) )
} }
cell.cardView.onSubmitTask = { [weak self] in cell.cardView.onSubmitTask = { [weak self] in
self?.showToast("功能开发中") self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
} }
cell.cardView.onToggleOnline = { [weak self] in cell.cardView.onToggleOnline = { [weak self] in
self?.viewModel.showOnlineStatusSwitchDialog() self?.viewModel.showOnlineStatusSwitchDialog()
} }

View 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)
}
}
}
}
}

View File

@ -3,7 +3,6 @@
// suixinkan // suixinkan
// //
import PhotosUI
import SnapKit import SnapKit
import UIKit import UIKit
@ -12,7 +11,6 @@ final class ProfileViewController: BaseViewController {
private let viewModel = ProfileViewModel() private let viewModel = ProfileViewModel()
private let profileAPI = NetworkServices.shared.profileAPI private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentStack = UIStackView() private let contentStack = UIStackView()
@ -35,7 +33,7 @@ final class ProfileViewController: BaseViewController {
private let logoutButton = UIButton(type: .system) private let logoutButton = UIButton(type: .system)
override func setupNavigationBar() { override func setupNavigationBar() {
title = "个人信息" title = "我的"
} }
override func setupUI() { override func setupUI() {
@ -87,18 +85,18 @@ final class ProfileViewController: BaseViewController {
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside) withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside) realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
avatarImageView.isUserInteractionEnabled = true
avatarImageView.addGestureRecognizer(avatarTap)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(handleAccountDidSwitch), selector: #selector(handleAccountDidSwitch),
name: NotificationName.accountDidSwitch, name: NotificationName.accountDidSwitch,
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(handleProfileDidUpdate),
name: NotificationName.userProfileDidUpdate,
object: nil
)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -191,19 +189,9 @@ final class ProfileViewController: BaseViewController {
} }
private func applyViewModel() { private func applyViewModel() {
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : viewModel.displayNickname nicknameField.text = viewModel.displayNickname
nicknameField.isEnabled = viewModel.isEditingProfile
uidLabel.text = "UID:\(viewModel.displayUID)" uidLabel.text = "UID:\(viewModel.displayUID)"
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
avatarImageView.image = image
} else {
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
}
editButton.setTitle(viewModel.isEditingProfile ? "完成" : "编辑", for: .normal)
editButton.isEnabled = !viewModel.isSaving
avatarImageView.alpha = viewModel.isEditingProfile ? 1 : 1
nameRow.configure(value: viewModel.displayRealName) nameRow.configure(value: viewModel.displayRealName)
accountRow.configure( accountRow.configure(
@ -289,26 +277,17 @@ final class ProfileViewController: BaseViewController {
Task { await reloadProfile(showGlobalLoading: true) } Task { await reloadProfile(showGlobalLoading: true) }
} }
@objc private func nicknameChanged() { @objc private func handleProfileDidUpdate() {
viewModel.updateEditingNickname(nicknameField.text ?? "") viewModel.applyLocalProfileUpdate()
applyViewModel()
} }
@objc private func editTapped() { @objc private func editTapped() {
if viewModel.isEditingProfile { let editVC = ProfileEditViewController(
Task { await saveProfile() } nickname: viewModel.displayNickname,
} else { avatarURL: viewModel.displayAvatarURL
viewModel.beginEditing() )
} navigationController?.pushViewController(editVC, animated: true)
}
@objc private func pickAvatar() {
guard viewModel.isEditingProfile else { return }
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
} }
@objc private func accountSwitchTapped() { @objc private func accountSwitchTapped() {
@ -382,26 +361,6 @@ final class ProfileViewController: BaseViewController {
} }
} }
private func saveProfile() async {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("当前景区信息缺失")
return
}
showLoading()
defer { hideLoading() }
do {
try await viewModel.saveProfile(
api: profileAPI,
uploader: ossUploadService,
scenicId: scenicId
)
showToast("保存成功")
} catch {
showToast(error.localizedDescription)
}
}
private func updatePassword(_ password: String) async { private func updatePassword(_ password: String) async {
showLoading() showLoading()
defer { hideLoading() } defer { hideLoading() }
@ -413,20 +372,3 @@ final class ProfileViewController: BaseViewController {
} }
} }
} }
extension ProfileViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
Task { @MainActor in
do {
try self.viewModel.prepareAvatarImage(data: data)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}

View 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
}
}
}

View 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)"))
}
}
}
}

View 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
}
}

View 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")
}
}

View 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,
]
}
}

View 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"
}
}

View File

@ -44,9 +44,17 @@ final class ProfileViewModelTests: XCTestCase {
} }
} }
func testBeginEditingPrefillsNickname() { func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() {
AppStore.shared.userName = "全局昵称"
AppStore.shared.avatar = "https://cdn.example.com/new.jpg"
let viewModel = ProfileViewModel() let viewModel = ProfileViewModel()
viewModel.beginEditing() viewModel.applyLocalProfileUpdate()
XCTAssertTrue(viewModel.isEditingProfile)
XCTAssertEqual(viewModel.displayNickname, "全局昵称")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/new.jpg")
AppStore.shared.userName = ""
AppStore.shared.avatar = ""
} }
} }

View 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: []))
}
}

View 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)
}
}

View 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")
}
}