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

将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 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,93 @@
//
// ScheduleAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
import Observation
///
@MainActor
protocol ScheduleServing {
///
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String]
///
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem]
///
func addSchedule(_ request: AddScheduleRequest) async throws
///
func deleteSchedule(id: Int) async throws
///
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse]
}
/// API
@MainActor
@Observable
final class ScheduleAPI: ScheduleServing {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/schedule/list-date",
queryItems: [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "date", value: yearMonth)
]
)
)
}
///
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/schedule/list",
queryItems: [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "date", value: date)
]
)
)
}
///
func addSchedule(_ request: AddScheduleRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/schedule/add", body: request)
) as EmptyPayload
}
///
func deleteSchedule(id: Int) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/schedule/delete", body: ScheduleDeleteRequest(id: id))
) as EmptyPayload
}
///
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)")]
)
)
}
}

View File

@ -0,0 +1,140 @@
//
// ScheduleModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
struct ScheduleItem: Decodable, Identifiable, Hashable {
let id: Int
let name: String
let remark: String
let startTime: String
let endTime: String
let scheduleDate: String
let orderNumber: String?
let userNickname: String?
let userPhone: String?
let orderStatus: Int?
let orderStatusName: String?
/// 线
enum CodingKeys: String, CodingKey {
case id
case name
case remark
case startTime = "start_time"
case endTime = "end_time"
case scheduleDate = "schedule_date"
case orderNumber = "order_number"
case userNickname = "user_nickname"
case userPhone = "user_phone"
case orderStatus = "order_status"
case orderStatusName = "order_status_name"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeScheduleLossyInt(forKey: .id) ?? 0
name = try container.decodeScheduleLossyString(forKey: .name)
remark = try container.decodeScheduleLossyString(forKey: .remark)
startTime = try container.decodeScheduleLossyString(forKey: .startTime)
endTime = try container.decodeScheduleLossyString(forKey: .endTime)
scheduleDate = try container.decodeScheduleLossyString(forKey: .scheduleDate)
orderNumber = container.decodeScheduleLossyOptionalString(forKey: .orderNumber)
userNickname = container.decodeScheduleLossyOptionalString(forKey: .userNickname)
userPhone = container.decodeScheduleLossyOptionalString(forKey: .userPhone)
orderStatus = try container.decodeScheduleLossyInt(forKey: .orderStatus)
orderStatusName = container.decodeScheduleLossyOptionalString(forKey: .orderStatusName)
}
}
///
struct AddScheduleRequest: Encodable, Equatable {
let scenicId: String
let name: String
let remark: String
let startTime: String
let endTime: String
let scheduleDate: String
let orderNumber: String?
/// 线
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case name
case remark
case startTime = "start_time"
case endTime = "end_time"
case scheduleDate = "schedule_date"
case orderNumber = "order_number"
}
}
///
struct ScheduleDeleteRequest: Encodable, Equatable {
let id: Int
}
/// ViewModel
struct ScheduleDraft: Equatable {
var name = ""
var remark = ""
var scheduleDate = ""
var startTime = ""
var endTime = ""
var manualOrderNumber = ""
var selectedOrder: AvailableOrderResponse?
/// 使
func finalOrderNumber() -> String? {
let picked = selectedOrder?.orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let manual = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
let value = picked.isEmpty ? manual : picked
return value.isEmpty ? nil : value
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeScheduleLossyString(forKey key: Key) throws -> String {
decodeScheduleLossyOptionalString(forKey: key) ?? ""
}
/// String Bool
func decodeScheduleLossyOptionalString(forKey key: Key) -> String? {
if let value = try? decodeIfPresent(String.self, forKey: key) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : 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 ? "1" : "0"
}
return nil
}
/// StringDouble Int Int
func decodeScheduleLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(text) ?? Double(text).map(Int.init)
}
return nil
}
}

View File

@ -0,0 +1,27 @@
# Schedule 模块业务逻辑
## 模块职责
Schedule 模块负责首页 `schedule_management` 入口,提供排班日历、某日排班列表、新增排班和删除排班能力。
模块状态只保存在 `ScheduleManagementViewModel``ScheduleAddViewModel` 内,不写入全局登录态、账号上下文或首页状态。
## 排班列表
`ScheduleManagementViewModel` 按当前月份加载有排班的日期标记,并按选中日期加载日排班列表。切换月份或日期时只刷新当前模块数据。
缺少当前景区时ViewModel 会清空月份标记和日程列表,并展示缺少经营上下文的空状态。
## 新增排班
`ScheduleAddViewModel` 管理名称、备注、日期、开始/结束时间和关联订单。关联订单可以从接口返回的可选订单中选择,也可以手填订单号兜底。
提交前会校验名称、景区、日期和时间顺序;重复提交会被忽略。提交成功后返回排班列表并刷新当前日期。
## 接口边界
`ScheduleAPI` 封装月排班日期、日排班列表、新增排班、删除排班和可关联订单接口。可关联订单模型放在共享业务模型中,避免 Schedule 直接依赖 Tasks 模块。
## 缓存边界
排班表单、关联订单、月标记和日程列表都只保存在内存中,不做本地缓存。

