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