Add TravelAlbum, ProfileSpace, and wired camera transfer modules.
Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
254
suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift
Normal file
254
suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift
Normal file
@ -0,0 +1,254 @@
|
||||
//
|
||||
// ProfileSpaceModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 摄影师个人空间配置响应,表示当前景区下的公开展示资料和接单配置。
|
||||
struct ProfileSpaceInfoResponse: Decodable, Equatable {
|
||||
let id: Int
|
||||
let scenicId: Int
|
||||
let photogUid: Int
|
||||
let realName: String
|
||||
let nickname: String
|
||||
let avatar: String
|
||||
let scenicCertification: [String]
|
||||
let description: String
|
||||
let attrLabel: [String]
|
||||
let shootLabel: [String]
|
||||
let cameraDevice: [String]
|
||||
let businessStartTime: String
|
||||
let businessEndTime: String
|
||||
let holidayStartTime: String
|
||||
let holidayEndTime: String
|
||||
let businessRange: [Int]
|
||||
let acceptOrderStatus: Int
|
||||
let schedule: [String: [ScheduleItem]]
|
||||
|
||||
/// 响应 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicId = "scenic_id"
|
||||
case photogUid = "photog_uid"
|
||||
case realName = "real_name"
|
||||
case nickname
|
||||
case avatar
|
||||
case scenicCertification = "scenic_certification"
|
||||
case description
|
||||
case attrLabel = "attr_label"
|
||||
case shootLabel = "shoot_label"
|
||||
case cameraDevice = "camera_device"
|
||||
case businessStartTime = "business_start_time"
|
||||
case businessEndTime = "business_end_time"
|
||||
case holidayStartTime = "holiday_start_time"
|
||||
case holidayEndTime = "holiday_end_time"
|
||||
case businessRange = "business_range"
|
||||
case acceptOrderStatus = "accept_order_status"
|
||||
case schedule
|
||||
}
|
||||
|
||||
/// 宽松解码后端资料字段,兼容 null、数字字符串和缺失数组。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeProfileSpaceLossyInt(forKey: .id) ?? 0
|
||||
scenicId = try container.decodeProfileSpaceLossyInt(forKey: .scenicId) ?? 0
|
||||
photogUid = try container.decodeProfileSpaceLossyInt(forKey: .photogUid) ?? 0
|
||||
realName = try container.decodeProfileSpaceLossyString(forKey: .realName)
|
||||
nickname = try container.decodeProfileSpaceLossyString(forKey: .nickname)
|
||||
avatar = try container.decodeProfileSpaceLossyString(forKey: .avatar)
|
||||
scenicCertification = try container.decodeProfileSpaceStringArray(forKey: .scenicCertification)
|
||||
description = try container.decodeProfileSpaceLossyString(forKey: .description)
|
||||
attrLabel = try container.decodeProfileSpaceStringArray(forKey: .attrLabel)
|
||||
shootLabel = try container.decodeProfileSpaceStringArray(forKey: .shootLabel)
|
||||
cameraDevice = try container.decodeProfileSpaceStringArray(forKey: .cameraDevice)
|
||||
businessStartTime = try container.decodeProfileSpaceLossyString(forKey: .businessStartTime)
|
||||
businessEndTime = try container.decodeProfileSpaceLossyString(forKey: .businessEndTime)
|
||||
holidayStartTime = try container.decodeProfileSpaceLossyString(forKey: .holidayStartTime)
|
||||
holidayEndTime = try container.decodeProfileSpaceLossyString(forKey: .holidayEndTime)
|
||||
businessRange = try container.decodeProfileSpaceIntArray(forKey: .businessRange)
|
||||
acceptOrderStatus = try container.decodeProfileSpaceLossyInt(forKey: .acceptOrderStatus) ?? 0
|
||||
schedule = (try? container.decodeIfPresent([String: [ScheduleItem]].self, forKey: .schedule)) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄影师个人空间配置更新请求。
|
||||
struct UpdateProfileSpaceRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let description: String
|
||||
let cameraDevice: [String]
|
||||
let attrLabel: [String]
|
||||
let shootLabel: [String]
|
||||
let businessStartTime: String
|
||||
let businessEndTime: String
|
||||
let holidayStartTime: String
|
||||
let holidayEndTime: String
|
||||
let businessRange: [Int]
|
||||
let acceptOrderStatus: Int
|
||||
|
||||
/// 请求 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case description
|
||||
case cameraDevice = "camera_device"
|
||||
case attrLabel = "attr_label"
|
||||
case shootLabel = "shoot_label"
|
||||
case businessStartTime = "business_start_time"
|
||||
case businessEndTime = "business_end_time"
|
||||
case holidayStartTime = "holiday_start_time"
|
||||
case holidayEndTime = "holiday_end_time"
|
||||
case businessRange = "business_range"
|
||||
case acceptOrderStatus = "accept_order_status"
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人空间接单状态枚举,承接后端 1-4 状态值。
|
||||
enum ProfileSpaceOrderStatus: Int, CaseIterable, Identifiable {
|
||||
case accepting = 1
|
||||
case paused = 2
|
||||
case full = 3
|
||||
case offline = 4
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 状态展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .accepting: "可接单"
|
||||
case .paused: "暂停接单"
|
||||
case .full: "已约满"
|
||||
case .offline: "下线"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人空间配置草稿,用于比较是否有未保存改动。
|
||||
struct ProfileSpaceEditableSnapshot: Equatable {
|
||||
var nickname: String
|
||||
var description: String
|
||||
var attrLabel: [String]
|
||||
var shootLabel: [String]
|
||||
var cameraDevice: [String]
|
||||
var businessStartTime: Date?
|
||||
var businessEndTime: Date?
|
||||
var holidayStartTime: Date?
|
||||
var holidayEndTime: Date?
|
||||
var acceptOrderStatus: Int
|
||||
var avatarURL: String
|
||||
}
|
||||
|
||||
/// 个人空间配置表单校验错误。
|
||||
enum ProfileSpaceValidationError: LocalizedError, Equatable {
|
||||
case missingScenic
|
||||
case emptyNickname
|
||||
case emptyLabel
|
||||
case duplicateLabel
|
||||
case labelTooLong
|
||||
case labelLimit
|
||||
case missingWorkdayStart
|
||||
case missingWorkdayEnd
|
||||
case missingHolidayStart
|
||||
case missingHolidayEnd
|
||||
case invalidWorkdayTime
|
||||
case invalidHolidayTime
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingScenic: "当前缺少景区信息"
|
||||
case .emptyNickname: "请输入昵称"
|
||||
case .emptyLabel: "标签不能为空"
|
||||
case .duplicateLabel: "标签已存在"
|
||||
case .labelTooLong: "标签最多20个字符"
|
||||
case .labelLimit: "最多8个标签"
|
||||
case .missingWorkdayStart: "请输入工作日开始时间"
|
||||
case .missingWorkdayEnd: "请输入工作日结束时间"
|
||||
case .missingHolidayStart: "请输入节假日开始时间"
|
||||
case .missingHolidayEnd: "请输入节假日结束时间"
|
||||
case .invalidWorkdayTime: "工作日开始时间必须早于结束时间"
|
||||
case .invalidHolidayTime: "节假日开始时间必须早于结束时间"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
/// 将日期格式化为后端需要的 HH:mm:ss 时间文本。
|
||||
var profileSpaceTimeText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// 将日期格式化为空间配置页面展示的 HH:mm 文本。
|
||||
var profileSpaceHourMinuteText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// 根据 HH:mm:ss 或 HH:mm 文本生成仅含时间部分的 Date。
|
||||
static func profileSpaceTime(from text: String) -> Date? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, trimmed != "00:00:00" else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = trimmed.count == 5 ? "HH:mm" : "HH:mm:ss"
|
||||
return formatter.date(from: trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeProfileSpaceLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return 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 ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 Int 宽松解码为整数。
|
||||
func decodeProfileSpaceLossyInt(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
|
||||
}
|
||||
|
||||
/// 宽松解码字符串数组,过滤空白项。
|
||||
func decodeProfileSpaceStringArray(forKey key: Key) throws -> [String] {
|
||||
guard let values = try? decodeIfPresent([String].self, forKey: key) else { return [] }
|
||||
return values
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// 宽松解码整数数组。
|
||||
func decodeProfileSpaceIntArray(forKey key: Key) throws -> [Int] {
|
||||
if let values = try? decodeIfPresent([Int].self, forKey: key) {
|
||||
return values
|
||||
}
|
||||
if let values = try? decodeIfPresent([String].self, forKey: key) {
|
||||
return values.compactMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
58
suixinkan/Features/ProfileSpace/ProfileSpace.md
Normal file
58
suixinkan/Features/ProfileSpace/ProfileSpace.md
Normal file
@ -0,0 +1,58 @@
|
||||
# ProfileSpace 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
ProfileSpace 模块负责首页“空间设置”入口,对齐 Android `space_settings -> ProfileInfoRoute` 的个人空间配置能力。
|
||||
|
||||
该模块聚焦摄影师在当前景区下的公开展示和接单配置:
|
||||
- 拉取个人空间资料。
|
||||
- 修改头像、昵称和简介。
|
||||
- 管理个人标签、拍摄说明标签和相机设备。
|
||||
- 配置工作日与法定节假日营业时间。
|
||||
- 配置接单状态。
|
||||
- 展示未来 7 天日程,并支持添加、删除和拨打客户电话。
|
||||
|
||||
相册管理不属于本模块,仍由 Assets/相册模块承接。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `ProfileSpaceView`:空间配置页 UI,负责资料展示、表单输入、日程展示和保存入口。
|
||||
- `ProfileSpaceViewModel`:维护编辑草稿、原始快照、脏状态、校验和提交逻辑。
|
||||
- `ProfileSpaceInfoResponse`:空间配置详情响应。
|
||||
- `UpdateProfileSpaceRequest`:空间配置更新请求。
|
||||
- `ProfileAPI`:封装空间配置读取和更新接口,同时复用用户昵称、头像更新接口。
|
||||
- `ScheduleAPI`:复用已有排班新增和删除接口。
|
||||
- `OSSUploadService`:复用头像上传能力。
|
||||
|
||||
## 加载流程
|
||||
|
||||
1. 首页点击 `space_settings` 后进入 `ProfileSpaceView`。
|
||||
2. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。
|
||||
3. `ProfileSpaceViewModel.load` 调用 `ProfileAPI.profileSpaceInfo`。
|
||||
4. 接口成功后回填头像、姓名、昵称、简介、标签、设备、营业时间、接单状态和日程 map。
|
||||
5. ViewModel 保存一份 `ProfileSpaceEditableSnapshot`,用于判断保存按钮是否可用。
|
||||
|
||||
缺少当前景区时,页面展示缺少景区空态,不发起空间配置接口。
|
||||
|
||||
## 保存流程
|
||||
|
||||
1. 用户修改表单后,ViewModel 根据当前草稿和原始快照判断 `hasChanges`。
|
||||
2. 点击保存时先校验:
|
||||
- 昵称不能为空。
|
||||
- 标签和拍摄说明均最多 8 个,单个最多 20 字,禁止空值和重复。
|
||||
- 四个营业时间必须填写。
|
||||
- 工作日、节假日开始时间必须早于结束时间。
|
||||
3. 如头像有变化,先调用 `OSSUploadService.uploadUserAvatar` 上传,再调用 `ProfileAPI.updateUserAvatarURL` 回写头像。
|
||||
4. 如昵称有变化,调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
||||
5. 调用 `ProfileAPI.updateProfileSpaceInfo` 保存空间配置。
|
||||
6. 保存成功后刷新原始快照,清除头像暂存,并同步 `AccountContext` 和账号快照中的昵称/头像展示信息。
|
||||
|
||||
保存失败时保留当前草稿和未保存状态,页面展示错误信息。
|
||||
|
||||
## 日程流程
|
||||
|
||||
空间配置页展示从空间详情接口返回的 `schedule` map,并在页面内显示从今天起连续 7 天的日程。
|
||||
|
||||
添加日程复用现有 `ScheduleAddView`,提交逻辑仍由 Schedule 模块负责。页面返回后会重新加载空间配置。
|
||||
|
||||
删除日程调用 `ScheduleAPI.deleteSchedule`,成功后重新加载空间配置,保持日程来源一致。
|
||||
@ -0,0 +1,302 @@
|
||||
//
|
||||
// ProfileSpaceViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 个人空间配置 ViewModel,负责加载资料、维护编辑草稿、校验并提交空间配置。
|
||||
final class ProfileSpaceViewModel: ObservableObject {
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var saving = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var nickname = ""
|
||||
@Published private(set) var realName = ""
|
||||
@Published private(set) var avatarURL = ""
|
||||
@Published private(set) var scenicCertification: [String] = []
|
||||
@Published var introduction = ""
|
||||
@Published var attrLabels: [String] = []
|
||||
@Published var shootLabels: [String] = []
|
||||
@Published var cameraDevices: [String] = []
|
||||
@Published var businessStartTime: Date?
|
||||
@Published var businessEndTime: Date?
|
||||
@Published var holidayStartTime: Date?
|
||||
@Published var holidayEndTime: Date?
|
||||
@Published var acceptOrderStatus = 0
|
||||
@Published private(set) var scheduleMap: [String: [ScheduleItem]] = [:]
|
||||
@Published private(set) var pendingAvatarData: Data?
|
||||
@Published private(set) var pendingAvatarFileName: String?
|
||||
@Published private(set) var avatarUploadProgress: Int?
|
||||
|
||||
private var originalSnapshot: ProfileSpaceEditableSnapshot?
|
||||
|
||||
/// 当前页面是否存在未保存改动。
|
||||
var hasChanges: Bool {
|
||||
guard let originalSnapshot else { return pendingAvatarData != nil }
|
||||
return editableSnapshot() != originalSnapshot || pendingAvatarData != nil
|
||||
}
|
||||
|
||||
/// 使用当前景区加载个人空间配置。
|
||||
func load(api: any ProfileSpaceServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
resetForMissingScenic()
|
||||
return
|
||||
}
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
let response = try await api.profileSpaceInfo(scenicId: scenicId)
|
||||
apply(response)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理用户选择的头像图片,成功后暂存在内存中等待保存。
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
}
|
||||
|
||||
/// 添加个人标签。
|
||||
func addAttrLabel(_ label: String) throws {
|
||||
attrLabels = try adding(label, to: attrLabels)
|
||||
}
|
||||
|
||||
/// 添加拍摄说明标签。
|
||||
func addShootLabel(_ label: String) throws {
|
||||
shootLabels = try adding(label, to: shootLabels)
|
||||
}
|
||||
|
||||
/// 删除个人标签。
|
||||
func removeAttrLabel(_ label: String) {
|
||||
attrLabels.removeAll { $0 == label }
|
||||
}
|
||||
|
||||
/// 删除拍摄说明标签。
|
||||
func removeShootLabel(_ label: String) {
|
||||
shootLabels.removeAll { $0 == label }
|
||||
}
|
||||
|
||||
/// 添加相机设备名称。
|
||||
func addCameraDevice(_ device: String) {
|
||||
let value = device.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return }
|
||||
cameraDevices.append(value)
|
||||
}
|
||||
|
||||
/// 删除相机设备名称。
|
||||
func removeCameraDevice(_ device: String) {
|
||||
cameraDevices.removeAll { $0 == device }
|
||||
}
|
||||
|
||||
/// 保存个人空间配置、昵称和待上传头像。
|
||||
func save(api: any ProfileSpaceServing, uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||||
guard let scenicId else {
|
||||
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
|
||||
throw ProfileSpaceValidationError.missingScenic
|
||||
}
|
||||
do {
|
||||
let request = try makeUpdateRequest(scenicId: scenicId)
|
||||
guard !saving else { return }
|
||||
saving = true
|
||||
errorMessage = nil
|
||||
avatarUploadProgress = pendingAvatarData == nil ? nil : 1
|
||||
defer {
|
||||
saving = false
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
|
||||
let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nextNickname != originalSnapshot?.nickname {
|
||||
try await api.updateUserInfo(nickname: nextNickname, password: nil, avatar: nil)
|
||||
}
|
||||
|
||||
try await api.updateProfileSpaceInfo(request)
|
||||
if let uploadedAvatarURL {
|
||||
avatarURL = uploadedAvatarURL
|
||||
}
|
||||
nickname = nextNickname
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
originalSnapshot = editableSnapshot()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除日程并重新加载个人空间配置。
|
||||
func deleteSchedule(
|
||||
item: ScheduleItem,
|
||||
profileAPI: any ProfileSpaceServing,
|
||||
scheduleAPI: any ScheduleServing,
|
||||
scenicId: Int?
|
||||
) async {
|
||||
guard let scenicId else {
|
||||
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await scheduleAPI.deleteSchedule(id: item.id)
|
||||
await load(api: profileAPI, scenicId: scenicId)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成全局账号资料兜底,用于保存昵称和头像后同步 AccountContext。
|
||||
func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? {
|
||||
AccountProfile(
|
||||
userId: fallback?.userId ?? "",
|
||||
displayName: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? (fallback?.displayName ?? "") : nickname,
|
||||
phone: fallback?.phone,
|
||||
avatarURL: avatarURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fallback?.avatarURL : avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 构造空间配置更新请求并执行表单校验。
|
||||
func makeUpdateRequest(scenicId: Int) throws -> UpdateProfileSpaceRequest {
|
||||
let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileSpaceValidationError.emptyNickname
|
||||
}
|
||||
guard let businessStartTime else {
|
||||
throw ProfileSpaceValidationError.missingWorkdayStart
|
||||
}
|
||||
guard let businessEndTime else {
|
||||
throw ProfileSpaceValidationError.missingWorkdayEnd
|
||||
}
|
||||
guard let holidayStartTime else {
|
||||
throw ProfileSpaceValidationError.missingHolidayStart
|
||||
}
|
||||
guard let holidayEndTime else {
|
||||
throw ProfileSpaceValidationError.missingHolidayEnd
|
||||
}
|
||||
guard seconds(in: businessStartTime) < seconds(in: businessEndTime) else {
|
||||
throw ProfileSpaceValidationError.invalidWorkdayTime
|
||||
}
|
||||
guard seconds(in: holidayStartTime) < seconds(in: holidayEndTime) else {
|
||||
throw ProfileSpaceValidationError.invalidHolidayTime
|
||||
}
|
||||
|
||||
return UpdateProfileSpaceRequest(
|
||||
scenicId: "\(scenicId)",
|
||||
description: introduction.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
cameraDevice: normalized(cameraDevices),
|
||||
attrLabel: normalized(attrLabels),
|
||||
shootLabel: normalized(shootLabels),
|
||||
businessStartTime: businessStartTime.profileSpaceTimeText,
|
||||
businessEndTime: businessEndTime.profileSpaceTimeText,
|
||||
holidayStartTime: holidayStartTime.profileSpaceTimeText,
|
||||
holidayEndTime: holidayEndTime.profileSpaceTimeText,
|
||||
businessRange: [],
|
||||
acceptOrderStatus: acceptOrderStatus
|
||||
)
|
||||
}
|
||||
|
||||
/// 将后端响应写入页面状态。
|
||||
private func apply(_ response: ProfileSpaceInfoResponse) {
|
||||
realName = response.realName
|
||||
nickname = response.nickname
|
||||
avatarURL = response.avatar
|
||||
scenicCertification = response.scenicCertification
|
||||
introduction = response.description
|
||||
attrLabels = response.attrLabel
|
||||
shootLabels = response.shootLabel
|
||||
cameraDevices = response.cameraDevice
|
||||
businessStartTime = Date.profileSpaceTime(from: response.businessStartTime)
|
||||
businessEndTime = Date.profileSpaceTime(from: response.businessEndTime)
|
||||
holidayStartTime = Date.profileSpaceTime(from: response.holidayStartTime)
|
||||
holidayEndTime = Date.profileSpaceTime(from: response.holidayEndTime)
|
||||
acceptOrderStatus = response.acceptOrderStatus
|
||||
scheduleMap = response.schedule
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
originalSnapshot = editableSnapshot()
|
||||
}
|
||||
|
||||
/// 缺少景区时清空页面业务状态。
|
||||
private func resetForMissingScenic() {
|
||||
loading = false
|
||||
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
|
||||
realName = ""
|
||||
nickname = ""
|
||||
avatarURL = ""
|
||||
scenicCertification = []
|
||||
introduction = ""
|
||||
attrLabels = []
|
||||
shootLabels = []
|
||||
cameraDevices = []
|
||||
businessStartTime = nil
|
||||
businessEndTime = nil
|
||||
holidayStartTime = nil
|
||||
holidayEndTime = nil
|
||||
acceptOrderStatus = 0
|
||||
scheduleMap = [:]
|
||||
originalSnapshot = nil
|
||||
}
|
||||
|
||||
/// 返回当前编辑态快照。
|
||||
private func editableSnapshot() -> ProfileSpaceEditableSnapshot {
|
||||
ProfileSpaceEditableSnapshot(
|
||||
nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
description: introduction.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
attrLabel: normalized(attrLabels),
|
||||
shootLabel: normalized(shootLabels),
|
||||
cameraDevice: normalized(cameraDevices),
|
||||
businessStartTime: businessStartTime,
|
||||
businessEndTime: businessEndTime,
|
||||
holidayStartTime: holidayStartTime,
|
||||
holidayEndTime: holidayEndTime,
|
||||
acceptOrderStatus: acceptOrderStatus,
|
||||
avatarURL: avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加标签并执行数量、长度和重复校验。
|
||||
private func adding(_ label: String, to labels: [String]) throws -> [String] {
|
||||
let value = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { throw ProfileSpaceValidationError.emptyLabel }
|
||||
guard value.count <= 20 else { throw ProfileSpaceValidationError.labelTooLong }
|
||||
guard labels.count < 8 else { throw ProfileSpaceValidationError.labelLimit }
|
||||
guard !labels.contains(value) else { throw ProfileSpaceValidationError.duplicateLabel }
|
||||
return labels + [value]
|
||||
}
|
||||
|
||||
/// 清洗字符串数组,移除空白项。
|
||||
private func normalized(_ values: [String]) -> [String] {
|
||||
values
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// 返回日期的当天秒数,用于只比较时间部分。
|
||||
private func seconds(in date: Date) -> Int {
|
||||
let components = Calendar.current.dateComponents([.hour, .minute, .second], from: date)
|
||||
return (components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60 + (components.second ?? 0)
|
||||
}
|
||||
|
||||
/// 上传待保存头像;没有待上传图片时返回 nil。
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.avatarUploadProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
733
suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift
Normal file
733
suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift
Normal file
@ -0,0 +1,733 @@
|
||||
//
|
||||
// ProfileSpaceView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 个人空间配置页面,承接首页“空间设置”入口。
|
||||
struct ProfileSpaceView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.scheduleAPI) private var scheduleAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@StateObject private var viewModel = ProfileSpaceViewModel()
|
||||
@State private var pickedAvatarItem: PhotosPickerItem?
|
||||
@State private var attrInput = ""
|
||||
@State private var shootInput = ""
|
||||
@State private var deviceInput = ""
|
||||
@State private var selectedDate = Date()
|
||||
@State private var deleteTarget: ProfileSpaceDeleteTarget?
|
||||
@State private var appearedOnce = false
|
||||
|
||||
private var scenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
}
|
||||
|
||||
private var selectedDateKey: String {
|
||||
selectedDate.scheduleDayText
|
||||
}
|
||||
|
||||
private var selectedSchedules: [ScheduleItem] {
|
||||
viewModel.scheduleMap[selectedDateKey] ?? []
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if scenicId == nil {
|
||||
AppContentUnavailableView("缺少景区", systemImage: "person.crop.circle.badge.exclamationmark", description: Text("请选择景区后再配置个人空间。"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
profileCard
|
||||
labelCard
|
||||
businessTimeCard
|
||||
deviceCard
|
||||
orderStatusCard
|
||||
scheduleCard
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.padding(.bottom, 78)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
saveBar
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("个人空间配置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: scenicId) {
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
.onAppear {
|
||||
guard appearedOnce else {
|
||||
appearedOnce = true
|
||||
return
|
||||
}
|
||||
Task { await reload(showLoading: false) }
|
||||
}
|
||||
.onChange(of: pickedAvatarItem) { item in
|
||||
Task { await prepareAvatarImage(from: item) }
|
||||
}
|
||||
.alert(item: $deleteTarget) { target in
|
||||
Alert(
|
||||
title: Text(target.title),
|
||||
message: Text(target.message),
|
||||
primaryButton: .destructive(Text("删除")) {
|
||||
delete(target)
|
||||
},
|
||||
secondaryButton: .cancel(Text("取消"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var profileCard: some View {
|
||||
sectionCard {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
|
||||
avatarPicker
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("姓名: \(viewModel.realName.isEmpty ? "--" : viewModel.realName)")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
TextField("请输入昵称", text: $viewModel.nickname)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: 42)
|
||||
TextField("请输入简介", text: $viewModel.introduction, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: 78)
|
||||
certificationTags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $pickedAvatarItem, matching: .images) {
|
||||
avatarPreview
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(Circle())
|
||||
.overlay {
|
||||
Circle().stroke(.white, lineWidth: 3)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(AppDesign.primary, in: Circle())
|
||||
.overlay { Circle().stroke(.white, lineWidth: 2) }
|
||||
}
|
||||
.overlay {
|
||||
if let progress = viewModel.avatarUploadProgress {
|
||||
ZStack {
|
||||
Circle().fill(.black.opacity(0.34))
|
||||
Text("\(progress)%")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("头像")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPreview: some View {
|
||||
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
RemoteAvatarImage(urlString: viewModel.avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var certificationTags: some View {
|
||||
if !viewModel.scenicCertification.isEmpty {
|
||||
FlowLayout(spacing: 6, lineSpacing: 6) {
|
||||
ForEach(viewModel.scenicCertification, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color(hex: 0xFFF0E2), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var labelCard: some View {
|
||||
sectionCard {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("标签")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
chipEditor(labels: viewModel.attrLabels, input: $attrInput, placeholder: "添加标签") { value in
|
||||
try viewModel.addAttrLabel(value)
|
||||
} remove: { value in
|
||||
deleteTarget = .attrLabel(value)
|
||||
}
|
||||
|
||||
Text("拍摄说明")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.padding(.top, AppMetrics.Spacing.xSmall)
|
||||
chipEditor(labels: viewModel.shootLabels, input: $shootInput, placeholder: "添加拍摄说明") { value in
|
||||
try viewModel.addShootLabel(value)
|
||||
} remove: { value in
|
||||
deleteTarget = .shootLabel(value)
|
||||
}
|
||||
|
||||
Text("每个标签最多20个字符,最多8个标签")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var businessTimeCard: some View {
|
||||
sectionCard {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
sectionTitle("营业时间")
|
||||
timeRow(title: "工作日营业时间", start: $viewModel.businessStartTime, end: $viewModel.businessEndTime)
|
||||
timeRow(title: "法定节假日营业时间", start: $viewModel.holidayStartTime, end: $viewModel.holidayEndTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var deviceCard: some View {
|
||||
sectionCard {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
sectionTitle("相机设备")
|
||||
FlowLayout(spacing: 8, lineSpacing: 8) {
|
||||
ForEach(viewModel.cameraDevices, id: \.self) { device in
|
||||
removableChip(title: device, tint: AppDesign.textSecondary) {
|
||||
deleteTarget = .device(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
TextField("请输入设备名", text: $deviceInput)
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: 42)
|
||||
Button {
|
||||
viewModel.addCameraDevice(deviceInput)
|
||||
deviceInput = ""
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.frame(width: 44, height: 42)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(deviceInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var orderStatusCard: some View {
|
||||
sectionCard {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
sectionTitle("接单状态")
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: AppMetrics.Spacing.small), count: 2), spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(ProfileSpaceOrderStatus.allCases) { status in
|
||||
Button {
|
||||
viewModel.acceptOrderStatus = status.rawValue
|
||||
} label: {
|
||||
Text(status.title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.foregroundStyle(viewModel.acceptOrderStatus == status.rawValue ? .white : AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(
|
||||
viewModel.acceptOrderStatus == status.rawValue ? AppDesign.primary : Color.white,
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(viewModel.acceptOrderStatus == status.rawValue ? AppDesign.primary : Color(hex: 0xD7DEE8), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scheduleCard: some View {
|
||||
sectionCard {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
HStack {
|
||||
sectionTitle("日程安排")
|
||||
Spacer()
|
||||
NavigationLink(value: AppRoute.home(.scheduleAdd)) {
|
||||
Label("添加日程", systemImage: "plus")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
}
|
||||
}
|
||||
weekSelector
|
||||
HStack {
|
||||
Text(selectedDate.scheduleDayText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
}
|
||||
if selectedSchedules.isEmpty {
|
||||
AppContentUnavailableView("该日无日程", systemImage: "calendar")
|
||||
.frame(minHeight: 120)
|
||||
} else {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(selectedSchedules) { item in
|
||||
scheduleItemCard(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var weekSelector: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(nextSevenDays, id: \.scheduleDayText) { date in
|
||||
Button {
|
||||
selectedDate = date
|
||||
} label: {
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(weekdayText(for: date))
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
Text(dayText(for: date))
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.frame(width: 34, height: 34)
|
||||
.background(isSelected(date) ? AppDesign.primary : Color(hex: 0xF3F5F8), in: RoundedRectangle(cornerRadius: 8))
|
||||
.foregroundStyle(isSelected(date) ? .white : AppDesign.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(Color(hex: 0xE5484D))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.top, AppMetrics.Spacing.small)
|
||||
}
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
HStack {
|
||||
if viewModel.saving {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
Text(viewModel.saving ? "保存中..." : "保存")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.background(viewModel.hasChanges && !viewModel.saving ? AppDesign.primary : Color(hex: 0xB6BECA), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.hasChanges || viewModel.saving)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(.white)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(Color(hex: 0xE5E7EB))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造通用白色分组卡片。
|
||||
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content()
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
/// 构造分组标题。
|
||||
private func sectionTitle(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
|
||||
/// 构造标签编辑器。
|
||||
private func chipEditor(
|
||||
labels: [String],
|
||||
input: Binding<String>,
|
||||
placeholder: String,
|
||||
add: @escaping (String) throws -> Void,
|
||||
remove: @escaping (String) -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
FlowLayout(spacing: 8, lineSpacing: 8) {
|
||||
ForEach(labels, id: \.self) { label in
|
||||
removableChip(title: label, tint: AppDesign.primary) {
|
||||
remove(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
TextField(placeholder, text: input)
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: 42)
|
||||
Button {
|
||||
do {
|
||||
try add(input.wrappedValue)
|
||||
input.wrappedValue = ""
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
} label: {
|
||||
Text("添加")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.frame(width: 56, height: 42)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(input.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造可删除标签。
|
||||
private func removableChip(title: String, tint: Color, onRemove: @escaping () -> Void) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
Button(action: onRemove) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.foregroundStyle(tint)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
|
||||
/// 构造营业时间行。
|
||||
private func timeRow(title: String, start: Binding<Date?>, end: Binding<Date?>) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
optionalTimePicker("开始", selection: start)
|
||||
Text("至")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
optionalTimePicker("结束", selection: end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造可为空的时间选择器。
|
||||
private func optionalTimePicker(_ title: String, selection: Binding<Date?>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
DatePicker(
|
||||
title,
|
||||
selection: Binding(
|
||||
get: { selection.wrappedValue ?? defaultTime },
|
||||
set: { selection.wrappedValue = $0 }
|
||||
),
|
||||
displayedComponents: .hourAndMinute
|
||||
)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(selection.wrappedValue?.profileSpaceHourMinuteText ?? "未设置")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(selection.wrappedValue == nil ? Color(hex: 0xE5484D) : AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造日程卡片。
|
||||
private func scheduleItemCard(_ item: ScheduleItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Text("\(item.startTime) - \(item.endTime) \(item.name)")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
deleteTarget = .schedule(item)
|
||||
} label: {
|
||||
Text("删除")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
}
|
||||
}
|
||||
if let orderNumber = item.orderNumber, !orderNumber.isEmpty {
|
||||
scheduleText("订单号: \(orderNumber)")
|
||||
}
|
||||
if let phone = item.userPhone, !phone.isEmpty {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
scheduleText("客户手机号: \(maskedPhone(phone))")
|
||||
Button("拨打") {
|
||||
if let url = URL(string: "tel:\(phone)") {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
}
|
||||
}
|
||||
if !item.remark.isEmpty {
|
||||
scheduleText("备注信息: \(item.remark)")
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// 构造日程辅助文本。
|
||||
private func scheduleText(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
/// 重新加载页面数据。
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading && scenicId != nil) {
|
||||
await viewModel.load(api: profileAPI, scenicId: scenicId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 PhotosPicker 选中的头像图片。
|
||||
private func prepareAvatarImage(from item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
try viewModel.prepareAvatarImage(data: data)
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存当前空间配置。
|
||||
private func save() async {
|
||||
do {
|
||||
try await globalLoading.withLoading {
|
||||
try await viewModel.save(api: profileAPI, uploader: ossUploadService, scenicId: scenicId)
|
||||
}
|
||||
if let profile = viewModel.accountProfileFallback(accountContext.profile) {
|
||||
accountContext.replaceProfile(profile)
|
||||
saveSnapshotProfile(profile)
|
||||
}
|
||||
toastCenter.show("保存成功")
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行删除确认目标。
|
||||
private func delete(_ target: ProfileSpaceDeleteTarget) {
|
||||
switch target {
|
||||
case .attrLabel(let label):
|
||||
viewModel.removeAttrLabel(label)
|
||||
case .shootLabel(let label):
|
||||
viewModel.removeShootLabel(label)
|
||||
case .device(let device):
|
||||
viewModel.removeCameraDevice(device)
|
||||
case .schedule(let item):
|
||||
Task {
|
||||
await globalLoading.withLoading {
|
||||
await viewModel.deleteSchedule(item: item, profileAPI: profileAPI, scheduleAPI: scheduleAPI, scenicId: scenicId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新账号快照里的展示资料。
|
||||
private func saveSnapshotProfile(_ profile: AccountProfile) {
|
||||
let existing = snapshotStore.load()
|
||||
snapshotStore.save(
|
||||
AccountSnapshot(
|
||||
profile: profile,
|
||||
accountType: accountContext.accountType ?? existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleCode: existing?.currentRoleCode,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
currentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var defaultTime: Date {
|
||||
Calendar.current.date(from: DateComponents(hour: 9, minute: 0)) ?? Date()
|
||||
}
|
||||
|
||||
private var nextSevenDays: [Date] {
|
||||
(0..<7).compactMap { Calendar.current.date(byAdding: .day, value: $0, to: Date()) }
|
||||
}
|
||||
|
||||
private func isSelected(_ date: Date) -> Bool {
|
||||
date.scheduleDayText == selectedDate.scheduleDayText
|
||||
}
|
||||
|
||||
private func weekdayText(for date: Date) -> String {
|
||||
let symbols = ["日", "一", "二", "三", "四", "五", "六"]
|
||||
let index = Calendar.current.component(.weekday, from: date) - 1
|
||||
return symbols[max(0, min(index, symbols.count - 1))]
|
||||
}
|
||||
|
||||
private func dayText(for date: Date) -> String {
|
||||
"\(Calendar.current.component(.day, from: date))"
|
||||
}
|
||||
|
||||
private func maskedPhone(_ phone: String) -> String {
|
||||
guard phone.count >= 11 else { return phone }
|
||||
let prefix = phone.prefix(3)
|
||||
let suffix = phone.suffix(4)
|
||||
return "\(prefix)****\(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人空间页面删除确认目标。
|
||||
private enum ProfileSpaceDeleteTarget: Identifiable {
|
||||
case attrLabel(String)
|
||||
case shootLabel(String)
|
||||
case device(String)
|
||||
case schedule(ScheduleItem)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .attrLabel(let label): "attr-\(label)"
|
||||
case .shootLabel(let label): "shoot-\(label)"
|
||||
case .device(let device): "device-\(device)"
|
||||
case .schedule(let item): "schedule-\(item.id)"
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .attrLabel: "删除标签"
|
||||
case .shootLabel: "删除拍摄说明"
|
||||
case .device: "删除设备"
|
||||
case .schedule: "删除日程"
|
||||
}
|
||||
}
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .attrLabel(let label):
|
||||
"您确定要删除\(label)标签吗?"
|
||||
case .shootLabel(let label):
|
||||
"您确定要删除\(label)拍摄说明吗?"
|
||||
case .device(let device):
|
||||
"您确定要删除\(device)设备吗?"
|
||||
case .schedule:
|
||||
"您确定要删除该日程吗?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单流式布局,用于标签自动换行。
|
||||
private struct FlowLayout: Layout {
|
||||
let spacing: CGFloat
|
||||
let lineSpacing: CGFloat
|
||||
|
||||
/// 计算流式布局所需尺寸。
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let maxWidth = proposal.width ?? 0
|
||||
let rows = makeRows(maxWidth: maxWidth, subviews: subviews)
|
||||
let width = rows.map(\.width).max() ?? 0
|
||||
let height = rows.reduce(CGFloat.zero) { $0 + $1.height } + lineSpacing * CGFloat(max(0, rows.count - 1))
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
|
||||
/// 摆放子视图。
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = makeRows(maxWidth: bounds.width, subviews: subviews)
|
||||
var y = bounds.minY
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
for item in row.items {
|
||||
subviews[item.index].place(
|
||||
at: CGPoint(x: x, y: y),
|
||||
proposal: ProposedViewSize(width: item.size.width, height: item.size.height)
|
||||
)
|
||||
x += item.size.width + spacing
|
||||
}
|
||||
y += row.height + lineSpacing
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据最大宽度拆分布局行。
|
||||
private func makeRows(maxWidth: CGFloat, subviews: Subviews) -> [FlowRow] {
|
||||
guard maxWidth > 0 else {
|
||||
return subviews.enumerated().map { index, subview in
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
return FlowRow(items: [FlowItem(index: index, size: size)], width: size.width, height: size.height)
|
||||
}
|
||||
}
|
||||
|
||||
var rows: [FlowRow] = []
|
||||
var currentItems: [FlowItem] = []
|
||||
var currentWidth: CGFloat = 0
|
||||
var currentHeight: CGFloat = 0
|
||||
|
||||
for (index, subview) in subviews.enumerated() {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
let nextWidth = currentItems.isEmpty ? size.width : currentWidth + spacing + size.width
|
||||
if nextWidth > maxWidth, !currentItems.isEmpty {
|
||||
rows.append(FlowRow(items: currentItems, width: currentWidth, height: currentHeight))
|
||||
currentItems = [FlowItem(index: index, size: size)]
|
||||
currentWidth = size.width
|
||||
currentHeight = size.height
|
||||
} else {
|
||||
currentItems.append(FlowItem(index: index, size: size))
|
||||
currentWidth = nextWidth
|
||||
currentHeight = max(currentHeight, size.height)
|
||||
}
|
||||
}
|
||||
|
||||
if !currentItems.isEmpty {
|
||||
rows.append(FlowRow(items: currentItems, width: currentWidth, height: currentHeight))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private struct FlowItem {
|
||||
let index: Int
|
||||
let size: CGSize
|
||||
}
|
||||
|
||||
private struct FlowRow {
|
||||
let items: [FlowItem]
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user