新增项目、排期与邀请模块,并接入首页路由

将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 15:57:15 +08:00
parent abcac9bfdf
commit 311a70d610
40 changed files with 4496 additions and 108 deletions

View File

@ -0,0 +1,255 @@
//
// ScheduleViews.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import SwiftUI
///
struct ScheduleManagementView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(ScheduleAPI.self) private var scheduleAPI
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = ScheduleManagementViewModel()
var body: some View {
VStack(spacing: 0) {
if accountContext.currentScenic?.id == nil {
ContentUnavailableView("缺少景区", systemImage: "calendar.badge.exclamationmark", description: Text("请选择景区后再查看排班。"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
calendarCard
scheduleListCard
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.refreshable { await reload(showLoading: false) }
}
}
.navigationTitle("日程管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: HomeRoute.scheduleAdd) {
Image(systemName: "plus")
}
.accessibilityLabel("添加日程")
}
}
.task(id: accountContext.currentScenic?.id) {
await reload(showLoading: true)
}
}
private var calendarCard: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
HStack {
Button {
Task { await viewModel.previousMonth(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
} label: {
Image(systemName: "chevron.left")
.frame(width: 36, height: 36)
}
Spacer()
Text(viewModel.monthDate.scheduleYearMonthText)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
Spacer()
Button {
Task { await viewModel.nextMonth(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
} label: {
Image(systemName: "chevron.right")
.frame(width: 36, height: 36)
}
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.monthDate.scheduleDaysInMonth, id: \.scheduleDayText) { date in
Button {
Task { await viewModel.selectDate(date, api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
} label: {
VStack(spacing: 4) {
Text(dayNumber(date))
.font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? .bold : .regular))
Circle()
.fill(viewModel.markedDays.contains(date.scheduleDayText) ? AppDesign.primary : .clear)
.frame(width: 5, height: 5)
}
.foregroundStyle(viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? .white : AppDesign.textPrimary)
.frame(height: 42)
.frame(maxWidth: .infinity)
.background(viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? AppDesign.primary : Color.clear, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private var scheduleListCard: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(viewModel.selectedDate.scheduleDayText)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
if viewModel.items.isEmpty {
ContentUnavailableView("当天暂无日程", systemImage: "calendar")
.frame(minHeight: 180)
} else {
ForEach(viewModel.items) { item in
ScheduleItemCard(item: item) {
Task { await viewModel.delete(item: item, api: scheduleAPI, scenicId: accountContext.currentScenic?.id) }
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
await viewModel.reload(api: scheduleAPI, scenicId: accountContext.currentScenic?.id)
}
}
private func dayNumber(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "d"
return formatter.string(from: date)
}
}
///
struct ScheduleAddView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AccountContext.self) private var accountContext
@Environment(ScheduleAPI.self) private var scheduleAPI
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = ScheduleAddViewModel()
@State private var date = Date()
@State private var startTime = Date()
@State private var endTime = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
formCard("基础信息") {
TextField("日程名称", text: $viewModel.draft.name)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("备注", text: $viewModel.draft.remark, axis: .vertical)
.lineLimit(2...4)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 82)
Picker("关联订单", selection: Binding(get: { viewModel.draft.selectedOrder?.orderNumber ?? "" }, set: { value in
viewModel.draft.selectedOrder = viewModel.availableOrders.first { $0.orderNumber == value }
})) {
Text("不关联").tag("")
ForEach(viewModel.availableOrders) { order in
Text("\(order.projectName) \(order.orderNumber)").tag(order.orderNumber)
}
}
TextField("手工输入订单号(兜底)", text: $viewModel.draft.manualOrderNumber)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
formCard("时间") {
DatePicker("日期", selection: $date, displayedComponents: .date)
DatePicker("开始时间", selection: $startTime, displayedComponents: .hourAndMinute)
DatePicker("结束时间", selection: $endTime, displayedComponents: .hourAndMinute)
}
if let error = viewModel.errorMessage {
Text(error)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xE5484D))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("添加日程")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(viewModel.submitting ? "保存中..." : "保存") {
Task { await submit() }
}
.disabled(viewModel.submitting)
}
}
.task {
await viewModel.loadOrders(api: scheduleAPI, scenicId: accountContext.currentScenic?.id)
}
}
private func formCard<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
content()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private func submit() async {
let start = combinedDate(date: date, time: startTime)
let end = combinedDate(date: date, time: endTime)
await globalLoading.withLoading {
if await viewModel.submit(api: scheduleAPI, scenicId: accountContext.currentScenic?.id, startDate: start, endDate: end) {
dismiss()
}
}
}
private func combinedDate(date: Date, time: Date) -> Date {
let calendar = Calendar.current
var dateComponents = calendar.dateComponents([.year, .month, .day], from: date)
let timeComponents = calendar.dateComponents([.hour, .minute], from: time)
dateComponents.hour = timeComponents.hour
dateComponents.minute = timeComponents.minute
return calendar.date(from: dateComponents) ?? date
}
}
///
private struct ScheduleItemCard: View {
let item: ScheduleItem
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text(item.name.isEmpty ? "--" : item.name)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
Spacer()
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
.frame(width: 32, height: 32)
}
}
row("clock", "\(item.startTime) - \(item.endTime)")
row("note.text", item.remark.isEmpty ? "无备注" : item.remark)
if let orderNumber = item.orderNumber, !orderNumber.isEmpty {
row("bag", "订单号:\(orderNumber)")
}
if let phone = item.userPhone, !phone.isEmpty {
row("person", "用户:\(phone)")
}
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
private func row(_ icon: String, _ text: String) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.foregroundStyle(AppDesign.textSecondary)
Text(text)
.foregroundStyle(AppDesign.textSecondary)
}
.font(.system(size: AppMetrics.FontSize.caption))
}
}