将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
576 lines
20 KiB
Swift
576 lines
20 KiB
Swift
//
|
|
// ProfileSpaceSettingsViewModel.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// 个人空间配置接口协议,便于页面和测试注入真实或模拟 API。
|
|
@MainActor
|
|
protocol ProfileSpaceSettingsAPI {
|
|
/// 拉取当前景区的个人空间配置。
|
|
func profileInfo(scenicId: Int) async throws -> PhotographerProfileInfoResponse
|
|
|
|
/// 更新当前景区的个人空间配置。
|
|
func updateProfileInfo(_ request: UpdatePhotographerProfileInfoRequest) async throws
|
|
|
|
/// 更新账号基础昵称或头像。
|
|
func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws
|
|
|
|
/// 添加个人日程。
|
|
func addSchedule(_ request: AddPhotographerScheduleRequest) async throws
|
|
|
|
/// 删除个人日程。
|
|
func deleteSchedule(id: Int) async throws
|
|
|
|
/// 上传裁剪后的摄影师头像。
|
|
func uploadAvatar(data: Data, fileName: String) async throws
|
|
}
|
|
|
|
extension ProfileAPI: ProfileSpaceSettingsAPI {}
|
|
|
|
/// 个人空间配置校验错误。
|
|
enum ProfileSpaceSettingsValidationError: LocalizedError, Equatable {
|
|
case emptyTime(String)
|
|
case invalidTimeRange(String)
|
|
case emptyLabel
|
|
case duplicateLabel
|
|
case labelTooLong
|
|
case labelLimitReached
|
|
case emptyDevice
|
|
case emptyScheduleName
|
|
case emptyScheduleTime
|
|
case invalidScheduleTime
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .emptyTime(let title):
|
|
"请输入\(title)"
|
|
case .invalidTimeRange(let title):
|
|
"\(title)开始时间不能大于结束时间"
|
|
case .emptyLabel:
|
|
"标签不能为空"
|
|
case .duplicateLabel:
|
|
"标签已存在"
|
|
case .labelTooLong:
|
|
"标签最多20个字符"
|
|
case .labelLimitReached:
|
|
"最多8个标签"
|
|
case .emptyDevice:
|
|
"设备名称不能为空"
|
|
case .emptyScheduleName:
|
|
"请输入日程名称"
|
|
case .emptyScheduleTime:
|
|
"请选择日程时间"
|
|
case .invalidScheduleTime:
|
|
"结束时间必须晚于开始时间"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 个人空间配置 ViewModel,对齐 Android `ProfileInfoViewModel` 的状态和校验。
|
|
final class ProfileSpaceSettingsViewModel {
|
|
|
|
private(set) var avatarURL = ""
|
|
private(set) var realName = ""
|
|
private(set) var nickname = ""
|
|
private(set) var editingNickname = ""
|
|
private(set) var introduction = ""
|
|
private(set) var scenicCertification: [String] = []
|
|
private(set) var attrLabels: [String] = []
|
|
private(set) var shootLabels: [String] = []
|
|
private(set) var businessStartTime: String?
|
|
private(set) var businessEndTime: String?
|
|
private(set) var holidayStartTime: String?
|
|
private(set) var holidayEndTime: String?
|
|
private(set) var cameraDevices: [String] = []
|
|
private(set) var acceptOrderStatus = 0
|
|
private(set) var scheduleMap: [String: [PhotographerSchedule]] = [:]
|
|
private(set) var selectedDate: Date
|
|
private(set) var isEditingProfile = false
|
|
private(set) var isCertificationExpanded = false
|
|
private(set) var isLoading = false
|
|
private(set) var hasChanges = false
|
|
|
|
var onStateChange: (() -> Void)?
|
|
var onShowMessage: ((String) -> Void)?
|
|
|
|
private var originalSnapshot = Snapshot()
|
|
private let appStore: AppStore
|
|
private let calendar: Calendar
|
|
private let nowProvider: () -> Date
|
|
|
|
init(
|
|
appStore: AppStore = .shared,
|
|
calendar: Calendar = Calendar(identifier: .gregorian),
|
|
nowProvider: @escaping () -> Date = Date.init
|
|
) {
|
|
self.appStore = appStore
|
|
self.calendar = calendar
|
|
self.nowProvider = nowProvider
|
|
selectedDate = calendar.startOfDay(for: nowProvider())
|
|
}
|
|
|
|
/// 当前景区 ID。
|
|
var scenicId: Int {
|
|
appStore.session.currentScenicId
|
|
}
|
|
|
|
/// 选中日期对应日程。
|
|
var selectedDateSchedules: [PhotographerSchedule] {
|
|
scheduleMap[Self.dateKey(from: selectedDate, calendar: calendar)] ?? []
|
|
}
|
|
|
|
/// 从今天起连续 7 天。
|
|
var weekDates: [Date] {
|
|
let start = calendar.startOfDay(for: nowProvider())
|
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
|
|
}
|
|
|
|
/// 拉取个人空间配置。
|
|
func load(api: any ProfileSpaceSettingsAPI) async {
|
|
guard scenicId > 0 else {
|
|
onShowMessage?("请先选择景区")
|
|
return
|
|
}
|
|
setLoading(true)
|
|
defer { setLoading(false) }
|
|
do {
|
|
let response = try await api.profileInfo(scenicId: scenicId)
|
|
apply(response)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// 切换昵称和简介编辑状态,退出编辑时按 Android 逻辑单独保存昵称。
|
|
func toggleProfileEditing(api: any ProfileSpaceSettingsAPI) async {
|
|
if isEditingProfile {
|
|
let newNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let currentNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !newNickname.isEmpty, newNickname != currentNickname else {
|
|
isEditingProfile = false
|
|
notifyStateChange()
|
|
return
|
|
}
|
|
setLoading(true)
|
|
defer { setLoading(false) }
|
|
do {
|
|
try await api.updateUserInfo(nickname: newNickname, password: nil, avatar: nil)
|
|
nickname = newNickname
|
|
appStore.session.userName = newNickname
|
|
isEditingProfile = false
|
|
onShowMessage?("修改成功")
|
|
notifyStateChange()
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
}
|
|
return
|
|
}
|
|
editingNickname = nickname
|
|
isEditingProfile = true
|
|
notifyStateChange()
|
|
}
|
|
|
|
/// 更新头像 URL。
|
|
func updateAvatarURL(_ url: String) {
|
|
avatarURL = url
|
|
appStore.session.avatar = url
|
|
notifyStateChange()
|
|
}
|
|
|
|
/// 展开或收起景区认证标签。
|
|
func toggleCertificationExpanded() {
|
|
isCertificationExpanded.toggle()
|
|
notifyStateChange()
|
|
}
|
|
|
|
/// 上传头像并重新拉取个人空间配置。
|
|
func uploadAvatar(
|
|
data: Data,
|
|
fileName: String,
|
|
api: any ProfileSpaceSettingsAPI
|
|
) async {
|
|
setLoading(true)
|
|
defer { setLoading(false) }
|
|
do {
|
|
try await api.uploadAvatar(data: data, fileName: fileName)
|
|
onShowMessage?("上传成功")
|
|
await load(api: api)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// 更新编辑昵称。
|
|
func updateEditingNickname(_ value: String) {
|
|
editingNickname = value
|
|
notifyStateChange()
|
|
}
|
|
|
|
/// 更新简介。
|
|
func updateIntroduction(_ value: String) {
|
|
introduction = value
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 添加个人标签。
|
|
func addAttrLabel(_ value: String) {
|
|
do {
|
|
attrLabels = try adding(label: value, to: attrLabels)
|
|
updateHasChanges()
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// 删除个人标签。
|
|
func removeAttrLabel(_ label: String) {
|
|
attrLabels.removeAll { $0 == label }
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 添加拍摄说明标签。
|
|
func addShootLabel(_ value: String) {
|
|
do {
|
|
shootLabels = try adding(label: value, to: shootLabels)
|
|
updateHasChanges()
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// 删除拍摄说明标签。
|
|
func removeShootLabel(_ label: String) {
|
|
shootLabels.removeAll { $0 == label }
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 更新工作日开始时间。
|
|
func updateBusinessStartTime(_ value: String) {
|
|
if let end = businessEndTime, !Self.isStart(value, before: end) {
|
|
onShowMessage?("开始时间不能大于结束时间")
|
|
return
|
|
}
|
|
businessStartTime = value
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 更新工作日结束时间。
|
|
func updateBusinessEndTime(_ value: String) {
|
|
if let start = businessStartTime, !Self.isStart(start, before: value) {
|
|
onShowMessage?("结束时间不能小于开始时间")
|
|
return
|
|
}
|
|
businessEndTime = value
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 更新节假日开始时间。
|
|
func updateHolidayStartTime(_ value: String) {
|
|
if let end = holidayEndTime, !Self.isStart(value, before: end) {
|
|
onShowMessage?("开始时间不能大于结束时间")
|
|
return
|
|
}
|
|
holidayStartTime = value
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 更新节假日结束时间。
|
|
func updateHolidayEndTime(_ value: String) {
|
|
if let start = holidayStartTime, !Self.isStart(start, before: value) {
|
|
onShowMessage?("结束时间不能小于开始时间")
|
|
return
|
|
}
|
|
holidayEndTime = value
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 添加相机设备。
|
|
func addDevice(_ value: String) {
|
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else {
|
|
onShowMessage?(ProfileSpaceSettingsValidationError.emptyDevice.localizedDescription)
|
|
return
|
|
}
|
|
cameraDevices.append(text)
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 删除相机设备。
|
|
func removeDevice(_ value: String) {
|
|
cameraDevices.removeAll { $0 == value }
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 更新接单状态。
|
|
func updateAcceptOrderStatus(_ status: Int) {
|
|
acceptOrderStatus = status
|
|
updateHasChanges()
|
|
}
|
|
|
|
/// 选择日程日期。
|
|
func selectDate(_ date: Date) {
|
|
selectedDate = calendar.startOfDay(for: date)
|
|
notifyStateChange()
|
|
}
|
|
|
|
/// 保存个人空间配置。
|
|
func save(api: any ProfileSpaceSettingsAPI) async {
|
|
do {
|
|
let request = try makeSaveRequest()
|
|
setLoading(true)
|
|
defer { setLoading(false) }
|
|
try await api.updateProfileInfo(request)
|
|
originalSnapshot = currentSnapshot()
|
|
hasChanges = false
|
|
onShowMessage?("保存成功")
|
|
notifyStateChange()
|
|
await load(api: api)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// 构建个人空间保存请求。
|
|
func makeSaveRequest() throws -> UpdatePhotographerProfileInfoRequest {
|
|
guard let businessStartTime else { throw ProfileSpaceSettingsValidationError.emptyTime("工作日开始时间") }
|
|
guard let businessEndTime else { throw ProfileSpaceSettingsValidationError.emptyTime("工作日结束时间") }
|
|
guard let holidayStartTime else { throw ProfileSpaceSettingsValidationError.emptyTime("节假日开始时间") }
|
|
guard let holidayEndTime else { throw ProfileSpaceSettingsValidationError.emptyTime("节假日结束时间") }
|
|
guard Self.isStart(businessStartTime, before: businessEndTime) else {
|
|
throw ProfileSpaceSettingsValidationError.invalidTimeRange("工作日")
|
|
}
|
|
guard Self.isStart(holidayStartTime, before: holidayEndTime) else {
|
|
throw ProfileSpaceSettingsValidationError.invalidTimeRange("节假日")
|
|
}
|
|
return UpdatePhotographerProfileInfoRequest(
|
|
scenicId: String(scenicId),
|
|
description: introduction,
|
|
cameraDevice: cameraDevices,
|
|
attrLabel: attrLabels,
|
|
shootLabel: shootLabels,
|
|
businessStartTime: Self.fullTimeString(businessStartTime),
|
|
businessEndTime: Self.fullTimeString(businessEndTime),
|
|
holidayStartTime: Self.fullTimeString(holidayStartTime),
|
|
holidayEndTime: Self.fullTimeString(holidayEndTime),
|
|
businessRange: [],
|
|
acceptOrderStatus: acceptOrderStatus
|
|
)
|
|
}
|
|
|
|
/// 构建新增日程请求。
|
|
func makeAddScheduleRequest(
|
|
name: String,
|
|
remark: String,
|
|
startTime: String?,
|
|
endTime: String?,
|
|
scheduleDate: Date,
|
|
orderNumber: String?
|
|
) throws -> AddPhotographerScheduleRequest {
|
|
let scheduleName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !scheduleName.isEmpty else { throw ProfileSpaceSettingsValidationError.emptyScheduleName }
|
|
guard let startTime, let endTime else { throw ProfileSpaceSettingsValidationError.emptyScheduleTime }
|
|
guard Self.isStart(startTime, before: endTime) else { throw ProfileSpaceSettingsValidationError.invalidScheduleTime }
|
|
let order = orderNumber?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return AddPhotographerScheduleRequest(
|
|
scenicId: String(scenicId),
|
|
name: scheduleName,
|
|
remark: remark,
|
|
startTime: startTime,
|
|
endTime: endTime,
|
|
scheduleDate: Self.dateKey(from: scheduleDate, calendar: calendar),
|
|
orderNumber: order?.isEmpty == true ? nil : order
|
|
)
|
|
}
|
|
|
|
/// 添加日程并刷新个人空间配置。
|
|
func addSchedule(
|
|
name: String,
|
|
remark: String,
|
|
startTime: String?,
|
|
endTime: String?,
|
|
scheduleDate: Date,
|
|
orderNumber: String?,
|
|
api: any ProfileSpaceSettingsAPI
|
|
) async -> Bool {
|
|
do {
|
|
let request = try makeAddScheduleRequest(
|
|
name: name,
|
|
remark: remark,
|
|
startTime: startTime,
|
|
endTime: endTime,
|
|
scheduleDate: scheduleDate,
|
|
orderNumber: orderNumber
|
|
)
|
|
setLoading(true)
|
|
defer { setLoading(false) }
|
|
try await api.addSchedule(request)
|
|
onShowMessage?("添加成功")
|
|
await load(api: api)
|
|
return true
|
|
} catch is CancellationError {
|
|
return false
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// 删除日程并刷新个人空间配置。
|
|
func deleteSchedule(id: Int, api: any ProfileSpaceSettingsAPI) async {
|
|
setLoading(true)
|
|
defer { setLoading(false) }
|
|
do {
|
|
try await api.deleteSchedule(id: id)
|
|
onShowMessage?("删除成功")
|
|
await load(api: api)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func apply(_ response: PhotographerProfileInfoResponse) {
|
|
realName = response.realName
|
|
nickname = response.nickname
|
|
editingNickname = response.nickname
|
|
if !response.avatar.isEmpty {
|
|
avatarURL = response.avatar
|
|
appStore.session.avatar = response.avatar
|
|
}
|
|
scenicCertification = response.scenicCertification
|
|
introduction = response.description
|
|
attrLabels = response.attrLabel
|
|
shootLabels = response.shootLabel
|
|
cameraDevices = response.cameraDevice
|
|
businessStartTime = Self.shortTimeString(response.businessStartTime)
|
|
businessEndTime = Self.shortTimeString(response.businessEndTime)
|
|
holidayStartTime = Self.shortTimeString(response.holidayStartTime)
|
|
holidayEndTime = Self.shortTimeString(response.holidayEndTime)
|
|
acceptOrderStatus = response.acceptOrderStatus
|
|
scheduleMap = response.schedule
|
|
appStore.session.userName = response.nickname
|
|
appStore.session.realName = response.realName
|
|
originalSnapshot = currentSnapshot()
|
|
hasChanges = false
|
|
notifyStateChange()
|
|
}
|
|
|
|
private func adding(label rawValue: String, to labels: [String]) throws -> [String] {
|
|
let label = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !label.isEmpty else { throw ProfileSpaceSettingsValidationError.emptyLabel }
|
|
guard label.count <= 20 else { throw ProfileSpaceSettingsValidationError.labelTooLong }
|
|
guard labels.count < 8 else { throw ProfileSpaceSettingsValidationError.labelLimitReached }
|
|
guard !labels.contains(label) else { throw ProfileSpaceSettingsValidationError.duplicateLabel }
|
|
return labels + [label]
|
|
}
|
|
|
|
private func setLoading(_ loading: Bool) {
|
|
isLoading = loading
|
|
notifyStateChange()
|
|
}
|
|
|
|
private func updateHasChanges() {
|
|
hasChanges = currentSnapshot() != originalSnapshot
|
|
notifyStateChange()
|
|
}
|
|
|
|
private func notifyStateChange() {
|
|
onStateChange?()
|
|
}
|
|
|
|
private func currentSnapshot() -> Snapshot {
|
|
Snapshot(
|
|
introduction: introduction,
|
|
attrLabels: attrLabels,
|
|
shootLabels: shootLabels,
|
|
businessStartTime: businessStartTime,
|
|
businessEndTime: businessEndTime,
|
|
holidayStartTime: holidayStartTime,
|
|
holidayEndTime: holidayEndTime,
|
|
cameraDevices: cameraDevices,
|
|
acceptOrderStatus: acceptOrderStatus
|
|
)
|
|
}
|
|
|
|
private static func shortTimeString(_ value: String) -> String? {
|
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty, text != "00:00:00" else { return nil }
|
|
if text.count >= 5 {
|
|
return String(text.prefix(5))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func fullTimeString(_ value: String) -> String {
|
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return text.count == 5 ? "\(text):00" : text
|
|
}
|
|
|
|
static func isStart(_ start: String, before end: String) -> Bool {
|
|
minutes(from: start) < minutes(from: end)
|
|
}
|
|
|
|
static func minutes(from value: String) -> Int {
|
|
let pieces = value.split(separator: ":").compactMap { Int($0) }
|
|
guard pieces.count >= 2 else { return 0 }
|
|
return pieces[0] * 60 + pieces[1]
|
|
}
|
|
|
|
static func dateKey(from date: Date, calendar: Calendar) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = calendar
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = calendar.timeZone
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
/// 将日期展示为 Android 风格的 yyyy年M月d日。
|
|
static func displayDate(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
|
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
|
return "\(components.year ?? 0)年\(components.month ?? 0)月\(components.day ?? 0)日"
|
|
}
|
|
|
|
/// 将日期展示为日数字。
|
|
static func displayDay(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
|
|
String(calendar.component(.day, from: date))
|
|
}
|
|
|
|
/// 将日期展示为中文星期。
|
|
static func displayWeekday(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
|
|
switch calendar.component(.weekday, from: date) {
|
|
case 1: return "日"
|
|
case 2: return "一"
|
|
case 3: return "二"
|
|
case 4: return "三"
|
|
case 5: return "四"
|
|
case 6: return "五"
|
|
case 7: return "六"
|
|
default: return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct Snapshot: Equatable {
|
|
var introduction = ""
|
|
var attrLabels: [String] = []
|
|
var shootLabels: [String] = []
|
|
var businessStartTime: String?
|
|
var businessEndTime: String?
|
|
var holidayStartTime: String?
|
|
var holidayEndTime: String?
|
|
var cameraDevices: [String] = []
|
|
var acceptOrderStatus = 0
|
|
}
|