From ce6de760551248a61f0c1c36d34c6c463a4a8242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 15 Jul 2026 14:59:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B4=A6=E5=8F=B7=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=B1=95=E7=A4=BA=E3=80=81=E9=A6=96=E9=A1=B5=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E5=9B=BE=E6=A0=87=E4=B8=8E=E6=9C=89=E7=BA=BF=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E8=AE=BE=E7=BD=AE=20chip=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../home_menu_space.imageset/Contents.json | 8 ++ .../Contents.json | 8 ++ .../profile_edit.imageset/Contents.json | 8 ++ .../Features/Auth/Models/AuthModels.swift | 21 +++- .../Home/Services/HomeMenuIconFactory.swift | 21 ++++ .../UI/Home/Views/AllFunctionMenuCell.swift | 5 +- .../UI/Home/Views/HomeCollectionCells.swift | 4 +- .../AccountSelectionViewController.swift | 14 +-- .../Profile/AccountSwitchViewController.swift | 14 +-- .../TravelAlbumEntryViewController.swift | 29 +---- .../WiredCameraTransferViewController.swift | 41 ++----- .../WiredTransferSettingChipButton.swift | 88 +++++++++++++++ ...PhotographerReportListViewController.swift | 2 +- .../WildReportRiskMapViewController.swift | 100 ++++++++++-------- .../AccountSwitchViewModelTests.swift | 3 +- suixinkanTests/AuthModelsTests.swift | 12 ++- suixinkanTests/HomeMenuIconFactoryTests.swift | 44 ++++++++ .../WiredTransferSettingChipButtonTests.swift | 29 +++++ 18 files changed, 321 insertions(+), 130 deletions(-) create mode 100644 suixinkan/Features/Home/Services/HomeMenuIconFactory.swift create mode 100644 suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift create mode 100644 suixinkanTests/HomeMenuIconFactoryTests.swift create mode 100644 suixinkanTests/WiredTransferSettingChipButtonTests.swift diff --git a/suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json b/suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json index 84c068f..0b7526e 100644 --- a/suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "home_menu_space.svg", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json b/suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json index 4c4901a..3cd9ed5 100644 --- a/suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "profile_avatar_default.svg", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json b/suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json index 78d1ae8..9758c6d 100644 --- a/suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "profile_edit.svg", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift index 3d79d8f..918a359 100644 --- a/suixinkan/Features/Auth/Models/AuthModels.swift +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -118,6 +118,7 @@ struct AccountSwitchAccount: Identifiable, Hashable { let title: String let subtitle: String let phone: String + let realName: String let avatar: String let scenicName: String let storeId: Int? @@ -201,6 +202,7 @@ struct V9ScenicUser: Decodable, Equatable { let phone: String let scenicId: Int let scenicName: String + let roleName: String let appRoleCode: String let appRoleName: String let isCurrent: Bool @@ -218,8 +220,9 @@ struct V9ScenicUser: Decodable, Equatable { accountType: accountType.nonEmpty ?? Self.accountTypeValue, businessUserId: businessUserId, title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号", - subtitle: username, + subtitle: joinAccountSubtitle(scenicName, roleName), phone: phone, + realName: realName, avatar: "", scenicName: scenicName, storeId: nil, @@ -242,6 +245,7 @@ struct V9ScenicUser: Decodable, Equatable { case phone case scenicId = "scenic_id" case scenicName = "scenic_name" + case roleName = "role_name" case appRoleCode = "app_role_code" case appRoleName = "app_role_name" case isCurrent = "is_current" @@ -262,6 +266,7 @@ struct V9ScenicUser: Decodable, Equatable { phone = try container.decodeLossyString(forKey: .phone) scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 scenicName = try container.decodeLossyString(forKey: .scenicName) + roleName = try container.decodeLossyString(forKey: .roleName) appRoleCode = try container.decodeLossyString(forKey: .appRoleCode) appRoleName = try container.decodeLossyString(forKey: .appRoleName) isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false @@ -285,6 +290,7 @@ struct V9StoreUser: Decodable, Equatable { let scenicName: String let storeId: Int let storeName: String + let roleName: String let appRoleCode: String let appRoleName: String let isCurrent: Bool @@ -302,8 +308,9 @@ struct V9StoreUser: Decodable, Equatable { accountType: accountType.nonEmpty ?? Self.accountTypeValue, businessUserId: businessUserId, title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", - subtitle: userName.nonEmpty ?? username, + subtitle: joinAccountSubtitle(scenicName, roleName), phone: phone, + realName: realName, avatar: avatar, scenicName: scenicName, storeId: storeId > 0 ? storeId : nil, @@ -327,6 +334,7 @@ struct V9StoreUser: Decodable, Equatable { case scenicName = "scenic_name" case storeId = "store_id" case storeName = "store_name" + case roleName = "role_name" case appRoleCode = "app_role_code" case appRoleName = "app_role_name" case isCurrent = "is_current" @@ -347,6 +355,7 @@ struct V9StoreUser: Decodable, Equatable { scenicName = try container.decodeLossyString(forKey: .scenicName) storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0 storeName = try container.decodeLossyString(forKey: .storeName) + roleName = try container.decodeLossyString(forKey: .roleName) appRoleCode = try container.decodeLossyString(forKey: .appRoleCode) appRoleName = try container.decodeLossyString(forKey: .appRoleName) isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false @@ -405,6 +414,14 @@ private extension KeyedDecodingContainer { } } +/// 将非空字段用小圆点拼接为账号副标题。 +private func joinAccountSubtitle(_ values: String...) -> String { + values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " · ") +} + private extension String { var nonEmpty: String? { let text = trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/suixinkan/Features/Home/Services/HomeMenuIconFactory.swift b/suixinkan/Features/Home/Services/HomeMenuIconFactory.swift new file mode 100644 index 0000000..3f07f02 --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeMenuIconFactory.swift @@ -0,0 +1,21 @@ +// +// HomeMenuIconFactory.swift +// suixinkan +// + +import UIKit + +/// 首页 / 全部功能菜单图标加载,统一 SF Symbol 与资源图的视觉尺寸。 +enum HomeMenuIconFactory { + + /// 与菜单图标槽位(约 24–26pt)对齐的系统图标配置。 + static let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + + /// 优先加载 Assets 资源图;否则按统一配置加载 SF Symbol。 + static func image(named iconName: String) -> UIImage? { + if let asset = UIImage(named: iconName) { + return asset + } + return UIImage(systemName: iconName, withConfiguration: symbolConfiguration) + } +} diff --git a/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift index 4c4776a..307622e 100644 --- a/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift +++ b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift @@ -42,10 +42,11 @@ final class AllFunctionMenuCell: UICollectionViewCell { /// 更新菜单内容、操作状态及整张卡片的无障碍语义。 func apply(menu: HomeMenuItem, actionStyle: ActionStyle) { - iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName) + iconView.image = HomeMenuIconFactory.image(named: menu.iconName) titleLabel.text = menu.title accessibilityLabel = menu.title + switch actionStyle { case .none: actionImageView.isHidden = true @@ -75,6 +76,8 @@ final class AllFunctionMenuCell: UICollectionViewCell { iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit + iconView.preferredSymbolConfiguration = HomeMenuIconFactory.symbolConfiguration + contentStackView.axis = .vertical contentStackView.alignment = .center diff --git a/suixinkan/UI/Home/Views/HomeCollectionCells.swift b/suixinkan/UI/Home/Views/HomeCollectionCells.swift index 13518f0..f4a3898 100644 --- a/suixinkan/UI/Home/Views/HomeCollectionCells.swift +++ b/suixinkan/UI/Home/Views/HomeCollectionCells.swift @@ -128,7 +128,7 @@ final class HomeMenuCell: UICollectionViewCell { } func apply(menu: HomeMenuItem) { - iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName) + iconView.image = HomeMenuIconFactory.image(named: menu.iconName) iconView.accessibilityIdentifier = menu.iconName titleLabel.text = menu.title } @@ -147,6 +147,8 @@ final class HomeMenuCell: UICollectionViewCell { iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit + iconView.preferredSymbolConfiguration = HomeMenuIconFactory.symbolConfiguration + contentStackView.axis = .vertical contentStackView.alignment = .center diff --git a/suixinkan/UI/Login/AccountSelectionViewController.swift b/suixinkan/UI/Login/AccountSelectionViewController.swift index 529f3f0..74fc843 100644 --- a/suixinkan/UI/Login/AccountSelectionViewController.swift +++ b/suixinkan/UI/Login/AccountSelectionViewController.swift @@ -186,7 +186,7 @@ private final class AccountSelectionCell: UITableViewCell { private let avatarPlaceholderLabel = UILabel() private let titleLabel = UILabel() private let subtitleLabel = UILabel() - private let phoneLabel = UILabel() + private let nameLabel = UILabel() private let typeTag = UILabel() private let currentTag = UILabel() private let checkmark = UIImageView() @@ -214,8 +214,8 @@ private final class AccountSelectionCell: UITableViewCell { subtitleLabel.font = .systemFont(ofSize: 13) subtitleLabel.textColor = AppColor.text666 - phoneLabel.font = .systemFont(ofSize: 12) - phoneLabel.textColor = UIColor(hex: 0x9AA1AA) + nameLabel.font = .systemFont(ofSize: 12) + nameLabel.textColor = UIColor(hex: 0x9AA1AA) typeTag.font = .systemFont(ofSize: 11, weight: .semibold) typeTag.textAlignment = .center @@ -239,7 +239,7 @@ private final class AccountSelectionCell: UITableViewCell { avatarView.addSubview(avatarPlaceholderLabel) card.addSubview(titleLabel) card.addSubview(subtitleLabel) - card.addSubview(phoneLabel) + card.addSubview(nameLabel) card.addSubview(typeTag) card.addSubview(currentTag) card.addSubview(checkmark) @@ -277,7 +277,7 @@ private final class AccountSelectionCell: UITableViewCell { make.trailing.equalTo(checkmark.snp.leading).offset(-8) } - phoneLabel.snp.makeConstraints { make in + nameLabel.snp.makeConstraints { make in make.top.equalTo(subtitleLabel.snp.bottom).offset(4) make.leading.equalTo(titleLabel) make.bottom.equalToSuperview().inset(16) @@ -304,8 +304,8 @@ private final class AccountSelectionCell: UITableViewCell { titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title subtitleLabel.text = account.subtitle subtitleLabel.isHidden = account.subtitle.isEmpty - phoneLabel.text = account.phone - phoneLabel.isHidden = account.phone.isEmpty + nameLabel.text = account.realName + nameLabel.isHidden = account.realName.isEmpty currentTag.isHidden = !account.isCurrent let avatarPlaceholderColor: UIColor diff --git a/suixinkan/UI/Profile/AccountSwitchViewController.swift b/suixinkan/UI/Profile/AccountSwitchViewController.swift index 0e74f0f..7a865e8 100644 --- a/suixinkan/UI/Profile/AccountSwitchViewController.swift +++ b/suixinkan/UI/Profile/AccountSwitchViewController.swift @@ -161,7 +161,7 @@ private final class AccountSwitchCell: UITableViewCell { private let titleLabel = UILabel() private let currentTagLabel = UILabel() private let subtitleLabel = UILabel() - private let phoneLabel = UILabel() + private let nameLabel = UILabel() private let tagLabel = UILabel() private let checkView = UIImageView() @@ -197,8 +197,8 @@ private final class AccountSwitchCell: UITableViewCell { currentTagLabel.setContentCompressionResistancePriority(.required, for: .horizontal) subtitleLabel.font = .systemFont(ofSize: 13) subtitleLabel.textColor = AppColor.text666 - phoneLabel.font = .systemFont(ofSize: 12) - phoneLabel.textColor = UIColor(hex: 0x9AA1AA) + nameLabel.font = .systemFont(ofSize: 12) + nameLabel.textColor = UIColor(hex: 0x9AA1AA) tagLabel.font = .systemFont(ofSize: 11, weight: .semibold) tagLabel.textAlignment = .center tagLabel.layer.cornerRadius = 4 @@ -210,7 +210,7 @@ private final class AccountSwitchCell: UITableViewCell { card.addSubview(titleStackView) card.addSubview(subtitleLabel) - card.addSubview(phoneLabel) + card.addSubview(nameLabel) card.addSubview(tagLabel) card.addSubview(checkView) @@ -234,7 +234,7 @@ private final class AccountSwitchCell: UITableViewCell { make.leading.equalTo(titleStackView) make.top.equalTo(titleStackView.snp.bottom).offset(4) } - phoneLabel.snp.makeConstraints { make in + nameLabel.snp.makeConstraints { make in make.leading.equalTo(titleLabel) make.top.equalTo(subtitleLabel.snp.bottom).offset(4) } @@ -257,7 +257,9 @@ private final class AccountSwitchCell: UITableViewCell { titleLabel.text = account.title currentTagLabel.isHidden = !isCurrent subtitleLabel.text = account.subtitle - phoneLabel.text = account.phone + subtitleLabel.isHidden = account.subtitle.isEmpty + nameLabel.text = account.realName + nameLabel.isHidden = account.realName.isEmpty tagLabel.text = account.isStoreUser ? "门店" : "景区" tagLabel.textColor = account.isStoreUser ? UIColor(hex: 0x0F9F6E) : UIColor(hex: 0x7C3AED) tagLabel.backgroundColor = account.isStoreUser ? UIColor(hex: 0xE8F8F1) : UIColor(hex: 0xF3ECFF) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift index 75f4375..782d74a 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift @@ -55,7 +55,6 @@ final class TravelAlbumEntryViewController: BaseViewController { cell.apply(album: album) cell.onShootUpload = { [weak self] in self?.pushWiredTransfer(album: album) } cell.onAlbumCode = { [weak self] in self?.loadAlbumCode(album) } - cell.onShare = { [weak self] in self?.showShareSheet() } return cell } @@ -162,18 +161,6 @@ final class TravelAlbumEntryViewController: BaseViewController { present(controller, animated: true) } - private func showShareSheet() { - let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - sheet.addAction(UIAlertAction(title: "微信好友", style: .default) { [weak self] _ in - self?.showToast("已选择微信好友") - }) - sheet.addAction(UIAlertAction(title: "QQ好友", style: .default) { [weak self] _ in - self?.showToast("已选择QQ好友") - }) - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) - } - private func pushWiredTransfer(album: TravelAlbum) { let controller = WiredCameraTransferViewController( viewModel: WiredCameraTransferViewModel( @@ -272,7 +259,6 @@ private final class TravelAlbumTaskCell: UITableViewCell { var onShootUpload: (() -> Void)? var onAlbumCode: (() -> Void)? - var onShare: (() -> Void)? private let cardView = UIView() private let coverView = UIImageView() @@ -280,7 +266,6 @@ private final class TravelAlbumTaskCell: UITableViewCell { private let nameLabel = UILabel() private let timeLabel = UILabel() private let phoneLabel = UILabel() - private let shareButton = UIButton(type: .system) private let codeButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system) @@ -314,8 +299,6 @@ private final class TravelAlbumTaskCell: UITableViewCell { phoneLabel.numberOfLines = 1 phoneLabel.setContentCompressionResistancePriority(.required, for: .vertical) - shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) - shareButton.tintColor = AppColor.primary codeButton.setImage(UIImage(systemName: "qrcode"), for: .normal) codeButton.tintColor = .black uploadButton.setImage(UIImage(systemName: "camera.fill"), for: .normal) @@ -332,7 +315,6 @@ private final class TravelAlbumTaskCell: UITableViewCell { cardView.addSubview(nameLabel) cardView.addSubview(timeLabel) cardView.addSubview(phoneLabel) - cardView.addSubview(shareButton) cardView.addSubview(codeButton) cardView.addSubview(uploadButton) @@ -347,20 +329,15 @@ private final class TravelAlbumTaskCell: UITableViewCell { make.center.equalToSuperview() make.size.equalTo(42) } - shareButton.snp.makeConstraints { make in - make.top.equalToSuperview().offset(12) - make.trailing.equalTo(codeButton.snp.leading).offset(-8) - make.size.equalTo(26) - } codeButton.snp.makeConstraints { make in - make.top.equalTo(shareButton) + make.top.equalToSuperview().offset(12) make.trailing.equalToSuperview().offset(-10) make.size.equalTo(26) } nameLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.equalTo(coverView.snp.trailing).offset(12) - make.trailing.equalTo(shareButton.snp.leading).offset(-8) + make.trailing.equalTo(codeButton.snp.leading).offset(-8) } timeLabel.snp.makeConstraints { make in make.top.equalTo(nameLabel.snp.bottom).offset(7) @@ -381,7 +358,6 @@ private final class TravelAlbumTaskCell: UITableViewCell { } uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) - shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) codeButton.addTarget(self, action: #selector(codeTapped), for: .touchUpInside) } @@ -404,7 +380,6 @@ private final class TravelAlbumTaskCell: UITableViewCell { } @objc private func uploadTapped() { onShootUpload?() } - @objc private func shareTapped() { onShare?() } @objc private func codeTapped() { onAlbumCode?() } } diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index 2d8f8c8..ac9ed3e 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -40,10 +40,9 @@ final class WiredCameraTransferViewController: BaseViewController { private let refreshButton = UIButton(type: .system) private let helpLabel = UILabel() private let chipsStack = UIStackView() - private let retouchButton = UIButton(type: .system) - private let formatButton = UIButton(type: .system) - private let modeButton = UIButton(type: .system) - private let modeChevronView = UIImageView() + private let retouchButton = WiredTransferSettingChipButton() + private let formatButton = WiredTransferSettingChipButton() + private let modeButton = WiredTransferSettingChipButton(showsChevron: true) private let settingsStatsDivider = UIView() private let statsCard = UIStackView() private let firstStatsDivider = UIView() @@ -160,19 +159,10 @@ final class WiredCameraTransferViewController: BaseViewController { chipsStack.spacing = 6 chipsStack.distribution = .fillEqually [retouchButton, formatButton, modeButton].forEach { - configureChipButton($0) chipsStack.addArrangedSubview($0) } retouchButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false - modeButton.setConfigurationContentInsets( - NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 32) - ) - modeChevronView.image = UIImage(systemName: "chevron.down")? - .withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) - modeChevronView.tintColor = AppColor.textTertiary - modeChevronView.contentMode = .scaleAspectFit - modeChevronView.isUserInteractionEnabled = false settingsStatsDivider.backgroundColor = AppColor.border statsCard.axis = .horizontal @@ -269,7 +259,6 @@ final class WiredCameraTransferViewController: BaseViewController { view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(bottomButtonsStack) - modeButton.addSubview(modeChevronView) bottomButtonsStack.addArrangedSubview(batchButton) bottomButtonsStack.addArrangedSubview(specifyButton) bottomButtonsStack.addArrangedSubview(albumImportButton) @@ -318,11 +307,6 @@ final class WiredCameraTransferViewController: BaseViewController { make.leading.trailing.equalToSuperview().inset(14) make.height.equalTo(34) } - modeChevronView.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-10) - make.centerY.equalToSuperview() - make.size.equalTo(16) - } settingsStatsDivider.snp.makeConstraints { make in make.top.equalTo(chipsStack.snp.bottom).offset(12) make.leading.trailing.equalToSuperview() @@ -431,9 +415,9 @@ final class WiredCameraTransferViewController: BaseViewController { statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10) refreshButton.setTitle(viewModel.actionButtonText, for: .normal) - retouchButton.setTitle(viewModel.retouchOption, for: .normal) - formatButton.setTitle("JPG", for: .normal) - modeButton.setTitle(viewModel.transferModeOption, for: .normal) + retouchButton.apply(title: viewModel.retouchOption) + formatButton.apply(title: "JPG") + modeButton.apply(title: viewModel.transferModeOption) helpLabel.attributedText = helpText(viewModel.sonyMTPHint) rebuildStats() @@ -642,19 +626,6 @@ final class WiredCameraTransferViewController: BaseViewController { statsCard.bringSubviewToFront(secondStatsDivider) } - private func configureChipButton(_ button: UIButton) { - button.titleLabel?.font = .systemFont(ofSize: 11) - button.setTitleColor(AppColor.textTertiary, for: .normal) - button.backgroundColor = AppColor.pageBackground - button.layer.cornerRadius = 8 - button.layer.borderWidth = 1 - button.layer.borderColor = AppColor.border.cgColor - button.contentHorizontalAlignment = .leading - button.setConfigurationContentInsets( - NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) - ) - } - private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton { var config = UIButton.Configuration.plain() config.title = "\(count)" diff --git a/suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift b/suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift new file mode 100644 index 0000000..d4b40d4 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift @@ -0,0 +1,88 @@ +// +// WiredTransferSettingChipButton.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 有线传输页设置 chip 按钮,通过子视图展示标题与可选下拉箭头,不依赖 `UIButton` 原生 title 样式。 +final class WiredTransferSettingChipButton: UIButton { + private let titleLabelView = UILabel() + private let chevronView = UIImageView() + private var titleTrailingToSuperviewConstraint: Constraint? + private var titleTrailingToChevronConstraint: Constraint? + + /// 是否展示右侧下拉箭头。 + var showsChevron: Bool { + didSet { updateChevronLayout() } + } + + /// 当前展示的标题文案。 + private(set) var titleText: String = "" + + /// 创建设置 chip 按钮。 + /// - Parameter showsChevron: 是否展示下拉箭头,默认不展示。 + init(showsChevron: Bool = false) { + self.showsChevron = showsChevron + super.init(frame: .zero) + setupUI() + updateChevronLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新 chip 标题文案。 + func apply(title: String) { + titleText = title + titleLabelView.text = title + accessibilityLabel = title + } + + private func setupUI() { + backgroundColor = AppColor.pageBackground + layer.cornerRadius = 8 + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + + titleLabelView.font = .systemFont(ofSize: 11) + titleLabelView.textColor = AppColor.textTertiary + titleLabelView.lineBreakMode = .byTruncatingTail + titleLabelView.isUserInteractionEnabled = false + + chevronView.image = UIImage(systemName: "chevron.down")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) + chevronView.tintColor = AppColor.textTertiary + chevronView.contentMode = .scaleAspectFit + chevronView.isUserInteractionEnabled = false + + addSubview(titleLabelView) + addSubview(chevronView) + + titleLabelView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(10) + make.centerY.equalToSuperview() + titleTrailingToSuperviewConstraint = make.trailing.lessThanOrEqualToSuperview().offset(-10).constraint + titleTrailingToChevronConstraint = make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-4).constraint + } + chevronView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-10) + make.centerY.equalToSuperview() + make.size.equalTo(16) + } + } + + private func updateChevronLayout() { + chevronView.isHidden = !showsChevron + if showsChevron { + titleTrailingToSuperviewConstraint?.deactivate() + titleTrailingToChevronConstraint?.activate() + } else { + titleTrailingToChevronConstraint?.deactivate() + titleTrailingToSuperviewConstraint?.activate() + } + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 169b5bb..f6caf40 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -1187,7 +1187,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController { button.titleLabel?.adjustsFontSizeToFitWidth = true button.titleLabel?.minimumScaleFactor = 0.82 button.setConfigurationContentInsets( - NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) + NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) ) button.configuration?.imagePlacement = .leading button.configuration?.imagePadding = 4 diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index de7330a..64190e2 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -328,14 +328,7 @@ final class WildReportRiskMapViewController: BaseViewController { } private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView { - let container = UIStackView() - container.axis = .vertical - container.spacing = 6 - - let row = UIStackView() - row.axis = .horizontal - row.alignment = .top - row.spacing = 12 + let container = UIView() let iconWrap = UIView() iconWrap.backgroundColor = headerIconColor(for: clue) @@ -348,17 +341,15 @@ final class WildReportRiskMapViewController: BaseViewController { make.center.equalToSuperview() make.size.equalTo(20) } - iconWrap.snp.makeConstraints { make in - make.size.equalTo(44) - } - let textStack = UIStackView() - textStack.axis = .vertical - textStack.spacing = 6 - var reportMetaText: String? + let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue)) + badge.setContentHuggingPriority(.required, for: .horizontal) + badge.setContentCompressionResistancePriority(.required, for: .horizontal) + + let title: UILabel + var metaText: String? if isReportClue(clue) { - let title = WildReportUI.label("线索 ID:\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2) - textStack.addArrangedSubview(title) + title = WildReportUI.label("线索 ID:\(clue.id)", font: .systemFont(ofSize: 14, weight: .bold), color: AppColor.textPrimary, lines: 1) var metaParts: [String] = [] if let distance = clue.distanceText ?? clue.distance { metaParts.append("距离:距离您\(distance)") @@ -366,41 +357,58 @@ final class WildReportRiskMapViewController: BaseViewController { if let updatedAt = clue.updatedAt { metaParts.append("时间:\(updatedAt)") } - reportMetaText = metaParts.isEmpty ? nil : metaParts.joined(separator: " ") + metaText = metaParts.isEmpty ? nil : metaParts.joined(separator: " ") } else { - let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2) - textStack.addArrangedSubview(title) + title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2) if clue.type == .green { - let metaText = [clue.distance.map { "距蓝点 \($0)" }, clue.direction, clue.updatedAt].compactMap { $0 }.joined(separator: " ") - if !metaText.isEmpty { - textStack.addArrangedSubview(WildReportUI.label(metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary)) + let text = [clue.distance.map { "距蓝点 \($0)" }, clue.direction, clue.updatedAt].compactMap { $0 }.joined(separator: " ") + metaText = text.isEmpty ? nil : text + } + } + + container.addSubview(iconWrap) + container.addSubview(badge) + container.addSubview(title) + + iconWrap.snp.makeConstraints { make in + make.top.leading.equalToSuperview() + make.size.equalTo(44) + } + badge.snp.makeConstraints { make in + make.top.trailing.equalToSuperview() + } + title.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalTo(iconWrap.snp.trailing).offset(12) + make.trailing.lessThanOrEqualTo(badge.snp.leading).offset(-12) + } + + if let metaText { + let metaLabel = WildReportUI.label(metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0) + container.addSubview(metaLabel) + metaLabel.snp.makeConstraints { make in + make.top.equalTo(title.snp.bottom).offset(6) + make.leading.equalTo(title) + make.trailing.equalToSuperview() + make.bottom.equalToSuperview() + } + iconWrap.snp.makeConstraints { make in + make.bottom.lessThanOrEqualToSuperview() + } + } else { + [iconWrap, title, badge].forEach { view in + view.snp.makeConstraints { make in + make.bottom.lessThanOrEqualToSuperview() } } - } - - let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue)) - row.addArrangedSubview(iconWrap) - row.addArrangedSubview(textStack) - row.addArrangedSubview(badge) - textStack.setContentHuggingPriority(.defaultLow, for: .horizontal) - badge.setContentHuggingPriority(.required, for: .horizontal) - container.addArrangedSubview(row) - - if let reportMetaText { - let metaRow = UIStackView() - metaRow.axis = .horizontal - metaRow.spacing = 0 - let spacer = UIView() - spacer.snp.makeConstraints { make in - make.width.equalTo(56) + iconWrap.snp.makeConstraints { make in + make.bottom.equalToSuperview().priority(.high) + } + title.snp.makeConstraints { make in + make.bottom.equalToSuperview().priority(.high) } - let metaLabel = WildReportUI.label(reportMetaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary) - metaLabel.textAlignment = .right - metaLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - metaRow.addArrangedSubview(spacer) - metaRow.addArrangedSubview(metaLabel) - container.addArrangedSubview(metaRow) } + return container } diff --git a/suixinkanTests/AccountSwitchViewModelTests.swift b/suixinkanTests/AccountSwitchViewModelTests.swift index 7eca497..d4a78b4 100644 --- a/suixinkanTests/AccountSwitchViewModelTests.swift +++ b/suixinkanTests/AccountSwitchViewModelTests.swift @@ -53,8 +53,9 @@ final class AccountSwitchViewModelTests: XCTestCase { accountType: V9ScenicUser.accountTypeValue, businessUserId: 0, title: "景区A", - subtitle: "张三", + subtitle: "角色A", phone: "13800138000", + realName: "张三", avatar: "", scenicName: "景区A", storeId: nil, diff --git a/suixinkanTests/AuthModelsTests.swift b/suixinkanTests/AuthModelsTests.swift index 0235b69..1d9105d 100644 --- a/suixinkanTests/AuthModelsTests.swift +++ b/suixinkanTests/AuthModelsTests.swift @@ -29,6 +29,7 @@ final class AuthModelsTests: XCTestCase { "phone": "13800138000", "scenic_id": 10, "scenic_name": "示例景区", + "role_name": "摄影师", "is_current": false } ], @@ -47,6 +48,7 @@ final class AuthModelsTests: XCTestCase { "scenic_name": "示例景区", "store_id": 20, "store_name": "示例门店", + "role_name": "店长", "is_current": false } ] @@ -59,7 +61,7 @@ final class AuthModelsTests: XCTestCase { XCTAssertEqual(response.token, "person-temp-token") XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) - XCTAssertEqual(response.accounts.map(\.subtitle), ["scenic_operator", "store_admin"]) + XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"]) } func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { @@ -96,13 +98,15 @@ final class AuthModelsTests: XCTestCase { "phone": "13800138000", "scenic_id": 10, "scenic_name": "示例景区", + "role_name": "摄影师", "is_current": false } """.data(using: .utf8)! let user = try JSONDecoder().decode(V9ScenicUser.self, from: json) XCTAssertEqual(user.businessUserId, 101) - XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "scenic_operator") + XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "示例景区 · 摄影师") + XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(ssUserId: 101)) } @@ -122,13 +126,15 @@ final class AuthModelsTests: XCTestCase { "scenic_name": "示例景区", "store_id": 20, "store_name": "示例门店", + "role_name": "店长", "is_current": false } """.data(using: .utf8)! let user = try JSONDecoder().decode(V9StoreUser.self, from: json) XCTAssertEqual(user.businessUserId, 201) - XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "store_admin") + XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "示例景区 · 店长") + XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) } } diff --git a/suixinkanTests/HomeMenuIconFactoryTests.swift b/suixinkanTests/HomeMenuIconFactoryTests.swift new file mode 100644 index 0000000..44e7c02 --- /dev/null +++ b/suixinkanTests/HomeMenuIconFactoryTests.swift @@ -0,0 +1,44 @@ +// +// HomeMenuIconFactoryTests.swift +// suixinkanTests +// + +import UIKit +import XCTest +@testable import suixinkan + +/// 首页菜单图标加载测试。 +final class HomeMenuIconFactoryTests: XCTestCase { + + func testAssetIconTakesPrecedence() { + let image = HomeMenuIconFactory.image(named: "home_menu_space") + + XCTAssertNotNil(image) + XCTAssertNil(UIImage(systemName: "home_menu_space")) + } + + func testSystemSymbolMatchesSharedConfiguration() { + let expected = UIImage( + systemName: "ellipsis", + withConfiguration: HomeMenuIconFactory.symbolConfiguration + ) + let actual = HomeMenuIconFactory.image(named: "ellipsis") + + XCTAssertNotNil(actual) + XCTAssertEqual(actual?.size, expected?.size) + } + + func testSystemSymbolDiffersFromUnconfiguredDefault() { + let configured = HomeMenuIconFactory.image(named: "ellipsis") + let plain = UIImage(systemName: "ellipsis") + + XCTAssertNotNil(configured) + XCTAssertNotNil(plain) + XCTAssertNotEqual(configured?.size, plain?.size) + } + + func testComplexSystemSymbolResolves() { + XCTAssertNotNil(HomeMenuIconFactory.image(named: "dot.radiowaves.left.and.right")) + XCTAssertNotNil(HomeMenuIconFactory.image(named: "rectangle.stack.badge.plus")) + } +} diff --git a/suixinkanTests/WiredTransferSettingChipButtonTests.swift b/suixinkanTests/WiredTransferSettingChipButtonTests.swift new file mode 100644 index 0000000..e4ed1c0 --- /dev/null +++ b/suixinkanTests/WiredTransferSettingChipButtonTests.swift @@ -0,0 +1,29 @@ +// +// WiredTransferSettingChipButtonTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +final class WiredTransferSettingChipButtonTests: XCTestCase { + @MainActor + func testApplyTitleUpdatesAccessibilityLabel() { + let button = WiredTransferSettingChipButton() + button.apply(title: "AI修图") + XCTAssertEqual(button.titleText, "AI修图") + XCTAssertEqual(button.accessibilityLabel, "AI修图") + } + + @MainActor + func testShowsChevronDefaultsToFalse() { + let button = WiredTransferSettingChipButton() + XCTAssertFalse(button.showsChevron) + } + + @MainActor + func testInitWithShowsChevronTrue() { + let button = WiredTransferSettingChipButton(showsChevron: true) + XCTAssertTrue(button.showsChevron) + } +}