View File

@ -0,0 +1,225 @@
//
// ScheduleViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
import Observation
///
enum ScheduleValidationError: LocalizedError, Equatable {
case missingScenic
case missingName
case invalidTime
///
var errorDescription: String? {
switch self {
case .missingScenic: return "当前缺少景区信息"
case .missingName: return "请输入日程名称"
case .invalidTime: return "结束时间必须晚于开始时间"
}
}
}
/// ViewModel
@MainActor
@Observable
final class ScheduleManagementViewModel {
var monthDate = Date.scheduleFirstDayOfMonth
var selectedDate = Date()
private(set) var markedDays: Set<String> = []
private(set) var items: [ScheduleItem] = []
private(set) var loading = false
var errorMessage: String?
///
func previousMonth(api: any ScheduleServing, scenicId: Int?) async {
monthDate = Calendar.current.date(byAdding: .month, value: -1, to: monthDate) ?? monthDate
selectedDate = monthDate
await reload(api: api, scenicId: scenicId)
}
///
func nextMonth(api: any ScheduleServing, scenicId: Int?) async {
monthDate = Calendar.current.date(byAdding: .month, value: 1, to: monthDate) ?? monthDate
selectedDate = monthDate
await reload(api: api, scenicId: scenicId)
}
///
func selectDate(_ date: Date, api: any ScheduleServing, scenicId: Int?) async {
selectedDate = date
await loadDay(api: api, scenicId: scenicId)
}
///
func reload(api: any ScheduleServing, scenicId: Int?) async {
guard let scenicId else {
reset()
return
}
loading = true
errorMessage = nil
defer { loading = false }
do {
async let days = api.monthScheduleDays(scenicId: scenicId, yearMonth: monthDate.scheduleYearMonthText)
async let list = api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText)
markedDays = Set(try await days)
items = try await list
} catch {
items = []
markedDays = []
errorMessage = error.localizedDescription
}
}
///
func loadDay(api: any ScheduleServing, scenicId: Int?) async {
guard let scenicId else {
reset()
return
}
do {
items = try await api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText)
} catch {
items = []
errorMessage = error.localizedDescription
}
}
///
func delete(item: ScheduleItem, api: any ScheduleServing, scenicId: Int?) async {
do {
try await api.deleteSchedule(id: item.id)
await reload(api: api, scenicId: scenicId)
} catch {
errorMessage = error.localizedDescription
}
}
private func reset() {
markedDays = []
items = []
loading = false
}
}
/// ViewModel
@MainActor
@Observable
final class ScheduleAddViewModel {
var draft = ScheduleDraft()
private(set) var availableOrders: [AvailableOrderResponse] = []
private(set) var loadingOrders = false
private(set) var submitting = false
var errorMessage: String?
///
func loadOrders(api: any ScheduleServing, scenicId: Int?) async {
guard let scenicId else {
availableOrders = []
return
}
loadingOrders = true
defer { loadingOrders = false }
do {
availableOrders = try await api.availableOrderList(scenicId: scenicId)
} catch {
availableOrders = []
errorMessage = error.localizedDescription
}
}
///
func submit(api: any ScheduleServing, scenicId: Int?, startDate: Date, endDate: Date) async -> Bool {
guard !submitting else { return false }
guard let scenicId else { return fail(.missingScenic) }
let normalizedName = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedName.isEmpty else { return fail(.missingName) }
guard endDate > startDate else { return fail(.invalidTime) }
submitting = true
errorMessage = nil
defer { submitting = false }
do {
try await api.addSchedule(
AddScheduleRequest(
scenicId: "\(scenicId)",
name: normalizedName,
remark: draft.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? normalizedName : draft.remark.trimmingCharacters(in: .whitespacesAndNewlines),
startTime: startDate.scheduleHourMinuteText,
endTime: endDate.scheduleHourMinuteText,
scheduleDate: startDate.scheduleDayText,
orderNumber: draft.finalOrderNumber()
)
)
return true
} catch {
errorMessage = normalizedError(error)
return false
}
}
///
private func normalizedError(_ error: Error) -> String {
let text = error.localizedDescription
if text.contains("start time") || text.contains("format H:i") {
return "开始时间格式不正确,请重新选择开始时间"
}
if text.contains("end time") {
return "结束时间格式不正确,请重新选择结束时间"
}
return text
}
private func fail(_ error: ScheduleValidationError) -> Bool {
errorMessage = error.localizedDescription
return false
}
}
extension Date {
///
static var scheduleFirstDayOfMonth: Date {
Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date()
}
/// yyyy-MM
var scheduleYearMonthText: String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM"
return formatter.string(from: self)
}
/// yyyy-MM-dd
var scheduleDayText: String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: self)
}
/// HH:mm
var scheduleHourMinuteText: String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "HH:mm"
return formatter.string(from: self)
}
///
var scheduleDaysInMonth: [Date] {
let calendar = Calendar.current
let range = calendar.range(of: .day, in: .month, for: self) ?? 1..<2
let components = calendar.dateComponents([.year, .month], from: self)
return range.compactMap { day in
var next = components
next.day = day
return calendar.date(from: next)
}
}
}

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