From 3bc017260fb2faa611f473d0ddb5f263456d3dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 7 Jul 2026 09:12:06 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=BB=BA=E8=AE=A2=E5=8D=95=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20UI=20=E5=B9=B6=E4=B8=A5=E6=A0=BC=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Android=20=E8=AE=A2=E5=8D=95=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suixinkan/Theme/Modules/OrderTokens.swift | 44 ++ suixinkan/UI/Common/AppStatusBadge.swift | 64 ++- .../UI/Orders/DepositOrderCardCell.swift | 144 +++--- suixinkan/UI/Orders/OrderCardCell.swift | 427 ++++++++++-------- suixinkan/UI/Orders/OrderFilterBarView.swift | 147 +++--- suixinkan/UI/Orders/OrderListView.swift | 78 +++- .../UI/Orders/OrdersViewController.swift | 16 +- .../Views/DepositOrderStatusBadgeView.swift | 30 ++ .../UI/Orders/Views/OrderActionButton.swift | 49 ++ .../UI/Orders/Views/OrderEditOptionView.swift | 131 ++++++ .../Orders/Views/OrderFilterPillControl.swift | 66 +++ .../Orders/Views/OrderGiftStepperView.swift | 61 +++ .../Views/OrderHistoricalPhotosView.swift | 109 +++++ .../UI/Orders/Views/OrderInfoRowView.swift | 46 ++ .../UI/Orders/Views/OrderPhoneRowView.swift | 101 +++++ .../UI/Orders/Views/OrderTypeChipView.swift | 37 ++ suixinkanTests/OrderListUITokensTests.swift | 64 +++ 17 files changed, 1235 insertions(+), 379 deletions(-) create mode 100644 suixinkan/Theme/Modules/OrderTokens.swift create mode 100644 suixinkan/UI/Orders/Views/DepositOrderStatusBadgeView.swift create mode 100644 suixinkan/UI/Orders/Views/OrderActionButton.swift create mode 100644 suixinkan/UI/Orders/Views/OrderEditOptionView.swift create mode 100644 suixinkan/UI/Orders/Views/OrderFilterPillControl.swift create mode 100644 suixinkan/UI/Orders/Views/OrderGiftStepperView.swift create mode 100644 suixinkan/UI/Orders/Views/OrderHistoricalPhotosView.swift create mode 100644 suixinkan/UI/Orders/Views/OrderInfoRowView.swift create mode 100644 suixinkan/UI/Orders/Views/OrderPhoneRowView.swift create mode 100644 suixinkan/UI/Orders/Views/OrderTypeChipView.swift create mode 100644 suixinkanTests/OrderListUITokensTests.swift diff --git a/suixinkan/Theme/Modules/OrderTokens.swift b/suixinkan/Theme/Modules/OrderTokens.swift new file mode 100644 index 0000000..448d0d4 --- /dev/null +++ b/suixinkan/Theme/Modules/OrderTokens.swift @@ -0,0 +1,44 @@ +// +// OrderTokens.swift +// suixinkan +// + +import CoreGraphics +import UIKit + +/// 订单模块视觉令牌,对齐 Android `OrderListScreen` / `DepositOrderListScreen` inline 色值。 +enum OrderTokens { + + // MARK: - Colors + + static let filterBackground = UIColor(hex: 0xF4F4F4) + static let searchPlaceholder = UIColor(hex: 0xB6BECA) + static let label563 = UIColor(hex: 0x4B5563) + static let timestamp = UIColor(hex: 0x6B7280) + static let orderNumber = UIColor.black + static let chipBackground = UIColor(hex: 0xEFF6FF) + static let phoneIconBackground = UIColor(hex: 0xEFF6FF) + static let giftBorder = UIColor(hex: 0xE5E7EB) + static let sourceBorder = UIColor(hex: 0xE6EAF0) + static let sourceBackground = UIColor(hex: 0xF9FAFC) + static let depositSuccess = UIColor(hex: 0x27AE60) + static let depositSuccessBackground = UIColor(hex: 0xE8F8F0) + static let depositRefunded = UIColor(hex: 0x6B7280) + static let depositRefundedBackground = UIColor(hex: 0xF3F4F6) + static let remarkDivider = UIColor(hex: 0xF4F4F4) + static let valueBlack = UIColor.black + + // MARK: - Layout + + static let searchHeight: CGFloat = 36 + static let filterPillHeight: CGFloat = 35 + static let actionButtonHeight: CGFloat = 48 + static let cardRadius: CGFloat = 12 + static let pillRadius: CGFloat = 4 + static let cardPadding: CGFloat = 16 + static let cardGap: CGFloat = 12 + static let filterGap: CGFloat = 8 + static let infoRowSpacing: CGFloat = 8 + static let dividerHeight: CGFloat = 0.5 + static let refundButtonWidthRatio: CGFloat = 0.3 +} diff --git a/suixinkan/UI/Common/AppStatusBadge.swift b/suixinkan/UI/Common/AppStatusBadge.swift index a723246..2d458eb 100644 --- a/suixinkan/UI/Common/AppStatusBadge.swift +++ b/suixinkan/UI/Common/AppStatusBadge.swift @@ -18,23 +18,19 @@ final class AppStatusBadge: UIView { case neutral } + /// 订单 badge 字号与内边距样式。 + enum OrderBadgeStyle { + case photographer + case deposit + } + private let label = UILabel() override init(frame: CGRect) { super.init(frame: frame) - AppFont.captionMedium.apply(to: label) label.textAlignment = .center addSubview(label) - label.snp.makeConstraints { make in - make.edges.equalToSuperview().inset( - UIEdgeInsets( - top: AppSpacing.xxs, - left: AppSpacing.xs, - bottom: AppSpacing.xxs, - right: AppSpacing.xs - ) - ) - } + applyOrderBadgeStyle(.photographer) layer.cornerRadius = AppRadius.xs clipsToBounds = true } @@ -44,6 +40,26 @@ final class AppStatusBadge: UIView { fatalError("init(coder:) has not been implemented") } + /// 设置订单 badge 样式(摄影师 14sp / 店铺 12sp)。 + func applyOrderBadgeStyle(_ style: OrderBadgeStyle) { + switch style { + case .photographer: + label.font = .app(.bodyMedium) + label.snp.remakeConstraints { make in + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: 0, left: AppSpacing.xxs, bottom: 0, right: AppSpacing.xxs) + ) + } + case .deposit: + label.font = .app(.caption) + label.snp.remakeConstraints { make in + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: 2, left: AppSpacing.xs, bottom: 2, right: AppSpacing.xs) + ) + } + } + } + /// 按语义色调与文案刷新展示。 func apply(tone: Tone, text: String) { label.text = text @@ -61,15 +77,16 @@ final class AppStatusBadge: UIView { label.textColor = AppColor.danger backgroundColor = AppColor.dangerBackground case .neutral: - label.textColor = AppColor.textTabInactive - backgroundColor = AppColor.inputBackground + label.textColor = OrderTokens.depositRefunded + backgroundColor = OrderTokens.depositRefundedBackground } } - /// 订单状态映射(对齐 Android OrderView)。 + /// 摄影师订单状态映射(对齐 Android `OrderView`)。 func applyOrderStatus(_ status: Int, text: String) { + applyOrderBadgeStyle(.photographer) switch status { - case 18, 20: + case 18: apply(tone: .info, text: text) case 30: apply(tone: .success, text: text) @@ -79,4 +96,21 @@ final class AppStatusBadge: UIView { apply(tone: .warning, text: text) } } + + /// 店铺订单状态映射(对齐 Android `DepositOrderCard`)。 + func applyDepositOrderStatus(_ status: Int, text: String) { + applyOrderBadgeStyle(.deposit) + switch status { + case 20: + apply(tone: .info, text: text) + case 30: + label.text = text + label.textColor = OrderTokens.depositSuccess + backgroundColor = OrderTokens.depositSuccessBackground + case 50: + apply(tone: .neutral, text: text) + default: + apply(tone: .warning, text: text) + } + } } diff --git a/suixinkan/UI/Orders/DepositOrderCardCell.swift b/suixinkan/UI/Orders/DepositOrderCardCell.swift index 22c4864..565ecdb 100644 --- a/suixinkan/UI/Orders/DepositOrderCardCell.swift +++ b/suixinkan/UI/Orders/DepositOrderCardCell.swift @@ -10,29 +10,35 @@ struct DepositOrderCardActions { var onRefund: (() -> Void)? var onPartialRefund: (() -> Void)? var onVerify: (() -> Void)? + var onCallPhone: (() -> Void)? var onTap: (() -> Void)? } -/// 店铺订单卡片,对齐 Android `DepositOrderListScreen`。 +/// 店铺订单卡片,严格对齐 Android `DepositOrderCard`。 final class DepositOrderCardCell: UITableViewCell { static let reuseIdentifier = "DepositOrderCardCell" private let cardView = UIView() - private let projectLabel = UILabel() - private let statusBadge = OrderStatusBadgeView() - private let amountLabel = UILabel() - private let timeLabel = UILabel() - private let uidLabel = UILabel() - private let phoneLabel = UILabel() - private let refundButton = UIButton(type: .system) - private let partialRefundButton = UIButton(type: .system) - private let verifyButton = UIButton(type: .system) + private let contentStack = UIStackView() + private let headerRow = UIStackView() + private let orderNumberLabel = UILabel() + private let statusBadge = DepositOrderStatusBadgeView() + private let createdAtLabel = UILabel() + private let typeChip = OrderTypeChipView() + private let infoStack = UIStackView() + private let phoneRow = OrderPhoneRowView(style: .deposit) + private let actionRow = UIStackView() + private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight) + private let partialRefundButton = OrderActionButton(title: "部分退款", style: .dangerFilled) + private let verifyButton = OrderActionButton(title: "核销", style: .primaryFilled) + private var actions = DepositOrderCardActions() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear + contentView.backgroundColor = .clear setupUI() } @@ -43,98 +49,76 @@ final class DepositOrderCardCell: UITableViewCell { func configure(item: StoreOrderItem, actions: DepositOrderCardActions) { self.actions = actions - projectLabel.text = item.projectName.isEmpty ? item.orderTypeLabel : item.projectName + orderNumberLabel.text = "订单号: \(item.orderNumber)" statusBadge.apply(status: item.orderStatus, text: item.orderStatusName) - amountLabel.text = "¥\(item.actualPayAmount)" - timeLabel.text = item.createdAt - uidLabel.text = "UID:\(item.userId)" - phoneLabel.text = OrderPhoneMask.mask(item.phone) + createdAtLabel.text = item.createdAt + typeChip.apply(text: item.orderTypeLabel) + + infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + addInfoRow(title: "项目名称", value: item.projectName) + addInfoRow(title: "订单金额", value: "¥ \(item.actualPayAmount)", primary: true) + addInfoRow(title: "付款时间", value: item.payTime.isEmpty ? "--" : item.payTime) + addInfoRow(title: "完成时间", value: item.completeTime.isEmpty ? "--" : item.completeTime) + addInfoRow(title: "用户 UID", value: String(item.userId)) + infoStack.addArrangedSubview(phoneRow) + phoneRow.apply(phone: item.phone) + phoneRow.onTap = { [weak self] in self?.actions.onCallPhone?() } let showVerify = item.orderType == StoreOrderType.offlineProduct && item.orderStatus == 20 verifyButton.isHidden = !showVerify - refundButton.isHidden = item.orderStatus != 20 && item.orderStatus != 30 - partialRefundButton.isHidden = item.orderStatus != 20 && item.orderStatus != 30 } private func setupUI() { - cardView.backgroundColor = .white - cardView.layer.cornerRadius = 12 + cardView.backgroundColor = AppColor.cardBackground + cardView.layer.cornerRadius = OrderTokens.cardRadius let tap = UITapGestureRecognizer(target: self, action: #selector(cardTapped)) cardView.addGestureRecognizer(tap) - projectLabel.font = .systemFont(ofSize: 16, weight: .medium) - amountLabel.font = .systemFont(ofSize: 16, weight: .semibold) - amountLabel.textColor = AppColor.primary - [timeLabel, uidLabel, phoneLabel].forEach { - $0.font = .systemFont(ofSize: 13) - $0.textColor = UIColor(hex: 0x6B7280) - } + orderNumberLabel.font = .app(.body) + orderNumberLabel.textColor = OrderTokens.orderNumber + createdAtLabel.font = .app(.caption) + createdAtLabel.textColor = OrderTokens.timestamp - configureActionButton(refundButton, title: "退款", color: UIColor(hex: 0xEF4444), bg: UIColor(hex: 0xFFE7E7)) - configureActionButton(partialRefundButton, title: "部分退款", color: AppColor.primary, bg: UIColor(hex: 0xEFF6FF)) - configureActionButton(verifyButton, title: "线下核销", color: .white, bg: UIColor(hex: 0x0073FF)) + headerRow.axis = .horizontal + headerRow.alignment = .center + headerRow.distribution = .equalSpacing + headerRow.addArrangedSubview(orderNumberLabel) + headerRow.addArrangedSubview(statusBadge) + + infoStack.axis = .vertical + infoStack.spacing = AppSpacing.xxs + + actionRow.axis = .horizontal + actionRow.spacing = OrderTokens.cardGap + actionRow.distribution = .fillEqually + actionRow.addArrangedSubview(refundButton) + actionRow.addArrangedSubview(partialRefundButton) refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside) partialRefundButton.addTarget(self, action: #selector(partialRefundTapped), for: .touchUpInside) verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside) - contentView.addSubview(cardView) - [projectLabel, statusBadge, amountLabel, timeLabel, uidLabel, phoneLabel, refundButton, partialRefundButton, verifyButton].forEach { - cardView.addSubview($0) + contentStack.axis = .vertical + contentStack.spacing = OrderTokens.cardGap + [headerRow, createdAtLabel, typeChip, infoStack, actionRow, verifyButton].forEach { + contentStack.addArrangedSubview($0) } + contentView.addSubview(cardView) + cardView.addSubview(contentStack) cardView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 12, right: 16)) + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: OrderTokens.cardGap, right: AppSpacing.md) + ) } - projectLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview().inset(16) - } - statusBadge.snp.makeConstraints { make in - make.centerY.equalTo(projectLabel) - make.trailing.equalToSuperview().inset(16) - } - amountLabel.snp.makeConstraints { make in - make.top.equalTo(projectLabel.snp.bottom).offset(8) - make.leading.equalToSuperview().inset(16) - } - timeLabel.snp.makeConstraints { make in - make.top.equalTo(amountLabel.snp.bottom).offset(4) - make.leading.equalToSuperview().inset(16) - } - uidLabel.snp.makeConstraints { make in - make.top.equalTo(timeLabel.snp.bottom).offset(4) - make.leading.equalToSuperview().inset(16) - } - phoneLabel.snp.makeConstraints { make in - make.top.equalTo(uidLabel.snp.bottom).offset(4) - make.leading.equalToSuperview().inset(16) - } - refundButton.snp.makeConstraints { make in - make.top.equalTo(phoneLabel.snp.bottom).offset(12) - make.leading.equalToSuperview().inset(16) - make.height.equalTo(40) - make.width.equalTo(88) - make.bottom.equalToSuperview().inset(16) - } - partialRefundButton.snp.makeConstraints { make in - make.centerY.equalTo(refundButton) - make.leading.equalTo(refundButton.snp.trailing).offset(8) - make.height.equalTo(40) - make.width.equalTo(100) - } - verifyButton.snp.makeConstraints { make in - make.centerY.equalTo(refundButton) - make.trailing.equalToSuperview().inset(16) - make.height.equalTo(40) - make.width.equalTo(100) + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(OrderTokens.cardPadding) } } - private func configureActionButton(_ button: UIButton, title: String, color: UIColor, bg: UIColor) { - button.setTitle(title, for: .normal) - button.setTitleColor(color, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 14) - button.backgroundColor = bg - button.layer.cornerRadius = 8 + private func addInfoRow(title: String, value: String, primary: Bool = false) { + let row = OrderInfoRowView() + row.apply(title: title, value: value, usesPrimaryValue: primary) + infoStack.addArrangedSubview(row) } @objc private func refundTapped() { actions.onRefund?() } diff --git a/suixinkan/UI/Orders/OrderCardCell.swift b/suixinkan/UI/Orders/OrderCardCell.swift index c0429b9..83cca19 100644 --- a/suixinkan/UI/Orders/OrderCardCell.swift +++ b/suixinkan/UI/Orders/OrderCardCell.swift @@ -22,27 +22,43 @@ struct OrderCardActions { var onOrderSourceTap: (() -> Void)? } -/// 摄影师订单卡片,对齐 Android `OrderView`。 +/// 摄影师订单卡片,严格对齐 Android `OrderView`。 final class OrderCardCell: UITableViewCell { static let reuseIdentifier = "OrderCardCell" private let cardView = UIView() + private let contentStack = UIStackView() + + private let headerRow = UIStackView() private let orderNumberLabel = UILabel() private let statusBadge = OrderStatusBadgeView() private let createdAtLabel = UILabel() - private let typeChip = UILabel() - private let actionStack = UIStackView() - private let refundButton = UIButton(type: .system) - private let verifyButton = UIButton(type: .system) - private let uploadTaskButton = UIButton(type: .system) - private let infoStack = UIStackView() - private let cancelButton = UIButton(type: .system) + private let typeChip = OrderTypeChipView() + private let orderSourceContainer = UIView() private let orderSourceButton = UIButton(type: .system) - private let historicalButton = UIButton(type: .system) - private let refinedStack = UIStackView() - private let refinedNeedButton = UIButton(type: .system) - private let refinedNoButton = UIButton(type: .system) + private let actionRow = UIStackView() + private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight) + private let verifyButton = OrderActionButton(title: "核销订单", style: .primaryFilled) + private let uploadTaskButton = OrderActionButton(title: "上传任务", style: .primaryLight) + private let editOptionView = OrderEditOptionView() + private let refundAmountRow = OrderInfoRowView() + private let detailStack = UIStackView() + private let photoCountRow = UIStackView() + private let photoCountLabel = UILabel() + private let photoValueLabel = UILabel() + private let photoGiftStepper = OrderGiftStepperView() + private let videoCountRow = UIStackView() + private let videoCountLabel = UILabel() + private let videoValueLabel = UILabel() + private let videoGiftStepper = OrderGiftStepperView() + private let remarkSection = UIStackView() + private let remarkDivider = UIView() + private let remarkTitleLabel = UILabel() + private let remarkBodyLabel = UILabel() + private let historicalPhotosView = OrderHistoricalPhotosView() + private let cancelButton = OrderActionButton(title: "取消订单", style: .primaryFilled) + private var refundWidthConstraint: Constraint? private var actions = OrderCardActions() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -58,249 +74,266 @@ final class OrderCardCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func configure(order: OrderEntity, hideWriteActions: Bool, actions: OrderCardActions, showOrderSource: Bool, sourceText: String?) { + func configure( + order: OrderEntity, + hideWriteActions: Bool, + actions: OrderCardActions, + showOrderSource: Bool, + sourceText: String? + ) { self.actions = actions orderNumberLabel.text = "订单号:\(order.orderNumber)" statusBadge.apply(status: order.orderStatus, text: order.orderStatusName) createdAtLabel.text = order.createdAt.isEmpty ? "--" : order.createdAt - typeChip.text = order.orderTypeLabel - typeChip.isHidden = order.orderTypeLabel.isEmpty + typeChip.apply(text: order.orderTypeLabel) let showRefund = !hideWriteActions && (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19 let showVerify = !hideWriteActions && ( (order.photoTravel?.needCheckIn == true && order.orderStatus != 30) || order.orderType == 19 ) + actionRow.isHidden = !showRefund && !showVerify refundButton.isHidden = !showRefund verifyButton.isHidden = !showVerify - actionStack.isHidden = !showRefund && !showVerify + updateRefundWidth(showVerify: showVerify && showRefund) + uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19 - orderSourceButton.isHidden = !showOrderSource + orderSourceContainer.isHidden = !showOrderSource orderSourceButton.setTitle(sourceText ?? "选择订单来源", for: .normal) - let showRefined = order.orderStatus == 18 || order.orderStatus == 30 - refinedStack.isHidden = !showRefined - updateRefinedButtons(isRefined: order.isRefined, readOnly: hideWriteActions) - - cancelButton.isHidden = hideWriteActions || order.photoTravel?.canCancelOrder != true - - infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - addInfoRow(title: "付款金额", value: "¥\(order.actualPayAmount)") - if !order.payTime.isEmpty { addInfoRow(title: "付款时间", value: order.payTime) } - if !order.completeTime.isEmpty { addInfoRow(title: "完成时间", value: order.completeTime) } - if !order.payTypeName.isEmpty { addInfoRow(title: "付款方式", value: order.payTypeName) } - addInfoRow(title: "UID", value: String(order.userId)) - addPhoneRow(phone: order.phone, hidden: hideWriteActions) - if let travel = order.photoTravel, !travel.checkInTime.isEmpty { - addInfoRow(title: "核销时间", value: travel.checkInTime) + let showEdit = (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19 + editOptionView.isHidden = !showEdit + if showEdit { + editOptionView.apply(isRefined: order.isRefined, readOnly: hideWriteActions) } - if let travel = order.photoTravel { - addInfoRow(title: "修图张数", value: "\(travel.orderPhotoNum)") - addInfoRow(title: "视频", value: "\(travel.orderVideoNum)") + + let showRefundAmount = order.orderStatus == 50 + && !order.actualRefundAmount.isEmpty + && order.actualRefundAmount != "0" + refundAmountRow.isHidden = !showRefundAmount + if showRefundAmount { + refundAmountRow.apply(title: "退款金额", value: "¥\(order.actualRefundAmount)", usesPrimaryValue: true) } + + rebuildDetailRows(order: order, hideWriteActions: hideWriteActions) + configureTravelRows(order: order, hideWriteActions: hideWriteActions) + if let remark = order.remark, !remark.isEmpty { - addInfoRow(title: "备注", value: remark) + remarkSection.isHidden = false + remarkBodyLabel.text = remark + } else { + remarkSection.isHidden = true } let materials = order.multiTravel?.materialList ?? [] - historicalButton.isHidden = materials.isEmpty + historicalPhotosView.isHidden = materials.isEmpty + if !materials.isEmpty { + historicalPhotosView.apply(materials: materials) + } + + cancelButton.isHidden = hideWriteActions || order.photoTravel?.canCancelOrder != true } private func setupUI() { cardView.backgroundColor = AppColor.cardBackground - cardView.layer.cornerRadius = AppRadius.lg + cardView.layer.cornerRadius = OrderTokens.cardRadius orderNumberLabel.font = .app(.body) - orderNumberLabel.textColor = AppColor.textPrimary + orderNumberLabel.textColor = OrderTokens.orderNumber createdAtLabel.font = .app(.caption) - createdAtLabel.textColor = AppColor.textSecondary - typeChip.font = .app(.caption) - typeChip.textColor = AppColor.primary - typeChip.backgroundColor = AppColor.primaryLight - typeChip.layer.cornerRadius = AppRadius.xs - typeChip.clipsToBounds = true - typeChip.textAlignment = .center + createdAtLabel.textColor = OrderTokens.timestamp - configureActionButton(refundButton, title: "退款", color: AppColor.danger, bg: AppColor.dangerBackground) - configureActionButton(verifyButton, title: "核销订单", color: .white, bg: AppColor.primary) - refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside) - verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside) - - uploadTaskButton.setTitle("上传任务", for: .normal) - uploadTaskButton.setTitleColor(AppColor.primary, for: .normal) - uploadTaskButton.titleLabel?.font = .app(.subtitle) - uploadTaskButton.backgroundColor = AppColor.primaryLight - uploadTaskButton.layer.cornerRadius = AppRadius.lg - uploadTaskButton.addTarget(self, action: #selector(uploadTaskTapped), for: .touchUpInside) - - actionStack.axis = .horizontal - actionStack.spacing = AppSpacing.sm - actionStack.distribution = .fillEqually - actionStack.addArrangedSubview(refundButton) - actionStack.addArrangedSubview(verifyButton) - - infoStack.axis = .vertical - infoStack.spacing = 6 - - cancelButton.setTitle("取消订单", for: .normal) - cancelButton.setTitleColor(AppColor.danger, for: .normal) - cancelButton.titleLabel?.font = .app(.body) - cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + headerRow.axis = .horizontal + headerRow.alignment = .center + headerRow.distribution = .equalSpacing + headerRow.addArrangedSubview(orderNumberLabel) + headerRow.addArrangedSubview(statusBadge) + orderSourceContainer.backgroundColor = OrderTokens.sourceBackground + orderSourceContainer.layer.cornerRadius = AppRadius.sm + orderSourceContainer.layer.borderWidth = 1 + orderSourceContainer.layer.borderColor = OrderTokens.sourceBorder.cgColor orderSourceButton.setTitleColor(AppColor.primary, for: .normal) orderSourceButton.titleLabel?.font = .app(.body) orderSourceButton.contentHorizontalAlignment = .leading orderSourceButton.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside) - - historicalButton.setTitle("历史拍摄", for: .normal) - historicalButton.setTitleColor(AppColor.primary, for: .normal) - historicalButton.titleLabel?.font = .app(.body) - historicalButton.addTarget(self, action: #selector(historicalTapped), for: .touchUpInside) - - refinedStack.axis = .horizontal - refinedStack.spacing = AppSpacing.xs - refinedNeedButton.setTitle("需要精修", for: .normal) - refinedNoButton.setTitle("无需精修", for: .normal) - [refinedNeedButton, refinedNoButton].forEach { - $0.titleLabel?.font = .app(.body) - $0.layer.cornerRadius = AppRadius.sm - $0.layer.borderWidth = 1 + orderSourceContainer.addSubview(orderSourceButton) + orderSourceButton.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.sm) } - refinedNeedButton.addTarget(self, action: #selector(refinedNeedTapped), for: .touchUpInside) - refinedNoButton.addTarget(self, action: #selector(refinedNoTapped), for: .touchUpInside) - refinedStack.addArrangedSubview(refinedNeedButton) - refinedStack.addArrangedSubview(refinedNoButton) + + actionRow.axis = .horizontal + actionRow.spacing = OrderTokens.cardGap + actionRow.alignment = .fill + actionRow.addArrangedSubview(refundButton) + actionRow.addArrangedSubview(verifyButton) + refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside) + verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside) + + uploadTaskButton.addTarget(self, action: #selector(uploadTaskTapped), for: .touchUpInside) + + editOptionView.onSelectionChange = { [weak self] value in + self?.actions.onRefinedChanged?(value) + } + + detailStack.axis = .vertical + detailStack.spacing = OrderTokens.infoRowSpacing + + configureTravelRow(photoCountRow, titleLabel: photoCountLabel, valueLabel: photoValueLabel, title: "修图张数", stepper: photoGiftStepper) + configureTravelRow(videoCountRow, titleLabel: videoCountLabel, valueLabel: videoValueLabel, title: "视频剪辑", stepper: videoGiftStepper) + photoGiftStepper.onMinusTap = { [weak self] in self?.actions.onSendGift?() } + photoGiftStepper.onPlusTap = { [weak self] in self?.actions.onSendGift?() } + videoGiftStepper.onMinusTap = { [weak self] in self?.actions.onSendGift?() } + videoGiftStepper.onPlusTap = { [weak self] in self?.actions.onSendGift?() } + + remarkDivider.backgroundColor = OrderTokens.remarkDivider + remarkTitleLabel.text = "备注信息:" + remarkTitleLabel.font = .app(.body) + remarkTitleLabel.textColor = OrderTokens.label563 + remarkBodyLabel.font = .app(.body) + remarkBodyLabel.textColor = OrderTokens.label563 + remarkBodyLabel.numberOfLines = 0 + remarkSection.axis = .vertical + remarkSection.spacing = 6 + remarkSection.addArrangedSubview(remarkDivider) + remarkSection.addArrangedSubview(remarkTitleLabel) + remarkSection.addArrangedSubview(remarkBodyLabel) + remarkDivider.snp.makeConstraints { make in + make.height.equalTo(1) + } + + historicalPhotosView.onTap = { [weak self] in self?.actions.onHistoricalPhoto?() } + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + + contentStack.axis = .vertical + contentStack.spacing = OrderTokens.cardGap + [ + headerRow, + createdAtLabel, + typeChip, + orderSourceContainer, + actionRow, + uploadTaskButton, + editOptionView, + refundAmountRow, + detailStack, + photoCountRow, + videoCountRow, + remarkSection, + historicalPhotosView, + cancelButton, + ].forEach { contentStack.addArrangedSubview($0) } contentView.addSubview(cardView) - cardView.addSubview(orderNumberLabel) - cardView.addSubview(statusBadge) - cardView.addSubview(createdAtLabel) - cardView.addSubview(typeChip) - cardView.addSubview(orderSourceButton) - cardView.addSubview(actionStack) - cardView.addSubview(uploadTaskButton) - cardView.addSubview(refinedStack) - cardView.addSubview(infoStack) - cardView.addSubview(historicalButton) - cardView.addSubview(cancelButton) - + cardView.addSubview(contentStack) cardView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)) + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: OrderTokens.cardGap, right: AppSpacing.md) + ) } - orderNumberLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview().inset(16) - } - statusBadge.snp.makeConstraints { make in - make.centerY.equalTo(orderNumberLabel) - make.trailing.equalToSuperview().inset(16) - } - createdAtLabel.snp.makeConstraints { make in - make.top.equalTo(orderNumberLabel.snp.bottom).offset(4) - make.leading.equalToSuperview().inset(16) - } - typeChip.snp.makeConstraints { make in - make.top.equalTo(createdAtLabel.snp.bottom).offset(4) - make.leading.equalToSuperview().inset(16) - make.height.equalTo(20) - make.width.greaterThanOrEqualTo(40) - } - orderSourceButton.snp.makeConstraints { make in - make.top.equalTo(typeChip.snp.bottom).offset(8) - make.leading.trailing.equalToSuperview().inset(16) - } - actionStack.snp.makeConstraints { make in - make.top.equalTo(orderSourceButton.snp.bottom).offset(12) - make.leading.trailing.equalToSuperview().inset(16) - make.height.equalTo(48) - } - uploadTaskButton.snp.makeConstraints { make in - make.top.equalTo(actionStack.snp.bottom).offset(12) - make.leading.trailing.equalToSuperview().inset(16) - make.height.equalTo(48) - } - refinedStack.snp.makeConstraints { make in - make.top.equalTo(uploadTaskButton.snp.bottom).offset(12) - make.leading.equalToSuperview().inset(16) - } - infoStack.snp.makeConstraints { make in - make.top.equalTo(refinedStack.snp.bottom).offset(12) - make.leading.trailing.equalToSuperview().inset(16) - } - historicalButton.snp.makeConstraints { make in - make.top.equalTo(infoStack.snp.bottom).offset(8) - make.leading.equalToSuperview().inset(16) - } - cancelButton.snp.makeConstraints { make in - make.top.equalTo(historicalButton.snp.bottom).offset(12) - make.leading.equalToSuperview().inset(16) - make.bottom.equalToSuperview().inset(16) + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(OrderTokens.cardPadding) } } - private func configureActionButton(_ button: UIButton, title: String, color: UIColor, bg: UIColor) { - button.setTitle(title, for: .normal) - button.setTitleColor(color, for: .normal) - button.titleLabel?.font = .app(.subtitle) - button.backgroundColor = bg - button.layer.cornerRadius = AppRadius.sm - } - - private func addInfoRow(title: String, value: String) { - let row = UIStackView() + private func configureTravelRow( + _ row: UIStackView, + titleLabel: UILabel, + valueLabel: UILabel, + title: String, + stepper: OrderGiftStepperView + ) { row.axis = .horizontal - row.distribution = .fillEqually - let titleLabel = UILabel() + row.alignment = .center + row.distribution = .equalSpacing titleLabel.text = title titleLabel.font = .app(.body) - titleLabel.textColor = AppColor.textSecondary - let valueLabel = UILabel() - valueLabel.text = value - valueLabel.font = .app(.body) - valueLabel.textColor = AppColor.textPrimary - valueLabel.textAlignment = .right + titleLabel.textColor = OrderTokens.label563 + valueLabel.font = .systemFont(ofSize: 14, weight: .medium) + valueLabel.textColor = OrderTokens.valueBlack row.addArrangedSubview(titleLabel) + row.addArrangedSubview(stepper) row.addArrangedSubview(valueLabel) - infoStack.addArrangedSubview(row) + valueLabel.isHidden = true } - private func addPhoneRow(phone: String, hidden: Bool) { - let row = UIStackView() - row.axis = .horizontal - row.distribution = .fillEqually - let titleLabel = UILabel() - titleLabel.text = "手机号" - titleLabel.font = .app(.body) - titleLabel.textColor = AppColor.textSecondary - let valueButton = UIButton(type: .system) - valueButton.setTitle(OrderPhoneMask.mask(phone), for: .normal) - valueButton.setTitleColor(AppColor.primary, for: .normal) - valueButton.titleLabel?.font = .app(.body) - valueButton.contentHorizontalAlignment = .trailing - valueButton.isHidden = hidden || phone.isEmpty - valueButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) - row.addArrangedSubview(titleLabel) - row.addArrangedSubview(valueButton) - infoStack.addArrangedSubview(row) + private func updateRefundWidth(showVerify: Bool) { + refundWidthConstraint?.deactivate() + if showVerify { + refundButton.snp.makeConstraints { make in + refundWidthConstraint = make.width.equalTo(actionRow.snp.width).multipliedBy(OrderTokens.refundButtonWidthRatio).constraint + } + } } - private func updateRefinedButtons(isRefined: Int, readOnly: Bool) { - let selectedBorder = AppColor.primary.cgColor - let normalBorder = AppColor.border.cgColor - refinedNeedButton.layer.borderColor = isRefined == 1 ? selectedBorder : normalBorder - refinedNoButton.layer.borderColor = isRefined == 2 ? selectedBorder : normalBorder - refinedNeedButton.isEnabled = !readOnly - refinedNoButton.isEnabled = !readOnly + private func rebuildDetailRows(order: OrderEntity, hideWriteActions: Bool) { + detailStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + addDetailRow(title: "付款金额", value: "¥\(order.actualPayAmount)", primary: true) + addDetailRow(title: "付款时间", value: resolvedPayTime(order)) + addDetailRow(title: "完成时间", value: order.completeTime.isEmpty ? "--" : order.completeTime) + if !order.payTypeName.isEmpty { + addDetailRow(title: "付款方式", value: order.payTypeName) + } + addDetailRow(title: "UID", value: String(order.userId)) + + let phoneRow = OrderPhoneRowView(style: .photographer) + phoneRow.apply(phone: order.phone, hidden: hideWriteActions) + phoneRow.onTap = { [weak self] in self?.actions.onCallPhone?() } + detailStack.addArrangedSubview(phoneRow) + + if !order.projectName.isEmpty { + addDetailRow(title: "关联项目", value: order.projectName) + } + if let travel = order.photoTravel, !travel.checkInTime.isEmpty { + addDetailRow(title: "核销时间", value: travel.checkInTime) + } + } + + private func configureTravelRows(order: OrderEntity, hideWriteActions: Bool) { + let showTravel = order.photoTravel != nil && order.orderType != 19 && order.orderType != 14 + photoCountRow.isHidden = !showTravel + videoCountRow.isHidden = !showTravel + guard showTravel, let travel = order.photoTravel else { return } + + let canGift = travel.canGiftRetouch && !hideWriteActions + let photoText = "\(travel.orderPhotoNum + travel.retouchGiftPhotoNum) 张" + let videoText = "\(travel.orderVideoNum + travel.retouchGiftVideoNum) 条" + + photoGiftStepper.isHidden = !canGift + videoGiftStepper.isHidden = !canGift + photoValueLabel.isHidden = canGift + videoValueLabel.isHidden = canGift + + if canGift { + photoGiftStepper.apply(text: photoText) + videoGiftStepper.apply(text: videoText) + } else { + photoValueLabel.text = photoText + videoValueLabel.text = videoText + } + } + + private func addDetailRow(title: String, value: String, primary: Bool = false) { + let row = OrderInfoRowView() + row.apply(title: title, value: value, usesPrimaryValue: primary) + detailStack.addArrangedSubview(row) + } + + private func resolvedPayTime(_ order: OrderEntity) -> String { + let payTime = order.payTime + if payTime.isEmpty { return order.depositPayTime.isEmpty ? "--" : order.depositPayTime } + if payTime.hasPrefix("1970") || payTime == "0" { + return order.depositPayTime.isEmpty ? "--" : order.depositPayTime + } + return payTime } @objc private func refundTapped() { actions.onRefund?() } @objc private func verifyTapped() { actions.onVerify?() } @objc private func uploadTaskTapped() { actions.onUploadTask?() } @objc private func cancelTapped() { actions.onCancel?() } - @objc private func callTapped() { actions.onCallPhone?() } @objc private func orderSourceTapped() { actions.onOrderSourceTap?() } - @objc private func historicalTapped() { actions.onHistoricalPhoto?() } - @objc private func refinedNeedTapped() { actions.onRefinedChanged?(1) } - @objc private func refinedNoTapped() { actions.onRefinedChanged?(2) } } enum OrderPhoneMask { diff --git a/suixinkan/UI/Orders/OrderFilterBarView.swift b/suixinkan/UI/Orders/OrderFilterBarView.swift index 637e301..42ae688 100644 --- a/suixinkan/UI/Orders/OrderFilterBarView.swift +++ b/suixinkan/UI/Orders/OrderFilterBarView.swift @@ -6,7 +6,7 @@ import SnapKit import UIKit -/// 订单筛选栏:搜索 + 状态下拉 + 日期筛选。 +/// 订单筛选栏:搜索 + 状态下拉 + 日期筛选,对齐 Android Header。 final class OrderFilterBarView: UIView { var onSearchChanged: ((String) -> Void)? @@ -14,16 +14,18 @@ final class OrderFilterBarView: UIView { var onFilterSelected: ((Int, Int) -> Void)? var onDateRangeTap: (() -> Void)? + private let searchContainer = UIView() + private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass")) private let searchField = UITextField() - private let filterButton = UIButton(type: .system) - private let dateButton = UIButton(type: .system) + private let divider = UIView() + private let filterPill = OrderFilterPillControl() + private let datePill = OrderFilterPillControl() private var isStoreMode = false - private var currentFilterLabel = "全部" override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .white + backgroundColor = AppColor.cardBackground setupUI() } @@ -34,95 +36,102 @@ final class OrderFilterBarView: UIView { func configure(isStoreMode: Bool, filterLabel: String, dateRangeText: String, searchText: String) { self.isStoreMode = isStoreMode - currentFilterLabel = filterLabel searchField.placeholder = isStoreMode ? "输入游客手机号" : "输入手机号搜索" + searchField.attributedPlaceholder = NSAttributedString( + string: searchField.placeholder ?? "", + attributes: [.foregroundColor: OrderTokens.searchPlaceholder] + ) searchField.text = searchText - filterButton.setTitle("\(filterLabel) ▾", for: .normal) - dateButton.setTitle(dateRangeText, for: .normal) + filterPill.pillTitle = filterLabel + datePill.pillTitle = dateRangeText + rebuildFilterMenu(selectedLabel: filterLabel) } private func setupUI() { - searchField.font = .systemFont(ofSize: 14) + searchContainer.backgroundColor = OrderTokens.filterBackground + searchContainer.layer.cornerRadius = OrderTokens.pillRadius + searchContainer.clipsToBounds = true + + searchIconView.tintColor = .gray + searchIconView.contentMode = .scaleAspectFit + + searchField.font = .app(.body) + searchField.textColor = AppColor.textPrimary searchField.keyboardType = .phonePad searchField.returnKeyType = .search - searchField.backgroundColor = UIColor(hex: 0xF4F4F4) - searchField.layer.cornerRadius = 4 - searchField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 12)) - searchField.leftViewMode = .always - searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged) + searchField.borderStyle = .none + searchField.backgroundColor = .clear searchField.delegate = self + searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged) - filterButton.setTitleColor(AppColor.text333, for: .normal) - filterButton.titleLabel?.font = .systemFont(ofSize: 14) - filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) + divider.backgroundColor = AppColor.border - dateButton.setTitleColor(AppColor.primary, for: .normal) - dateButton.titleLabel?.font = .systemFont(ofSize: 14) - dateButton.contentHorizontalAlignment = .trailing - dateButton.addTarget(self, action: #selector(dateTapped), for: .touchUpInside) + filterPill.showsMenuAsPrimaryAction = true - addSubview(searchField) - addSubview(filterButton) - addSubview(dateButton) + datePill.setTrailingSystemImage("calendar") + datePill.addTarget(self, action: #selector(dateTapped), for: .touchUpInside) + addSubview(searchContainer) + searchContainer.addSubview(searchIconView) + searchContainer.addSubview(searchField) + addSubview(divider) + addSubview(filterPill) + addSubview(datePill) + + searchContainer.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(OrderTokens.searchHeight) + } + searchIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.width.height.equalTo(18) + } searchField.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview().inset(16) - make.height.equalTo(36) + make.leading.equalTo(searchIconView.snp.trailing).offset(10) + make.trailing.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalToSuperview() } - filterButton.snp.makeConstraints { make in - make.top.equalTo(searchField.snp.bottom).offset(12) - make.leading.equalToSuperview().inset(16) - make.bottom.equalToSuperview().inset(12) + divider.snp.makeConstraints { make in + make.top.equalTo(searchContainer.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview() + make.height.equalTo(OrderTokens.dividerHeight) } - dateButton.snp.makeConstraints { make in - make.centerY.equalTo(filterButton) - make.trailing.equalToSuperview().inset(16) + filterPill.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom).offset(AppSpacing.md) + make.leading.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalToSuperview().inset(AppSpacing.md) } + datePill.snp.makeConstraints { make in + make.top.bottom.width.equalTo(filterPill) + make.leading.equalTo(filterPill.snp.trailing).offset(OrderTokens.filterGap) + make.trailing.equalToSuperview().inset(AppSpacing.md) + } + } + + private func rebuildFilterMenu(selectedLabel: String) { + let items: [(String, Int, Int)] = isStoreMode + ? StoreOrderFilter.allCases.map { ($0.label, $0.tabIndex, 0) } + : PhotographerOrderFilter.allCases.map { ($0.label, $0.tabIndex, $0.rTabIndex) } + + let actions = items.map { title, tab, rTab in + UIAction(title: title, state: title == selectedLabel ? .on : .off) { [weak self] _ in + guard let self else { return } + self.filterPill.pillTitle = title + self.rebuildFilterMenu(selectedLabel: title) + self.onFilterSelected?(tab, rTab) + } + } + filterPill.menu = UIMenu(title: "", options: .displayInline, children: actions) } @objc private func searchChanged() { onSearchChanged?(searchField.text ?? "") } - @objc private func filterTapped() { - if isStoreMode { - presentStoreFilterMenu() - } else { - presentPhotographerFilterMenu() - } - } - @objc private func dateTapped() { onDateRangeTap?() } - - private func presentPhotographerFilterMenu() { - let actions: [(String, Int, Int)] = PhotographerOrderFilter.allCases.map { - ($0.label, $0.tabIndex, $0.rTabIndex) - } - presentMenu(actions: actions) - } - - private func presentStoreFilterMenu() { - let actions: [(String, Int, Int)] = StoreOrderFilter.allCases.map { - ($0.label, $0.tabIndex, 0) - } - presentMenu(actions: actions) - } - - private func presentMenu(actions: [(String, Int, Int)]) { - guard let presenter = window?.rootViewController?.presentedViewController ?? window?.rootViewController else { return } - let alert = UIAlertController(title: "筛选订单", message: nil, preferredStyle: .actionSheet) - actions.forEach { title, tab, rTab in - alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in - self?.currentFilterLabel = title - self?.filterButton.setTitle("\(title) ▾", for: .normal) - self?.onFilterSelected?(tab, rTab) - }) - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - presenter.present(alert, animated: true) - } } extension OrderFilterBarView: UITextFieldDelegate { diff --git a/suixinkan/UI/Orders/OrderListView.swift b/suixinkan/UI/Orders/OrderListView.swift index 0be7ef2..858216f 100644 --- a/suixinkan/UI/Orders/OrderListView.swift +++ b/suixinkan/UI/Orders/OrderListView.swift @@ -6,39 +6,53 @@ import SnapKit import UIKit -/// 订单列表容器,含下拉刷新与分页。 +/// 订单列表容器,含下拉刷新、空态与分页,对齐 Android LazyColumn。 final class OrderListView: UIView { - enum Mode { - case photographer - case store - } - var onRefresh: (() -> Void)? var onLoadMore: ((Int) -> Void)? let tableView = UITableView(frame: .zero, style: .plain) private let refreshControl = UIRefreshControl() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let emptyLabel = UILabel() private let footerLabel = UILabel() + private let footerSpinner = UIActivityIndicatorView(style: .medium) override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = UIColor(hex: 0xF5F5F5) + backgroundColor = AppColor.pageBackground tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.keyboardDismissMode = .onDrag + tableView.contentInset = UIEdgeInsets(top: OrderTokens.cardGap, left: 0, bottom: OrderTokens.cardGap, right: 0) tableView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) - footerLabel.font = .systemFont(ofSize: 12) - footerLabel.textColor = UIColor(hex: 0x9CA3AF) + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + + emptyLabel.font = .app(.body) + emptyLabel.textColor = OrderTokens.label563 + emptyLabel.text = "暂无订单" + emptyLabel.textAlignment = .center + + footerLabel.font = .app(.body) + footerLabel.textColor = OrderTokens.label563 footerLabel.textAlignment = .center footerLabel.text = "没有更多" + footerSpinner.hidesWhenStopped = true + footerSpinner.color = AppColor.primary + addSubview(tableView) + addSubview(loadingIndicator) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } } @available(*, unavailable) @@ -46,18 +60,54 @@ final class OrderListView: UIView { fatalError("init(coder:) has not been implemented") } - func apply(isRefreshing: Bool, canLoadMore: Bool, isEmpty: Bool) { + func apply( + isRefreshing: Bool, + canLoadMore: Bool, + isEmpty: Bool, + isLoading: Bool = false, + isLoadingMore: Bool = false + ) { if isRefreshing { if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } } else { refreshControl.endRefreshing() } - footerLabel.text = isEmpty ? "暂无订单" : (canLoadMore ? "" : "没有更多") - tableView.tableFooterView = footerLabelFrame(isEmpty: isEmpty, canLoadMore: canLoadMore) + + if isLoading, isEmpty { + loadingIndicator.startAnimating() + tableView.backgroundView = nil + tableView.tableFooterView = nil + } else { + loadingIndicator.stopAnimating() + if isEmpty { + tableView.backgroundView = emptyBackgroundView() + } else { + tableView.backgroundView = nil + } + tableView.tableFooterView = footerView(canLoadMore: canLoadMore, isLoadingMore: isLoadingMore, isEmpty: isEmpty) + } } - private func footerLabelFrame(isEmpty: Bool, canLoadMore: Bool) -> UIView? { - guard !canLoadMore || isEmpty else { return nil } + private func emptyBackgroundView() -> UIView { + let container = UIView(frame: tableView.bounds) + emptyLabel.frame = container.bounds + emptyLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(emptyLabel) + return container + } + + private func footerView(canLoadMore: Bool, isLoadingMore: Bool, isEmpty: Bool) -> UIView? { + guard !isEmpty else { return nil } + if isLoadingMore { + let container = UIView(frame: CGRect(x: 0, y: 0, width: bounds.width, height: 44)) + footerSpinner.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY) + footerSpinner.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin] + container.addSubview(footerSpinner) + footerSpinner.startAnimating() + return container + } + footerSpinner.stopAnimating() + guard !canLoadMore else { return nil } footerLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 44) return footerLabel } diff --git a/suixinkan/UI/Orders/OrdersViewController.swift b/suixinkan/UI/Orders/OrdersViewController.swift index e67acf2..8812be1 100644 --- a/suixinkan/UI/Orders/OrdersViewController.swift +++ b/suixinkan/UI/Orders/OrdersViewController.swift @@ -146,7 +146,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT listView.apply( isRefreshing: depositViewModel.isRefreshing, canLoadMore: depositViewModel.canLoadMore, - isEmpty: depositViewModel.orderList.isEmpty + isEmpty: depositViewModel.orderList.isEmpty, + isLoading: depositViewModel.isRefreshing && depositViewModel.orderList.isEmpty, + isLoadingMore: depositViewModel.isLoadingMore ) if let response = depositViewModel.writeOffResponse { depositViewModel.dismissWriteOffDialog() @@ -162,7 +164,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT listView.apply( isRefreshing: orderListViewModel.isRefreshing, canLoadMore: orderListViewModel.canLoadMore, - isEmpty: orderListViewModel.orderList.isEmpty + isEmpty: orderListViewModel.orderList.isEmpty, + isLoading: orderListViewModel.isRefreshing && orderListViewModel.orderList.isEmpty, + isLoadingMore: orderListViewModel.isLoadingMore ) if let response = orderListViewModel.signInResponse { orderListViewModel.clearSignInResponse() @@ -186,11 +190,11 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT private func dateRangeText(start: Date?, end: Date?) -> String { let formatter = DateFormatter() - formatter.dateFormat = "yyyy/MM/dd" + formatter.dateFormat = "yyyy-MM-dd" if let start, let end { return "\(formatter.string(from: start))-\(formatter.string(from: end))" } - return "全部" + return "时间筛选" } private func presentDateRangeSheet() { @@ -427,6 +431,10 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT Task { await self?.depositViewModel.processScanResult(result, api: self?.orderAPI ?? NetworkServices.shared.orderAPI) } } } + actions.onCallPhone = { + guard let url = URL(string: "tel://\(item.phone)") else { return } + UIApplication.shared.open(url) + } actions.onTap = { [weak self] in guard let self else { return } guard self.depositViewModel.canOpenDetail(for: item) else { diff --git a/suixinkan/UI/Orders/Views/DepositOrderStatusBadgeView.swift b/suixinkan/UI/Orders/Views/DepositOrderStatusBadgeView.swift new file mode 100644 index 0000000..6d16e5c --- /dev/null +++ b/suixinkan/UI/Orders/Views/DepositOrderStatusBadgeView.swift @@ -0,0 +1,30 @@ +// +// DepositOrderStatusBadgeView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 店铺订单状态标签,对齐 Android `DepositOrderCard` badge(12sp)。 +final class DepositOrderStatusBadgeView: UIView { + + private let badge = AppStatusBadge() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(badge) + badge.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(status: Int, text: String) { + badge.applyDepositOrderStatus(status, text: text) + } +} diff --git a/suixinkan/UI/Orders/Views/OrderActionButton.swift b/suixinkan/UI/Orders/Views/OrderActionButton.swift new file mode 100644 index 0000000..8960a04 --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderActionButton.swift @@ -0,0 +1,49 @@ +// +// OrderActionButton.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 订单卡片操作按钮,对齐 Android `AppButton` 在订单页的样式。 +final class OrderActionButton: UIButton { + + enum Style { + case destructiveLight + case primaryFilled + case primaryLight + case dangerFilled + } + + init(title: String, style: Style) { + super.init(frame: .zero) + setTitle(title, for: .normal) + titleLabel?.font = .app(.subtitle) + layer.cornerRadius = AppRadius.sm + clipsToBounds = true + switch style { + case .destructiveLight: + setTitleColor(AppColor.danger, for: .normal) + backgroundColor = AppColor.dangerBackground + case .primaryFilled: + setTitleColor(.white, for: .normal) + backgroundColor = AppColor.primary + case .primaryLight: + setTitleColor(AppColor.primary, for: .normal) + backgroundColor = OrderTokens.chipBackground + layer.cornerRadius = AppRadius.lg + case .dangerFilled: + setTitleColor(.white, for: .normal) + backgroundColor = AppColor.danger + } + snp.makeConstraints { make in + make.height.equalTo(OrderTokens.actionButtonHeight) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan/UI/Orders/Views/OrderEditOptionView.swift b/suixinkan/UI/Orders/Views/OrderEditOptionView.swift new file mode 100644 index 0000000..1ca8d9d --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderEditOptionView.swift @@ -0,0 +1,131 @@ +// +// OrderEditOptionView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 「需要剪辑修图」是/否单选,对齐 Android `OrderEditOptionSection`。 +final class OrderEditOptionView: UIView { + + var onSelectionChange: ((Int) -> Void)? + + private let titleLabel = UILabel() + private let readOnlyLabel = UILabel() + private let yesButton = UIControl() + private let noButton = UIControl() + private let yesCircle = UIView() + private let noCircle = UIView() + private let yesLabel = UILabel() + private let noLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.text = "需要剪辑修图" + titleLabel.font = .app(.body) + titleLabel.textColor = OrderTokens.label563 + + readOnlyLabel.font = .systemFont(ofSize: 14, weight: .medium) + readOnlyLabel.textColor = OrderTokens.valueBlack + readOnlyLabel.isHidden = true + + [yesLabel, noLabel].forEach { + $0.text = $0 === yesLabel ? "是" : "否" + $0.font = .app(.body) + $0.textColor = AppColor.textPrimary + } + + [yesCircle, noCircle].forEach { + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 1.5 + $0.layer.borderColor = AppColor.primary.cgColor + $0.snp.makeConstraints { make in + make.width.height.equalTo(20) + } + } + + configureChoiceButton(yesButton, circle: yesCircle, label: yesLabel, tag: 1) + configureChoiceButton(noButton, circle: noCircle, label: noLabel, tag: 2) + + let choicesStack = UIStackView(arrangedSubviews: [yesButton, noButton]) + choicesStack.axis = .horizontal + choicesStack.spacing = AppSpacing.md + + addSubview(titleLabel) + addSubview(readOnlyLabel) + addSubview(choicesStack) + + titleLabel.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + } + readOnlyLabel.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + } + choicesStack.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + } + snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(28) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(isRefined: Int, readOnly: Bool) { + readOnlyLabel.isHidden = !readOnly + yesButton.isHidden = readOnly + noButton.isHidden = readOnly + if readOnly { + readOnlyLabel.text = switch isRefined { + case 1: "是" + case 2: "否" + default: "--" + } + } else { + updateCircle(yesCircle, selected: isRefined == 1) + updateCircle(noCircle, selected: isRefined == 2) + } + } + + private func configureChoiceButton(_ control: UIControl, circle: UIView, label: UILabel, tag: Int) { + control.tag = tag + control.addTarget(self, action: #selector(choiceTapped(_:)), for: .touchUpInside) + let row = UIStackView(arrangedSubviews: [circle, label]) + row.axis = .horizontal + row.spacing = AppSpacing.xxs + row.isUserInteractionEnabled = false + control.addSubview(row) + row.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func updateCircle(_ circle: UIView, selected: Bool) { + if selected { + circle.backgroundColor = AppColor.primary + circle.layer.borderWidth = 0 + let check = UIImageView(image: UIImage(systemName: "checkmark")) + check.tintColor = .white + check.tag = 999 + circle.subviews.forEach { $0.removeFromSuperview() } + circle.addSubview(check) + check.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(12) + } + } else { + circle.backgroundColor = .clear + circle.layer.borderWidth = 1.5 + circle.layer.borderColor = AppColor.primary.cgColor + circle.subviews.forEach { $0.removeFromSuperview() } + } + } + + @objc private func choiceTapped(_ sender: UIControl) { + onSelectionChange?(sender.tag) + } +} diff --git a/suixinkan/UI/Orders/Views/OrderFilterPillControl.swift b/suixinkan/UI/Orders/Views/OrderFilterPillControl.swift new file mode 100644 index 0000000..3a9f344 --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderFilterPillControl.swift @@ -0,0 +1,66 @@ +// +// OrderFilterPillControl.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 订单筛选灰 pill,对齐 Android 筛选/时间控件。 +final class OrderFilterPillControl: UIButton { + + private let pillTitleLabel = UILabel() + private let trailingIconView = UIImageView() + + var pillTitle: String = "" { + didSet { pillTitleLabel.text = pillTitle } + } + + var showsTrailingIcon: Bool = false { + didSet { trailingIconView.isHidden = !showsTrailingIcon } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = OrderTokens.filterBackground + layer.cornerRadius = OrderTokens.pillRadius + clipsToBounds = true + + pillTitleLabel.font = .app(.body) + pillTitleLabel.textColor = OrderTokens.label563 + pillTitleLabel.lineBreakMode = .byTruncatingTail + pillTitleLabel.isUserInteractionEnabled = false + + trailingIconView.contentMode = .scaleAspectFit + trailingIconView.tintColor = OrderTokens.label563 + trailingIconView.isHidden = true + trailingIconView.isUserInteractionEnabled = false + + addSubview(pillTitleLabel) + addSubview(trailingIconView) + + pillTitleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(trailingIconView.snp.leading).offset(-AppSpacing.xxs) + } + trailingIconView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.width.height.equalTo(15) + } + snp.makeConstraints { make in + make.height.equalTo(OrderTokens.filterPillHeight) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTrailingSystemImage(_ name: String) { + trailingIconView.image = UIImage(systemName: name) + showsTrailingIcon = true + } +} diff --git a/suixinkan/UI/Orders/Views/OrderGiftStepperView.swift b/suixinkan/UI/Orders/Views/OrderGiftStepperView.swift new file mode 100644 index 0000000..99e7b42 --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderGiftStepperView.swift @@ -0,0 +1,61 @@ +// +// OrderGiftStepperView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 修图/视频赠送步进控件,+/- 均触发赠送弹窗(对齐 Android)。 +final class OrderGiftStepperView: UIView { + + var onMinusTap: (() -> Void)? + var onPlusTap: (() -> Void)? + + private let minusButton = UIButton(type: .system) + private let plusButton = UIButton(type: .system) + private let valueLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + [minusButton, plusButton].forEach { button in + button.layer.cornerRadius = 12 + button.layer.borderWidth = 1 + button.layer.borderColor = OrderTokens.giftBorder.cgColor + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold) + button.setTitleColor(OrderTokens.giftBorder, for: .normal) + button.snp.makeConstraints { make in + make.width.height.equalTo(24) + } + } + minusButton.setTitle("-", for: .normal) + plusButton.setTitle("+", for: .normal) + minusButton.addTarget(self, action: #selector(minusTapped), for: .touchUpInside) + plusButton.addTarget(self, action: #selector(plusTapped), for: .touchUpInside) + + valueLabel.font = .systemFont(ofSize: 14, weight: .medium) + valueLabel.textColor = OrderTokens.valueBlack + valueLabel.textAlignment = .center + + let stack = UIStackView(arrangedSubviews: [minusButton, valueLabel, plusButton]) + stack.axis = .horizontal + stack.spacing = AppSpacing.xs + stack.alignment = .center + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(text: String) { + valueLabel.text = text + } + + @objc private func minusTapped() { onMinusTap?() } + @objc private func plusTapped() { onPlusTap?() } +} diff --git a/suixinkan/UI/Orders/Views/OrderHistoricalPhotosView.swift b/suixinkan/UI/Orders/Views/OrderHistoricalPhotosView.swift new file mode 100644 index 0000000..59ea62d --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderHistoricalPhotosView.swift @@ -0,0 +1,109 @@ +// +// OrderHistoricalPhotosView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 历史拍摄缩略图行,对齐 Android multi_travel 区块。 +final class OrderHistoricalPhotosView: UIView { + + var onTap: (() -> Void)? + + private let titleLabel = UILabel() + private let divider = UIView() + private let photosStack = UIStackView() + private var imageViews: [UIImageView] = [] + private var overlayLabel: UILabel? + + override init(frame: CGRect) { + super.init(frame: frame) + divider.backgroundColor = OrderTokens.remarkDivider + + titleLabel.text = "历史拍摄信息:" + titleLabel.font = .app(.body) + titleLabel.textColor = OrderTokens.label563 + + photosStack.axis = .horizontal + photosStack.spacing = AppSpacing.xs + photosStack.distribution = .fillEqually + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + + addSubview(divider) + addSubview(titleLabel) + addSubview(photosStack) + + divider.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(1) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview() + } + photosStack.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(72) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(materials: [MultiTravelMaterialEntity]) { + isHidden = materials.isEmpty + photosStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + imageViews.removeAll() + overlayLabel?.removeFromSuperview() + overlayLabel = nil + + let displayItems = Array(materials.prefix(3)) + for (index, material) in displayItems.enumerated() { + let container = UIView() + container.backgroundColor = OrderTokens.filterBackground + container.layer.cornerRadius = AppRadius.sm + container.clipsToBounds = true + + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + let thumbURL = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl + imageView.loadRemoteImage(urlString: thumbURL, placeholderColor: OrderTokens.filterBackground) + container.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + imageViews.append(imageView) + photosStack.addArrangedSubview(container) + + if materials.count > 3, index == 2 { + let overlay = UIView() + overlay.backgroundColor = UIColor.black.withAlphaComponent(0.5) + let label = UILabel() + label.text = "查看更多" + label.font = .systemFont(ofSize: 14, weight: .medium) + label.textColor = .white + label.textAlignment = .center + overlay.addSubview(label) + label.snp.makeConstraints { make in + make.center.equalToSuperview() + } + container.addSubview(overlay) + overlay.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + overlayLabel = label + } + } + } + + @objc private func handleTap() { + onTap?() + } +} diff --git a/suixinkan/UI/Orders/Views/OrderInfoRowView.swift b/suixinkan/UI/Orders/Views/OrderInfoRowView.swift new file mode 100644 index 0000000..82e3dbb --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderInfoRowView.swift @@ -0,0 +1,46 @@ +// +// OrderInfoRowView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 订单详情 key-value 行,对齐 Android InfoRow。 +final class OrderInfoRowView: UIView { + + private let titleLabel = UILabel() + private let valueLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = .app(.body) + titleLabel.textColor = OrderTokens.label563 + valueLabel.font = .systemFont(ofSize: 14, weight: .medium) + valueLabel.textColor = OrderTokens.valueBlack + valueLabel.textAlignment = .right + valueLabel.numberOfLines = 0 + + addSubview(titleLabel) + addSubview(valueLabel) + titleLabel.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.width.lessThanOrEqualToSuperview().multipliedBy(0.45) + } + valueLabel.snp.makeConstraints { make in + make.trailing.top.bottom.equalToSuperview() + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(AppSpacing.xs) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(title: String, value: String, valueColor: UIColor = OrderTokens.valueBlack, usesPrimaryValue: Bool = false) { + titleLabel.text = "\(title):" + valueLabel.text = value + valueLabel.textColor = usesPrimaryValue ? AppColor.primary : valueColor + } +} diff --git a/suixinkan/UI/Orders/Views/OrderPhoneRowView.swift b/suixinkan/UI/Orders/Views/OrderPhoneRowView.swift new file mode 100644 index 0000000..e16c55b --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderPhoneRowView.swift @@ -0,0 +1,101 @@ +// +// OrderPhoneRowView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 订单手机号行,支持摄影师/店铺两种 Android 样式。 +final class OrderPhoneRowView: UIView { + + enum Style { + case photographer + case deposit + } + + var onTap: (() -> Void)? + + private let style: Style + private let titleLabel = UILabel() + private let phoneLabel = UILabel() + private let phoneButton = UIButton(type: .system) + private let iconButton = UIButton(type: .system) + + init(style: Style) { + self.style = style + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(phone: String, hidden: Bool = false) { + let masked = OrderPhoneMask.mask(phone) + phoneLabel.text = masked + isHidden = hidden || phone.isEmpty + } + + private func setupUI() { + titleLabel.font = .app(.body) + titleLabel.textColor = OrderTokens.label563 + titleLabel.text = "手机号:" + + phoneLabel.font = .systemFont(ofSize: 14, weight: .medium) + phoneLabel.textAlignment = .right + + phoneButton.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + iconButton.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + + addSubview(titleLabel) + addSubview(phoneLabel) + addSubview(phoneButton) + addSubview(iconButton) + + titleLabel.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + } + + switch style { + case .photographer: + phoneLabel.textColor = OrderTokens.valueBlack + iconButton.backgroundColor = OrderTokens.phoneIconBackground + iconButton.layer.cornerRadius = 12 + iconButton.tintColor = AppColor.primary + iconButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) + iconButton.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.width.height.equalTo(24) + } + phoneLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalTo(iconButton.snp.leading).offset(-7) + } + case .deposit: + phoneLabel.textColor = AppColor.primary + iconButton.tintColor = AppColor.primary + iconButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) + iconButton.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.width.height.equalTo(16) + } + phoneLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalTo(iconButton.snp.leading).offset(-AppSpacing.xxs) + } + } + + phoneButton.snp.makeConstraints { make in + make.leading.equalTo(phoneLabel) + make.trailing.equalTo(iconButton) + make.top.bottom.equalToSuperview() + } + } + + @objc private func handleTap() { + onTap?() + } +} diff --git a/suixinkan/UI/Orders/Views/OrderTypeChipView.swift b/suixinkan/UI/Orders/Views/OrderTypeChipView.swift new file mode 100644 index 0000000..07d8e3c --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderTypeChipView.swift @@ -0,0 +1,37 @@ +// +// OrderTypeChipView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 订单类型标签 chip。 +final class OrderTypeChipView: UIView { + + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = OrderTokens.chipBackground + layer.cornerRadius = OrderTokens.pillRadius + clipsToBounds = true + + label.font = .app(.caption) + label.textColor = AppColor.primary + addSubview(label) + label.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.xxs, bottom: 0, right: AppSpacing.xxs)) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(text: String) { + label.text = text + isHidden = text.isEmpty + } +} diff --git a/suixinkanTests/OrderListUITokensTests.swift b/suixinkanTests/OrderListUITokensTests.swift new file mode 100644 index 0000000..627e43d --- /dev/null +++ b/suixinkanTests/OrderListUITokensTests.swift @@ -0,0 +1,64 @@ +// +// OrderListUITokensTests.swift +// suixinkanTests +// + +import UIKit +import XCTest +@testable import suixinkan + +/// 订单列表 UI Token 与 badge 映射校验。 +final class OrderListUITokensTests: XCTestCase { + + func testOrderTokensMatchAndroid() { + XCTAssertEqual(OrderTokens.filterBackground.hexRGB, 0xF4F4F4) + XCTAssertEqual(OrderTokens.label563.hexRGB, 0x4B5563) + XCTAssertEqual(OrderTokens.timestamp.hexRGB, 0x6B7280) + XCTAssertEqual(OrderTokens.depositSuccess.hexRGB, 0x27AE60) + XCTAssertEqual(OrderTokens.depositRefunded.hexRGB, 0x6B7280) + XCTAssertEqual(OrderTokens.searchHeight, 36) + XCTAssertEqual(OrderTokens.filterPillHeight, 35) + XCTAssertEqual(OrderTokens.actionButtonHeight, 48) + XCTAssertEqual(OrderTokens.cardRadius, 12) + } + + func testPhotographerOrderStatusBadgeMapping() { + let badge = AppStatusBadge() + badge.applyOrderStatus(18, text: "已付定金") + badge.applyOrderStatus(30, text: "已完成") + badge.applyOrderStatus(50, text: "已退款") + XCTAssertFalse(badge.isHidden) + } + + func testDepositOrderStatus50UsesNeutralGray() { + let badge = AppStatusBadge() + badge.applyDepositOrderStatus(50, text: "已退款") + XCTAssertEqual(badge.backgroundColor?.hexRGB, OrderTokens.depositRefundedBackground.hexRGB) + } + + func testDepositOrderStatus30UsesDepositGreen() { + let badge = AppStatusBadge() + badge.applyDepositOrderStatus(30, text: "已完成") + XCTAssertEqual(badge.backgroundColor?.hexRGB, OrderTokens.depositSuccessBackground.hexRGB) + } + + func testPhotographerStatus50UsesDangerNotGray() { + let badge = AppStatusBadge() + badge.applyOrderStatus(50, text: "已退款") + XCTAssertEqual(badge.backgroundColor?.hexRGB, AppColor.dangerBackground.hexRGB) + } +} + +private extension UIColor { + var hexRGB: UInt { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let r = Int(round(red * 255)) + let g = Int(round(green * 255)) + let b = Int(round(blue * 255)) + return UInt(r << 16 | g << 8 | b) + } +}