Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,91 @@
//
// ScheduleAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
@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
final class ScheduleAPI: ScheduleServing {
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,149 @@
//
// ScheduleViewControllers.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
///
final class ScheduleManagementViewController: ModuleTableViewController {
private let viewModel = ScheduleManagementViewModel()
override func viewDidLoad() {
title = "排班管理"
navigationItem.rightBarButtonItems = [
UIBarButtonItem(title: "新增", style: .plain, target: self, action: #selector(addSchedule)),
UIBarButtonItem(title: "下月", style: .plain, target: self, action: #selector(nextMonth))
]
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "上月",
style: .plain,
target: self,
action: #selector(previousMonth)
)
super.viewDidLoad()
wireViewModel(viewModel) { [weak self] in self?.updateTitle() }
}
override func tableRowCount() -> Int {
viewModel.items.count
}
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
cell.configure(
title: item.name,
subtitle: "\(item.startTime) - \(item.endTime)",
detail: item.orderNumber ?? item.remark
)
}
override func didSelectTableRow(at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
let alert = UIAlertController(title: item.name, message: "删除该排班?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.delete(item: item, api: self.services.scheduleAPI, scenicId: self.services.currentScenicId) }
})
present(alert, animated: true)
}
override func reloadContent() async {
await viewModel.reload(api: services.scheduleAPI, scenicId: services.currentScenicId)
updateTitle()
}
private func updateTitle() {
let month = viewModel.monthDate.scheduleYearMonthText
title = "排班 \(month)"
}
@objc private func previousMonth() {
Task { await viewModel.previousMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) }
}
@objc private func nextMonth() {
Task { await viewModel.nextMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) }
}
@objc private func addSchedule() {
navigationController?.pushViewController(ScheduleAddViewController(), animated: true)
}
}
extension ScheduleManagementViewModel: ViewModelBindable {}
///
final class ScheduleAddViewController: ModuleTableViewController {
private let viewModel = ScheduleAddViewModel()
private let nameField = UITextField()
private let remarkField = UITextField()
override func viewDidLoad() {
title = "新增排班"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "提交",
style: .done,
target: self,
action: #selector(submit)
)
super.viewDidLoad()
wireViewModel(viewModel) { }
setupFormHeader()
}
private func setupFormHeader() {
nameField.placeholder = "日程名称"
nameField.borderStyle = .roundedRect
remarkField.placeholder = "备注"
remarkField.borderStyle = .roundedRect
let stack = UIStackView(arrangedSubviews: [nameField, remarkField])
stack.axis = .vertical
stack.spacing = 8
stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96)
stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
stack.isLayoutMarginsRelativeArrangement = true
tableView.tableHeaderView = stack
}
override func tableRowCount() -> Int {
viewModel.availableOrders.count
}
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let order = viewModel.availableOrders[indexPath.row]
let selected = viewModel.draft.selectedOrder?.orderNumber == order.orderNumber
cell.configure(title: order.orderNumber, subtitle: order.projectName, detail: selected ? "已选择" : nil)
cell.accessoryType = selected ? .checkmark : .none
}
override func didSelectTableRow(at indexPath: IndexPath) {
viewModel.draft.selectedOrder = viewModel.availableOrders[indexPath.row]
reloadTable()
}
override func reloadContent() async {
await viewModel.loadOrders(api: services.scheduleAPI, scenicId: services.currentScenicId)
}
@objc private func submit() {
viewModel.draft.name = nameField.text ?? ""
viewModel.draft.remark = remarkField.text ?? ""
Task {
let start = Date()
let end = Calendar.current.date(byAdding: .hour, value: 1, to: start) ?? start
let success = await viewModel.submit(
api: services.scheduleAPI,
scenicId: services.currentScenicId,
startDate: start,
endDate: end
)
if success { navigationController?.popViewController(animated: true) }
}
}
}
extension ScheduleAddViewModel: ViewModelBindable {}

View File

@ -0,0 +1,224 @@
//
// ScheduleViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
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
final class ScheduleManagementViewModel {
var onChange: (() -> Void)?
var monthDate = Date.scheduleFirstDayOfMonth { didSet { onChange?() } }
var selectedDate = Date() { didSet { onChange?() } }
private(set) var markedDays: Set<String> = [] { didSet { onChange?() } }
private(set) var items: [ScheduleItem] = [] { didSet { onChange?() } }
private(set) var loading = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
///
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
final class ScheduleAddViewModel {
var onChange: (() -> Void)?
var draft = ScheduleDraft() { didSet { onChange?() } }
private(set) var availableOrders: [AvailableOrderResponse] = [] { didSet { onChange?() } }
private(set) var loadingOrders = false { didSet { onChange?() } }
private(set) var submitting = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
///
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)
}
}
}