新增任务模块,并接入首页任务管理路由
实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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