新增任务模块,并接入首页任务管理路由

实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 15:26:13 +08:00
parent 09922f70fe
commit ffb16eca29
23 changed files with 2795 additions and 8 deletions

View File

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

View File

@ -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 的映射,并同步补充单元测试。

View File

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

View File

@ -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",

View File

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

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

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

View 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` 管理。
本轮不迁完整云盘资产管理,只保留发布任务所需的文件选择能力。云盘的新建文件夹、上传、删除、移动、重命名等能力后续应放在独立云盘模块中迁移。

View File

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

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

View File

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

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

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

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

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