Add Tasks module with home routing for task management flows.

Implement task list, detail, and create pages with API integration, cloud/local attachment support, and unit tests.

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

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