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:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View 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 ""
}
/// StringDouble 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 []
}
}

View 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`,成功后重新加载空间配置,保持日程来源一致。

View File

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

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