diff --git a/AGENTS.md b/AGENTS.md index 525aa3b..1bbd9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,12 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、 - View 层只负责展示与事件转发,业务逻辑放在 ViewModel - ViewModel 不直接持有 UIView / UIViewController 引用 +### Swift 并发 + +- 工程已将编译选项 **`SWIFT_DEFAULT_ACTOR_ISOLATION`** 设置为 **`nonisolated`**(见 Xcode Build Settings) +- 即类型与方法**默认不**隔离到 `@MainActor`;需要主线程/UI 相关逻辑时,再显式标注 `@MainActor` 或使用 `MainActor.assumeIsolated` / `await MainActor.run` +- 仅在确实需要脱离默认隔离(如 delegate 回调、静态工具方法)时再显式写 `nonisolated`,避免冗余标注 + ### 注释 - 定义的**类**、**结构体**、**方法**均须添加注释,说明其职责、用途或行为 @@ -117,5 +123,6 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、 - 同步进度详见 [功能同步Checklist.md](功能同步Checklist.md) - 参考工程:`../suixinkan_ios_new`(SwiftUI) +- 高德 Key:在 `suixinkan_ios/Info.plist` 的 `AMapAPIKey` 填入控制台 Key(真机地图生效) - 构建:`xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios build` - 测试:`xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios -destination 'platform=iOS Simulator,name=iPhone 17'` diff --git a/Podfile b/Podfile index 1ac2c29..8d9923c 100644 --- a/Podfile +++ b/Podfile @@ -6,15 +6,90 @@ target 'suixinkan_ios' do pod 'SnapKit', '~> 5.7' pod 'Kingfisher', '~> 8.0' + # 高德地图 SDK:仅真机构建时链接;模拟器通过 post_install 剥离 + pod 'AMap3DMap-NO-IDFA', '~> 11.1' + pod 'AMapSearch-NO-IDFA', '~> 9.7' + pod 'AMapLocation-NO-IDFA', '~> 2.11' + target 'suixinkan_iosTests' do inherit! :search_paths end end +SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish + -ObjC -l"c++" -l"z" + -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" + -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" + -framework "OpenGLES" -framework "QuartzCore" -framework "Security" + -framework "SystemConfiguration" +FLAGS + post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' end end + + %w[Pods-suixinkan_ios Pods-suixinkan_iosTests].each do |target_name| + support_dir = File.join(installer.sandbox.root, 'Target Support Files', target_name) + Dir.glob(File.join(support_dir, '*.xcconfig')).each do |path| + content = File.read(path) + content.gsub!("EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64\n", '') + + unless content.include?('OTHER_LDFLAGS[sdk=iphonesimulator*]') + content << "\nOTHER_LDFLAGS[sdk=iphonesimulator*] = #{SIMULATOR_OTHER_LDFLAGS}\n" + content << "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*] = $(inherited)\n" + end + + File.write(path, content) + end + end + + installer.pods_project.targets.each do |target| + next unless target.name.start_with?('AMap') + + target.build_configurations.each do |config| + config.build_settings['SUPPORTED_PLATFORMS'] = 'iphoneos' + end + end + + resources_script = File.join( + installer.sandbox.root, + 'Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh' + ) + if File.exist?(resources_script) + content = File.read(resources_script) + unless content.include?('SKIP_AMAP_RESOURCES_FOR_SIMULATOR') + content.sub!( + "RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt", + <<~SHELL + # SKIP_AMAP_RESOURCES_FOR_SIMULATOR + if [ "${EFFECTIVE_PLATFORM_NAME}" = "-iphonesimulator" ]; then + exit 0 + fi + + RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt + SHELL + ) + File.write(resources_script, content) + end + end + + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.user_project.native_targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = '' + config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO' + + next unless target.name == 'suixinkan_ios' + + existing = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] || '$(inherited)' + unless existing.to_s.include?('AMAP_ENABLED') + config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] = "#{existing} AMAP_ENABLED" + end + end + end + aggregate_target.user_project.save + end end diff --git a/Podfile.lock b/Podfile.lock index 58a8163..2711869 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,20 +1,38 @@ PODS: + - AMap3DMap-NO-IDFA (11.2.000): + - AMapFoundation-NO-IDFA (>= 1.9.0) + - AMapFoundation-NO-IDFA (1.9.0) + - AMapLocation-NO-IDFA (2.12.0): + - AMapFoundation-NO-IDFA (>= 1.9.0) + - AMapSearch-NO-IDFA (9.8.0): + - AMapFoundation-NO-IDFA (>= 1.9.0) - Kingfisher (8.10.0) - SnapKit (5.7.1) DEPENDENCIES: + - AMap3DMap-NO-IDFA (~> 11.1) + - AMapLocation-NO-IDFA (~> 2.11) + - AMapSearch-NO-IDFA (~> 9.7) - Kingfisher (~> 8.0) - SnapKit (~> 5.7) SPEC REPOS: trunk: + - AMap3DMap-NO-IDFA + - AMapFoundation-NO-IDFA + - AMapLocation-NO-IDFA + - AMapSearch-NO-IDFA - Kingfisher - SnapKit SPEC CHECKSUMS: + AMap3DMap-NO-IDFA: f53ee0cb33db83c1a08856b5995e53a0c2096158 + AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e + AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18 + AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a -PODFILE CHECKSUM: dda5ec1556392b8b6a9747637c579bd75423177c +PODFILE CHECKSUM: 8f89212788a9d27c878d93b811a6eaa818955f8c COCOAPODS: 1.16.2 diff --git a/Scripts/add_swift_doc_comments.py b/Scripts/add_swift_doc_comments.py new file mode 100644 index 0000000..00f18f9 --- /dev/null +++ b/Scripts/add_swift_doc_comments.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""为 Swift 源码中缺少 /// 的类型与方法补充中文文档注释。""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "suixinkan_ios" + +SKIP_DIRS = {"Pods", ".build", "DerivedData"} + +TYPE_DECL = re.compile( + r"^(?P\s*)" + r"(?:(?:@\w+(?:\([^)]*\))?\s+|@MainActor\s+)*)" + r"(?:(?P(?:final|private|fileprivate|public|internal|open)\s+)*)" + r"(?Pclass|struct|enum|protocol|actor)\s+(?P\w+)" +) + +FUNC_DECL = re.compile( + r"^(?P\s*)" + r"(?:(?:@\w+(?:\([^)]*\))?\s+|@MainActor\s+|@objc\s+)*)" + r"(?:(?P(?:override|private|fileprivate|public|internal|static|class|mutating|nonisolated)\s+)*)" + r"func\s+(?P\w+)" +) + +INIT_DECL = re.compile( + r"^(?P\s*)" + r"(?:(?:@\w+(?:\([^)]*\))?\s+|@available\([^)]+\)\s+)*)" + r"(?:(?:override|required|convenience|private|fileprivate|public|internal)\s+)*" + r"init(\?|\()" +) + +METHOD_DOCS: dict[str, str] = { + "viewDidLoad": "视图加载完成后的 UI 初始化与数据绑定。", + "viewWillAppear": "视图即将展示,刷新可见状态。", + "viewDidAppear": "视图已展示,执行需等待布局完成的逻辑。", + "viewWillDisappear": "视图即将消失,保存或清理临时状态。", + "viewDidDisappear": "视图已消失。", + "viewDidLayoutSubviews": "子视图布局完成后调整依赖 frame 的 UI。", + "deinit": "释放资源并解除绑定。", + "numberOfSections": "返回列表 section 数量。", + "numberOfRowsInSection": "返回指定 section 的行数。", + "cellForRowAt": "配置并返回指定 indexPath 的 Cell。", + "didSelectRowAt": "处理行选中事件。", + "heightForRowAt": "返回指定行高度。", + "titleForHeaderInSection": "返回 section 标题。", + "numberOfItemsInSection": "返回指定 section 的 item 数量。", + "cellForItemAt": "配置并返回指定 indexPath 的 Collection Cell。", + "didSelectItemAt": "处理 item 选中事件。", + "sizeForItemAt": "返回 item 尺寸。", + "makeUIViewController": "创建并返回对应路由的 ViewController。", + "updateUIViewController": "更新 ViewController 状态。", + "encode": "编码为可持久化或传输的数据。", + "decode": "从外部数据解码为模型实例。", + "hash": "计算哈希值,用于集合与 Diffable 标识。", + "==": "判断两个实例是否相等。", +} + +TYPE_SUFFIX_DOCS: list[tuple[str, str]] = [ + ("ViewController", "页面控制器,负责 UI 展示与用户交互。"), + ("ViewModel", "视图模型,负责业务逻辑与状态管理。"), + ("TableViewCell", "列表 Cell,负责单行内容展示。"), + ("CollectionViewCell", "集合视图 Cell,负责单项内容展示。"), + ("Cell", "列表或网格 Cell,负责单项内容展示。"), + ("Serving", "服务协议,定义模块对外能力。"), + ("API", "网络接口封装。"), + ("Store", "本地持久化或内存存储。"), + ("Provider", "能力提供者,封装外部依赖。"), + ("Coordinator", "流程协调器,串联多步业务。"), + ("Router", "路由跳转封装。"), + ("Manager", "管理器,负责模块级生命周期与调度。"), + ("Service", "服务层,封装可复用业务能力。"), + ("Error", "错误类型定义。"), + ("State", "状态实体。"), + ("Context", "上下文,持有跨页面共享状态。"), + ("Delegate", "代理协议或实现。"), + ("DataSource", "数据源协议或实现。"), + ("Loader", "加载器,负责异步拉取与组装数据。"), + ("Runtime", "运行时调度器,负责后台任务与状态同步。"), + ("Client", "客户端封装,负责与外部服务通信。"), + ("Factory", "工厂,负责按路由或参数创建 ViewController。"), + ("Driver", "驱动器,供 UI 测试或自动化场景使用。"), +] + + +def preceding_doc_index(lines: list[str], index: int) -> int | None: + """向上查找最近的 /// 或阻断性声明,返回 /// 行号。""" + j = index - 1 + while j >= 0: + stripped = lines[j].strip() + if stripped == "": + j -= 1 + continue + if stripped.startswith("///"): + return j + if stripped.startswith("@") or stripped.startswith("// MARK:"): + j -= 1 + continue + break + return None + + +def has_doc_comment(lines: list[str], index: int) -> bool: + return preceding_doc_index(lines, index) is not None + + +def camel_to_chinese_hint(name: str) -> str: + if name in METHOD_DOCS: + return METHOD_DOCS[name] + if name.startswith("handle"): + return f"处理{name[6:]}相关事件。" + if name.startswith("load"): + return f"加载{name[4:]}数据。" + if name.startswith("fetch"): + return f"请求{name[5:]}数据。" + if name.startswith("reload"): + return f"刷新{name[6:]}。" + if name.startswith("update"): + return f"更新{name[6:]}状态。" + if name.startswith("configure"): + return f"配置{name[9:]}展示内容。" + if name.startswith("setup"): + return f"初始化{name[5:]}相关 UI 或状态。" + if name.startswith("bind"): + return f"绑定{name[4:]}回调或数据。" + if name.startswith("make"): + return f"创建{name[4:]}实例。" + if name.startswith("wire"): + return f"连接{name[4:]}与 UI 刷新逻辑。" + if name.startswith("sync"): + return f"同步{name[4:]}状态。" + if name.startswith("register"): + return f"注册{name[8:]}。" + if name.startswith("did"): + return f"{name} 回调处理。" + if name.startswith("will"): + return f"{name} 回调处理。" + if name.startswith("on"): + return f"响应{name[2:]}事件。" + if name.startswith("is") or name.startswith("has"): + return f"判断{name}条件。" + if name.startswith("validate"): + return f"校验{name[8:]}输入或状态。" + if name.startswith("submit"): + return f"提交{name[6:]}。" + if name.startswith("show"): + return f"展示{name[4:]}。" + if name.startswith("hide"): + return f"隐藏{name[4:]}。" + if name.startswith("present"): + return f"弹出{name[7:]}页面。" + if name.startswith("push"): + return f"Push {name[4:]}页面。" + if name.startswith("pop"): + return f"Pop {name[3:]}页面。" + if name.startswith("start"): + return f"启动{name[5:]}流程。" + if name.startswith("stop"): + return f"停止{name[4:]}流程。" + if name.startswith("reset"): + return f"重置{name[5:]}状态。" + if name.startswith("parse"): + return f"解析{name[5:]}数据。" + if name.startswith("normalize"): + return f"规范化{name[9:]}格式。" + if name.startswith("refresh"): + return f"刷新{name[7:]}展示。" + if name.startswith("attach"): + return f"挂载{name[6:]}到目标视图层级。" + if name.startswith("mount"): + return f"挂载{name[5:]}子控制器。" + if name.startswith("run"): + return f"执行{name[3:]}循环或任务。" + if name.startswith("poll"): + return f"轮询{name[4:]}数据。" + if name.startswith("resume"): + return f"恢复{name[6:]}流程。" + if name.startswith("enqueue"): + return f"入队等待{name[7:]}处理。" + if name.startswith("route"): + return f"根据{name[5:]}执行路由跳转。" + if name.startswith("navigate"): + return f"导航至{name[8:]}页面。" + if name == "init": + return "初始化实例。" + return f"{name} 方法实现。" + + +def type_doc(name: str, kind: str) -> str: + for suffix, desc in TYPE_SUFFIX_DOCS: + if name.endswith(suffix): + prefix = name[: -len(suffix)] + if prefix: + return f"{prefix}{desc}" + return desc + kind_map = { + "enum": "枚举,定义相关常量或状态。", + "struct": "结构体,封装数据实体。", + "protocol": "协议,定义能力约束。", + "actor": "Actor,保证并发安全的状态访问。", + "class": "类,封装业务逻辑或 UI 组件。", + } + return kind_map.get(kind, f"{name} 类型定义。") + + +def should_skip_init(line: str) -> bool: + return "unavailable" in line or "init?(coder" in line + + +def should_skip_func(name: str) -> bool: + return name in {"body", "callAsFunction", "previewLayout"} + + +def process_file(path: Path) -> bool: + text = path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + output: list[str] = [] + changed = False + i = 0 + + while i < len(lines): + line = lines[i] + stripped = line.rstrip("\n") + + type_match = TYPE_DECL.match(stripped) + func_match = FUNC_DECL.match(stripped) + init_match = INIT_DECL.match(stripped) + + if type_match and not has_doc_comment(lines, i): + indent = type_match.group("indent") + name = type_match.group("name") + kind = type_match.group("kind") + output.append(f"{indent}/// {type_doc(name, kind)}\n") + changed = True + + elif func_match and not has_doc_comment(lines, i): + name = func_match.group("name") + if not should_skip_func(name): + indent = func_match.group("indent") + output.append(f"{indent}/// {camel_to_chinese_hint(name)}\n") + changed = True + + elif init_match and not has_doc_comment(lines, i) and not should_skip_init(stripped): + indent = init_match.group("indent") + output.append(f"{indent}/// 初始化实例。\n") + changed = True + + output.append(line) + i += 1 + + if changed: + path.write_text("".join(output), encoding="utf-8") + return changed + + +def main() -> int: + target = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT + changed_files: list[str] = [] + + for _ in range(3): + round_changed: list[str] = [] + for path in sorted(target.rglob("*.swift")): + if any(part in SKIP_DIRS for part in path.parts): + continue + if process_file(path): + round_changed.append(str(path.relative_to(ROOT.parent))) + changed_files = round_changed + if not round_changed: + break + + print(f"Updated {len(changed_files)} files in last pass") + for file in changed_files: + print(f" - {file}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/polish_swift_doc_comments.py b/Scripts/polish_swift_doc_comments.py new file mode 100644 index 0000000..e511525 --- /dev/null +++ b/Scripts/polish_swift_doc_comments.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""修正自动生成的低质量 Swift 文档注释。""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "suixinkan_ios" + +METHOD_OVERRIDES: dict[str, str] = { + "startIfNeeded": "条件满足时启动轮询任务。", + "runLoop": "按间隔持续轮询队列数据。", + "pollOnce": "执行一次队列数据拉取与播报判断。", + "pollIntervalSeconds": "根据前后台状态返回轮询间隔秒数。", + "selectedSpotId": "读取用户当前选中的打卡点 ID。", + "resetSnapshot": "重置播报状态与上次轮询快照。", + "beginBackgroundTask": "申请后台任务,保证进入后台时仍可短时轮询。", + "endBackgroundTask": "结束后台任务。", + "resumePending": "恢复所有等待播报结束的 continuation。", + "enqueue": "将语音文本加入播报队列,可选择替换待播内容。", + "registerNavigationBridge": "向 UIKit 导航桥注册当前 Tab 的 NavigationController。", + "syncFromRouterPath": "根据 Router 路径深度补齐或回退导航栈。", + "syncRouterPathFromStack": "导航栈变化后裁剪 Router 路径,保持双向同步。", + "observeContextChanges": "监听权限与账号上下文变化并刷新菜单。", + "rebuildMenus": "按当前角色权限重建首页菜单与常用应用。", + "menuItem": "按 URI 解析并返回可用菜单项。", + "startCountdownTimerIfNeeded": "在线状态下启动位置上报倒计时。", + "scenicTapped": "点击景区名称,跳转景区选择页。", + "reminderTapped": "选择位置上报提前提醒时间。", + "locationReportTapped": "跳转位置上报页面。", + "paymentTapped": "跳转立即收款页面。", + "taskCreateTapped": "跳转提交任务页面。", + "onlineTapped": "切换在线/离线状态并更新倒计时。", + "setupTopBar": "搭建首页顶部景区选择栏。", + "setupTableView": "配置首页分组列表布局与注册 Cell。", + "makeStatusCard": "构建在线状态与倒计时卡片。", + "makeLocationCard": "构建位置上报提醒卡片。", + "makeStoreCard": "构建门店信息卡片。", + "makeQuickActionsRow": "构建快捷操作按钮行。", + "configureViews": "初始化子视图与约束。", + "refreshPresentation": "根据当前状态刷新展示或隐藏动画。", + "showBanner": "展示 Toast 横幅动画。", + "hideBanner": "隐藏 Toast 横幅动画。", + "loadAnimation": "加载 Lottie 动画资源。", + "route": "解析推送载荷并执行路由跳转。", + "navigateHomeRoute": "切换至首页 Tab 并 Push 目标路由。", + "hexString": "将二进制数据转为十六进制字符串。", + "receiveLoop": "持续接收 WebSocket 消息并分发处理。", + "stringValue": "安全地将任意值转为字符串。", + "homeRouteURI": "将首页路由编码为 URI 字符串。", + "make": "按路由参数创建 ViewController。", + "fail": "记录校验失败并返回 false。", + "normalizedError": "将后端错误信息归一化为用户可读中文。", + "amountText": "格式化金额展示文本。", + "summaryCard": "创建统计摘要卡片视图。", + "selectPeriod": "切换统计周期并重新加载数据。", + "reload": "加载或刷新页面数据。", + "loadMore": "加载下一页列表数据。", + "updateFilterTitle": "更新筛选条件标题展示。", + "updateSummary": "刷新钱包摘要区域。", + "setupHeader": "搭建页面头部区域。", + "updateTitle": "刷新导航或页面标题。", + "setupFormHeader": "搭建表单页头部说明区域。", + "wireViewModel": "绑定 ViewModel 变更回调并触发列表刷新。", + "bindViewModel": "绑定 ViewModel 数据变更并刷新 UI。", + "handleRefresh": "下拉刷新触发,重新加载页面数据。", + "mountChild": "按登录阶段挂载对应子控制器。", + "updateScenicTitle": "刷新顶部景区名称展示。", +} + +LINE_REPLACEMENTS: dict[str, str] = { + "/// speechSynthesizer 方法实现。": "/// 语音播报生命周期回调。", + "/// 处理相关事件。": "/// 更新轮询结果并根据队列变化触发语音播报。", + "/// tableView 方法实现。": "/// UITableView 数据源或代理回调。", + "/// 启动IfNeeded流程。": "/// 条件满足时启动轮询任务。", + "/// 执行Loop循环或任务。": "/// 按间隔持续轮询队列数据。", + "/// 轮询Once数据。": "/// 执行一次队列数据拉取与播报判断。", + "/// 轮询IntervalSeconds数据。": "/// 根据前后台状态返回轮询间隔秒数。", + "/// 重置Snapshot状态。": "/// 重置播报状态与上次轮询快照。", + "/// 恢复Pending流程。": "/// 恢复所有等待播报结束的 continuation。", + "/// 更新ScenicTitle状态。": "/// 刷新顶部景区名称展示。", + "/// 绑定ViewModel回调或数据。": "/// 绑定 ViewModel 数据变更并刷新 UI。", + "/// 响应lineTapped事件。": "/// 切换在线/离线状态并更新倒计时。", + "/// observeContextChanges 方法实现。": "/// 监听权限与账号上下文变化并刷新菜单。", + "/// rebuildMenus 方法实现。": "/// 按当前角色权限重建首页菜单与常用应用。", + "/// menuItem 方法实现。": "/// 按 URI 解析并返回可用菜单项。", + "/// 启动CountdownTimerIfNeeded流程。": "/// 在线状态下启动位置上报倒计时。", + "/// scenicTapped 方法实现。": "/// 点击景区名称,跳转景区选择页。", + "/// reminderTapped 方法实现。": "/// 选择位置上报提前提醒时间。", + "/// locationReportTapped 方法实现。": "/// 跳转位置上报页面。", + "/// paymentTapped 方法实现。": "/// 跳转立即收款页面。", + "/// taskCreateTapped 方法实现。": "/// 跳转提交任务页面。", + "/// selectedSpotId 方法实现。": "/// 读取用户当前选中的打卡点 ID。", + "/// beginBackgroundTask 方法实现。": "/// 申请后台任务,保证进入后台时仍可短时轮询。", + "/// endBackgroundTask 方法实现。": "/// 结束后台任务。", +} + +METHOD_LINE = re.compile(r"^(?P\s*)/// (?P\w+) 方法实现。\s*$") +FUNC_LINE = re.compile(r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:override\s+|private\s+|static\s+)*func\s+(?P\w+)") + +WORD_HINTS: dict[str, str] = { + "Tapped": "点击", + "Setup": "初始化", + "Configure": "配置", + "Update": "更新", + "Reload": "刷新", + "Load": "加载", + "Fetch": "请求", + "Create": "创建", + "Delete": "删除", + "Clear": "清空", + "Copy": "复制", + "Show": "展示", + "Hide": "隐藏", + "Start": "启动", + "Stop": "停止", + "Cancel": "取消", + "Confirm": "确认", + "Apply": "提交", + "Parse": "解析", + "Format": "格式化", + "Decode": "解码", + "Encode": "编码", + "Validate": "校验", + "Submit": "提交", + "Toggle": "切换", + "Refresh": "刷新", + "Build": "构建", + "Make": "创建", + "Present": "弹出", + "Dismiss": "关闭", + "Select": "选择", + "Filter": "筛选", + "Handle": "处理", + "Check": "检查", + "Request": "请求", + "Register": "注册", + "Bind": "绑定", + "Sync": "同步", + "Mount": "挂载", + "Embed": "嵌入", + "Generate": "生成", + "Edit": "编辑", + "Add": "添加", + "Reset": "重置", + "Save": "保存", + "Open": "打开", + "Close": "关闭", + "Login": "登录", + "Logout": "登出", + "Complete": "完成", + "Continue": "继续", + "Consume": "消费", + "Dedupe": "去重", + "Scanner": "扫码", + "Camera": "相机", + "Permission": "权限", + "Avatar": "头像", + "Header": "头部", + "Card": "卡片", + "Row": "行", + "Field": "字段", + "Timer": "计时器", + "Countdown": "倒计时", + "Queue": "排队", + "Order": "订单", + "Task": "任务", + "Project": "项目", + "Scenic": "景区", + "Store": "门店", + "Wallet": "钱包", + "Payment": "支付", + "Album": "相册", + "Schedule": "排班", + "Invite": "邀请", + "Message": "消息", + "Location": "位置", + "Live": "直播", + "Flyer": "飞手", + "Punch": "打卡", + "Detail": "详情", + "List": "列表", + "Summary": "摘要", + "Amount": "金额", + "Code": "验证码", + "QR": "二维码", + "URL": "链接", + "Text": "文本", + "Title": "标题", + "Form": "表单", + "Keyboard": "键盘", + "Business": "营业", + "Time": "时间", + "Date": "日期", + "Folder": "文件夹", + "File": "文件", + "Broadcast": "播报", + "Voice": "语音", + "Background": "后台", + "Foreground": "前台", + "Remote": "远程", + "Scan": "扫码", + "Verify": "验证", + "Collection": "集合", + "Table": "列表", + "View": "视图", + "Model": "模型", + "Data": "数据", + "Payload": "载荷", + "Snapshot": "快照", + "Runtime": "运行时", + "Settings": "设置", + "Menu": "菜单", + "Route": "路由", + "Navigation": "导航", + "Tab": "Tab", + "Badge": "角标", + "Filter": "筛选", + "Search": "搜索", + "Upload": "上传", + "Download": "下载", + "Image": "图片", + "Video": "视频", + "Audio": "音频", + "Map": "地图", + "Spot": "打卡点", + "Ticket": "票号", + "Stats": "统计", + "Report": "上报", + "Audit": "审核", + "Settlement": "结算", + "Withdrawal": "提现", + "Deposit": "押金", + "Refund": "退款", + "WriteOff": "核销", + "Pilot": "飞手", + "Certification": "认证", + "RealName": "实名", + "Account": "账号", + "Switch": "切换", + "Profile": "个人中心", + "Auth": "认证", + "Session": "会话", + "Token": "令牌", + "Push": "推送", + "Notification": "通知", + "Socket": "WebSocket", + "Client": "客户端", + "Provider": "提供者", + "Factory": "工厂", + "Helper": "工具", + "Extension": "扩展", + "IfNeeded": "(按需)", + "And": "并", + "Or": "或", + "From": "从", + "To": "至", + "For": "用于", + "With": "附带", + "Without": "不带", + "All": "全部", + "Current": "当前", + "Selected": "选中", + "Pending": "待处理", + "Active": "活跃", + "Empty": "空", + "Default": "默认", + "Custom": "自定义", + "Local": "本地", + "Remote": "远程", + "Legacy": "兼容旧版", + "Normalized": "规范化", + "Coerced": "强制转换", + "Lossy": "宽松", + "Deduplicated": "去重", + "Sorted": "排序", + "Changed": "变更", + "Interval": "间隔", + "Threshold": "阈值", + "Positive": "正数", + "Decimal": "小数", + "Int": "整数", + "String": "字符串", + "Bool": "布尔", + "Value": "值", + "Object": "对象", + "JSON": "JSON", + "LatLng": "经纬度", + "LngLat": "经纬度", + "Pair": "坐标对", + "LooksLike": "判断是否符合", + "Fill": "填充", + "Cycle": "循环切换", + "ApplyViewModel": "应用 ViewModel 状态", + "Wire": "连接", + "Dispose": "释放订阅", + "Embed": "嵌入", + "Labeled": "带标签", + "LongValid": "长期有效", + "ClearListAndDetail": "清空列表与详情", + "ClearQueueData": "清空排队数据", + "ClearMessages": "清空消息", + "ClearFolders": "清空文件夹", + "CheckPermissionAndStart": "检查权限并启动", + "ConsumePendingRemoteCalledTickets": "消费待处理的远程叫号", + "ConsumePendingScanCodeIfNeeded": "按需消费待处理扫码结果", + "FillFormIfNeeded": "按需回填表单", + "CopyOrderNumber": "复制订单号", + "CopyDownloadLink": "复制下载链接", + "CopyCode": "复制验证码", + "CopyURL": "复制链接", + "ConfirmVerify": "确认验证码", + "CreatePunchPoint": "创建打卡点", + "CreateTask": "创建任务", + "CreateProject": "创建项目", + "AddSchedule": "添加排班", + "ApplyAmount": "提交金额", + "BusinessTime": "营业时间", + "BusinessTimePayload": "构建营业时间请求体", + "AvatarPlaceholder": "生成头像占位图", + "FormatQueueTime": "格式化排队时间", + "FromJSONObject": "从 JSON 对象解析", + "GenerateQRCode": "生成二维码", + "Int64Value": "解析 Int64 值", + "IntValue": "解析 Int 值", + "DecimalValue": "解析小数值", + "EmptyToZero": "空值转零", + "FileType": "解析文件类型", + "DecodeLossyBool": "宽松解码布尔值", + "DecodeLossyDouble": "宽松解码浮点数", + "DecodeLossyInt": "宽松解码整数", + "DecodeLossyString": "宽松解码字符串", + "LiveDecodeLossyInt": "直播模块宽松解码整数", + "LiveDecodeLossyString": "直播模块宽松解码字符串", + "CoercedBroadcastInterval": "规范化播报间隔", + "CoercedCountdownThreshold": "规范化倒计时阈值", + "LegacyPositiveInt": "兼容旧版正整数解析", + "DeduplicatedAndSorted": "去重并排序", + "CompleteLogin": "完成登录流程", + "LoginTapped": "点击登录按钮", + "LogoutTapped": "点击登出", + "CancelTapped": "点击取消", + "ConfirmTapped": "点击确认", + "CloseTapped": "点击关闭", + "ContinueTapped": "点击继续", + "EditTapped": "点击编辑", + "DetailTapped": "点击详情", +} + + +def split_camel(name: str) -> list[str]: + parts = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name).split() + return parts + + +def describe_method(name: str) -> str: + if name in METHOD_OVERRIDES: + return METHOD_OVERRIDES[name] + if name in WORD_HINTS: + return f"{WORD_HINTS[name]}。" + + # 尝试从最长前缀/后缀匹配组合词 + if name.endswith("Tapped"): + base = name[:-6] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"点击{hint}的处理逻辑。" + if name.endswith("IfNeeded"): + base = name[:-8] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"按需{hint}。" + if name.startswith("make"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"创建{hint}。" + if name.startswith("setup"): + base = name[5:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"初始化{hint}。" + if name.startswith("update"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"更新{hint}。" + if name.startswith("load"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"加载{hint}。" + if name.startswith("clear"): + base = name[5:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"清空{hint}。" + if name.startswith("copy"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"复制{hint}。" + if name.startswith("create"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"创建{hint}。" + if name.startswith("decode"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"解码{hint}。" + if name.startswith("format"): + base = name[6:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"格式化{hint}。" + if name.startswith("live"): + base = name[4:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"直播{hint}相关逻辑。" + if name.startswith("flyer"): + base = name[5:] + hint = "".join(WORD_HINTS.get(p, p) for p in split_camel(base)) or base + return f"飞手{hint}相关逻辑。" + + parts = split_camel(name) + if len(parts) == 1: + return f"{name} 业务逻辑。" + hint = "".join(WORD_HINTS.get(p, p) for p in parts) + return f"{hint}相关逻辑。" + + +def process_file(path: Path) -> bool: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + changed = False + + for i, line in enumerate(lines): + stripped = line.rstrip("\n") + if stripped in LINE_REPLACEMENTS: + indent = re.match(r"^(\s*)", line).group(1) + output.append(f"{indent}{LINE_REPLACEMENTS[stripped]}\n") + changed = True + continue + + match = METHOD_LINE.match(stripped) + if match: + indent = match.group("indent") + name = match.group("name") + output.append(f"{indent}/// {describe_method(name)}\n") + changed = True + continue + + if stripped.startswith("/// ") and i + 1 < len(lines): + func_match = FUNC_LINE.match(lines[i + 1]) + if func_match: + name = func_match.group("name") + if name in METHOD_OVERRIDES and ("方法实现" in stripped or name in stripped): + indent = re.match(r"^(\s*)", line).group(1) + output.append(f"{indent}/// {METHOD_OVERRIDES[name]}\n") + changed = True + continue + + output.append(line) + + if changed: + path.write_text("".join(output), encoding="utf-8") + return changed + + +def main() -> int: + target = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT + count = sum(1 for path in sorted(target.rglob("*.swift")) if process_file(path)) + print(f"Polished {count} files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/suixinkan_ios.xcodeproj/project.pbxproj b/suixinkan_ios.xcodeproj/project.pbxproj index ebbea9d..b9bbafd 100644 --- a/suixinkan_ios.xcodeproj/project.pbxproj +++ b/suixinkan_ios.xcodeproj/project.pbxproj @@ -134,6 +134,7 @@ 9331714B2FEE49BE00240726 /* Frameworks */, 9331714C2FEE49BE00240726 /* Resources */, FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */, + 367681C4AC079C922280F774 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -234,6 +235,27 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 367681C4AC079C922280F774 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -337,6 +359,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = suixinkan_ios/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -356,8 +379,9 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED"; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -375,6 +399,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = suixinkan_ios/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -394,8 +419,9 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED"; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -533,6 +559,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( @@ -549,7 +576,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -568,6 +595,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( @@ -584,7 +612,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; diff --git a/suixinkan_ios/App/AMapBootstrap.swift b/suixinkan_ios/App/AMapBootstrap.swift index 1206486..7b4c5f4 100644 --- a/suixinkan_ios/App/AMapBootstrap.swift +++ b/suixinkan_ios/App/AMapBootstrap.swift @@ -13,8 +13,9 @@ import AMapLocationKit import AMapSearchKit import MAMapKit +/// 高德地图 SDK 初始化封装,真机构建时配置 Key 与隐私合规。 enum AMapBootstrap { - /// 在 App 启动时调用一次。将 `apiKey` 替换为高德控制台申请的 Key。 + /// 在 App 启动时调用一次,配置 API Key 并声明隐私政策已展示与同意。 static func configure(apiKey: String) { guard !apiKey.isEmpty else { return } @@ -30,7 +31,9 @@ enum AMapBootstrap { } } #else +/// 模拟器构建占位,不链接高德 SDK。 enum AMapBootstrap { + /// 空实现,保持调用方编译通过。 static func configure(apiKey: String) {} } #endif diff --git a/suixinkan_ios/App/AppServices.swift b/suixinkan_ios/App/AppServices.swift index 2a683b2..cccc9b2 100644 --- a/suixinkan_ios/App/AppServices.swift +++ b/suixinkan_ios/App/AppServices.swift @@ -67,6 +67,7 @@ final class AppServices { accountContext.profile?.userId } + /// 初始化实例。 private init() { let client = APIClient() let tokenStore = SessionTokenStore() diff --git a/suixinkan_ios/App/AppUITestRouteDriver.swift b/suixinkan_ios/App/AppUITestRouteDriver.swift index fd1bbe3..913d965 100644 --- a/suixinkan_ios/App/AppUITestRouteDriver.swift +++ b/suixinkan_ios/App/AppUITestRouteDriver.swift @@ -8,7 +8,7 @@ #if DEBUG import UIKit -/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开自定义 TabBar 下 XCUITest 难以触发的 push。 +/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开 Tab 切换与 push 链路。 @MainActor enum AppUITestRouteDriver { private static var didApply = false diff --git a/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift index cbe8ba6..3c91fd6 100644 --- a/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift +++ b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift @@ -164,6 +164,7 @@ enum AppRouteViewControllerFactory { } } + /// 将首页路由编码为 URI 字符串。 private static func homeRouteURI(_ route: HomeRoute) -> String { if case let .modulePlaceholder(uri, _) = route { return uri @@ -175,6 +176,7 @@ enum AppRouteViewControllerFactory { @MainActor /// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。 enum HomeRouteViewControllerFactory { + /// 创建实例。 static func make(for route: HomeRoute) -> UIViewController { AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared) } diff --git a/suixinkan_ios/App/Navigation/AppTab.swift b/suixinkan_ios/App/Navigation/AppTab.swift index f02307f..2542c50 100644 --- a/suixinkan_ios/App/Navigation/AppTab.swift +++ b/suixinkan_ios/App/Navigation/AppTab.swift @@ -29,19 +29,47 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable { } } - var systemImage: String { + /// 选中态 TabBar 图标资源名。 + var selectedImageName: String { switch self { case .home: - "house" + "TabHomeSelected" case .orders: - "doc.text" + "TabOrderSelected" case .statistics: - "chart.bar" + "TabDataSelected" case .profile: - "person" + "TabProfileSelected" } } + /// 未选中态 TabBar 图标资源名。 + var unselectedImageName: String { + switch self { + case .home: + "TabHomeUnselected" + case .orders: + "TabOrderUnselected" + case .statistics: + "TabDataUnselected" + case .profile: + "TabProfileUnselected" + } + } + + /// 构建系统 TabBarItem,供 `UITabBarController` 使用。 + func makeTabBarItem() -> UITabBarItem { + let unselectedImage = UIImage(named: unselectedImageName)?.withRenderingMode(.alwaysOriginal) + let selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal) + let item = UITabBarItem( + title: title, + image: unselectedImage, + selectedImage: selectedImage + ) + item.accessibilityIdentifier = "main.tab.\(rawValue)" + return item + } + /// 构建 Tab 根页面 ViewController。 func makeRootViewController(services: AppServices) -> UIViewController { switch self { diff --git a/suixinkan_ios/App/Navigation/NavigationRouter.swift b/suixinkan_ios/App/Navigation/NavigationRouter.swift index 998ba00..e4aa5ee 100644 --- a/suixinkan_ios/App/Navigation/NavigationRouter.swift +++ b/suixinkan_ios/App/Navigation/NavigationRouter.swift @@ -53,6 +53,7 @@ final class AppRouter { private let routers: [AppTab: RouterPath] + /// 初始化实例。 init() { var builtRouters: [AppTab: RouterPath] = [:] for tab in AppTab.allCases { diff --git a/suixinkan_ios/App/Navigation/TabNavigationController.swift b/suixinkan_ios/App/Navigation/TabNavigationController.swift index 1f49cf1..35ca5c0 100644 --- a/suixinkan_ios/App/Navigation/TabNavigationController.swift +++ b/suixinkan_ios/App/Navigation/TabNavigationController.swift @@ -13,8 +13,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl private let services: AppServices private var isSyncingStack = false - weak var tabBarHost: MainTabBarController? - + /// 初始化实例。 init(tab: AppTab, services: AppServices) { self.appTab = tab self.services = services @@ -43,15 +42,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl pushViewController(viewController, animated: animated) } - func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - let isRoot = viewController === viewControllers.first - tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated) - } - + /// navigationController相关逻辑。 func navigationController( _ navigationController: UINavigationController, didShow viewController: UIViewController, @@ -60,6 +51,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl syncRouterPathFromStack() } + /// 注册NavigationBridge。 private func registerNavigationBridge() { switch appTab { case .home: @@ -71,6 +63,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl } } + /// 同步FromRouterPath状态。 private func syncFromRouterPath() { guard !isSyncingStack else { return } @@ -79,6 +72,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl let currentDepth = max(viewControllers.count - 1, 0) if targetDepth > currentDepth { + // Router 路径变深:按路径依次 push 缺失页面 for index in currentDepth.. UIViewController { let controller = UIViewController() controller.view.backgroundColor = .systemBackground diff --git a/suixinkan_ios/AppDelegate.swift b/suixinkan_ios/AppDelegate.swift index b232f66..3c9e034 100644 --- a/suixinkan_ios/AppDelegate.swift +++ b/suixinkan_ios/AppDelegate.swift @@ -2,35 +2,62 @@ // AppDelegate.swift // suixinkan_ios // -// Created by hanqiu on 2026/6/26. -// import UIKit +/// 应用入口,承接 APNs 回调并在启动时初始化高德 SDK。 @main class AppDelegate: UIResponder, UIApplicationDelegate { - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + /// 应用启动:重置 UI 测试状态并配置高德 Key。 + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + AppUITestLaunchState.resetIfNeeded() + if let key = Bundle.main.object(forInfoDictionaryKey: "AMapAPIKey") as? String { + AMapBootstrap.configure(apiKey: key) + } return true } - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Task { @MainActor in + PushNotificationManager.shared.handleDeviceToken(deviceToken) + } } + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + Task { @MainActor in + PushNotificationManager.shared.handleRegistrationError(error) + } + } + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + let payload = PushPayload(userInfo: userInfo) + Task { @MainActor in + PushNotificationManager.shared.handleRemoteNotification(payload) + completionHandler(.newData) + } + } } - diff --git a/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..24cada9 100644 --- a/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..508217d Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/Contents.json new file mode 100644 index 0000000..37520de --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_data_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png b/suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png new file mode 100644 index 0000000..25a61ce Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/Contents.json new file mode 100644 index 0000000..faa503c --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_data_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png b/suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png new file mode 100644 index 0000000..2a92be2 Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/Contents.json new file mode 100644 index 0000000..8cca175 --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_home_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png b/suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png new file mode 100644 index 0000000..8aeef0c Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/Contents.json new file mode 100644 index 0000000..7c6d760 --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_home_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png b/suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png new file mode 100644 index 0000000..48cedd3 Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/Contents.json new file mode 100644 index 0000000..1b32685 --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_order_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png b/suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png new file mode 100644 index 0000000..8d10fbc Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/Contents.json new file mode 100644 index 0000000..0241d89 --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_order_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png b/suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png new file mode 100644 index 0000000..366d345 Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/Contents.json new file mode 100644 index 0000000..3c5e11e --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_profile_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png b/suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png new file mode 100644 index 0000000..bf60175 Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png differ diff --git a/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/Contents.json b/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/Contents.json new file mode 100644 index 0000000..565e49d --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_profile_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png b/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png new file mode 100644 index 0000000..9c24f06 Binary files /dev/null and b/suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png differ diff --git a/suixinkan_ios/Core/Design/AppContentUnavailableView.swift b/suixinkan_ios/Core/Design/AppContentUnavailableView.swift index 0c76646..e2082d3 100644 --- a/suixinkan_ios/Core/Design/AppContentUnavailableView.swift +++ b/suixinkan_ios/Core/Design/AppContentUnavailableView.swift @@ -50,6 +50,7 @@ final class AppContentUnavailableView: UIView { actionsContainer.isHidden = actions.isEmpty } + /// 配置Views展示内容。 private func configureViews( title: String, systemImage: String, diff --git a/suixinkan_ios/Core/Design/AppMetrics.swift b/suixinkan_ios/Core/Design/AppMetrics.swift index 213b27f..7250bba 100644 --- a/suixinkan_ios/Core/Design/AppMetrics.swift +++ b/suixinkan_ios/Core/Design/AppMetrics.swift @@ -9,6 +9,7 @@ import CoreGraphics /// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。 final class AppMetrics { + /// 初始化实例。 private init() {} /// 字体尺寸实体,统一维护 App 内常用字号。 diff --git a/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift b/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift index a72891f..ccaa5fd 100644 --- a/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift +++ b/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift @@ -119,6 +119,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { nil } + /// 配置Views展示内容。 private func configureViews() { isUserInteractionEnabled = true accessibilityLabel = "加载中" @@ -171,6 +172,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { ]) } + /// 刷新Presentation展示。 private func refreshPresentation(animated: Bool) { let shouldShow = state.isVisible let updates = { @@ -237,6 +239,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { /// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。 #if canImport(Lottie) + /// 加载Animation数据。 private static func loadAnimation() -> LottieAnimation? { if let animation = LottieAnimation.named("loading") { return animation @@ -250,6 +253,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView { return nil } #else + /// 加载Animation数据。 private static func loadAnimation() -> Any? { nil } diff --git a/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift b/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift index 963cbd3..1ff1d80 100644 --- a/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift +++ b/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift @@ -22,6 +22,7 @@ final class ForegroundLocationProvider: NSObject { private let geocoder = CLGeocoder() private var continuation: CheckedContinuation? + /// 初始化实例。 override init() { super.init() manager.delegate = self diff --git a/suixinkan_ios/Core/Map/Map.md b/suixinkan_ios/Core/Map/Map.md new file mode 100644 index 0000000..590fe98 --- /dev/null +++ b/suixinkan_ios/Core/Map/Map.md @@ -0,0 +1,16 @@ +# Core/Map 模块 + +## 职责 + +封装高德地图 UIKit 组件,供运营区域围栏展示与打卡点编辑选点使用。 + +## 组件 + +- `OperatingAreaMapView`:绘制运营围栏 polygon;模拟器降级为坐标摘要。 +- `PunchPointMapPickerView`:打卡点编辑页地图点选;模拟器提示使用「当前位置」。 + +## 构建说明 + +- 真机构建:`AMAP_ENABLED` + CocoaPods 高德 SDK。 +- 模拟器:不链接 AMap,自动展示文字/坐标兜底 UI。 +- 在 `Info.plist` 配置 `AMapAPIKey` 为高德控制台 Key(需与 Bundle ID 绑定)。 diff --git a/suixinkan_ios/Core/Map/OperatingAreaMapView.swift b/suixinkan_ios/Core/Map/OperatingAreaMapView.swift new file mode 100644 index 0000000..fa583cf --- /dev/null +++ b/suixinkan_ios/Core/Map/OperatingAreaMapView.swift @@ -0,0 +1,199 @@ +// +// OperatingAreaMapView.swift +// suixinkan_ios +// + +import CoreLocation +import SnapKit +import UIKit + +/// 运营区域围栏地图:真机启用高德 polygon;模拟器展示坐标摘要。 +final class OperatingAreaMapView: UIView { + var rings: [OperatingFenceRing] = [] { + didSet { updateContent() } + } + + private let contentContainer = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentContainer.clipsToBounds = true + contentContainer.layer.cornerRadius = AppMetrics.CornerRadius.card + addSubview(contentContainer) + contentContainer.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + updateContent() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateContent() { + contentContainer.subviews.forEach { $0.removeFromSuperview() } + #if AMAP_ENABLED + let mapView = OperatingAreaAMapView(rings: rings) + contentContainer.addSubview(mapView) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + #else + let fallback = OperatingAreaMapFallbackView(rings: rings) + contentContainer.addSubview(fallback) + fallback.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + #endif + } +} + +#if AMAP_ENABLED +import MAMapKit + +private final class OperatingAreaAMapView: UIView, MAMapViewDelegate { + private let mapView = MAMapView() + private var ringsByOverlay: [ObjectIdentifier: OperatingFenceRing] = [:] + private var rings: [OperatingFenceRing] + + init(rings: [OperatingFenceRing]) { + self.rings = rings + super.init(frame: .zero) + mapView.delegate = self + mapView.showsCompass = false + mapView.showsScale = false + mapView.isRotateEnabled = false + addSubview(mapView) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + applyRings() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func applyRings() { + ringsByOverlay.removeAll() + mapView.removeOverlays(mapView.overlays) + mapView.removeAnnotations(mapView.annotations) + + var allCoordinates: [CLLocationCoordinate2D] = [] + for ring in rings { + var coordinates = ring.points.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + } + guard coordinates.count >= 3 else { continue } + guard let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) else { + continue + } + ringsByOverlay[ObjectIdentifier(polygon)] = ring + mapView.add(polygon) + allCoordinates.append(contentsOf: coordinates) + + if let center = ring.centerCoordinate { + let annotation = MAPointAnnotation() + annotation.coordinate = center + annotation.title = ring.regionName + mapView.addAnnotation(annotation) + } + } + + if let region = Self.region(for: allCoordinates) { + mapView.setRegion(region, animated: false) + } + } + + func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { + guard let polygon = overlay as? MAPolygon else { return nil } + let ring = ringsByOverlay[ObjectIdentifier(polygon)] + let renderer = MAPolygonRenderer(polygon: polygon) + let color = ring?.isCurrentStore == true ? UIColor.systemRed : UIColor.systemBlue + renderer?.strokeColor = color + renderer?.fillColor = color.withAlphaComponent(0.18) + renderer?.lineWidth = 3 + return renderer + } + + private static func region(for coordinates: [CLLocationCoordinate2D]) -> MACoordinateRegion? { + guard !coordinates.isEmpty else { return nil } + let minLat = coordinates.map(\.latitude).min() ?? 0 + let maxLat = coordinates.map(\.latitude).max() ?? 0 + let minLng = coordinates.map(\.longitude).min() ?? 0 + let maxLng = coordinates.map(\.longitude).max() ?? 0 + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLng + maxLng) / 2 + ) + let span = MACoordinateSpan( + latitudeDelta: max((maxLat - minLat) * 1.4, 0.01), + longitudeDelta: max((maxLng - minLng) * 1.4, 0.01) + ) + return MACoordinateRegion(center: center, span: span) + } +} + +private extension OperatingFenceRing { + var centerCoordinate: CLLocationCoordinate2D? { + guard !points.isEmpty else { return nil } + let latitude = points.reduce(0) { $0 + $1.latitude } / Double(points.count) + let longitude = points.reduce(0) { $0 + $1.longitude } / Double(points.count) + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} +#endif + +private final class OperatingAreaMapFallbackView: UIView { + init(rings: [OperatingFenceRing]) { + super.init(frame: .zero) + backgroundColor = UIColor(hex: 0xEEF2F7) + + let titleLabel = UILabel() + titleLabel.text = "模拟器未启用高德地图,以下为围栏坐标摘要" + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .medium) + titleLabel.textColor = AppDesign.textSecondary + titleLabel.numberOfLines = 0 + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + + addSubview(titleLabel) + addSubview(stack) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) + } + stack.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small) + make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) + } + + for ring in rings.prefix(6) { + let label = UILabel() + label.numberOfLines = 0 + label.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + label.textColor = AppDesign.textSecondary + let name = ring.regionName.isEmpty ? "未命名区域" : ring.regionName + let coords = ring.points.prefix(4) + .map { String(format: "%.6f, %.6f", $0.latitude, $0.longitude) } + .joined(separator: " ") + label.text = "\(name)\n\(coords)" + stack.addArrangedSubview(label) + } + + if rings.isEmpty { + let empty = UILabel() + empty.text = "暂无围栏数据" + empty.textColor = AppDesign.placeholder + stack.addArrangedSubview(empty) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan_ios/Core/Map/PunchPointMapPickerView.swift b/suixinkan_ios/Core/Map/PunchPointMapPickerView.swift new file mode 100644 index 0000000..17a86ba --- /dev/null +++ b/suixinkan_ios/Core/Map/PunchPointMapPickerView.swift @@ -0,0 +1,139 @@ +// +// PunchPointMapPickerView.swift +// suixinkan_ios +// + +import CoreLocation +import SnapKit +import UIKit + +/// 打卡点地图选点:真机高德地图点选 + 逆地理;模拟器展示提示。 +@MainActor +final class PunchPointMapPickerView: UIView { + var onLocationPicked: ((Double, Double, String) -> Void)? + + var coordinate: CLLocationCoordinate2D? { + didSet { updatePin() } + } + + private let hintLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + layer.cornerRadius = AppMetrics.CornerRadius.card + backgroundColor = UIColor(hex: 0xEEF2F7) + + hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + hintLabel.textColor = AppDesign.textSecondary + hintLabel.numberOfLines = 0 + hintLabel.textAlignment = .center + + #if AMAP_ENABLED + hintLabel.text = "点击地图选择打卡位置" + let mapView = PunchPointAMapPickerView { [weak self] lat, lng, address in + self?.onLocationPicked?(lat, lng, address) + } + addSubview(mapView) + addSubview(hintLabel) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + hintLabel.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview().inset(8) + } + #else + hintLabel.text = "模拟器未启用高德地图,请使用「当前位置」按钮选点" + addSubview(hintLabel) + hintLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppMetrics.Spacing.medium) + } + #endif + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updatePin() { + // Pin updates handled inside AMap subview when coordinate is set externally. + } + + func setCoordinate(latitude: Double, longitude: Double) { + coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} + +#if AMAP_ENABLED +import AMapSearchKit +import MAMapKit + +private final class PunchPointAMapPickerView: UIView, MAMapViewDelegate, AMapSearchDelegate { + private let mapView = MAMapView() + private let searchAPI = AMapSearchAPI() + private var pin: MAPointAnnotation? + private var pendingRegeo: CheckedContinuation? + private let onPick: (Double, Double, String) -> Void + + init(onPick: @escaping (Double, Double, String) -> Void) { + self.onPick = onPick + super.init(frame: .zero) + searchAPI?.delegate = self + mapView.delegate = self + mapView.showsUserLocation = true + mapView.userTrackingMode = .none + mapView.zoomLevel = 16 + addSubview(mapView) + mapView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) { + Task { await handlePick(coordinate) } + } + + private func handlePick(_ coordinate: CLLocationCoordinate2D) async { + if let pin { + mapView.removeAnnotation(pin) + } + let annotation = MAPointAnnotation() + annotation.coordinate = coordinate + pin = annotation + mapView.addAnnotation(annotation) + + let address = await reverseGeocode(coordinate) + onPick(coordinate.latitude, coordinate.longitude, address) + } + + private func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async -> String { + await withCheckedContinuation { continuation in + pendingRegeo = continuation + let request = AMapReGeocodeSearchRequest() + request.location = AMapGeoPoint.location( + withLatitude: CGFloat(coordinate.latitude), + longitude: CGFloat(coordinate.longitude) + ) + request.requireExtension = true + searchAPI?.aMapReGoecodeSearch(request) + } + } + + func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { + let address = response.regeocode.formattedAddress ?? "已选位置" + pendingRegeo?.resume(returning: address) + pendingRegeo = nil + } + + func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { + pendingRegeo?.resume(returning: "已选位置") + pendingRegeo = nil + } +} +#endif diff --git a/suixinkan_ios/Core/Push/PushNotificationManager.swift b/suixinkan_ios/Core/Push/PushNotificationManager.swift index 480937b..4c4760c 100644 --- a/suixinkan_ios/Core/Push/PushNotificationManager.swift +++ b/suixinkan_ios/Core/Push/PushNotificationManager.swift @@ -21,6 +21,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate private let tokenDefaultsKey = "apns_device_token" private let uploadedTokenDefaultsKey = "apns_uploaded_token" + /// 初始化实例。 override init() { super.init() } @@ -99,6 +100,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate route(from: payload) } + /// user通知Center相关逻辑。 nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -111,6 +113,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate completionHandler([.banner, .list, .sound, .badge]) } + /// user通知Center相关逻辑。 nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -123,6 +126,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate } } + /// 根据执行路由跳转。 private func route(from payload: PushPayload) { guard let router else { return } @@ -142,6 +146,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate } } + /// 导航至HomeRoute页面。 private func navigateHomeRoute(_ route: HomeRoute) { guard let router else { return } router.select(.home) diff --git a/suixinkan_ios/Core/Push/PushPayload.swift b/suixinkan_ios/Core/Push/PushPayload.swift index 64554e8..7d6f3db 100644 --- a/suixinkan_ios/Core/Push/PushPayload.swift +++ b/suixinkan_ios/Core/Push/PushPayload.swift @@ -9,6 +9,7 @@ import Foundation /// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。 enum APNsDeviceToken { + /// 将二进制数据转为十六进制字符串。 static func hexString(from data: Data) -> String { data.map { String(format: "%02x", $0) }.joined() } @@ -16,6 +17,7 @@ enum APNsDeviceToken { /// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。 struct PushPayload: Sendable { + /// 枚举,定义相关常量或状态。 enum Route: Sendable, Equatable { case payment case order @@ -64,6 +66,7 @@ struct PushPayload: Sendable { return .messageCenter } + /// mergeJSON相关逻辑。 nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) { if let dict = value as? [String: Any] { for (key, value) in dict { @@ -84,6 +87,7 @@ struct PushPayload: Sendable { } } + /// 安全地将任意值转为字符串。 nonisolated private static func stringValue(_ value: Any?) -> String { switch value { case let string as String: diff --git a/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift b/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift index 769723a..40d2294 100644 --- a/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift +++ b/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift @@ -9,6 +9,7 @@ import Foundation /// 排队语音播报内容。 struct ScenicQueueAnnouncement: Equatable { + /// 枚举,定义相关常量或状态。 enum Kind: Equatable { case newTickets(count: Int) case calledTicket(id: Int64) diff --git a/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift index fb9dd0a..29ce773 100644 --- a/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift +++ b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift @@ -69,6 +69,7 @@ final class ScenicQueueRuntime { self.scenePhase = scenePhase guard !suspendedByQueueScreen else { + // 排队页自行监听时,避免双通道轮询 stop() return } @@ -86,6 +87,7 @@ final class ScenicQueueRuntime { return } + // 景区或打卡点切换时重置播报状态,避免跨点误报 if lastScenicId != scenicId || lastSpotId != spotId { resetSnapshot() lastScenicId = scenicId @@ -93,6 +95,7 @@ final class ScenicQueueRuntime { } if scenePhase == .background, !backgroundPollingEnabled { + // 用户关闭后台轮询时,进入后台即停止 stop() return } @@ -126,6 +129,7 @@ final class ScenicQueueRuntime { Task { await pollOnce() } } + /// 启动IfNeeded流程。 private func startIfNeeded() { guard pollTask == nil else { return } isMonitoring = true @@ -137,6 +141,7 @@ final class ScenicQueueRuntime { } } + /// 执行Loop循环或任务。 private func runLoop() async { while !Task.isCancelled { await pollOnce() @@ -145,6 +150,7 @@ final class ScenicQueueRuntime { } } + /// 轮询Once数据。 private func pollOnce() async { guard let api, let scenicId else { return } let spotId = selectedSpotId() @@ -168,6 +174,7 @@ final class ScenicQueueRuntime { page: 1, pageSize: 20 ) + // 并行拉取统计与排队列表,减少单次轮询耗时 let (stats, home) = try await (statsData, homeData) handle(stats: stats, tickets: home.list?.list ?? []) lastError = nil @@ -176,6 +183,7 @@ final class ScenicQueueRuntime { } } + /// 处理相关事件。 private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" @@ -190,6 +198,7 @@ final class ScenicQueueRuntime { } } + /// 轮询IntervalSeconds数据。 private func pollIntervalSeconds() -> UInt64 { switch scenePhase { case .active: @@ -203,11 +212,13 @@ final class ScenicQueueRuntime { } } + /// 读取用户当前选中的打卡点 ID。 private func selectedSpotId() -> Int { ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) ?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) } + /// 重置Snapshot状态。 private func resetSnapshot() { announcementState.reset() lastQueueCount = 0 @@ -215,6 +226,7 @@ final class ScenicQueueRuntime { lastSpokenText = "" } + /// 申请后台任务,保证进入后台时仍可短时轮询。 private func beginBackgroundTask() { guard backgroundTaskId == .invalid else { return } backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in @@ -225,6 +237,7 @@ final class ScenicQueueRuntime { } } + /// 结束后台任务。 private func endBackgroundTask() { guard backgroundTaskId != .invalid else { return } UIApplication.shared.endBackgroundTask(backgroundTaskId) @@ -238,6 +251,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { private let synthesizer = AVSpeechSynthesizer() private var pendingContinuations: [CheckedContinuation] = [] + /// 初始化实例。 override init() { super.init() synthesizer.delegate = self @@ -265,6 +279,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { resumePending() } + /// 入队等待处理。 private func enqueue(_ normalized: String, continuation: CheckedContinuation?, replacePending: Bool) { if replacePending { synthesizer.stopSpeaking(at: .immediate) @@ -279,14 +294,17 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { synthesizer.speak(utterance) } + /// speechSynthesizer相关逻辑。 nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { Task { @MainActor in self.resumePending() } } + /// speechSynthesizer相关逻辑。 nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { Task { @MainActor in self.resumePending() } } + /// 恢复Pending流程。 private func resumePending() { let continuations = pendingContinuations pendingContinuations.removeAll() diff --git a/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift b/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift index 7d0d814..03647dd 100644 --- a/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift +++ b/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift @@ -74,6 +74,7 @@ final class ScenicQueueSocketClient { webSocketTask = nil } + /// 持续接收 WebSocket 消息并分发处理。 private func receiveLoop( task: URLSessionWebSocketTask, onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void @@ -130,6 +131,7 @@ final class ScenicQueueSocketClient { return ScenicQueueSocketMessage(code: code, data: socketData) } + /// 安全地将任意值转为字符串。 private static func stringValue(_ value: Any?) -> String { switch value { case let value as String: @@ -143,6 +145,7 @@ final class ScenicQueueSocketClient { } } + /// int值相关逻辑。 private static func intValue(_ value: Any?) -> Int? { if let value = value as? Int { return value } if let value = value as? NSNumber { return value.intValue } @@ -150,6 +153,7 @@ final class ScenicQueueSocketClient { return nil } + /// int64值相关逻辑。 private static func int64Value(_ value: Any?) -> Int64? { if let value = value as? Int64 { return value } if let value = value as? Int { return Int64(value) } diff --git a/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift b/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift index f1d3e54..8c7cec1 100644 --- a/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift +++ b/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift @@ -12,6 +12,7 @@ final class FeaturePlaceholderViewController: UIViewController { private let pageTitle: String private let uri: String + /// 初始化实例。 init(title: String, uri: String) { self.pageTitle = title self.uri = uri @@ -23,6 +24,7 @@ final class FeaturePlaceholderViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0xF5F7FA) diff --git a/suixinkan_ios/Core/UI/RemoteImage.swift b/suixinkan_ios/Core/UI/RemoteImage.swift index 5bcb70e..ccffd1e 100644 --- a/suixinkan_ios/Core/UI/RemoteImage.swift +++ b/suixinkan_ios/Core/UI/RemoteImage.swift @@ -8,6 +8,7 @@ import Kingfisher import UIKit +/// `UIImageView` 远程图片加载扩展,统一走 Kingfisher 缓存与占位策略。 extension UIImageView { /// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。 func loadRemoteImage( @@ -68,11 +69,13 @@ extension UIImageView { kf.cancelDownloadTask() } + /// 规范化dURLString格式。 private func normalizedURLString(from urlString: String?) -> String? { let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? nil : text } + /// avatarPlaceholder相关逻辑。 private static func avatarPlaceholder( systemImageName: String, iconSize: CGFloat, @@ -98,6 +101,7 @@ extension UIImageView { } } +/// `UIImageView` 头像圆角辅助扩展。 extension UIImageView { /// 在布局变化后刷新圆形头像圆角。 func refreshRemoteAvatarCornerRadius() { diff --git a/suixinkan_ios/Core/UI/ViewControllerHelpers.swift b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift index 46011d5..cec5414 100644 --- a/suixinkan_ios/Core/UI/ViewControllerHelpers.swift +++ b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift @@ -23,6 +23,7 @@ private final class ViewModelBindingToken { private weak var owner: AnyObject? private let handler: () -> Void + /// 初始化实例。 init(owner: AnyObject, handler: @escaping () -> Void) { self.owner = owner self.handler = handler @@ -33,17 +34,22 @@ private final class ViewModelBindingToken { } } +/// `UIViewController` 通用 UI 与全局反馈扩展。 extension UIViewController { + /// 全局依赖容器快捷访问。 var appServices: AppServices { AppServices.shared } + /// 展示全局 Toast 提示。 func showToast(_ message: String) { appServices.toastCenter.show(message) } + /// 展示全局 Loading 遮罩。 func showGlobalLoading(_ message: String = "") { appServices.globalLoading.show(message: message) } + /// 隐藏GlobalLoading。 func hideGlobalLoading() { appServices.globalLoading.hide() } diff --git a/suixinkan_ios/Core/UIKit/ListDiffableSupport.swift b/suixinkan_ios/Core/UIKit/ListDiffableSupport.swift new file mode 100644 index 0000000..28653d6 --- /dev/null +++ b/suixinkan_ios/Core/UIKit/ListDiffableSupport.swift @@ -0,0 +1,173 @@ +// +// ListDiffableSupport.swift +// suixinkan +// +// 列表 Diffable Data Source 通用类型与布局辅助。 +// + +import SnapKit +import UIKit + +// MARK: - Module Table(简单单列 / 多 section 列表) + +/// 模块列表 section 标识,使用 section 索引。 +typealias ModuleTableSection = Int + +/// 模块列表行标识,格式为 `section-row`。 +typealias ModuleTableRow = String + +/// 构建模块列表行标识。 +func moduleTableRowID(section: Int, row: Int) -> ModuleTableRow { + "\(section)-\(row)" +} + +/// 从行标识解析 indexPath。 +func moduleTableIndexPath(from rowID: ModuleTableRow) -> IndexPath? { + let parts = rowID.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 2, let section = Int(parts[0]), let row = Int(parts[1]) else { return nil } + return IndexPath(row: row, section: section) +} + +// MARK: - Simple Table(独立页面的固定行列表) + +/// 简单列表 section 标识。 +typealias SimpleTableSection = Int + +/// 简单列表行标识。 +typealias SimpleTableRow = String + +// MARK: - Collection Layout + +/// UICollectionView 布局工厂,提供全宽卡片与网格 section 的 Compositional Layout。 +enum CollectionDiffableLayout { + /// 全宽单行 section,高度由 estimated 或 absolute 决定。 + static func fullWidthSection(estimatedHeight: CGFloat = 100) -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(estimatedHeight) + ) + ) + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(estimatedHeight) + ), + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.pageHorizontal, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.pageHorizontal + ) + return section + } + + /// 固定高度全宽 section。 + static func fullWidthSection(height: CGFloat) -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ) + ) + let group = NSCollectionLayoutGroup.vertical( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ), + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.pageHorizontal, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.pageHorizontal + ) + return section + } + + /// 三列网格 section,用于首页 / 更多功能菜单。 + static func gridSection( + columns: Int = 3, + itemHeight: CGFloat = 102, + interItemSpacing: CGFloat = 15, + lineSpacing: CGFloat = 15, + contentInsets: NSDirectionalEdgeInsets? = nil + ) -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0 / CGFloat(columns)), + heightDimension: .absolute(itemHeight) + ) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemHeight) + ), + subitem: item, + count: columns + ) + group.interItemSpacing = .fixed(interItemSpacing) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = lineSpacing + section.contentInsets = contentInsets ?? NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.pageHorizontal, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.pageHorizontal + ) + return section + } + + /// 带标题的 section 头。 + static func addHeader( + to section: NSCollectionLayoutSection, + height: CGFloat = 36 + ) -> NSCollectionLayoutSection { + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + section.boundarySupplementaryItems = [header] + return section + } +} + +/// 通用 section 标题补充视图。 +final class CollectionSectionHeaderView: UICollectionReusableView { + static let reuseID = "CollectionSectionHeaderView" + + private let titleLabel = UILabel() + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + titleLabel.textColor = AppDesignUIKit.textSecondary + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + make.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + make.bottom.equalToSuperview().inset(4) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置 section 标题文本。 + func configure(title: String) { + titleLabel.text = title + } +} diff --git a/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift index a75f0a1..840126e 100644 --- a/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift +++ b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift @@ -12,6 +12,7 @@ import UIKit final class TitleSubtitleTableViewCell: UITableViewCell { static let reuseIdentifier = "TitleSubtitleTableViewCell" + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) selectionStyle = .default @@ -28,6 +29,7 @@ final class TitleSubtitleTableViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + /// 填充标题、副标题与详情文本,副标题优先于详情展示。 func configure(title: String, subtitle: String? = nil, detail: String? = nil) { textLabel?.text = title if let subtitle, !subtitle.isEmpty { @@ -38,18 +40,20 @@ final class TitleSubtitleTableViewCell: UITableViewCell { } } -/// 模块列表页基类,封装 UITableView、下拉刷新和 ViewModel onChange 绑定。 -@MainActor -class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { +/// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。 +class ModuleTableViewController: UIViewController, UITableViewDelegate { let services = AppServices.shared let tableView = UITableView(frame: .zero, style: .insetGrouped) private let refreshControl = UIRefreshControl() private let activityIndicator = UIActivityIndicatorView(style: .medium) private var viewModelReloadHandler: (() -> Void)? + /// Diffable 数据源,驱动列表展示与动画刷新。 + private(set) var tableDataSource: UITableViewDiffableDataSource! + var isLoading = false { didSet { - if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 { + if isLoading, tableDataSource.snapshot().numberOfItems == 0 { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() @@ -57,12 +61,19 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl } } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0xF5F7FA) navigationItem.largeTitleDisplayMode = .never - tableView.dataSource = self + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, row: ModuleTableRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + return self.tableCell(for: indexPath, row: row, in: tableView) + } + tableView.delegate = self tableView.backgroundColor = .clear tableView.register( @@ -85,36 +96,45 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl Task { await reloadContent() } } + /// 绑定 ViewModel 数据变更回调,子类在 `reloadContent` 完成后调用。 func bindViewModel(onChange: (() -> Void)?) { viewModelReloadHandler = onChange } - func reloadTable() { - tableView.reloadData() + /// 通过 Diffable snapshot 刷新列表,替代 reloadData。 + func reloadTable(animated: Bool = true) { + tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated) } + /// 返回 section 数量,子类可覆写以支持多 section。 + func numberOfTableSections() -> Int { 1 } + + /// 返回指定 section 行数,子类必须覆写。 + func tableRowCount(in section: Int) -> Int { + section == 0 ? tableRowCount() : 0 + } + + /// 返回列表行数(单 section 场景),子类覆写。 func tableRowCount() -> Int { 0 } + /// 构建 Diffable snapshot,子类一般无需覆写。 + func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + for section in 0.. Int { 1 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - tableRowCount() - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供 Cell,子类可覆写以自定义多 section 展示。 + func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -125,19 +145,46 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl return cell } + /// 处理行选中事件,子类覆写以响应跳转或操作。 + func didSelectTableRow(at indexPath: IndexPath) {} + + /// 加载或刷新页面数据,子类覆写以实现网络请求与状态更新。 + func reloadContent() async {} + + /// section 标题,子类覆写以展示分组标题。 + func tableSectionTitle(for section: Int) -> String? { nil } + + /// 下拉刷新触发,重新加载内容并结束刷新动画。 + @objc private func handleRefresh() { + Task { + await reloadContent() + refreshControl.endRefreshing() + } + } + + /// UITableView 代理:展示 section 标题。 + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + tableSectionTitle(for: section) + } + + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) didSelectTableRow(at: indexPath) } + /// UITableView 代理:行即将展示,用于分页加载等。 func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { willDisplayTableRow(at: indexPath) } + /// willDisplayTableRow 回调处理。 func willDisplayTableRow(at indexPath: IndexPath) {} } +/// `ModuleTableViewController` 扩展,提供 ViewModel 绑定便捷方法。 extension ModuleTableViewController { + /// 将 ViewModel 的 onChange 与列表刷新联动,并在绑定时立即刷新一次。 func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) { if let bindable = viewModel as? ViewModelBindable { bindable.onChange = { [weak self] in @@ -146,6 +193,7 @@ extension ModuleTableViewController { } } reload() + reloadTable(animated: false) } } @@ -154,3 +202,140 @@ extension ModuleTableViewController { protocol ViewModelBindable: AnyObject { var onChange: (() -> Void)? { get set } } + +/// 简单固定行列表基类,使用 UITableView Diffable,适用于设置、详情等页面。 +class SimpleTableDiffableViewController: UIViewController, UITableViewDelegate { + let tableView: UITableView + + private(set) var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 + init(style: UITableView.Style = .insetGrouped) { + tableView = UITableView(frame: .zero, style: style) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 构建 snapshot,子类必须覆写。 + func buildSnapshot() -> NSDiffableDataSourceSnapshot { + NSDiffableDataSourceSnapshot() + } + + /// 配置 Cell,子类必须覆写。 + func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {} + + /// 行选中,子类可覆写。 + func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {} + + /// section 标题。 + func sectionTitle(for section: SimpleTableSection) -> String? { nil } + + /// section 页脚。 + func sectionFooter(for section: SimpleTableSection) -> String? { nil } + + /// 应用 snapshot 刷新列表。 + func applySnapshot(animated: Bool = true) { + tableDataSource.apply(buildSnapshot(), animatingDifferences: animated) + } + + /// 视图加载完成后的 UI 初始化。 + override func viewDidLoad() { + super.viewDidLoad() + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: SimpleTableRow) -> UITableViewCell? in + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + self?.configureCell(cell, row: row, at: indexPath) + return cell + } + tableView.delegate = self + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + applySnapshot(animated: false) + } + + /// UITableView 代理:section 标题。 + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + return sectionTitle(for: sectionID) + } + + /// UITableView 代理:section 页脚。 + func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + return sectionFooter(for: sectionID) + } + + /// UITableView 代理:行选中。 + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return } + didSelectRow(row, at: indexPath) + } +} + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +/// 模块复杂列表基类,封装 UICollectionView Diffable 与下拉刷新。 +class ModuleCollectionViewController: UIViewController { + let services = AppServices.shared + let collectionView: UICollectionView + private let refreshControl = UIRefreshControl() + private let activityIndicator = UIActivityIndicatorView(style: .medium) + + /// 初始化实例。 + init(collectionViewLayout: UICollectionViewLayout) { + collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 视图加载完成后的 UI 初始化。 + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(hex: 0xF5F7FA) + navigationItem.largeTitleDisplayMode = .never + + collectionView.backgroundColor = .clear + collectionView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged) + + activityIndicator.hidesWhenStopped = true + view.addSubview(collectionView) + view.addSubview(activityIndicator) + collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + activityIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + Task { await reloadContent() } + } + + /// 加载或刷新页面数据,子类覆写。 + func reloadContent() async {} + + /// 下拉刷新触发。 + @objc private func handleRefresh() { + Task { + await reloadContent() + refreshControl.endRefreshing() + } + } +} diff --git a/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift b/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift index 94e3a22..4342c90 100644 --- a/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift +++ b/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class CloudStorageViewController: ModuleTableViewController { private let viewModel = CloudStorageViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "云盘" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,22 +24,27 @@ final class CloudStorageViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.files.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let file = viewModel.files[indexPath.row] cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)") } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.assetsAPI) } + /// willDisplayTableRow 回调处理。 override func willDisplayTableRow(at indexPath: IndexPath) { guard indexPath.row >= viewModel.files.count - 2 else { return } Task { await viewModel.loadMore(api: services.assetsAPI) } } + /// openTransit相关逻辑。 @objc private func openTransit() { navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true) } @@ -50,22 +56,27 @@ extension CloudStorageViewModel: ViewModelBindable {} final class CloudStorageTransitViewController: ModuleTableViewController { private let store = CloudTransferStore.shared + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "传输记录" super.viewDidLoad() store.onChange = { [weak self] in self?.reloadTable() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { store.records.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let record = store.records[indexPath.row] cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%") } + /// 刷新Content。 override func reloadContent() async {} } +/// MediaLibraryKindRoute,业务类型定义。 enum MediaLibraryKindRoute { case material case sample @@ -76,6 +87,7 @@ final class MediaLibraryViewController: ModuleTableViewController { private let kind: MediaLibraryKindRoute private let viewModel: MediaLibraryViewModel + /// 初始化实例。 init(kind: MediaLibraryKindRoute = .material) { self.kind = kind switch kind { @@ -92,6 +104,7 @@ final class MediaLibraryViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = kind == .sample ? "样片库" : "素材库" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -104,17 +117,21 @@ final class MediaLibraryViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.assetsAPI) } + /// open上传相关逻辑。 @objc private func openUpload() { navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true) } @@ -128,6 +145,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController { private let viewModel = MediaLibraryEditorViewModel() private let nameField = UITextField() + /// 初始化实例。 init(kind: MediaLibraryKindRoute) { self.kind = kind super.init(nibName: nil, bundle: nil) @@ -138,6 +156,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = kind == .sample ? "上传样片" : "上传素材" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -154,17 +173,21 @@ final class MediaLibraryUploadViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.projects.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let project = viewModel.projects[indexPath.row] cell.configure(title: project.name, subtitle: project.statusName) } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI) } + /// 提交。 @objc private func submit() { viewModel.name = nameField.text ?? "" Task { @@ -186,19 +209,23 @@ extension MediaLibraryEditorViewModel: ViewModelBindable {} final class AlbumListViewController: ModuleTableViewController { private let viewModel = AlbumListViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "相册" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.folders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let folder = viewModel.folders[indexPath.row] cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张") } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId) } @@ -210,19 +237,23 @@ extension AlbumListViewModel: ViewModelBindable {} final class AlbumTrailerViewController: ModuleTableViewController { private let viewModel = AlbumTrailerViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "相册预告" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.folders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let folder = viewModel.folders[indexPath.row] cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张") } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId) } diff --git a/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift b/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift index 9148992..41c38a4 100644 --- a/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift +++ b/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift @@ -6,9 +6,14 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +private typealias AccountSelectionSection = Int +private typealias AccountSelectionItem = String + @MainActor /// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。 -final class AccountSelectionViewController: UIViewController { +final class AccountSelectionViewController: UIViewController, UITableViewDelegate { private let payload: AccountSelectionPayload private var isLoading: Bool @@ -17,9 +22,13 @@ final class AccountSelectionViewController: UIViewController { private var selectedAccountId: String? private let tableView = UITableView(frame: .zero, style: .plain) + + /// Diffable 数据源,驱动账号列表与选中态刷新。 + private var tableDataSource: UITableViewDiffableDataSource! private let confirmButton = UIButton(type: .system) private let bottomBar = UIView() + /// 初始化实例。 init( payload: AccountSelectionPayload, isLoading: Bool, @@ -38,6 +47,7 @@ final class AccountSelectionViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "选择账号" @@ -47,9 +57,47 @@ final class AccountSelectionViewController: UIViewController { selectedAccountId = payload.accounts.first?.id configureTableView() + configureTableDataSource() configureBottomBar() } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSelectionItem) -> UITableViewCell? in + guard let self, + let cell = tableView.dequeueReusableCell( + withIdentifier: AccountSelectionCell.reuseIdentifier, + for: indexPath + ) as? AccountSelectionCell, + let account = self.payload.accounts.first(where: { $0.id == item }) else { + return UITableViewCell() + } + cell.configure(account: account, selected: account.id == self.selectedAccountId) + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(payload.accounts.map(\.id), toSection: 0) + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 更新Loading状态。 func updateLoading(_ loading: Bool) { isLoading = loading isModalInPresentation = loading @@ -65,10 +113,10 @@ final class AccountSelectionViewController: UIViewController { selectedAccount != nil && !isLoading } + /// 配置TableView展示内容。 private func configureTableView() { tableView.backgroundColor = .clear tableView.separatorStyle = .none - tableView.dataSource = self tableView.delegate = self tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier) view.addSubview(tableView) @@ -78,6 +126,7 @@ final class AccountSelectionViewController: UIViewController { } } + /// 配置BottomBar展示内容。 private func configureBottomBar() { bottomBar.backgroundColor = .white @@ -112,37 +161,26 @@ final class AccountSelectionViewController: UIViewController { } } + /// 点击cancel的处理逻辑。 @objc private func cancelTapped() { onCancel() dismiss(animated: true) } + /// 点击confirm的处理逻辑。 @objc private func confirmTapped() { guard let selectedAccount else { return } onConfirm(selectedAccount) } } -extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - payload.accounts.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell( - withIdentifier: AccountSelectionCell.reuseIdentifier, - for: indexPath - ) as? AccountSelectionCell else { - return UITableViewCell() - } - let account = payload.accounts[indexPath.row] - cell.configure(account: account, selected: account.id == selectedAccountId) - return cell - } - +extension AccountSelectionViewController { + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - selectedAccountId = payload.accounts[indexPath.row].id - tableView.reloadData() + tableView.deselectRow(at: indexPath, animated: true) + guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return } + selectedAccountId = item + applyTableSnapshot(animated: false, reconfigure: true) confirmButton.isEnabled = canConfirm confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6) } @@ -161,6 +199,7 @@ private final class AccountSelectionCell: UITableViewCell { private let currentTag = UILabel() private let checkmark = UIImageView() + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none @@ -257,6 +296,7 @@ private final class AccountSelectionCell: UITableViewCell { nil } + /// 配置展示内容。 func configure(account: AccountSwitchAccount, selected: Bool) { titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title subtitleLabel.text = account.subtitle diff --git a/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift b/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift index 954170d..ce4a604 100644 --- a/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift +++ b/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift @@ -26,6 +26,7 @@ final class LoginViewController: UIViewController { private let privacyPolicyButton = UIButton(type: .system) private let loginButton = UIButton(type: .system) + /// 初始化实例。 init(services: AppServices) { self.services = services super.init(nibName: nil, bundle: nil) @@ -36,6 +37,7 @@ final class LoginViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0x0B1220) @@ -44,6 +46,7 @@ final class LoginViewController: UIViewController { viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences()) } + /// 配置Views展示内容。 private func configureViews() { backgroundImageView.contentMode = .scaleAspectFill backgroundImageView.clipsToBounds = true @@ -161,6 +164,7 @@ final class LoginViewController: UIViewController { view.addGestureRecognizer(tap) } + /// 配置AgreementText展示内容。 private func configureAgreementText() { privacyLabel.numberOfLines = 0 privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) @@ -197,6 +201,7 @@ final class LoginViewController: UIViewController { } } + /// 绑定ViewModel回调或数据。 private func bindViewModel() { viewModel.onChange = { [weak self] in self?.renderViewModel() @@ -204,6 +209,7 @@ final class LoginViewController: UIViewController { renderViewModel() } + /// render视图模型相关逻辑。 private func renderViewModel() { if usernameField.textField.text != viewModel.username { usernameField.textField.text = viewModel.username @@ -241,38 +247,46 @@ final class LoginViewController: UIViewController { private weak var accountSelectionController: AccountSelectionViewController? + /// username变更相关逻辑。 @objc private func usernameChanged() { viewModel.username = usernameField.textField.text ?? "" viewModel.normalizeUsernameCountryCodeIfNeeded() services.toastCenter.dismiss() } + /// password变更相关逻辑。 @objc private func passwordChanged() { viewModel.password = passwordField.textField.text ?? "" services.toastCenter.dismiss() } + /// togglePrivacy相关逻辑。 @objc private func togglePrivacy() { viewModel.privacyChecked.toggle() } + /// openUserAgreement相关逻辑。 @objc private func openUserAgreement() { showToast("用户协议页面待接入") } + /// openPrivacyPolicy相关逻辑。 @objc private func openPrivacyPolicy() { showToast("隐私政策页面待接入") } + /// 点击login的处理逻辑。 @objc private func loginTapped() { view.endEditing(true) performLogin() } + /// dismiss键盘相关逻辑。 @objc private func dismissKeyboard() { view.endEditing(true) } + /// perform登录相关逻辑。 private func performLogin() { if let validationError = viewModel.validateForLogin() { if validationError == .privacyUnchecked { @@ -307,6 +321,7 @@ final class LoginViewController: UIViewController { } } + /// 弹出AgreementSheet页面。 private func presentAgreementSheet() { let controller = LoginAgreementConsentViewController( onOpenAgreement: { [weak self] title in @@ -324,6 +339,7 @@ final class LoginViewController: UIViewController { present(controller, animated: true) } + /// 弹出AccountSelection页面。 private func presentAccountSelection(_ payload: AccountSelectionPayload) { let controller = AccountSelectionViewController( payload: payload, @@ -342,6 +358,7 @@ final class LoginViewController: UIViewController { present(navigation, animated: true) } + /// select账号相关逻辑。 private func selectAccount(_ account: AccountSwitchAccount) { Task { do { @@ -359,6 +376,7 @@ final class LoginViewController: UIViewController { } } + /// complete登录相关逻辑。 private func completeLogin(with response: V9AuthResponse) async { do { try await services.authSessionCoordinator.completeLogin( @@ -378,6 +396,7 @@ final class LoginViewController: UIViewController { } extension LoginViewController: UITextFieldDelegate { + /// text字段ShouldReturn相关逻辑。 func textFieldShouldReturn(_ textField: UITextField) -> Bool { if textField === usernameField.textField { passwordField.textField.becomeFirstResponder() @@ -396,6 +415,7 @@ private final class LoginInputField: UIView { private let toggleButton = UIButton(type: .custom) private var isSecure = false + /// 初始化实例。 init(iconName: String, placeholder: String, isSecure: Bool) { self.isSecure = isSecure super.init(frame: .zero) @@ -450,12 +470,14 @@ private final class LoginInputField: UIView { nil } + /// setSecureEntry相关逻辑。 func setSecureEntry(_ secure: Bool) { textField.isSecureTextEntry = secure let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible" toggleButton.setImage(UIImage(named: imageName), for: .normal) } + /// toggleVisibility相关逻辑。 @objc private func toggleVisibility() { onToggleVisibility?() } @@ -467,6 +489,7 @@ private final class LoginAgreementConsentViewController: UIViewController { private let onOpenAgreement: (String) -> Void private let onAgreeAndContinue: () -> Void + /// 初始化实例。 init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) { self.onOpenAgreement = onOpenAgreement self.onAgreeAndContinue = onAgreeAndContinue @@ -477,6 +500,7 @@ private final class LoginAgreementConsentViewController: UIViewController { nil } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground @@ -524,6 +548,7 @@ private final class LoginAgreementConsentViewController: UIViewController { } } + /// 点击continue的处理逻辑。 @objc private func continueTapped() { dismiss(animated: true) { [onAgreeAndContinue] in onAgreeAndContinue() diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift index 5d18340..c741259 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift @@ -50,6 +50,7 @@ enum HomeMenuRouting { viewController.navigationController?.pushViewController(target, animated: true) } + /// Push Placeholder页面。 private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) { viewController.navigationController?.pushViewController( FeaturePlaceholderViewController(title: title, uri: uri), diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift index 7d9a33f..a2541ce 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift @@ -6,6 +6,19 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +/// 全部功能页 section 标识,区分常用应用与更多功能网格。 +private enum HomeMoreSection: Hashable { + case commonApps + case moreFunctions +} + +/// 全部功能页 item 标识,以菜单 URI 作为唯一键。 +private enum HomeMoreItem: Hashable { + case menu(uri: String) +} + /// 首页全部功能页,支持常用应用增删和更多功能网格展示。 final class HomeMoreFunctionsViewController: UIViewController { @@ -13,29 +26,38 @@ final class HomeMoreFunctionsViewController: UIViewController { private let commonMenuStore = HomeCommonMenuStore() private var commonURIs: [String] = [] - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.separatorStyle = .none - table.dataSource = self - table.delegate = self - table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID) - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.delegate = self + collection.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID) + collection.register( + CollectionSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CollectionSectionHeaderView.reuseID + ) + return collection }() + /// Diffable 数据源,驱动常用应用与更多功能两个网格 section。 + private var dataSource: UICollectionViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "全部功能" view.backgroundColor = AppDesignUIKit.pageBackground navigationItem.largeTitleDisplayMode = .never - view.addSubview(tableView) - tableView.snp.makeConstraints { make in + configureDataSource() + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + self?.applySnapshot() } rebuildMenus() @@ -44,6 +66,95 @@ final class HomeMoreFunctionsViewController: UIViewController { } } + /// 构建 Compositional Layout,两个 section 均为带标题的三列网格。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self, + let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex] + else { + return CollectionDiffableLayout.gridSection(itemHeight: 112) + } + let gridInsets = NSDirectionalEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + leading: AppMetrics.Spacing.mediumLarge, + bottom: AppMetrics.Spacing.xxSmall, + trailing: AppMetrics.Spacing.mediumLarge + ) + let grid = CollectionDiffableLayout.gridSection( + columns: 3, + itemHeight: 112, + interItemSpacing: 14, + lineSpacing: AppMetrics.Spacing.mediumLarge, + contentInsets: gridInsets + ) + switch section { + case .commonApps: + return CollectionDiffableLayout.addHeader(to: grid, height: 36) + case .moreFunctions: + return CollectionDiffableLayout.addHeader(to: grid, height: 36) + } + } + } + + /// 注册 Diffable 数据源与 Cell 配置闭包。 + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self, + case .menu(let uri) = item, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section] + else { return UICollectionViewCell() } + + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeMoreMenuItemCell.reuseID, + for: indexPath + ) as! HomeMoreMenuItemCell + + let isCommon = section == .commonApps + let items = isCommon ? self.commonItems : self.moreItems + guard let menuItem = items.first(where: { $0.uri == uri }) else { return cell } + + cell.configure(item: menuItem, isCommon: isCommon) { [weak self] in + self?.toggleCommon(menuItem, isCommon: isCommon) + } + return cell + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + guard let self, + kind == UICollectionView.elementKindSectionHeader, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section], + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: CollectionSectionHeaderView.reuseID, + for: indexPath + ) as? CollectionSectionHeaderView + else { return nil } + let title = section == .commonApps ? "常用应用" : "更多功能" + header.configure(title: title) + return header + } + } + + /// 根据当前常用应用与权限菜单构建 snapshot 并应用 diff 更新。 + private func applySnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([.commonApps, .moreFunctions]) + snapshot.appendItems( + commonItems.map { HomeMoreItem.menu(uri: $0.uri) }, + toSection: .commonApps + ) + snapshot.appendItems( + moreItems.map { HomeMoreItem.menu(uri: $0.uri) }, + toSection: .moreFunctions + ) + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 按当前角色权限重建首页菜单与常用应用。 private func rebuildMenus() { let services = appServices viewModel.buildMenus( @@ -51,7 +162,7 @@ final class HomeMoreFunctionsViewController: UIViewController { currentRoleId: services.permissionContext.currentRole?.id ) commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) - tableView.reloadData() + applySnapshot() } private var commonItems: [HomeMenuItem] { @@ -62,6 +173,7 @@ final class HomeMoreFunctionsViewController: UIViewController { viewModel.menuItems.filter { !isCommonURI($0.uri) } } + /// 按 URI 解析并返回可用菜单项。 private func menuItem(for uri: String) -> HomeMenuItem? { let availableURIs = Set(viewModel.menuItems.map(\.uri)) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) @@ -73,20 +185,23 @@ final class HomeMoreFunctionsViewController: UIViewController { ) } + /// 判断 URI 是否已在常用应用中。 private func isCommonURI(_ uri: String) -> Bool { let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey } } + /// 切换菜单项的常用应用状态。 private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) { if isCommon { commonURIs = commonMenuStore.remove(item.uri, current: commonURIs) } else { commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems) } - tableView.reloadData() + applySnapshot() } + /// 打开菜单对应页面。 private func openMenu(_ item: HomeMenuItem) { let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title) if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return } @@ -94,108 +209,24 @@ final class HomeMoreFunctionsViewController: UIViewController { } } -extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 2 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 0 ? "常用应用" : "更多功能" - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell - let isCommon = indexPath.section == 0 - let items = isCommon ? commonItems : moreItems - cell.configure(items: items, isCommon: isCommon) { [weak self] item in - self?.openMenu(item) - } onToggle: { [weak self] item in - self?.toggleCommon(item, isCommon: isCommon) - } - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let count = indexPath.section == 0 ? commonItems.count : moreItems.count - let rows = max(1, Int(ceil(Double(count) / 3.0))) - return CGFloat(rows) * 124 + 8 - } -} - -private final class HomeMoreMenuGridCell: UITableViewCell { - static let reuseID = "HomeMoreMenuGridCell" - - private var items: [HomeMenuItem] = [] - private var isCommon = false - private var onSelect: ((HomeMenuItem) -> Void)? - private var onToggle: ((HomeMenuItem) -> Void)? - - private lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.minimumInteritemSpacing = 14 - layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge - let view = UICollectionView(frame: .zero, collectionViewLayout: layout) - view.backgroundColor = .clear - view.isScrollEnabled = false - view.dataSource = self - view.delegate = self - view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID) - return view - }() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - backgroundColor = .clear - selectionStyle = .none - contentView.addSubview(collectionView) - collectionView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure( - items: [HomeMenuItem], - isCommon: Bool, - onSelect: @escaping (HomeMenuItem) -> Void, - onToggle: @escaping (HomeMenuItem) -> Void - ) { - self.items = items - self.isCommon = isCommon - self.onSelect = onSelect - self.onToggle = onToggle - collectionView.reloadData() - } -} - -extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - items.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell - let item = items[indexPath.item] - cell.configure(item: item, isCommon: isCommon) { [weak self] in - self?.onToggle?(item) - } - return cell - } +// MARK: - UICollectionViewDelegate +extension HomeMoreFunctionsViewController: UICollectionViewDelegate { + /// 点击网格项打开对应功能页(加减按钮由 Cell 内部处理)。 func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - onSelect?(items[indexPath.item]) - } + guard let item = dataSource.itemIdentifier(for: indexPath), + case .menu(let uri) = item + else { return } - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let width = (collectionView.bounds.width - 28) / 3 - return CGSize(width: width, height: 112) + let allItems = commonItems + moreItems + guard let menuItem = allItems.first(where: { $0.uri == uri }) else { return } + openMenu(menuItem) } } +// MARK: - Menu Item Cell + +/// 全部功能页菜单网格单项 Cell,支持常用应用加减操作。 private final class HomeMoreMenuItemCell: UICollectionViewCell { static let reuseID = "HomeMoreMenuItemCell" @@ -204,6 +235,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell { private let toggleButton = UIButton(type: .system) private var onToggle: (() -> Void)? + /// 初始化实例。 override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white @@ -243,6 +275,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + /// 配置菜单项展示内容与常用应用切换按钮。 func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) { self.onToggle = onToggle titleLabel.text = item.title @@ -254,7 +287,15 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell { toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary } + /// 点击加减按钮切换常用应用状态。 @objc private func toggleTapped() { onToggle?() } } + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift index 08b5d65..f07efb1 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift @@ -13,6 +13,7 @@ final class HomePlaceholderViewController: UIViewController { private let pageTitle: String private let uri: String? + /// 初始化实例。 init(title: String, uri: String? = nil) { pageTitle = title self.uri = uri @@ -24,6 +25,7 @@ final class HomePlaceholderViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = pageTitle diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift index c85a52d..ac0f7d0 100644 --- a/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift @@ -6,6 +6,26 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +/// 首页 section 标识,按角色动态展示工作状态、门店与常用应用网格。 +private enum HomeSection: Hashable { + case workStatus + case locationReport + case storeInfo + case quickActions + case commonMenus +} + +/// 首页 item 标识;工作状态类 item 携带展示数据以支持倒计时 diff 刷新。 +private enum HomeItem: Hashable { + case workStatus(isOnline: Bool, secondsUntilReport: Int, reminderMinutes: Int) + case locationReport + case storeInfo(storeID: Int) + case quickActions(isOnline: Bool) + case menu(uri: String) +} + /// 首页工作台,展示景区头部、工作状态、位置上报卡片和常用应用网格。 final class HomeViewController: UIViewController { @@ -34,40 +54,55 @@ final class HomeViewController: UIViewController { return button }() - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.separatorStyle = .none - table.showsVerticalScrollIndicator = false - table.dataSource = self - table.delegate = self - table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID) - table.register(UITableViewCell.self, forCellReuseIdentifier: "cell") - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.showsVerticalScrollIndicator = false + collection.delegate = self + collection.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseID) + collection.register(HomeLocationReportCell.self, forCellWithReuseIdentifier: HomeLocationReportCell.reuseID) + collection.register(HomeStoreInfoCell.self, forCellWithReuseIdentifier: HomeStoreInfoCell.reuseID) + collection.register(HomeQuickActionsCell.self, forCellWithReuseIdentifier: HomeQuickActionsCell.reuseID) + collection.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID) + collection.register( + CollectionSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CollectionSectionHeaderView.reuseID + ) + return collection }() + /// Diffable 数据源,驱动多 section 卡片与常用应用网格。 + private var dataSource: UICollectionViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = AppDesignUIKit.pageBackground setupTopBar() - setupTableView() + configureDataSource() + setupCollectionView() bindViewModel() rebuildMenus() observeContextChanges() } + /// 视图即将展示,刷新可见状态。 override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) startCountdownTimerIfNeeded() } + /// 视图即将消失,保存或清理临时状态。 override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) countdownTimer?.invalidate() countdownTimer = nil } + /// 初始化 TopBar 相关 UI 或状态。 private func setupTopBar() { let topBar = UIView() topBar.backgroundColor = .white @@ -85,20 +120,160 @@ final class HomeViewController: UIViewController { updateScenicTitle() } - private func setupTableView() { - view.addSubview(tableView) - tableView.snp.makeConstraints { make in + /// 初始化 CollectionView 布局约束。 + private func setupCollectionView() { + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(78) make.leading.trailing.bottom.equalToSuperview() } } - private func bindViewModel() { - viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + /// 构建 Compositional Layout,按 section 类型分配全宽卡片或三列网格。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self, + let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex] + else { + return CollectionDiffableLayout.fullWidthSection() + } + switch section { + case .workStatus: + return CollectionDiffableLayout.fullWidthSection(height: 100) + case .locationReport: + return CollectionDiffableLayout.fullWidthSection(height: 148) + case .storeInfo: + return CollectionDiffableLayout.fullWidthSection(height: 88) + case .quickActions: + return CollectionDiffableLayout.fullWidthSection(height: 118) + case .commonMenus: + return CollectionDiffableLayout.addHeader( + to: CollectionDiffableLayout.gridSection(itemHeight: 102), + height: 36 + ) + } } } + /// 注册 Diffable 数据源与 Cell 配置闭包。 + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + switch item { + case .workStatus(let online, let seconds, let reminder): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeWorkStatusCell.reuseID, + for: indexPath + ) as! HomeWorkStatusCell + cell.configure( + isOnline: online, + countdownText: self.countdownText(seconds: seconds), + reminderText: self.reminderText(minutes: reminder), + onOnlineTap: { [weak self] in self?.onlineTapped() }, + onReminderTap: { [weak self] in self?.reminderTapped() } + ) + return cell + case .locationReport: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeLocationReportCell.reuseID, + for: indexPath + ) as! HomeLocationReportCell + cell.configure(onReportTap: { [weak self] in self?.locationReportTapped() }) + return cell + case .storeInfo(let storeID): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeStoreInfoCell.reuseID, + for: indexPath + ) as! HomeStoreInfoCell + if let store = self.appServices.accountContext.currentStore, store.id == storeID { + cell.configure( + storeName: store.name, + scenicName: self.appServices.accountContext.currentScenic?.name + ) + } + return cell + case .quickActions(let online): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeQuickActionsCell.reuseID, + for: indexPath + ) as! HomeQuickActionsCell + cell.configure( + isOnline: online, + onPaymentTap: { [weak self] in self?.paymentTapped() }, + onTaskCreateTap: { [weak self] in self?.taskCreateTapped() }, + onOnlineTap: { [weak self] in self?.onlineTapped() } + ) + return cell + case .menu(let uri): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeMenuItemCell.reuseID, + for: indexPath + ) as! HomeMenuItemCell + if let menuItem = self.menuItem(for: uri) ?? self.displayMenuItems.first(where: { $0.uri == uri }) { + cell.configure(item: menuItem) + } + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + guard let self, + kind == UICollectionView.elementKindSectionHeader, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section], + section == .commonMenus, + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: CollectionSectionHeaderView.reuseID, + for: indexPath + ) as? CollectionSectionHeaderView + else { return nil } + header.configure(title: "常用应用") + return header + } + } + + /// 根据当前角色与状态构建 snapshot 并应用 diff 更新。 + private func applySnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + + if shouldShowWorkStatus { + snapshot.appendSections([.workStatus, .locationReport]) + snapshot.appendItems( + [.workStatus(isOnline: isOnline, secondsUntilReport: secondsUntilReport, reminderMinutes: reminderMinutes)], + toSection: .workStatus + ) + snapshot.appendItems([.locationReport], toSection: .locationReport) + } + + if isStoreManager, let store = appServices.accountContext.currentStore { + snapshot.appendSections([.storeInfo]) + snapshot.appendItems([.storeInfo(storeID: store.id)], toSection: .storeInfo) + } + + if shouldShowWorkStatus { + snapshot.appendSections([.quickActions]) + snapshot.appendItems([.quickActions(isOnline: isOnline)], toSection: .quickActions) + } + + snapshot.appendSections([.commonMenus]) + snapshot.appendItems( + displayMenuItems.map { HomeItem.menu(uri: $0.uri) }, + toSection: .commonMenus + ) + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 绑定 ViewModel 回调或数据。 + private func bindViewModel() { + viewModel.onChange = { [weak self] in + self?.applySnapshot() + } + } + + /// 监听权限与账号上下文变化并刷新菜单。 private func observeContextChanges() { let services = appServices services.permissionContext.onChange = { [weak self] in @@ -106,10 +281,11 @@ final class HomeViewController: UIViewController { } services.accountContext.onChange = { [weak self] in self?.updateScenicTitle() - self?.tableView.reloadData() + self?.applySnapshot() } } + /// 按当前角色权限重建首页菜单与常用应用。 private func rebuildMenus() { let services = appServices viewModel.buildMenus( @@ -117,9 +293,10 @@ final class HomeViewController: UIViewController { currentRoleId: services.permissionContext.currentRole?.id ) commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) - tableView.reloadData() + applySnapshot() } + /// 刷新顶部景区名称展示。 private func updateScenicTitle() { let name = appServices.accountContext.currentScenic?.name ?? "请选择景区" scenicButton.configuration?.attributedTitle = AttributedString( @@ -130,25 +307,30 @@ final class HomeViewController: UIViewController { ) } + /// 当前登录角色 ID。 private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + /// 部分精简角色不展示工作状态与位置上报区块。 private var shouldShowWorkStatus: Bool { guard let currentRoleId else { return true } return !minimalTopRoleIds.contains(currentRoleId) } + /// 是否为门店管理员角色(roleId = 46)。 private var isStoreManager: Bool { currentRoleId == 46 } + /// 常用应用展示项:优先用户自定义,不足 3 个时取权限菜单前 3 项,末尾固定「更多功能」。 private var displayMenuItems: [HomeMenuItem] { let selected = commonURIs.compactMap { menuItem(for: $0) } let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)] } + /// 按 URI 解析并返回可用菜单项。 private func menuItem(for uri: String) -> HomeMenuItem? { let availableURIs = Set(viewModel.menuItems.map(\.uri)) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) @@ -160,31 +342,36 @@ final class HomeViewController: UIViewController { ) } - private var countdownDisplay: String { - let hours = secondsUntilReport / 3_600 - let minutes = (secondsUntilReport % 3_600) / 60 - let seconds = secondsUntilReport % 60 - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + /// 格式化倒计时展示文本。 + private func countdownText(seconds: Int) -> String { + let hours = seconds / 3_600 + let minutes = (seconds % 3_600) / 60 + let secs = seconds % 60 + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secs))" } - private var reminderText: String { - reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟" + /// 格式化提前提醒展示文本。 + private func reminderText(minutes: Int) -> String { + minutes == 0 ? "不提醒" : "提前\(minutes)分钟" } + /// 在线状态下启动位置上报倒计时。 private func startCountdownTimerIfNeeded() { countdownTimer?.invalidate() guard isOnline else { return } countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self, self.isOnline, self.secondsUntilReport > 0 else { return } self.secondsUntilReport -= 1 - self.tableView.reloadSections(IndexSet(integer: 0), with: .none) + self.applySnapshot(animated: false) } } + /// 点击景区名称,跳转景区选择页。 @objc private func scenicTapped() { HomeMenuRouting.push(.scenicSelection, from: self) } + /// 切换在线 / 离线状态。 @objc private func onlineTapped() { let message = isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" @@ -200,147 +387,137 @@ final class HomeViewController: UIViewController { } else { self.countdownTimer?.invalidate() } - self.tableView.reloadSections(IndexSet(integer: 0), with: .none) + self.applySnapshot() }) present(alert, animated: true) } + /// 选择位置上报提前提醒时间。 @objc private func reminderTapped() { let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet) for minute in [0, 5, 10, 15, 30] { let title = minute == 0 ? "不提醒" : "\(minute)分钟" sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in self?.reminderMinutes = minute - self?.tableView.reloadSections(IndexSet(integer: 0), with: .none) + self?.applySnapshot() }) } sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) present(sheet, animated: true) } + /// 跳转位置上报页面。 @objc private func locationReportTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self) } + /// 跳转立即收款页面。 @objc private func paymentTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self) } + /// 跳转提交任务页面。 @objc private func taskCreateTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self) } } -// MARK: - UITableView +// MARK: - UICollectionViewDelegate -extension HomeViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { - var count = 1 - if shouldShowWorkStatus { count += 2 } - if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 } - return count +extension HomeViewController: UICollectionViewDelegate { + /// 点击常用应用网格项,执行菜单路由。 + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath), + case .menu(let uri) = item, + let menuItem = displayMenuItems.first(where: { $0.uri == uri }) + else { return } + HomeMenuRouting.openMenu(menuItem, from: self) } +} - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - 1 - } +// MARK: - Card Cells - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - if section == tableView.numberOfSections - 1 { - return "常用应用" - } - return nil - } +/// 在线状态卡片 Cell,展示在线切换、倒计时与提醒设置。 +private final class HomeWorkStatusCell: UICollectionViewCell { + static let reuseID = "HomeWorkStatusCell" - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.section == tableView.numberOfSections - 1 { - let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell - cell.configure(items: displayMenuItems) { [weak self] item in - self.flatMap { HomeMenuRouting.openMenu(item, from: $0) } - } - return cell - } + private let cardView = UIView() + private let onlineButton = UIButton(type: .system) + private let clockLabel = UILabel() + private let reminderButton = UIButton(type: .system) - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - cell.selectionStyle = .none - cell.contentView.subviews.forEach { $0.removeFromSuperview() } + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + contentView.addSubview(cardView) - if shouldShowWorkStatus { - if indexPath.section == 0 { - cell.contentView.addSubview(makeStatusCard()) - } else if indexPath.section == 1 { - cell.contentView.addSubview(makeLocationCard()) - } else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore { - cell.contentView.addSubview(makeStoreCard(store)) - } else { - cell.contentView.addSubview(makeQuickActionsRow()) - } - } else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 { - cell.contentView.addSubview(makeStoreCard(store)) - } - - cell.contentView.subviews.first?.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets( - top: AppMetrics.Spacing.xxSmall, - left: AppMetrics.Spacing.pageHorizontal, - bottom: AppMetrics.Spacing.xxSmall, - right: AppMetrics.Spacing.pageHorizontal - )) - } - cell.backgroundColor = .clear - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - if indexPath.section == tableView.numberOfSections - 1 { return 240 } - if shouldShowWorkStatus { - switch indexPath.section { - case 0: return 100 - case 1: return 148 - case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88 - default: return 118 - } - } - if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 } - return UITableView.automaticDimension - } - - private func makeStatusCard() -> UIView { - let card = makeCardView() - - let onlineButton = UIButton(type: .system) - onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal) onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) - onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal) - onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4) onlineButton.layer.cornerRadius = 4 onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) - onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside) - let clockLabel = UILabel() - clockLabel.text = " \(countdownDisplay)" clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) clockLabel.textColor = AppDesignUIKit.primary - let reminderButton = UIButton(type: .system) - reminderButton.setTitle(" \(reminderText)", for: .normal) reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal) - reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton]) stack.axis = .horizontal stack.distribution = .equalSpacing stack.alignment = .center - card.addSubview(stack) + cardView.addSubview(stack) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(15) } - return card } - private func makeLocationCard() -> UIView { - let card = makeCardView() + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置在线状态、倒计时与提醒,并绑定点击回调。 + func configure( + isOnline: Bool, + countdownText: String, + reminderText: String, + onOnlineTap: @escaping () -> Void, + onReminderTap: @escaping () -> Void + ) { + onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal) + onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal) + onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4) + clockLabel.text = " \(countdownText)" + reminderButton.setTitle(" \(reminderText)", for: .normal) + + onlineButton.removeAction(identifiedBy: UIAction.Identifier("online"), for: .touchUpInside) + reminderButton.removeAction(identifiedBy: UIAction.Identifier("reminder"), for: .touchUpInside) + onlineButton.addAction(UIAction(identifier: UIAction.Identifier("online")) { _ in onOnlineTap() }, for: .touchUpInside) + reminderButton.addAction(UIAction(identifier: UIAction.Identifier("reminder")) { _ in onReminderTap() }, for: .touchUpInside) + } +} + +/// 位置上报卡片 Cell,展示打卡范围提示与立即上报入口。 +private final class HomeLocationReportCell: UICollectionViewCell { + static let reuseID = "HomeLocationReportCell" + + private let cardView = UIView() + private let actionButton = UIButton(type: .system) + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + contentView.addSubview(cardView) let titleLabel = UILabel() titleLabel.text = "立即上报" @@ -356,15 +533,16 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { textStack.axis = .vertical textStack.spacing = AppMetrics.Spacing.xxSmall - let actionButton = UIButton(type: .system) actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal) actionButton.tintColor = .white actionButton.backgroundColor = AppDesignUIKit.primary actionButton.layer.cornerRadius = 46 - actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside) - card.addSubview(textStack) - card.addSubview(actionButton) + cardView.addSubview(textStack) + cardView.addSubview(actionButton) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } textStack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(15) make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12) @@ -374,72 +552,38 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { make.centerY.equalToSuperview() make.width.height.equalTo(92) } - return card } - private func makeQuickActionsRow() -> UIView { - let stack = UIStackView() - stack.axis = .horizontal - stack.spacing = AppMetrics.Spacing.small - stack.distribution = .fillEqually - - stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped))) - stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped))) - stack.addArrangedSubview(quickActionButton( - icon: isOnline ? "wifi" : "wifi.slash", - title: isOnline ? "在线" : "离线", - action: #selector(onlineTapped), - active: isOnline - )) - return stack + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView { - let card = makeCardView() - card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white - - let iconView = UIImageView(image: UIImage(systemName: icon)) - iconView.tintColor = AppDesignUIKit.primary - iconView.contentMode = .scaleAspectFit - - let label = UILabel() - label.text = title - label.font = .systemFont(ofSize: AppMetrics.FontSize.body) - label.textAlignment = .center - - let stack = UIStackView(arrangedSubviews: [iconView, label]) - stack.axis = .vertical - stack.spacing = AppMetrics.Spacing.xSmall - stack.alignment = .center - card.addSubview(stack) - stack.snp.makeConstraints { make in - make.center.equalToSuperview() - } - iconView.snp.makeConstraints { make in - make.height.equalTo(34) - } - - let button = UIButton(type: .custom) - button.addTarget(self, action: action, for: .touchUpInside) - card.addSubview(button) - button.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - card.snp.makeConstraints { make in - make.height.equalTo(102) - } - return card + /// 配置立即上报按钮点击回调。 + func configure(onReportTap: @escaping () -> Void) { + actionButton.removeAction(identifiedBy: UIAction.Identifier("report"), for: .touchUpInside) + actionButton.addAction(UIAction(identifier: UIAction.Identifier("report")) { _ in onReportTap() }, for: .touchUpInside) } +} - private func makeStoreCard(_ store: BusinessScope) -> UIView { - let card = makeCardView() +/// 门店信息卡片 Cell,展示当前门店名称与营业状态。 +private final class HomeStoreInfoCell: UICollectionViewCell { + static let reuseID = "HomeStoreInfoCell" + + private let cardView = UIView() + private let nameLabel = UILabel() + private let scenicLabel = UILabel() + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + contentView.addSubview(cardView) - let nameLabel = UILabel() - nameLabel.text = store.name nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold) - let scenicLabel = UILabel() - scenicLabel.text = appServices.accountContext.currentScenic?.name scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) scenicLabel.textColor = UIColor(hex: 0x7B8EAA) scenicLabel.numberOfLines = 2 @@ -457,8 +601,11 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { textStack.axis = .vertical textStack.spacing = AppMetrics.Spacing.xSmall - card.addSubview(textStack) - card.addSubview(statusLabel) + cardView.addSubview(textStack) + cardView.addSubview(statusLabel) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } textStack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8) @@ -468,39 +615,36 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { make.width.greaterThanOrEqualTo(52) make.height.equalTo(22) } - return card + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置门店名称与所属景区。 + func configure(storeName: String, scenicName: String?) { + nameLabel.text = storeName + scenicLabel.text = scenicName } } -// MARK: - Menu Grid Cell +/// 快捷操作行 Cell,展示收款、任务与在线状态入口。 +private final class HomeQuickActionsCell: UICollectionViewCell { + static let reuseID = "HomeQuickActionsCell" -private final class HomeMenuGridCell: UITableViewCell { - static let reuseID = "HomeMenuGridCell" + private let stackView = UIStackView() - private var onSelect: ((HomeMenuItem) -> Void)? - private var items: [HomeMenuItem] = [] - - private lazy var collectionView: UICollectionView = { - let layout = UICollectionViewFlowLayout() - layout.minimumInteritemSpacing = 15 - layout.minimumLineSpacing = 15 - let view = UICollectionView(frame: .zero, collectionViewLayout: layout) - view.backgroundColor = .clear - view.isScrollEnabled = false - view.dataSource = self - view.delegate = self - view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID) - return view - }() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear - selectionStyle = .none - contentView.addSubview(collectionView) - collectionView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) - make.height.equalTo(220) + stackView.axis = .horizontal + stackView.spacing = AppMetrics.Spacing.small + stackView.distribution = .fillEqually + contentView.addSubview(stackView) + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() } } @@ -509,40 +653,87 @@ private final class HomeMenuGridCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) { - self.items = items - self.onSelect = onSelect - collectionView.reloadData() - } -} - -extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - items.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell - cell.configure(item: items[indexPath.item]) - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - onSelect?(items[indexPath.item]) - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let width = (collectionView.bounds.width - 30) / 3 - return CGSize(width: width, height: 102) + /// 配置三个快捷操作按钮与点击回调。 + func configure( + isOnline: Bool, + onPaymentTap: @escaping () -> Void, + onTaskCreateTap: @escaping () -> Void, + onOnlineTap: @escaping () -> Void + ) { + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + stackView.addArrangedSubview(quickActionCard( + icon: "qrcode", + title: "立即收款", + active: false, + action: onPaymentTap + )) + stackView.addArrangedSubview(quickActionCard( + icon: "checklist.checked", + title: "提交任务", + active: false, + action: onTaskCreateTap + )) + stackView.addArrangedSubview(quickActionCard( + icon: isOnline ? "wifi" : "wifi.slash", + title: isOnline ? "在线" : "离线", + active: isOnline, + action: onOnlineTap + )) + } + + /// 构建单个快捷操作卡片。 + private func quickActionCard( + icon: String, + title: String, + active: Bool, + action: @escaping () -> Void + ) -> UIView { + let card = UIView() + card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white + card.layer.cornerRadius = 8 + + let iconView = UIImageView(image: UIImage(systemName: icon)) + iconView.tintColor = AppDesignUIKit.primary + iconView.contentMode = .scaleAspectFit + + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: AppMetrics.FontSize.body) + label.textAlignment = .center + + let innerStack = UIStackView(arrangedSubviews: [iconView, label]) + innerStack.axis = .vertical + innerStack.spacing = AppMetrics.Spacing.xSmall + innerStack.alignment = .center + card.addSubview(innerStack) + innerStack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + iconView.snp.makeConstraints { make in + make.height.equalTo(34) + } + + let button = UIButton(type: .custom) + button.addAction(UIAction { _ in action() }, for: .touchUpInside) + card.addSubview(button) + button.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + card.snp.makeConstraints { make in + make.height.equalTo(102) + } + return card } } +/// 首页菜单网格单项 Cell,负责图标与标题展示。 private final class HomeMenuItemCell: UICollectionViewCell { static let reuseID = "HomeMenuItemCell" private let iconView = UIImageView() private let titleLabel = UILabel() + /// 初始化实例。 override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white @@ -574,6 +765,7 @@ private final class HomeMenuItemCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + /// 配置菜单项图标与标题。 func configure(item: HomeMenuItem) { titleLabel.text = item.title let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri)) @@ -581,3 +773,10 @@ private final class HomeMenuItemCell: UICollectionViewCell { iconView.tintColor = AppDesign.primary } } + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift index c0d3132..3563e05 100644 --- a/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift +++ b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift @@ -20,6 +20,7 @@ final class PhotographerInviteViewController: UIViewController { private let rulesLabel = UILabel() private let activityIndicator = UIActivityIndicatorView(style: .medium) + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "邀请摄影师" @@ -29,6 +30,7 @@ final class PhotographerInviteViewController: UIViewController { Task { await viewModel.reload(api: services.inviteAPI) } } + /// 初始化UI相关 UI 或状态。 private func setupUI() { navigationItem.rightBarButtonItems = [ UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)), @@ -64,6 +66,7 @@ final class PhotographerInviteViewController: UIViewController { contentStack.addArrangedSubview(rulesLabel) } + /// render 业务逻辑。 private func render() { activityIndicator.isHidden = !viewModel.loading if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } @@ -76,7 +79,9 @@ final class PhotographerInviteViewController: UIViewController { } } + /// 复制验证码。 @objc private func copyCode() { viewModel.copyInviteCode() } + /// 复制链接。 @objc private func copyURL() { viewModel.copyInviteUrl() } } @@ -87,6 +92,7 @@ final class InviteRecordViewController: ModuleTableViewController { private let viewModel = InviteRecordViewModel() private let summaryLabel = UILabel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "邀请记录" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -103,6 +109,7 @@ final class InviteRecordViewController: ModuleTableViewController { } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { summaryLabel.font = .systemFont(ofSize: 14) summaryLabel.textColor = AppDesign.textSecondary @@ -112,10 +119,12 @@ final class InviteRecordViewController: ModuleTableViewController { tableView.tableHeaderView = summaryLabel } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.displayRows.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let row = viewModel.displayRows[indexPath.row] cell.configure( @@ -125,6 +134,7 @@ final class InviteRecordViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload( inviteAPI: services.inviteAPI, @@ -134,11 +144,13 @@ final class InviteRecordViewController: ModuleTableViewController { updateSummary() } + /// 更新Summary状态。 private func updateSummary() { summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)" navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户" } + /// toggleTab相关逻辑。 @objc private func toggleTab() { Task { let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite diff --git a/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift b/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift index 881db5f..5c176a0 100644 --- a/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift +++ b/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift @@ -61,6 +61,7 @@ final class PhotographerInviteViewModel { return UIImage(cgImage: cgImage) } + /// 清空。 private func clear() { inviteCode = "" inviteUrl = "" @@ -128,6 +129,7 @@ final class InviteRecordViewModel { await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true) } + /// 加载InviteRows数据。 private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws { let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize) let mapped = users.map { @@ -152,6 +154,7 @@ final class InviteRecordViewModel { hasMore = users.count >= invitePageSize } + /// 加载RewardRows数据。 private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws { let response = try await api.walletEarningDetail( startDate: "2025-01-01", @@ -183,6 +186,7 @@ final class InviteRecordViewModel { hasMore = rewardRows.count < response.total && !mapped.isEmpty } + /// 清空。 private func clear() { totalRewardText = "¥ 0.00" withdrawableText = "¥ 0.00" diff --git a/suixinkan_ios/Features/Live/API/LiveAPI.swift b/suixinkan_ios/Features/Live/API/LiveAPI.swift index 862ad1d..3f8eab0 100644 --- a/suixinkan_ios/Features/Live/API/LiveAPI.swift +++ b/suixinkan_ios/Features/Live/API/LiveAPI.swift @@ -10,17 +10,29 @@ import Foundation /// 直播服务协议,定义直播管理和直播相册接口能力。 @MainActor protocol LiveServing { + /// 直播列表相关逻辑。 func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse + /// 直播详情相关逻辑。 func liveDetail(liveId: Int) async throws -> LiveEntity + /// 直播创建相关逻辑。 func liveCreate(_ request: LiveCreateRequest) async throws + /// 直播启动相关逻辑。 func liveStart(liveId: Int) async throws + /// 直播停止相关逻辑。 func liveStop(liveId: Int) async throws + /// 直播Finish相关逻辑。 func liveFinish(liveId: Int) async throws + /// 直播Set推送Mode相关逻辑。 func liveSetPushMode(liveId: Int, mode: Int) async throws + /// 直播相册列表相关逻辑。 func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse + /// 直播相册创建文件夹相关逻辑。 func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws + /// 直播相册删除文件夹相关逻辑。 func liveAlbumDeleteFolder(folderId: Int) async throws + /// 直播相册文件夹详情相关逻辑。 func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem + /// 直播相册删除Files相关逻辑。 func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws } diff --git a/suixinkan_ios/Features/Live/Models/LiveModels.swift b/suixinkan_ios/Features/Live/Models/LiveModels.swift index 867c15c..468aa83 100644 --- a/suixinkan_ios/Features/Live/Models/LiveModels.swift +++ b/suixinkan_ios/Features/Live/Models/LiveModels.swift @@ -14,6 +14,7 @@ struct LiveListResponse: Decodable { let page: Int let pageSize: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case items case total @@ -21,6 +22,7 @@ struct LiveListResponse: Decodable { case pageSize = "page_size" } + /// 初始化实例。 init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) { self.items = items self.total = total @@ -28,6 +30,7 @@ struct LiveListResponse: Decodable { self.pageSize = pageSize } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? [] @@ -57,6 +60,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable { let manualPushState: Int let viewsCount: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case title @@ -77,6 +81,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable { case viewsCount = "views_count" } + /// 初始化实例。 init( id: Int = 0, title: String = "", @@ -115,6 +120,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable { self.viewsCount = viewsCount } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 @@ -151,6 +157,7 @@ struct LiveCreateRequest: Encodable, Equatable { let title: String let coverImg: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case title @@ -162,6 +169,7 @@ struct LiveCreateRequest: Encodable, Equatable { struct LiveControlRequest: Encodable, Equatable { let liveId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case liveId = "live_id" } @@ -172,6 +180,7 @@ struct LivePushModeRequest: Encodable, Equatable { let liveId: Int let manualPushMode: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case liveId = "live_id" case manualPushMode = "manual_push_mode" @@ -186,6 +195,7 @@ struct LiveAlbumFolderListResponse: Decodable { let total: Int let totalPages: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case items case page @@ -194,6 +204,7 @@ struct LiveAlbumFolderListResponse: Decodable { case totalPages = "total_pages" } + /// 初始化实例。 init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) { self.items = items self.page = page @@ -202,6 +213,7 @@ struct LiveAlbumFolderListResponse: Decodable { self.totalPages = totalPages } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? [] @@ -220,6 +232,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable { let creator: String let items: [LiveAlbumFileItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case albumId = "album_id" @@ -228,6 +241,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable { case items } + /// 初始化实例。 init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) { self.id = id self.albumId = albumId @@ -236,6 +250,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable { self.items = items } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 @@ -254,6 +269,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable { let size: Int64 let coverImg: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case url @@ -262,6 +278,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable { case coverImg = "cover_img" } + /// 初始化实例。 init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) { self.id = id self.url = url @@ -270,6 +287,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable { self.coverImg = coverImg } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 @@ -295,6 +313,7 @@ struct LiveAlbumCreateFolderRequest: Encodable, Equatable { let name: String let items: [LiveAlbumCreateFileItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case name @@ -309,6 +328,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable { let size: Int64 let coverImg: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case url case type @@ -321,6 +341,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable { struct LiveAlbumDeleteFolderRequest: Encodable, Equatable { let folderId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case folderId = "folder_id" } @@ -331,6 +352,7 @@ struct LiveAlbumDeleteFilesRequest: Encodable, Equatable { let folderId: Int let fileIds: [Int] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case folderId = "folder_id" case fileIds = "file_ids" @@ -346,6 +368,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable { let size: Int64 var uploadedURL: String? + /// 初始化实例。 init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) { self.id = id self.data = data @@ -359,6 +382,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable { } private extension KeyedDecodingContainer { + /// 直播解码宽松字符串相关逻辑。 func liveDecodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -378,6 +402,7 @@ private extension KeyedDecodingContainer { return "" } + /// 直播解码宽松整数相关逻辑。 func liveDecodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value diff --git a/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift b/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift index f2ae87f..5b12d67 100644 --- a/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift +++ b/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift @@ -11,14 +11,17 @@ import UIKit final class LiveManagementViewController: ModuleTableViewController { private let viewModel = LiveManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "直播管理" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure( @@ -28,10 +31,12 @@ final class LiveManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) } + /// table视图相关逻辑。 override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard indexPath.row >= viewModel.items.count - 2 else { return } Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) } @@ -44,19 +49,23 @@ extension LiveManagementViewModel: ViewModelBindable {} final class LiveAlbumViewController: ModuleTableViewController { private let viewModel = LiveAlbumViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "直播相册" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.folders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let folder = viewModel.folders[indexPath.row] cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张") } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) } diff --git a/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift b/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift index c05f12b..84eda98 100644 --- a/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift +++ b/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift @@ -10,10 +10,12 @@ import Foundation /// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。 enum LivePlaybackURLResolver { + /// playable链接相关逻辑。 static func playableURL(from live: LiveEntity) -> URL? { playableURL(from: live.playbackURLCandidates) } + /// playable链接相关逻辑。 static func playableURL(from candidates: [String]) -> URL? { for candidate in candidates { if let url = playableURL(from: candidate) { @@ -23,6 +25,7 @@ enum LivePlaybackURLResolver { return nil } + /// playable链接相关逻辑。 static func playableURL(from rawValue: String) -> URL? { let value = rawValue.liveTrimmed guard !value.isEmpty, let url = URL(string: value) else { return nil } @@ -65,6 +68,7 @@ final class LivePlaybackViewModel { return false } + /// 初始化实例。 init(live: LiveEntity? = nil, urlString: String? = nil) { if let live { load(live: live) @@ -73,14 +77,17 @@ final class LivePlaybackViewModel { } } + /// 加载数据。 func load(live: LiveEntity) { load(url: LivePlaybackURLResolver.playableURL(from: live)) } + /// 加载数据。 func load(urlString: String) { load(url: LivePlaybackURLResolver.playableURL(from: urlString)) } + /// play 业务逻辑。 func play() { guard let url = playableURL else { return } if player == nil { @@ -90,12 +97,14 @@ final class LivePlaybackViewModel { state = .playing(url) } + /// pause 业务逻辑。 func pause() { guard let url = playableURL else { return } player?.pause() state = .ready(url) } + /// 刷新。 func reload() { guard let url = playableURL else { return } releasePlayer() @@ -103,6 +112,7 @@ final class LivePlaybackViewModel { state = .ready(url) } + /// release 业务逻辑。 func release() { releasePlayer() if let url = playableURL { @@ -112,6 +122,7 @@ final class LivePlaybackViewModel { } } + /// 加载数据。 private func load(url: URL?) { releasePlayer() guard let url else { @@ -124,6 +135,7 @@ final class LivePlaybackViewModel { state = .ready(url) } + /// releasePlayer相关逻辑。 private func releasePlayer() { player?.pause() player = nil diff --git a/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift b/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift index 12441c0..fe9c756 100644 --- a/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift +++ b/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift @@ -47,25 +47,34 @@ enum LivePushNetworkState: Equatable { protocol LivePushAdapter { var name: String { get } var isAvailable: Bool { get } + /// prepare 业务逻辑。 func prepare(pushURL: URL) async throws + /// start 业务逻辑。 func start() async throws + /// stop 业务逻辑。 func stop() async throws + /// dispose 业务逻辑。 func dispose() async } /// 权限提供者协议,隔离 AVFoundation 便于测试。 protocol LivePermissionProviding { + /// camera权限相关逻辑。 func cameraPermission() async -> LivePushPermissionState + /// microphone权限相关逻辑。 func microphonePermission() async -> LivePushPermissionState } /// 网络监听协议,隔离 NWPathMonitor 便于测试。 protocol LiveNetworkMonitoring: AnyObject { var currentState: LivePushNetworkState { get } + /// start 业务逻辑。 func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) + /// stop 业务逻辑。 func stop() } +/// LivePushReadiness错误类型定义。 enum LivePushReadinessError: LocalizedError, Equatable { case missingPushURL case invalidPushURL @@ -89,32 +98,41 @@ enum LivePushReadinessError: LocalizedError, Equatable { } } +/// UnsupportedLivePushAdapter,业务类型定义。 struct UnsupportedLivePushAdapter: LivePushAdapter { let name = "未接入推流 SDK" let isAvailable = false + /// prepare 业务逻辑。 func prepare(pushURL: URL) async throws { throw LivePushReadinessError.sdkUnavailable } + /// start 业务逻辑。 func start() async throws { throw LivePushReadinessError.sdkUnavailable } + /// stop 业务逻辑。 func stop() async throws {} + /// dispose 业务逻辑。 func dispose() async {} } +/// SystemLivePermission能力提供者,封装外部依赖。 struct SystemLivePermissionProvider: LivePermissionProviding { + /// camera权限相关逻辑。 func cameraPermission() async -> LivePushPermissionState { await permission(for: .video) } + /// microphone权限相关逻辑。 func microphonePermission() async -> LivePushPermissionState { await permission(for: .audio) } + /// permission 业务逻辑。 private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState { switch AVCaptureDevice.authorizationStatus(for: mediaType) { case .authorized: @@ -130,6 +148,7 @@ struct SystemLivePermissionProvider: LivePermissionProviding { } } +/// SystemLiveNetworkMonitor 类型定义。 final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { var onChange: (() -> Void)? @@ -137,6 +156,7 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { private let queue = DispatchQueue(label: "com.suixinkan.live.network") private(set) var currentState: LivePushNetworkState = .unknown + /// start 业务逻辑。 func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) { monitor.pathUpdateHandler = { [weak self] path in let state = Self.state(from: path) @@ -146,10 +166,12 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { monitor.start(queue: queue) } + /// stop 业务逻辑。 func stop() { monitor.cancel() } + /// state 业务逻辑。 private static func state(from path: NWPath) -> LivePushNetworkState { guard path.status == .satisfied else { return .unavailable } if path.usesInterfaceType(.wifi) { return .wifi } @@ -177,6 +199,7 @@ final class LivePushReadinessViewModel { private let adapter: any LivePushAdapter private var pushURL: URL? + /// 初始化实例。 init( permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(), networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(), @@ -190,6 +213,7 @@ final class LivePushReadinessViewModel { self.networkState = networkMonitor.currentState } + /// 配置展示内容。 func configure(pushURL rawValue: String) { let value = rawValue.liveTrimmed guard !value.isEmpty else { @@ -206,6 +230,7 @@ final class LivePushReadinessViewModel { errorMessage = nil } + /// startMonitoring相关逻辑。 func startMonitoring() { networkState = networkMonitor.currentState networkMonitor.start { [weak self] state in @@ -215,10 +240,12 @@ final class LivePushReadinessViewModel { } } + /// stopMonitoring相关逻辑。 func stopMonitoring() { networkMonitor.stop() } + /// refreshPermissions相关逻辑。 func refreshPermissions() async { async let camera = permissionProvider.cameraPermission() async let microphone = permissionProvider.microphonePermission() @@ -226,6 +253,7 @@ final class LivePushReadinessViewModel { microphonePermission = await microphone } + /// runDiagnostics相关逻辑。 func runDiagnostics() throws { do { try validateReadiness() @@ -240,6 +268,7 @@ final class LivePushReadinessViewModel { errorMessage = nil } + /// prepare 业务逻辑。 func prepare() async throws { try validateReadiness() guard let pushURL else { throw LivePushReadinessError.missingPushURL } @@ -253,6 +282,7 @@ final class LivePushReadinessViewModel { } } + /// start推送相关逻辑。 func startPush() async throws { try validateReadiness() guard adapter.isAvailable else { @@ -265,11 +295,13 @@ final class LivePushReadinessViewModel { errorMessage = nil } + /// stop推送相关逻辑。 func stopPush() async { try? await adapter.stop() running = false } + /// dispose 业务逻辑。 func dispose() async { stopMonitoring() await adapter.dispose() @@ -277,6 +309,7 @@ final class LivePushReadinessViewModel { prepared = false } + /// 校验Readiness输入或状态。 private func validateReadiness() throws { guard pushURL != nil else { throw LivePushReadinessError.missingPushURL diff --git a/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift b/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift index a5ec54b..960bc5c 100644 --- a/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift +++ b/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift @@ -104,6 +104,7 @@ final class LiveManagementViewModel { await reload(api: api, scenicId: scenicId, showLoading: false) } + /// 加载Page数据。 private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize) if page == 1 { @@ -118,6 +119,7 @@ final class LiveManagementViewModel { hasMore = items.count < total } + /// 重置状态。 private func reset() { clearListAndDetail() loading = false @@ -125,6 +127,7 @@ final class LiveManagementViewModel { errorMessage = nil } + /// 清空列表并详情。 private func clearListAndDetail() { items = [] detail = nil @@ -143,6 +146,7 @@ final class LiveDetailViewModel { var actionInFlight = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } + /// 初始化实例。 init(detail: LiveEntity) { self.detail = detail } @@ -265,6 +269,7 @@ final class LiveAlbumViewModel { await reload(api: api, scenicId: scenicId, showLoading: false) } + /// 加载Page数据。 private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { let response = try await api.liveAlbumList( scenicId: scenicId, @@ -284,6 +289,7 @@ final class LiveAlbumViewModel { hasMore = folders.count < total } + /// 重置状态。 private func reset() { clearFolders() loading = false @@ -291,6 +297,7 @@ final class LiveAlbumViewModel { errorMessage = nil } + /// 清空Folders。 private func clearFolders() { folders = [] page = 1 @@ -377,6 +384,7 @@ final class LiveAlbumPreviewViewModel { var loading = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } + /// 初始化实例。 init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) { self.folderId = folderId currentIndex = max(startIndex, 0) diff --git a/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift b/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift index f22868a..e0b854c 100644 --- a/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift +++ b/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift @@ -39,6 +39,7 @@ struct LocationReportSubmitResponse: Decodable, Equatable { let expired: Int let status: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case staffId = "staff_id" case expired @@ -73,6 +74,7 @@ struct LocationReportHistoryItem: Decodable, Equatable, Identifiable { let remark: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case staffId = "staff_id" diff --git a/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift b/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift index 999ddd0..7e72ef6 100644 --- a/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift +++ b/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class LocationReportViewController: ModuleTableViewController { private let viewModel = LocationReportViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "位置上报" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,17 +24,21 @@ final class LocationReportViewController: ModuleTableViewController { wireViewModel(viewModel) { } } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? 3 : 1 } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "状态" : "操作" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -55,8 +60,8 @@ final class LocationReportViewController: ModuleTableViewController { return cell } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + /// 处理行选中。 + override func didSelectTableRow(at indexPath: IndexPath) { if indexPath.section == 1 { Task { _ = await viewModel.setOnline( @@ -69,10 +74,12 @@ final class LocationReportViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入") } + /// 提交Report。 @objc private func submitReport() { Task { _ = await viewModel.submit( @@ -91,25 +98,30 @@ extension LocationReportViewModel: ViewModelBindable {} final class LocationReportHistoryViewController: ModuleTableViewController { private let viewModel = LocationReportHistoryViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "上报历史" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let record = viewModel.items[indexPath.row] cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI) } + /// table视图相关逻辑。 override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard indexPath.row >= viewModel.items.count - 2 else { return } Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) } diff --git a/suixinkan_ios/Features/Main/Main.md b/suixinkan_ios/Features/Main/Main.md index caccf7b..af5e90a 100644 --- a/suixinkan_ios/Features/Main/Main.md +++ b/suixinkan_ios/Features/Main/Main.md @@ -2,14 +2,9 @@ ## 模块职责 -Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。 +Main 模块负责登录后的主界面 Tab 容器。 -主界面由 `MainTabsView` 承载: -- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。 -- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。 -- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。 -- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。 -- 每个 Tab 的导航路径由 `AppRouter` 单独保存。 +主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。 ## Tab 结构 @@ -19,41 +14,33 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页 - `statistics`:数据 - `profile`:我的 -`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。 +各 Tab 根页面已接入真实业务页面:`HomeViewController`、`OrdersViewController`、`StatisticsViewController`、`ProfileViewController`。 ## 导航流程 -1. `MainTabsView` 从 Environment 读取 `AppRouter`。 -2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。 -3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。 -4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。 -5. 路径绑定来自 `appRouter.binding(for:)`。 -6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。 -7. `navigationDestination` 根据 `AppRoute` 展示详情页。 -8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。 +1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源。 +2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步。 +3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。 +4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar,默认隐藏。 +5. 跨 Tab 跳转通过 `AppRouter.select` 或 `AppRouter.selectOrders(entry:)` 处理。 -## 自定义 TabBar +## 订单角标 -自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成: -- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。 -- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。 -- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。 - `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。 -- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。 -- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。 -- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。 -- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。 +- 角标展示在订单 Tab 的 `tabBarItem.badgeValue` 上。 +- 账号景区或门店变化、切换到订单 Tab 时会刷新角标。 -系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。 +## 扫码核销 + +系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。 ## 后续迁移规则 迁移新页面时: -- 优先替换对应 Tab 的 RootView。 -- 保持每个 Tab 自己的 `NavigationStack`。 +- 优先替换对应 Tab 的根 ViewController。 +- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。 - 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。 - 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。 - Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。 - 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。 -- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。 - 真实业务页面接入后,应同步补充该模块文档和单元测试。 diff --git a/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift index d8a732c..0c14da3 100644 --- a/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift +++ b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift @@ -3,24 +3,22 @@ // suixinkan // -import SnapKit import UIKit @MainActor -/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。 -final class MainTabBarController: UIViewController { +/// 主 Tab 容器,使用系统 `UITabBarController` 展示四个一级入口。 +final class MainTabBarController: UITabBarController, UITabBarControllerDelegate { private let services: AppServices private let badgeViewModel = MainTabBadgeViewModel() - private let contentContainer = UIView() - private let customTabBar = CustomMainTabBarView() - private var tabNavigationControllers: [AppTab: TabNavigationController] = [:] - private var loadedTabs: Set = [.home] + private var isSyncingTabSelection = false + /// 初始化实例。 init(services: AppServices) { self.services = services super.init(nibName: nil, bundle: nil) + delegate = self } @available(*, unavailable) @@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .white - configureLayout() + configureTabBarAppearance() + configureTabs() bindRouter() bindBadgeViewModel() bindAccountContext() - switchToTab(services.appRouter.selectedTab, animated: false) + syncSelectedTabFromRouter(animated: false) Task { await refreshOrderBadge() } #if DEBUG Task { await AppUITestRouteDriver.applyIfNeeded(services: services) } #endif } - /// 控制自定义 TabBar 显隐,push 子页面时隐藏。 - func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) { - let updates = { - self.customTabBar.alpha = hidden ? 0 : 1 - self.customTabBar.isUserInteractionEnabled = !hidden - } - - guard animated else { - updates() - return - } - - UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates) + /// 配置系统 TabBar 外观。 + private func configureTabBarAppearance() { + tabBar.tintColor = AppDesign.primary + tabBar.backgroundColor = .white } - private func configureLayout() { - view.addSubview(contentContainer) - view.addSubview(customTabBar) - - customTabBar.onTabSelected = { [weak self] tab in - self?.services.appRouter.select(tab) - } - customTabBar.onScanTapped = { [weak self] in - self?.presentScanner() - } - - contentContainer.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - make.bottom.equalTo(customTabBar.snp.top) - } - - customTabBar.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview() - make.bottom.equalTo(view.safeAreaLayoutGuide) - make.height.equalTo(72) + /// 为每个 Tab 创建独立导航栈并绑定 TabBarItem。 + private func configureTabs() { + viewControllers = AppTab.allCases.map { tab in + let navigationController = TabNavigationController(tab: tab, services: services) + navigationController.tabBarItem = tab.makeTabBarItem() + tabNavigationControllers[tab] = navigationController + return navigationController } } + /// 绑定 Router 回调,同步程序化 Tab 切换。 private func bindRouter() { services.appRouter.onChange = { [weak self] in self?.handleRouterChange() } } + /// 绑定角标 ViewModel 回调。 private func bindBadgeViewModel() { badgeViewModel.onChange = { [weak self] in self?.updateOrderBadge() } } + /// 绑定账号上下文,景区或门店变化时刷新角标。 private func bindAccountContext() { services.accountContext.onChange = { [weak self] in Task { await self?.refreshOrderBadge() } } } + /// 处理 Router 变更,保持选中 Tab 与 `AppRouter` 一致。 private func handleRouterChange() { - switchToTab(services.appRouter.selectedTab, animated: false) + syncSelectedTabFromRouter(animated: false) if services.appRouter.selectedTab == .orders { Task { await refreshOrderBadge() } } } - private func switchToTab(_ tab: AppTab, animated: Bool) { - loadedTabs.insert(tab) - customTabBar.selectedTab = tab + /// 将 `AppRouter.selectedTab` 同步到系统 TabBar 选中项。 + private func syncSelectedTabFromRouter(animated: Bool) { + guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return } + guard selectedIndex != index else { return } - let navigationController = navigationController(for: tab) - for child in children where child !== navigationController { - child.willMove(toParent: nil) - child.view.removeFromSuperview() - child.removeFromParent() - } - - addChild(navigationController) - contentContainer.addSubview(navigationController.view) - navigationController.view.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - navigationController.didMove(toParent: self) - - let isRoot = navigationController.viewControllers.count <= 1 - setCustomTabBarHidden(!isRoot, animated: animated) - } - - private func navigationController(for tab: AppTab) -> TabNavigationController { - if let existing = tabNavigationControllers[tab] { - return existing - } - - let navigationController = TabNavigationController(tab: tab, services: services) - navigationController.tabBarHost = self - tabNavigationControllers[tab] = navigationController - return navigationController + isSyncingTabSelection = true + selectedIndex = index + isSyncingTabSelection = false } + /// 更新订单 Tab 待核销角标。 private func updateOrderBadge() { - guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else { - customTabBar.orderBadgeText = nil - return + let badgeValue: String? + if let count = badgeViewModel.pendingWriteOffCount, count > 0 { + badgeValue = "\(count)" + } else { + badgeValue = nil } - customTabBar.orderBadgeText = "\(count)" + tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue } + /// 刷新订单 Tab 待核销角标数据。 private func refreshOrderBadge() async { await badgeViewModel.refreshPendingWriteOffCount( api: services.ordersAPI, @@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController { ) } - private func presentScanner() { - let scanner = OrderCodeScannerViewController() - scanner.onScanResult = { [weak self] result in - guard let self else { return } - switch result { - case .success(let rawCode): - dismiss(animated: true) { - self.services.appRouter.routeToOrderVerification(scannedCode: rawCode) - } - case .failure(let error): - showToast(error.localizedDescription) - } + /// 用户点击 TabBar 时同步选中状态到 `AppRouter`。 + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + guard !isSyncingTabSelection, + let navigationController = viewController as? TabNavigationController else { return } + + services.appRouter.select(navigationController.appTab) + if navigationController.appTab == .orders { + Task { await refreshOrderBadge() } } - let navigation = UINavigationController(rootViewController: scanner) - navigation.modalPresentationStyle = .fullScreen - present(navigation, animated: true) - } -} - -/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。 -private final class CustomMainTabBarView: UIView { - - var selectedTab: AppTab = .home { - didSet { refreshSelection() } - } - - var orderBadgeText: String? { - didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil } - } - - var onTabSelected: ((AppTab) -> Void)? - var onScanTapped: (() -> Void)? - - private struct TabItem { - let tab: AppTab - let title: String - let selectedImage: String - let unselectedImage: String - } - - private let items: [TabItem] = [ - .init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"), - .init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"), - .init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"), - .init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected") - ] - - private var tabButtons: [AppTab: UIButton] = [:] - private var tabTitleLabels: [AppTab: UILabel] = [:] - private let ordersBadgeLabel = UILabel() - private let topDivider = UIView() - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .white - configureViews() - refreshSelection() - } - - required init?(coder: NSCoder) { - nil - } - - private func configureViews() { - topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04) - addSubview(topDivider) - topDivider.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - make.height.equalTo(0.5) - } - - let leftStack = UIStackView() - leftStack.axis = .horizontal - leftStack.distribution = .fillEqually - leftStack.spacing = 0 - - let rightStack = UIStackView() - rightStack.axis = .horizontal - rightStack.distribution = .fillEqually - rightStack.spacing = 0 - - for (index, item) in items.enumerated() { - let buttonContainer = makeTabButton(for: item) - if index < 2 { - leftStack.addArrangedSubview(buttonContainer) - } else { - rightStack.addArrangedSubview(buttonContainer) - } - } - - let scanButton = UIButton(type: .system) - scanButton.backgroundColor = AppDesign.primary - scanButton.layer.cornerRadius = 29 - scanButton.tintColor = .white - scanButton.setImage( - UIImage(systemName: "qrcode.viewfinder")?.withConfiguration( - UIImage.SymbolConfiguration(pointSize: 30, weight: .bold) - ), - for: .normal - ) - scanButton.accessibilityLabel = "扫码核销" - scanButton.accessibilityIdentifier = "main.scan" - scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside) - - let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack]) - row.axis = .horizontal - row.alignment = .center - row.spacing = 0 - addSubview(row) - - scanButton.snp.makeConstraints { make in - make.width.equalTo(74) - make.height.equalTo(64) - } - - row.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(22) - make.top.bottom.equalToSuperview() - } - - leftStack.snp.makeConstraints { make in - make.width.equalTo(rightStack) - } - } - - private func makeTabButton(for item: TabItem) -> UIView { - let container = UIView() - - let button = UIButton(type: .custom) - button.accessibilityLabel = item.title - button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)" - button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0 - button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) - tabButtons[item.tab] = button - - let titleLabel = UILabel() - titleLabel.text = item.title - titleLabel.font = .systemFont(ofSize: 12) - titleLabel.textAlignment = .center - tabTitleLabels[item.tab] = titleLabel - - container.addSubview(button) - container.addSubview(titleLabel) - - button.snp.makeConstraints { make in - make.top.equalToSuperview().offset(8) - make.centerX.equalToSuperview() - make.width.height.equalTo(24) - } - - titleLabel.snp.makeConstraints { make in - make.top.equalTo(button.snp.bottom).offset(3) - make.centerX.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview() - } - - if item.tab == .orders { - ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold) - ordersBadgeLabel.textColor = .white - ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444) - ordersBadgeLabel.textAlignment = .center - ordersBadgeLabel.layer.cornerRadius = 7.5 - ordersBadgeLabel.clipsToBounds = true - ordersBadgeLabel.isHidden = true - container.addSubview(ordersBadgeLabel) - ordersBadgeLabel.snp.makeConstraints { make in - make.top.equalTo(button).offset(-4) - make.leading.equalTo(button.snp.trailing).offset(-6) - make.height.equalTo(15) - make.width.greaterThanOrEqualTo(15) - } - } - - return container - } - - private func refreshSelection() { - for item in items { - let isSelected = item.tab == selectedTab - let imageName = isSelected ? item.selectedImage : item.unselectedImage - tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal) - tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3) - tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular) - } - } - - @objc private func tabTapped(_ sender: UIButton) { - guard sender.tag >= 0, sender.tag < items.count else { return } - onTabSelected?(items[sender.tag].tab) - } - - @objc private func scanTapped() { - onScanTapped?() } } diff --git a/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift b/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift index 2ec5edb..0de7990 100644 --- a/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift +++ b/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift @@ -11,6 +11,7 @@ final class PlaceholderViewController: UIViewController { private let pageTitle: String + /// 初始化实例。 init(title: String) { pageTitle = title super.init(nibName: nil, bundle: nil) @@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0xF5F7FA) diff --git a/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift b/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift index 04122e8..70b4fd6 100644 --- a/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift +++ b/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift @@ -14,6 +14,7 @@ struct MessageListResponse: Decodable, Equatable { let lastId: Int let items: [MessageEntity] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case hasMore = "has_more" case lastId = "last_id" @@ -47,6 +48,7 @@ struct MessageEntity: Decodable, Equatable, Identifiable { let createdAt: String let isRead: Bool + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case type diff --git a/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift b/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift index dc583e2..c57bad1 100644 --- a/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift +++ b/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift @@ -11,6 +11,7 @@ import UIKit final class MessageCenterViewController: ModuleTableViewController { private let viewModel = MessageCenterViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "消息中心" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,10 +24,12 @@ final class MessageCenterViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.messages.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.messages[indexPath.row] cell.configure( @@ -37,6 +40,7 @@ final class MessageCenterViewController: ModuleTableViewController { cell.accessoryType = item.isRead ? .none : .disclosureIndicator } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.messages[indexPath.row] Task { @@ -45,20 +49,24 @@ final class MessageCenterViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { isLoading = viewModel.messages.isEmpty await viewModel.reloadFirstPage(api: services.messageCenterAPI) isLoading = false } + /// mark全部Read相关逻辑。 @objc private func markAllRead() { Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) } } + /// 更新BarButtons状态。 private func updateBarButtons() { navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty } + /// 展示Alert。 private func showAlert(title: String, message: String) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "知道了", style: .default)) diff --git a/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift b/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift index d92287a..b481106 100644 --- a/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift +++ b/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift @@ -146,6 +146,7 @@ final class MessageCenterViewModel { } } + /// deduplicated并排序相关逻辑。 private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] { var unique: [String: MessageItem] = [:] for item in source { @@ -154,6 +155,7 @@ final class MessageCenterViewModel { return unique.values.sorted { $0.time > $1.time } } + /// 清空Messages。 private func clearMessages() { messages = [] hasMoreMessages = false diff --git a/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift b/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift index 9e62124..1703d03 100644 --- a/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift +++ b/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift @@ -10,7 +10,9 @@ import Foundation /// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。 @MainActor protocol OperatingAreaServing { + /// store营业Area相关逻辑。 func storeBusinessArea(storeId: Int) async throws -> ListPayload + /// scenicAdmin营业Area相关逻辑。 func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload } diff --git a/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift b/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift index 6ed6754..f226269 100644 --- a/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift +++ b/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift @@ -31,6 +31,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable { let typeText: String let auditStatusText: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -40,6 +41,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable { case auditStatusText = "audit_status_text" } + /// 初始化实例。 init( id: Int = 0, name: String = "", @@ -56,6 +58,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable { self.auditStatusText = auditStatusText } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0 @@ -76,6 +79,7 @@ enum OperatingMapArea: Decodable, Equatable { case array([OperatingMapArea]) case object([String: OperatingMapArea]) + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { @@ -106,6 +110,7 @@ enum OperatingMapArea: Decodable, Equatable { return fromJSONObject(raw) } + /// fromJSONObject相关逻辑。 private static func fromJSONObject(_ value: Any) -> OperatingMapArea { switch value { case is NSNull: @@ -140,6 +145,7 @@ struct OperatingFenceRing: Identifiable, Equatable { let points: [OperatingGeoPoint] let isCurrentStore: Bool + /// 初始化实例。 init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) { self.id = "\(itemId)-\(ringIndex)" self.itemId = itemId @@ -184,6 +190,7 @@ enum OperatingAreaParser { return parseElement(area) } + /// 解析Element数据。 private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] { switch value { case .null, .bool, .number: @@ -199,6 +206,7 @@ enum OperatingAreaParser { } } + /// 解析Object数据。 private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] { let type: String? if case let .string(rawType)? = object["type"] { @@ -231,6 +239,7 @@ enum OperatingAreaParser { } } + /// 解析ArrayRoot数据。 private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] { guard let first = array.first else { return [] } switch first { @@ -257,6 +266,7 @@ enum OperatingAreaParser { } } + /// 解析ObjectRing数据。 private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { let points = array.compactMap { element -> OperatingGeoPoint? in guard case let .object(object) = element else { return nil } @@ -265,6 +275,7 @@ enum OperatingAreaParser { return points.count >= 3 ? points : nil } + /// 解析Ring数据。 private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { let points = array.compactMap { element -> OperatingGeoPoint? in switch element { @@ -283,6 +294,7 @@ enum OperatingAreaParser { return points.count >= 3 ? points : nil } + /// point从对象相关逻辑。 private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? { let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue @@ -290,6 +302,7 @@ enum OperatingAreaParser { return OperatingGeoPoint(latitude: lat, longitude: lng) } + /// 规范化Pair格式。 private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint { if looksLikeLngLatPair(lng: a, lat: b) { return OperatingGeoPoint(latitude: b, longitude: a) @@ -303,24 +316,29 @@ enum OperatingAreaParser { return OperatingGeoPoint(latitude: a, longitude: b) } + /// looksLikeLngLat坐标对相关逻辑。 private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool { (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat) } + /// looksLikeLatLng坐标对相关逻辑。 private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool { (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng) } } +/// 结构体,封装数据实体。 private struct OperatingDynamicCodingKey: CodingKey { let stringValue: String let intValue: Int? + /// 初始化实例。 init?(stringValue: String) { self.stringValue = stringValue intValue = nil } + /// 初始化实例。 init?(intValue: Int) { stringValue = String(intValue) self.intValue = intValue @@ -341,6 +359,7 @@ private extension OperatingMapArea { } private extension KeyedDecodingContainer { + /// operating解码宽松字符串相关逻辑。 func operatingDecodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -357,6 +376,7 @@ private extension KeyedDecodingContainer { return "" } + /// operating解码宽松整数相关逻辑。 func operatingDecodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value diff --git a/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift index bc58b77..32839e5 100644 --- a/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift +++ b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift @@ -1,37 +1,146 @@ // // OperatingAreaViewController.swift -// suixinkan -// -// Created by Codex on 2026/6/26. +// suixinkan_ios // +import SnapKit import UIKit -/// 运营区域列表页。 -final class OperatingAreaViewController: ModuleTableViewController { +/// 运营区域页:汇总信息、围栏地图与区域列表。 +final class OperatingAreaViewController: UIViewController { private let viewModel = OperatingAreaViewModel() + private let mapView = OperatingAreaMapView() + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let summaryLabel = UILabel() + private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash") + private var dataSource: UITableViewDiffableDataSource! + private var services: AppServices { AppServices.shared } + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { - title = "运营区域" super.viewDidLoad() - wireViewModel(viewModel) { } + title = "运营区域" + view.backgroundColor = UIColor(hex: 0xF5F7FA) + setupLayout() + wireViewModel() + Task { await reload(showLoading: true) } } - override func tableRowCount() -> Int { - viewModel.items.count + private func setupLayout() { + summaryLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + summaryLabel.textColor = AppDesign.textSecondary + summaryLabel.numberOfLines = 0 + + mapView.snp.makeConstraints { make in + make.height.equalTo(320) + } + + blockReasonView.isHidden = true + + tableView.register(TitleSubtitleTableViewCell.self, forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier) + tableView.refreshControl = UIRefreshControl() + tableView.refreshControl?.addTarget(self, action: #selector(onPullRefresh), for: .valueChanged) + + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemID in + guard let self else { return UITableViewCell() } + let cell = tableView.dequeueReusableCell( + withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, + for: indexPath + ) as! TitleSubtitleTableViewCell + guard let item = self.viewModel.items.first(where: { String($0.id) == itemID }) else { + return cell + } + let ringCount = self.viewModel.fenceRings.filter { $0.itemId == item.id }.count + cell.configure( + title: item.name.isEmpty ? "未命名区域" : item.name, + subtitle: [item.typeText, item.statusText].filter { !$0.isEmpty }.joined(separator: " · "), + detail: "围栏 \(ringCount) 个" + ) + return cell + } + + let headerStack = UIStackView(arrangedSubviews: [summaryLabel, blockReasonView, mapView]) + headerStack.axis = .vertical + headerStack.spacing = AppMetrics.Spacing.medium + + let headerContainer = UIView() + headerContainer.addSubview(headerStack) + headerStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + } + headerContainer.layoutIfNeeded() + let height = headerStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height + headerContainer.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: height + 24) + tableView.tableHeaderView = headerContainer + + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } } - override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { - let item = viewModel.items[indexPath.row] - cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count) 组") + private func wireViewModel() { + viewModel.onChange = { [weak self] in + self?.render() + } } - override func reloadContent() async { + private func render() { + summaryLabel.text = "\(viewModel.title) · \(scopeText()) · 区域 \(viewModel.summary.areaCount) · 围栏 \(viewModel.summary.fenceCount)" + + if let reason = viewModel.blockReason { + blockReasonView.isHidden = false + blockReasonView.update(title: reason.message, systemImage: "mappin.slash") + mapView.isHidden = true + } else { + blockReasonView.isHidden = true + mapView.isHidden = false + mapView.rings = viewModel.fenceRings + } + applyTableSnapshot() + } + + /// 通过 Diffable snapshot 刷新区域列表。 + private func applyTableSnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.items.map { String($0.id) }, toSection: 0) + dataSource.apply(snapshot, animatingDifferences: animated) + } + + private func scopeText() -> String { + switch viewModel.mode { + case .storeAdmin: + return services.accountContext.currentStore?.name ?? "当前店铺" + case .scenicAdmin: + return services.accountContext.currentScenic?.name ?? "当前景区" + case nil: + return services.accountContext.currentScenic?.name + ?? services.accountContext.currentStore?.name + ?? "未选择业务范围" + } + } + + @objc private func onPullRefresh() { + Task { + await reload(showLoading: false) + tableView.refreshControl?.endRefreshing() + } + } + + private func reload(showLoading: Bool) async { + if showLoading { services.globalLoading.show() } + defer { if showLoading { services.globalLoading.hide() } } + await viewModel.reload( api: services.operatingAreaAPI, accountContext: services.accountContext, permissionContext: services.permissionContext ) + if let message = viewModel.errorMessage { + services.toastCenter.show(message) + } } } diff --git a/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift b/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift index 2c6b8f8..a58325b 100644 --- a/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift +++ b/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift @@ -110,6 +110,7 @@ final class OperatingAreaViewModel { mode?.title ?? "运营区域" } + /// apply 业务逻辑。 private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) { self.items = items if items.isEmpty { @@ -139,6 +140,7 @@ final class OperatingAreaViewModel { blockReason = parsed.isEmpty ? .noParsableFenceData : nil } + /// setBlocked相关逻辑。 private func setBlocked(_ reason: OperatingAreaBlockReason) { resetLoadedData() blockReason = reason @@ -146,6 +148,7 @@ final class OperatingAreaViewModel { loading = false } + /// 重置LoadedData状态。 private func resetLoadedData() { items = [] fenceRings = [] diff --git a/suixinkan_ios/Features/Orders/Models/OrderModels.swift b/suixinkan_ios/Features/Orders/Models/OrderModels.swift index 1d33233..4dfa2da 100644 --- a/suixinkan_ios/Features/Orders/Models/OrderModels.swift +++ b/suixinkan_ios/Features/Orders/Models/OrderModels.swift @@ -62,6 +62,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable { let isNeedEdit: Bool let isRefined: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case photogUid = "photog_uid" case orderNumber = "order_number" @@ -131,6 +132,7 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable { let retouchGiftPhotoNum: Int let retouchGiftVideoNum: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderPhotoNum = "order_photo_num" case orderVideoNum = "order_video_num" @@ -163,6 +165,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable { let orderStatusName: String let orderVerificationTime: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case orderVerificationStatus = "order_verification_status" @@ -192,6 +195,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable { struct WriteOffRequest: Encodable { let orderNumber: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" } @@ -207,6 +211,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable { let statusName: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case orderNumber = "order_number" @@ -234,6 +239,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable { struct DepositOrderWriteOffRequest: Encodable, Equatable { let orderNumber: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" } @@ -244,6 +250,7 @@ struct DepositOrderRefundRequest: Encodable, Equatable { let orderNumber: String let refundReason: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case refundReason = "refund_reason" @@ -275,6 +282,7 @@ struct OrderRefundRequest: Encodable, Equatable { let refundAmount: String let refundReason: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case refundType = "refund_type" @@ -314,6 +322,7 @@ struct StoreOrderDetailResponse: Decodable, Equatable, Hashable { let remark: String let multiTravel: StoreOrderMultiTravelInfo? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case orderType = "order_type" @@ -362,6 +371,7 @@ struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable { let projectInfo: StoreOrderProjectInfo? let shootingList: [StoreOrderShootingListItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case projectInfo = "project_info" case shootingList = "shooting_list" @@ -382,6 +392,7 @@ struct StoreOrderProjectInfo: Decodable, Equatable, Hashable { let singleSpotPhotoNum: Int let singleSpotVideoNum: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case settleSpotNum = "settle_spot_num" case singleSpotMaterialNum = "single_spot_material_num" @@ -412,6 +423,7 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable let startAvg: Double let start: Double + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicSpotId = "scenic_spot_id" case photogUid = "photog_uid" @@ -447,6 +459,7 @@ struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable { let materialList: [OrderMediaFile] let completeList: [OrderMediaFile] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicSpotId = "scenic_spot_id" case scenicSpotName = "scenic_spot_name" @@ -480,6 +493,7 @@ struct StoreOrderComment: Decodable, Equatable, Hashable { let content: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case starShooting = "star_shooting" case starRetouching = "star_retouching" @@ -522,6 +536,7 @@ struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable { coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileName = "file_name" case fileUrl = "file_url" @@ -550,6 +565,7 @@ struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable { let projectTypeName: String let photogSpotList: [PhotogSpotItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case projectName = "project_name" case projectType = "project_type" @@ -583,6 +599,7 @@ struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable { return nickname.isEmpty ? photogName : nickname } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicSpotId = "scenic_spot_id" case photogUid = "photog_uid" @@ -609,6 +626,7 @@ struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Ha let id: Int let name: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -629,6 +647,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable { let cloudFile: [MultiTravelCloudFileItem] let uploadFile: [MultiTravelUploadFileItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderNumber = "order_number" case scenicSpotId = "scenic_spot_id" @@ -641,6 +660,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable { struct MultiTravelCloudFileItem: Encodable, Equatable { let fileId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileId = "file_id" } @@ -651,6 +671,7 @@ struct MultiTravelUploadFileItem: Encodable, Equatable { let fileName: String let fileUrl: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileName = "file_name" case fileUrl = "file_url" diff --git a/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift index 6e30e7d..92ebbe6 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift @@ -6,32 +6,127 @@ import SnapKit import UIKit +// MARK: - Deposit List Diffable 标识 + +private typealias DepositListSection = Int +private typealias DepositListItem = String + +/// 押金列表空态 / 加载态标识。 +private enum DepositListItemID { + static let placeholder = "depositList:placeholder" +} + +// MARK: - Deposit Detail Diffable 标识 + +private typealias DepositDetailSection = Int +private typealias DepositDetailRow = String + +/// 押金详情行标识。 +private enum DepositDetailRowID { + static let placeholder = "depositDetail:placeholder" + static func field(_ index: Int) -> String { "depositDetail:field:\(index)" } +} + +// MARK: - Deposit Shooting Diffable 标识 + +private typealias DepositShootingSection = Int +private typealias DepositShootingRow = String + +/// 押金拍摄信息 section 索引。 +private enum DepositShootingSectionID { + static let summary = 0 + static let material = 1 + static let complete = 2 +} + +/// 押金拍摄信息行标识。 +private enum DepositShootingRowID { + static let placeholder = "depositShooting:placeholder" + static let scenicSpot = "depositShooting:scenicSpot" + static let rating = "depositShooting:rating" + static func material(_ index: Int) -> String { "depositShooting:material:\(index)" } + static func complete(_ index: Int) -> String { "depositShooting:complete:\(index)" } +} + /// 押金订单列表页,支持查询、分页、核销和退款。 -final class DepositOrderListViewController: UIViewController { +final class DepositOrderListViewController: UIViewController, UITableViewDelegate { private let viewModel = DepositOrderListViewModel() private var orderNumberField = UITextField() private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID) return table }() + /// Diffable 数据源,驱动押金订单列表与分页刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "押金订单" view.backgroundColor = AppDesignUIKit.pageBackground + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) } setupHeader() Task { await reload(showLoading: true) } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: DepositListItem) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + if item == DepositListItemID.placeholder { + let cell = UITableViewCell() + cell.textLabel?.text = self.viewModel.loading ? "加载中..." : "暂无押金订单" + cell.selectionStyle = .none + return cell + } + guard let orderItem = self.viewModel.orders.first(where: { $0.orderNumber == item }) else { + return UITableViewCell() + } + let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell + cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber) + cell.onDetail = { [weak self] in + self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: orderItem.orderNumber), from: $0) } + } + cell.onWriteOff = { [weak self] in self?.writeOff(orderItem) } + cell.onRefund = { [weak self] in self?.refund(orderItem) } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + if viewModel.orders.isEmpty { + snapshot.appendItems([DepositListItemID.placeholder], toSection: 0) + } else { + snapshot.appendItems(viewModel.orders.map(\.orderNumber), toSection: 0) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 初始化Header相关 UI 或状态。 private func setupHeader() { let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120)) orderNumberField.placeholder = "请输入押金订单号" @@ -55,12 +150,14 @@ final class DepositOrderListViewController: UIViewController { tableView.tableHeaderView = header } + /// open详情相关逻辑。 @objc private func openDetail() { let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !text.isEmpty else { return } HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self) } + /// 刷新。 private func reload(showLoading: Bool) async { if showLoading { await appServices.globalLoading.withLoading { @@ -72,6 +169,7 @@ final class DepositOrderListViewController: UIViewController { if let message = viewModel.errorMessage { showToast(message) } } + /// writeOff相关逻辑。 private func writeOff(_ item: DepositOrderListItem) { let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) @@ -86,6 +184,7 @@ final class DepositOrderListViewController: UIViewController { present(alert, animated: true) } + /// refund 业务逻辑。 private func refund(_ item: DepositOrderListItem) { let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert) alert.addTextField { $0.placeholder = "退款原因" } @@ -101,43 +200,25 @@ final class DepositOrderListViewController: UIViewController { }) present(alert, animated: true) } -} - -extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - max(viewModel.orders.count, 1) - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { "押金订单列表 \(viewModel.orders.count)/\(viewModel.total)" } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard !viewModel.orders.isEmpty else { - let cell = UITableViewCell() - cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单" - cell.selectionStyle = .none - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell - let item = viewModel.orders[indexPath.row] - cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber) - cell.onDetail = { [weak self] in - self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) } - } - cell.onWriteOff = { [weak self] in self?.writeOff(item) } - cell.onRefund = { [weak self] in self?.refund(item) } - return cell - } - + /// UITableView 代理:分页加载。 func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return } + guard let item = tableDataSource.itemIdentifier(for: indexPath), + item != DepositListItemID.placeholder, + item == viewModel.orders.last?.orderNumber, + viewModel.hasMore else { return } Task { await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false) } } } +/// DepositOrder列表或网格 Cell,负责单项内容展示。 private final class DepositOrderCell: UITableViewCell { static let reuseID = "DepositOrderCell" var onDetail: (() -> Void)? @@ -148,6 +229,7 @@ private final class DepositOrderCell: UITableViewCell { private let statusLabel = UILabel() private let amountLabel = UILabel() + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none @@ -182,14 +264,18 @@ private final class DepositOrderCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(item: DepositOrderListItem, isOperating: Bool) { titleLabel.text = item.orderNumber statusLabel.text = isOperating ? "处理中" : item.statusName amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)" } + /// 点击detail的处理逻辑。 @objc private func detailTapped() { onDetail?() } + /// 点击writeOff的处理逻辑。 @objc private func writeOffTapped() { onWriteOff?() } + /// 点击refund的处理逻辑。 @objc private func refundTapped() { onRefund?() } } @@ -201,11 +287,14 @@ final class DepositOrderDetailViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.backgroundColor = AppDesignUIKit.pageBackground return table }() + /// Diffable 数据源,驱动详情字段展示。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(orderNumber: String) { self.orderNumber = orderNumber super.init(nibName: nil, bundle: nil) @@ -214,14 +303,66 @@ final class DepositOrderDetailViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "押金订单详情" + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } Task { await loadDetail() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, _: IndexPath, row: DepositDetailRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + guard let detail = self.viewModel.detail else { + cell.textLabel?.text = self.viewModel.errorMessage ?? (self.viewModel.loading ? "加载中..." : "暂无详情") + return cell + } + let rows: [(String, String)] = [ + ("订单号", detail.orderNumber), + ("状态", detail.orderStatusName), + ("类型", detail.orderTypeLabel), + ("付款金额", detail.actualPayAmount), + ("手机号", detail.phone), + ("项目", detail.projectName), + ("创建时间", detail.createdAt), + ("备注", detail.remark) + ] + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + if index < rows.count { + cell.textLabel?.text = rows[index].0 + cell.detailTextLabel?.text = rows[index].1 + } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + if viewModel.detail == nil { + snapshot.appendItems([DepositDetailRowID.placeholder], toSection: 0) + } else { + snapshot.appendItems((0..<8).map { DepositDetailRowID.field($0) }, toSection: 0) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true) { + tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated) + } + + /// 加载Detail数据。 private func loadDetail() async { await appServices.globalLoading.withLoading { await viewModel.load( @@ -230,41 +371,13 @@ final class DepositOrderDetailViewController: UIViewController { orderNumber: orderNumber ) } - tableView.reloadData() + applyTableSnapshot() if let message = viewModel.errorMessage { showToast(message) } } } -extension DepositOrderDetailViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - viewModel.detail == nil ? 1 : 8 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - cell.selectionStyle = .none - guard let detail = viewModel.detail else { - cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情") - return cell - } - let rows: [(String, String)] = [ - ("订单号", detail.orderNumber), - ("状态", detail.orderStatusName), - ("类型", detail.orderTypeLabel), - ("付款金额", detail.actualPayAmount), - ("手机号", detail.phone), - ("项目", detail.projectName), - ("创建时间", detail.createdAt), - ("备注", detail.remark) - ] - cell.textLabel?.text = rows[indexPath.row].0 - cell.detailTextLabel?.text = rows[indexPath.row].1 - return cell - } -} - /// 押金拍摄信息页。 -final class DepositOrderShootingInfoViewController: UIViewController { +final class DepositOrderShootingInfoViewController: UIViewController, UITableViewDelegate { private let orderNumber: String private let scenicSpotId: Int @@ -273,10 +386,14 @@ final class DepositOrderShootingInfoViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self + table.delegate = self return table }() + /// Diffable 数据源,驱动拍摄摘要与素材列表刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(orderNumber: String, scenicSpotId: Int, photogUid: Int) { self.orderNumber = orderNumber self.scenicSpotId = scenicSpotId @@ -287,14 +404,75 @@ final class DepositOrderShootingInfoViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "押金拍摄信息" + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } Task { await loadDetail() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: DepositShootingRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + guard let detail = self.viewModel.detail else { + let cell = UITableViewCell() + cell.textLabel?.text = self.viewModel.errorMessage ?? "加载中..." + return cell + } + if row == DepositShootingRowID.scenicSpot || row == DepositShootingRowID.rating { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + if row == DepositShootingRowID.scenicSpot { + cell.textLabel?.text = "打卡点" + cell.detailTextLabel?.text = detail.scenicSpotName + } else { + cell.textLabel?.text = "拍摄评分" + cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--" + } + return cell + } + let files: [OrderMediaFile] + if row.hasPrefix("depositShooting:material:") { + files = detail.materialList + } else { + files = detail.completeList + } + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let media = files[index] + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + cell.textLabel?.text = media.fileName + cell.detailTextLabel?.text = media.fileUrl + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + guard let detail = viewModel.detail else { + snapshot.appendSections([0]) + snapshot.appendItems([DepositShootingRowID.placeholder], toSection: 0) + return snapshot + } + snapshot.appendSections([DepositShootingSectionID.summary, DepositShootingSectionID.material, DepositShootingSectionID.complete]) + snapshot.appendItems([DepositShootingRowID.scenicSpot, DepositShootingRowID.rating], toSection: DepositShootingSectionID.summary) + snapshot.appendItems(detail.materialList.indices.map { DepositShootingRowID.material($0) }, toSection: DepositShootingSectionID.material) + snapshot.appendItems(detail.completeList.indices.map { DepositShootingRowID.complete($0) }, toSection: DepositShootingSectionID.complete) + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true) { + tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated) + } + + /// 加载Detail数据。 private func loadDetail() async { await appServices.globalLoading.withLoading { await viewModel.load( @@ -305,56 +483,25 @@ final class DepositOrderShootingInfoViewController: UIViewController { photogUid: photogUid ) } - tableView.reloadData() + applyTableSnapshot() if let message = viewModel.errorMessage { showToast(message) } } -} - -extension DepositOrderShootingInfoViewController: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - viewModel.detail == nil ? 1 : 3 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let detail = viewModel.detail else { return 1 } - switch section { - case 0: return 2 - case 1: return detail.materialList.count - default: return detail.completeList.count - } - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - guard viewModel.detail != nil else { return nil } - switch section { - case 1: return "素材" - case 2: return "成片" + guard viewModel.detail != nil, + let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + switch sectionID { + case DepositShootingSectionID.material: return "素材" + case DepositShootingSectionID.complete: return "成片" default: return nil } } +} - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let detail = viewModel.detail else { - let cell = UITableViewCell() - cell.textLabel?.text = viewModel.errorMessage ?? "加载中..." - return cell - } - if indexPath.section == 0 { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - if indexPath.row == 0 { - cell.textLabel?.text = "打卡点" - cell.detailTextLabel?.text = detail.scenicSpotName - } else { - cell.textLabel?.text = "拍摄评分" - cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--" - } - return cell - } - let files = indexPath.section == 1 ? detail.materialList : detail.completeList - let media = files[indexPath.row] - let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) - cell.textLabel?.text = media.fileName - cell.detailTextLabel?.text = media.fileUrl - return cell +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift index b14438c..22794e6 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift @@ -32,6 +32,7 @@ final class OrderCodeScannerViewController: UIViewController { private let scannerController = OrderScannerCaptureViewController() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black @@ -49,6 +50,7 @@ final class OrderCodeScannerViewController: UIViewController { #endif } + /// embed扫码相关逻辑。 private func embedScanner() { scannerController.onScanResult = { [weak self] result in self?.onScanResult?(result) @@ -77,6 +79,7 @@ final class OrderCodeScannerViewController: UIViewController { } } + /// 展示UnavailablePlaceholder。 private func showUnavailablePlaceholder() { let stack = UIStackView() stack.axis = .vertical @@ -109,6 +112,7 @@ final class OrderCodeScannerViewController: UIViewController { } } + /// 点击close的处理逻辑。 @objc private func closeTapped() { dismiss(animated: true) } @@ -145,6 +149,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada return button }() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black @@ -152,17 +157,20 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada checkPermissionAndStart() } + /// 子视图布局完成后调整依赖 frame 的 UI。 override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.layer.bounds } + /// 视图即将消失,保存或清理临时状态。 override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) updateTorch(enabled: false) stopSession() } + /// check权限并启动相关逻辑。 private func checkPermissionAndStart() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: @@ -185,6 +193,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// 启动Session流程。 private func startSession() { hasFinishedScan = false guard !session.isRunning else { return } @@ -199,11 +208,13 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// 停止Session流程。 private func stopSession() { guard session.isRunning else { return } session.stopRunning() } + /// 创建ScannerButton实例。 private static func makeScannerButton(title: String) -> UIButton { var config = UIButton.Configuration.filled() config.title = title @@ -213,6 +224,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada return UIButton(configuration: config) } + /// 配置Session展示内容。 private func configureSession() throws { if previewLayer != nil { return } guard let videoDevice = AVCaptureDevice.default(for: .video) else { @@ -242,6 +254,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada torchButton.isEnabled = videoDevice.hasTorch } + /// metadataOutput相关逻辑。 func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !hasFinishedScan else { return } guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, @@ -253,6 +266,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada onScanResult?(.success(code)) } + /// 初始化Controls相关 UI 或状态。 private func setupControls() { view.addSubview(scanFrameView) view.addSubview(torchButton) @@ -273,10 +287,12 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// toggleTorch相关逻辑。 @objc private func toggleTorch() { updateTorch(enabled: !(videoDevice?.isTorchActive ?? false)) } + /// 更新Torch状态。 private func updateTorch(enabled: Bool) { guard let device = videoDevice, device.hasTorch else { return } do { @@ -293,6 +309,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada } } + /// restart扫码相关逻辑。 @objc private func restartScan() { hasFinishedScan = false updateTorch(enabled: false) diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift index ca8dce9..c18c3ce 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift @@ -6,8 +6,45 @@ import SnapKit import UIKit +// MARK: - Store Order Diffable 标识 + +private typealias StoreOrderDetailSection = Int +private typealias StoreOrderDetailRow = String + +/// 门店订单详情 section 索引。 +private enum StoreOrderDetailSectionID { + static let context = 0 + static let orderInfo = 1 + static let payment = 2 + static let customer = 3 + static let shooting = 4 + static let actions = 5 +} + +/// 门店订单详情行标识。 +private enum StoreOrderDetailRowID { + static let context = "storeDetail:context" + static func orderInfo(_ index: Int) -> String { "storeDetail:orderInfo:\(index)" } + static func payment(_ index: Int) -> String { "storeDetail:payment:\(index)" } + static func customer(_ index: Int) -> String { "storeDetail:customer:\(index)" } + static func shooting(_ id: String) -> String { "storeDetail:shooting:\(id)" } + static func action(_ index: Int) -> String { "storeDetail:action:\(index)" } +} + +// MARK: - Write-Off Diffable 标识 + +/// 核销详情行标识。 +private enum WriteOffDetailRowID { + static let orderNumber = "writeOff:orderNumber" + static let project = "writeOff:project" + static let phone = "writeOff:phone" + static let amount = "writeOff:amount" + static let status = "writeOff:status" + static let verificationTime = "writeOff:verificationTime" +} + /// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。 -final class StoreOrderDetailViewController: UIViewController { +final class StoreOrderDetailViewController: UIViewController, UITableViewDelegate { private let item: OrderEntity private let viewModel: OrderDetailViewModel @@ -15,12 +52,15 @@ final class StoreOrderDetailViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self table.backgroundColor = AppDesignUIKit.pageBackground return table }() + /// Diffable 数据源,驱动多 section 订单详情刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(item: OrderEntity) { self.item = item self.viewModel = OrderDetailViewModel(item: item) @@ -30,31 +70,176 @@ final class StoreOrderDetailViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "订单详情" view.backgroundColor = AppDesignUIKit.pageBackground + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) } Task { await loadDetail() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: StoreOrderDetailRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + return self.configureDetailCell(row: row) + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + + if viewModel.contextMessage != nil { + snapshot.appendSections([StoreOrderDetailSectionID.context]) + snapshot.appendItems([StoreOrderDetailRowID.context], toSection: StoreOrderDetailSectionID.context) + } + + snapshot.appendSections([StoreOrderDetailSectionID.orderInfo]) + snapshot.appendItems((0..<7).map { StoreOrderDetailRowID.orderInfo($0) }, toSection: StoreOrderDetailSectionID.orderInfo) + + snapshot.appendSections([StoreOrderDetailSectionID.payment]) + snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.payment($0) }, toSection: StoreOrderDetailSectionID.payment) + + snapshot.appendSections([StoreOrderDetailSectionID.customer]) + snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.customer($0) }, toSection: StoreOrderDetailSectionID.customer) + + if !viewModel.shootingList.isEmpty { + snapshot.appendSections([StoreOrderDetailSectionID.shooting]) + let shootingRows = viewModel.shootingList.enumerated().map { index, shooting in + StoreOrderDetailRowID.shooting("\(shooting.id)-\(index)") + } + snapshot.appendItems(shootingRows, toSection: StoreOrderDetailSectionID.shooting) + } + + snapshot.appendSections([StoreOrderDetailSectionID.actions]) + snapshot.appendItems((0..<5).map { StoreOrderDetailRowID.action($0) }, toSection: StoreOrderDetailSectionID.actions) + + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 配置详情 Cell 内容。 + private func configureDetailCell(row: StoreOrderDetailRow) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + cell.textLabel?.numberOfLines = 1 + cell.textLabel?.textColor = nil + cell.accessoryType = .none + cell.isUserInteractionEnabled = true + let display = viewModel.display + + if row == StoreOrderDetailRowID.context { + cell.textLabel?.text = viewModel.contextMessage + cell.textLabel?.numberOfLines = 0 + return cell + } + + if row.hasPrefix("storeDetail:orderInfo:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"] + cell.textLabel?.text = rows[index] + switch index { + case 0: cell.detailTextLabel?.text = display.orderNumber + case 1: cell.detailTextLabel?.text = display.orderStatusName + case 2: cell.detailTextLabel?.text = display.orderTypeLabel + case 3: cell.detailTextLabel?.text = display.createdAt + case 4: cell.detailTextLabel?.text = display.payTime + case 5: cell.detailTextLabel?.text = display.completeTime + default: + cell.textLabel?.textColor = AppDesignUIKit.primary + cell.selectionStyle = .default + } + return cell + } + + if row.hasPrefix("storeDetail:payment:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"] + cell.textLabel?.text = rows[index] + switch index { + case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))" + case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))" + case 2: cell.detailTextLabel?.text = display.payTypeName + default: cell.detailTextLabel?.text = "\(display.userId)" + } + return cell + } + + if row.hasPrefix("storeDetail:customer:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let rows = ["手机号", "关联项目", "项目 ID", "备注"] + cell.textLabel?.text = rows[index] + switch index { + case 0: cell.detailTextLabel?.text = display.phone + case 1: cell.detailTextLabel?.text = display.projectName + case 2: cell.detailTextLabel?.text = "\(display.projectId)" + default: cell.detailTextLabel?.text = display.remark + } + return cell + } + + if row.hasPrefix("storeDetail:shooting:") { + let suffix = row.replacingOccurrences(of: "storeDetail:shooting:", with: "") + let indexPart = suffix.split(separator: "-").last.flatMap { Int($0) } ?? 0 + if indexPart < viewModel.shootingList.count { + let shooting = viewModel.shootingList[indexPart] + cell.textLabel?.text = shooting.scenicSpotName + cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName + } + return cell + } + + if row.hasPrefix("storeDetail:action:") { + let index = Int(row.split(separator: ":").last ?? "") ?? 0 + let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"] + cell.textLabel?.text = actions[index] + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + if index == 4, !refundViewModel.canRefund(item) { + cell.isUserInteractionEnabled = false + cell.textLabel?.textColor = AppDesignUIKit.textSecondary + } + return cell + } + + return cell + } + + /// 加载Detail数据。 private func loadDetail() async { await appServices.globalLoading.withLoading(message: "加载详情中...") { await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id) } + applyTableSnapshot(reconfigure: true) if let error = viewModel.errorMessage { showToast(error) } } + /// 复制订单Number。 private func copyOrderNumber() { UIPasteboard.general.string = viewModel.display.orderNumber showToast("订单号已复制") } + /// 弹出Refund页面。 private func presentRefund() { refundViewModel.begin(item: item) let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert) @@ -75,99 +260,34 @@ final class StoreOrderDetailViewController: UIViewController { }) present(alert, animated: true) } -} - -extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 6 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case 0: return viewModel.contextMessage == nil ? 0 : 1 - case 1: return 7 - case 2: return 4 - case 3: return 4 - case 4: return viewModel.shootingList.count - case 5: return 5 - default: return 0 - } - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch section { - case 1: "订单信息" - case 2: "支付信息" - case 3: "客户与项目" - case 4 where !viewModel.shootingList.isEmpty: "拍摄点" - case 5: "后续功能" - default: nil + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + switch sectionID { + case StoreOrderDetailSectionID.orderInfo: return "订单信息" + case StoreOrderDetailSectionID.payment: return "支付信息" + case StoreOrderDetailSectionID.customer: return "客户与项目" + case StoreOrderDetailSectionID.shooting: return "拍摄点" + case StoreOrderDetailSectionID.actions: return "后续功能" + default: return nil } } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - cell.selectionStyle = .none - let display = viewModel.display - - switch indexPath.section { - case 0: - cell.textLabel?.text = viewModel.contextMessage - cell.textLabel?.numberOfLines = 0 - case 1: - let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"] - cell.textLabel?.text = rows[indexPath.row] - switch indexPath.row { - case 0: cell.detailTextLabel?.text = display.orderNumber - case 1: cell.detailTextLabel?.text = display.orderStatusName - case 2: cell.detailTextLabel?.text = display.orderTypeLabel - case 3: cell.detailTextLabel?.text = display.createdAt - case 4: cell.detailTextLabel?.text = displayPayTime - case 5: cell.detailTextLabel?.text = display.completeTime - default: - cell.textLabel?.textColor = AppDesignUIKit.primary - cell.selectionStyle = .default - } - case 2: - let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"] - cell.textLabel?.text = rows[indexPath.row] - switch indexPath.row { - case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))" - case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))" - case 2: cell.detailTextLabel?.text = display.payTypeName - default: cell.detailTextLabel?.text = "\(display.userId)" - } - case 3: - let rows = ["手机号", "关联项目", "项目 ID", "备注"] - cell.textLabel?.text = rows[indexPath.row] - switch indexPath.row { - case 0: cell.detailTextLabel?.text = display.phone - case 1: cell.detailTextLabel?.text = display.projectName - case 2: cell.detailTextLabel?.text = "\(display.projectId)" - default: cell.detailTextLabel?.text = display.remark - } - case 4: - let shooting = viewModel.shootingList[indexPath.row] - cell.textLabel?.text = shooting.scenicSpotName - cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName - case 5: - let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"] - cell.textLabel?.text = actions[indexPath.row] - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = .default - if indexPath.row == 4, !refundViewModel.canRefund(item) { - cell.isUserInteractionEnabled = false - cell.textLabel?.textColor = AppDesignUIKit.textSecondary - } - default: break - } - return cell - } - + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return } - guard indexPath.section == 5 else { return } + guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return } + + if row == StoreOrderDetailRowID.orderInfo(6) { + copyOrderNumber() + return + } + + guard row.hasPrefix("storeDetail:action:") else { return } + let index = Int(row.split(separator: ":").last ?? "") ?? -1 let orderNumber = viewModel.display.orderNumber - switch indexPath.row { + switch index { case 0: HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self) case 1 where item.orderType == 19: @@ -178,7 +298,8 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self) case 4: presentRefund() - default: break + default: + break } } @@ -188,65 +309,79 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele return payTime } + /// empty至Zero相关逻辑。 private func emptyToZero(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value } } +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + /// 核销订单详情页,展示核销列表项摘要信息。 -final class WriteOffOrderDetailViewController: UIViewController { +final class WriteOffOrderDetailViewController: SimpleTableDiffableViewController { private let item: WriteOffOrderItem - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self - table.backgroundColor = AppDesignUIKit.pageBackground - return table - }() - + /// 初始化实例。 init(item: WriteOffOrderItem) { self.item = item - super.init(nibName: nil, bundle: nil) + super.init(style: .insetGrouped) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "核销详情" - view.addSubview(tableView) - tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + tableView.backgroundColor = AppDesignUIKit.pageBackground } -} -extension WriteOffOrderDetailViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 } + /// 构建 Diffable snapshot。 + override func buildSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems([ + WriteOffDetailRowID.orderNumber, + WriteOffDetailRowID.project, + WriteOffDetailRowID.phone, + WriteOffDetailRowID.amount, + WriteOffDetailRowID.status, + WriteOffDetailRowID.verificationTime + ], toSection: 0) + return snapshot + } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + /// 配置 Cell 展示内容。 + override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) { cell.selectionStyle = .none - switch indexPath.row { - case 0: + switch row { + case WriteOffDetailRowID.orderNumber: cell.textLabel?.text = "订单号" cell.detailTextLabel?.text = item.orderNumber - case 1: + case WriteOffDetailRowID.project: cell.textLabel?.text = "项目" cell.detailTextLabel?.text = item.projectName - case 2: + case WriteOffDetailRowID.phone: cell.textLabel?.text = "手机号" cell.detailTextLabel?.text = item.userPhone - case 3: + case WriteOffDetailRowID.amount: cell.textLabel?.text = "金额" cell.detailTextLabel?.text = "¥\(item.orderAmount)" - case 4: + case WriteOffDetailRowID.status: cell.textLabel?.text = "状态" cell.detailTextLabel?.text = item.orderStatusName - default: + case WriteOffDetailRowID.verificationTime: cell.textLabel?.text = "核销时间" cell.detailTextLabel?.text = item.orderVerificationTime + default: + break } - return cell } } diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift index 40e7157..44011cc 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift @@ -19,6 +19,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { private let localListLabel = UILabel() private let submitButton = UIButton(type: .system) + /// 初始化实例。 init(initialOrderNumber: String) { self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber) super.init(nibName: nil, bundle: nil) @@ -27,6 +28,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "任务上传" @@ -36,12 +38,14 @@ final class MultiTravelTaskUploadViewController: UIViewController { Task { await viewModel.loadSpots(api: appServices.ordersAPI) } } + /// 绑定ViewModel回调或数据。 private func bindViewModel() { viewModel.onChange = { [weak self] in self?.applyViewModel() } } + /// 初始化Form相关 UI 或状态。 private func setupForm() { orderField.borderStyle = .roundedRect orderField.placeholder = "关联订单号" @@ -90,6 +94,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { applyViewModel() } + /// labeled行相关逻辑。 private func labeledRow(_ title: String, _ content: UIView) -> UIStackView { let label = UILabel() label.text = title @@ -100,6 +105,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { return row } + /// apply视图模型相关逻辑。 private func applyViewModel() { orderField.text = viewModel.orderNumber spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal) @@ -123,14 +129,17 @@ final class MultiTravelTaskUploadViewController: UIViewController { submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材" } + /// order变更相关逻辑。 @objc private func orderChanged() { viewModel.orderNumber = orderField.text ?? "" } + /// 刷新Spots展示。 @objc private func refreshSpots() { Task { await viewModel.loadSpots(api: appServices.ordersAPI) } } + /// select打卡点相关逻辑。 @objc private func selectSpot() { guard !viewModel.spots.isEmpty else { return } let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet) @@ -144,6 +153,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { present(sheet, animated: true) } + /// pick本地Files相关逻辑。 @objc private func pickLocalFiles() { var config = PHPickerConfiguration() config.selectionLimit = 9 @@ -153,6 +163,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { present(picker, animated: true) } + /// 提交Tapped。 @objc private func submitTapped() { Task { let success = await viewModel.submit( @@ -172,6 +183,7 @@ final class MultiTravelTaskUploadViewController: UIViewController { } extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate { + /// picker 业务逻辑。 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } @@ -199,19 +211,41 @@ extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate { } } +// MARK: - Historical Shooting Diffable 标识 + +private typealias HistoricalShootingSection = Int +private typealias HistoricalShootingRow = String + +/// 历史拍摄 section 索引。 +private enum HistoricalShootingSectionID { + static let summary = 0 + static let spots = 1 +} + +/// 历史拍摄行标识。 +private enum HistoricalShootingRowID { + static let project = "historicalShooting:project" + static let projectType = "historicalShooting:projectType" + static let empty = "historicalShooting:empty" + static func spot(_ id: String) -> String { "historicalShooting:spot:\(id)" } +} + /// 历史拍摄信息页。 -final class HistoricalShootingInfoViewController: UIViewController { +final class HistoricalShootingInfoViewController: UIViewController, UITableViewDelegate { private let orderNumber: String private let viewModel = HistoricalShootingInfoViewModel() private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self return table }() + /// Diffable 数据源,驱动历史拍摄摘要与点位列表刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 初始化实例。 init(orderNumber: String) { self.orderNumber = orderNumber super.init(nibName: nil, bundle: nil) @@ -220,49 +254,89 @@ final class HistoricalShootingInfoViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "历史拍摄" + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) } Task { await loadData() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, _: IndexPath, row: HistoricalShootingRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + switch row { + case HistoricalShootingRowID.project: + cell.textLabel?.text = "项目" + cell.detailTextLabel?.text = self.viewModel.projectName + case HistoricalShootingRowID.projectType: + cell.textLabel?.text = "项目类型" + cell.detailTextLabel?.text = self.viewModel.projectTypeName + case HistoricalShootingRowID.empty: + cell.textLabel?.text = self.viewModel.errorMessage ?? "暂无历史拍摄" + default: + if row.hasPrefix("historicalShooting:spot:") { + let id = row.replacingOccurrences(of: "historicalShooting:spot:", with: "") + if let spot = self.viewModel.spots.first(where: { $0.id == id }) { + cell.textLabel?.text = spot.scenicSpotName + cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)" + } + } + } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([HistoricalShootingSectionID.summary, HistoricalShootingSectionID.spots]) + snapshot.appendItems([HistoricalShootingRowID.project, HistoricalShootingRowID.projectType], toSection: HistoricalShootingSectionID.summary) + if viewModel.spots.isEmpty { + snapshot.appendItems([HistoricalShootingRowID.empty], toSection: HistoricalShootingSectionID.spots) + } else { + snapshot.appendItems(viewModel.spots.map { HistoricalShootingRowID.spot($0.id) }, toSection: HistoricalShootingSectionID.spots) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 加载Data数据。 private func loadData() async { await appServices.globalLoading.withLoading { await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber) } + applyTableSnapshot() if let message = viewModel.errorMessage { showToast(message) } } -} - -extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 2 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 2 : max(viewModel.spots.count, 1) - } + /// UITableView 代理:section 标题。 func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 1 ? "拍摄点位" : nil - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - cell.selectionStyle = .none - if indexPath.section == 0 { - cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型" - cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName - return cell - } - guard !viewModel.spots.isEmpty else { - cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄" - return cell - } - let spot = viewModel.spots[indexPath.row] - cell.textLabel?.text = spot.scenicSpotName - cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)" - return cell + guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil } + return sectionID == HistoricalShootingSectionID.spots ? "拍摄点位" : nil + } +} + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift index 36a7059..b7de248 100644 --- a/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift @@ -6,6 +6,31 @@ import SnapKit import UIKit +// MARK: - Diffable 标识(Int / String 避免 MainActor 默认隔离下 Hashable 冲突) + +/// 订单列表 section 索引。 +private enum OrdersSectionID { + static let header = 0 + static let toolbar = 1 + static let content = 2 +} + +private typealias OrdersSection = Int + +/// 订单列表 item 字符串标识。 +private enum OrdersItemID { + static let header = "orders:header" + static let filter = "orders:filter" + static let writeOffAction = "orders:writeOffAction" + static let missingContext = "orders:missingContext" + static let emptyStore = "orders:empty:store" + static let emptyWriteOff = "orders:empty:writeOff" + static func storeOrder(_ orderNumber: String) -> String { "orders:store:\(orderNumber)" } + static func writeOffOrder(_ orderNumber: String) -> String { "orders:writeoff:\(orderNumber)" } +} + +private typealias OrdersItem = String + /// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。 final class OrdersViewController: UIViewController { @@ -13,36 +38,43 @@ final class OrdersViewController: UIViewController { private var manualOrderNumber = "" private var scanHintMessage: String? - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.separatorStyle = .none - table.dataSource = self - table.delegate = self - table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID) - table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID) - table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID) - table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID) - table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID) - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.delegate = self + collection.register(OrdersHeaderCell.self, forCellWithReuseIdentifier: OrdersHeaderCell.reuseID) + collection.register(OrdersFilterCell.self, forCellWithReuseIdentifier: OrdersFilterCell.reuseID) + collection.register(OrdersWriteOffActionCell.self, forCellWithReuseIdentifier: OrdersWriteOffActionCell.reuseID) + collection.register(OrderEntityCell.self, forCellWithReuseIdentifier: OrderEntityCell.reuseID) + collection.register(WriteOffOrderCell.self, forCellWithReuseIdentifier: WriteOffOrderCell.reuseID) + collection.register(OrdersEmptyStateCell.self, forCellWithReuseIdentifier: OrdersEmptyStateCell.reuseID) + return collection + }() + + private lazy var dataSource: UICollectionViewDiffableDataSource = { + UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in + self?.cell(for: collectionView, at: indexPath, item: item) ?? UICollectionViewCell() + } }() private lazy var refreshControl = UIRefreshControl() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "订单" view.backgroundColor = AppDesignUIKit.pageBackground - view.addSubview(tableView) - tableView.snp.makeConstraints { make in + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) - tableView.refreshControl = refreshControl + collectionView.refreshControl = refreshControl viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + self?.applySnapshot() } appServices.appRouter.onChange = { [weak self] in @@ -64,6 +96,202 @@ final class OrdersViewController: UIViewController { } } + /// 构建 Compositional Layout,按 section 类型选用全宽布局。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self else { + return CollectionDiffableLayout.fullWidthSection() + } + let section = self.dataSource.snapshot().sectionIdentifiers[sectionIndex] + switch section { + case OrdersSectionID.header: + return CollectionDiffableLayout.fullWidthSection(height: 120) + case OrdersSectionID.toolbar: + let height: CGFloat = self.viewModel.selectedEntry == .storeOrders ? 130 : 150 + return CollectionDiffableLayout.fullWidthSection(height: height) + case OrdersSectionID.content: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 88) + default: + return CollectionDiffableLayout.fullWidthSection() + } + } + } + + /// 根据当前 ViewModel 与页面状态构建并应用 Diffable 快照。 + private func applySnapshot(animatingDifferences: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([OrdersSectionID.header]) + snapshot.appendItems([OrdersItemID.header], toSection: OrdersSectionID.header) + + guard currentScenicId != nil else { + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + return + } + + snapshot.appendSections([OrdersSectionID.toolbar, OrdersSectionID.content]) + + if viewModel.selectedEntry == .storeOrders { + snapshot.appendItems([OrdersItemID.filter], toSection: OrdersSectionID.toolbar) + appendStoreOrderItems(to: &snapshot) + } else { + snapshot.appendItems([OrdersItemID.writeOffAction], toSection: OrdersSectionID.toolbar) + appendWriteOffOrderItems(to: &snapshot) + } + + dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + } + + /// 向快照追加订单管理列表或空态 item。 + private func appendStoreOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot) { + if viewModel.storeOrders.isEmpty { + guard !(viewModel.loading && viewModel.storeOrders.isEmpty) else { return } + snapshot.appendItems([OrdersItemID.emptyStore], toSection: OrdersSectionID.content) + return + } + snapshot.appendItems( + viewModel.storeOrders.map { OrdersItemID.storeOrder($0.orderNumber) }, + toSection: OrdersSectionID.content + ) + } + + /// 向快照追加核销订单列表或空态 item。 + private func appendWriteOffOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot) { + if viewModel.writeOffOrders.isEmpty { + guard !(viewModel.loading && viewModel.writeOffOrders.isEmpty) else { return } + snapshot.appendItems([OrdersItemID.emptyWriteOff], toSection: OrdersSectionID.content) + return + } + snapshot.appendItems( + viewModel.writeOffOrders.map { OrdersItemID.writeOffOrder($0.orderNumber) }, + toSection: OrdersSectionID.content + ) + } + + /// 按 item 类型 dequeue 并配置对应 Cell。 + private func cell( + for collectionView: UICollectionView, + at indexPath: IndexPath, + item: OrdersItem + ) -> UICollectionViewCell { + switch item { + case OrdersItemID.header: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersHeaderCell.reuseID, + for: indexPath + ) as! OrdersHeaderCell + cell.configure( + selectedEntry: viewModel.selectedEntry, + scenicName: appServices.accountContext.currentScenic?.name ?? "--", + storeTotal: viewModel.storeTotal, + writeOffTotal: viewModel.writeOffTotal, + onSelectEntry: { [weak self] entry in self?.switchEntry(entry) } + ) + return cell + + case OrdersItemID.filter: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersFilterCell.reuseID, + for: indexPath + ) as! OrdersFilterCell + let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" + cell.configure( + statusTitle: statusTitle, + phone: viewModel.searchPhone, + onStatus: { [weak self] in self?.presentStatusFilter() }, + onDate: { [weak self] in self?.presentDateFilter() }, + onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text }, + onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } } + ) + return cell + + case OrdersItemID.writeOffAction: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersWriteOffActionCell.reuseID, + for: indexPath + ) as! OrdersWriteOffActionCell + cell.configure( + manualOrderNumber: manualOrderNumber, + isVerifying: viewModel.isVerifying, + hint: scanHintMessage, + onScan: { [weak self] in self?.presentScanner() }, + onManualChange: { [weak self] text in self?.manualOrderNumber = text }, + onVerify: { [weak self] in + guard let self, !self.manualOrderNumber.isEmpty else { return } + self.confirmVerify(orderNumber: self.manualOrderNumber) + } + ) + return cell + + case OrdersItemID.missingContext: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersEmptyStateCell.reuseID, + for: indexPath + ) as! OrdersEmptyStateCell + cell.embed( + makeEmptyStateView( + title: "缺少经营上下文", + message: "请先在首页选择景区后查看订单。", + systemImage: "mountain.2" + ), + preferredHeight: 360 + ) + return cell + + case OrdersItemID.emptyStore: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersEmptyStateCell.reuseID, + for: indexPath + ) as! OrdersEmptyStateCell + cell.embed( + makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray"), + preferredHeight: 260 + ) + return cell + + case OrdersItemID.emptyWriteOff: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrdersEmptyStateCell.reuseID, + for: indexPath + ) as! OrdersEmptyStateCell + cell.embed( + makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray"), + preferredHeight: 260 + ) + return cell + + default: + if item.hasPrefix("orders:store:") { + let orderNumber = String(item.dropFirst("orders:store:".count)) + guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { + return UICollectionViewCell() + } + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: OrderEntityCell.reuseID, + for: indexPath + ) as! OrderEntityCell + cell.configure(item: order) + return cell + } + if item.hasPrefix("orders:writeoff:") { + let orderNumber = String(item.dropFirst("orders:writeoff:".count)) + guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { + return UICollectionViewCell() + } + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: WriteOffOrderCell.reuseID, + for: indexPath + ) as! WriteOffOrderCell + cell.configure( + item: order, + isVerifying: viewModel.currentVerifyingOrderNumber == order.orderNumber + ) + return cell + } + return UICollectionViewCell() + } + } + + /// 下拉刷新触发重新加载。 @objc private func refreshPulled() { Task { await reload(showLoading: false) @@ -75,6 +303,7 @@ final class OrdersViewController: UIViewController { private var currentStoreId: Int? { appServices.accountContext.currentStore?.id } private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + /// 重新加载当前子入口对应的订单数据。 private func reload(showLoading: Bool) async { do { try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") { @@ -91,6 +320,7 @@ final class OrdersViewController: UIViewController { } } + /// 滚动到底部时加载下一页。 private func loadMore() async { do { if viewModel.selectedEntry == .storeOrders { @@ -111,6 +341,7 @@ final class OrdersViewController: UIViewController { } } + /// 切换订单管理 / 核销订单子入口。 private func switchEntry(_ entry: OrdersEntry) { guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry @@ -118,6 +349,7 @@ final class OrdersViewController: UIViewController { Task { await reload(showLoading: true) } } + /// 提交订单核销请求。 private func verify(orderNumber: String) async { guard let scenicId = currentScenicId else { return } do { @@ -135,12 +367,14 @@ final class OrdersViewController: UIViewController { } } + /// 消费路由中暂存的扫码结果(核销入口)。 private func consumePendingScanCodeIfNeeded() async { guard viewModel.selectedEntry == .verificationOrders, let code = appServices.appRouter.consumePendingOrderScanCode() else { return } handleScanResult(code) } + /// 解析扫码结果并引导核销或提示未匹配。 private func handleScanResult(_ rawCode: String) { guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else { showToast("未识别到有效订单号") @@ -150,10 +384,11 @@ final class OrdersViewController: UIViewController { confirmVerify(orderNumber: parsed.orderNumber) } else { scanHintMessage = "扫码成功,当前列表未找到该订单" - tableView.reloadData() + applySnapshot() } } + /// 弹出核销确认对话框。 private func confirmVerify(orderNumber: String) { let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) @@ -163,6 +398,7 @@ final class OrdersViewController: UIViewController { present(alert, animated: true) } + /// 打开订单号扫码页。 private func presentScanner() { let scanner = OrderCodeScannerViewController() scanner.onScanResult = { [weak self, weak scanner] result in @@ -180,6 +416,7 @@ final class OrdersViewController: UIViewController { present(nav, animated: true) } + /// 弹出时间筛选快捷选项。 private func presentDateFilter() { let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in @@ -198,6 +435,7 @@ final class OrdersViewController: UIViewController { present(alert, animated: true) } + /// 弹出订单状态筛选 ActionSheet。 private func presentStatusFilter() { let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet) for filter in OrderFilters.statusFilters { @@ -211,143 +449,51 @@ final class OrdersViewController: UIViewController { } } -extension OrdersViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { - guard currentScenicId != nil else { return 1 } - return viewModel.selectedEntry == .storeOrders ? 3 : 3 - } +// MARK: - UICollectionViewDelegate - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if section == 0 { return 1 } - if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 } - if currentScenicId == nil { return 1 } - if viewModel.selectedEntry == .storeOrders { - return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1) - } - return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.section == 0 { - let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell - let services = appServices - cell.configure( - selectedEntry: viewModel.selectedEntry, - scenicName: services.accountContext.currentScenic?.name ?? "--", - storeTotal: viewModel.storeTotal, - writeOffTotal: viewModel.writeOffTotal, - onSelectEntry: { [weak self] entry in self?.switchEntry(entry) } - ) - return cell - } - - if currentScenicId == nil { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in - make.edges.equalToSuperview() - make.height.equalTo(360) - } - return cell - } - - if indexPath.section == 1 { - if viewModel.selectedEntry == .storeOrders { - let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell - let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" - cell.configure( - statusTitle: statusTitle, - phone: viewModel.searchPhone, - onStatus: { [weak self] in self?.presentStatusFilter() }, - onDate: { [weak self] in self?.presentDateFilter() }, - onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text }, - onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } } - ) - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell - cell.configure( - manualOrderNumber: manualOrderNumber, - isVerifying: viewModel.isVerifying, - hint: scanHintMessage, - onScan: { [weak self] in self?.presentScanner() }, - onManualChange: { [weak self] text in self?.manualOrderNumber = text }, - onVerify: { [weak self] in - guard let self, !self.manualOrderNumber.isEmpty else { return } - self.confirmVerify(orderNumber: self.manualOrderNumber) - } - ) - return cell - } - - if viewModel.selectedEntry == .storeOrders { - if viewModel.storeOrders.isEmpty { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } - return cell - } - let item = viewModel.storeOrders[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell - cell.configure(item: item) - return cell - } - - if viewModel.writeOffOrders.isEmpty { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } - return cell - } - let item = viewModel.writeOffOrders[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell - cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber) - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - guard indexPath.section == 2 else { return } - if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count { - HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self) - } else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count { - HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self) +extension OrdersViewController: UICollectionViewDelegate { + /// 点击订单卡片进入详情。 + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + if item.hasPrefix("orders:store:") { + let orderNumber = String(item.dropFirst("orders:store:".count)) + guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return } + HomeMenuRouting.pushOrders(.storeDetail(order), from: self) + } else if item.hasPrefix("orders:writeoff:") { + let orderNumber = String(item.dropFirst("orders:writeoff:".count)) + guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return } + HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self) } } - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard indexPath.section == 2 else { return } + /// 列表即将展示最后一项时触发分页加载。 + func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } let isLast: Bool - if viewModel.selectedEntry == .storeOrders { - isLast = indexPath.row == viewModel.storeOrders.count - 1 + if item.hasPrefix("orders:store:") { + let orderNumber = String(item.dropFirst("orders:store:".count)) + isLast = orderNumber == viewModel.storeOrders.last?.orderNumber + } else if item.hasPrefix("orders:writeoff:") { + let orderNumber = String(item.dropFirst("orders:writeoff:".count)) + isLast = orderNumber == viewModel.writeOffOrders.last?.orderNumber } else { - isLast = indexPath.row == viewModel.writeOffOrders.count - 1 + isLast = false + } + if isLast { + Task { await loadMore() } } - if isLast { Task { await loadMore() } } - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - if indexPath.section == 0 { return 120 } - if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 } - return UITableView.automaticDimension } } // MARK: - Cells -private final class OrdersHeaderCell: UITableViewCell { +/// 订单页头部 Cell,展示子入口切换与统计摘要。 +private final class OrdersHeaderCell: UICollectionViewCell { static let reuseID = "OrdersHeaderCell" private var onSelectEntry: ((OrdersEntry) -> Void)? @@ -356,16 +502,16 @@ private final class OrdersHeaderCell: UITableViewCell { private let leftPill = UILabel() private let rightPill = UILabel() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) } let segmentBackground = UIView() @@ -410,7 +556,14 @@ private final class OrdersHeaderCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) { + /// 配置头部展示内容与入口切换回调。 + func configure( + selectedEntry: OrdersEntry, + scenicName: String, + storeTotal: Int, + writeOffTotal: Int, + onSelectEntry: @escaping (OrdersEntry) -> Void + ) { self.onSelectEntry = onSelectEntry updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders) updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders) @@ -423,6 +576,7 @@ private final class OrdersHeaderCell: UITableViewCell { } } + /// 更新分段按钮选中样式。 private func updateSegment(_ button: UIButton, title: String, selected: Bool) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium) @@ -431,26 +585,29 @@ private final class OrdersHeaderCell: UITableViewCell { button.layer.cornerRadius = 6 } + /// 切换到订单管理入口。 @objc private func storeTapped() { onSelectEntry?(.storeOrders) } + /// 切换到核销订单入口。 @objc private func verifyTapped() { onSelectEntry?(.verificationOrders) } } -private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate { +/// 订单筛选栏 Cell,承载状态 / 时间与手机号搜索。 +private final class OrdersFilterCell: UICollectionViewCell, UITextFieldDelegate { static let reuseID = "OrdersFilterCell" private let phoneField = UITextField() private var onPhoneChange: ((String) -> Void)? private var onSearch: (() -> Void)? - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) card.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) } phoneField.delegate = self phoneField.keyboardType = .phonePad @@ -470,32 +627,44 @@ private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) { + /// 配置筛选栏展示内容与交互回调。 + func configure( + statusTitle: String, + phone: String, + onStatus: @escaping () -> Void, + onDate: @escaping () -> Void, + onPhoneChange: @escaping (String) -> Void, + onSearch: @escaping () -> Void + ) { phoneField.text = phone self.onPhoneChange = onPhoneChange self.onSearch = onSearch } + /// 手机号输入变化时同步到 ViewModel。 func textFieldDidChangeSelection(_ textField: UITextField) { onPhoneChange?(textField.text ?? "") } } -private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate { +/// 核销操作栏 Cell,承载扫码与手动输入核销。 +private final class OrdersWriteOffActionCell: UICollectionViewCell, UITextFieldDelegate { static let reuseID = "OrdersWriteOffActionCell" private let manualField = UITextField() private var onManualChange: ((String) -> Void)? private var onVerify: (() -> Void)? - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) - card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } manualField.delegate = self manualField.placeholder = "手动输入订单号" @@ -516,33 +685,45 @@ private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelega @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) { + /// 配置核销操作栏展示内容与交互回调。 + func configure( + manualOrderNumber: String, + isVerifying: Bool, + hint: String?, + onScan: @escaping () -> Void, + onManualChange: @escaping (String) -> Void, + onVerify: @escaping () -> Void + ) { manualField.text = manualOrderNumber self.onManualChange = onManualChange self.onVerify = onVerify } + /// 手动订单号输入变化时同步到页面状态。 func textFieldDidChangeSelection(_ textField: UITextField) { onManualChange?(textField.text ?? "") } } -private final class OrderEntityCell: UITableViewCell { +/// 订单管理列表 Cell,展示单笔订单摘要。 +private final class OrderEntityCell: UICollectionViewCell { static let reuseID = "OrderEntityCell" private let titleLabel = UILabel() private let statusLabel = UILabel() private let amountLabel = UILabel() private let phoneLabel = UILabel() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .default + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) - card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) @@ -563,6 +744,7 @@ private final class OrderEntityCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置订单卡片展示内容。 func configure(item: OrderEntity) { titleLabel.text = item.orderNumber statusLabel.text = item.orderStatusName @@ -571,19 +753,23 @@ private final class OrderEntityCell: UITableViewCell { } } -private final class WriteOffOrderCell: UITableViewCell { +/// 核销订单列表 Cell,展示单笔核销订单摘要。 +private final class WriteOffOrderCell: UICollectionViewCell { static let reuseID = "WriteOffOrderCell" private let titleLabel = UILabel() private let subtitleLabel = UILabel() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + /// 初始化 Cell 与子视图布局。 + override init(frame: CGRect) { + super.init(frame: frame) backgroundColor = .clear let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 contentView.addSubview(card) - card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) subtitleLabel.textColor = AppDesignUIKit.textSecondary @@ -597,8 +783,34 @@ private final class WriteOffOrderCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置核销订单卡片展示内容。 func configure(item: WriteOffOrderItem, isVerifying: Bool) { titleLabel.text = item.orderNumber subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName) } } + +/// 订单列表空态 Cell,复用通用空态视图。 +private final class OrdersEmptyStateCell: UICollectionViewCell { + static let reuseID = "OrdersEmptyStateCell" + private var heightConstraint: Constraint? + + /// 初始化 Cell。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + /// 嵌入空态视图并设置占位高度。 + func embed(_ emptyView: UIView, preferredHeight: CGFloat) { + contentView.subviews.forEach { $0.removeFromSuperview() } + contentView.addSubview(emptyView) + emptyView.snp.makeConstraints { make in + make.edges.equalToSuperview() + heightConstraint = make.height.equalTo(preferredHeight).constraint + } + } +} diff --git a/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift b/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift index 47f337e..8f6b19e 100644 --- a/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift +++ b/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift @@ -120,6 +120,7 @@ final class DepositOrderListViewModel { errorMessage = nil } + /// 重置State状态。 private func resetState() { orders = [] total = 0 @@ -170,6 +171,7 @@ final class DepositOrderDetailViewModel { errorMessage = nil } + /// 重置状态。 private func reset(message: String) { detail = nil loading = false @@ -220,6 +222,7 @@ final class DepositOrderShootingInfoViewModel { errorMessage = nil } + /// 重置状态。 private func reset(message: String) { detail = nil loading = false @@ -324,10 +327,12 @@ final class OrderRefundViewModel { return moneyText(value) } + /// decimal值相关逻辑。 private static func decimalValue(_ text: String) -> Decimal { Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0 } + /// money文本相关逻辑。 private static func moneyText(_ value: Decimal) -> String { let number = NSDecimalNumber(decimal: value) let handler = NSDecimalNumberHandler( @@ -379,6 +384,7 @@ final class HistoricalShootingInfoViewModel { errorMessage = nil } + /// 重置状态。 private func reset(message: String) { projectName = "" projectTypeName = "" @@ -520,6 +526,7 @@ final class MultiTravelTaskUploadViewModel { errorMessage = nil } + /// 校验BeforeSubmit输入或状态。 private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? { let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard let scenicId, scenicId > 0 else { @@ -549,6 +556,7 @@ final class MultiTravelTaskUploadViewModel { return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId) } + /// upload本地Files相关逻辑。 private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] { var uploadFiles: [MultiTravelUploadFileItem] = [] for index in selectedLocalFiles.indices { @@ -583,11 +591,13 @@ final class MultiTravelTaskUploadViewModel { return uploadFiles } + /// 更新LocalFileProgress状态。 private func updateLocalFileProgress(id: UUID, progress: Int) { guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } selectedLocalFiles[index].progress = progress } + /// 重置SpotSelection状态。 private func resetSpotSelection() { spots = [] selectedSpotId = nil @@ -595,11 +605,13 @@ final class MultiTravelTaskUploadViewModel { isLoadingSpots = false } + /// fileType相关逻辑。 private static func fileType(for fileName: String) -> Int { let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 } + /// Submit上下文,持有跨页面共享状态。 private struct SubmitContext { let scenicId: Int let orderNumber: String diff --git a/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift index ca4e9a2..0e50279 100644 --- a/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift +++ b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift @@ -18,6 +18,7 @@ final class PaymentCollectionViewController: UIViewController { private let remarkField = UITextField() private let activityIndicator = UIActivityIndicatorView(style: .large) + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "收款" @@ -27,6 +28,7 @@ final class PaymentCollectionViewController: UIViewController { Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) } } + /// 初始化UI相关 UI 或状态。 private func setupUI() { qrImageView.contentMode = .scaleAspectFit statusLabel.numberOfLines = 0 @@ -56,18 +58,21 @@ final class PaymentCollectionViewController: UIViewController { ] } + /// render 业务逻辑。 private func render() { qrImageView.image = viewModel.qrImage statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status) if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } } + /// apply金额相关逻辑。 @objc private func applyAmount() { viewModel.amountText = amountField.text ?? "" viewModel.remarkText = remarkField.text ?? "" _ = viewModel.applyDynamicAmount() } + /// togglePolling相关逻辑。 @objc private func togglePolling() { Task { await viewModel.pollUntilPaymentDetected( diff --git a/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift b/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift index e821186..abac7cf 100644 --- a/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift +++ b/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift @@ -10,9 +10,13 @@ import Foundation /// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。 @MainActor protocol PilotCertificationServing { + /// 飞手详情相关逻辑。 func flyerDetail() async throws -> FlyerDetailResponse + /// 飞手Send验证码相关逻辑。 func flyerSendCode(phone: String) async throws + /// 飞手提交相关逻辑。 func flyerApply(_ request: FlyerApplyRequest) async throws + /// 飞手编辑相关逻辑。 func flyerEdit(_ request: FlyerEditRequest) async throws } diff --git a/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift b/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift index f7ae4ca..749db49 100644 --- a/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift +++ b/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift @@ -48,6 +48,7 @@ struct FlyerDetailResponse: Decodable, Equatable { let realnameStatusText: String let certificationLogs: [FlyerCertificationLogItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name = "flyer_nickname" @@ -72,6 +73,7 @@ struct FlyerDetailResponse: Decodable, Equatable { case certificationLogs = "flyers_certification_logs" } + /// 初始化实例。 init( id: Int = 0, name: String = "", @@ -118,6 +120,7 @@ struct FlyerDetailResponse: Decodable, Equatable { self.certificationLogs = certificationLogs } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 @@ -155,6 +158,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { let remark: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case flyerId = "flyer_id" @@ -166,6 +170,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { case createdAt = "created_at" } + /// 初始化实例。 init( id: Int = 0, flyerId: Int = 0, @@ -186,6 +191,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { self.createdAt = createdAt } + /// 初始化实例。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 @@ -218,6 +224,7 @@ struct FlyerApplyRequest: Encodable, Equatable { let contactPhone: String let code: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case name case realnameStatus = "realname_status" @@ -248,6 +255,7 @@ struct FlyerEditRequest: Encodable, Equatable { let contactPhone: String let code: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -277,6 +285,7 @@ enum PilotCertificationValidationError: LocalizedError, Equatable { } private extension KeyedDecodingContainer { + /// pilot解码宽松字符串相关逻辑。 func pilotDecodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -293,6 +302,7 @@ private extension KeyedDecodingContainer { return "" } + /// pilot解码宽松整数相关逻辑。 func pilotDecodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value diff --git a/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift b/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift index 45a9409..9537bf1 100644 --- a/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift +++ b/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift @@ -13,6 +13,7 @@ final class PilotCertificationViewController: ModuleTableViewController { private let viewModel = PilotCertificationViewModel() private let statusLabel = UILabel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "飞手认证" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -26,6 +27,7 @@ final class PilotCertificationViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateHeader() } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { statusLabel.numberOfLines = 0 statusLabel.font = .systemFont(ofSize: 14) @@ -34,17 +36,21 @@ final class PilotCertificationViewController: ModuleTableViewController { tableView.tableHeaderView = statusLabel } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? formRows.count : 1 } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "认证信息" : "操作" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -58,8 +64,8 @@ final class PilotCertificationViewController: ModuleTableViewController { return cell } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + /// 处理行选中。 + override func didSelectTableRow(at indexPath: IndexPath) { if indexPath.section == 1 { Task { do { @@ -72,11 +78,13 @@ final class PilotCertificationViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI) updateHeader() } + /// 提交。 @objc private func submit() { Task { do { @@ -102,6 +110,7 @@ final class PilotCertificationViewController: ModuleTableViewController { ] } + /// 更新Header状态。 private func updateHeader() { let status = viewModel.auditStatusText statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")" diff --git a/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift b/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift index dcdc2a0..8236e97 100644 --- a/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift +++ b/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift @@ -10,6 +10,7 @@ import Foundation @MainActor /// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。 protocol PilotRealNameServing { + /// realNameInfo相关逻辑。 func realNameInfo() async throws -> RealNameInfoResponse } @@ -167,6 +168,7 @@ final class PilotCertificationViewModel { flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 } } + /// apply 业务逻辑。 private func apply(_ flyer: FlyerDetailResponse) { self.flyer = flyer name = flyer.name @@ -183,6 +185,7 @@ final class PilotCertificationViewModel { uploadProgress = nil } + /// upload待处理Certificate图片相关逻辑。 private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws { guard let data = pendingCertificateImageData else { return } guard let scenicId, scenicId > 0 else { @@ -203,6 +206,7 @@ final class PilotCertificationViewModel { pendingCertificateFileName = nil } + /// 创建ApplyRequest实例。 private func makeApplyRequest() -> FlyerApplyRequest { FlyerApplyRequest( name: name.trimmedForPilot, @@ -219,6 +223,7 @@ final class PilotCertificationViewModel { ) } + /// 创建EditRequest实例。 private func makeEditRequest() -> FlyerEditRequest { let apply = makeApplyRequest() return FlyerEditRequest( @@ -237,6 +242,7 @@ final class PilotCertificationViewModel { ) } + /// statusName相关逻辑。 private static func statusName(for status: Int) -> String { switch status { case 1: "审核中" @@ -254,6 +260,7 @@ final class PilotCertificationViewModel { return formatter }() + /// date 业务逻辑。 private static func date(from value: String) -> Date? { let text = String(value.prefix(10)) guard !text.isEmpty else { return nil } @@ -261,9 +268,11 @@ final class PilotCertificationViewModel { } } +/// PilotEmptyRealName服务协议,定义模块对外能力。 private struct PilotEmptyRealNameServing: PilotRealNameServing { let info: RealNameInfo? + /// realNameInfo相关逻辑。 func realNameInfo() async throws -> RealNameInfoResponse { RealNameInfoResponse(realNameInfo: info) } diff --git a/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift index 40bd878..64c9e0d 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift @@ -6,8 +6,18 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +private typealias AccountSwitchSection = Int +private typealias AccountSwitchItem = String + +/// 账号切换列表空态标识。 +private enum AccountSwitchItemID { + static let empty = "accountSwitch:empty" +} + /// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。 -final class AccountSwitchViewController: UIViewController { +final class AccountSwitchViewController: UIViewController, UITableViewDelegate { private let viewModel = AccountSwitchViewModel() @@ -15,7 +25,6 @@ final class AccountSwitchViewController: UIViewController { let table = UITableView(frame: .zero, style: .plain) table.backgroundColor = UIColor(hex: 0xF5F7FB) table.separatorStyle = .none - table.dataSource = self table.delegate = self table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID) return table @@ -27,11 +36,16 @@ final class AccountSwitchViewController: UIViewController { return button }() + /// Diffable 数据源,驱动账号列表与选中态刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "账号切换" view.backgroundColor = UIColor(hex: 0xF5F7FB) + configureTableDataSource() view.addSubview(tableView) view.addSubview(confirmButton) tableView.snp.makeConstraints { make in @@ -45,18 +59,75 @@ final class AccountSwitchViewController: UIViewController { } viewModel.onChange = { [weak self] in - self?.tableView.reloadData() + self?.applyTableSnapshot(reconfigure: true) self?.updateConfirmButton() } Task { await loadAccounts() } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSwitchItem) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + if item == AccountSwitchItemID.empty { + let cell = UITableViewCell() + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + let empty = makeEmptyStateView( + title: "暂无可切换账号", + message: "当前登录账号下没有其他可切换账号。", + systemImage: "person.crop.circle.badge.exclamationmark" + ) + cell.contentView.addSubview(empty) + empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) } + return cell + } + guard let account = self.viewModel.accounts.first(where: { $0.id == item }) else { + return UITableViewCell() + } + let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell + cell.configure( + account: account, + selected: self.viewModel.selectedAccountId == account.id, + isCurrent: account.isCurrent || self.isCurrentAccount(account) + ) + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + if viewModel.accounts.isEmpty, !viewModel.loading { + snapshot.appendItems([AccountSwitchItemID.empty], toSection: 0) + } else { + snapshot.appendItems(viewModel.accounts.map(\.id), toSection: 0) + } + return snapshot + } + + /// 应用 snapshot 刷新列表。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 更新ConfirmButton状态。 private func updateConfirmButton() { let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching confirmButton.isEnabled = enabled confirmButton.alpha = enabled ? 1 : 0.5 } + /// 加载Accounts数据。 private func loadAccounts(force: Bool = false) async { do { try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") { @@ -82,10 +153,12 @@ final class AccountSwitchViewController: UIViewController { return current.userId.isEmpty ? nil : current.userId } + /// 判断isCurrentAccount条件。 private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { viewModel.isCurrent(account, currentAccountId: currentAccountId) } + /// 点击confirm的处理逻辑。 @objc private func confirmTapped() { guard let account = viewModel.selectedAccount else { return } if account.isCurrent || isCurrentAccount(account) { @@ -95,6 +168,7 @@ final class AccountSwitchViewController: UIViewController { Task { await switchAccount(account) } } + /// switch账号相关逻辑。 private func switchAccount(_ account: AccountSwitchAccount) async { do { let response = try await appServices.globalLoading.withLoading(message: "切换中...") { @@ -121,53 +195,29 @@ final class AccountSwitchViewController: UIViewController { } } + /// non空相关逻辑。 private func nonEmpty(_ value: String?) -> String? { let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? nil : text } -} - -extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard !viewModel.accounts.isEmpty else { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView( - title: "暂无可切换账号", - message: "当前登录账号下没有其他可切换账号。", - systemImage: "person.crop.circle.badge.exclamationmark" - ) - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) } - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell - let account = viewModel.accounts[indexPath.row] - cell.configure( - account: account, - selected: viewModel.selectedAccountId == account.id, - isCurrent: account.isCurrent || isCurrentAccount(account) - ) - return cell - } + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard indexPath.row < viewModel.accounts.count else { return } - viewModel.select(viewModel.accounts[indexPath.row]) + guard let item = tableDataSource.itemIdentifier(for: indexPath), + item != AccountSwitchItemID.empty, + let account = viewModel.accounts.first(where: { $0.id == item }) else { return } + viewModel.select(account) } + /// UITableView 代理:行高。 func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - viewModel.accounts.isEmpty ? 280 : 92 + guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return 92 } + return item == AccountSwitchItemID.empty ? 280 : 92 } } +/// AccountSwitch列表或网格 Cell,负责单项内容展示。 private final class AccountSwitchCell: UITableViewCell { static let reuseID = "AccountSwitchCell" @@ -178,6 +228,7 @@ private final class AccountSwitchCell: UITableViewCell { private let tagLabel = UILabel() private let checkView = UIImageView() + /// 初始化实例。 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none @@ -245,6 +296,7 @@ private final class AccountSwitchCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) { let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines) titleLabel.text = title.isEmpty ? account.accountTypeLabel : title diff --git a/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift index 99043cf..aca90d2 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift @@ -7,8 +7,29 @@ import PhotosUI import SnapKit import UIKit +// MARK: - Diffable 标识 + +private typealias ProfileTableSection = Int +private typealias ProfileTableRow = String + +/// 个人信息页 section 索引。 +private enum ProfileSectionID { + static let info = 0 + static let logout = 1 +} + +/// 个人信息页行标识。 +private enum ProfileRowID { + static let name = "profile:name" + static let account = "profile:account" + static let phone = "profile:phone" + static let realName = "profile:realName" + static let settings = "profile:settings" + static let logout = "profile:logout" +} + /// 个人信息页,展示用户资料、账号状态和设置入口。 -final class ProfileViewController: UIViewController { +final class ProfileViewController: UIViewController, UITableViewDelegate { private let viewModel = ProfileViewModel() @@ -19,18 +40,22 @@ final class ProfileViewController: UIViewController { private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self table.delegate = self return table }() private lazy var refreshControl = UIRefreshControl() + /// Diffable 数据源,驱动个人信息列表刷新。 + private var tableDataSource: UITableViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "个人信息" view.backgroundColor = UIColor(hex: 0xF7FAFF) setupHeader() + configureTableDataSource() view.addSubview(tableView) tableView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(150) @@ -49,6 +74,74 @@ final class ProfileViewController: UIViewController { Task { await reloadProfile(showToast: false) } } + /// 配置 Diffable 数据源。 + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] (_: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in + guard let self else { return UITableViewCell() } + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + switch row { + case ProfileRowID.logout: + cell.textLabel?.text = "退出登录" + cell.textLabel?.textColor = UIColor(hex: 0xEF4444) + cell.textLabel?.textAlignment = .center + cell.selectionStyle = .default + case ProfileRowID.name: + cell.textLabel?.text = "姓名" + cell.detailTextLabel?.text = self.viewModel.displayRealName + cell.selectionStyle = .none + case ProfileRowID.account: + cell.textLabel?.text = "当前账号" + cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--" + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + case ProfileRowID.phone: + cell.textLabel?.text = "手机号" + cell.detailTextLabel?.text = self.viewModel.displayPhone + cell.selectionStyle = .none + case ProfileRowID.realName: + cell.textLabel?.text = "实名认证" + cell.detailTextLabel?.text = self.viewModel.realNameStatusText + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + case ProfileRowID.settings: + cell.textLabel?.text = "设置" + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + default: + break + } + return cell + } + applyTableSnapshot(animated: false) + } + + /// 构建 Diffable snapshot。 + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([ProfileSectionID.info, ProfileSectionID.logout]) + snapshot.appendItems([ + ProfileRowID.name, + ProfileRowID.account, + ProfileRowID.phone, + ProfileRowID.realName, + ProfileRowID.settings + ], toSection: ProfileSectionID.info) + snapshot.appendItems([ProfileRowID.logout], toSection: ProfileSectionID.logout) + return snapshot + } + + /// 应用 snapshot 刷新列表,必要时重配 Cell 内容。 + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 初始化Header相关 UI 或状态。 private func setupHeader() { let header = UIView() header.backgroundColor = .white @@ -101,6 +194,7 @@ final class ProfileViewController: UIViewController { } } + /// apply视图模型相关逻辑。 private func applyViewModel() { nicknameLabel.text = viewModel.displayNickname uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")" @@ -111,9 +205,10 @@ final class ProfileViewController: UIViewController { } editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal) editButton.isEnabled = !viewModel.isSaving - tableView.reloadData() + applyTableSnapshot(animated: false, reconfigure: true) } + /// 刷新Pulled展示。 @objc private func refreshPulled() { Task { await reloadProfile(showToast: true) @@ -121,6 +216,7 @@ final class ProfileViewController: UIViewController { } } + /// 刷新Profile。 private func reloadProfile(showToast: Bool) async { do { try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") { @@ -131,6 +227,7 @@ final class ProfileViewController: UIViewController { } } + /// 点击edit的处理逻辑。 @objc private func editTapped() { if viewModel.isEditingProfile { Task { await saveProfileEdits() } @@ -140,6 +237,7 @@ final class ProfileViewController: UIViewController { } } + /// 弹出NicknameEditor页面。 private func presentNicknameEditor() { let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert) alert.addTextField { [weak self] field in @@ -157,6 +255,7 @@ final class ProfileViewController: UIViewController { present(alert, animated: true) } + /// save个人中心Edits相关逻辑。 private func saveProfileEdits() async { guard let scenicId = appServices.accountContext.currentScenic?.id else { showToast("请先选择景区") @@ -176,6 +275,7 @@ final class ProfileViewController: UIViewController { } } + /// pick头像相关逻辑。 @objc private func pickAvatar() { var config = PHPickerConfiguration() config.filter = .images @@ -185,6 +285,7 @@ final class ProfileViewController: UIViewController { present(picker, animated: true) } + /// 点击logout的处理逻辑。 @objc private func logoutTapped() { let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) @@ -201,58 +302,19 @@ final class ProfileViewController: UIViewController { }) present(alert, animated: true) } -} - -extension ProfileViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { 2 } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 5 : 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - if indexPath.section == 1 { - cell.textLabel?.text = "退出登录" - cell.textLabel?.textColor = UIColor(hex: 0xEF4444) - cell.textLabel?.textAlignment = .center - return cell - } - switch indexPath.row { - case 0: - cell.textLabel?.text = "姓名" - cell.detailTextLabel?.text = viewModel.displayRealName - case 1: - cell.textLabel?.text = "当前账号" - cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--" - cell.accessoryType = .disclosureIndicator - case 2: - cell.textLabel?.text = "手机号" - cell.detailTextLabel?.text = viewModel.displayPhone - case 3: - cell.textLabel?.text = "实名认证" - cell.detailTextLabel?.text = viewModel.realNameStatusText - cell.accessoryType = .disclosureIndicator - default: - cell.textLabel?.text = "设置" - cell.accessoryType = .disclosureIndicator - } - cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default - return cell - } + /// UITableView 代理:处理行选中。 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 1 { + guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return } + switch row { + case ProfileRowID.logout: logoutTapped() - return - } - switch indexPath.row { - case 1: + case ProfileRowID.account: HomeMenuRouting.pushProfile(.accountSwitch, from: self) - case 3: + case ProfileRowID.realName: HomeMenuRouting.pushProfile(.realNameAuth, from: self) - case 4: + case ProfileRowID.settings: HomeMenuRouting.pushProfile(.settings, from: self) default: break @@ -261,6 +323,7 @@ extension ProfileViewController: UITableViewDataSource, UITableViewDelegate { } extension ProfileViewController: PHPickerViewControllerDelegate { + /// picker 业务逻辑。 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } diff --git a/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift index 5b16955..f1200e6 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift @@ -25,6 +25,7 @@ final class RealNameAuthViewController: UIViewController { private let startDatePicker = UIDatePicker() private let endDatePicker = UIDatePicker() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "实名认证" @@ -34,6 +35,7 @@ final class RealNameAuthViewController: UIViewController { Task { await loadInfo() } } + /// 初始化Form相关 UI 或状态。 private func setupForm() { contentStack.axis = .vertical contentStack.spacing = 16 @@ -107,6 +109,7 @@ final class RealNameAuthViewController: UIViewController { contentStack.addArrangedSubview(submitButton) } + /// 创建SectionTitle实例。 private func makeSectionTitle(_ text: String) -> UILabel { let label = UILabel() label.text = text @@ -114,6 +117,7 @@ final class RealNameAuthViewController: UIViewController { return label } + /// labeled字段相关逻辑。 private func labeledField(_ title: String, _ field: UITextField) -> UIStackView { let label = UILabel() label.text = title @@ -125,6 +129,7 @@ final class RealNameAuthViewController: UIViewController { return stack } + /// apply视图模型相关逻辑。 private func applyViewModel() { realNameField.text = viewModel.realName idCardField.text = viewModel.idCardNo @@ -163,6 +168,7 @@ final class RealNameAuthViewController: UIViewController { } } + /// 加载Info数据。 private func loadInfo() async { do { try await appServices.globalLoading.withLoading(message: "加载中...") { @@ -173,10 +179,12 @@ final class RealNameAuthViewController: UIViewController { } } + /// longValid变更相关逻辑。 @objc private func longValidChanged() { viewModel.isLongValid = longValidSwitch.isOn } + /// 点击send验证码的处理逻辑。 @objc private func sendCodeTapped() { Task { do { @@ -187,6 +195,7 @@ final class RealNameAuthViewController: UIViewController { } } + /// 提交Tapped。 @objc private func submitTapped() { viewModel.realName = realNameField.text ?? "" viewModel.idCardNo = idCardField.text ?? "" @@ -215,16 +224,19 @@ final class RealNameAuthViewController: UIViewController { private var pendingSide: RealNameImageSide = .front + /// pickFront相关逻辑。 @objc private func pickFront() { pendingSide = .front presentImagePicker() } + /// pickBack相关逻辑。 @objc private func pickBack() { pendingSide = .back presentImagePicker() } + /// 弹出ImagePicker页面。 private func presentImagePicker() { var config = PHPickerConfiguration() config.filter = .images @@ -236,6 +248,7 @@ final class RealNameAuthViewController: UIViewController { } extension RealNameAuthViewController: PHPickerViewControllerDelegate { + /// picker 业务逻辑。 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } @@ -253,6 +266,7 @@ extension RealNameAuthViewController: PHPickerViewControllerDelegate { } private extension UILabel { + /// 初始化实例。 convenience init(text: String) { self.init() self.text = text diff --git a/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift index 6082e19..4074096 100644 --- a/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift +++ b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift @@ -7,64 +7,81 @@ import SnapKit import UIKit import WebKit +// MARK: - Diffable 标识 + +/// 设置页行标识。 +private enum SettingsRowID { + static let about = "settings:about" + static let version = "settings:version" + static let download = "settings:download" + static let userAgreement = "settings:userAgreement" + static let privacyPolicy = "settings:privacyPolicy" +} + +/// 设置页行操作类型。 +private enum SettingsRowAction { + case agreement(AgreementPage) + case version + case download +} + /// 设置中心页面。 -final class SettingsViewController: UIViewController { +final class SettingsViewController: SimpleTableDiffableViewController { private var copiedDownloadLink = false - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .insetGrouped) - table.dataSource = self - table.delegate = self - return table - }() - - private let rows: [(title: String, action: SettingsRowAction)] = [ - ("关于我们", .agreement(.about)), - ("系统版本", .version), - ("App下载", .download), - ("用户协议", .agreement(.userAgreement)), - ("隐私政策", .agreement(.privacyPolicy)) + private let rowActions: [String: SettingsRowAction] = [ + SettingsRowID.about: .agreement(.about), + SettingsRowID.version: .version, + SettingsRowID.download: .download, + SettingsRowID.userAgreement: .agreement(.userAgreement), + SettingsRowID.privacyPolicy: .agreement(.privacyPolicy) ] - private enum SettingsRowAction { - case agreement(AgreementPage) - case version - case download - } + private let rowTitles: [String: String] = [ + SettingsRowID.about: "关于我们", + SettingsRowID.version: "系统版本", + SettingsRowID.download: "App下载", + SettingsRowID.userAgreement: "用户协议", + SettingsRowID.privacyPolicy: "隐私政策" + ] + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "设置" - view.addSubview(tableView) - tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } } - private var downloadLink: String { - APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString + /// 构建 Diffable snapshot。 + override func buildSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems([ + SettingsRowID.about, + SettingsRowID.version, + SettingsRowID.download, + SettingsRowID.userAgreement, + SettingsRowID.privacyPolicy + ], toSection: 0) + return snapshot } - private func copyDownloadLink() { - UIPasteboard.general.string = downloadLink - copiedDownloadLink = true - showToast("下载链接已复制") - tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.copiedDownloadLink = false - self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none) - } + /// section 页脚文案。 + override func sectionFooter(for section: SimpleTableSection) -> String? { + "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" } -} -extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) - let row = rows[indexPath.row] - cell.textLabel?.text = row.title + /// 配置 Cell 展示内容。 + override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) { + cell.textLabel?.text = rowTitles[row] cell.textLabel?.textColor = UIColor(hex: 0x4B5563) - switch row.action { + cell.detailTextLabel?.text = nil + cell.detailTextLabel?.textColor = nil + cell.accessoryType = .none + cell.selectionStyle = .default + + guard let action = rowActions[row] else { return } + switch action { case .version: cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText() cell.selectionStyle = .none @@ -74,12 +91,12 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { case .agreement: cell.accessoryType = .disclosureIndicator } - return cell } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - switch rows[indexPath.row].action { + /// 处理行选中。 + override func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) { + guard let action = rowActions[row] else { return } + switch action { case .agreement(let page): HomeMenuRouting.pushProfile(.agreement(page), from: self) case .download: @@ -89,8 +106,25 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { } } - func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" + private var downloadLink: String { + APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString + } + + /// 复制下载链接并刷新对应行展示。 + private func copyDownloadLink() { + UIPasteboard.general.string = downloadLink + copiedDownloadLink = true + showToast("下载链接已复制") + var snapshot = tableDataSource.snapshot() + snapshot.reconfigureItems([SettingsRowID.download]) + tableDataSource.apply(snapshot, animatingDifferences: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + self.copiedDownloadLink = false + var snapshot = self.tableDataSource.snapshot() + snapshot.reconfigureItems([SettingsRowID.download]) + self.tableDataSource.apply(snapshot, animatingDifferences: false) + } } } @@ -101,6 +135,7 @@ final class AgreementViewController: UIViewController { private let webView = WKWebView(frame: .zero) private let loadingIndicator = UIActivityIndicatorView(style: .large) + /// 初始化实例。 init(page: AgreementPage) { self.page = page super.init(nibName: nil, bundle: nil) @@ -109,6 +144,7 @@ final class AgreementViewController: UIViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = page.title @@ -124,10 +160,12 @@ final class AgreementViewController: UIViewController { } extension AgreementViewController: WKNavigationDelegate { + /// web视图相关逻辑。 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { loadingIndicator.stopAnimating() } + /// web视图相关逻辑。 func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { loadingIndicator.stopAnimating() showToast(error.localizedDescription) diff --git a/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift b/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift index 453973a..80a786b 100644 --- a/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift +++ b/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class ProjectManagementViewController: ModuleTableViewController { private let viewModel = ProjectManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "项目管理" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,13 +24,16 @@ final class ProjectManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)") } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] navigationController?.pushViewController( @@ -38,10 +42,12 @@ final class ProjectManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId) } + /// 创建项目。 @objc private func createProject() { navigationController?.pushViewController( ProjectEditorViewController(projectId: nil), @@ -56,19 +62,23 @@ extension ProjectManagementViewModel: ViewModelBindable {} final class StoreProjectManagementViewController: ModuleTableViewController { private let viewModel = StoreProjectManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "店铺项目" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.filteredItems.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)") } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] navigationController?.pushViewController( @@ -77,6 +87,7 @@ final class StoreProjectManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.projectAPI, userId: services.userId) } @@ -91,6 +102,7 @@ final class ProjectDetailViewController: ModuleTableViewController { private let photographerViewModel = ProjectManagementViewModel() private let storeViewModel = StoreProjectManagementViewModel() + /// 初始化实例。 init(projectId: Int, storeMode: Bool) { self.projectId = projectId self.storeMode = storeMode @@ -102,6 +114,7 @@ final class ProjectDetailViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "项目详情" super.viewDidLoad() @@ -116,10 +129,12 @@ final class ProjectDetailViewController: ModuleTableViewController { storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { detail == nil ? 0 : 4 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { guard let detail else { return } switch indexPath.row { @@ -130,6 +145,7 @@ final class ProjectDetailViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { if storeMode { await storeViewModel.loadDetail(id: projectId, api: services.projectAPI) @@ -147,6 +163,7 @@ final class ProjectEditorViewController: ModuleTableViewController { private let priceField = UITextField() private let projectId: Int? + /// 初始化实例。 init(projectId: Int?) { self.projectId = projectId super.init(nibName: nil, bundle: nil) @@ -157,6 +174,7 @@ final class ProjectEditorViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = projectId == nil ? "新建项目" : "编辑项目" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -170,6 +188,7 @@ final class ProjectEditorViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { nameField.placeholder = "项目名称" nameField.borderStyle = .roundedRect @@ -187,8 +206,10 @@ final class ProjectEditorViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { 0 } + /// 刷新Content。 override func reloadContent() async { guard let projectId else { return } if let detail = try? await services.projectAPI.projectDetail(id: projectId) { @@ -197,12 +218,14 @@ final class ProjectEditorViewController: ModuleTableViewController { } } + /// 按需fill表单。 private func fillFormIfNeeded() { if nameField.text?.isEmpty != false { nameField.text = viewModel.name } if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText } if priceField.text?.isEmpty != false { priceField.text = viewModel.price } } + /// save 业务逻辑。 @objc private func save() { viewModel.name = nameField.text ?? "" viewModel.descriptionText = descriptionField.text ?? "" @@ -231,6 +254,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController { private let viewModel = StoreProjectEditorViewModel(mode: .create) private let nameField = UITextField() + /// 初始化实例。 init(projectId: Int?) { super.init(nibName: nil, bundle: nil) } @@ -240,6 +264,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "店铺项目" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -256,18 +281,22 @@ final class StoreProjectEditorViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.scenicList.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let scenic = viewModel.scenicList[indexPath.row] cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil) } + /// 刷新Content。 override func reloadContent() async { guard let userId = Int(services.userId ?? "") else { return } await viewModel.loadScenicList(api: services.projectAPI, userId: userId) } + /// save 业务逻辑。 @objc private func save() { viewModel.name = nameField.text ?? "" Task { diff --git a/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift b/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift index d2cabf0..d0e2e90 100644 --- a/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift +++ b/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift @@ -130,6 +130,7 @@ final class ProjectManagementViewModel { return value.isEmpty ? nil : value } + /// 重置List状态。 private func resetList() { items = [] loading = false @@ -139,6 +140,7 @@ final class ProjectManagementViewModel { total = 0 } + /// sorted 业务逻辑。 private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { values.sorted { lhs, rhs in if lhs.status != rhs.status { return lhs.status < rhs.status } @@ -146,6 +148,7 @@ final class ProjectManagementViewModel { } } + /// deduplicated 业务逻辑。 private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { var result: [Int: PhotographerProjectItem] = [:] values.forEach { result[$0.id] = $0 } @@ -278,6 +281,7 @@ final class ProjectEditorViewModel { return labels.isEmpty ? nil : labels } + /// resolveCover链接相关逻辑。 private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { if let coverImage { return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in @@ -287,6 +291,7 @@ final class ProjectEditorViewModel { return existingCoverURL } + /// resolveCarouselURLs相关逻辑。 private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { var urls = existingCarouselURLs for image in carouselImages { @@ -298,11 +303,13 @@ final class ProjectEditorViewModel { return urls } + /// 规范化dMoney格式。 private func normalizedMoney(_ value: String) -> String? { let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 return amount > 0 ? String(format: "%.2f", amount) : nil } + /// 记录校验失败并返回 false。 private func fail(_ error: ProjectEditorError) -> Bool { errorMessage = error.localizedDescription return false @@ -511,6 +518,7 @@ final class StoreProjectEditorViewModel { return nil } + /// resolveCover链接相关逻辑。 private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { if let coverImage { return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in } @@ -518,6 +526,7 @@ final class StoreProjectEditorViewModel { return existingCoverURL } + /// resolveCarouselURLs相关逻辑。 private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { var urls = existingCarouselURLs for image in carouselImages { @@ -527,6 +536,7 @@ final class StoreProjectEditorViewModel { return urls } + /// 提交MultiPointProject。 private func submitMultiPointProject( userId: Int, api: any ProjectServing, @@ -586,6 +596,7 @@ final class StoreProjectEditorViewModel { } } + /// 提交OfflineProject。 private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws { let scenicId = selectedScenicIds.first ?? 0 let storeId = selectedStoreId ?? 0 @@ -617,6 +628,7 @@ final class StoreProjectEditorViewModel { } } + /// 记录校验失败并返回 false。 private func fail(_ error: ProjectEditorError) -> Bool { errorMessage = error.localizedDescription return false diff --git a/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift b/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift index 8ad66e6..5fad5fd 100644 --- a/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift +++ b/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift @@ -36,6 +36,7 @@ struct PunchPointRegion: Codable, Hashable { var address: String var scenicSpotStr: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case lat case lot @@ -80,6 +81,7 @@ struct PunchPointItem: Decodable, Hashable, Identifiable { let auditTime: String let auditRemark: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicAreaId = "scenic_area_id" @@ -167,6 +169,7 @@ struct AddPunchPointRequest: Encodable, Equatable { let scenicSpotStr: String let guideImages: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicAreaId = "scenic_area_id" case name @@ -187,6 +190,7 @@ struct EditPunchPointRequest: Encodable, Equatable { let scenicSpotStr: String let guideImages: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicAreaId = "scenic_area_id" diff --git a/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift index bdff60d..80a2931 100644 --- a/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift +++ b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift @@ -5,6 +5,7 @@ // Created by Codex on 2026/6/26. // +import CoreLocation import SnapKit import UIKit @@ -18,6 +19,7 @@ private extension PunchPointItem { final class PunchPointListViewController: ModuleTableViewController { private let viewModel = PunchPointListViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "打卡点" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -30,13 +32,16 @@ final class PunchPointListViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] navigationController?.pushViewController( @@ -45,10 +50,12 @@ final class PunchPointListViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI) } + /// 创建打卡Point。 @objc private func createPunchPoint() { navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true) } @@ -62,6 +69,7 @@ final class PunchPointDetailViewController: ModuleTableViewController { private let summary: PunchPointItem? private let viewModel = PunchPointListViewModel() + /// 初始化实例。 init(punchPointId: Int, summary: PunchPointItem?) { self.punchPointId = punchPointId self.summary = summary @@ -73,6 +81,7 @@ final class PunchPointDetailViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = summary?.name ?? "打卡点详情" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -88,11 +97,13 @@ final class PunchPointDetailViewController: ModuleTableViewController { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { guard viewModel.selectedDetail != nil || summary != nil else { return 0 } return 4 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let detail = viewModel.selectedDetail ?? summary guard let detail else { return } @@ -104,10 +115,12 @@ final class PunchPointDetailViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI) } + /// 展示QR。 @objc private func showQR() { guard let detail = viewModel.selectedDetail ?? summary else { return } navigationController?.pushViewController( @@ -118,10 +131,17 @@ final class PunchPointDetailViewController: ModuleTableViewController { } /// 打卡点编辑页。 -final class PunchPointEditorViewController: ModuleTableViewController { +final class PunchPointEditorViewController: UIViewController { private let viewModel = PunchPointEditorViewModel() - private let nameField = UITextField() private let punchPointId: Int? + private let nameField = UITextField() + private let addressField = UITextField() + private let latitudeField = UITextField() + private let longitudeField = UITextField() + private let mapPicker = PunchPointMapPickerView() + private let locationProvider = ForegroundLocationProvider() + + private var services: AppServices { AppServices.shared } init(punchPointId: Int?) { self.punchPointId = punchPointId @@ -134,48 +154,126 @@ final class PunchPointEditorViewController: ModuleTableViewController { } override func viewDidLoad() { + super.viewDidLoad() title = punchPointId == nil ? "新建打卡点" : "编辑打卡点" + view.backgroundColor = UIColor(hex: 0xF5F7FA) navigationItem.rightBarButtonItem = UIBarButtonItem( title: "保存", style: .done, target: self, action: #selector(save) ) - super.viewDidLoad() - nameField.placeholder = "打卡点名称" - nameField.borderStyle = .roundedRect - nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) - tableView.tableHeaderView = nameField - wireViewModel(viewModel) { [weak self] in - if self?.nameField.text?.isEmpty != false { - self?.nameField.text = self?.viewModel.name + + configureFields() + layoutForm() + wireViewModel() + + mapPicker.onLocationPicked = { [weak self] lat, lng, address in + self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address) + self?.syncFieldsFromViewModel() + } + + Task { + guard let punchPointId else { return } + if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) { + viewModel.apply(detail) + syncFieldsFromViewModel() } } } - override func tableRowCount() -> Int { 1 } - - override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { - cell.configure(title: "地址", subtitle: viewModel.address) + private func configureFields() { + [nameField, addressField, latitudeField, longitudeField].forEach { + $0.borderStyle = .roundedRect + $0.font = .systemFont(ofSize: AppMetrics.FontSize.body) + } + nameField.placeholder = "打卡点名称" + addressField.placeholder = "地址" + latitudeField.placeholder = "纬度" + longitudeField.placeholder = "经度" + latitudeField.keyboardType = .decimalPad + longitudeField.keyboardType = .decimalPad } - override func reloadContent() async { - guard let punchPointId else { return } - if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) { - viewModel.apply(detail) - nameField.text = viewModel.name + private func layoutForm() { + let locateButton = UIButton(type: .system) + locateButton.setTitle("使用当前位置", for: .normal) + locateButton.addTarget(self, action: #selector(useCurrentLocation), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [ + nameField, + mapPicker, + addressField, + latitudeField, + longitudeField, + locateButton + ]) + stack.axis = .vertical + stack.spacing = AppMetrics.Spacing.small + + view.addSubview(stack) + nameField.snp.makeConstraints { make in make.height.equalTo(44) } + mapPicker.snp.makeConstraints { make in make.height.equalTo(220) } + addressField.snp.makeConstraints { make in make.height.equalTo(44) } + latitudeField.snp.makeConstraints { make in make.height.equalTo(44) } + longitudeField.snp.makeConstraints { make in make.height.equalTo(44) } + locateButton.snp.makeConstraints { make in make.height.equalTo(44) } + + stack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.medium) + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + } + } + + private func wireViewModel() { + viewModel.onChange = { [weak self] in + self?.syncFieldsFromViewModel() + } + } + + private func syncFieldsFromViewModel() { + if nameField.text?.isEmpty != false { nameField.text = viewModel.name } + addressField.text = viewModel.address + latitudeField.text = viewModel.latitudeText + longitudeField.text = viewModel.longitudeText + if let lat = Double(viewModel.latitudeText), let lng = Double(viewModel.longitudeText) { + mapPicker.setCoordinate(latitude: lat, longitude: lng) + } + } + + @objc private func useCurrentLocation() { + Task { + do { + let result = try await locationProvider.requestCurrentLocation() + viewModel.applyLocation( + latitude: result.latitude, + longitude: result.longitude, + address: result.address + ) + syncFieldsFromViewModel() + } catch { + services.toastCenter.show("定位失败:\(error.localizedDescription)") + } } } @objc private func save() { viewModel.name = nameField.text ?? "" + viewModel.address = addressField.text ?? "" + viewModel.latitudeText = latitudeField.text ?? "" + viewModel.longitudeText = longitudeField.text ?? "" Task { let success = await viewModel.submit( scenicId: services.currentScenicId, api: services.punchPointAPI, uploadService: services.ossUploadService ) - if success { navigationController?.popViewController(animated: true) } + if success { + services.toastCenter.show("保存成功") + navigationController?.popViewController(animated: true) + } else if let message = viewModel.errorMessage { + services.toastCenter.show(message) + } } } } @@ -187,6 +285,7 @@ final class PunchPointQRViewController: UIViewController { private let pageTitle: String private let qrURL: String + /// 初始化实例。 init(title: String, qrURL: String) { pageTitle = title self.qrURL = qrURL @@ -198,6 +297,7 @@ final class PunchPointQRViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = pageTitle diff --git a/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift b/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift index a1789a9..2333503 100644 --- a/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift +++ b/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift @@ -10,17 +10,29 @@ import Foundation /// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。 @MainActor protocol ScenicQueueServing: AnyObject { + /// scenic排队统计相关逻辑。 func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData + /// scenic排队Home相关逻辑。 func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData + /// socket令牌相关逻辑。 func socketToken() async throws -> SocketTokenResponse + /// scenic排队Call相关逻辑。 func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData + /// scenic排队Pass相关逻辑。 func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData + /// scenic排队Finish相关逻辑。 func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData + /// scenic排队RequeueInsertBefore相关逻辑。 func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws + /// scenic排队UserMark相关逻辑。 func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws + /// scenic排队Setting相关逻辑。 func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData + /// scenic排队保存Setting相关逻辑。 func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws + /// scenic排队Shoot排队QRCode相关逻辑。 func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData + /// scenic排队SettingChangeLog相关逻辑。 func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData } diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift index 0da1016..3104519 100644 --- a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift @@ -13,6 +13,7 @@ struct ScenicQueueStatsData: Decodable, Equatable { let avgWaitMin: Double let time: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case queueCount = "queue_count" case avgWaitMin = "avg_wait_min" @@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable { let list: ScenicQueueHomeListBlock? let time: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case stats case list @@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable { struct ScenicQueueHomeStats: Decodable, Equatable { let type: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case type } @@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable { let page: Int let pageSize: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case list case total @@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable { isMissRequeue == 1 } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case queueCode = "queue_code" @@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable { case missRequeueText = "is_miss_requeue_text" } + /// 枚举,定义相关常量或状态。 enum AlternateCodingKeys: String, CodingKey { case queueNo = "queue_no" case queueNumber = "queue_number" @@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable { missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines) } + /// 格式化排队时间。 private static func formatQueueTime(_ raw: String) -> String { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return "--" } @@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable { struct SocketTokenResponse: Decodable, Equatable { let socketToken: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case socketToken = "socket_token" } @@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable { let statusText: String let calledAt: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case status @@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable { let recordId: Int64 let operatorId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case recordId = "record_id" case operatorId = "operator_id" @@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable { let queueBanDays: Int? let operatorId: Int? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case uid case scenicId = "scenic_id" @@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable { } private extension KeyedDecodingContainer { + /// 解码宽松字符串。 func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value @@ -449,6 +461,7 @@ private extension KeyedDecodingContainer { return "" } + /// 解码宽松整数。 func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value @@ -467,6 +480,7 @@ private extension KeyedDecodingContainer { return nil } + /// 解码宽松Double。 func decodeLossyDouble(forKey key: Key) throws -> Double? { if let value = try? decodeIfPresent(Double.self, forKey: key) { return value @@ -484,6 +498,7 @@ private extension KeyedDecodingContainer { } private extension String { + /// non空或默认相关逻辑。 func nonEmptyOrDefault(_ fallback: String) -> String { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? fallback : text diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift index 2bfac30..70e153a 100644 --- a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift @@ -13,6 +13,7 @@ struct ScenicQueueSettingData: Decodable, Equatable { let setting: ScenicQueueSettingItem? let time: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case exists case setting @@ -64,6 +65,7 @@ struct ScenicQueueSettingItem: Decodable, Equatable { let createdAt: String let updatedAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicId = "scenic_id" @@ -131,6 +133,7 @@ struct ScenicQueueVoiceBroadcastItem: Codable, Identifiable, Equatable { let content: String let sortOrder: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case content case sortOrder = "sort_order" @@ -175,6 +178,7 @@ struct ScenicQueueSaveSettingRequest: Encodable, Equatable { let status: Int let remark: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case scenicSpotId = "scenic_spot_id" @@ -208,6 +212,7 @@ struct ScenicQueueSettingChangeLogData: Decodable, Equatable { let pageSize: Int let list: [ScenicQueueSettingChangeLogItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case total case page @@ -243,6 +248,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable { let displayText: String let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case scenicSpotName = "scenic_spot_name" @@ -270,6 +276,7 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable { struct ScenicQueueShootQueueQRCodeData: Decodable, Equatable { let qrcodeUrl: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case qrcodeUrl = "qrcode_url" } @@ -309,6 +316,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable { var businessStartTime: String = "10:00" var businessEndTime: String = "20:00" + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case shootMinute = "shoot_minute" case shootSecond = "shoot_second" @@ -334,6 +342,7 @@ struct ScenicQueueSettingsSnapshot: Codable, Equatable { } private extension KeyedDecodingContainer { + /// 解码宽松字符串。 func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } @@ -342,6 +351,7 @@ private extension KeyedDecodingContainer { return "" } + /// 解码宽松整数。 func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } @@ -354,6 +364,7 @@ private extension KeyedDecodingContainer { return nil } + /// 解码宽松布尔。 func decodeLossyBool(forKey key: Key) throws -> Bool? { if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 } diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift index 792e58a..f32522a 100644 --- a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift @@ -155,17 +155,20 @@ enum ScenicQueueSettingsStore { UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)") } + /// 规范化dUserId格式。 private static func normalizedUserId(_ userId: String?) -> String { let text = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? "anonymous" : text } + /// positive整数相关逻辑。 private static func positiveInt(forKey key: String) -> Int? { guard UserDefaults.standard.object(forKey: key) != nil else { return nil } let value = UserDefaults.standard.integer(forKey: key) return value > 0 ? value : nil } + /// legacy正数整数相关逻辑。 private static func legacyPositiveInt(forKey key: String) -> Int? { positiveInt(forKey: key) } diff --git a/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift b/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift index e002d52..222e0cc 100644 --- a/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift +++ b/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class QueueManagementViewController: ModuleTableViewController { private let viewModel = QueueManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "排队管理" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,13 +24,16 @@ final class QueueManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateTitle() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure(title: item.phoneMasked, subtitle: item.statusText, detail: item.queueCode) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload( api: services.scenicQueueAPI, @@ -40,10 +44,12 @@ final class QueueManagementViewController: ModuleTableViewController { updateTitle() } + /// 更新Title状态。 private func updateTitle() { title = "排队 \(viewModel.queueCount)" } + /// open设置相关逻辑。 @objc private func openSettings() { navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) } @@ -55,14 +61,17 @@ extension QueueManagementViewModel: ViewModelBindable {} final class ScenicQueueSettingsViewController: ModuleTableViewController { private let viewModel = ScenicQueueSettingsViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "排队设置" super.viewDidLoad() wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.scenicSpots.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let spot = viewModel.scenicSpots[indexPath.row] let selected = viewModel.selectedSpotId == spot.id @@ -70,10 +79,12 @@ final class ScenicQueueSettingsViewController: ModuleTableViewController { cell.accessoryType = selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.selectedSpotId = viewModel.scenicSpots[indexPath.row].id } + /// 刷新Content。 override func reloadContent() async { await viewModel.load( api: services.scenicQueueAPI, diff --git a/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift b/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift index 4a1c696..d767ee2 100644 --- a/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift +++ b/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift @@ -209,6 +209,7 @@ final class QueueManagementViewModel { return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0) } + /// 处理SocketMessage相关事件。 private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async { guard message.isScenicQueueEvent, let params = message.data?.params, @@ -227,6 +228,7 @@ final class QueueManagementViewModel { } } + /// 处理RemoteTicketCalled相关事件。 private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async { guard let recordId = params.recordId, recordId > 0 else { return } if let operatorUid = params.operatorUid, @@ -251,6 +253,7 @@ final class QueueManagementViewModel { await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) } + /// 加载QueueingTickets数据。 private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] { let home = try await api.scenicQueueHome( scenicId: scenicId, @@ -262,6 +265,7 @@ final class QueueManagementViewModel { return home.list?.list ?? [] } + /// consume待处理远程CalledTickets相关逻辑。 private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) { guard let ticket = tickets.first(where: { pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号")) @@ -272,6 +276,7 @@ final class QueueManagementViewModel { remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode) } + /// 加载Page数据。 private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws { guard let scenicSpotId = selectedSpotId else { return } let home = try await api.scenicQueueHome( @@ -300,6 +305,7 @@ final class QueueManagementViewModel { refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId) } + /// mark排队Called相关逻辑。 private func markQueueCalled(id: Int64, statusText: String) { let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines) items = items.map { item in @@ -330,6 +336,7 @@ final class QueueManagementViewModel { } } + /// 重置状态。 private func reset() { stopRealtime() loading = false @@ -343,6 +350,7 @@ final class QueueManagementViewModel { clearQueueData() } + /// 清空排队数据。 private func clearQueueData() { loadingMore = false items = [] @@ -354,6 +362,7 @@ final class QueueManagementViewModel { lastSyncTimeText = "--" } + /// 刷新ShootingCallConfig展示。 private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) { let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot() showStartShootingButton = snapshot.showStartShootingButton @@ -362,6 +371,7 @@ final class QueueManagementViewModel { prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled } + /// 同步QueueSettingFromServerIfMatchesLocal状态。 private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async { guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId), data.exists, @@ -648,6 +658,7 @@ final class ScenicQueueSettingsViewModel { presetVoices.remove(at: index) } + /// 重置SettingsState状态。 private func resetSettingsState() { loading = false scenicSpots = [] @@ -678,6 +689,7 @@ final class ScenicQueueSettingsViewModel { qrcodeURL = "" } + /// apply 业务逻辑。 private func apply(_ setting: ScenicQueueSettingItem?) { guard let setting else { return } if let spotId = setting.scenicSpotId { selectedSpotId = spotId } @@ -707,6 +719,7 @@ final class ScenicQueueSettingsViewModel { if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) } } + /// apply 业务逻辑。 private func apply(_ snapshot: ScenicQueueSettingsSnapshot) { photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))" photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))" @@ -730,6 +743,7 @@ final class ScenicQueueSettingsViewModel { businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0)) } + /// 规范化dMaxQueueRangeForDisplay格式。 private func normalizedMaxQueueRangeForDisplay() throws -> String { let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm) guard !normalized.isEmpty, @@ -745,12 +759,14 @@ final class ScenicQueueSettingsViewModel { return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue) } + /// queueDistanceMeter用于提交相关逻辑。 private func queueDistanceMeterForSubmit() throws -> Int { _ = try normalizedMaxQueueRangeForDisplay() let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0 return NSDecimalNumber(decimal: value * Decimal(1000)).intValue } + /// 加载QRCodeImage数据。 private func loadQRCodeImage(from text: String) async throws -> UIImage { if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) { let (data, _) = try await URLSession.shared.data(from: url) @@ -760,6 +776,7 @@ final class ScenicQueueSettingsViewModel { throw APIError.networkFailed("二维码图片生成失败") } + /// generateQRCode相关逻辑。 private static func generateQRCode(from string: String) -> UIImage? { let filter = CIFilter.qrCodeGenerator() filter.message = Data(string.utf8) @@ -770,12 +787,14 @@ final class ScenicQueueSettingsViewModel { return UIImage(cgImage: cgImage) } + /// saveTiming快照相关逻辑。 private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) { UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey) UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey) UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey) } + /// saveShootingCall设置相关逻辑。 private func saveShootingCallSettings(autoCallAhead: Int) { UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey) UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey) @@ -783,10 +802,12 @@ final class ScenicQueueSettingsViewModel { UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey) } + /// save自定义文本Locally相关逻辑。 private func saveCustomTextLocally() { ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId) } + /// voiceBroadcasts用于保存载荷相关逻辑。 private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] { Array(presetVoices .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -796,6 +817,7 @@ final class ScenicQueueSettingsViewModel { .map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) }) } + /// 创建Snapshot实例。 private func makeSnapshot( min: Int? = nil, sec: Int? = nil, @@ -832,18 +854,22 @@ final class ScenicQueueSettingsViewModel { ) } + /// coerced播报间隔相关逻辑。 static func coercedBroadcastInterval(_ raw: Int) -> Int { (40...60).contains(raw) ? raw : 50 } + /// coerced倒计时阈值相关逻辑。 static func coercedCountdownThreshold(_ raw: Int) -> Int { (10...30).contains(raw) ? raw : 15 } + /// business时间相关逻辑。 static func businessTime(hour: Int, minute: Int) -> Date { Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date() } + /// 解析BusinessTime数据。 static func parseBusinessTime(_ raw: String, fallback: Date) -> Date { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return fallback } @@ -856,6 +882,7 @@ final class ScenicQueueSettingsViewModel { return fallback } + /// business时间载荷相关逻辑。 static func businessTimePayload(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -863,6 +890,7 @@ final class ScenicQueueSettingsViewModel { return formatter.string(from: date) } + /// queueDistanceDisplay相关逻辑。 private static func queueDistanceDisplay(meters: Int) -> String { let clamped = min(max(meters, 0), 9_999_990) return String(format: "%.2f", Double(clamped) / 1000) @@ -875,6 +903,7 @@ private extension String { return text.isEmpty ? nil : text } + /// non空或默认相关逻辑。 func nonEmptyOrDefault(_ fallback: String) -> String { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? fallback : text diff --git a/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift b/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift index 755928d..3538f87 100644 --- a/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift +++ b/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift @@ -14,6 +14,7 @@ struct ScenicAreaNode: Decodable, Equatable, Identifiable { let name: String let children: [ScenicAreaNode] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -44,6 +45,7 @@ struct ScenicAreaNode: Decodable, Equatable, Identifiable { struct ScenicApplicationPendingsResponse: Decodable, Equatable { let items: [ScenicApplicationPendingResponse] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case items case list @@ -83,6 +85,7 @@ struct ScenicApplicationPendingResponse: Decodable, Equatable, Identifiable { let auditNote: String? let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -171,6 +174,7 @@ struct ScenicApplicationSubmitRequest: Encodable, Equatable { let remark: String let scenicId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicName = "scenic_name" case scenicImages = "scenic_images" @@ -196,6 +200,7 @@ struct RoleApplyPendingResponse: Decodable, Equatable, Hashable, Identifiable { let auditedAt: String? let auditNote: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -259,6 +264,7 @@ struct RoleApplyScenicItem: Decodable, Equatable, Hashable, Identifiable { let id: Int let name: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case name @@ -283,6 +289,7 @@ struct RoleApplySubmitRequest: Encodable, Equatable { let scenicId: [Int] let roleId: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case roleId = "role_id" @@ -295,6 +302,7 @@ struct ScenicApplicationUploadPlaceholder: Encodable, Equatable { let fileType: String let fileSize: Int64 + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case fileName = "file_name" case fileType = "file_type" diff --git a/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift b/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift index c939f43..912c92f 100644 --- a/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift +++ b/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift @@ -5,20 +5,44 @@ // Created by Codex on 2026/6/26. // +import CoreLocation import UIKit /// 景区选择页。 final class ScenicSelectionViewController: ModuleTableViewController { private let viewModel = ScenicSelectionViewModel() private let searchBar = UISearchBar() + private let locationProvider = ScenicSelectionLocationProvider() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "选择景区" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "定位", + style: .plain, + target: self, + action: #selector(requestLocation) + ) super.viewDidLoad() setupSearchBar() wireViewModel(viewModel) { } } + @objc private func requestLocation() { + viewModel.currentLocationText = "定位中..." + locationProvider.onLocation = { [weak self] location in + self?.viewModel.applyCurrentLocation( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + } + locationProvider.onFailure = { [weak self] message in + self?.services.toastCenter.show(message) + } + locationProvider.request() + } + + /// 初始化SearchBar相关 UI 或状态。 private func setupSearchBar() { searchBar.placeholder = "搜索景区" searchBar.delegate = self @@ -26,15 +50,18 @@ final class ScenicSelectionViewController: ModuleTableViewController { tableView.tableHeaderView = searchBar } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.filteredItems.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] cell.configure(title: item.name, subtitle: item.address, detail: item.distanceText) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.filteredItems[indexPath.row] viewModel.select( @@ -46,6 +73,7 @@ final class ScenicSelectionViewController: ModuleTableViewController { navigationController?.popViewController(animated: true) } + /// 刷新Content。 override func reloadContent() async { viewModel.reload(from: services.accountContext) } @@ -54,6 +82,7 @@ final class ScenicSelectionViewController: ModuleTableViewController { extension ScenicSelectionViewModel: ViewModelBindable {} extension ScenicSelectionViewController: UISearchBarDelegate { + /// searchBar相关逻辑。 func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { viewModel.searchQuery = searchText } @@ -63,6 +92,7 @@ extension ScenicSelectionViewController: UISearchBarDelegate { final class PermissionApplyViewController: ModuleTableViewController { private let viewModel = PermissionApplyViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "权限申请" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -75,17 +105,21 @@ final class PermissionApplyViewController: ModuleTableViewController { wireViewModel(viewModel) { } } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? viewModel.roleOptions.count : viewModel.scenicOptions.count } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "选择角色" : "选择景区" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -102,8 +136,8 @@ final class PermissionApplyViewController: ModuleTableViewController { return cell } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + /// 处理行选中。 + override func didSelectTableRow(at indexPath: IndexPath) { if indexPath.section == 0 { viewModel.selectRole(id: viewModel.roleOptions[indexPath.row].id) Task { await viewModel.loadScenicListIfNeeded(api: services.scenicPermissionAPI, force: true) } @@ -112,6 +146,7 @@ final class PermissionApplyViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { viewModel.bootstrap(rolePermissions: services.permissionContext.rolePermissions) if viewModel.selectedRoleId != nil { @@ -119,6 +154,7 @@ final class PermissionApplyViewController: ModuleTableViewController { } } + /// 提交。 @objc private func submit() { Task { await viewModel.submit(api: services.scenicPermissionAPI) @@ -138,16 +174,19 @@ extension PermissionApplyViewModel: ViewModelBindable {} final class PermissionApplyStatusViewController: ModuleTableViewController { private let viewModel = PermissionApplyStatusViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "申请状态" super.viewDidLoad() viewModel.onChange = { [weak self] in self?.reloadTable() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.pending == nil ? 0 : 4 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { guard let pending = viewModel.pending else { return } switch indexPath.row { @@ -158,6 +197,7 @@ final class PermissionApplyStatusViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.scenicPermissionAPI, applyCode: nil) } @@ -168,6 +208,7 @@ final class ScenicApplicationViewController: ModuleTableViewController { private let viewModel = ScenicApplicationViewModel() private let nameField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "景区申请" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -188,8 +229,10 @@ final class ScenicApplicationViewController: ModuleTableViewController { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { 3 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { switch indexPath.row { case 0: cell.configure(title: "省份", subtitle: viewModel.selectedProvince) @@ -198,10 +241,12 @@ final class ScenicApplicationViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadInitial(api: services.scenicPermissionAPI) } + /// 提交。 @objc private func submit() { viewModel.scenicName = nameField.text ?? "" Task { diff --git a/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift b/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift index eb0a490..40fc8e3 100644 --- a/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift +++ b/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift @@ -20,6 +20,7 @@ struct ScenicSettlementSubmitRequest: Encodable, Equatable { let applyAmount: String let applyRemark: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case applyAmount = "apply_amount" diff --git a/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift b/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift index a5b2930..b13a852 100644 --- a/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift +++ b/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift @@ -13,6 +13,7 @@ final class ScenicSettlementViewController: ModuleTableViewController { private let amountField = UITextField() private let remarkField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "景区结算" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -26,6 +27,7 @@ final class ScenicSettlementViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { amountField.placeholder = "结算金额" amountField.borderStyle = .roundedRect @@ -41,20 +43,24 @@ final class ScenicSettlementViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.options.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let option = viewModel.options[indexPath.row] cell.configure(title: option.name, subtitle: option.selected ? "已选择" : nil) cell.accessoryType = option.selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.toggleScenic(id: viewModel.options[indexPath.row].id) } + /// 刷新Content。 override func reloadContent() async { await viewModel.load( api: services.scenicPermissionAPI, @@ -62,6 +68,7 @@ final class ScenicSettlementViewController: ModuleTableViewController { ) } + /// 提交。 @objc private func submit() { viewModel.amountText = amountField.text ?? "" viewModel.remarkText = remarkField.text ?? "" @@ -83,23 +90,28 @@ extension ScenicSettlementViewModel: ViewModelBindable {} final class ScenicSettlementReviewViewController: ModuleTableViewController { private let viewModel = ScenicSettlementReviewViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "结算审核" super.viewDidLoad() wireViewModel(viewModel) { } } - override func numberOfSections(in tableView: UITableView) -> Int { 2 } + /// 返回列表 section 数量。 + override func numberOfTableSections() -> Int { 2 } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + /// 返回指定 section 行数。 + override func tableRowCount(in section: Int) -> Int { section == 0 ? viewModel.scenicApplications.count : viewModel.roleApplications.count } - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + /// section 标题。 + override func tableSectionTitle(for section: Int) -> String? { section == 0 ? "景区申请" : "权限申请" } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + /// 提供自定义 Cell。 + override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, for: indexPath @@ -122,6 +134,7 @@ final class ScenicSettlementReviewViewController: ModuleTableViewController { return cell } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.scenicPermissionAPI) } diff --git a/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift b/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift index 314e878..6e0fdbd 100644 --- a/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift +++ b/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class ScheduleManagementViewController: ModuleTableViewController { private let viewModel = ScheduleManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "排班管理" navigationItem.rightBarButtonItems = [ @@ -27,10 +28,12 @@ final class ScheduleManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateTitle() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.items.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] cell.configure( @@ -40,6 +43,7 @@ final class ScheduleManagementViewController: ModuleTableViewController { ) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let item = viewModel.items[indexPath.row] let alert = UIAlertController(title: item.name, message: "删除该排班?", preferredStyle: .alert) @@ -51,24 +55,29 @@ final class ScheduleManagementViewController: ModuleTableViewController { present(alert, animated: true) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.scheduleAPI, scenicId: services.currentScenicId) updateTitle() } + /// 更新Title状态。 private func updateTitle() { let month = viewModel.monthDate.scheduleYearMonthText title = "排班 \(month)" } + /// previousMonth相关逻辑。 @objc private func previousMonth() { Task { await viewModel.previousMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) } } + /// nextMonth相关逻辑。 @objc private func nextMonth() { Task { await viewModel.nextMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) } } + /// add排班相关逻辑。 @objc private func addSchedule() { navigationController?.pushViewController(ScheduleAddViewController(), animated: true) } @@ -82,6 +91,7 @@ final class ScheduleAddViewController: ModuleTableViewController { private let nameField = UITextField() private let remarkField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "新增排班" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -95,6 +105,7 @@ final class ScheduleAddViewController: ModuleTableViewController { setupFormHeader() } + /// 初始化FormHeader相关 UI 或状态。 private func setupFormHeader() { nameField.placeholder = "日程名称" nameField.borderStyle = .roundedRect @@ -109,10 +120,12 @@ final class ScheduleAddViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.availableOrders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let order = viewModel.availableOrders[indexPath.row] let selected = viewModel.draft.selectedOrder?.orderNumber == order.orderNumber @@ -120,15 +133,18 @@ final class ScheduleAddViewController: ModuleTableViewController { cell.accessoryType = selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.draft.selectedOrder = viewModel.availableOrders[indexPath.row] reloadTable() } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadOrders(api: services.scheduleAPI, scenicId: services.currentScenicId) } + /// 提交。 @objc private func submit() { viewModel.draft.name = nameField.text ?? "" viewModel.draft.remark = remarkField.text ?? "" diff --git a/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift b/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift index 6ccce58..bdc74eb 100644 --- a/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift +++ b/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift @@ -99,6 +99,7 @@ final class ScheduleManagementViewModel { } } + /// 重置状态。 private func reset() { markedDays = [] items = [] @@ -174,6 +175,7 @@ final class ScheduleAddViewModel { return text } + /// 记录校验失败并返回 false。 private func fail(_ error: ScheduleValidationError) -> Bool { errorMessage = error.localizedDescription return false diff --git a/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift b/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift index 5a577b7..2f99a21 100644 --- a/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift +++ b/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift @@ -80,7 +80,7 @@ enum StatisticsPeriod: String, CaseIterable, Identifiable { } /// 数据统计汇总实体,表示订单金额、数量、客单价、实收和退款。 -struct StatisticsSummaryResponse: Decodable, Equatable { +struct StatisticsSummaryResponse: Decodable, Equatable, Hashable { let orderAmountSum: String let orderCount: Int let orderPriceAverage: String @@ -92,6 +92,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable { var receivedAmountValue: Double { Self.parseAmount(receivedAmountSum) } var refundAmountValue: Double { Self.parseAmount(refundAmountSum) } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case orderAmountSum = "order_amount_sum" case orderCount = "order_count" @@ -125,6 +126,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable { refundAmountSum = try container.decodeLossyString(forKey: .refundAmountSum) } + /// 解析Amount数据。 private static func parseAmount(_ text: String) -> Double { let normalized = text.filter { "0123456789.-".contains($0) } return Double(normalized) ?? 0 @@ -132,7 +134,7 @@ struct StatisticsSummaryResponse: Decodable, Equatable { } /// 每日统计实体,表示某天订单数、客单价、退款和实收。 -struct StatisticsDailyItem: Decodable, Identifiable, Equatable { +struct StatisticsDailyItem: Decodable, Identifiable, Equatable, Hashable { var id: String { date } let date: String @@ -145,6 +147,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable { var refundValue: Double { Self.parseAmount(refund) } var receivedValue: Double { Self.parseAmount(received) } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case date case orderCount = "order_count" @@ -163,6 +166,7 @@ struct StatisticsDailyItem: Decodable, Identifiable, Equatable { received = try container.decodeLossyString(forKey: .received) } + /// 解析Amount数据。 private static func parseAmount(_ text: String) -> Double { let normalized = text.filter { "0123456789.-".contains($0) } return Double(normalized) ?? 0 diff --git a/suixinkan_ios/Features/Statistics/Statistics.md b/suixinkan_ios/Features/Statistics/Statistics.md index d56a51e..0db0d3d 100644 --- a/suixinkan_ios/Features/Statistics/Statistics.md +++ b/suixinkan_ios/Features/Statistics/Statistics.md @@ -8,7 +8,7 @@ Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单 ## 核心对象 -- `StatisticsView`:数据 Tab 根视图,读取当前景区、当前角色和统计 API。 +- `StatisticsViewController`:数据 Tab 根页面,使用 `UICollectionView` + Diffable Data Source 展示多 section 看板。 - `StatisticsViewModel`:管理时间段、汇总数据、每日明细、分页状态和加载状态。 - `StatisticsAPI`:封装摄影师和景区管理员两套统计接口。 - `StatisticsPeriod`:表示今日、昨日、7日、本月四个快捷时间段。 @@ -24,3 +24,9 @@ Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单 ## 分页规则 每日明细第一页随刷新或时间段切换加载。列表滚动到底部后,如果当前数量小于 total,则继续加载下一页。加载更多失败时保留已有数据和当前页状态。 + +## 列表实现 + +- Section:`empty`(无景区)、`period`(周期切换)、`summary`(汇总卡)、`daily`(每日明细,带 section 头「每日明细」) +- 使用 `UICollectionViewDiffableDataSource` + `NSDiffableDataSourceSnapshot` 驱动刷新,分页追加时保留插入动画 +- 布局复用 `CollectionDiffableLayout` 全宽 section;最后一项日明细 `willDisplay` 时触发 `loadMore` diff --git a/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift b/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift index ccd9130..bc34594 100644 --- a/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift +++ b/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift @@ -6,39 +6,191 @@ import SnapKit import UIKit +// MARK: - Diffable 标识 + +/// 统计页 section 标识,区分空状态、周期切换、汇总与每日明细。 +private enum StatisticsSection: Hashable { + case empty + case period + case summary + case daily +} + +/// 统计页 item 标识,携带展示数据以保证 diff 后 Cell 内容同步更新。 +private enum StatisticsItem: Hashable { + case emptyContext + case period(StatisticsPeriod) + case summary(StatisticsSummaryResponse) + case dailyEmpty + case daily(StatisticsDailyItem) +} + /// 数据 Tab 根页面,展示订单统计汇总和每日明细。 final class StatisticsViewController: UIViewController { private let viewModel = StatisticsViewModel() - private lazy var tableView: UITableView = { - let table = UITableView(frame: .zero, style: .grouped) - table.backgroundColor = AppDesignUIKit.pageBackground - table.dataSource = self - table.delegate = self - table.register(StatisticsSummaryCell.self, forCellReuseIdentifier: StatisticsSummaryCell.reuseID) - table.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseID) - table.register(StatisticsPeriodCell.self, forCellReuseIdentifier: StatisticsPeriodCell.reuseID) - return table + private lazy var collectionView: UICollectionView = { + let layout = makeCollectionLayout() + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.backgroundColor = AppDesignUIKit.pageBackground + collection.delegate = self + collection.register(StatisticsPeriodCell.self, forCellWithReuseIdentifier: StatisticsPeriodCell.reuseID) + collection.register(StatisticsSummaryCell.self, forCellWithReuseIdentifier: StatisticsSummaryCell.reuseID) + collection.register(StatisticsDailyCell.self, forCellWithReuseIdentifier: StatisticsDailyCell.reuseID) + collection.register(StatisticsEmptyCell.self, forCellWithReuseIdentifier: StatisticsEmptyCell.reuseID) + collection.register( + CollectionSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CollectionSectionHeaderView.reuseID + ) + return collection }() private lazy var refreshControl = UIRefreshControl() + /// Diffable 数据源,驱动多 section 列表与分页插入动画。 + private var dataSource: UICollectionViewDiffableDataSource! + + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() title = "数据" view.backgroundColor = AppDesignUIKit.pageBackground - view.addSubview(tableView) - tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + configureDataSource() + view.addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) - tableView.refreshControl = refreshControl + collectionView.refreshControl = refreshControl - viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + viewModel.onChange = { [weak self] in self?.applySnapshot() } appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } appServices.permissionContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } + applySnapshot(animated: false) Task { await reload(showLoading: true) } } + /// 构建 Compositional Layout,按 section 类型分配全宽卡片布局。 + private func makeCollectionLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let self else { return CollectionDiffableLayout.fullWidthSection() } + let snapshot = self.dataSource?.snapshot() + let section = snapshot?.sectionIdentifiers[safe: sectionIndex] + switch section { + case .empty: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 360) + case .period: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 56) + case .summary: + return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 280) + case .daily: + return CollectionDiffableLayout.addHeader( + to: CollectionDiffableLayout.fullWidthSection(estimatedHeight: 52), + height: 36 + ) + case .none: + return CollectionDiffableLayout.fullWidthSection() + } + } + } + + /// 注册 Diffable 数据源与 Cell 配置闭包。 + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + switch item { + case .emptyContext: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsEmptyCell.reuseID, + for: indexPath + ) as! StatisticsEmptyCell + let empty = self.makeEmptyStateView( + title: "缺少经营上下文", + message: "请先在首页选择景区后查看数据看板。", + systemImage: "chart.bar.doc.horizontal" + ) + cell.setHostedView(empty, height: 360) + return cell + case .period(let selectedPeriod): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsPeriodCell.reuseID, + for: indexPath + ) as! StatisticsPeriodCell + cell.configure(selectedPeriod: selectedPeriod) { [weak self] period in + self?.selectPeriod(period) + } + return cell + case .summary(let summary): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsSummaryCell.reuseID, + for: indexPath + ) as! StatisticsSummaryCell + cell.configure( + periodText: self.viewModel.selectedPeriod.selectedTimeText, + summary: summary, + amountText: self.amountText + ) + return cell + case .dailyEmpty: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsEmptyCell.reuseID, + for: indexPath + ) as! StatisticsEmptyCell + cell.configurePlainText("暂无数据", height: 52) + return cell + case .daily(let dailyItem): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: StatisticsDailyCell.reuseID, + for: indexPath + ) as! StatisticsDailyCell + cell.configure(item: dailyItem, amountText: self.amountText) + return cell + } + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard kind == UICollectionView.elementKindSectionHeader, + let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section], + section == .daily, + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: CollectionSectionHeaderView.reuseID, + for: indexPath + ) as? CollectionSectionHeaderView + else { return nil } + header.configure(title: "每日明细") + return header + } + } + + /// 根据 ViewModel 状态构建 snapshot 并应用 diff 更新。 + private func applySnapshot(animated: Bool = true) { + var snapshot = NSDiffableDataSourceSnapshot() + + if currentScenicId == nil { + snapshot.appendSections([.empty]) + snapshot.appendItems([.emptyContext], toSection: .empty) + } else { + snapshot.appendSections([.period, .summary, .daily]) + snapshot.appendItems([.period(viewModel.selectedPeriod)], toSection: .period) + snapshot.appendItems([.summary(viewModel.summary)], toSection: .summary) + + if viewModel.loading, viewModel.dailyItems.isEmpty { + // 首次加载中不展示占位行,避免闪烁。 + } else if viewModel.dailyItems.isEmpty { + snapshot.appendItems([.dailyEmpty], toSection: .daily) + } else { + let items = viewModel.dailyItems.map { StatisticsItem.daily($0) } + snapshot.appendItems(items, toSection: .daily) + } + } + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + /// 下拉刷新触发。 @objc private func refreshPulled() { Task { await reload(showLoading: false) @@ -49,9 +201,10 @@ final class StatisticsViewController: UIViewController { private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id } private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + /// 重新加载汇总与第一页日数据。 private func reload(showLoading: Bool) async { guard currentScenicId != nil else { - tableView.reloadData() + applySnapshot() return } do { @@ -68,6 +221,7 @@ final class StatisticsViewController: UIViewController { } } + /// 切换统计周期并重新加载数据。 private func selectPeriod(_ period: StatisticsPeriod) { Task { do { @@ -85,6 +239,7 @@ final class StatisticsViewController: UIViewController { } } + /// 加载每日明细下一页。 private func loadMore() async { do { try await viewModel.loadMore( @@ -97,85 +252,37 @@ final class StatisticsViewController: UIViewController { } } + /// 格式化金额展示文本。 private func amountText(_ value: Double) -> String { "¥\(String(format: "%.2f", value))" } } -extension StatisticsViewController: UITableViewDataSource, UITableViewDelegate { - func numberOfSections(in tableView: UITableView) -> Int { - currentScenicId == nil ? 1 : 3 - } +// MARK: - UICollectionViewDelegate - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if currentScenicId == nil { return 1 } - switch section { - case 0: return 1 - case 1: return 1 - default: - if viewModel.loading && viewModel.dailyItems.isEmpty { return 0 } - return max(viewModel.dailyItems.count, 1) - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if currentScenicId == nil { - let cell = UITableViewCell() - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.contentView.subviews.forEach { $0.removeFromSuperview() } - let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看数据看板。", systemImage: "chart.bar.doc.horizontal") - cell.contentView.addSubview(empty) - empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(360) } - return cell - } - - switch indexPath.section { - case 0: - let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsPeriodCell.reuseID, for: indexPath) as! StatisticsPeriodCell - cell.configure(selectedPeriod: viewModel.selectedPeriod) { [weak self] period in - self?.selectPeriod(period) - } - return cell - case 1: - let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsSummaryCell.reuseID, for: indexPath) as! StatisticsSummaryCell - cell.configure( - periodText: viewModel.selectedPeriod.selectedTimeText, - summary: viewModel.summary, - amountText: amountText - ) - return cell - default: - if viewModel.dailyItems.isEmpty { - let cell = UITableViewCell() - cell.textLabel?.text = "暂无数据" - cell.selectionStyle = .none - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsDailyCell.reuseID, for: indexPath) as! StatisticsDailyCell - cell.configure(item: viewModel.dailyItems[indexPath.row], amountText: amountText) - return cell - } - } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 2 ? "每日明细" : nil - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard indexPath.section == 2, indexPath.row == viewModel.dailyItems.count - 1 else { return } +extension StatisticsViewController: UICollectionViewDelegate { + /// 最后一项日明细即将展示时触发分页加载。 + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath), + case .daily(let dailyItem) = item, + dailyItem.date == viewModel.dailyItems.last?.date + else { return } Task { await loadMore() } } } -private final class StatisticsPeriodCell: UITableViewCell { +// MARK: - Cells + +/// 统计周期切换 Cell,横向展示快捷时间段按钮。 +private final class StatisticsPeriodCell: UICollectionViewCell { static let reuseID = "StatisticsPeriodCell" private var onSelect: ((StatisticsPeriod) -> Void)? private let stack = UIStackView() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear stack.axis = .horizontal stack.spacing = 8 stack.distribution = .fillEqually @@ -186,6 +293,7 @@ private final class StatisticsPeriodCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(selectedPeriod: StatisticsPeriod, onSelect: @escaping (StatisticsPeriod) -> Void) { self.onSelect = onSelect stack.arrangedSubviews.forEach { $0.removeFromSuperview() } @@ -202,19 +310,22 @@ private final class StatisticsPeriodCell: UITableViewCell { } } + /// 点击周期按钮后回调选中项。 @objc private func periodTapped(_ sender: UIButton) { let period = StatisticsPeriod.allCases[sender.tag] onSelect?(period) } } -private final class StatisticsSummaryCell: UITableViewCell { +/// 统计汇总 Cell,展示订单金额、数量、客单价等指标卡片。 +private final class StatisticsSummaryCell: UICollectionViewCell { static let reuseID = "StatisticsSummaryCell" private let stack = UIStackView() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear stack.axis = .vertical stack.spacing = 8 contentView.addSubview(stack) @@ -224,6 +335,7 @@ private final class StatisticsSummaryCell: UITableViewCell { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(periodText: String, summary: StatisticsSummaryResponse, amountText: (Double) -> String) { stack.arrangedSubviews.forEach { $0.removeFromSuperview() } let dateLabel = UILabel() @@ -249,6 +361,7 @@ private final class StatisticsSummaryCell: UITableViewCell { stack.addArrangedSubview(row2) } + /// 创建统计摘要卡片视图。 private func summaryCard(_ title: String, _ value: String, _ color: UIColor) -> UIView { let card = UIView() card.backgroundColor = color.withAlphaComponent(0.08) @@ -270,21 +383,94 @@ private final class StatisticsSummaryCell: UITableViewCell { } } -private final class StatisticsDailyCell: UITableViewCell { +/// 每日统计明细 Cell,展示日期、订单数与实收金额。 +private final class StatisticsDailyCell: UICollectionViewCell { static let reuseID = "StatisticsDailyCell" - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none + private let titleLabel = UILabel() + private let detailLabel = UILabel() + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .secondarySystemGroupedBackground + contentView.backgroundColor = .secondarySystemGroupedBackground + contentView.layer.cornerRadius = 8 + contentView.clipsToBounds = true + + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + detailLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + detailLabel.textColor = AppDesignUIKit.textSecondary + + contentView.addSubview(titleLabel) + contentView.addSubview(detailLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().inset(12) + } + detailLabel.snp.makeConstraints { make in + make.leading.trailing.equalTo(titleLabel) + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.bottom.equalToSuperview().inset(12) + } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// 配置展示内容。 func configure(item: StatisticsDailyItem, amountText: (Double) -> String) { - textLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) - detailTextLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption) - textLabel?.text = item.date - detailTextLabel?.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))" + titleLabel.text = item.date + detailLabel.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))" + } +} + +/// 空状态 Cell,用于缺少景区上下文或日明细为空时的占位展示。 +private final class StatisticsEmptyCell: UICollectionViewCell { + static let reuseID = "StatisticsEmptyCell" + + private var heightConstraint: Constraint? + + /// 初始化实例。 + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + /// 嵌入外部构建的空状态视图并固定高度。 + func setHostedView(_ view: UIView, height: CGFloat) { + contentView.subviews.forEach { $0.removeFromSuperview() } + heightConstraint?.deactivate() + contentView.addSubview(view) + view.snp.makeConstraints { make in + make.edges.equalToSuperview() + heightConstraint = make.height.equalTo(height).constraint + } + } + + /// 配置纯文案占位,用于日明细为空场景。 + func configurePlainText(_ title: String, height: CGFloat) { + contentView.subviews.forEach { $0.removeFromSuperview() } + heightConstraint?.deactivate() + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + label.textColor = AppDesignUIKit.textSecondary + label.textAlignment = .center + contentView.addSubview(label) + label.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + heightConstraint = make.height.equalTo(height).constraint + } + } +} + +/// 安全下标,避免 section 越界。 +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift b/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift index 15655b6..556f1ae 100644 --- a/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift +++ b/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift @@ -11,6 +11,7 @@ import UIKit final class TaskManagementViewController: ModuleTableViewController { private let viewModel = TaskManagementViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "任务管理" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,15 +24,18 @@ final class TaskManagementViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.tasks.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let task = viewModel.tasks[indexPath.row] cell.configure(title: task.name, subtitle: task.statusName, detail: task.createdAt) } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { let task = viewModel.tasks[indexPath.row] navigationController?.pushViewController( @@ -40,15 +44,18 @@ final class TaskManagementViewController: ModuleTableViewController { ) } + /// 刷新Content。 override func reloadContent() async { try? await viewModel.reload(api: services.taskAPI, scenicId: services.currentScenicId) } + /// table视图相关逻辑。 override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard indexPath.row >= viewModel.tasks.count - 2 else { return } Task { try? await viewModel.loadMore(api: services.taskAPI, scenicId: services.currentScenicId) } } + /// 创建任务。 @objc private func createTask() { navigationController?.pushViewController(TaskCreateViewController(), animated: true) } @@ -62,6 +69,7 @@ final class TaskDetailViewController: ModuleTableViewController { private let summary: PhotographerTaskItem? private let viewModel = TaskDetailViewModel() + /// 初始化实例。 init(taskId: Int, summary: PhotographerTaskItem?) { self.taskId = taskId self.summary = summary @@ -73,6 +81,7 @@ final class TaskDetailViewController: ModuleTableViewController { fatalError("init(coder:) has not been implemented") } + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = summary?.name ?? "任务详情" super.viewDidLoad() @@ -81,11 +90,13 @@ final class TaskDetailViewController: ModuleTableViewController { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { guard let detail = viewModel.detail else { return summary == nil ? 0 : 4 } return 6 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { if let detail = viewModel.detail { switch indexPath.row { @@ -106,6 +117,7 @@ final class TaskDetailViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.taskAPI, taskId: taskId) } @@ -119,6 +131,7 @@ final class TaskCreateViewController: ModuleTableViewController { private let nameField = UITextField() private let remarkField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "发布任务" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -132,6 +145,7 @@ final class TaskCreateViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { nameField.placeholder = "任务名称" nameField.borderStyle = .roundedRect @@ -146,10 +160,12 @@ final class TaskCreateViewController: ModuleTableViewController { tableView.tableHeaderView = stack } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.availableOrders.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let order = viewModel.availableOrders[indexPath.row] let selected = viewModel.selectedOrder?.orderNumber == order.orderNumber @@ -157,14 +173,17 @@ final class TaskCreateViewController: ModuleTableViewController { cell.accessoryType = selected ? .checkmark : .none } + /// didSelectTableRow 回调处理。 override func didSelectTableRow(at indexPath: IndexPath) { viewModel.selectedOrder = viewModel.availableOrders[indexPath.row] } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadAvailableOrders(api: services.taskAPI, scenicId: services.currentScenicId) } + /// 提交。 @objc private func submit() { viewModel.taskName = nameField.text ?? "" viewModel.remark = remarkField.text ?? "" diff --git a/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift b/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift index 363c950..5fcf18e 100644 --- a/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift +++ b/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift @@ -166,6 +166,7 @@ final class TaskCreateViewModel { /// 校验表单并按需写入错误提示。 private func validateForm(scenicId: Int?, shouldSetError: Bool) -> Bool { + /// 记录校验失败并返回 false。 func fail(_ message: String) -> Bool { if shouldSetError { errorMessage = message } return false diff --git a/suixinkan_ios/Features/Wallet/Models/WalletModels.swift b/suixinkan_ios/Features/Wallet/Models/WalletModels.swift index 523052d..5200eda 100644 --- a/suixinkan_ios/Features/Wallet/Models/WalletModels.swift +++ b/suixinkan_ios/Features/Wallet/Models/WalletModels.swift @@ -13,6 +13,7 @@ struct WalletSummaryResponse: Decodable, Equatable { let amountCurrentBalance: String let amountWithdrawable: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case amountTotal = "amount_total" case amountCurrentBalance = "amount_current_balance" @@ -42,6 +43,7 @@ struct WalletEarningDetailResponse: Decodable, Equatable { let total: Int let list: [WalletEarningDetailGroup] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case totalAmount = "total_amount" case totalPoints = "total_points" @@ -76,6 +78,7 @@ struct WalletEarningDetailGroup: Decodable, Equatable, Identifiable { var id: String { date } + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case date case dayAmount = "day_amount" @@ -113,6 +116,7 @@ struct WalletEarningDetailItem: Decodable, Equatable, Identifiable { let withdrawLabel: String? let source: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case amount @@ -168,6 +172,7 @@ struct WalletWithdrawListResponse: Decodable, Equatable { let total: Int let item: [WalletWithdrawRecord] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case total case item @@ -197,6 +202,7 @@ struct WalletWithdrawRecord: Decodable, Equatable, Identifiable { let auditTime: String? let completedAt: String? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case amount @@ -224,6 +230,7 @@ struct WalletWithdrawRecord: Decodable, Equatable, Identifiable { struct BankCardInfoResponse: Decodable, Equatable { let bankCard: WalletBankCardInfo? + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case bankCard = "bank_card" } @@ -244,6 +251,7 @@ struct WalletBankCardInfo: Decodable, Equatable, Identifiable { let auditStatus: Int let auditStatusLabel: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case realName = "real_name" case bankName = "bank_name" @@ -279,6 +287,7 @@ struct WalletBankCardInfo: Decodable, Equatable, Identifiable { struct BankListResponse: Decodable, Equatable { let banks: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case banks } @@ -296,6 +305,7 @@ struct AreaNode: Decodable, Equatable, Identifiable { let name: String let children: [AreaNode] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case code @@ -324,6 +334,7 @@ struct WithdrawInfoResponse: Decodable, Equatable { let bankCard: WithdrawBankCardInfo let withdrawInfo: [String] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case amountWithdrawable = "amount_withdrawable" case minWithdrawAmount = "min_withdraw_amount" @@ -353,6 +364,7 @@ struct WithdrawBankCardInfo: Decodable, Equatable { let bankName: String let cardNumber: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case realName = "real_name" case bankName = "bank_name" @@ -380,6 +392,7 @@ struct WithdrawApplyRequest: Encodable { let amount: String let smsCode: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case amount case smsCode = "sms_code" @@ -398,6 +411,7 @@ struct UpdateBankInfoRequest: Encodable, Equatable { let cityCode: String let smsVerifyCode: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case realName = "real_name" case cardNumber = "card_number" @@ -419,6 +433,7 @@ struct PointOverviewResponse: Decodable, Equatable { let pendingPoints: Int let time: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case totalPoints = "total_points" case availablePoints = "available_points" @@ -459,6 +474,7 @@ struct PointWithdrawListRequest: Encodable { let page: Int let pageSize: Int + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case status case page @@ -471,6 +487,7 @@ struct PointWithdrawListResponse: Decodable, Equatable { let total: Int let list: [PointWithdrawItem] + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case total case list @@ -498,6 +515,7 @@ struct PointWithdrawItem: Decodable, Equatable, Identifiable { let status: Int let createdAt: String + /// 枚举,定义相关常量或状态。 enum CodingKeys: String, CodingKey { case id case points diff --git a/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift b/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift index e39b9a1..cbfaf08 100644 --- a/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift +++ b/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift @@ -12,6 +12,7 @@ final class WalletViewController: ModuleTableViewController { private let viewModel = WalletViewModel() private let summaryLabel = UILabel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "我的钱包" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -25,6 +26,7 @@ final class WalletViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateSummary() } } + /// 初始化Header相关 UI 或状态。 private func setupHeader() { summaryLabel.numberOfLines = 0 summaryLabel.font = .systemFont(ofSize: 14) @@ -34,6 +36,7 @@ final class WalletViewController: ModuleTableViewController { tableView.tableHeaderView = summaryLabel } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { switch viewModel.selectedTab { case .earnings: @@ -43,6 +46,7 @@ final class WalletViewController: ModuleTableViewController { } } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { switch viewModel.selectedTab { case .earnings: @@ -55,16 +59,19 @@ final class WalletViewController: ModuleTableViewController { } } + /// 刷新Content。 override func reloadContent() async { await viewModel.loadInitial(api: services.walletAPI, staffId: services.staffId) updateSummary() } + /// 更新Summary状态。 private func updateSummary() { summaryLabel.text = "\(viewModel.withdrawableText) · \(viewModel.totalAmountText)" navigationItem.rightBarButtonItem?.title = viewModel.selectedTab == .earnings ? "提现记录" : "收益明细" } + /// openWithdraw相关逻辑。 @objc private func openWithdraw() { navigationController?.pushViewController(WalletWithdrawViewController(), animated: true) } @@ -78,6 +85,7 @@ final class WalletWithdrawViewController: ModuleTableViewController { private let amountField = UITextField() private let smsField = UITextField() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "申请提现" navigationItem.rightBarButtonItems = [ @@ -100,21 +108,26 @@ final class WalletWithdrawViewController: ModuleTableViewController { wireViewModel(viewModel) { } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { 1 } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let amount = viewModel.info?.amountWithdrawable ?? "0" cell.configure(title: "可提现", subtitle: "¥ \(amount)") } + /// 刷新Content。 override func reloadContent() async { await viewModel.load(api: services.walletAPI) } + /// sendSms相关逻辑。 @objc private func sendSms() { Task { await viewModel.sendSms(api: services.walletAPI) } } + /// 提交。 @objc private func submit() { viewModel.amountText = amountField.text ?? "" viewModel.smsCode = smsField.text ?? "" diff --git a/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift b/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift index 224b7ec..e7ea56c 100644 --- a/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift +++ b/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift @@ -11,6 +11,7 @@ import UIKit final class WithdrawalAuditViewController: ModuleTableViewController { private let viewModel = WithdrawalAuditViewModel() + /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { title = "提现审核" navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -23,25 +24,30 @@ final class WithdrawalAuditViewController: ModuleTableViewController { wireViewModel(viewModel) { [weak self] in self?.updateFilterTitle() } } + /// table行Count相关逻辑。 override func tableRowCount() -> Int { viewModel.filteredRecords.count } + /// 配置Cell展示内容。 override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { let record = viewModel.filteredRecords[indexPath.row] cell.configure(title: "¥\(record.amount)", subtitle: record.statusLabel, detail: record.createdAt) } + /// 刷新Content。 override func reloadContent() async { await viewModel.reload(api: services.walletAPI) updateFilterTitle() } + /// willDisplayTableRow 回调处理。 override func willDisplayTableRow(at indexPath: IndexPath) { guard indexPath.row >= viewModel.filteredRecords.count - 2 else { return } Task { await viewModel.loadMore(api: services.walletAPI) } } + /// cycle筛选相关逻辑。 @objc private func cycleFilter() { let filters = WithdrawalAuditFilter.allCases guard let index = filters.firstIndex(of: viewModel.selectedFilter) else { return } @@ -52,6 +58,7 @@ final class WithdrawalAuditViewController: ModuleTableViewController { } } + /// 更新FilterTitle状态。 private func updateFilterTitle() { navigationItem.rightBarButtonItem?.title = viewModel.selectedFilter.title } diff --git a/suixinkan_ios/Info.plist b/suixinkan_ios/Info.plist index 0eb786d..4d2b652 100644 --- a/suixinkan_ios/Info.plist +++ b/suixinkan_ios/Info.plist @@ -2,6 +2,20 @@ + AMapAPIKey + + NSCameraUsageDescription + 用于扫码核销订单 + NSLocationWhenInUseUsageDescription + 用于景区选择、打卡点选点、运营区域和位置上报时获取当前位置 + NSPhotoLibraryAddUsageDescription + 用于保存收款二维码到相册 + NSPhotoLibraryUsageDescription + 用于选择头像、证件和打卡点图片 + UIBackgroundModes + + remote-notification + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/suixinkan_ios/SceneDelegate.swift b/suixinkan_ios/SceneDelegate.swift index df5c7bf..161ecf4 100644 --- a/suixinkan_ios/SceneDelegate.swift +++ b/suixinkan_ios/SceneDelegate.swift @@ -7,10 +7,12 @@ import UIKit +/// 窗口 Scene 代理,负责创建根窗口并挂载 `RootViewController`。 class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + /// Scene 连接时初始化窗口、重置 UI 测试状态并展示根控制器。 func scene( _ scene: UIScene, willConnectTo session: UISceneSession, @@ -26,18 +28,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.window = window } + /// Scene 断开连接,当前无额外资源释放。 func sceneDidDisconnect(_ scene: UIScene) { } + /// Scene 进入活跃态,当前无额外处理。 func sceneDidBecomeActive(_ scene: UIScene) { } + /// Scene 即将失活,当前无额外处理。 func sceneWillResignActive(_ scene: UIScene) { } + /// Scene 即将进入前台,当前无额外处理。 func sceneWillEnterForeground(_ scene: UIScene) { } + /// Scene 进入后台,当前无额外处理。 func sceneDidEnterBackground(_ scene: UIScene) { } } diff --git a/suixinkan_iosTests/NavigationRouterTests.swift b/suixinkan_iosTests/NavigationRouterTests.swift index 344664a..f4d37d5 100644 --- a/suixinkan_iosTests/NavigationRouterTests.swift +++ b/suixinkan_iosTests/NavigationRouterTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import suixinkan_ios /// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。 +@MainActor final class NavigationRouterTests: XCTestCase { /// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。 func testPushedRoutesHideTabBarByDefault() { diff --git a/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift b/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift index da9e55b..df5c3a2 100644 --- a/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift +++ b/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift @@ -212,6 +212,7 @@ private enum PilotTestError: LocalizedError { var errorDescription: String? { "测试错误" } } +@MainActor private func fillValidForm(_ viewModel: PilotCertificationViewModel) { viewModel.name = "航拍摄影师" viewModel.certType = .caac diff --git a/suixinkan_iosTests/PushNotificationTests.swift b/suixinkan_iosTests/PushNotificationTests.swift index 1511ca6..14c170a 100644 --- a/suixinkan_iosTests/PushNotificationTests.swift +++ b/suixinkan_iosTests/PushNotificationTests.swift @@ -10,6 +10,7 @@ import XCTest @testable import suixinkan_ios /// 推送链路测试,覆盖 APNs token、payload 解析、token 上报和通知路由。 +@MainActor final class PushNotificationTests: XCTestCase { func testAPNsDeviceTokenUsesLowercaseHexString() { let token = APNsDeviceToken.hexString(from: Data([0x00, 0x0f, 0x10, 0xab, 0xff])) diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 67b67ac..9200ae0 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -1,152 +1,133 @@ # 功能同步 Checklist(UIKit 工程) 更新时间:2026-06-26 -工程:`suixinkan_ios_new_uikit`(Swift + UIKit + SnapKit) +工程:[`suixinkan_ios_new_uikit`](.)(Swift + UIKit + SnapKit) +参考:[`../suixinkan_ios_new`](../suixinkan_ios_new)(SwiftUI) -## UIKit 同步进度摘要 +## 同步进度摘要 -| 阶段 | 状态 | +| 维度 | 状态 | | --- | --- | -| 阶段 0 工程脚手架 | 完成 | -| 阶段 1 Core 基础设施 | 完成 | -| 阶段 2 登录与会话 | 完成 | -| 阶段 3 Tab 壳与导航 | 完成 | -| 阶段 4 四 Tab 主功能(里程碑 1) | 完成 | -| 阶段 5 首页子业务模块 | 完成 | -| 阶段 6 平台能力(Lottie/Push/AMap 占位) | 完成 | -| 阶段 7 单元测试验收 | 完成(`xcodebuild test` 全绿) | +| 代码同步(阶段 0–5) | **完成** — Core / 登录 / 四 Tab / 子业务模块均有 UIKit 实现 | +| 单元测试 | **完成** — `xcodebuild test` 全绿 | +| 高德地图 | **完成(真机)** — Pod 已接入;模拟器自动降级 | +| 推送 | **完成** — `PushNotificationManager` + `AppDelegate` APNs 回调 | +| UI 视觉还原 | **部分完成** — 功能对齐,细节待逐页走查 | +| XCUITest | **未开始** — 可选后续补充 | ## 状态说明 -- 完成:UI、核心功能、文档和必要单元测试已同步,并通过编译验证。 -- 部分完成:已有入口、壳、路由或部分主流程,但旧工程完整业务尚未迁移。 -- 未开始:new 工程尚无对应真实业务页面或服务。 -- 不迁移:已明确不纳入 iOS 迁移范围。 +- **完成**:UIKit 页面、ViewModel、必要单测已同步,编译与测试通过。 +- **部分完成**:主流程可用,但与参考工程在 UI 细节或平台能力上仍有差距。 +- **未开始**:UIKit 工程尚无对应实现。 +- **不迁移**:参考工程已明确不纳入 iOS 范围。 + +--- ## 基础架构 -| 状态 | 模块 | 同步内容 | 剩余事项 | +| 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 | -| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | -| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | -| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | -| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | -| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 | -| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | -| 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | -| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | +| 完成 | App 根结构 | `RootViewController` 按 `AppSession.phase` 切换登录 / 恢复 / 主 Tab | 持续随路由扩展 | +| 完成 | Tab 导航 | 系统 `UITabBarController` 四 Tab + 独立 `TabNavigationController`;push 子页隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | +| 完成 | 全局状态 | `AppServices.shared` 注入 Session / Context / Router / Toast / Loading | 避免新增 monolith 状态 | +| 完成 | 登录态缓存 | Keychain token + UserDefaults 快照 / 偏好 + 冷启动恢复 | — | +| 完成 | 网络框架 | `APIClient` / `APIEnvelope` / 统一错误与 token | — | +| 完成 | OSS 上传 | `UploadAPI` + `OSSUploadService` | — | +| 完成 | 网络图片 | `UIImageView` + Kingfisher 扩展 | — | +| 完成 | 设计常量 | `AppDesign`(UIColor)+ `AppMetrics` | 新页继续复用 | +| 完成 | 文档规范 | `AGENTS.md` + 各 Feature 模块 `.md` | 新模块同步补文档 | -## 登录与账号上下文 +--- -| 状态 | 模块 | 同步内容 | 剩余事项 | +## 平台能力 + +| 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | 登录页 | 登录 UI、手机号/密码登录、协议勾选、上次手机号恢复。 | 后续如有验证码登录再迁移。 | -| 完成 | 多账号选择 | 登录后多账号选择、临时 token 内存保存、选择后写正式 token。 | 继续跟随后端账号结构变化维护。 | -| 完成 | 账号上下文 | 用户信息、角色权限、景区列表、门店列表、当前景区/门店选择和恢复。 | 后续业务模块按当前上下文取数。 | -| 完成 | 景点/打卡点上下文 | 当前景区下景点/打卡点懒加载,失败不影响登录态。 | 打卡点管理页面已接入,后续可继续增强地图交互。 | -| 完成 | 权限菜单数据 | 递归解析角色权限、URI 去重、按旧工程顺序排序。 | 新 URI 出现时补路由映射或标记不支持。 | +| 完成 | Core/Map | CocoaPods 高德 SDK;`OperatingAreaMapView` 围栏 polygon;`PunchPointMapPickerView` 点选 + 逆地理;模拟器坐标摘要兜底 | 在 `Info.plist` 填写 `AMapAPIKey` 后真机验证 | +| 完成 | Core/Push | `PushNotificationManager`;`AppDelegate` 注册 token 与远程通知 | 真机验证 APNs 与后端联调 | +| 完成 | Core/Queue | `ScenicQueueRuntime` WebSocket + 叫号 | 真机验证语音播报策略 | +| 部分完成 | Core/Validation | `AppFormValidator` + 各表单页内联校验 | 未单独抽离全部校验规则 | +| 完成 | Lottie Loading | SPM `Lottie` + `GlobalLoadingOverlayView` | — | +| 不迁移 | 旧 UIKit 兼容页 | — | 参考工程 legacy UIKit 目录不迁移 | -## 首页 +--- -| 状态 | 模块 | 同步内容 | 剩余事项 | +## 登录与账号 + +| 状态 | 模块 | UIKit 实现 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | 首页主 UI | 顶部景区、工作状态、位置上报卡、快捷操作、门店卡、常用应用网格。 | 视觉可随旧工程细节继续微调。 | -| 完成 | 首页权限菜单 | 从 `PermissionContext` 构建菜单,支持 URI 别名去重和旧工程排序。 | 未知 URI 继续通过诊断报告跟踪。 | -| 完成 | 常用应用 | 默认常用应用、添加/移除、UserDefaults 持久化、按权限过滤。 | 后续可加入拖拽排序。 | -| 完成 | 更多功能 | 全部功能页、常用应用增删、首页入口跳转。 | 未迁移功能仍进入占位页。 | -| 完成 | 首页路由壳 | 已迁移入口跳真实页面;订单/数据入口切换 Tab;未知入口安全占位。 | 按模块逐步替换占位页。 | -| 完成 | 景区选择入口 | 旧工程同款景区选择页、搜索、定位距离、景区卡片、封面、地址、营业状态、切换景区并持久化。 | 后续如有景区选择视觉细节变化再微调。 | -| 完成 | 权限申请入口 | `permission_apply`、`permission_apply_status`、`scenicapplication` 已接真实页面。 | 景区结算仍按独立模块后续迁移。 | +| 完成 | 登录页 | `LoginViewController` | — | +| 完成 | 多账号选择 | `AccountSelectionViewController` | — | +| 完成 | 账号上下文 | `AccountContext` / `PermissionContext` / `ScenicSpotContext` | — | +| 完成 | 权限菜单 | `HomeMenuRouter` URI 映射 | 未知 URI 进占位页并记录诊断 | -## 订单 Tab +--- -| 状态 | 模块 | 同步内容 | 剩余事项 | +## 四 Tab 主功能 + +| Tab | 状态 | UIKit 入口 | 备注 | | --- | --- | --- | --- | -| 完成 | 订单 Tab 根页面 | 订单管理/核销订单分段入口。 | 后续继续补长尾订单流程。 | -| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 无。 | -| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 无。 | -| 完成 | 扫码核销 | AVFoundation 扫码、订单号解析、命中高亮、二次确认后核销。 | 后续可继续补扫码 UI 细节。 | -| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 无。 | -| 完成 | 押金订单 | 押金订单列表、手输订单号查详情、押金详情、押金核销、押金退款、押金拍摄信息。 | 后续可继续补押金订单 UI 细节和异常空态。 | -| 完成 | 退款流程 | 普通订单退款入口规则、全额/部分退款金额校验、退款提交和详情刷新。 | 复杂退款审核进度如后端需要可单独补。 | -| 完成 | 历史拍摄 | 多点位历史拍摄项目、拍摄点和媒体展示。 | 仅展示已有媒体,不做上传、删除、下载或编辑。 | -| 完成 | 任务上传 | 多点旅拍订单选择已核销打卡点、云盘素材、本地图片/视频 OSS 上传并提交素材。 | 仅 `orderType == 19` 展示。 | -| 完成 | 视频预告/尾片上传 | 进入订单尾片引导页,并复用相册预览上传。 | 没有独立订单尾片接口,暂不做尾片审核进度。 | +| 首页 | 完成 | `HomeViewController` | 权限菜单、常用应用、景区选择 | +| 订单 | 完成 | `OrdersViewController` | 含扫码、详情、押金、退款、尾片等 | +| 数据 | 完成 | `StatisticsViewController` | 时间段 + 汇总 + 日明细 | +| 我的 | 完成 | `ProfileViewController` | 头像 OSS、改密、实名、设置、协议 | -## 数据 Tab - -| 状态 | 模块 | 同步内容 | 剩余事项 | -| --- | --- | --- | --- | -| 完成 | 数据看板 | 今日、昨日、7 日、本月时间段切换;统计汇总卡;每日明细。 | 后续按旧工程继续补图表细节。 | -| 完成 | 数据分页 | 日数据分页加载、切换时间段重置分页、加载失败保留当前数据。 | 持续跟随后端字段变化。 | -| 完成 | 角色接口分流 | 摄影师/景区管理员两套统计接口。 | 其他角色如有特殊逻辑再补。 | - -## 个人中心 - -| 状态 | 模块 | 同步内容 | 剩余事项 | -| --- | --- | --- | --- | -| 完成 | 个人信息首页 | 头像、昵称、UID、姓名、手机号、账号状态、当前景区、退出登录。 | 后续可继续细化旧工程视觉。 | -| 完成 | 头像上传 | `PhotosPicker` 选图、压缩、OSS 上传、回写头像 URL、刷新账号快照。 | 后续可补裁剪体验。 | -| 完成 | 昵称编辑 | 昵称校验、保存、同步 `AccountContext`。 | 无。 | -| 完成 | 修改密码 | 密码弹窗、最小长度校验、接口提交。 | 无。 | -| 完成 | 账号切换 | 可切换账号列表、默认选中、set-user、刷新登录上下文。 | 无。 | -| 完成 | 实名认证 | 信息加载、短信验证码、身份证校验、证件图片选择、OSS 上传、提交审核。 | 后续可补示例图或拍照入口。 | -| 完成 | 系统设置 | 关于我们、版本号、下载链接、协议入口。 | 若旧工程有更多设置项需继续核对。 | -| 完成 | 协议页 | 关于我们、用户协议、隐私政策、钱包协议页面。 | 无。 | +--- ## 首页子业务模块 -| 状态 | 模块 | 当前情况 | 后续事项 | -| --- | --- | --- | --- | -| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 | -| 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 | -| 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 | -| 完成 | 排队管理 | `/scenic-queue` / `queue_management` 已接真实页面;支持队列列表、叫号、过号、完成、重新排队、用户标记、设置、二维码、日志、实时监听和页面离开后的短轮询。 | 后续可继续按真实设备验证语音播报和后台活跃策略。 | -| 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 | -| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | -| 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 | -| 完成 | 发布任务 | 发布表单、选择订单、选择云盘文件、本地图片/视频 OSS 上传、提交任务。 | 完整云盘资产管理已由相册云盘模块接管,任务页只保留发布所需选择能力。 | -| 完成 | 日程管理 | 月份切换、日期标记、日程列表、下拉刷新、删除日程、新增排班、关联订单或手填订单号兜底。 | 后续如旧工程有排班详情页或高级重复规则再继续迁移。 | -| 完成 | 打卡点管理 | 列表、筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传已接入。 | 后续可把高德地图完整选点 UI 替换进编辑页。 | -| 完成 | 摄影师项目管理 | `pm` / `project_edit` 已接真实页面;支持列表、搜索、分页、详情、新建、编辑、删除和项目图片 OSS 上传。 | 后续可继续补项目数据分析、项目订单深层流程。 | -| 完成 | 店铺项目管理 | `pm_manager` 已接真实页面;支持店铺项目列表、详情、新建、编辑、删除、多点位项目和押金项目。 | 后续如有店铺后台审核扩展再单独迁移。 | -| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | -| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | -| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | -| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 | -| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 | -| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 | -| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | -| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | -| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 | -| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 | -| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | -| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | -| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | -| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | - -## 旧工程能力尚未系统迁移 - -| 状态 | 模块 | 说明 | +| 状态 | 模块 | UIKit ViewController | | --- | --- | --- | -| 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 | -| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | -| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | -| 完成 | Core/Queue | 已迁移排队 WebSocket、远端叫号去重、语音播报和短轮询运行时。 | -| 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 | -| 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 | +| 完成 | 钱包 | `WalletViewControllers` | +| 完成 | 提现审核 | `WithdrawalAuditViewController` | +| 完成 | 消息中心 | `MessageCenterViewController` | +| 完成 | 排队管理 | `QueueManagementViewControllers` | +| 完成 | 立即收款 | `PaymentCollectionViewController` | +| 完成 | 任务 / 发布任务 | `TaskViewControllers` | +| 完成 | 日程管理 | `ScheduleViewControllers` | +| 完成 | 打卡点管理 | `PunchPointViewControllers`(含地图选点) | +| 完成 | 项目管理 / 店铺项目 | `ProjectViewControllers` | +| 完成 | 相册 / 云盘 / 素材 / 样片 | `AssetsViewControllers` | +| 完成 | 直播 / 直播相册 | `LiveViewControllers` | +| 完成 | 景区 / 权限 / 结算 | `ScenicPermissionViewControllers` / `ScenicSettlementViewControllers` | +| 完成 | 运营区域 | `OperatingAreaViewController`(含围栏地图) | +| 完成 | 位置上报 | `LocationReportViewControllers` | +| 完成 | 注册邀请 | `InviteViewControllers` | +| 完成 | 飞手认证 | `PilotCertificationViewController` | +| 不迁移 | 飞控 / DJI | `HomeMenuRouter` 标记 unsupported | -## 最近验证记录 +--- -已通过: +## 已知差异(相对 SwiftUI 参考工程) + +| 项目 | 说明 | +| --- | --- | +| UI 视觉 | UITableView 功能型布局,非 SwiftUI 像素级还原 | +| 直播推流 | 无真 RTMP SDK,外部 `push_url` + 诊断层(与参考工程一致) | +| 订单尾片 | 无独立尾片审核进度接口 | +| 高德 Key | 需在 `Info.plist` → `AMapAPIKey` 填入控制台 Key(Bundle ID: `com.hanqiu.suixinkan-ios`) | + +--- + +## 验证命令 ```bash -xcodebuild test -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' -xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'generic/platform=iOS Simulator' +# 编译(模拟器) +xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios \ + -destination 'generic/platform=iOS Simulator' build + +# 单元测试 +xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios \ + -destination 'platform=iOS Simulator,name=iPhone 17' ``` -## 下一批建议迁移顺序 +最近验证:**BUILD SUCCEEDED** / **TEST SUCCEEDED**(2026-06-26) -1. 直播真推流 SDK:旧 iOS 未接入真推流 SDK,当前已按旧 iOS 对齐为外部 `push_url` + 推流诊断;如后续需要真 SDK,可在腾讯/TRTC、RTMP SDK 等方案明确后替换 `LivePushAdapter` 实现并补真机联调。 -2. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。 +--- + +## 后续可选 + +1. 真机填入 `AMapAPIKey` 并验证运营区域围栏、打卡点地图选点。 +2. 逐页 UI 走查,对齐 SwiftUI 参考工程视觉与交互细节。 +3. 新增 `suixinkan_iosUITests` 做关键流程自动化。