// // ProfileSpaceSettingsViewController.swift // suixinkan // import Kingfisher import PhotosUI import SnapKit import UIKit /// 个人空间配置页面,对齐 Android `ProfileInfoScreen`。 final class ProfileSpaceSettingsViewController: BaseViewController { private let viewModel: ProfileSpaceSettingsViewModel private let profileAPI: ProfileAPI private let scrollView = UIScrollView() private let contentStack = UIStackView() private let bottomBar = UIView() private let saveButton = AppButton(title: "保存") private let avatarImageView = UIImageView() private let avatarChangeOverlay = UIView() private let avatarChangeLabel = UILabel() private let realNameLabel = UILabel() private let nicknameField = UITextField() private let editProfileButton = UIButton(type: .system) private let introductionTextView = UITextView() private let introductionPlaceholderLabel = UILabel() private let certificationWrap = ProfileTagWrapView() private let certificationExpandButton = UIButton(type: .system) private let attrWrap = ProfileTagWrapView() private let shootWrap = ProfileTagWrapView() private let attrField = UITextField() private let shootField = UITextField() private let attrAddButton = UIButton(type: .system) private let shootAddButton = UIButton(type: .system) private let businessStartButton = ProfileTimeButton() private let businessEndButton = ProfileTimeButton() private let holidayStartButton = ProfileTimeButton() private let holidayEndButton = ProfileTimeButton() private let deviceWrap = ProfileTagWrapView() private let addDeviceButton = ProfileDashedBorderButton(type: .system) private var statusButtons: [UIButton] = [] private let dateStack = UIStackView() private let selectedDateLabel = UILabel() private let scheduleStack = UIStackView() private let addScheduleButton = UIButton(type: .system) init( viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(), profileAPI: ProfileAPI? = nil ) { self.viewModel = viewModel self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "个人空间配置" navigationItem.backButtonDisplayMode = .minimal } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) ProfileNavigationStyle.apply(to: navigationController, showsDivider: false) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) ProfileNavigationStyle.restore(navigationController) } override func setupUI() { view.backgroundColor = AppColor.pageBackground scrollView.alwaysBounceVertical = true view.addSubview(scrollView) view.addSubview(bottomBar) scrollView.addSubview(contentStack) bottomBar.addSubview(saveButton) contentStack.axis = .vertical contentStack.spacing = 16 contentStack.layoutMargins = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) contentStack.isLayoutMarginsRelativeArrangement = true bottomBar.backgroundColor = .white saveButton.isEnabled = false saveButton.snp.updateConstraints { make in make.height.equalTo(48) } configureProfileCard() configureLabelCard() configureBusinessHoursCard() configureDeviceCard() configureOrderStatusCard() configureScheduleCard() } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } saveButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(15) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } contentStack.snp.makeConstraints { make in make.edges.equalToSuperview() make.width.equalTo(scrollView.snp.width) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) editProfileButton.addTarget(self, action: #selector(editProfileTapped), for: .touchUpInside) certificationExpandButton.addTarget(self, action: #selector(certificationExpandTapped), for: .touchUpInside) addDeviceButton.addTarget(self, action: #selector(addDeviceTapped), for: .touchUpInside) addScheduleButton.addTarget(self, action: #selector(addScheduleTapped), for: .touchUpInside) attrField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged) shootField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged) [businessStartButton, businessEndButton, holidayStartButton, holidayEndButton].forEach { $0.addTarget(self, action: #selector(timeButtonTapped(_:)), for: .touchUpInside) } let avatarTap = UITapGestureRecognizer(target: self, action: #selector(avatarTapped)) avatarImageView.addGestureRecognizer(avatarTap) avatarImageView.isUserInteractionEnabled = true } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.load(api: profileAPI) } } @MainActor private func applyViewModel() { if viewModel.isLoading { showLoading() } else { hideLoading() } saveButton.isEnabled = viewModel.hasChanges updateAvatar(urlString: viewModel.avatarURL) realNameLabel.text = "姓名: \(viewModel.realName)" nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : "昵称: \(viewModel.nickname)" nicknameField.isEnabled = viewModel.isEditingProfile introductionTextView.isEditable = viewModel.isEditingProfile introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x4B5563) updateIntroductionPlaceholder() editProfileButton.setImage(ProfileSpaceIcon.edit, for: .normal) avatarChangeOverlay.isHidden = !viewModel.isEditingProfile let visibleCertifications = viewModel.isCertificationExpanded ? viewModel.scenicCertification : Array(viewModel.scenicCertification.prefix(3)) certificationWrap.setItems(visibleCertifications, style: .certification, removable: false) certificationExpandButton.isHidden = viewModel.scenicCertification.count <= 3 certificationExpandButton.setTitle(viewModel.isCertificationExpanded ? "收起" : "展开", for: .normal) attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in self?.confirmDelete(title: "删除标签", message: "您确定要删除\(label)标签吗?") { self?.viewModel.removeAttrLabel(label) } } shootWrap.setItems(viewModel.shootLabels, style: .label, removable: true) { [weak self] label in self?.confirmDelete(title: "删除拍摄说明", message: "您确定要删除\(label)拍摄说明吗?") { self?.viewModel.removeShootLabel(label) } } businessStartButton.setTitle(viewModel.businessStartTime ?? "00:00", for: .normal) businessEndButton.setTitle(viewModel.businessEndTime ?? "00:00", for: .normal) holidayStartButton.setTitle(viewModel.holidayStartTime ?? "00:00", for: .normal) holidayEndButton.setTitle(viewModel.holidayEndTime ?? "00:00", for: .normal) deviceWrap.setItems(viewModel.cameraDevices, style: .device, removable: true) { [weak self] device in self?.confirmDelete(title: "删除设备", message: "您确定要删除\(device)设备吗?") { self?.viewModel.removeDevice(device) } } addDeviceButton.isHidden = viewModel.cameraDevices.count > 10 deviceWrap.invalidateIntrinsicContentSize() deviceWrap.setNeedsLayout() updateStatusButtons() updateLabelAddButtons() rebuildDateButtons() rebuildSchedules() } private func configureProfileCard() { let card = makeCard() let row = UIStackView() row.axis = .horizontal row.alignment = .top row.spacing = 16 avatarImageView.layer.cornerRadius = 58 avatarImageView.clipsToBounds = true avatarImageView.backgroundColor = AppColor.primaryLight avatarImageView.contentMode = .scaleAspectFill avatarImageView.image = UIImage(named: "profile_avatar_default") avatarImageView.tintColor = UIColor(hex: 0xB6BECA) avatarImageView.accessibilityLabel = "头像" avatarImageView.snp.makeConstraints { make in make.width.height.equalTo(116) } avatarImageView.addSubview(avatarChangeOverlay) avatarChangeOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.35) avatarChangeOverlay.isHidden = true avatarChangeOverlay.addSubview(avatarChangeLabel) avatarChangeLabel.text = "点击更换" avatarChangeLabel.textColor = .white avatarChangeLabel.font = .systemFont(ofSize: 12, weight: .medium) avatarChangeOverlay.snp.makeConstraints { make in make.edges.equalToSuperview() } avatarChangeLabel.snp.makeConstraints { make in make.center.equalToSuperview() } let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 0 realNameLabel.font = .systemFont(ofSize: 14) realNameLabel.textColor = UIColor(hex: 0x4B5563) let nicknameRow = UIStackView() nicknameRow.axis = .horizontal nicknameRow.alignment = .center nicknameRow.spacing = 8 nicknameField.font = .systemFont(ofSize: 16) nicknameField.textColor = .black nicknameField.borderStyle = .none nicknameField.placeholder = "请输入昵称" nicknameField.returnKeyType = .done nicknameField.delegate = self nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged) editProfileButton.tintColor = AppColor.primary editProfileButton.accessibilityLabel = "编辑个人信息" editProfileButton.snp.makeConstraints { make in make.width.height.equalTo(44) } nicknameRow.addArrangedSubview(nicknameField) nicknameRow.addArrangedSubview(editProfileButton) introductionTextView.font = .systemFont(ofSize: 14) introductionTextView.backgroundColor = .clear introductionTextView.textContainerInset = .zero introductionTextView.textContainer.lineFragmentPadding = 0 introductionTextView.delegate = self introductionTextView.isScrollEnabled = false introductionTextView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(24) } introductionPlaceholderLabel.text = "请输入个人简介" introductionPlaceholderLabel.font = introductionTextView.font introductionPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA) introductionTextView.addSubview(introductionPlaceholderLabel) introductionPlaceholderLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() } textStack.addArrangedSubview(realNameLabel) textStack.addArrangedSubview(nicknameRow) textStack.setCustomSpacing(8, after: nicknameRow) textStack.addArrangedSubview(introductionTextView) textStack.setCustomSpacing(4, after: introductionTextView) textStack.addArrangedSubview(certificationWrap) certificationExpandButton.setTitleColor(AppColor.primary, for: .normal) certificationExpandButton.titleLabel?.font = .systemFont(ofSize: 14) certificationExpandButton.contentHorizontalAlignment = .left certificationExpandButton.isHidden = true certificationExpandButton.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(20) } textStack.addArrangedSubview(certificationExpandButton) row.addArrangedSubview(avatarImageView) row.addArrangedSubview(textStack) card.addArrangedSubview(row) contentStack.addArrangedSubview(card) } private func configureLabelCard() { let card = makeCard() card.spacing = 0 let title = makeSectionTitle("标签") card.addArrangedSubview(title) card.setCustomSpacing(12, after: title) card.addArrangedSubview(attrWrap) card.setCustomSpacing(8, after: attrWrap) let attrRow = makeAddRow( field: attrField, button: attrAddButton, placeholder: "添加标签", action: #selector(addAttrTapped) ) card.addArrangedSubview(attrRow) card.setCustomSpacing(20, after: attrRow) card.addArrangedSubview(shootWrap) card.setCustomSpacing(12, after: shootWrap) let shootRow = makeAddRow( field: shootField, button: shootAddButton, placeholder: "添加拍摄说明", action: #selector(addShootTapped) ) card.addArrangedSubview(shootRow) card.setCustomSpacing(8, after: shootRow) let hint = UILabel() hint.text = "每个标签最多20个字符,最多8个标签" hint.font = .systemFont(ofSize: 12) hint.textColor = UIColor(hex: 0xB6BECA) card.addArrangedSubview(hint) contentStack.addArrangedSubview(card) } private func configureBusinessHoursCard() { let card = makeCard() let title = makeSectionTitle("营业时间") let workdayRow = makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton) card.addArrangedSubview(title) card.addArrangedSubview(workdayRow) card.setCustomSpacing(20, after: workdayRow) card.addArrangedSubview(makeTimeRow(title: "法定节假日营业时间", start: holidayStartButton, end: holidayEndButton)) contentStack.addArrangedSubview(card) } private func configureDeviceCard() { let card = makeCard() card.addArrangedSubview(makeSectionTitle("相机设备")) card.addArrangedSubview(deviceWrap) addDeviceButton.setTitle("+ 添加设备", for: .normal) addDeviceButton.setTitleColor(AppColor.primary, for: .normal) addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14) addDeviceButton.layer.cornerRadius = 4 addDeviceButton.setConfigurationContentInsets( NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) ) addDeviceButton.contentHorizontalAlignment = .left addDeviceButton.snp.makeConstraints { make in make.height.equalTo(28) } deviceWrap.setTrailingView(addDeviceButton) contentStack.addArrangedSubview(card) } private func configureOrderStatusCard() { let card = makeCard() card.addArrangedSubview(makeSectionTitle("接单状态")) let labels = ["可接单", "暂停接单", "已约满", "下线"] for rowIndex in 0..<2 { let row = UIStackView() row.axis = .horizontal row.spacing = 15 row.distribution = .fillEqually for index in 0..<2 { let status = rowIndex * 2 + index + 1 let button = UIButton(type: .system) button.setTitle(labels[status - 1], for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14) button.tag = status button.layer.cornerRadius = 8 button.layer.borderWidth = 1 button.addTarget(self, action: #selector(statusTapped(_:)), for: .touchUpInside) button.snp.makeConstraints { make in make.height.equalTo(52) } statusButtons.append(button) row.addArrangedSubview(button) } card.addArrangedSubview(row) } contentStack.addArrangedSubview(card) } private func configureScheduleCard() { let card = makeCard() card.addArrangedSubview(makeSectionTitle("日程安排")) dateStack.axis = .horizontal dateStack.spacing = 4 dateStack.distribution = .fillEqually card.addArrangedSubview(dateStack) let header = UIStackView() header.axis = .horizontal header.alignment = .center header.spacing = 12 selectedDateLabel.font = .systemFont(ofSize: 14) selectedDateLabel.textColor = UIColor(hex: 0x252525) addScheduleButton.setTitle("添加日程", for: .normal) addScheduleButton.setTitleColor(AppColor.primary, for: .normal) addScheduleButton.titleLabel?.font = .systemFont(ofSize: 14) header.addArrangedSubview(selectedDateLabel) header.addArrangedSubview(UIView()) header.addArrangedSubview(addScheduleButton) card.addArrangedSubview(header) card.setCustomSpacing(8, after: header) scheduleStack.axis = .vertical scheduleStack.spacing = 12 card.addArrangedSubview(scheduleStack) contentStack.addArrangedSubview(card) } private func makeCard() -> UIStackView { let stack = UIStackView() stack.axis = .vertical stack.spacing = 16 stack.backgroundColor = .white stack.layer.cornerRadius = 12 stack.clipsToBounds = true stack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) stack.isLayoutMarginsRelativeArrangement = true return stack } private func makeSectionTitle(_ title: String) -> UILabel { let label = UILabel() label.text = title label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = ["相机设备", "接单状态", "日程安排"].contains(title) ? UIColor(hex: 0x4B5563) : UIColor(hex: 0x252525) return label } private func makeAddRow( field: UITextField, button: UIButton, placeholder: String, action: Selector ) -> UIView { let row = UIStackView() row.axis = .horizontal row.spacing = 8 row.alignment = .fill field.placeholder = placeholder field.font = .systemFont(ofSize: 14) field.returnKeyType = .done field.delegate = self field.layer.cornerRadius = 8 field.layer.borderColor = AppColor.border.cgColor field.layer.borderWidth = 1 field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) field.leftViewMode = .always field.snp.makeConstraints { make in make.height.equalTo(36) } button.setTitle("添加标签", for: .normal) button.setTitleColor(.white, for: .normal) button.setTitleColor(.white, for: .disabled) button.backgroundColor = AppColor.primary button.titleLabel?.font = .systemFont(ofSize: 14) button.layer.cornerRadius = 4 button.addTarget(self, action: action, for: .touchUpInside) button.isEnabled = false button.snp.makeConstraints { make in make.width.equalTo(80) make.height.equalTo(36) } row.addArrangedSubview(field) row.addArrangedSubview(button) return row } private func makeTimeRow(title: String, start: ProfileTimeButton, end: ProfileTimeButton) -> UIView { let row = UIStackView() row.axis = .horizontal row.alignment = .center row.spacing = 10 let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = UIColor(hex: 0x4B5563) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let toLabel = UILabel() toLabel.text = "至" toLabel.font = .systemFont(ofSize: 14) toLabel.textColor = UIColor(hex: 0x4B5563) let spacer = UIView() row.addArrangedSubview(titleLabel) row.addArrangedSubview(spacer) row.addArrangedSubview(start) row.addArrangedSubview(toLabel) row.addArrangedSubview(end) [start, end].forEach { button in button.snp.makeConstraints { make in make.width.equalTo(60) make.height.equalTo(36) } } return row } private func updateAvatar(urlString: String?) { let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty else { avatarImageView.kf.cancelDownloadTask() avatarImageView.backgroundColor = AppColor.primaryLight avatarImageView.image = UIImage(named: "profile_avatar_default") avatarImageView.tintColor = UIColor(hex: 0xB6BECA) avatarImageView.contentMode = .scaleAspectFit return } avatarImageView.contentMode = .scaleAspectFill avatarImageView.loadRemoteImage(urlString: trimmed) } private func updateIntroductionPlaceholder() { introductionPlaceholderLabel.isHidden = !viewModel.isEditingProfile || !introductionTextView.text.isEmpty introductionTextView.layer.borderWidth = viewModel.isEditingProfile ? 1 : 0 introductionTextView.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor introductionTextView.layer.cornerRadius = 4 introductionTextView.textContainerInset = viewModel.isEditingProfile ? UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) : .zero introductionPlaceholderLabel.snp.remakeConstraints { make in make.top.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0) make.leading.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0) make.trailing.equalToSuperview().inset(viewModel.isEditingProfile ? 8 : 0) } } private func updateLabelAddButtons() { let pairs = [(attrField, attrAddButton), (shootField, shootAddButton)] for (field, button) in pairs { let enabled = !(field.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty button.isEnabled = enabled button.backgroundColor = enabled ? AppColor.primary : AppColor.buttonDisabled } } private func updateStatusButtons() { for button in statusButtons { let selected = button.tag == viewModel.acceptOrderStatus button.backgroundColor = selected ? AppColor.primary : .white button.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xB6BECA)).cgColor button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal) button.accessibilityTraits = selected ? [.button, .selected] : .button } } private func rebuildDateButtons() { dateStack.arrangedSubviews.forEach { $0.removeFromSuperview() } for date in viewModel.weekDates { let button = ProfileDateButton() button.configure( weekday: ProfileSpaceSettingsViewModel.displayWeekday(date), day: ProfileSpaceSettingsViewModel.displayDay(date), selected: Calendar.current.isDate(date, inSameDayAs: viewModel.selectedDate) ) button.date = date button.addTarget(self, action: #selector(dateTapped(_:)), for: .touchUpInside) dateStack.addArrangedSubview(button) } selectedDateLabel.text = ProfileSpaceSettingsViewModel.displayDate(viewModel.selectedDate) } private func rebuildSchedules() { scheduleStack.arrangedSubviews.forEach { $0.removeFromSuperview() } let schedules = viewModel.selectedDateSchedules guard !schedules.isEmpty else { let empty = UILabel() empty.text = "该日无日程" empty.textColor = AppColor.textTertiary empty.font = .systemFont(ofSize: 14) empty.textAlignment = .center empty.snp.makeConstraints { make in make.height.equalTo(76) } scheduleStack.addArrangedSubview(empty) return } for schedule in schedules { let item = ProfileScheduleItemView(schedule: schedule) item.onDelete = { [weak self] in self?.confirmDelete(title: "删除日程", message: "您确定要删除该日程吗?") { guard let self else { return } Task { await self.viewModel.deleteSchedule(id: schedule.id, api: self.profileAPI) } } } item.onCall = { [weak self] phone in self?.callPhone(phone) } scheduleStack.addArrangedSubview(item) } } private func confirmDelete(title: String, message: String, onConfirm: @escaping () -> Void) { let dialog = ProfileDeleteDialogViewController(title: title, message: message, onConfirm: onConfirm) present(dialog, animated: false) } private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { let dialog = ProfileTimePickerDialogViewController( initialDate: date(fromTime: initialTime ?? "00:00"), onConfirm: { [weak self] date in guard let self else { return } onConfirm(timeString(from: date)) } ) present(dialog, animated: false) } private func date(fromTime value: String) -> Date { var components = DateComponents() let pieces = value.split(separator: ":").compactMap { Int($0) } components.hour = pieces.first ?? 0 components.minute = pieces.dropFirst().first ?? 0 return Calendar.current.date(from: components) ?? Date() } private func timeString(from date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm" return formatter.string(from: date) } private func callPhone(_ phone: String) { let digits = phone.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: "tel://\(digits)"), UIApplication.shared.canOpenURL(url) else { return } UIApplication.shared.open(url) } @objc private func saveTapped() { view.endEditing(true) Task { await viewModel.save(api: profileAPI) } } @objc private func editProfileTapped() { view.endEditing(true) Task { await viewModel.toggleProfileEditing(api: profileAPI) } } @objc private func nicknameChanged() { viewModel.updateEditingNickname(nicknameField.text ?? "") } @objc private func addAttrTapped() { viewModel.addAttrLabel(attrField.text ?? "") attrField.text = "" updateLabelAddButtons() } @objc private func addShootTapped() { viewModel.addShootLabel(shootField.text ?? "") shootField.text = "" updateLabelAddButtons() } @objc private func labelInputChanged() { updateLabelAddButtons() } @objc private func certificationExpandTapped() { viewModel.toggleCertificationExpanded() } @objc private func timeButtonTapped(_ sender: ProfileTimeButton) { if sender === businessStartButton { showTimePicker(initialTime: viewModel.businessStartTime) { [weak self] in self?.viewModel.updateBusinessStartTime($0) } } else if sender === businessEndButton { showTimePicker(initialTime: viewModel.businessEndTime) { [weak self] in self?.viewModel.updateBusinessEndTime($0) } } else if sender === holidayStartButton { showTimePicker(initialTime: viewModel.holidayStartTime) { [weak self] in self?.viewModel.updateHolidayStartTime($0) } } else { showTimePicker(initialTime: viewModel.holidayEndTime) { [weak self] in self?.viewModel.updateHolidayEndTime($0) } } } @objc private func addDeviceTapped() { let dialog = ProfileDeviceDialogViewController { [weak self] value in self?.viewModel.addDevice(value) } present(dialog, animated: false) } @objc private func statusTapped(_ sender: UIButton) { viewModel.updateAcceptOrderStatus(sender.tag) } @objc private func dateTapped(_ sender: ProfileDateButton) { guard let date = sender.date else { return } viewModel.selectDate(date) } @objc private func addScheduleTapped() { let controller = ProfileAddScheduleViewController( selectedDate: viewModel.selectedDate, viewModel: viewModel, profileAPI: profileAPI ) navigationController?.pushViewController(controller, animated: true) } @objc private func avatarTapped() { guard viewModel.isEditingProfile else { return } var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .images configuration.selectionLimit = 1 let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } } extension ProfileSpaceSettingsViewController: UITextFieldDelegate, UITextViewDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return true } func textField( _ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String ) -> Bool { guard textField === attrField || textField === shootField else { return true } let current = textField.text ?? "" guard let swiftRange = Range(range, in: current) else { return false } return current.replacingCharacters(in: swiftRange, with: string).count <= 20 } func textViewDidChange(_ textView: UITextView) { viewModel.updateIntroduction(textView.text) updateIntroductionPlaceholder() } func textView( _ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String ) -> Bool { guard text == "\n" else { return true } textView.resignFirstResponder() return false } } extension ProfileSpaceSettingsViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let provider = results.first?.itemProvider else { return } Task { do { let image = try await ProfileAvatarPickerLoader.loadImage(from: provider) await MainActor.run { let crop = ProfileAvatarCropViewController(image: image) { [weak self] data in guard let self else { return } Task { await self.viewModel.uploadAvatar( data: data, fileName: "avatar_\(UUID().uuidString).jpg", api: self.profileAPI ) } } self.present(crop, animated: true) } } catch { await MainActor.run { showToast(error.localizedDescription) } } } } } /// 添加日程页面,对齐 Android `AddScheduleScreen`。 final class ProfileAddScheduleViewController: BaseViewController { private let selectedDate: Date private let viewModel: ProfileSpaceSettingsViewModel private let profileAPI: ProfileAPI private let contentStack = UIStackView() private let bottomBar = UIView() private let saveButton = AppButton(title: "保存") private let nameField = UITextField() private let startButton = ProfileTimeButton() private let endButton = ProfileTimeButton() private let orderButton = UIButton(type: .system) private let remarkTextView = UITextView() private let remarkPlaceholderLabel = UILabel() private let countLabel = UILabel() private var selectedOrderNumber: String? init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) { self.selectedDate = selectedDate self.viewModel = viewModel self.profileAPI = profileAPI super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "添加日程" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) ProfileNavigationStyle.apply(to: navigationController, showsDivider: true) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) ProfileNavigationStyle.restore(navigationController) } override func setupUI() { view.addSubview(contentStack) view.addSubview(bottomBar) bottomBar.addSubview(saveButton) bottomBar.backgroundColor = .white view.backgroundColor = AppColor.pageBackground saveButton.isEnabled = false saveButton.snp.updateConstraints { make in make.height.equalTo(48) } contentStack.axis = .vertical contentStack.spacing = 20 contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) contentStack.isLayoutMarginsRelativeArrangement = true contentStack.backgroundColor = .white contentStack.layer.cornerRadius = 12 contentStack.addArrangedSubview(makeTitle("日程安排")) configureNameField() contentStack.addArrangedSubview(nameField) contentStack.addArrangedSubview(makeTimeRow()) configureOrderButton() contentStack.addArrangedSubview(orderButton) configureRemarkView() contentStack.addArrangedSubview(remarkTextView) contentStack.addArrangedSubview(countLabel) } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } saveButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(15) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } contentStack.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(16) make.leading.trailing.equalToSuperview().inset(16) } nameField.snp.makeConstraints { make in make.height.equalTo(44) } orderButton.snp.makeConstraints { make in make.height.equalTo(44) } remarkTextView.snp.makeConstraints { make in make.height.equalTo(100) } } override func bindActions() { saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) startButton.addTarget(self, action: #selector(startTapped), for: .touchUpInside) endButton.addTarget(self, action: #selector(endTapped), for: .touchUpInside) orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside) remarkTextView.delegate = self nameField.addTarget(self, action: #selector(formValueChanged), for: .editingChanged) } private func makeTitle(_ title: String) -> UILabel { let label = UILabel() label.text = title label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = .black return label } private func configureNameField() { nameField.placeholder = "请输入日程名称" nameField.font = .systemFont(ofSize: 14) nameField.returnKeyType = .done nameField.delegate = self nameField.layer.borderColor = AppColor.border.cgColor nameField.layer.borderWidth = 1 nameField.layer.cornerRadius = 8 nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) nameField.leftViewMode = .always } private func makeTimeRow() -> UIView { let row = UIStackView() row.axis = .horizontal row.alignment = .center row.spacing = 12 let title = UILabel() title.text = "时间" title.font = .systemFont(ofSize: 14) title.textColor = UIColor(hex: 0x4B5563) let toLabel = UILabel() toLabel.text = "至" toLabel.font = .systemFont(ofSize: 14) toLabel.textColor = UIColor(hex: 0x4B5563) [startButton, endButton].forEach { button in button.setTitle("请选择", for: .normal) button.snp.makeConstraints { make in make.width.equalTo(60) make.height.equalTo(36) } } row.addArrangedSubview(title) row.addArrangedSubview(UIView()) row.addArrangedSubview(startButton) row.addArrangedSubview(toLabel) row.addArrangedSubview(endButton) return row } private func configureOrderButton() { orderButton.setTitle("关联订单 (可选)", for: .normal) orderButton.setTitleColor(.black, for: .normal) orderButton.contentHorizontalAlignment = .left orderButton.titleLabel?.font = .systemFont(ofSize: 14) orderButton.layer.borderColor = AppColor.border.cgColor orderButton.layer.borderWidth = 1 orderButton.layer.cornerRadius = 8 var configuration = UIButton.Configuration.plain() configuration.title = "关联订单 (可选)" configuration.image = UIImage(systemName: "chevron.down") configuration.imagePlacement = .trailing configuration.imagePadding = 8 configuration.baseForegroundColor = .black configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) orderButton.configuration = configuration } private func configureRemarkView() { remarkTextView.font = .systemFont(ofSize: 14) remarkTextView.textColor = UIColor(hex: 0x4B5563) remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor remarkTextView.layer.borderWidth = 1 remarkTextView.layer.cornerRadius = 8 remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12) remarkTextView.textContainer.lineFragmentPadding = 0 remarkPlaceholderLabel.text = "请输入日程内容" remarkPlaceholderLabel.font = remarkTextView.font remarkPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA) remarkTextView.addSubview(remarkPlaceholderLabel) remarkPlaceholderLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(10) make.leading.equalToSuperview().offset(12) make.trailing.equalToSuperview().inset(12) } countLabel.text = "0/200" countLabel.textAlignment = .right countLabel.font = .systemFont(ofSize: 12) countLabel.textColor = UIColor(hex: 0xB6BECA) } private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { let date = Self.date(from: initialTime) let dialog = ProfileTimePickerDialogViewController(initialDate: date) { selectedDate in let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm" onConfirm(formatter.string(from: selectedDate)) } present(dialog, animated: false) } @objc private func startTapped() { showTimePicker(initialTime: startButton.title(for: .normal)) { [weak self] value in if let end = self?.endButton.title(for: .normal), end != "请选择", !ProfileSpaceSettingsViewModel.isStart(value, before: end) { self?.showToast("结束时间必须晚于开始时间,已清除结束时间") self?.endButton.setTitle("请选择", for: .normal) } self?.startButton.setTitle(value, for: .normal) self?.updateSaveEnabled() } } @objc private func endTapped() { showTimePicker(initialTime: endButton.title(for: .normal)) { [weak self] value in if let start = self?.startButton.title(for: .normal), start != "请选择", !ProfileSpaceSettingsViewModel.isStart(start, before: value) { self?.showToast("结束时间必须晚于开始时间") return } self?.endButton.setTitle(value, for: .normal) self?.updateSaveEnabled() } } @objc private func orderTapped() { let controller = TaskOrderSelectViewController(initialOrderNumber: selectedOrderNumber) controller.onConfirmed = { [weak self] orderNumber in self?.selectedOrderNumber = orderNumber self?.applySelectedOrder() } navigationController?.pushViewController(controller, animated: true) } @objc private func saveTapped() { view.endEditing(true) let start = startButton.title(for: .normal) == "请选择" ? nil : startButton.title(for: .normal) let end = endButton.title(for: .normal) == "请选择" ? nil : endButton.title(for: .normal) Task { let success = await viewModel.addSchedule( name: nameField.text ?? "", remark: remarkTextView.text ?? "", startTime: start, endTime: end, scheduleDate: selectedDate, orderNumber: selectedOrderNumber, api: profileAPI ) if success { _ = await MainActor.run { navigationController?.popViewController(animated: true) } } } } @objc private func formValueChanged() { updateSaveEnabled() } private func updateSaveEnabled() { let name = (nameField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let start = startButton.title(for: .normal) let end = endButton.title(for: .normal) saveButton.isEnabled = !name.isEmpty && start != nil && start != "请选择" && end != nil && end != "请选择" && ProfileSpaceSettingsViewModel.isStart(start ?? "", before: end ?? "") } private func applySelectedOrder() { var configuration = orderButton.configuration ?? .plain() configuration.title = selectedOrderNumber ?? "关联订单 (可选)" configuration.baseForegroundColor = .black orderButton.configuration = configuration } private static func date(from time: String?) -> Date { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm" return formatter.date(from: time ?? "") ?? formatter.date(from: "00:00") ?? Date(timeIntervalSince1970: 0) } } extension ProfileAddScheduleViewController: UITextFieldDelegate, UITextViewDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return true } func textViewDidChange(_ textView: UITextView) { if textView.text.count > 200 { textView.text = String(textView.text.prefix(200)) } remarkPlaceholderLabel.isHidden = !textView.text.isEmpty countLabel.text = "\(textView.text.count)/200" } func textView( _ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String ) -> Bool { guard text == "\n" else { return true } textView.resignFirstResponder() return false } } /// 个人空间配置时间按钮。 private final class ProfileTimeButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) titleLabel?.font = .systemFont(ofSize: 14) titleLabel?.lineBreakMode = .byClipping setTitleColor(UIColor(hex: 0x4B5563), for: .normal) backgroundColor = AppColor.inputBackground layer.cornerRadius = 4 setConfigurationContentInsets( NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) ) contentHorizontalAlignment = .center } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { bounds.insetBy(dx: -4, dy: -4).contains(point) } } /// 个人空间配置日期按钮。 private final class ProfileDateButton: UIButton { var date: Date? private let weekdayLabel = UILabel() private let dayContainer = UIView() private let dayLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) weekdayLabel.font = .systemFont(ofSize: 14) weekdayLabel.textAlignment = .center weekdayLabel.textColor = UIColor(hex: 0x4B5563) dayContainer.layer.cornerRadius = 4 dayContainer.isUserInteractionEnabled = false dayLabel.font = .systemFont(ofSize: 14) dayLabel.textAlignment = .center dayContainer.addSubview(dayLabel) addSubview(weekdayLabel) addSubview(dayContainer) weekdayLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(18) } dayContainer.snp.makeConstraints { make in make.top.equalTo(weekdayLabel.snp.bottom).offset(12) make.centerX.equalToSuperview() make.width.height.equalTo(36) make.bottom.equalToSuperview() } dayLabel.snp.makeConstraints { make in make.center.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(weekday: String, day: String, selected: Bool) { weekdayLabel.text = weekday dayLabel.text = day dayLabel.textColor = selected ? .white : UIColor(hex: 0x4B5563) dayContainer.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground accessibilityLabel = "星期\(weekday),\(day)日" accessibilityTraits = selected ? [.button, .selected] : .button } } /// 个人空间配置标签换行容器。 private final class ProfileTagWrapView: UIView { enum Style { case label case certification case device } private var itemViews: [UIView] = [] private var removeHandler: ((String) -> Void)? private weak var trailingView: UIView? func setTrailingView(_ view: UIView) { trailingView = view if view.superview !== self { addSubview(view) } itemViews.append(view) invalidateIntrinsicContentSize() setNeedsLayout() } func setItems(_ items: [String], style: Style, removable: Bool, onRemove: ((String) -> Void)? = nil) { subviews.forEach { $0.removeFromSuperview() } itemViews = items.map { item in let view = makeItem(title: item, style: style, removable: removable) addSubview(view) return view } if let trailingView { addSubview(trailingView) itemViews.append(trailingView) } removeHandler = onRemove invalidateIntrinsicContentSize() setNeedsLayout() } override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: measuredHeight(for: bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 62)) } override func layoutSubviews() { super.layoutSubviews() layoutItems(width: bounds.width) } private func layoutItems(width: CGFloat) { let spacing: CGFloat = 8 var x: CGFloat = 0 var y: CGFloat = 0 var rowHeight: CGFloat = 0 for view in itemViews { guard !view.isHidden else { continue } let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) if x > 0, x + size.width > width { x = 0 y += rowHeight + spacing rowHeight = 0 } view.frame = CGRect(x: x, y: y, width: size.width, height: size.height) x += size.width + spacing rowHeight = max(rowHeight, size.height) } } private func measuredHeight(for width: CGFloat) -> CGFloat { guard !itemViews.isEmpty else { return 1 } let spacing: CGFloat = 8 var x: CGFloat = 0 var height: CGFloat = 0 var rowHeight: CGFloat = 0 for view in itemViews { guard !view.isHidden else { continue } let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) if x > 0, x + size.width > width { x = 0 height += rowHeight + spacing rowHeight = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) } return height + rowHeight } private func makeItem(title: String, style: Style, removable: Bool) -> UIView { let button = UIButton(type: .system) button.setTitle(removable ? "\(title) ×" : title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: style == .device ? 14 : 12) let horizontalInset: CGFloat = style == .device ? 8 : 4 button.setConfigurationContentInsets( NSDirectionalEdgeInsets(top: 2, leading: horizontalInset, bottom: 2, trailing: horizontalInset) ) button.layer.cornerRadius = 4 switch style { case .label: button.backgroundColor = AppColor.primaryLight button.setTitleColor(AppColor.primary, for: .normal) case .certification: button.backgroundColor = AppColor.warningBackground button.setTitleColor(AppColor.warning, for: .normal) case .device: button.backgroundColor = AppColor.inputBackground button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal) button.snp.makeConstraints { make in make.height.equalTo(28) } } if removable { button.addAction(UIAction { [weak self] _ in self?.removeHandler?(title) }, for: .touchUpInside) } return button } } /// 个人空间配置日程列表项。 private final class ProfileScheduleItemView: UIStackView { var onDelete: (() -> Void)? var onCall: ((String) -> Void)? private let schedule: PhotographerSchedule init(schedule: PhotographerSchedule) { self.schedule = schedule super.init(frame: .zero) setupUI() } @available(*, unavailable) required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { axis = .vertical spacing = 4 backgroundColor = UIColor(hex: 0xF9FAFB) layer.cornerRadius = 8 layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) isLayoutMarginsRelativeArrangement = true let top = UIStackView() top.axis = .horizontal top.spacing = 8 let title = UILabel() title.text = "\(schedule.startTime) - \(schedule.endTime) \(schedule.name)" title.font = .systemFont(ofSize: 14) title.textColor = UIColor(hex: 0x4B5563) title.numberOfLines = 1 let delete = UIButton(type: .system) delete.setTitle("删除", for: .normal) delete.setTitleColor(UIColor(hex: 0xFF5656), for: .normal) delete.titleLabel?.font = .systemFont(ofSize: 14) delete.addAction(UIAction { [weak self] _ in self?.onDelete?() }, for: .touchUpInside) top.addArrangedSubview(title) top.addArrangedSubview(delete) addArrangedSubview(top) if let order = schedule.orderNumber, !order.isEmpty { addArrangedSubview(makeLine("订单号: \(order)")) } if let phone = schedule.userPhone, !phone.isEmpty { let row = UIStackView() row.axis = .horizontal row.spacing = 16 row.addArrangedSubview(makeLine("客户手机号: \(Self.maskPhone(phone))")) let call = UIButton(type: .system) call.setTitle("拨打", for: .normal) call.setTitleColor(AppColor.primary, for: .normal) call.titleLabel?.font = .systemFont(ofSize: 14) call.addAction(UIAction { [weak self] _ in self?.onCall?(phone) }, for: .touchUpInside) row.addArrangedSubview(call) addArrangedSubview(row) } if !schedule.remark.isEmpty { addArrangedSubview(makeLine("备注信息: \(schedule.remark)")) } } private func makeLine(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = .systemFont(ofSize: 14) label.textColor = UIColor(hex: 0x4B5563) label.numberOfLines = 1 return label } private static func maskPhone(_ phone: String) -> String { guard phone.count >= 11 else { return phone } let prefix = phone.prefix(3) let suffix = phone.suffix(phone.count - 7) return "\(prefix)****\(suffix)" } } /// 头像选择器数据加载器。 private enum ProfileAvatarPickerLoader { static func loadImage(from provider: NSItemProvider) async throws -> UIImage { try await withCheckedThrowingContinuation { continuation in guard provider.canLoadObject(ofClass: UIImage.self) else { continuation.resume(throwing: OSSUploadError.unsupportedFileType) return } provider.loadObject(ofClass: UIImage.self) { object, error in if let error { continuation.resume(throwing: error) return } guard let image = object as? UIImage else { continuation.resume(throwing: OSSUploadError.emptyFile) return } continuation.resume(returning: image) } } } }