// // BookkeepingViewController.swift // VenusKitto // // Created by Neoa on 2025/8/26. // import Foundation import UIKit // MARK: - 简单模型(Cell 使用) fileprivate struct BookItem { let date: Date let title: String let amount: Double // 负数为支出,正数为收入 let count: Int // 次数/数量 let petName: String // ✅ 新增:用于 cell 上显示宠物名 } fileprivate struct DayGroup { let date: Date let total: Double let items: [BookItem] } // MARK: - 注入的宠物(带 id) fileprivate struct BKPet { let id: String let name: String } // MARK: - 网络返回模型(记账账单) fileprivate struct BillRecordResponse: Codable { let code: String let msg: String? let data: BillData } fileprivate struct BillData: Codable { let petName: String let week: String? let dayExpend: String? let groupedData: [BillGroupDTO] let time: String? let totalCost: String } fileprivate struct BillGroupDTO: Codable { let date: String // yyyy.MM.dd let week: String? let dayTotal: String // 当天合计 let items: [BillItemDTO] } fileprivate struct BillItemDTO: Codable { let id: String let petId: String let avatar: String? let petName: String let recordTypeId: String? let recordUrl: String? let title: String let recordDate: String? let content: String? let amount: String? // 金额(正数) let note: String? let module: String? } // MARK: - BookkeepingViewController (记账页面) final class BookkeepingViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate, UIPopoverPresentationControllerDelegate { // 顶部标题 private let titleLabel: UILabel = { let l = UILabel() l.text = "记账" l.font = .systemFont(ofSize: 28, weight: .semibold) l.textColor = UIColor(hex: "#2B2B2B") return l }() private lazy var settingsButton: UIButton = { let b = UIButton(type: .system) if let img = UIImage(systemName: "gearshape") { b.setImage(img, for: .normal) } else { b.setTitle("⚙️", for: .normal) } b.tintColor = UIColor(hex: "#2B2B2B") b.addTarget(self, action: #selector(tapSettings), for: .touchUpInside) return b }() private lazy var addBarItem: UIBarButtonItem = { UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(tapAdd)) }() // Header 子模块 private let petsRow = UIStackView() private let periodRow = UIStackView() private let monthRow = UIStackView() private let monthLeft = UIButton(type: .system) private let monthRight = UIButton(type: .system) private let monthLabel = UILabel() private let summaryCard = SummaryCardView() // 分段:总 / 年 / 月 private let totalChip = SegmentChipButton(title: "总", maskedCorners: [.layerMinXMinYCorner, .layerMinXMaxYCorner], cornerRadius: 12) private let yearChip = SegmentChipButton(title: "年", maskedCorners: [], cornerRadius: 0) private let monthChip = SegmentChipButton(title: "月", maskedCorners: [.layerMaxXMinYCorner, .layerMaxXMaxYCorner], cornerRadius: 12) // 列表 private let tableView = UITableView(frame: .zero, style: .grouped) // tableHeader 容器,用于在布局变化时重算高度 private var headerContainer: UIView? // 空视图 private lazy var emptyView: UIView = { let container = UIView() container.backgroundColor = .clear let imageView = UIImageView() imageView.image = UIImage(named: "emptydata") // 使用现有的图片资源 imageView.contentMode = .scaleAspectFit // imageView.tintColor = UIColor(hex: "#CFCFCF") let titleLabel = UILabel() titleLabel.text = "暂无记账记录" titleLabel.font = .systemFont(ofSize: 18, weight: .medium) titleLabel.textColor = UIColor(hex: "#CFCFCF") titleLabel.textAlignment = .center let subtitleLabel = UILabel() subtitleLabel.text = "快去添加第一条记账记录吧~" subtitleLabel.font = .systemFont(ofSize: 14) subtitleLabel.textColor = UIColor(hex: "#CFCFCF") subtitleLabel.textAlignment = .center let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel, subtitleLabel]) stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 16 container.addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ imageView.widthAnchor.constraint(equalToConstant: 80), imageView.heightAnchor.constraint(equalToConstant: 80), stackView.centerXAnchor.constraint(equalTo: container.centerXAnchor), stackView.centerYAnchor.constraint(equalTo: container.centerYAnchor), stackView.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 40), stackView.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -40) ]) container.isHidden = true return container }() // 数据(由外部注入,带 id) private var pets: [BKPet] = [] private var selectedPetIndex: Int = 0 // 期间:0=总 1=年 2=月(目前服务端以月为主,我们都传当前年+月) private var periodIndex: Int = 2 private var currentMonth: Date = Date() private var dayGroups: [DayGroup] = [] // 年份选择支持 private var yearOptions: [Int] = [] private var tempSelectedYear: Int = Calendar.current.component(.year, from: Date()) private var monthOptions: [Int] = Array(1...12) private var tempSelectedMonth: Int = Calendar.current.component(.month, from: Date()) /// 清空并填充宠物 Chips(避免重复添加) private func populatePetsRow() { // 先移除旧的 petsRow.arrangedSubviews.forEach { v in petsRow.removeArrangedSubview(v) v.removeFromSuperview() } // 再添加新的 for (i, p) in pets.enumerated() { let btn = ChipButton(title: p.name) // 设置强 hugging/压缩优先级,使按钮不被拉伸 btn.setContentHuggingPriority(.required, for: .horizontal) btn.setContentCompressionResistancePriority(.required, for: .horizontal) btn.tag = i btn.addTarget(self, action: #selector(tapPet(_:)), for: .touchUpInside) petsRow.addArrangedSubview(btn) } // 在末尾加一个弹性占位视图,使宠物名称始终靠左 let spacer = UIView() spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) petsRow.addArrangedSubview(spacer) updatePetChips() } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: "#FFFEFC") navigationItem.rightBarButtonItem = addBarItem buildTopBar() buildHeader() buildTable() // 若外部尚未注入宠物,提供一个占位按钮 if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] } refetch() // 优先拉接口;无 petId 时回落到本地 demo // ✅ 监听“记账创建成功”的通知 NotificationCenter.default.addObserver( self, selector: #selector(handleBookkeepingRecordCreated(_:)), name: .bookkeepingRecordDidCreate, object: nil ) } @objc private func handleBookkeepingRecordCreated(_ note: Notification) { // 如果通知里带了 petId,仅当前选中的宠物匹配时刷新;否则直接刷新 if let pid = note.userInfo?["petId"] as? String, let cur = currentPetId() { if pid == cur { refetch() } } else { refetch() } } deinit { NotificationCenter.default.removeObserver(self) } @objc private func tapSettings() { print("Settings tapped") let vc = SettingsViewController() navigationController?.pushViewController(vc, animated: true) } // 外部注入宠物列表(带 id) func setPets(_ list: [(id: String, name: String)], selectedIndex: Int = 0) { self.pets = list.map { BKPet(id: $0.id, name: $0.name) } self.selectedPetIndex = max(0, min(selectedIndex, pets.count - 1)) // 重建宠物 chips(统一入口,避免重复) populatePetsRow() refetch() } private func currentPetId() -> String? { guard !pets.isEmpty else { return nil } let idx = max(0, min(selectedPetIndex, pets.count - 1)) let pid = pets[idx].id.trimmingCharacters(in: .whitespacesAndNewlines) return pid.isEmpty ? nil : pid } private func buildTopBar() { view.addSubview(titleLabel) view.addSubview(settingsButton) titleLabel.translatesAutoresizingMaskIntoConstraints = false settingsButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), settingsButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), settingsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), settingsButton.heightAnchor.constraint(equalToConstant: 28), settingsButton.widthAnchor.constraint(equalToConstant: 28), ]) } private func buildHeader() { // 宠物行 petsRow.axis = .horizontal petsRow.spacing = 12 petsRow.alignment = .center petsRow.distribution = .fill // 让内容按自身尺寸布局,配合尾部 spacer 左对齐 // 如果还没填充,才初始化一次,避免在 setPets 之后重复追加 if petsRow.arrangedSubviews.isEmpty { if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] } populatePetsRow() } // 期间行(总 / 年 / 月)—— 左对齐、0 间距;“年”仅左侧圆角,“月”仅右侧圆角 periodRow.axis = .horizontal periodRow.spacing = 0 periodRow.alignment = .fill periodRow.distribution = .fill // 处理点击 totalChip.tag = 100 totalChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside) yearChip.tag = 101 yearChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside) monthChip.tag = 102 monthChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside) // "总 年 月" 作为一个零间距的分段组 let segmentGroup = UIStackView(arrangedSubviews: [totalChip, yearChip, monthChip]) segmentGroup.axis = .horizontal segmentGroup.spacing = 0 segmentGroup.alignment = .fill segmentGroup.distribution = .fill segmentGroup.setContentHuggingPriority(.required, for: .horizontal) periodRow.addArrangedSubview(segmentGroup) // 不再在 periodRow 内添加 spacer;让外层容器控制左右分布 updatePeriodChips() // 月份选择 monthRow.axis = .horizontal monthRow.alignment = .center monthRow.spacing = 8 monthLeft.setImage(UIImage(systemName: "chevron.left"), for: .normal) monthRight.setImage(UIImage(systemName: "chevron.right"), for: .normal) [monthLeft, monthRight].forEach { $0.tintColor = UIColor(hex: "#2B2B2B") } monthLeft.addTarget(self, action: #selector(prevMonth), for: .touchUpInside) monthRight.addTarget(self, action: #selector(nextMonth), for: .touchUpInside) monthLabel.font = .systemFont(ofSize: 16, weight: .semibold) monthLabel.textColor = UIColor(hex: "#2B2B2B") monthLabel.isUserInteractionEnabled = true let _tapMonth = UITapGestureRecognizer(target: self, action: #selector(tapMonthLabel)) monthLabel.addGestureRecognizer(_tapMonth) // 推荐:hugging/压缩优先级 monthLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) monthLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) monthRow.addArrangedSubview(monthLeft) monthRow.addArrangedSubview(monthLabel) monthRow.addArrangedSubview(monthRight) updateMonthLabel() // periodRow + monthRow 合并到同一行:左“总/年/月”,右“月份选择” let flexibleSpacer = UIView() flexibleSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) flexibleSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) // 将 periodRow 只承载分段(无内部 spacer),monthRow 放右侧并保持自身大小 monthRow.setContentHuggingPriority(.required, for: .horizontal) monthRow.setContentCompressionResistancePriority(.required, for: .horizontal) let periodMonthRow = UIStackView(arrangedSubviews: [periodRow, flexibleSpacer, monthRow]) periodMonthRow.axis = .horizontal periodMonthRow.alignment = .center periodMonthRow.spacing = 8 periodMonthRow.distribution = .fill // header 内容栈 let headerStack = UIStackView(arrangedSubviews: [petsRow, periodMonthRow, summaryCard]) headerStack.axis = .vertical headerStack.spacing = 14 headerStack.alignment = .fill headerStack.isLayoutMarginsRelativeArrangement = true headerStack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) // 新实现:不挂到 tableHeaderView,等 viewDidLayoutSubviews let container = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) container.autoresizingMask = [.flexibleWidth] container.addSubview(headerStack) headerStack.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ headerStack.topAnchor.constraint(equalTo: container.topAnchor), headerStack.leadingAnchor.constraint(equalTo: container.leadingAnchor), headerStack.trailingAnchor.constraint(equalTo: container.trailingAnchor), headerStack.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) // 仅缓存,不在此时挂到 tableHeaderView,等待尺寸可用后在 viewDidLayoutSubviews 里再挂 self.headerContainer = container } private func buildTable() { tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.dataSource = self tableView.delegate = self tableView.register(ExpenseCell.self, forCellReuseIdentifier: "ExpenseCell") view.addSubview(tableView) view.addSubview(emptyView) tableView.translatesAutoresizingMaskIntoConstraints = false emptyView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), emptyView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 200), emptyView.leadingAnchor.constraint(equalTo: view.leadingAnchor), emptyView.trailingAnchor.constraint(equalTo: view.trailingAnchor), emptyView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) tableView.estimatedRowHeight = 121 tableView.rowHeight = UITableView.automaticDimension } // 让 tableHeader 自适应高度与宽度(用于旋转/尺寸变化) override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() guard let container = headerContainer, let headerStack = container.subviews.first as? UIStackView else { return } // 只有当 tableView 已经有了确定的宽度时才挂载/更新 header let width = tableView.bounds.width guard width > 0 else { return } if container.frame.width != width { container.frame.size.width = width } let target = CGSize(width: width, height: UIView.layoutFittingCompressedSize.height) let height = headerStack.systemLayoutSizeFitting( target, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel ).height if tableView.tableHeaderView == nil { // 首次挂载 container.frame.size.height = height tableView.tableHeaderView = container } else { // 尺寸变化时更新 if container.frame.height != height { container.frame.size.height = height tableView.tableHeaderView = container // 重新赋值以使变更生效 } } // 避免底部与外层 TabBar 重叠,留出滚动区域 let extraBottom: CGFloat = 24 if tableView.contentInset.bottom != extraBottom { tableView.contentInset.bottom = extraBottom tableView.scrollIndicatorInsets.bottom = extraBottom } } // MARK: - 触发刷新 & 网络请求 private func refetch() { guard let pid = currentPetId() else { // 无 petId(占位宠物),显示空视图 showEmptyView() return } fetchBillRecords(petId: pid, month: currentMonth) } private func fetchBillRecords(petId: String, month: Date) { let yFmt = DateFormatter(); yFmt.dateFormat = "yyyy" let ymFmt = DateFormatter(); ymFmt.dateFormat = "yyyy.MM" let yearStr: String let yearMonthStr: String switch periodIndex { case 0: // 总:都传空 yearStr = "" yearMonthStr = "" case 1: // 年:只传 year yearStr = yFmt.string(from: month) yearMonthStr = "" default: // 月:同时传 year 和 yearMonth yearStr = yFmt.string(from: month) yearMonthStr = ymFmt.string(from: month) } var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryBillRecordInfoList") comps?.queryItems = [ URLQueryItem(name: "petId", value: petId), URLQueryItem(name: "year", value: yearStr), URLQueryItem(name: "yearMonth", value: yearMonthStr) ] guard let url = comps?.url else { return } var req = URLRequest(url: url) req.httpMethod = "GET" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = UserDefaults.standard.string(forKey: "userToken") { req.setValue(token, forHTTPHeaderField: "Authorization") } URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in if let http = resp as? HTTPURLResponse { print("📊 bill status: \(http.statusCode)") } if let err = err { print("❌ bill error: \(err)"); return } guard let data = data else { print("❌ bill empty data"); return } do { let decoder = JSONDecoder() let rsp = try decoder.decode(BillRecordResponse.self, from: data) if rsp.code == "200" { DispatchQueue.main.async { self?.applyBillData(rsp.data) } } else { print("📊 bill server msg: \(rsp.msg ?? "-") code=\(rsp.code)") } } catch { print("❌ bill decode error: \(error)") } }.resume() } private func applyBillData(_ data: BillData) { // 顶部汇总卡 let yF = DateFormatter(); yF.dateFormat = "yyyy" let ymF = DateFormatter(); ymF.dateFormat = "yyyy.MM" let total = Double(data.totalCost) ?? 0 let suffix: String switch periodIndex { case 0: suffix = "合计花费" case 1: suffix = "\(yF.string(from: currentMonth))花费" default: suffix = "\(ymF.string(from: currentMonth))花费" } summaryCard.configure(title: "\(data.petName) \(suffix)", amount: -total) // 分组 -> dayGroups let df = DateFormatter(); df.dateFormat = "yyyy.MM.dd"; df.locale = Locale(identifier: "zh_CN") var groups: [DayGroup] = [] for g in data.groupedData { let date = df.date(from: g.date) ?? Date() var items: [BookItem] = [] for it in g.items { let amt = Double(it.amount ?? "0") ?? 0 items.append(BookItem(date: date, title: it.title, amount: -amt, count: 1, petName: it.petName)) } let dayTotal = -(Double(g.dayTotal) ?? items.reduce(0) { $0 + $1.amount }) groups.append(DayGroup(date: date, total: dayTotal, items: items)) } groups.sort { $0.date > $1.date } self.dayGroups = groups // 检查是否有数据,决定显示列表还是空视图 if dayGroups.isEmpty { showEmptyView() } else { hideEmptyView() } self.tableView.reloadData() } // MARK: - Demo 数据(本地兜底) private func reloadDemoData() { let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" let d1 = fmt.date(from: "2025-08-25")! let demoPetName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name let items = [ BookItem(date: d1, title: "指甲剪", amount: -10, count: 1, petName: demoPetName), BookItem(date: d1, title: "厕所", amount: -50, count: 1, petName: demoPetName) ] // 分组 let groups = Dictionary(grouping: items, by: { dayOnly($0.date) }) dayGroups = groups.keys.sorted(by: >).map { day in let its = groups[day]!.sorted { $0.title < $1.title } let total = its.reduce(0) { $0 + $1.amount } return DayGroup(date: day, total: total, items: its) } // 顶部汇总卡 let monthTotal = items.reduce(0) { $0 + $1.amount } let petName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name summaryCard.configure(title: "\(petName) \(DateFormatter.with("yyyy.MM").string(from: currentMonth))花费", amount: monthTotal) tableView.reloadData() } @objc private func tapAdd() { // TODO: push 新增账目 print("➕ Add bookkeeping") } @objc private func tapPet(_ sender: UIButton) { selectedPetIndex = sender.tag updatePetChips() refetch() } @objc private func tapPeriod(_ sender: UIButton) { periodIndex = sender.tag - 100 updatePeriodChips() updateMonthLabel() // ← 新增:切换分段后立刻刷新“月份选择”区 refetch() } @objc private func prevMonth() { guard periodIndex != 0 else { return } // “总”下不允许切换 if periodIndex == 1 { // 年视图:按年切 currentMonth = Calendar.current.date(byAdding: .year, value: -1, to: currentMonth)! } else { // 月视图:按月切 currentMonth = Calendar.current.date(byAdding: .month, value: -1, to: currentMonth)! } updateMonthLabel() refetch() } @objc private func nextMonth() { guard periodIndex != 0 else { return } if periodIndex == 1 { currentMonth = Calendar.current.date(byAdding: .year, value: 1, to: currentMonth)! } else { currentMonth = Calendar.current.date(byAdding: .month, value: 1, to: currentMonth)! } updateMonthLabel() refetch() } @objc private func tapMonthLabel() { switch periodIndex { case 1: showYearPickerSheet() case 2: showYearMonthPickerSheet() default: break } } private func showYearMonthPickerSheet() { // 年月两列选择(不显示日) let cur = currentMonth let curY = Calendar.current.component(.year, from: cur) let curM = Calendar.current.component(.month, from: cur) // 准备年份与默认选中 yearOptions = Array((curY - 10)...(curY + 10)) tempSelectedYear = curY tempSelectedMonth = curM let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet) if let p = ac.popoverPresentationController { p.sourceView = monthLabel p.sourceRect = monthLabel.bounds p.permittedArrowDirections = .up p.delegate = self } let pv = UIPickerView() pv.tag = 99 // 99 表示“年月”两列 pv.dataSource = self pv.delegate = self ac.view.addSubview(pv) pv.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor), pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8), pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor) ]) if let yIdx = yearOptions.firstIndex(of: curY) { pv.selectRow(yIdx, inComponent: 0, animated: false) } pv.selectRow(curM - 1, inComponent: 1, animated: false) ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in guard let self = self else { return } let newDate = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: self.tempSelectedMonth, day: 1)) ?? self.currentMonth self.currentMonth = newDate self.updateMonthLabel() self.refetch() })) ac.addAction(UIAlertAction(title: "取消", style: .cancel)) present(ac, animated: true) } private func showYearPickerSheet() { // 备选年份(当前年±10年) let curY = Calendar.current.component(.year, from: currentMonth) let start = curY - 10 let end = curY + 10 yearOptions = Array(start...end) tempSelectedYear = curY let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet) if let p = ac.popoverPresentationController { p.sourceView = monthLabel p.sourceRect = monthLabel.bounds p.permittedArrowDirections = .up p.delegate = self } let pv = UIPickerView() pv.tag = 98 // 98 表示“仅年份”一列 pv.dataSource = self pv.delegate = self if let idx = yearOptions.firstIndex(of: curY) { pv.selectRow(idx, inComponent: 0, animated: false) } ac.view.addSubview(pv) pv.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor), pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8), pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor) ]) ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in guard let self = self else { return } let comps = Calendar.current.dateComponents([.month], from: self.currentMonth) let new = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: comps.month ?? 1, day: 1)) ?? self.currentMonth self.currentMonth = new self.updateMonthLabel() self.refetch() })) ac.addAction(UIAlertAction(title: "取消", style: .cancel)) present(ac, animated: true) } private func updateMonthLabel() { switch periodIndex { case 0: // 总 monthLabel.text = "总" monthLeft.isHidden = true monthRight.isHidden = true case 1: // 年 let y = DateFormatter(); y.dateFormat = "yyyy" monthLabel.text = y.string(from: currentMonth) monthLeft.isHidden = false monthRight.isHidden = false default: // 月 let f = DateFormatter(); f.dateFormat = "yyyy.MM" monthLabel.text = f.string(from: currentMonth) monthLeft.isHidden = false monthRight.isHidden = false } } private func updatePetChips() { for (i, v) in petsRow.arrangedSubviews.enumerated() { (v as? ChipButton)?.isOn = (i == selectedPetIndex) } } private func updatePeriodChips() { totalChip.isOn = (periodIndex == 0) yearChip.isOn = (periodIndex == 1) monthChip.isOn = (periodIndex == 2) } // MARK: - Table func numberOfSections(in tableView: UITableView) -> Int { dayGroups.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { dayGroups[section].items.count } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let v = DayHeaderView() let g = dayGroups[section] v.configure(date: g.date, total: g.total) return v } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { 50 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ExpenseCell", for: indexPath) as! ExpenseCell let item = dayGroups[indexPath.section].items[indexPath.row] cell.configure(title: item.title, amount: item.amount, petName: item.petName) return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) } func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return 121 } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 121 } // MARK: - UIPickerView DataSource & Delegate (Year/Year-Month) func numberOfComponents(in pickerView: UIPickerView) -> Int { return pickerView.tag == 99 ? 2 : 1 } func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { if pickerView.tag == 99 { return component == 0 ? yearOptions.count : monthOptions.count } else { return yearOptions.count } } func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { if pickerView.tag == 99 { if component == 0 { return "\(yearOptions[row])年" } else { return String(format: "%02d月", monthOptions[row]) } } else { return "\(yearOptions[row])年" } } func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { if pickerView.tag == 99 { if component == 0, row >= 0, row < yearOptions.count { tempSelectedYear = yearOptions[row] } else if component == 1, row >= 0, row < monthOptions.count { tempSelectedMonth = monthOptions[row] } } else { if row >= 0 && row < yearOptions.count { tempSelectedYear = yearOptions[row] } } } // MARK: - 空视图管理 private func showEmptyView() { dayGroups = [] // 设置汇总卡为 0 let petName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name let suffix: String let yF = DateFormatter(); yF.dateFormat = "yyyy" let ymF = DateFormatter(); ymF.dateFormat = "yyyy.MM" switch periodIndex { case 0: suffix = "合计花费" case 1: suffix = "\(yF.string(from: currentMonth))花费" default: suffix = "\(ymF.string(from: currentMonth))花费" } summaryCard.configure(title: "\(petName) \(suffix)", amount: 0) emptyView.isHidden = false tableView.reloadData() } private func hideEmptyView() { emptyView.isHidden = true } // MARK: - UIPopoverPresentationControllerDelegate fallback for iPad popover func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) { if popoverPresentationController.sourceView == nil && popoverPresentationController.barButtonItem == nil { popoverPresentationController.sourceView = monthLabel.superview ?? view let base = popoverPresentationController.sourceView! popoverPresentationController.sourceRect = base.bounds popoverPresentationController.permittedArrowDirections = .up } } } // MARK: - 小组件 fileprivate final class ChipButton: UIButton { var isOn: Bool = false { didSet { refresh() } } private let inset = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) init(title: String) { super.init(frame: .zero) setTitle(title, for: .normal) titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal) layer.cornerRadius = 12 contentEdgeInsets = inset backgroundColor = .clear refresh() } required init?(coder: NSCoder) { fatalError() } private func refresh() { layer.cornerRadius = 12 if isOn { backgroundColor = UIColor(hex: "#FFE059") setTitleColor(.black, for: .normal) } else { backgroundColor = .clear setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal) } } } fileprivate final class SegmentChipButton: UIButton { var isOn: Bool = false { didSet { refresh() } } private let inset = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) private let masked: CACornerMask private let cornerRadius: CGFloat init(title: String, maskedCorners: CACornerMask, cornerRadius: CGFloat = 12) { self.masked = maskedCorners self.cornerRadius = cornerRadius super.init(frame: .zero) setTitle(title, for: .normal) titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal) contentEdgeInsets = inset // 仅指定的角为圆角,其余为直角 layer.cornerRadius = cornerRadius layer.maskedCorners = masked layer.masksToBounds = true backgroundColor = .clear refresh() } required init?(coder: NSCoder) { fatalError() } private func refresh() { layer.cornerRadius = cornerRadius if isOn { backgroundColor = UIColor(hex: "#FFE059") setTitleColor(.black, for: .normal) } else { backgroundColor = UIColor(hex: "#FEF3C1") setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal) } } } fileprivate final class SummaryCardView: UIView { private let container = UIImageView() private let titleLabel = UILabel() private let amountLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) translatesAutoresizingMaskIntoConstraints = false container.backgroundColor = .white container.image = UIImage(named: "Home378") container.layer.cornerRadius = 14 // container.layer.borderWidth = 1 // container.layer.borderColor = UIColor(hex: "#2B2B2B").withAlphaComponent(0.15).cgColor container.layer.shadowColor = UIColor.black.cgColor container.layer.shadowOpacity = 0.06 container.layer.shadowRadius = 6 container.layer.shadowOffset = .init(width: 0, height: 2) addSubview(container) [titleLabel, amountLabel].forEach { container.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false } titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = UIColor(hex: "#2B2B2B") amountLabel.font = .systemFont(ofSize: 22, weight: .semibold) amountLabel.textColor = UIColor(hex: "#2B2B2B") amountLabel.textAlignment = .right container.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ container.heightAnchor.constraint(equalToConstant: 100), container.leadingAnchor.constraint(equalTo: leadingAnchor), container.trailingAnchor.constraint(equalTo: trailingAnchor), container.topAnchor.constraint(equalTo: topAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor), titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16), amountLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor) ]) } required init?(coder: NSCoder) { fatalError() } func configure(title: String, amount: Double) { titleLabel.text = title amountLabel.text = String(format: "¥ %.2f", abs(amount)) } } fileprivate final class DayHeaderView: UIView { private let dateLabel = UILabel() private let totalLabel = UILabel() private let under = UIImageView() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear dateLabel.font = .systemFont(ofSize: 13, weight: .semibold) dateLabel.textColor = UIColor(hex: "#2B2B2B") totalLabel.font = .systemFont(ofSize: 13) totalLabel.textColor = UIColor(hex: "#2B2B2B") // under.backgroundColor = UIColor(hex: "#FFE059") // under.layer.cornerRadius = 2 under.image = UIImage(named: "Bookkeep395") addSubview(dateLabel); addSubview(totalLabel); addSubview(under) dateLabel.translatesAutoresizingMaskIntoConstraints = false totalLabel.translatesAutoresizingMaskIntoConstraints = false under.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ dateLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), dateLabel.centerYAnchor.constraint(equalTo: centerYAnchor), totalLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), totalLabel.centerYAnchor.constraint(equalTo: centerYAnchor), under.heightAnchor.constraint(equalToConstant: 12), // under.widthAnchor.constraint(equalToConstant: 60), under.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: -5), under.leadingAnchor.constraint(equalTo: dateLabel.leadingAnchor), under.trailingAnchor.constraint(equalTo: dateLabel.trailingAnchor) ]) } required init?(coder: NSCoder) { fatalError() } func configure(date: Date, total: Double) { let f1 = DateFormatter(); f1.dateFormat = "yyyy.MM.dd EEEE"; f1.locale = Locale(identifier: "zh_CN") dateLabel.text = f1.string(from: date) totalLabel.text = String(format: "支出: ¥ %.2f", abs(total)) } } fileprivate final class PetBadgeView: UIView { private let iconView = UIImageView() private let nameLabel = UILabel() private let container = UIView() override init(frame: CGRect) { super.init(frame: frame) // 带描边的圆角白底 container.backgroundColor = .white container.layer.cornerRadius = 7 container.layer.masksToBounds = true container.layer.borderWidth = 1 container.layer.borderColor = UIColor(hex: "#5B4227").cgColor addSubview(container) iconView.contentMode = .scaleAspectFit iconView.layer.cornerRadius = 5 iconView.layer.masksToBounds = true nameLabel.font = .systemFont(ofSize: 12) nameLabel.textColor = UIColor(hex: "#5B4227") let stack = UIStackView(arrangedSubviews: [iconView, nameLabel]) stack.axis = .horizontal stack.alignment = .center stack.spacing = 6 container.addSubview(stack) container.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false iconView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ container.topAnchor.constraint(equalTo: topAnchor), container.leadingAnchor.constraint(equalTo: leadingAnchor), container.trailingAnchor.constraint(equalTo: trailingAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor), stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 4), stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4), stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4), iconView.widthAnchor.constraint(equalToConstant: 10), iconView.heightAnchor.constraint(equalToConstant: 10) ]) } required init?(coder: NSCoder) { fatalError() } func set(name: String, icon: UIImage?) { nameLabel.text = name iconView.image = icon ?? UIImage(named: "peihead") } } fileprivate final class ExpenseCell: UITableViewCell { // 手绘边框容器(使用切图) private let container = UIImageView() // 上半区元素 private let icon = UIImageView(image: UIImage(named: "peihead")) private let titleLabel = UILabel() private let amountLabel = UILabel() // 中部整条虚线 private let dashLayer = CAShapeLayer() // 右下角宠物名徽章 private let petBadge = PetBadgeView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) backgroundColor = .clear selectionStyle = .none // 手绘边框底图 container.image = UIImage(named: "Home378") container.contentMode = .scaleToFill contentView.addSubview(container) container.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), container.heightAnchor.constraint(greaterThanOrEqualToConstant: 121) ]) // 上半区 icon.contentMode = .scaleAspectFill icon.layer.cornerRadius = 8 icon.layer.masksToBounds = true titleLabel.font = .systemFont(ofSize: 16, weight: .regular) titleLabel.textColor = UIColor(hex: "#2B2B2B") amountLabel.font = .systemFont(ofSize: 16, weight: .heavy) amountLabel.textColor = UIColor(hex: "#2B2B2B") amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal) amountLabel.textAlignment = .right [icon, titleLabel, amountLabel, petBadge].forEach { v in v.translatesAutoresizingMaskIntoConstraints = false container.addSubview(v) } NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), icon.topAnchor.constraint(equalTo: container.topAnchor, constant: 16), icon.widthAnchor.constraint(equalToConstant: 32), icon.heightAnchor.constraint(equalToConstant: 32), titleLabel.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 14), titleLabel.topAnchor.constraint(equalTo: icon.topAnchor,constant: 4), titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: amountLabel.leadingAnchor, constant: -8), amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16), amountLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), petBadge.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16), petBadge.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14), petBadge.heightAnchor.constraint(greaterThanOrEqualToConstant: 14) ]) // 中部整条虚线(添加在容器上) dashLayer.strokeColor = UIColor(hex: "#CFCFCF").cgColor dashLayer.fillColor = UIColor.clear.cgColor dashLayer.lineDashPattern = [6, 6] dashLayer.lineWidth = 1 container.layer.addSublayer(dashLayer) } required init?(coder: NSCoder) { fatalError() } override func layoutSubviews() { super.layoutSubviews() // 让中部虚线横穿整个卡片(留左右内边距) let left: CGFloat = 16 let right: CGFloat = bounds.width - 16 // 取上半区最大底部再往下 16 作为分隔线位置 let topMax = max(icon.frame.maxY, titleLabel.frame.maxY, amountLabel.frame.maxY) let y = topMax + 16 let path = UIBezierPath() path.move(to: CGPoint(x: left, y: y)) path.addLine(to: CGPoint(x: right, y: y)) dashLayer.path = path.cgPath } func configure(title: String, amount: Double, petName: String) { titleLabel.text = title let prefix = amount < 0 ? "- " : "+ " amountLabel.text = String(format: "%@¥ %.2f", prefix, abs(amount)) petBadge.set(name: petName, icon: UIImage(named: "peihead")) } } // MARK: - Utils fileprivate func dayOnly(_ d: Date) -> Date { let cal = Calendar.current return cal.date(from: cal.dateComponents([.year, .month, .day], from: d))! } private extension DateFormatter { static func with(_ f: String) -> DateFormatter { let d = DateFormatter() d.dateFormat = f return d } } // MARK: - HEX 颜色 extension UIColor { convenience init(hex: String, alpha: CGFloat = 1.0) { var hexSanitized = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: hexSanitized).scanHexInt64(&int) let a, r, g, b: UInt64 switch hexSanitized.count { case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) default: (a, r, g, b) = (255, 0, 0, 0) } self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255) } }