新增任务模块,并接入首页任务管理路由
实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -28,6 +28,7 @@ struct RootView: View {
|
||||
@State private var paymentAPI: PaymentAPI
|
||||
@State private var walletAPI: WalletAPI
|
||||
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
||||
@State private var taskAPI: TaskAPI
|
||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||
@State private var sessionBootstrapper: SessionBootstrapper
|
||||
|
||||
@ -49,6 +50,7 @@ struct RootView: View {
|
||||
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
||||
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
||||
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
|
||||
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
||||
_authSessionCoordinator = State(
|
||||
initialValue: AuthSessionCoordinator(
|
||||
tokenStore: tokenStore,
|
||||
@ -87,6 +89,7 @@ struct RootView: View {
|
||||
.environment(paymentAPI)
|
||||
.environment(walletAPI)
|
||||
.environment(scenicPermissionAPI)
|
||||
.environment(taskAPI)
|
||||
.environment(authSessionCoordinator)
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
|
||||
@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
|
||||
## 后续迁移
|
||||
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
|
||||
|
||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||
|
||||
@ -29,6 +29,9 @@ enum HomeRoute: Hashable {
|
||||
case settings
|
||||
case paymentCollection
|
||||
case wallet
|
||||
case taskManagement
|
||||
case taskCreate
|
||||
case taskDetail(id: Int, summary: PhotographerTaskItem?)
|
||||
case modulePlaceholder(uri: String, title: String)
|
||||
}
|
||||
|
||||
|
||||
@ -91,6 +91,10 @@ enum HomeMenuRouter {
|
||||
return .destination(.wallet)
|
||||
case "payment_collection", "payment_qr", "payment_code":
|
||||
return .destination(.paymentCollection)
|
||||
case "task_management", "task_management_editor":
|
||||
return .destination(.taskManagement)
|
||||
case "task_create":
|
||||
return .destination(.taskCreate)
|
||||
case "store", "more_functions":
|
||||
return .destination(.moreFunctions)
|
||||
case "fly", "pilot_controller":
|
||||
@ -106,9 +110,6 @@ enum HomeMenuRouter {
|
||||
"deposit_order",
|
||||
"deposit_order_shooting_info",
|
||||
"withdrawal_audit",
|
||||
"task_management",
|
||||
"task_management_editor",
|
||||
"task_create",
|
||||
"schedule_management",
|
||||
"checkin_points",
|
||||
"pm",
|
||||
|
||||
@ -30,6 +30,12 @@ extension HomeRoute {
|
||||
PaymentCollectionView()
|
||||
case .wallet:
|
||||
WalletView()
|
||||
case .taskManagement:
|
||||
TaskManagementView()
|
||||
case .taskCreate:
|
||||
TaskCreateView()
|
||||
case .taskDetail(let id, let summary):
|
||||
TaskDetailView(taskId: id, summary: summary)
|
||||
case let .modulePlaceholder(uri, title):
|
||||
HomeMigrationModuleView(title: title, uri: uri)
|
||||
}
|
||||
|
||||
148
suixinkan/Features/Tasks/API/TaskAPI.swift
Normal file
148
suixinkan/Features/Tasks/API/TaskAPI.swift
Normal file
@ -0,0 +1,148 @@
|
||||
//
|
||||
// TaskAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 任务服务协议,抽象任务列表、详情、发布和云盘选择接口以便测试替换。
|
||||
@MainActor
|
||||
protocol TaskServing {
|
||||
/// 获取摄影师任务列表。
|
||||
func taskList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
taskStatus: Int,
|
||||
taskName: String?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<PhotographerTaskItem>
|
||||
|
||||
/// 获取任务详情。
|
||||
func taskDetail(id: Int) async throws -> TaskDetailResponse
|
||||
|
||||
/// 获取发布任务时可关联的订单列表。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse]
|
||||
|
||||
/// 发布任务。
|
||||
func addTask(_ request: AddTaskRequest) async throws
|
||||
|
||||
/// 获取云盘文件列表,供发布任务选择附件。
|
||||
func cloudFileList(
|
||||
parentFolderId: Int,
|
||||
name: String,
|
||||
type: Int,
|
||||
orderBy: Int,
|
||||
page: Int,
|
||||
pageSize: Int
|
||||
) async throws -> ListPayload<CloudDriveFile>
|
||||
}
|
||||
|
||||
/// 任务 API,封装任务管理和发布任务相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskAPI: TaskServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化任务 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取摄影师任务列表。
|
||||
func taskList(
|
||||
scenicId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10,
|
||||
taskStatus: Int = 0,
|
||||
taskName: String? = nil,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil
|
||||
) async throws -> ListPayload<PhotographerTaskItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
|
||||
URLQueryItem(name: "task_status", value: "\(max(taskStatus, 0))")
|
||||
]
|
||||
if let taskName = taskName?.trimmingCharacters(in: .whitespacesAndNewlines), !taskName.isEmpty {
|
||||
query.append(URLQueryItem(name: "task_name", value: taskName))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/task/list",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取任务详情。
|
||||
func taskDetail(id: Int) async throws -> TaskDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/task/info",
|
||||
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取发布任务时可关联的订单列表。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/task/available-order",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发布任务。
|
||||
func addTask(_ request: AddTaskRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/task/create",
|
||||
body: request
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 获取云盘文件列表,供发布任务选择附件。
|
||||
func cloudFileList(
|
||||
parentFolderId: Int,
|
||||
name: String = "",
|
||||
type: Int = 0,
|
||||
orderBy: Int = 2,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/cloud-driver/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"),
|
||||
URLQueryItem(name: "name", value: name),
|
||||
URLQueryItem(name: "type", value: "\(type)"),
|
||||
URLQueryItem(name: "order_by", value: "\(orderBy)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
393
suixinkan/Features/Tasks/Models/TaskModels.swift
Normal file
393
suixinkan/Features/Tasks/Models/TaskModels.swift
Normal file
@ -0,0 +1,393 @@
|
||||
//
|
||||
// TaskModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 摄影师任务列表实体,表示任务管理页中的一条任务记录。
|
||||
struct PhotographerTaskItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let type: Int
|
||||
let orderNumber: String
|
||||
let taskStatus: Int
|
||||
let statusName: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let photogRemark: String
|
||||
let editorName: String
|
||||
let operateTime: String
|
||||
let orderUserNickname: String
|
||||
let orderUserPhone: String
|
||||
let orderUserId: String
|
||||
let media: [TaskMediaItem]
|
||||
|
||||
/// 任务列表字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case type
|
||||
case orderNumber = "order_number"
|
||||
case taskStatus = "task_status"
|
||||
case statusName = "status_name"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case photogRemark = "photog_remark"
|
||||
case editorName = "editor_name"
|
||||
case operateTime = "operate_time"
|
||||
case orderUserNickname = "order_user_nickname"
|
||||
case orderUserPhone = "order_user_phone"
|
||||
case orderUserId = "order_user_id"
|
||||
case media
|
||||
}
|
||||
|
||||
/// 宽松解码任务列表字段,避免后端数字和字符串混用导致页面不可用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
taskStatus = try container.decodeLossyInt(forKey: .taskStatus) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||
photogRemark = try container.decodeLossyString(forKey: .photogRemark)
|
||||
editorName = try container.decodeLossyString(forKey: .editorName)
|
||||
operateTime = try container.decodeLossyString(forKey: .operateTime)
|
||||
orderUserNickname = try container.decodeLossyString(forKey: .orderUserNickname)
|
||||
orderUserPhone = try container.decodeLossyString(forKey: .orderUserPhone)
|
||||
orderUserId = try container.decodeLossyString(forKey: .orderUserId)
|
||||
media = (try? container.decode([TaskMediaItem].self, forKey: .media)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务媒体实体,表示任务附件或任务结果中的图片、视频文件。
|
||||
struct TaskMediaItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let photogTaskId: Int
|
||||
let userId: Int
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let fileUrl: String
|
||||
let fileSize: Int64
|
||||
let coverUrl: String
|
||||
let duration: Int
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
/// 判断当前媒体是否应按视频展示。
|
||||
var isVideo: Bool {
|
||||
fileType == 1 || Self.hasVideoExtension(fileName) || Self.hasVideoExtension(fileUrl)
|
||||
}
|
||||
|
||||
/// 返回用于缩略图展示的 URL,优先使用封面图。
|
||||
var displayURL: String {
|
||||
coverUrl.isEmpty ? fileUrl : coverUrl
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否是常见视频格式。
|
||||
private static func hasVideoExtension(_ value: String) -> Bool {
|
||||
["mp4", "mov", "m4v", "avi"].contains(URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased())
|
||||
}
|
||||
|
||||
/// 任务媒体字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case photogTaskId = "photog_task_id"
|
||||
case userId = "user_id"
|
||||
case fileName = "file_name"
|
||||
case fileType = "file_type"
|
||||
case fileUrl = "file_url"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case duration
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
/// 宽松解码任务媒体字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
photogTaskId = try container.decodeLossyInt(forKey: .photogTaskId) ?? 0
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
fileName = try container.decodeLossyString(forKey: .fileName)
|
||||
fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0
|
||||
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||||
fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
||||
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||||
duration = try container.decodeLossyInt(forKey: .duration) ?? 0
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务详情实体,表示任务详情页展示的完整任务信息。
|
||||
struct TaskDetailResponse: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let createdAt: String
|
||||
let taskStatus: Int
|
||||
let taskStatusLabel: String
|
||||
let acceptedAt: String
|
||||
let editorName: String
|
||||
let editorPhone: String
|
||||
let urgentHour: Int
|
||||
let photogRemark: String
|
||||
let order: TaskDetailOrder?
|
||||
let taskResult: [TaskMediaItem]
|
||||
|
||||
/// 任务详情字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case createdAt = "created_at"
|
||||
case taskStatus = "task_status"
|
||||
case taskStatusLabel = "task_status_label"
|
||||
case acceptedAt = "accepted_at"
|
||||
case editorName = "editor_name"
|
||||
case editorPhone = "editor_phone"
|
||||
case urgentHour = "urgent_hour"
|
||||
case photogRemark = "photog_remark"
|
||||
case order
|
||||
case taskResult = "task_result"
|
||||
}
|
||||
|
||||
/// 宽松解码任务详情字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
taskStatus = try container.decodeLossyInt(forKey: .taskStatus) ?? 0
|
||||
taskStatusLabel = try container.decodeLossyString(forKey: .taskStatusLabel)
|
||||
acceptedAt = try container.decodeLossyString(forKey: .acceptedAt)
|
||||
editorName = try container.decodeLossyString(forKey: .editorName)
|
||||
editorPhone = try container.decodeLossyString(forKey: .editorPhone)
|
||||
urgentHour = try container.decodeLossyInt(forKey: .urgentHour) ?? 0
|
||||
photogRemark = try container.decodeLossyString(forKey: .photogRemark)
|
||||
order = try? container.decode(TaskDetailOrder.self, forKey: .order)
|
||||
taskResult = (try? container.decode([TaskMediaItem].self, forKey: .taskResult)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务关联订单实体,表示任务详情中的订单摘要。
|
||||
struct TaskDetailOrder: Decodable, Hashable {
|
||||
let projectName: String
|
||||
let orderNumber: String
|
||||
let payTime: String
|
||||
let userPhone: String
|
||||
|
||||
/// 任务关联订单字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case orderNumber = "order_number"
|
||||
case payTime = "pay_time"
|
||||
case userPhone = "user_phone"
|
||||
}
|
||||
|
||||
/// 宽松解码订单摘要字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
}
|
||||
}
|
||||
|
||||
/// 可关联订单实体,表示发布任务时可选择的订单。
|
||||
struct AvailableOrderResponse: Decodable, Identifiable, Hashable {
|
||||
var id: String { orderNumber }
|
||||
let projectName: String
|
||||
let orderNumber: String
|
||||
let orderStatus: Int
|
||||
let orderStatusLabel: String
|
||||
let payTime: String
|
||||
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 userPhone = "user_phone"
|
||||
}
|
||||
|
||||
/// 宽松解码可关联订单字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||
orderStatusLabel = try container.decodeLossyString(forKey: .orderStatusLabel)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布任务请求实体,描述提交给后端的新任务内容。
|
||||
struct AddTaskRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let name: String
|
||||
let orderNumber: String?
|
||||
let remark: String
|
||||
let urgentHour: Int
|
||||
let cloudFile: [CloudFileItem]
|
||||
let uploadFile: [UploadFileItem]
|
||||
|
||||
/// 发布任务请求字段映射,兼容后端下划线命名。
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务云盘附件请求实体,表示发布任务时选择的云盘文件。
|
||||
struct CloudFileItem: Encodable, Equatable {
|
||||
let fileId: Int
|
||||
let remark: String
|
||||
|
||||
/// 云盘附件请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileId = "file_id"
|
||||
case remark
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务上传附件请求实体,表示发布任务时上传到 OSS 的本地文件。
|
||||
struct UploadFileItem: Encodable, Equatable {
|
||||
let fileUrl: String
|
||||
let fileName: String
|
||||
let remark: String
|
||||
|
||||
/// 上传附件请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileUrl = "file_url"
|
||||
case fileName = "file_name"
|
||||
case remark
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘文件实体,表示发布任务时可选择的云盘文件或文件夹。
|
||||
struct CloudDriveFile: Decodable, Identifiable, 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
|
||||
|
||||
/// 判断当前云盘项是否为文件夹。
|
||||
var isFolder: Bool { type == 99 }
|
||||
|
||||
/// 判断当前云盘项是否为视频文件。
|
||||
var isVideo: Bool {
|
||||
type == 1 || Self.hasVideoExtension(name) || Self.hasVideoExtension(fileUrl)
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否是常见视频格式。
|
||||
private static func hasVideoExtension(_ value: String) -> Bool {
|
||||
["mp4", "mov", "m4v", "avi"].contains(URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased())
|
||||
}
|
||||
|
||||
/// 云盘文件字段映射,兼容后端下划线命名。
|
||||
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, name: String) {
|
||||
self.id = id
|
||||
parentFolderId = 0
|
||||
fileUrl = ""
|
||||
coverUrl = ""
|
||||
updatedAt = ""
|
||||
self.name = name
|
||||
createdAt = ""
|
||||
childNum = 0
|
||||
type = 99
|
||||
fileSize = 0
|
||||
}
|
||||
|
||||
/// 宽松解码云盘文件字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
parentFolderId = try container.decodeLossyInt(forKey: .parentFolderId) ?? 0
|
||||
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||||
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||||
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
childNum = try container.decodeLossyInt(forKey: .childNum) ?? 0
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
30
suixinkan/Features/Tasks/Tasks.md
Normal file
30
suixinkan/Features/Tasks/Tasks.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Tasks 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Tasks 模块负责首页“任务管理”和“发布任务”入口,包括任务列表、筛选、分页、任务详情、可关联订单、云盘附件选择和本地附件 OSS 上传。
|
||||
|
||||
任务状态只保存在本模块 ViewModel 内,不写入 `AppSession`、`AccountContext` 或 TabBar。当前景区 ID 从 `AccountContext.currentScenic` 读取,缺少景区时不请求任务接口。
|
||||
|
||||
## 数据来源
|
||||
|
||||
`TaskAPI` 封装任务接口:
|
||||
- `taskList` 获取任务列表。
|
||||
- `taskDetail` 获取任务详情。
|
||||
- `availableOrderList` 获取发布任务可关联订单。
|
||||
- `addTask` 提交发布任务。
|
||||
- `cloudFileList` 仅用于发布任务选择云盘附件,不代表完整云盘模块迁移。
|
||||
|
||||
任务本地附件上传复用 `OSSUploadService.uploadTaskFile`,上传成功后把最终 OSS URL 放入 `upload_file` 提交给发布任务接口。OSS STS、上传进度和本地文件数据不落盘。
|
||||
|
||||
## 页面流程
|
||||
|
||||
任务管理页支持状态筛选、任务名称搜索、开始/结束日期筛选、下拉刷新和加载更多。点击任务卡片进入任务详情页,详情接口失败时保留列表摘要信息作为兜底。
|
||||
|
||||
发布任务页支持填写任务名称、任务备注、紧急小时、选择关联订单、选择云盘文件和选择本地图片/视频。提交前先校验表单,再上传本地附件,最后调用发布任务接口。任意附件上传失败时不提交任务。
|
||||
|
||||
## 迁移边界
|
||||
|
||||
本轮不迁旧工程中把订单管理和核销订单拼成“待办任务”的逻辑;订单与核销已经归 `Features/Orders` 管理。
|
||||
|
||||
本轮不迁完整云盘资产管理,只保留发布任务所需的文件选择能力。云盘的新建文件夹、上传、删除、移动、重命名等能力后续应放在独立云盘模块中迁移。
|
||||
@ -0,0 +1,139 @@
|
||||
//
|
||||
// TaskCloudFileSelectionViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 云盘文件类型筛选实体,表示发布任务选择附件时的过滤方式。
|
||||
enum TaskCloudFileFilter: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case video = 1
|
||||
case image = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 返回筛选项展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
"全部"
|
||||
case .video:
|
||||
"视频"
|
||||
case .image:
|
||||
"图片"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘文件选择 ViewModel,负责目录浏览、筛选、分页和附件选择。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskCloudFileSelectionViewModel {
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
var files: [CloudDriveFile] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var searchText = ""
|
||||
var selectedFilter: TaskCloudFileFilter = .all
|
||||
var selectedFiles: [CloudDriveFile] = []
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
/// 当前目录 ID。
|
||||
var currentFolderId: Int {
|
||||
path.last?.id ?? 0
|
||||
}
|
||||
|
||||
/// 判断当前目录是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
files.count < total
|
||||
}
|
||||
|
||||
/// 重新加载当前目录第一页。
|
||||
func reload(api: any TaskServing) async throws {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestFiles(api: api, page: 1)
|
||||
page = 1
|
||||
files = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载当前目录下一页。
|
||||
func loadMore(api: any TaskServing) async throws {
|
||||
guard hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
let nextPage = page + 1
|
||||
let payload = try await requestFiles(api: api, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
files.append(contentsOf: payload.list)
|
||||
}
|
||||
|
||||
/// 进入指定文件夹并刷新列表。
|
||||
func enterFolder(_ folder: CloudDriveFile, api: any TaskServing) async throws {
|
||||
guard folder.isFolder else { return }
|
||||
path.append(folder)
|
||||
try await reload(api: api)
|
||||
}
|
||||
|
||||
/// 回到指定面包屑目录并刷新列表。
|
||||
func popToFolder(at index: Int, api: any TaskServing) async throws {
|
||||
guard path.indices.contains(index) else { return }
|
||||
path = Array(path.prefix(index + 1))
|
||||
try await reload(api: api)
|
||||
}
|
||||
|
||||
/// 切换文件选中状态,文件夹不会被选为附件。
|
||||
func toggleSelection(_ file: CloudDriveFile) {
|
||||
guard !file.isFolder else { return }
|
||||
if let index = selectedFiles.firstIndex(where: { $0.id == file.id }) {
|
||||
selectedFiles.remove(at: index)
|
||||
} else {
|
||||
selectedFiles.append(file)
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断指定云盘文件是否已选中。
|
||||
func isSelected(_ file: CloudDriveFile) -> Bool {
|
||||
selectedFiles.contains { $0.id == file.id }
|
||||
}
|
||||
|
||||
/// 将当前选中的云盘文件转换为发布任务表单附件。
|
||||
func makeSelectionItems() -> [TaskCloudSelectionItem] {
|
||||
selectedFiles.map {
|
||||
TaskCloudSelectionItem(id: $0.id, fileName: $0.name, fileType: $0.type, remark: "")
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装并发送云盘文件列表请求。
|
||||
private func requestFiles(api: any TaskServing, page: Int) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await api.cloudFileList(
|
||||
parentFolderId: currentFolderId,
|
||||
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: selectedFilter.rawValue,
|
||||
orderBy: 2,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
}
|
||||
}
|
||||
251
suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift
Normal file
251
suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift
Normal file
@ -0,0 +1,251 @@
|
||||
//
|
||||
// TaskCreateViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 任务云盘选择项实体,表示发布任务表单中的云盘附件。
|
||||
struct TaskCloudSelectionItem: Identifiable, Hashable {
|
||||
let id: Int
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
var remark: String
|
||||
}
|
||||
|
||||
/// 任务本地上传项实体,表示发布任务表单中的本地图片或视频。
|
||||
struct TaskLocalUploadItem: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
var remark: String
|
||||
var uploadedURL: String?
|
||||
var progress: Int
|
||||
var errorMessage: String?
|
||||
|
||||
/// 判断当前文件是否正在上传。
|
||||
var isUploading: Bool {
|
||||
progress > 0 && progress < 100 && uploadedURL == nil && errorMessage == nil
|
||||
}
|
||||
|
||||
/// 返回上传状态展示文案。
|
||||
var statusText: String {
|
||||
if let errorMessage { return errorMessage }
|
||||
if uploadedURL != nil { return "已上传" }
|
||||
if progress > 0 { return "上传中 \(progress)%" }
|
||||
return "待上传"
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布任务 ViewModel,负责表单、附件上传和提交任务。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskCreateViewModel {
|
||||
var taskName = ""
|
||||
var remark = ""
|
||||
var urgentHourText = "0"
|
||||
var selectedOrder: AvailableOrderResponse?
|
||||
var availableOrders: [AvailableOrderResponse] = []
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
var isLoadingOrders = false
|
||||
var isSubmitting = false
|
||||
var errorMessage: String?
|
||||
var didSubmitSuccessfully = false
|
||||
|
||||
/// 判断当前表单是否满足提交按钮启用条件。
|
||||
var canSubmit: Bool {
|
||||
validateForm(scenicId: 1, shouldSetError: false)
|
||||
&& !isSubmitting
|
||||
&& !selectedLocalFiles.contains(where: \.isUploading)
|
||||
}
|
||||
|
||||
/// 加载可关联订单列表。
|
||||
func loadAvailableOrders(api: any TaskServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
availableOrders = []
|
||||
return
|
||||
}
|
||||
isLoadingOrders = true
|
||||
defer { isLoadingOrders = false }
|
||||
|
||||
do {
|
||||
availableOrders = try await api.availableOrderList(scenicId: scenicId)
|
||||
} catch {
|
||||
availableOrders = []
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择或清除关联订单。
|
||||
func selectOrder(_ order: AvailableOrderResponse?) {
|
||||
selectedOrder = order
|
||||
}
|
||||
|
||||
/// 添加云盘附件,按文件 ID 去重。
|
||||
func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) {
|
||||
var next = selectedCloudFiles
|
||||
for file in files where !next.contains(where: { $0.id == file.id }) {
|
||||
next.append(file)
|
||||
}
|
||||
selectedCloudFiles = next
|
||||
}
|
||||
|
||||
/// 移除指定云盘附件。
|
||||
func removeCloudFile(id: Int) {
|
||||
selectedCloudFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 添加本地附件数据。
|
||||
func addLocalFile(data: Data, fileName: String) {
|
||||
let fileType = Self.fileType(for: fileName)
|
||||
selectedLocalFiles.append(
|
||||
TaskLocalUploadItem(
|
||||
id: UUID(),
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
fileType: fileType,
|
||||
remark: "",
|
||||
uploadedURL: nil,
|
||||
progress: 0,
|
||||
errorMessage: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 移除指定本地附件。
|
||||
func removeLocalFile(id: UUID) {
|
||||
selectedLocalFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 提交任务;本地附件会先上传 OSS,成功后再提交最终 URL。
|
||||
@discardableResult
|
||||
func submit(api: any TaskServing, uploadService: any OSSUploadServing, scenicId: Int?) async throws -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard validateForm(scenicId: scenicId, shouldSetError: true) else { return false }
|
||||
guard let scenicId, let urgentHour = Int(urgentHourText.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
||||
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
didSubmitSuccessfully = false
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let uploadedFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: scenicId)
|
||||
let request = AddTaskRequest(
|
||||
scenicId: "\(scenicId)",
|
||||
name: taskName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
orderNumber: selectedOrder?.orderNumber,
|
||||
remark: remark.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
urgentHour: urgentHour,
|
||||
cloudFile: selectedCloudFiles.map { CloudFileItem(fileId: $0.id, remark: $0.remark) },
|
||||
uploadFile: uploadedFiles
|
||||
)
|
||||
try await api.addTask(request)
|
||||
didSubmitSuccessfully = true
|
||||
resetForm()
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空发布任务表单。
|
||||
func resetForm() {
|
||||
taskName = ""
|
||||
remark = ""
|
||||
urgentHourText = "0"
|
||||
selectedOrder = nil
|
||||
selectedCloudFiles = []
|
||||
selectedLocalFiles = []
|
||||
}
|
||||
|
||||
/// 校验表单并按需写入错误提示。
|
||||
private func validateForm(scenicId: Int?, shouldSetError: Bool) -> Bool {
|
||||
func fail(_ message: String) -> Bool {
|
||||
if shouldSetError { errorMessage = message }
|
||||
return false
|
||||
}
|
||||
|
||||
guard scenicId != nil else {
|
||||
return fail("请先选择景区")
|
||||
}
|
||||
guard !taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return fail("请输入任务名称")
|
||||
}
|
||||
guard !remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return fail("请输入任务备注")
|
||||
}
|
||||
guard let urgentHour = Int(urgentHourText.trimmingCharacters(in: .whitespacesAndNewlines)), urgentHour >= 0 else {
|
||||
return fail("请输入正确的紧急小时")
|
||||
}
|
||||
guard !selectedLocalFiles.contains(where: \.isUploading) else {
|
||||
return fail("文件上传中,请稍后提交")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// 上传所有尚未上传的本地附件并返回提交请求需要的文件列表。
|
||||
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [UploadFileItem] {
|
||||
var uploadedFiles: [UploadFileItem] = []
|
||||
for index in selectedLocalFiles.indices {
|
||||
if let uploadedURL = selectedLocalFiles[index].uploadedURL {
|
||||
uploadedFiles.append(
|
||||
UploadFileItem(
|
||||
fileUrl: uploadedURL,
|
||||
fileName: selectedLocalFiles[index].fileName,
|
||||
remark: selectedLocalFiles[index].remark
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
selectedLocalFiles[index].errorMessage = nil
|
||||
let fileID = selectedLocalFiles[index].id
|
||||
do {
|
||||
let url = try await uploadService.uploadTaskFile(
|
||||
data: selectedLocalFiles[index].data,
|
||||
fileName: selectedLocalFiles[index].fileName,
|
||||
fileType: selectedLocalFiles[index].fileType,
|
||||
scenicId: scenicId,
|
||||
onProgress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.updateLocalFileProgress(id: fileID, progress: progress)
|
||||
}
|
||||
}
|
||||
)
|
||||
selectedLocalFiles[index].uploadedURL = url
|
||||
selectedLocalFiles[index].progress = 100
|
||||
uploadedFiles.append(
|
||||
UploadFileItem(
|
||||
fileUrl: url,
|
||||
fileName: selectedLocalFiles[index].fileName,
|
||||
remark: selectedLocalFiles[index].remark
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
selectedLocalFiles[index].progress = 0
|
||||
selectedLocalFiles[index].errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return uploadedFiles
|
||||
}
|
||||
|
||||
/// 更新指定本地附件的上传进度。
|
||||
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
||||
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
||||
selectedLocalFiles[index].progress = progress
|
||||
}
|
||||
|
||||
/// 根据文件名推断后端需要的文件类型。
|
||||
private static func fileType(for fileName: String) -> Int {
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
//
|
||||
// TaskManagementViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 任务状态筛选实体,表示任务管理页支持的任务状态。
|
||||
enum TaskStatusFilter: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case unaccepted = 1
|
||||
case accepted = 2
|
||||
case completed = 3
|
||||
case rejected = 4
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 返回状态筛选在页面上的展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
"全部"
|
||||
case .unaccepted:
|
||||
"待接收"
|
||||
case .accepted:
|
||||
"处理中"
|
||||
case .completed:
|
||||
"已完成"
|
||||
case .rejected:
|
||||
"已驳回"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务管理 ViewModel,负责任务列表、筛选、分页和错误状态。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskManagementViewModel {
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var tasks: [PhotographerTaskItem] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var selectedStatus: TaskStatusFilter = .all
|
||||
var searchText = ""
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 判断任务列表是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
tasks.count < total
|
||||
}
|
||||
|
||||
/// 首屏加载任务列表;无景区时清空旧数据且不请求接口。
|
||||
func reload(api: any TaskServing, scenicId: Int?) async throws {
|
||||
guard let scenicId else {
|
||||
resetList()
|
||||
errorMessage = "请先选择景区"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestList(api: api, scenicId: scenicId, page: 1)
|
||||
page = 1
|
||||
tasks = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
resetList()
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页任务列表;最后一页或正在加载时不会重复请求。
|
||||
func loadMore(api: any TaskServing, scenicId: Int?) async throws {
|
||||
guard let scenicId, hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
let nextPage = page + 1
|
||||
do {
|
||||
let payload = try await requestList(api: api, scenicId: scenicId, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
tasks.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空任务列表和分页状态。
|
||||
func resetList() {
|
||||
tasks = []
|
||||
total = 0
|
||||
page = 1
|
||||
isLoading = false
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
/// 组装并发送任务列表请求。
|
||||
private func requestList(api: any TaskServing, scenicId: Int, page: Int) async throws -> ListPayload<PhotographerTaskItem> {
|
||||
try await api.taskList(
|
||||
scenicId: scenicId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
taskStatus: selectedStatus.rawValue,
|
||||
taskName: trimmedSearchText,
|
||||
startTime: Self.requestDateString(from: startDate),
|
||||
endTime: Self.requestDateString(from: endDate)
|
||||
)
|
||||
}
|
||||
|
||||
/// 搜索关键字去除空白后为空则不传给后端。
|
||||
private var trimmedSearchText: String? {
|
||||
let text = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// 日期请求格式化器。
|
||||
private static let requestDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 将可选日期格式化成请求字符串。
|
||||
private static func requestDateString(from date: Date?) -> String? {
|
||||
guard let date else { return nil }
|
||||
return requestDateFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务详情 ViewModel,负责详情加载和列表摘要兜底展示。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskDetailViewModel {
|
||||
var isLoading = false
|
||||
var detail: TaskDetailResponse?
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载任务详情;接口失败时保留外部传入的列表摘要。
|
||||
func load(api: any TaskServing, taskId: Int) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.taskDetail(id: taskId)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
281
suixinkan/Features/Tasks/Views/TaskCreateView.swift
Normal file
281
suixinkan/Features/Tasks/Views/TaskCreateView.swift
Normal file
@ -0,0 +1,281 @@
|
||||
//
|
||||
// TaskCreateView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 发布任务页面,负责填写任务信息、选择附件并提交任务。
|
||||
struct TaskCreateView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = TaskCreateViewModel()
|
||||
@State private var showOrderSelection = false
|
||||
@State private var showCloudSelection = false
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
basicFormSection
|
||||
orderSection
|
||||
cloudFileSection
|
||||
localFileSection
|
||||
submitButton
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("发布任务")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.loadAvailableOrders(api: taskAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
.sheet(isPresented: $showOrderSelection) {
|
||||
NavigationStack {
|
||||
TaskOrderSelectionView(
|
||||
orders: viewModel.availableOrders,
|
||||
selectedOrder: viewModel.selectedOrder,
|
||||
onSelect: { order in
|
||||
viewModel.selectOrder(order)
|
||||
showOrderSelection = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCloudSelection) {
|
||||
NavigationStack {
|
||||
TaskCloudFileSelectionView { items in
|
||||
viewModel.mergeCloudFiles(items)
|
||||
showCloudSelection = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: pickerItems) { _, items in
|
||||
Task { await importPickerItems(items) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 基础表单区域。
|
||||
private var basicFormSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("任务信息")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
TextField("任务名称", text: $viewModel.taskName)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
TextField("紧急小时", text: $viewModel.urgentHourText)
|
||||
.keyboardType(.numberPad)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
TextField("任务备注", text: $viewModel.remark, axis: .vertical)
|
||||
.lineLimit(4, reservesSpace: true)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 104)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 关联订单区域。
|
||||
private var orderSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text("关联订单")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
Button(viewModel.selectedOrder == nil ? "选择订单" : "更换") {
|
||||
showOrderSelection = true
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
|
||||
if let order = viewModel.selectedOrder {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(order.projectName.isEmpty ? "未命名项目" : order.projectName)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("订单号:\(order.orderNumber)")
|
||||
Text("手机号:\(order.userPhone.isEmpty ? "暂无" : order.userPhone)")
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Button("清除关联订单") {
|
||||
viewModel.selectOrder(nil)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(Color(hex: 0xEF4444))
|
||||
} else {
|
||||
Text("可不选择订单,直接发布普通任务。")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 云盘附件区域。
|
||||
private var cloudFileSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text("云盘附件")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
Button("选择云盘文件") {
|
||||
showCloudSelection = true
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
|
||||
if viewModel.selectedCloudFiles.isEmpty {
|
||||
Text("未选择云盘文件")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ForEach(viewModel.selectedCloudFiles) { file in
|
||||
TaskAttachmentRow(title: file.fileName, subtitle: "云盘文件") {
|
||||
viewModel.removeCloudFile(id: file.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 本地附件区域。
|
||||
private var localFileSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
Text("本地附件")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
PhotosPicker(selection: $pickerItems, maxSelectionCount: 9, matching: .any(of: [.images, .videos])) {
|
||||
Text("选择图片/视频")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.selectedLocalFiles.isEmpty {
|
||||
Text("未选择本地文件")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ForEach(viewModel.selectedLocalFiles) { file in
|
||||
TaskAttachmentRow(title: file.fileName, subtitle: file.statusText) {
|
||||
viewModel.removeLocalFile(id: file.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 提交按钮。
|
||||
private var submitButton: some View {
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Text(viewModel.isSubmitting ? "提交中..." : "提交任务")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
}
|
||||
.disabled(viewModel.isSubmitting)
|
||||
.opacity(viewModel.isSubmitting ? 0.6 : 1)
|
||||
}
|
||||
|
||||
/// 导入 PhotosPicker 选择的本地文件。
|
||||
private func importPickerItems(_ items: [PhotosPickerItem]) async {
|
||||
for item in items {
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
|
||||
let fileName = Self.fileName(for: item)
|
||||
viewModel.addLocalFile(data: data, fileName: fileName)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
pickerItems = []
|
||||
}
|
||||
|
||||
/// 提交发布任务表单。
|
||||
private func submit() async {
|
||||
do {
|
||||
let success = try await globalLoading.withLoading {
|
||||
try await viewModel.submit(api: taskAPI, uploadService: ossUploadService, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
if success {
|
||||
toastCenter.show("任务发布成功")
|
||||
dismiss()
|
||||
} else if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
} catch {
|
||||
toastCenter.show(viewModel.errorMessage ?? error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据选择项类型生成本地附件文件名。
|
||||
private static func fileName(for item: PhotosPickerItem) -> String {
|
||||
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) || $0.conforms(to: .video) }
|
||||
return "\(UUID().uuidString).\(isVideo ? "mp4" : "jpg")"
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务附件行,展示文件名、状态和删除按钮。
|
||||
private struct TaskAttachmentRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "paperclip")
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||
.background(AppDesign.primarySoft, in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||
}
|
||||
.accessibilityLabel("删除附件")
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
}
|
||||
186
suixinkan/Features/Tasks/Views/TaskDetailView.swift
Normal file
186
suixinkan/Features/Tasks/Views/TaskDetailView.swift
Normal file
@ -0,0 +1,186 @@
|
||||
//
|
||||
// TaskDetailView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 任务详情页面,展示任务基础信息、关联订单和任务结果。
|
||||
struct TaskDetailView: View {
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let taskId: Int
|
||||
let summary: PhotographerTaskItem?
|
||||
|
||||
@State private var viewModel = TaskDetailViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
headerSection
|
||||
orderSection
|
||||
resultSection
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("任务详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await globalLoading.withOptionalLoading(viewModel.detail == nil) {
|
||||
await viewModel.load(api: taskAPI, taskId: taskId)
|
||||
}
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务头部信息区域。
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack(alignment: .top) {
|
||||
Text(displayName)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Text(displayStatus)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||
.background(AppDesign.primarySoft, in: Capsule())
|
||||
}
|
||||
|
||||
TaskInfoRow(title: "任务备注", value: detail?.photogRemark ?? summary?.photogRemark ?? "暂无")
|
||||
TaskInfoRow(title: "创建时间", value: detail?.createdAt ?? summary?.createdAt ?? "暂无")
|
||||
TaskInfoRow(title: "紧急小时", value: detail.map { "\($0.urgentHour) 小时" } ?? "暂无")
|
||||
TaskInfoRow(title: "编辑人员", value: detail?.editorName.nonEmpty ?? summary?.editorName.nonEmpty ?? "暂无")
|
||||
TaskInfoRow(title: "编辑电话", value: detail?.editorPhone.nonEmpty ?? "暂无")
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 关联订单区域。
|
||||
private var orderSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("关联订单")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
TaskInfoRow(title: "项目名称", value: detail?.order?.projectName.nonEmpty ?? summary?.orderUserNickname.nonEmpty ?? "暂无")
|
||||
TaskInfoRow(title: "订单号", value: detail?.order?.orderNumber.nonEmpty ?? summary?.orderNumber.nonEmpty ?? "暂无")
|
||||
TaskInfoRow(title: "手机号", value: detail?.order?.userPhone.nonEmpty ?? summary?.orderUserPhone.nonEmpty ?? "暂无")
|
||||
TaskInfoRow(title: "支付时间", value: detail?.order?.payTime.nonEmpty ?? "暂无")
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 任务结果区域。
|
||||
private var resultSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("任务结果")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
if mediaItems.isEmpty {
|
||||
Text("暂无任务结果文件")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(mediaItems) { media in
|
||||
TaskMediaThumbView(media: media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 当前已加载的详情。
|
||||
private var detail: TaskDetailResponse? {
|
||||
viewModel.detail
|
||||
}
|
||||
|
||||
/// 展示任务名称。
|
||||
private var displayName: String {
|
||||
detail?.name.nonEmpty ?? summary?.name.nonEmpty ?? "未命名任务"
|
||||
}
|
||||
|
||||
/// 展示任务状态。
|
||||
private var displayStatus: String {
|
||||
detail?.taskStatusLabel.nonEmpty ?? summary?.statusName.nonEmpty ?? "未知"
|
||||
}
|
||||
|
||||
/// 展示任务媒体列表。
|
||||
private var mediaItems: [TaskMediaItem] {
|
||||
if let detail {
|
||||
return detail.taskResult
|
||||
}
|
||||
return summary?.media ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务信息行,展示左右结构的标题和值。
|
||||
private struct TaskInfoRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(width: 72, alignment: .leading)
|
||||
Text(value.isEmpty ? "暂无" : value)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务媒体缩略图视图,使用 Kingfisher 加载网络图片。
|
||||
private struct TaskMediaThumbView: View {
|
||||
let media: TaskMediaItem
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RemoteImage(urlString: media.displayURL) {
|
||||
Image(systemName: media.isVideo ? "video.fill" : "photo")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: 0xEEF2F7))
|
||||
}
|
||||
.frame(height: 92)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
|
||||
if media.isVideo {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(AppMetrics.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 去除空白后返回非空字符串。
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
272
suixinkan/Features/Tasks/Views/TaskManagementView.swift
Normal file
272
suixinkan/Features/Tasks/Views/TaskManagementView.swift
Normal file
@ -0,0 +1,272 @@
|
||||
//
|
||||
// TaskManagementView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 任务管理页面,展示任务列表、筛选条件和发布任务入口。
|
||||
struct TaskManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = TaskManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
filterSection
|
||||
contentSection
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("任务管理")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
router.navigate(to: .home(.taskCreate))
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("发布任务")
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.task {
|
||||
guard viewModel.tasks.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选区域,包含状态、搜索和日期筛选。
|
||||
private var filterSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Picker("任务状态", selection: $viewModel.selectedStatus) {
|
||||
ForEach(TaskStatusFilter.allCases) { status in
|
||||
Text(status.title).tag(status)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedStatus) { _, _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索任务名称", text: $viewModel.searchText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { Task { await reload(showLoading: true) } }
|
||||
Button("搜索") {
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
TaskDatePickerRow(title: "开始日期", date: $viewModel.startDate)
|
||||
TaskDatePickerRow(title: "结束日期", date: $viewModel.endDate)
|
||||
HStack {
|
||||
Button("清除日期") {
|
||||
viewModel.startDate = nil
|
||||
viewModel.endDate = nil
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("应用日期") {
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表内容区域,展示空状态、任务卡片和加载更多。
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if accountContext.currentScenic?.id == nil {
|
||||
TaskEmptyState(title: "缺少景区上下文", message: "请先在首页选择景区后再查看任务。")
|
||||
} else if viewModel.tasks.isEmpty {
|
||||
TaskEmptyState(title: "暂无任务", message: viewModel.errorMessage ?? "当前筛选条件下没有任务。")
|
||||
} else {
|
||||
ForEach(viewModel.tasks) { task in
|
||||
Button {
|
||||
router.navigate(to: .home(.taskDetail(id: task.id, summary: task)))
|
||||
} label: {
|
||||
TaskCardView(task: task)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
guard task.id == viewModel.tasks.last?.id else { return }
|
||||
Task { await loadMore() }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
Text("加载更多...")
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
} else if viewModel.hasMore {
|
||||
Button("加载更多") {
|
||||
Task { await loadMore() }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载任务列表。
|
||||
private func reload(showLoading: Bool) async {
|
||||
do {
|
||||
try await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
|
||||
try await viewModel.reload(api: taskAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页任务列表。
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMore(api: taskAPI, scenicId: accountContext.currentScenic?.id)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务卡片视图,展示任务名称、状态、订单和时间信息。
|
||||
private struct TaskCardView: View {
|
||||
let task: PhotographerTaskItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(task.name.isEmpty ? "未命名任务" : task.name)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
if !task.orderNumber.isEmpty {
|
||||
Text("订单号:\(task.orderNumber)")
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: AppMetrics.Spacing.small)
|
||||
|
||||
Text(task.statusName.isEmpty ? "\(task.taskStatus)" : task.statusName)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(statusColor)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||
.background(statusColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
|
||||
if !task.photogRemark.isEmpty {
|
||||
Text(task.photogRemark)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Label(task.editorName.isEmpty ? "暂无编辑" : task.editorName, systemImage: "person.crop.circle")
|
||||
Spacer()
|
||||
Text(task.createdAt.isEmpty ? task.updatedAt : task.createdAt)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 返回任务状态对应的强调色。
|
||||
private var statusColor: Color {
|
||||
switch task.taskStatus {
|
||||
case 3:
|
||||
AppDesign.success
|
||||
case 4:
|
||||
Color(hex: 0xEF4444)
|
||||
case 1, 2:
|
||||
AppDesign.warning
|
||||
default:
|
||||
AppDesign.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务日期筛选行,封装可选日期选择控件。
|
||||
private struct TaskDatePickerRow: View {
|
||||
let title: String
|
||||
@Binding var date: Date?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
DatePicker(
|
||||
title,
|
||||
selection: Binding(
|
||||
get: { date ?? Date() },
|
||||
set: { date = $0 }
|
||||
),
|
||||
displayedComponents: .date
|
||||
)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务空状态视图,用于缺少景区或无数据时展示。
|
||||
private struct TaskEmptyState: View {
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 36, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(message)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||
.padding(.horizontal, AppMetrics.Spacing.large)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
271
suixinkan/Features/Tasks/Views/TaskSelectionViews.swift
Normal file
271
suixinkan/Features/Tasks/Views/TaskSelectionViews.swift
Normal file
@ -0,0 +1,271 @@
|
||||
//
|
||||
// TaskSelectionViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 任务订单选择页面,供发布任务时关联已有订单。
|
||||
struct TaskOrderSelectionView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let orders: [AvailableOrderResponse]
|
||||
let selectedOrder: AvailableOrderResponse?
|
||||
let onSelect: (AvailableOrderResponse?) -> Void
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Button {
|
||||
onSelect(nil)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("不关联订单", systemImage: selectedOrder == nil ? "checkmark.circle.fill" : "circle")
|
||||
}
|
||||
|
||||
ForEach(orders) { order in
|
||||
Button {
|
||||
onSelect(order)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: selectedOrder?.orderNumber == order.orderNumber ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(order.projectName.isEmpty ? "未命名项目" : order.projectName)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("订单号:\(order.orderNumber)")
|
||||
Text("手机号:\(order.userPhone.isEmpty ? "暂无" : order.userPhone)")
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("选择订单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务云盘文件选择页面,供发布任务时选择云盘附件。
|
||||
struct TaskCloudFileSelectionView: View {
|
||||
@Environment(TaskAPI.self) private var taskAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let onConfirm: ([TaskCloudSelectionItem]) -> Void
|
||||
|
||||
@State private var viewModel = TaskCloudFileSelectionViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
filterBar
|
||||
breadcrumbBar
|
||||
fileList
|
||||
confirmBar
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("选择云盘文件")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索与类型筛选区域。
|
||||
private var filterBar: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索文件名", text: $viewModel.searchText)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { Task { await reload() } }
|
||||
Button("搜索") {
|
||||
Task { await reload() }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
Picker("文件类型", selection: $viewModel.selectedFilter) {
|
||||
ForEach(TaskCloudFileFilter.allCases) { filter in
|
||||
Text(filter.title).tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedFilter) { _, _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
/// 云盘路径面包屑区域。
|
||||
private var breadcrumbBar: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
ForEach(Array(viewModel.path.enumerated()), id: \.element.id) { index, folder in
|
||||
Button(folder.name) {
|
||||
Task { await popToFolder(index) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.foregroundStyle(index == viewModel.path.count - 1 ? AppDesign.textPrimary : AppDesign.primary)
|
||||
if index < viewModel.path.count - 1 {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
.background(Color(hex: 0xF8FAFC))
|
||||
}
|
||||
|
||||
/// 云盘文件列表区域。
|
||||
private var fileList: some View {
|
||||
List {
|
||||
if viewModel.files.isEmpty {
|
||||
Text(viewModel.errorMessage ?? "暂无文件")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||
.listRowBackground(Color.clear)
|
||||
} else {
|
||||
ForEach(viewModel.files) { file in
|
||||
Button {
|
||||
Task { await handleTap(file) }
|
||||
} label: {
|
||||
CloudFileRow(file: file, isSelected: viewModel.isSelected(file))
|
||||
}
|
||||
.onAppear {
|
||||
guard file.id == viewModel.files.last?.id else { return }
|
||||
Task { await loadMore() }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
Text("加载更多...")
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
/// 底部确认区域。
|
||||
private var confirmBar: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
Text("已选 \(viewModel.selectedFiles.count) 个文件")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Button {
|
||||
onConfirm(viewModel.makeSelectionItems())
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("确认")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 108, height: 44)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
/// 重新加载当前云盘目录。
|
||||
private func reload() async {
|
||||
do {
|
||||
try await viewModel.reload(api: taskAPI)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页云盘文件。
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMore(api: taskAPI)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理云盘列表点击,文件夹进入目录,文件切换选中。
|
||||
private func handleTap(_ file: CloudDriveFile) async {
|
||||
if file.isFolder {
|
||||
do {
|
||||
try await viewModel.enterFolder(file, api: taskAPI)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
} else {
|
||||
viewModel.toggleSelection(file)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回到指定面包屑目录。
|
||||
private func popToFolder(_ index: Int) async {
|
||||
do {
|
||||
try await viewModel.popToFolder(at: index, api: taskAPI)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘文件列表行,展示文件或文件夹图标和选择状态。
|
||||
private struct CloudFileRow: View {
|
||||
let file: CloudDriveFile
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: file.isFolder ? "folder.fill" : (file.isVideo ? "video.fill" : "photo.fill"))
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(file.isFolder ? AppDesign.warning : AppDesign.primary)
|
||||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(file.name.isEmpty ? "未命名文件" : file.name)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(file.isFolder ? "\(file.childNum) 项" : "\(file.fileSize / 1024) KB")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !file.isFolder {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isSelected ? AppDesign.primary : AppDesign.placeholder)
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user