BookkeepingViewController.swift 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179
  1. //
  2. // BookkeepingViewController.swift
  3. // VenusKitto
  4. //
  5. // Created by Neoa on 2025/8/26.
  6. //
  7. import Foundation
  8. import UIKit
  9. // MARK: - 简单模型(Cell 使用)
  10. fileprivate struct BookItem {
  11. let date: Date
  12. let title: String
  13. let amount: Double // 负数为支出,正数为收入
  14. let count: Int // 次数/数量
  15. let petName: String // ✅ 新增:用于 cell 上显示宠物名
  16. }
  17. fileprivate struct DayGroup {
  18. let date: Date
  19. let total: Double
  20. let items: [BookItem]
  21. }
  22. // MARK: - 注入的宠物(带 id)
  23. fileprivate struct BKPet {
  24. let id: String
  25. let name: String
  26. }
  27. // MARK: - 网络返回模型(记账账单)
  28. fileprivate struct BillRecordResponse: Codable {
  29. let code: String
  30. let msg: String?
  31. let data: BillData
  32. }
  33. fileprivate struct BillData: Codable {
  34. let petName: String
  35. let week: String?
  36. let dayExpend: String?
  37. let groupedData: [BillGroupDTO]
  38. let time: String?
  39. let totalCost: String
  40. }
  41. fileprivate struct BillGroupDTO: Codable {
  42. let date: String // yyyy.MM.dd
  43. let week: String?
  44. let dayTotal: String // 当天合计
  45. let items: [BillItemDTO]
  46. }
  47. fileprivate struct BillItemDTO: Codable {
  48. let id: String
  49. let petId: String
  50. let avatar: String?
  51. let petName: String
  52. let recordTypeId: String?
  53. let recordUrl: String?
  54. let title: String
  55. let recordDate: String?
  56. let content: String?
  57. let amount: String? // 金额(正数)
  58. let note: String?
  59. let module: String?
  60. }
  61. // MARK: - BookkeepingViewController (记账页面)
  62. final class BookkeepingViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate, UIPopoverPresentationControllerDelegate {
  63. // 顶部标题
  64. private let titleLabel: UILabel = {
  65. let l = UILabel()
  66. l.text = "记账"
  67. l.font = .systemFont(ofSize: 28, weight: .semibold)
  68. l.textColor = UIColor(hex: "#2B2B2B")
  69. return l
  70. }()
  71. private lazy var settingsButton: UIButton = {
  72. let b = UIButton(type: .system)
  73. if let img = UIImage(systemName: "gearshape") {
  74. b.setImage(img, for: .normal)
  75. } else {
  76. b.setTitle("⚙️", for: .normal)
  77. }
  78. b.tintColor = UIColor(hex: "#2B2B2B")
  79. b.addTarget(self, action: #selector(tapSettings), for: .touchUpInside)
  80. return b
  81. }()
  82. private lazy var addBarItem: UIBarButtonItem = {
  83. UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(tapAdd))
  84. }()
  85. // Header 子模块
  86. private let petsRow = UIStackView()
  87. private let periodRow = UIStackView()
  88. private let monthRow = UIStackView()
  89. private let monthLeft = UIButton(type: .system)
  90. private let monthRight = UIButton(type: .system)
  91. private let monthLabel = UILabel()
  92. private let summaryCard = SummaryCardView()
  93. // 分段:总 / 年 / 月
  94. private let totalChip = SegmentChipButton(title: "总",
  95. maskedCorners: [.layerMinXMinYCorner, .layerMinXMaxYCorner],
  96. cornerRadius: 12)
  97. private let yearChip = SegmentChipButton(title: "年",
  98. maskedCorners: [],
  99. cornerRadius: 0)
  100. private let monthChip = SegmentChipButton(title: "月",
  101. maskedCorners: [.layerMaxXMinYCorner, .layerMaxXMaxYCorner],
  102. cornerRadius: 12)
  103. // 列表
  104. private let tableView = UITableView(frame: .zero, style: .grouped)
  105. // tableHeader 容器,用于在布局变化时重算高度
  106. private var headerContainer: UIView?
  107. // 空视图
  108. private lazy var emptyView: UIView = {
  109. let container = UIView()
  110. container.backgroundColor = .clear
  111. let imageView = UIImageView()
  112. imageView.image = UIImage(named: "emptydata") // 使用现有的图片资源
  113. imageView.contentMode = .scaleAspectFit
  114. // imageView.tintColor = UIColor(hex: "#CFCFCF")
  115. let titleLabel = UILabel()
  116. titleLabel.text = "暂无记账记录"
  117. titleLabel.font = .systemFont(ofSize: 18, weight: .medium)
  118. titleLabel.textColor = UIColor(hex: "#CFCFCF")
  119. titleLabel.textAlignment = .center
  120. let subtitleLabel = UILabel()
  121. subtitleLabel.text = "快去添加第一条记账记录吧~"
  122. subtitleLabel.font = .systemFont(ofSize: 14)
  123. subtitleLabel.textColor = UIColor(hex: "#CFCFCF")
  124. subtitleLabel.textAlignment = .center
  125. let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel, subtitleLabel])
  126. stackView.axis = .vertical
  127. stackView.alignment = .center
  128. stackView.spacing = 16
  129. container.addSubview(stackView)
  130. stackView.translatesAutoresizingMaskIntoConstraints = false
  131. imageView.translatesAutoresizingMaskIntoConstraints = false
  132. NSLayoutConstraint.activate([
  133. imageView.widthAnchor.constraint(equalToConstant: 80),
  134. imageView.heightAnchor.constraint(equalToConstant: 80),
  135. stackView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  136. stackView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  137. stackView.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 40),
  138. stackView.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -40)
  139. ])
  140. container.isHidden = true
  141. return container
  142. }()
  143. // 数据(由外部注入,带 id)
  144. private var pets: [BKPet] = []
  145. private var selectedPetIndex: Int = 0
  146. // 期间:0=总 1=年 2=月(目前服务端以月为主,我们都传当前年+月)
  147. private var periodIndex: Int = 2
  148. private var currentMonth: Date = Date()
  149. private var dayGroups: [DayGroup] = []
  150. // 年份选择支持
  151. private var yearOptions: [Int] = []
  152. private var tempSelectedYear: Int = Calendar.current.component(.year, from: Date())
  153. private var monthOptions: [Int] = Array(1...12)
  154. private var tempSelectedMonth: Int = Calendar.current.component(.month, from: Date())
  155. /// 清空并填充宠物 Chips(避免重复添加)
  156. private func populatePetsRow() {
  157. // 先移除旧的
  158. petsRow.arrangedSubviews.forEach { v in
  159. petsRow.removeArrangedSubview(v)
  160. v.removeFromSuperview()
  161. }
  162. // 再添加新的
  163. for (i, p) in pets.enumerated() {
  164. let btn = ChipButton(title: p.name)
  165. // 设置强 hugging/压缩优先级,使按钮不被拉伸
  166. btn.setContentHuggingPriority(.required, for: .horizontal)
  167. btn.setContentCompressionResistancePriority(.required, for: .horizontal)
  168. btn.tag = i
  169. btn.addTarget(self, action: #selector(tapPet(_:)), for: .touchUpInside)
  170. petsRow.addArrangedSubview(btn)
  171. }
  172. // 在末尾加一个弹性占位视图,使宠物名称始终靠左
  173. let spacer = UIView()
  174. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  175. spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  176. petsRow.addArrangedSubview(spacer)
  177. updatePetChips()
  178. }
  179. override func viewDidLoad() {
  180. super.viewDidLoad()
  181. view.backgroundColor = UIColor(hex: "#FFFEFC")
  182. navigationItem.rightBarButtonItem = addBarItem
  183. buildTopBar()
  184. buildHeader()
  185. buildTable()
  186. // 若外部尚未注入宠物,提供一个占位按钮
  187. if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] }
  188. refetch() // 优先拉接口;无 petId 时回落到本地 demo
  189. // ✅ 监听“记账创建成功”的通知
  190. NotificationCenter.default.addObserver(
  191. self,
  192. selector: #selector(handleBookkeepingRecordCreated(_:)),
  193. name: .bookkeepingRecordDidCreate,
  194. object: nil
  195. )
  196. }
  197. @objc private func handleBookkeepingRecordCreated(_ note: Notification) {
  198. // 如果通知里带了 petId,仅当前选中的宠物匹配时刷新;否则直接刷新
  199. if let pid = note.userInfo?["petId"] as? String, let cur = currentPetId() {
  200. if pid == cur {
  201. refetch()
  202. }
  203. } else {
  204. refetch()
  205. }
  206. }
  207. deinit {
  208. NotificationCenter.default.removeObserver(self)
  209. }
  210. @objc private func tapSettings() {
  211. print("Settings tapped")
  212. let vc = SettingsViewController()
  213. navigationController?.pushViewController(vc, animated: true)
  214. }
  215. // 外部注入宠物列表(带 id)
  216. func setPets(_ list: [(id: String, name: String)], selectedIndex: Int = 0) {
  217. self.pets = list.map { BKPet(id: $0.id, name: $0.name) }
  218. self.selectedPetIndex = max(0, min(selectedIndex, pets.count - 1))
  219. // 重建宠物 chips(统一入口,避免重复)
  220. populatePetsRow()
  221. refetch()
  222. }
  223. private func currentPetId() -> String? {
  224. guard !pets.isEmpty else { return nil }
  225. let idx = max(0, min(selectedPetIndex, pets.count - 1))
  226. let pid = pets[idx].id.trimmingCharacters(in: .whitespacesAndNewlines)
  227. return pid.isEmpty ? nil : pid
  228. }
  229. private func buildTopBar() {
  230. view.addSubview(titleLabel)
  231. view.addSubview(settingsButton)
  232. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  233. settingsButton.translatesAutoresizingMaskIntoConstraints = false
  234. NSLayoutConstraint.activate([
  235. titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
  236. titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
  237. settingsButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
  238. settingsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
  239. settingsButton.heightAnchor.constraint(equalToConstant: 28),
  240. settingsButton.widthAnchor.constraint(equalToConstant: 28),
  241. ])
  242. }
  243. private func buildHeader() {
  244. // 宠物行
  245. petsRow.axis = .horizontal
  246. petsRow.spacing = 12
  247. petsRow.alignment = .center
  248. petsRow.distribution = .fill // 让内容按自身尺寸布局,配合尾部 spacer 左对齐
  249. // 如果还没填充,才初始化一次,避免在 setPets 之后重复追加
  250. if petsRow.arrangedSubviews.isEmpty {
  251. if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] }
  252. populatePetsRow()
  253. }
  254. // 期间行(总 / 年 / 月)—— 左对齐、0 间距;“年”仅左侧圆角,“月”仅右侧圆角
  255. periodRow.axis = .horizontal
  256. periodRow.spacing = 0
  257. periodRow.alignment = .fill
  258. periodRow.distribution = .fill
  259. // 处理点击
  260. totalChip.tag = 100
  261. totalChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
  262. yearChip.tag = 101
  263. yearChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
  264. monthChip.tag = 102
  265. monthChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
  266. // "总 年 月" 作为一个零间距的分段组
  267. let segmentGroup = UIStackView(arrangedSubviews: [totalChip, yearChip, monthChip])
  268. segmentGroup.axis = .horizontal
  269. segmentGroup.spacing = 0
  270. segmentGroup.alignment = .fill
  271. segmentGroup.distribution = .fill
  272. segmentGroup.setContentHuggingPriority(.required, for: .horizontal)
  273. periodRow.addArrangedSubview(segmentGroup)
  274. // 不再在 periodRow 内添加 spacer;让外层容器控制左右分布
  275. updatePeriodChips()
  276. // 月份选择
  277. monthRow.axis = .horizontal
  278. monthRow.alignment = .center
  279. monthRow.spacing = 8
  280. monthLeft.setImage(UIImage(systemName: "chevron.left"), for: .normal)
  281. monthRight.setImage(UIImage(systemName: "chevron.right"), for: .normal)
  282. [monthLeft, monthRight].forEach { $0.tintColor = UIColor(hex: "#2B2B2B") }
  283. monthLeft.addTarget(self, action: #selector(prevMonth), for: .touchUpInside)
  284. monthRight.addTarget(self, action: #selector(nextMonth), for: .touchUpInside)
  285. monthLabel.font = .systemFont(ofSize: 16, weight: .semibold)
  286. monthLabel.textColor = UIColor(hex: "#2B2B2B")
  287. monthLabel.isUserInteractionEnabled = true
  288. let _tapMonth = UITapGestureRecognizer(target: self, action: #selector(tapMonthLabel))
  289. monthLabel.addGestureRecognizer(_tapMonth)
  290. // 推荐:hugging/压缩优先级
  291. monthLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  292. monthLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  293. monthRow.addArrangedSubview(monthLeft)
  294. monthRow.addArrangedSubview(monthLabel)
  295. monthRow.addArrangedSubview(monthRight)
  296. updateMonthLabel()
  297. // periodRow + monthRow 合并到同一行:左“总/年/月”,右“月份选择”
  298. let flexibleSpacer = UIView()
  299. flexibleSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  300. flexibleSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  301. // 将 periodRow 只承载分段(无内部 spacer),monthRow 放右侧并保持自身大小
  302. monthRow.setContentHuggingPriority(.required, for: .horizontal)
  303. monthRow.setContentCompressionResistancePriority(.required, for: .horizontal)
  304. let periodMonthRow = UIStackView(arrangedSubviews: [periodRow, flexibleSpacer, monthRow])
  305. periodMonthRow.axis = .horizontal
  306. periodMonthRow.alignment = .center
  307. periodMonthRow.spacing = 8
  308. periodMonthRow.distribution = .fill
  309. // header 内容栈
  310. let headerStack = UIStackView(arrangedSubviews: [petsRow, periodMonthRow, summaryCard])
  311. headerStack.axis = .vertical
  312. headerStack.spacing = 14
  313. headerStack.alignment = .fill
  314. headerStack.isLayoutMarginsRelativeArrangement = true
  315. headerStack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
  316. // 新实现:不挂到 tableHeaderView,等 viewDidLayoutSubviews
  317. let container = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
  318. container.autoresizingMask = [.flexibleWidth]
  319. container.addSubview(headerStack)
  320. headerStack.translatesAutoresizingMaskIntoConstraints = false
  321. NSLayoutConstraint.activate([
  322. headerStack.topAnchor.constraint(equalTo: container.topAnchor),
  323. headerStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  324. headerStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  325. headerStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  326. ])
  327. // 仅缓存,不在此时挂到 tableHeaderView,等待尺寸可用后在 viewDidLayoutSubviews 里再挂
  328. self.headerContainer = container
  329. }
  330. private func buildTable() {
  331. tableView.backgroundColor = .clear
  332. tableView.separatorStyle = .none
  333. tableView.dataSource = self
  334. tableView.delegate = self
  335. tableView.register(ExpenseCell.self, forCellReuseIdentifier: "ExpenseCell")
  336. view.addSubview(tableView)
  337. view.addSubview(emptyView)
  338. tableView.translatesAutoresizingMaskIntoConstraints = false
  339. emptyView.translatesAutoresizingMaskIntoConstraints = false
  340. NSLayoutConstraint.activate([
  341. tableView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
  342. tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  343. tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  344. tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  345. emptyView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 200),
  346. emptyView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  347. emptyView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  348. emptyView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  349. ])
  350. tableView.estimatedRowHeight = 121
  351. tableView.rowHeight = UITableView.automaticDimension
  352. }
  353. // 让 tableHeader 自适应高度与宽度(用于旋转/尺寸变化)
  354. override func viewDidLayoutSubviews() {
  355. super.viewDidLayoutSubviews()
  356. guard let container = headerContainer,
  357. let headerStack = container.subviews.first as? UIStackView else { return }
  358. // 只有当 tableView 已经有了确定的宽度时才挂载/更新 header
  359. let width = tableView.bounds.width
  360. guard width > 0 else { return }
  361. if container.frame.width != width {
  362. container.frame.size.width = width
  363. }
  364. let target = CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
  365. let height = headerStack.systemLayoutSizeFitting(
  366. target,
  367. withHorizontalFittingPriority: .required,
  368. verticalFittingPriority: .fittingSizeLevel
  369. ).height
  370. if tableView.tableHeaderView == nil {
  371. // 首次挂载
  372. container.frame.size.height = height
  373. tableView.tableHeaderView = container
  374. } else {
  375. // 尺寸变化时更新
  376. if container.frame.height != height {
  377. container.frame.size.height = height
  378. tableView.tableHeaderView = container // 重新赋值以使变更生效
  379. }
  380. }
  381. // 避免底部与外层 TabBar 重叠,留出滚动区域
  382. let extraBottom: CGFloat = 24
  383. if tableView.contentInset.bottom != extraBottom {
  384. tableView.contentInset.bottom = extraBottom
  385. tableView.scrollIndicatorInsets.bottom = extraBottom
  386. }
  387. }
  388. // MARK: - 触发刷新 & 网络请求
  389. private func refetch() {
  390. guard let pid = currentPetId() else {
  391. // 无 petId(占位宠物),显示空视图
  392. showEmptyView()
  393. return
  394. }
  395. fetchBillRecords(petId: pid, month: currentMonth)
  396. }
  397. private func fetchBillRecords(petId: String, month: Date) {
  398. let yFmt = DateFormatter(); yFmt.dateFormat = "yyyy"
  399. let ymFmt = DateFormatter(); ymFmt.dateFormat = "yyyy.MM"
  400. let yearStr: String
  401. let yearMonthStr: String
  402. switch periodIndex {
  403. case 0: // 总:都传空
  404. yearStr = ""
  405. yearMonthStr = ""
  406. case 1: // 年:只传 year
  407. yearStr = yFmt.string(from: month)
  408. yearMonthStr = ""
  409. default: // 月:同时传 year 和 yearMonth
  410. yearStr = yFmt.string(from: month)
  411. yearMonthStr = ymFmt.string(from: month)
  412. }
  413. var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryBillRecordInfoList")
  414. comps?.queryItems = [
  415. URLQueryItem(name: "petId", value: petId),
  416. URLQueryItem(name: "year", value: yearStr),
  417. URLQueryItem(name: "yearMonth", value: yearMonthStr)
  418. ]
  419. guard let url = comps?.url else { return }
  420. var req = URLRequest(url: url)
  421. req.httpMethod = "GET"
  422. req.setValue("application/json", forHTTPHeaderField: "Content-Type")
  423. if let token = UserDefaults.standard.string(forKey: "userToken") {
  424. req.setValue(token, forHTTPHeaderField: "Authorization")
  425. }
  426. URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in
  427. if let http = resp as? HTTPURLResponse { print("📊 bill status: \(http.statusCode)") }
  428. if let err = err { print("❌ bill error: \(err)"); return }
  429. guard let data = data else { print("❌ bill empty data"); return }
  430. do {
  431. let decoder = JSONDecoder()
  432. let rsp = try decoder.decode(BillRecordResponse.self, from: data)
  433. if rsp.code == "200" {
  434. DispatchQueue.main.async { self?.applyBillData(rsp.data) }
  435. } else {
  436. print("📊 bill server msg: \(rsp.msg ?? "-") code=\(rsp.code)")
  437. }
  438. } catch {
  439. print("❌ bill decode error: \(error)")
  440. }
  441. }.resume()
  442. }
  443. private func applyBillData(_ data: BillData) {
  444. // 顶部汇总卡
  445. let yF = DateFormatter(); yF.dateFormat = "yyyy"
  446. let ymF = DateFormatter(); ymF.dateFormat = "yyyy.MM"
  447. let total = Double(data.totalCost) ?? 0
  448. let suffix: String
  449. switch periodIndex {
  450. case 0: suffix = "合计花费"
  451. case 1: suffix = "\(yF.string(from: currentMonth))花费"
  452. default: suffix = "\(ymF.string(from: currentMonth))花费"
  453. }
  454. summaryCard.configure(title: "\(data.petName) \(suffix)", amount: -total)
  455. // 分组 -> dayGroups
  456. let df = DateFormatter(); df.dateFormat = "yyyy.MM.dd"; df.locale = Locale(identifier: "zh_CN")
  457. var groups: [DayGroup] = []
  458. for g in data.groupedData {
  459. let date = df.date(from: g.date) ?? Date()
  460. var items: [BookItem] = []
  461. for it in g.items {
  462. let amt = Double(it.amount ?? "0") ?? 0
  463. items.append(BookItem(date: date, title: it.title, amount: -amt, count: 1, petName: it.petName))
  464. }
  465. let dayTotal = -(Double(g.dayTotal) ?? items.reduce(0) { $0 + $1.amount })
  466. groups.append(DayGroup(date: date, total: dayTotal, items: items))
  467. }
  468. groups.sort { $0.date > $1.date }
  469. self.dayGroups = groups
  470. // 检查是否有数据,决定显示列表还是空视图
  471. if dayGroups.isEmpty {
  472. showEmptyView()
  473. } else {
  474. hideEmptyView()
  475. }
  476. self.tableView.reloadData()
  477. }
  478. // MARK: - Demo 数据(本地兜底)
  479. private func reloadDemoData() {
  480. let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
  481. let d1 = fmt.date(from: "2025-08-25")!
  482. let demoPetName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name
  483. let items = [
  484. BookItem(date: d1, title: "指甲剪", amount: -10, count: 1, petName: demoPetName),
  485. BookItem(date: d1, title: "厕所", amount: -50, count: 1, petName: demoPetName)
  486. ]
  487. // 分组
  488. let groups = Dictionary(grouping: items, by: { dayOnly($0.date) })
  489. dayGroups = groups.keys.sorted(by: >).map { day in
  490. let its = groups[day]!.sorted { $0.title < $1.title }
  491. let total = its.reduce(0) { $0 + $1.amount }
  492. return DayGroup(date: day, total: total, items: its)
  493. }
  494. // 顶部汇总卡
  495. let monthTotal = items.reduce(0) { $0 + $1.amount }
  496. let petName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name
  497. summaryCard.configure(title: "\(petName) \(DateFormatter.with("yyyy.MM").string(from: currentMonth))花费", amount: monthTotal)
  498. tableView.reloadData()
  499. }
  500. @objc private func tapAdd() {
  501. // TODO: push 新增账目
  502. print("➕ Add bookkeeping")
  503. }
  504. @objc private func tapPet(_ sender: UIButton) {
  505. selectedPetIndex = sender.tag
  506. updatePetChips()
  507. refetch()
  508. }
  509. @objc private func tapPeriod(_ sender: UIButton) {
  510. periodIndex = sender.tag - 100
  511. updatePeriodChips()
  512. updateMonthLabel() // ← 新增:切换分段后立刻刷新“月份选择”区
  513. refetch()
  514. }
  515. @objc private func prevMonth() {
  516. guard periodIndex != 0 else { return } // “总”下不允许切换
  517. if periodIndex == 1 { // 年视图:按年切
  518. currentMonth = Calendar.current.date(byAdding: .year, value: -1, to: currentMonth)!
  519. } else { // 月视图:按月切
  520. currentMonth = Calendar.current.date(byAdding: .month, value: -1, to: currentMonth)!
  521. }
  522. updateMonthLabel()
  523. refetch()
  524. }
  525. @objc private func nextMonth() {
  526. guard periodIndex != 0 else { return }
  527. if periodIndex == 1 {
  528. currentMonth = Calendar.current.date(byAdding: .year, value: 1, to: currentMonth)!
  529. } else {
  530. currentMonth = Calendar.current.date(byAdding: .month, value: 1, to: currentMonth)!
  531. }
  532. updateMonthLabel()
  533. refetch()
  534. }
  535. @objc private func tapMonthLabel() {
  536. switch periodIndex {
  537. case 1:
  538. showYearPickerSheet()
  539. case 2:
  540. showYearMonthPickerSheet()
  541. default:
  542. break
  543. }
  544. }
  545. private func showYearMonthPickerSheet() {
  546. // 年月两列选择(不显示日)
  547. let cur = currentMonth
  548. let curY = Calendar.current.component(.year, from: cur)
  549. let curM = Calendar.current.component(.month, from: cur)
  550. // 准备年份与默认选中
  551. yearOptions = Array((curY - 10)...(curY + 10))
  552. tempSelectedYear = curY
  553. tempSelectedMonth = curM
  554. let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)
  555. if let p = ac.popoverPresentationController {
  556. p.sourceView = monthLabel
  557. p.sourceRect = monthLabel.bounds
  558. p.permittedArrowDirections = .up
  559. p.delegate = self
  560. }
  561. let pv = UIPickerView()
  562. pv.tag = 99 // 99 表示“年月”两列
  563. pv.dataSource = self
  564. pv.delegate = self
  565. ac.view.addSubview(pv)
  566. pv.translatesAutoresizingMaskIntoConstraints = false
  567. NSLayoutConstraint.activate([
  568. pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor),
  569. pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8),
  570. pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor)
  571. ])
  572. if let yIdx = yearOptions.firstIndex(of: curY) { pv.selectRow(yIdx, inComponent: 0, animated: false) }
  573. pv.selectRow(curM - 1, inComponent: 1, animated: false)
  574. ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in
  575. guard let self = self else { return }
  576. let newDate = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: self.tempSelectedMonth, day: 1)) ?? self.currentMonth
  577. self.currentMonth = newDate
  578. self.updateMonthLabel()
  579. self.refetch()
  580. }))
  581. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  582. present(ac, animated: true)
  583. }
  584. private func showYearPickerSheet() {
  585. // 备选年份(当前年±10年)
  586. let curY = Calendar.current.component(.year, from: currentMonth)
  587. let start = curY - 10
  588. let end = curY + 10
  589. yearOptions = Array(start...end)
  590. tempSelectedYear = curY
  591. let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)
  592. if let p = ac.popoverPresentationController {
  593. p.sourceView = monthLabel
  594. p.sourceRect = monthLabel.bounds
  595. p.permittedArrowDirections = .up
  596. p.delegate = self
  597. }
  598. let pv = UIPickerView()
  599. pv.tag = 98 // 98 表示“仅年份”一列
  600. pv.dataSource = self
  601. pv.delegate = self
  602. if let idx = yearOptions.firstIndex(of: curY) { pv.selectRow(idx, inComponent: 0, animated: false) }
  603. ac.view.addSubview(pv)
  604. pv.translatesAutoresizingMaskIntoConstraints = false
  605. NSLayoutConstraint.activate([
  606. pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor),
  607. pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8),
  608. pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor)
  609. ])
  610. ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in
  611. guard let self = self else { return }
  612. let comps = Calendar.current.dateComponents([.month], from: self.currentMonth)
  613. let new = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: comps.month ?? 1, day: 1)) ?? self.currentMonth
  614. self.currentMonth = new
  615. self.updateMonthLabel()
  616. self.refetch()
  617. }))
  618. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  619. present(ac, animated: true)
  620. }
  621. private func updateMonthLabel() {
  622. switch periodIndex {
  623. case 0: // 总
  624. monthLabel.text = "总"
  625. monthLeft.isHidden = true
  626. monthRight.isHidden = true
  627. case 1: // 年
  628. let y = DateFormatter(); y.dateFormat = "yyyy"
  629. monthLabel.text = y.string(from: currentMonth)
  630. monthLeft.isHidden = false
  631. monthRight.isHidden = false
  632. default: // 月
  633. let f = DateFormatter(); f.dateFormat = "yyyy.MM"
  634. monthLabel.text = f.string(from: currentMonth)
  635. monthLeft.isHidden = false
  636. monthRight.isHidden = false
  637. }
  638. }
  639. private func updatePetChips() {
  640. for (i, v) in petsRow.arrangedSubviews.enumerated() {
  641. (v as? ChipButton)?.isOn = (i == selectedPetIndex)
  642. }
  643. }
  644. private func updatePeriodChips() {
  645. totalChip.isOn = (periodIndex == 0)
  646. yearChip.isOn = (periodIndex == 1)
  647. monthChip.isOn = (periodIndex == 2)
  648. }
  649. // MARK: - Table
  650. func numberOfSections(in tableView: UITableView) -> Int { dayGroups.count }
  651. func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { dayGroups[section].items.count }
  652. func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
  653. let v = DayHeaderView()
  654. let g = dayGroups[section]
  655. v.configure(date: g.date, total: g.total)
  656. return v
  657. }
  658. func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { 50 }
  659. func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  660. let cell = tableView.dequeueReusableCell(withIdentifier: "ExpenseCell", for: indexPath) as! ExpenseCell
  661. let item = dayGroups[indexPath.section].items[indexPath.row]
  662. cell.configure(title: item.title, amount: item.amount, petName: item.petName)
  663. return cell
  664. }
  665. func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  666. tableView.deselectRow(at: indexPath, animated: true)
  667. }
  668. func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
  669. return 121
  670. }
  671. func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
  672. return 121
  673. }
  674. // MARK: - UIPickerView DataSource & Delegate (Year/Year-Month)
  675. func numberOfComponents(in pickerView: UIPickerView) -> Int {
  676. return pickerView.tag == 99 ? 2 : 1
  677. }
  678. func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
  679. if pickerView.tag == 99 {
  680. return component == 0 ? yearOptions.count : monthOptions.count
  681. } else {
  682. return yearOptions.count
  683. }
  684. }
  685. func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
  686. if pickerView.tag == 99 {
  687. if component == 0 {
  688. return "\(yearOptions[row])年"
  689. } else {
  690. return String(format: "%02d月", monthOptions[row])
  691. }
  692. } else {
  693. return "\(yearOptions[row])年"
  694. }
  695. }
  696. func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
  697. if pickerView.tag == 99 {
  698. if component == 0, row >= 0, row < yearOptions.count {
  699. tempSelectedYear = yearOptions[row]
  700. } else if component == 1, row >= 0, row < monthOptions.count {
  701. tempSelectedMonth = monthOptions[row]
  702. }
  703. } else {
  704. if row >= 0 && row < yearOptions.count { tempSelectedYear = yearOptions[row] }
  705. }
  706. }
  707. // MARK: - 空视图管理
  708. private func showEmptyView() {
  709. dayGroups = []
  710. // 设置汇总卡为 0
  711. let petName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name
  712. let suffix: String
  713. let yF = DateFormatter(); yF.dateFormat = "yyyy"
  714. let ymF = DateFormatter(); ymF.dateFormat = "yyyy.MM"
  715. switch periodIndex {
  716. case 0: suffix = "合计花费"
  717. case 1: suffix = "\(yF.string(from: currentMonth))花费"
  718. default: suffix = "\(ymF.string(from: currentMonth))花费"
  719. }
  720. summaryCard.configure(title: "\(petName) \(suffix)", amount: 0)
  721. emptyView.isHidden = false
  722. tableView.reloadData()
  723. }
  724. private func hideEmptyView() {
  725. emptyView.isHidden = true
  726. }
  727. // MARK: - UIPopoverPresentationControllerDelegate fallback for iPad popover
  728. func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) {
  729. if popoverPresentationController.sourceView == nil && popoverPresentationController.barButtonItem == nil {
  730. popoverPresentationController.sourceView = monthLabel.superview ?? view
  731. let base = popoverPresentationController.sourceView!
  732. popoverPresentationController.sourceRect = base.bounds
  733. popoverPresentationController.permittedArrowDirections = .up
  734. }
  735. }
  736. }
  737. // MARK: - 小组件
  738. fileprivate final class ChipButton: UIButton {
  739. var isOn: Bool = false { didSet { refresh() } }
  740. private let inset = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10)
  741. init(title: String) {
  742. super.init(frame: .zero)
  743. setTitle(title, for: .normal)
  744. titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
  745. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  746. layer.cornerRadius = 12
  747. contentEdgeInsets = inset
  748. backgroundColor = .clear
  749. refresh()
  750. }
  751. required init?(coder: NSCoder) { fatalError() }
  752. private func refresh() {
  753. layer.cornerRadius = 12
  754. if isOn {
  755. backgroundColor = UIColor(hex: "#FFE059")
  756. setTitleColor(.black, for: .normal)
  757. } else {
  758. backgroundColor = .clear
  759. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  760. }
  761. }
  762. }
  763. fileprivate final class SegmentChipButton: UIButton {
  764. var isOn: Bool = false { didSet { refresh() } }
  765. private let inset = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
  766. private let masked: CACornerMask
  767. private let cornerRadius: CGFloat
  768. init(title: String, maskedCorners: CACornerMask, cornerRadius: CGFloat = 12) {
  769. self.masked = maskedCorners
  770. self.cornerRadius = cornerRadius
  771. super.init(frame: .zero)
  772. setTitle(title, for: .normal)
  773. titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
  774. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  775. contentEdgeInsets = inset
  776. // 仅指定的角为圆角,其余为直角
  777. layer.cornerRadius = cornerRadius
  778. layer.maskedCorners = masked
  779. layer.masksToBounds = true
  780. backgroundColor = .clear
  781. refresh()
  782. }
  783. required init?(coder: NSCoder) { fatalError() }
  784. private func refresh() {
  785. layer.cornerRadius = cornerRadius
  786. if isOn {
  787. backgroundColor = UIColor(hex: "#FFE059")
  788. setTitleColor(.black, for: .normal)
  789. } else {
  790. backgroundColor = UIColor(hex: "#FEF3C1")
  791. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  792. }
  793. }
  794. }
  795. fileprivate final class SummaryCardView: UIView {
  796. private let container = UIImageView()
  797. private let titleLabel = UILabel()
  798. private let amountLabel = UILabel()
  799. override init(frame: CGRect) {
  800. super.init(frame: frame)
  801. translatesAutoresizingMaskIntoConstraints = false
  802. container.backgroundColor = .white
  803. container.image = UIImage(named: "Home378")
  804. container.layer.cornerRadius = 14
  805. // container.layer.borderWidth = 1
  806. // container.layer.borderColor = UIColor(hex: "#2B2B2B").withAlphaComponent(0.15).cgColor
  807. container.layer.shadowColor = UIColor.black.cgColor
  808. container.layer.shadowOpacity = 0.06
  809. container.layer.shadowRadius = 6
  810. container.layer.shadowOffset = .init(width: 0, height: 2)
  811. addSubview(container)
  812. [titleLabel, amountLabel].forEach { container.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
  813. titleLabel.font = .systemFont(ofSize: 14)
  814. titleLabel.textColor = UIColor(hex: "#2B2B2B")
  815. amountLabel.font = .systemFont(ofSize: 22, weight: .semibold)
  816. amountLabel.textColor = UIColor(hex: "#2B2B2B")
  817. amountLabel.textAlignment = .right
  818. container.translatesAutoresizingMaskIntoConstraints = false
  819. NSLayoutConstraint.activate([
  820. container.heightAnchor.constraint(equalToConstant: 100),
  821. container.leadingAnchor.constraint(equalTo: leadingAnchor),
  822. container.trailingAnchor.constraint(equalTo: trailingAnchor),
  823. container.topAnchor.constraint(equalTo: topAnchor),
  824. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  825. titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  826. titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  827. amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  828. amountLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  829. ])
  830. }
  831. required init?(coder: NSCoder) { fatalError() }
  832. func configure(title: String, amount: Double) {
  833. titleLabel.text = title
  834. amountLabel.text = String(format: "¥ %.2f", abs(amount))
  835. }
  836. }
  837. fileprivate final class DayHeaderView: UIView {
  838. private let dateLabel = UILabel()
  839. private let totalLabel = UILabel()
  840. private let under = UIImageView()
  841. override init(frame: CGRect) {
  842. super.init(frame: frame)
  843. backgroundColor = .clear
  844. dateLabel.font = .systemFont(ofSize: 13, weight: .semibold)
  845. dateLabel.textColor = UIColor(hex: "#2B2B2B")
  846. totalLabel.font = .systemFont(ofSize: 13)
  847. totalLabel.textColor = UIColor(hex: "#2B2B2B")
  848. // under.backgroundColor = UIColor(hex: "#FFE059")
  849. // under.layer.cornerRadius = 2
  850. under.image = UIImage(named: "Bookkeep395")
  851. addSubview(dateLabel); addSubview(totalLabel); addSubview(under)
  852. dateLabel.translatesAutoresizingMaskIntoConstraints = false
  853. totalLabel.translatesAutoresizingMaskIntoConstraints = false
  854. under.translatesAutoresizingMaskIntoConstraints = false
  855. NSLayoutConstraint.activate([
  856. dateLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  857. dateLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  858. totalLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  859. totalLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  860. under.heightAnchor.constraint(equalToConstant: 12),
  861. // under.widthAnchor.constraint(equalToConstant: 60),
  862. under.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: -5),
  863. under.leadingAnchor.constraint(equalTo: dateLabel.leadingAnchor),
  864. under.trailingAnchor.constraint(equalTo: dateLabel.trailingAnchor)
  865. ])
  866. }
  867. required init?(coder: NSCoder) { fatalError() }
  868. func configure(date: Date, total: Double) {
  869. let f1 = DateFormatter(); f1.dateFormat = "yyyy.MM.dd EEEE"; f1.locale = Locale(identifier: "zh_CN")
  870. dateLabel.text = f1.string(from: date)
  871. totalLabel.text = String(format: "支出: ¥ %.2f", abs(total))
  872. }
  873. }
  874. fileprivate final class PetBadgeView: UIView {
  875. private let iconView = UIImageView()
  876. private let nameLabel = UILabel()
  877. private let container = UIView()
  878. override init(frame: CGRect) {
  879. super.init(frame: frame)
  880. // 带描边的圆角白底
  881. container.backgroundColor = .white
  882. container.layer.cornerRadius = 7
  883. container.layer.masksToBounds = true
  884. container.layer.borderWidth = 1
  885. container.layer.borderColor = UIColor(hex: "#5B4227").cgColor
  886. addSubview(container)
  887. iconView.contentMode = .scaleAspectFit
  888. iconView.layer.cornerRadius = 5
  889. iconView.layer.masksToBounds = true
  890. nameLabel.font = .systemFont(ofSize: 12)
  891. nameLabel.textColor = UIColor(hex: "#5B4227")
  892. let stack = UIStackView(arrangedSubviews: [iconView, nameLabel])
  893. stack.axis = .horizontal
  894. stack.alignment = .center
  895. stack.spacing = 6
  896. container.addSubview(stack)
  897. container.translatesAutoresizingMaskIntoConstraints = false
  898. stack.translatesAutoresizingMaskIntoConstraints = false
  899. iconView.translatesAutoresizingMaskIntoConstraints = false
  900. NSLayoutConstraint.activate([
  901. container.topAnchor.constraint(equalTo: topAnchor),
  902. container.leadingAnchor.constraint(equalTo: leadingAnchor),
  903. container.trailingAnchor.constraint(equalTo: trailingAnchor),
  904. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  905. stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
  906. stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
  907. stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
  908. stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
  909. iconView.widthAnchor.constraint(equalToConstant: 10),
  910. iconView.heightAnchor.constraint(equalToConstant: 10)
  911. ])
  912. }
  913. required init?(coder: NSCoder) { fatalError() }
  914. func set(name: String, icon: UIImage?) {
  915. nameLabel.text = name
  916. iconView.image = icon ?? UIImage(named: "peihead")
  917. }
  918. }
  919. fileprivate final class ExpenseCell: UITableViewCell {
  920. // 手绘边框容器(使用切图)
  921. private let container = UIImageView()
  922. // 上半区元素
  923. private let icon = UIImageView(image: UIImage(named: "peihead"))
  924. private let titleLabel = UILabel()
  925. private let amountLabel = UILabel()
  926. // 中部整条虚线
  927. private let dashLayer = CAShapeLayer()
  928. // 右下角宠物名徽章
  929. private let petBadge = PetBadgeView()
  930. override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
  931. super.init(style: style, reuseIdentifier: reuseIdentifier)
  932. backgroundColor = .clear
  933. selectionStyle = .none
  934. // 手绘边框底图
  935. container.image = UIImage(named: "Home378")
  936. container.contentMode = .scaleToFill
  937. contentView.addSubview(container)
  938. container.translatesAutoresizingMaskIntoConstraints = false
  939. NSLayoutConstraint.activate([
  940. container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
  941. container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
  942. container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
  943. container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
  944. container.heightAnchor.constraint(greaterThanOrEqualToConstant: 121)
  945. ])
  946. // 上半区
  947. icon.contentMode = .scaleAspectFill
  948. icon.layer.cornerRadius = 8
  949. icon.layer.masksToBounds = true
  950. titleLabel.font = .systemFont(ofSize: 16, weight: .regular)
  951. titleLabel.textColor = UIColor(hex: "#2B2B2B")
  952. amountLabel.font = .systemFont(ofSize: 16, weight: .heavy)
  953. amountLabel.textColor = UIColor(hex: "#2B2B2B")
  954. amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  955. amountLabel.textAlignment = .right
  956. [icon, titleLabel, amountLabel, petBadge].forEach { v in
  957. v.translatesAutoresizingMaskIntoConstraints = false
  958. container.addSubview(v)
  959. }
  960. NSLayoutConstraint.activate([
  961. icon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  962. icon.topAnchor.constraint(equalTo: container.topAnchor, constant: 16),
  963. icon.widthAnchor.constraint(equalToConstant: 32),
  964. icon.heightAnchor.constraint(equalToConstant: 32),
  965. titleLabel.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 14),
  966. titleLabel.topAnchor.constraint(equalTo: icon.topAnchor,constant: 4),
  967. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: amountLabel.leadingAnchor, constant: -8),
  968. amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  969. amountLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
  970. petBadge.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  971. petBadge.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  972. petBadge.heightAnchor.constraint(greaterThanOrEqualToConstant: 14)
  973. ])
  974. // 中部整条虚线(添加在容器上)
  975. dashLayer.strokeColor = UIColor(hex: "#CFCFCF").cgColor
  976. dashLayer.fillColor = UIColor.clear.cgColor
  977. dashLayer.lineDashPattern = [6, 6]
  978. dashLayer.lineWidth = 1
  979. container.layer.addSublayer(dashLayer)
  980. }
  981. required init?(coder: NSCoder) { fatalError() }
  982. override func layoutSubviews() {
  983. super.layoutSubviews()
  984. // 让中部虚线横穿整个卡片(留左右内边距)
  985. let left: CGFloat = 16
  986. let right: CGFloat = bounds.width - 16
  987. // 取上半区最大底部再往下 16 作为分隔线位置
  988. let topMax = max(icon.frame.maxY, titleLabel.frame.maxY, amountLabel.frame.maxY)
  989. let y = topMax + 16
  990. let path = UIBezierPath()
  991. path.move(to: CGPoint(x: left, y: y))
  992. path.addLine(to: CGPoint(x: right, y: y))
  993. dashLayer.path = path.cgPath
  994. }
  995. func configure(title: String, amount: Double, petName: String) {
  996. titleLabel.text = title
  997. let prefix = amount < 0 ? "- " : "+ "
  998. amountLabel.text = String(format: "%@¥ %.2f", prefix, abs(amount))
  999. petBadge.set(name: petName, icon: UIImage(named: "peihead"))
  1000. }
  1001. }
  1002. // MARK: - Utils
  1003. fileprivate func dayOnly(_ d: Date) -> Date {
  1004. let cal = Calendar.current
  1005. return cal.date(from: cal.dateComponents([.year, .month, .day], from: d))!
  1006. }
  1007. private extension DateFormatter {
  1008. static func with(_ f: String) -> DateFormatter {
  1009. let d = DateFormatter()
  1010. d.dateFormat = f
  1011. return d
  1012. }
  1013. }
  1014. // MARK: - HEX 颜色
  1015. extension UIColor {
  1016. convenience init(hex: String, alpha: CGFloat = 1.0) {
  1017. var hexSanitized = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
  1018. var int: UInt64 = 0
  1019. Scanner(string: hexSanitized).scanHexInt64(&int)
  1020. let a, r, g, b: UInt64
  1021. switch hexSanitized.count {
  1022. case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
  1023. case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
  1024. case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
  1025. default: (a, r, g, b) = (255, 0, 0, 0)
  1026. }
  1027. self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
  1028. }
  1029. }