Advance UIKit rewrite with AMap integration and core UI modules.
Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
@ -47,6 +47,12 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、
|
|||||||
- View 层只负责展示与事件转发,业务逻辑放在 ViewModel
|
- View 层只负责展示与事件转发,业务逻辑放在 ViewModel
|
||||||
- ViewModel 不直接持有 UIView / UIViewController 引用
|
- 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)
|
- 同步进度详见 [功能同步Checklist.md](功能同步Checklist.md)
|
||||||
- 参考工程:`../suixinkan_ios_new`(SwiftUI)
|
- 参考工程:`../suixinkan_ios_new`(SwiftUI)
|
||||||
|
- 高德 Key:在 `suixinkan_ios/Info.plist` 的 `AMapAPIKey` 填入控制台 Key(真机地图生效)
|
||||||
- 构建:`xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios build`
|
- 构建:`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'`
|
- 测试:`xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios -destination 'platform=iOS Simulator,name=iPhone 17'`
|
||||||
|
|||||||
75
Podfile
@ -6,15 +6,90 @@ target 'suixinkan_ios' do
|
|||||||
pod 'SnapKit', '~> 5.7'
|
pod 'SnapKit', '~> 5.7'
|
||||||
pod 'Kingfisher', '~> 8.0'
|
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
|
target 'suixinkan_iosTests' do
|
||||||
inherit! :search_paths
|
inherit! :search_paths
|
||||||
end
|
end
|
||||||
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|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
target.build_configurations.each do |config|
|
target.build_configurations.each do |config|
|
||||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
20
Podfile.lock
@ -1,20 +1,38 @@
|
|||||||
PODS:
|
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)
|
- Kingfisher (8.10.0)
|
||||||
- SnapKit (5.7.1)
|
- SnapKit (5.7.1)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- AMap3DMap-NO-IDFA (~> 11.1)
|
||||||
|
- AMapLocation-NO-IDFA (~> 2.11)
|
||||||
|
- AMapSearch-NO-IDFA (~> 9.7)
|
||||||
- Kingfisher (~> 8.0)
|
- Kingfisher (~> 8.0)
|
||||||
- SnapKit (~> 5.7)
|
- SnapKit (~> 5.7)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
|
- AMap3DMap-NO-IDFA
|
||||||
|
- AMapFoundation-NO-IDFA
|
||||||
|
- AMapLocation-NO-IDFA
|
||||||
|
- AMapSearch-NO-IDFA
|
||||||
- Kingfisher
|
- Kingfisher
|
||||||
- SnapKit
|
- SnapKit
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
AMap3DMap-NO-IDFA: f53ee0cb33db83c1a08856b5995e53a0c2096158
|
||||||
|
AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e
|
||||||
|
AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18
|
||||||
|
AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef
|
||||||
Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d
|
Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
|
|
||||||
PODFILE CHECKSUM: dda5ec1556392b8b6a9747637c579bd75423177c
|
PODFILE CHECKSUM: 8f89212788a9d27c878d93b811a6eaa818955f8c
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
280
Scripts/add_swift_doc_comments.py
Normal file
@ -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<indent>\s*)"
|
||||||
|
r"(?:(?:@\w+(?:\([^)]*\))?\s+|@MainActor\s+)*)"
|
||||||
|
r"(?:(?P<modifiers>(?:final|private|fileprivate|public|internal|open)\s+)*)"
|
||||||
|
r"(?P<kind>class|struct|enum|protocol|actor)\s+(?P<name>\w+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
FUNC_DECL = re.compile(
|
||||||
|
r"^(?P<indent>\s*)"
|
||||||
|
r"(?:(?:@\w+(?:\([^)]*\))?\s+|@MainActor\s+|@objc\s+)*)"
|
||||||
|
r"(?:(?P<modifiers>(?:override|private|fileprivate|public|internal|static|class|mutating|nonisolated)\s+)*)"
|
||||||
|
r"func\s+(?P<name>\w+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
INIT_DECL = re.compile(
|
||||||
|
r"^(?P<indent>\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())
|
||||||
471
Scripts/polish_swift_doc_comments.py
Normal file
@ -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<indent>\s*)/// (?P<name>\w+) 方法实现。\s*$")
|
||||||
|
FUNC_LINE = re.compile(r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:override\s+|private\s+|static\s+)*func\s+(?P<name>\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())
|
||||||
@ -134,6 +134,7 @@
|
|||||||
9331714B2FEE49BE00240726 /* Frameworks */,
|
9331714B2FEE49BE00240726 /* Frameworks */,
|
||||||
9331714C2FEE49BE00240726 /* Resources */,
|
9331714C2FEE49BE00240726 /* Resources */,
|
||||||
FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */,
|
FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */,
|
||||||
|
367681C4AC079C922280F774 /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@ -234,6 +235,27 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase 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 */ = {
|
A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -337,6 +359,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = suixinkan_ios/Info.plist;
|
INFOPLIST_FILE = suixinkan_ios/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@ -356,8 +379,9 @@
|
|||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_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_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -375,6 +399,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = suixinkan_ios/Info.plist;
|
INFOPLIST_FILE = suixinkan_ios/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@ -394,8 +419,9 @@
|
|||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_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_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -533,6 +559,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@ -549,7 +576,7 @@
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -568,6 +595,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@ -584,7 +612,7 @@
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@ -13,8 +13,9 @@ import AMapLocationKit
|
|||||||
import AMapSearchKit
|
import AMapSearchKit
|
||||||
import MAMapKit
|
import MAMapKit
|
||||||
|
|
||||||
|
/// 高德地图 SDK 初始化封装,真机构建时配置 Key 与隐私合规。
|
||||||
enum AMapBootstrap {
|
enum AMapBootstrap {
|
||||||
/// 在 App 启动时调用一次。将 `apiKey` 替换为高德控制台申请的 Key。
|
/// 在 App 启动时调用一次,配置 API Key 并声明隐私政策已展示与同意。
|
||||||
static func configure(apiKey: String) {
|
static func configure(apiKey: String) {
|
||||||
guard !apiKey.isEmpty else { return }
|
guard !apiKey.isEmpty else { return }
|
||||||
|
|
||||||
@ -30,7 +31,9 @@ enum AMapBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
|
/// 模拟器构建占位,不链接高德 SDK。
|
||||||
enum AMapBootstrap {
|
enum AMapBootstrap {
|
||||||
|
/// 空实现,保持调用方编译通过。
|
||||||
static func configure(apiKey: String) {}
|
static func configure(apiKey: String) {}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -67,6 +67,7 @@ final class AppServices {
|
|||||||
accountContext.profile?.userId
|
accountContext.profile?.userId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
private init() {
|
private init() {
|
||||||
let client = APIClient()
|
let client = APIClient()
|
||||||
let tokenStore = SessionTokenStore()
|
let tokenStore = SessionTokenStore()
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开自定义 TabBar 下 XCUITest 难以触发的 push。
|
/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开 Tab 切换与 push 链路。
|
||||||
@MainActor
|
@MainActor
|
||||||
enum AppUITestRouteDriver {
|
enum AppUITestRouteDriver {
|
||||||
private static var didApply = false
|
private static var didApply = false
|
||||||
|
|||||||
@ -164,6 +164,7 @@ enum AppRouteViewControllerFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将首页路由编码为 URI 字符串。
|
||||||
private static func homeRouteURI(_ route: HomeRoute) -> String {
|
private static func homeRouteURI(_ route: HomeRoute) -> String {
|
||||||
if case let .modulePlaceholder(uri, _) = route {
|
if case let .modulePlaceholder(uri, _) = route {
|
||||||
return uri
|
return uri
|
||||||
@ -175,6 +176,7 @@ enum AppRouteViewControllerFactory {
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。
|
/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。
|
||||||
enum HomeRouteViewControllerFactory {
|
enum HomeRouteViewControllerFactory {
|
||||||
|
/// 创建实例。
|
||||||
static func make(for route: HomeRoute) -> UIViewController {
|
static func make(for route: HomeRoute) -> UIViewController {
|
||||||
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,19 +29,47 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemImage: String {
|
/// 选中态 TabBar 图标资源名。
|
||||||
|
var selectedImageName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .home:
|
case .home:
|
||||||
"house"
|
"TabHomeSelected"
|
||||||
case .orders:
|
case .orders:
|
||||||
"doc.text"
|
"TabOrderSelected"
|
||||||
case .statistics:
|
case .statistics:
|
||||||
"chart.bar"
|
"TabDataSelected"
|
||||||
case .profile:
|
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。
|
/// 构建 Tab 根页面 ViewController。
|
||||||
func makeRootViewController(services: AppServices) -> UIViewController {
|
func makeRootViewController(services: AppServices) -> UIViewController {
|
||||||
switch self {
|
switch self {
|
||||||
|
|||||||
@ -53,6 +53,7 @@ final class AppRouter {
|
|||||||
|
|
||||||
private let routers: [AppTab: RouterPath]
|
private let routers: [AppTab: RouterPath]
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init() {
|
init() {
|
||||||
var builtRouters: [AppTab: RouterPath] = [:]
|
var builtRouters: [AppTab: RouterPath] = [:]
|
||||||
for tab in AppTab.allCases {
|
for tab in AppTab.allCases {
|
||||||
|
|||||||
@ -13,8 +13,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
private let services: AppServices
|
private let services: AppServices
|
||||||
private var isSyncingStack = false
|
private var isSyncingStack = false
|
||||||
|
|
||||||
weak var tabBarHost: MainTabBarController?
|
/// 初始化实例。
|
||||||
|
|
||||||
init(tab: AppTab, services: AppServices) {
|
init(tab: AppTab, services: AppServices) {
|
||||||
self.appTab = tab
|
self.appTab = tab
|
||||||
self.services = services
|
self.services = services
|
||||||
@ -43,15 +42,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
pushViewController(viewController, animated: animated)
|
pushViewController(viewController, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func navigationController(
|
/// navigationController相关逻辑。
|
||||||
_ navigationController: UINavigationController,
|
|
||||||
willShow viewController: UIViewController,
|
|
||||||
animated: Bool
|
|
||||||
) {
|
|
||||||
let isRoot = viewController === viewControllers.first
|
|
||||||
tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigationController(
|
func navigationController(
|
||||||
_ navigationController: UINavigationController,
|
_ navigationController: UINavigationController,
|
||||||
didShow viewController: UIViewController,
|
didShow viewController: UIViewController,
|
||||||
@ -60,6 +51,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
syncRouterPathFromStack()
|
syncRouterPathFromStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注册NavigationBridge。
|
||||||
private func registerNavigationBridge() {
|
private func registerNavigationBridge() {
|
||||||
switch appTab {
|
switch appTab {
|
||||||
case .home:
|
case .home:
|
||||||
@ -71,6 +63,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 同步FromRouterPath状态。
|
||||||
private func syncFromRouterPath() {
|
private func syncFromRouterPath() {
|
||||||
guard !isSyncingStack else { return }
|
guard !isSyncingStack else { return }
|
||||||
|
|
||||||
@ -79,6 +72,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
let currentDepth = max(viewControllers.count - 1, 0)
|
let currentDepth = max(viewControllers.count - 1, 0)
|
||||||
|
|
||||||
if targetDepth > currentDepth {
|
if targetDepth > currentDepth {
|
||||||
|
// Router 路径变深:按路径依次 push 缺失页面
|
||||||
for index in currentDepth..<targetDepth {
|
for index in currentDepth..<targetDepth {
|
||||||
let route = routerPath.path[index]
|
let route = routerPath.path[index]
|
||||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
||||||
@ -89,6 +83,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
if targetDepth < currentDepth {
|
if targetDepth < currentDepth {
|
||||||
|
// Router 路径变浅:pop 至目标深度
|
||||||
if targetDepth == 0 {
|
if targetDepth == 0 {
|
||||||
popToRootViewController(animated: true)
|
popToRootViewController(animated: true)
|
||||||
} else if targetDepth < viewControllers.count {
|
} else if targetDepth < viewControllers.count {
|
||||||
@ -97,6 +92,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 同步RouterPathFromStack状态。
|
||||||
private func syncRouterPathFromStack() {
|
private func syncRouterPathFromStack() {
|
||||||
guard !isSyncingStack else { return }
|
guard !isSyncingStack else { return }
|
||||||
|
|
||||||
|
|||||||
@ -106,6 +106,7 @@ final class ToastOverlayView: UIView {
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Views展示内容。
|
||||||
private func configureViews() {
|
private func configureViews() {
|
||||||
bannerView.backgroundColor = AppDesign.primary
|
bannerView.backgroundColor = AppDesign.primary
|
||||||
bannerView.isHidden = true
|
bannerView.isHidden = true
|
||||||
@ -134,6 +135,7 @@ final class ToastOverlayView: UIView {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Presentation展示。
|
||||||
private func refreshPresentation(animated: Bool) {
|
private func refreshPresentation(animated: Bool) {
|
||||||
guard let message = toastCenter?.message, !message.isEmpty else {
|
guard let message = toastCenter?.message, !message.isEmpty else {
|
||||||
hideBanner(animated: animated)
|
hideBanner(animated: animated)
|
||||||
@ -144,6 +146,7 @@ final class ToastOverlayView: UIView {
|
|||||||
showBanner(animated: animated)
|
showBanner(animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 展示Banner。
|
||||||
private func showBanner(animated: Bool) {
|
private func showBanner(animated: Bool) {
|
||||||
guard bannerView.isHidden || bannerView.alpha < 1 else { return }
|
guard bannerView.isHidden || bannerView.alpha < 1 else { return }
|
||||||
|
|
||||||
@ -162,6 +165,7 @@ final class ToastOverlayView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 隐藏Banner。
|
||||||
private func hideBanner(animated: Bool) {
|
private func hideBanner(animated: Bool) {
|
||||||
guard !bannerView.isHidden else { return }
|
guard !bannerView.isHidden else { return }
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ final class RootViewController: UIViewController {
|
|||||||
private var currentChild: UIViewController?
|
private var currentChild: UIViewController?
|
||||||
private let restoringView = UIView()
|
private let restoringView = UIView()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(services: AppServices = .shared) {
|
init(services: AppServices = .shared) {
|
||||||
self.services = services
|
self.services = services
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -24,6 +25,7 @@ final class RootViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化视图、监听登录阶段变化并启动会话恢复。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
@ -38,11 +40,13 @@ final class RootViewController: UIViewController {
|
|||||||
configurePushNotifications()
|
configurePushNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 窗口展示后将 Toast 与全局 Loading 挂载到 window 层级。
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
attachGlobalOverlays()
|
attachGlobalOverlays()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 冷启动时恢复本地会话,并在已登录时申请推送权限。
|
||||||
private func bootstrapSession() {
|
private func bootstrapSession() {
|
||||||
Task {
|
Task {
|
||||||
await services.globalLoading.withLoading {
|
await services.globalLoading.withLoading {
|
||||||
@ -62,16 +66,19 @@ final class RootViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置推送管理器与路由跳转回调。
|
||||||
private func configurePushNotifications() {
|
private func configurePushNotifications() {
|
||||||
services.configurePushNotifications()
|
services.configurePushNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将全局 Toast 与 Loading 视图附加到当前 window。
|
||||||
private func attachGlobalOverlays() {
|
private func attachGlobalOverlays() {
|
||||||
guard let window = view.window else { return }
|
guard let window = view.window else { return }
|
||||||
services.toastCenter.attachToWindow(window)
|
services.toastCenter.attachToWindow(window)
|
||||||
services.globalLoading.attachToWindow(window)
|
services.globalLoading.attachToWindow(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 登录阶段变化时切换子页面,并在登出时清理各上下文状态。
|
||||||
private func handleSessionPhaseChange() {
|
private func handleSessionPhaseChange() {
|
||||||
mountChild(for: services.appSession.phase)
|
mountChild(for: services.appSession.phase)
|
||||||
|
|
||||||
@ -82,6 +89,7 @@ final class RootViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
Task { await reloadScenicSpotContextIfNeeded() }
|
Task { await reloadScenicSpotContextIfNeeded() }
|
||||||
case .loggedOut:
|
case .loggedOut:
|
||||||
|
// 登出后重置账号、权限、路由与排队运行时,避免残留旧状态
|
||||||
services.accountContext.reset()
|
services.accountContext.reset()
|
||||||
services.permissionContext.reset()
|
services.permissionContext.reset()
|
||||||
services.scenicSpotContext.reset()
|
services.scenicSpotContext.reset()
|
||||||
@ -93,6 +101,7 @@ final class RootViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 登录成功后按当前景区 ID 刷新景区上下文。
|
||||||
private func reloadScenicSpotContextIfNeeded() async {
|
private func reloadScenicSpotContextIfNeeded() async {
|
||||||
guard services.appSession.isLoggedIn else {
|
guard services.appSession.isLoggedIn else {
|
||||||
services.scenicSpotContext.reset()
|
services.scenicSpotContext.reset()
|
||||||
@ -104,6 +113,7 @@ final class RootViewController: UIViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按登录阶段挂载对应子控制器:登录页、恢复占位或主 Tab。
|
||||||
private func mountChild(for phase: AuthPhase) {
|
private func mountChild(for phase: AuthPhase) {
|
||||||
let nextChild: UIViewController
|
let nextChild: UIViewController
|
||||||
switch phase {
|
switch phase {
|
||||||
@ -130,6 +140,7 @@ final class RootViewController: UIViewController {
|
|||||||
currentChild = nextChild
|
currentChild = nextChild
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 会话恢复阶段的空白占位页,避免闪烁登录页。
|
||||||
private func restoringViewController() -> UIViewController {
|
private func restoringViewController() -> UIViewController {
|
||||||
let controller = UIViewController()
|
let controller = UIViewController()
|
||||||
controller.view.backgroundColor = .systemBackground
|
controller.view.backgroundColor = .systemBackground
|
||||||
|
|||||||
@ -2,35 +2,62 @@
|
|||||||
// AppDelegate.swift
|
// AppDelegate.swift
|
||||||
// suixinkan_ios
|
// suixinkan_ios
|
||||||
//
|
//
|
||||||
// Created by hanqiu on 2026/6/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// 应用入口,承接 APNs 回调并在启动时初始化高德 SDK。
|
||||||
@main
|
@main
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
/// 应用启动:重置 UI 测试状态并配置高德 Key。
|
||||||
|
func application(
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
_ application: UIApplication,
|
||||||
// Override point for customization after application launch.
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
AppUITestLaunchState.resetIfNeeded()
|
||||||
|
if let key = Bundle.main.object(forInfoDictionaryKey: "AMapAPIKey") as? String {
|
||||||
|
AMapBootstrap.configure(apiKey: key)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UISceneSession Lifecycle
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
// Called when a new scene session is being created.
|
options: UIScene.ConnectionOptions
|
||||||
// Use this method to select a configuration to create the new scene with.
|
) -> UISceneConfiguration {
|
||||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {}
|
||||||
// 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.
|
func application(
|
||||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
_ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -12,6 +13,7 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -23,6 +25,7 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "icon_1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
BIN
suixinkan_ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
21
suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
21
suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
21
suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
21
suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
21
suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
21
suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
21
suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
21
suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan_ios/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
@ -50,6 +50,7 @@ final class AppContentUnavailableView: UIView {
|
|||||||
actionsContainer.isHidden = actions.isEmpty
|
actionsContainer.isHidden = actions.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Views展示内容。
|
||||||
private func configureViews(
|
private func configureViews(
|
||||||
title: String,
|
title: String,
|
||||||
systemImage: String,
|
systemImage: String,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import CoreGraphics
|
|||||||
|
|
||||||
/// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。
|
/// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。
|
||||||
final class AppMetrics {
|
final class AppMetrics {
|
||||||
|
/// 初始化实例。
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
/// 字体尺寸实体,统一维护 App 内常用字号。
|
/// 字体尺寸实体,统一维护 App 内常用字号。
|
||||||
|
|||||||
@ -119,6 +119,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Views展示内容。
|
||||||
private func configureViews() {
|
private func configureViews() {
|
||||||
isUserInteractionEnabled = true
|
isUserInteractionEnabled = true
|
||||||
accessibilityLabel = "加载中"
|
accessibilityLabel = "加载中"
|
||||||
@ -171,6 +172,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Presentation展示。
|
||||||
private func refreshPresentation(animated: Bool) {
|
private func refreshPresentation(animated: Bool) {
|
||||||
let shouldShow = state.isVisible
|
let shouldShow = state.isVisible
|
||||||
let updates = {
|
let updates = {
|
||||||
@ -237,6 +239,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
|||||||
|
|
||||||
/// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。
|
/// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。
|
||||||
#if canImport(Lottie)
|
#if canImport(Lottie)
|
||||||
|
/// 加载Animation数据。
|
||||||
private static func loadAnimation() -> LottieAnimation? {
|
private static func loadAnimation() -> LottieAnimation? {
|
||||||
if let animation = LottieAnimation.named("loading") {
|
if let animation = LottieAnimation.named("loading") {
|
||||||
return animation
|
return animation
|
||||||
@ -250,6 +253,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
|
/// 加载Animation数据。
|
||||||
private static func loadAnimation() -> Any? {
|
private static func loadAnimation() -> Any? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ final class ForegroundLocationProvider: NSObject {
|
|||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
manager.delegate = self
|
manager.delegate = self
|
||||||
|
|||||||
16
suixinkan_ios/Core/Map/Map.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Core/Map 模块
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
|
||||||
|
封装高德地图 UIKit 组件,供运营区域围栏展示与打卡点编辑选点使用。
|
||||||
|
|
||||||
|
## 组件
|
||||||
|
|
||||||
|
- `OperatingAreaMapView`:绘制运营围栏 polygon;模拟器降级为坐标摘要。
|
||||||
|
- `PunchPointMapPickerView`:打卡点编辑页地图点选;模拟器提示使用「当前位置」。
|
||||||
|
|
||||||
|
## 构建说明
|
||||||
|
|
||||||
|
- 真机构建:`AMAP_ENABLED` + CocoaPods 高德 SDK。
|
||||||
|
- 模拟器:不链接 AMap,自动展示文字/坐标兜底 UI。
|
||||||
|
- 在 `Info.plist` 配置 `AMapAPIKey` 为高德控制台 Key(需与 Bundle ID 绑定)。
|
||||||
199
suixinkan_ios/Core/Map/OperatingAreaMapView.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
139
suixinkan_ios/Core/Map/PunchPointMapPickerView.swift
Normal file
@ -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<String, Never>?
|
||||||
|
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
|
||||||
@ -21,6 +21,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
private let tokenDefaultsKey = "apns_device_token"
|
private let tokenDefaultsKey = "apns_device_token"
|
||||||
private let uploadedTokenDefaultsKey = "apns_uploaded_token"
|
private let uploadedTokenDefaultsKey = "apns_uploaded_token"
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
@ -99,6 +100,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
route(from: payload)
|
route(from: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// user通知Center相关逻辑。
|
||||||
nonisolated func userNotificationCenter(
|
nonisolated func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
@ -111,6 +113,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
completionHandler([.banner, .list, .sound, .badge])
|
completionHandler([.banner, .list, .sound, .badge])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// user通知Center相关逻辑。
|
||||||
nonisolated func userNotificationCenter(
|
nonisolated func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
@ -123,6 +126,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据执行路由跳转。
|
||||||
private func route(from payload: PushPayload) {
|
private func route(from payload: PushPayload) {
|
||||||
guard let router else { return }
|
guard let router else { return }
|
||||||
|
|
||||||
@ -142,6 +146,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 导航至HomeRoute页面。
|
||||||
private func navigateHomeRoute(_ route: HomeRoute) {
|
private func navigateHomeRoute(_ route: HomeRoute) {
|
||||||
guard let router else { return }
|
guard let router else { return }
|
||||||
router.select(.home)
|
router.select(.home)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import Foundation
|
|||||||
|
|
||||||
/// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。
|
/// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。
|
||||||
enum APNsDeviceToken {
|
enum APNsDeviceToken {
|
||||||
|
/// 将二进制数据转为十六进制字符串。
|
||||||
static func hexString(from data: Data) -> String {
|
static func hexString(from data: Data) -> String {
|
||||||
data.map { String(format: "%02x", $0) }.joined()
|
data.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
@ -16,6 +17,7 @@ enum APNsDeviceToken {
|
|||||||
|
|
||||||
/// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。
|
/// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。
|
||||||
struct PushPayload: Sendable {
|
struct PushPayload: Sendable {
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum Route: Sendable, Equatable {
|
enum Route: Sendable, Equatable {
|
||||||
case payment
|
case payment
|
||||||
case order
|
case order
|
||||||
@ -64,6 +66,7 @@ struct PushPayload: Sendable {
|
|||||||
return .messageCenter
|
return .messageCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mergeJSON相关逻辑。
|
||||||
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
|
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
|
||||||
if let dict = value as? [String: Any] {
|
if let dict = value as? [String: Any] {
|
||||||
for (key, value) in dict {
|
for (key, value) in dict {
|
||||||
@ -84,6 +87,7 @@ struct PushPayload: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 安全地将任意值转为字符串。
|
||||||
nonisolated private static func stringValue(_ value: Any?) -> String {
|
nonisolated private static func stringValue(_ value: Any?) -> String {
|
||||||
switch value {
|
switch value {
|
||||||
case let string as String:
|
case let string as String:
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import Foundation
|
|||||||
|
|
||||||
/// 排队语音播报内容。
|
/// 排队语音播报内容。
|
||||||
struct ScenicQueueAnnouncement: Equatable {
|
struct ScenicQueueAnnouncement: Equatable {
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum Kind: Equatable {
|
enum Kind: Equatable {
|
||||||
case newTickets(count: Int)
|
case newTickets(count: Int)
|
||||||
case calledTicket(id: Int64)
|
case calledTicket(id: Int64)
|
||||||
|
|||||||
@ -69,6 +69,7 @@ final class ScenicQueueRuntime {
|
|||||||
self.scenePhase = scenePhase
|
self.scenePhase = scenePhase
|
||||||
|
|
||||||
guard !suspendedByQueueScreen else {
|
guard !suspendedByQueueScreen else {
|
||||||
|
// 排队页自行监听时,避免双通道轮询
|
||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -86,6 +87,7 @@ final class ScenicQueueRuntime {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 景区或打卡点切换时重置播报状态,避免跨点误报
|
||||||
if lastScenicId != scenicId || lastSpotId != spotId {
|
if lastScenicId != scenicId || lastSpotId != spotId {
|
||||||
resetSnapshot()
|
resetSnapshot()
|
||||||
lastScenicId = scenicId
|
lastScenicId = scenicId
|
||||||
@ -93,6 +95,7 @@ final class ScenicQueueRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if scenePhase == .background, !backgroundPollingEnabled {
|
if scenePhase == .background, !backgroundPollingEnabled {
|
||||||
|
// 用户关闭后台轮询时,进入后台即停止
|
||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -126,6 +129,7 @@ final class ScenicQueueRuntime {
|
|||||||
Task { await pollOnce() }
|
Task { await pollOnce() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 启动IfNeeded流程。
|
||||||
private func startIfNeeded() {
|
private func startIfNeeded() {
|
||||||
guard pollTask == nil else { return }
|
guard pollTask == nil else { return }
|
||||||
isMonitoring = true
|
isMonitoring = true
|
||||||
@ -137,6 +141,7 @@ final class ScenicQueueRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 执行Loop循环或任务。
|
||||||
private func runLoop() async {
|
private func runLoop() async {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
await pollOnce()
|
await pollOnce()
|
||||||
@ -145,6 +150,7 @@ final class ScenicQueueRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 轮询Once数据。
|
||||||
private func pollOnce() async {
|
private func pollOnce() async {
|
||||||
guard let api, let scenicId else { return }
|
guard let api, let scenicId else { return }
|
||||||
let spotId = selectedSpotId()
|
let spotId = selectedSpotId()
|
||||||
@ -168,6 +174,7 @@ final class ScenicQueueRuntime {
|
|||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20
|
pageSize: 20
|
||||||
)
|
)
|
||||||
|
// 并行拉取统计与排队列表,减少单次轮询耗时
|
||||||
let (stats, home) = try await (statsData, homeData)
|
let (stats, home) = try await (statsData, homeData)
|
||||||
handle(stats: stats, tickets: home.list?.list ?? [])
|
handle(stats: stats, tickets: home.list?.list ?? [])
|
||||||
lastError = nil
|
lastError = nil
|
||||||
@ -176,6 +183,7 @@ final class ScenicQueueRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理相关事件。
|
||||||
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
|
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "HH:mm:ss"
|
formatter.dateFormat = "HH:mm:ss"
|
||||||
@ -190,6 +198,7 @@ final class ScenicQueueRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 轮询IntervalSeconds数据。
|
||||||
private func pollIntervalSeconds() -> UInt64 {
|
private func pollIntervalSeconds() -> UInt64 {
|
||||||
switch scenePhase {
|
switch scenePhase {
|
||||||
case .active:
|
case .active:
|
||||||
@ -203,11 +212,13 @@ final class ScenicQueueRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取用户当前选中的打卡点 ID。
|
||||||
private func selectedSpotId() -> Int {
|
private func selectedSpotId() -> Int {
|
||||||
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
||||||
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
|
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置Snapshot状态。
|
||||||
private func resetSnapshot() {
|
private func resetSnapshot() {
|
||||||
announcementState.reset()
|
announcementState.reset()
|
||||||
lastQueueCount = 0
|
lastQueueCount = 0
|
||||||
@ -215,6 +226,7 @@ final class ScenicQueueRuntime {
|
|||||||
lastSpokenText = ""
|
lastSpokenText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 申请后台任务,保证进入后台时仍可短时轮询。
|
||||||
private func beginBackgroundTask() {
|
private func beginBackgroundTask() {
|
||||||
guard backgroundTaskId == .invalid else { return }
|
guard backgroundTaskId == .invalid else { return }
|
||||||
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
|
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
|
||||||
@ -225,6 +237,7 @@ final class ScenicQueueRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 结束后台任务。
|
||||||
private func endBackgroundTask() {
|
private func endBackgroundTask() {
|
||||||
guard backgroundTaskId != .invalid else { return }
|
guard backgroundTaskId != .invalid else { return }
|
||||||
UIApplication.shared.endBackgroundTask(backgroundTaskId)
|
UIApplication.shared.endBackgroundTask(backgroundTaskId)
|
||||||
@ -238,6 +251,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
private let synthesizer = AVSpeechSynthesizer()
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
|
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
synthesizer.delegate = self
|
synthesizer.delegate = self
|
||||||
@ -265,6 +279,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
resumePending()
|
resumePending()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 入队等待处理。
|
||||||
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
|
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
|
||||||
if replacePending {
|
if replacePending {
|
||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
@ -279,14 +294,17 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
synthesizer.speak(utterance)
|
synthesizer.speak(utterance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// speechSynthesizer相关逻辑。
|
||||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||||
Task { @MainActor in self.resumePending() }
|
Task { @MainActor in self.resumePending() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// speechSynthesizer相关逻辑。
|
||||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||||
Task { @MainActor in self.resumePending() }
|
Task { @MainActor in self.resumePending() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 恢复Pending流程。
|
||||||
private func resumePending() {
|
private func resumePending() {
|
||||||
let continuations = pendingContinuations
|
let continuations = pendingContinuations
|
||||||
pendingContinuations.removeAll()
|
pendingContinuations.removeAll()
|
||||||
|
|||||||
@ -74,6 +74,7 @@ final class ScenicQueueSocketClient {
|
|||||||
webSocketTask = nil
|
webSocketTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 持续接收 WebSocket 消息并分发处理。
|
||||||
private func receiveLoop(
|
private func receiveLoop(
|
||||||
task: URLSessionWebSocketTask,
|
task: URLSessionWebSocketTask,
|
||||||
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
|
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
|
||||||
@ -130,6 +131,7 @@ final class ScenicQueueSocketClient {
|
|||||||
return ScenicQueueSocketMessage(code: code, data: socketData)
|
return ScenicQueueSocketMessage(code: code, data: socketData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 安全地将任意值转为字符串。
|
||||||
private static func stringValue(_ value: Any?) -> String {
|
private static func stringValue(_ value: Any?) -> String {
|
||||||
switch value {
|
switch value {
|
||||||
case let value as String:
|
case let value as String:
|
||||||
@ -143,6 +145,7 @@ final class ScenicQueueSocketClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// int值相关逻辑。
|
||||||
private static func intValue(_ value: Any?) -> Int? {
|
private static func intValue(_ value: Any?) -> Int? {
|
||||||
if let value = value as? Int { return value }
|
if let value = value as? Int { return value }
|
||||||
if let value = value as? NSNumber { return value.intValue }
|
if let value = value as? NSNumber { return value.intValue }
|
||||||
@ -150,6 +153,7 @@ final class ScenicQueueSocketClient {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// int64值相关逻辑。
|
||||||
private static func int64Value(_ value: Any?) -> Int64? {
|
private static func int64Value(_ value: Any?) -> Int64? {
|
||||||
if let value = value as? Int64 { return value }
|
if let value = value as? Int64 { return value }
|
||||||
if let value = value as? Int { return Int64(value) }
|
if let value = value as? Int { return Int64(value) }
|
||||||
|
|||||||
@ -12,6 +12,7 @@ final class FeaturePlaceholderViewController: UIViewController {
|
|||||||
private let pageTitle: String
|
private let pageTitle: String
|
||||||
private let uri: String
|
private let uri: String
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(title: String, uri: String) {
|
init(title: String, uri: String) {
|
||||||
self.pageTitle = title
|
self.pageTitle = title
|
||||||
self.uri = uri
|
self.uri = uri
|
||||||
@ -23,6 +24,7 @@ final class FeaturePlaceholderViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
import Kingfisher
|
import Kingfisher
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// `UIImageView` 远程图片加载扩展,统一走 Kingfisher 缓存与占位策略。
|
||||||
extension UIImageView {
|
extension UIImageView {
|
||||||
/// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。
|
/// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。
|
||||||
func loadRemoteImage(
|
func loadRemoteImage(
|
||||||
@ -68,11 +69,13 @@ extension UIImageView {
|
|||||||
kf.cancelDownloadTask()
|
kf.cancelDownloadTask()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 规范化dURLString格式。
|
||||||
private func normalizedURLString(from urlString: String?) -> String? {
|
private func normalizedURLString(from urlString: String?) -> String? {
|
||||||
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return text.isEmpty ? nil : text
|
return text.isEmpty ? nil : text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// avatarPlaceholder相关逻辑。
|
||||||
private static func avatarPlaceholder(
|
private static func avatarPlaceholder(
|
||||||
systemImageName: String,
|
systemImageName: String,
|
||||||
iconSize: CGFloat,
|
iconSize: CGFloat,
|
||||||
@ -98,6 +101,7 @@ extension UIImageView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `UIImageView` 头像圆角辅助扩展。
|
||||||
extension UIImageView {
|
extension UIImageView {
|
||||||
/// 在布局变化后刷新圆形头像圆角。
|
/// 在布局变化后刷新圆形头像圆角。
|
||||||
func refreshRemoteAvatarCornerRadius() {
|
func refreshRemoteAvatarCornerRadius() {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ private final class ViewModelBindingToken {
|
|||||||
private weak var owner: AnyObject?
|
private weak var owner: AnyObject?
|
||||||
private let handler: () -> Void
|
private let handler: () -> Void
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(owner: AnyObject, handler: @escaping () -> Void) {
|
init(owner: AnyObject, handler: @escaping () -> Void) {
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.handler = handler
|
self.handler = handler
|
||||||
@ -33,17 +34,22 @@ private final class ViewModelBindingToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `UIViewController` 通用 UI 与全局反馈扩展。
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
|
/// 全局依赖容器快捷访问。
|
||||||
var appServices: AppServices { AppServices.shared }
|
var appServices: AppServices { AppServices.shared }
|
||||||
|
|
||||||
|
/// 展示全局 Toast 提示。
|
||||||
func showToast(_ message: String) {
|
func showToast(_ message: String) {
|
||||||
appServices.toastCenter.show(message)
|
appServices.toastCenter.show(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 展示全局 Loading 遮罩。
|
||||||
func showGlobalLoading(_ message: String = "") {
|
func showGlobalLoading(_ message: String = "") {
|
||||||
appServices.globalLoading.show(message: message)
|
appServices.globalLoading.show(message: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 隐藏GlobalLoading。
|
||||||
func hideGlobalLoading() {
|
func hideGlobalLoading() {
|
||||||
appServices.globalLoading.hide()
|
appServices.globalLoading.hide()
|
||||||
}
|
}
|
||||||
|
|||||||
173
suixinkan_ios/Core/UIKit/ListDiffableSupport.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import UIKit
|
|||||||
final class TitleSubtitleTableViewCell: UITableViewCell {
|
final class TitleSubtitleTableViewCell: UITableViewCell {
|
||||||
static let reuseIdentifier = "TitleSubtitleTableViewCell"
|
static let reuseIdentifier = "TitleSubtitleTableViewCell"
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .default
|
selectionStyle = .default
|
||||||
@ -28,6 +29,7 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 填充标题、副标题与详情文本,副标题优先于详情展示。
|
||||||
func configure(title: String, subtitle: String? = nil, detail: String? = nil) {
|
func configure(title: String, subtitle: String? = nil, detail: String? = nil) {
|
||||||
textLabel?.text = title
|
textLabel?.text = title
|
||||||
if let subtitle, !subtitle.isEmpty {
|
if let subtitle, !subtitle.isEmpty {
|
||||||
@ -38,18 +40,20 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模块列表页基类,封装 UITableView、下拉刷新和 ViewModel onChange 绑定。
|
/// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。
|
||||||
@MainActor
|
class ModuleTableViewController: UIViewController, UITableViewDelegate {
|
||||||
class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
|
||||||
let services = AppServices.shared
|
let services = AppServices.shared
|
||||||
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
private let refreshControl = UIRefreshControl()
|
private let refreshControl = UIRefreshControl()
|
||||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
private var viewModelReloadHandler: (() -> Void)?
|
private var viewModelReloadHandler: (() -> Void)?
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动列表展示与动画刷新。
|
||||||
|
private(set) var tableDataSource: UITableViewDiffableDataSource<ModuleTableSection, ModuleTableRow>!
|
||||||
|
|
||||||
var isLoading = false {
|
var isLoading = false {
|
||||||
didSet {
|
didSet {
|
||||||
if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 {
|
if isLoading, tableDataSource.snapshot().numberOfItems == 0 {
|
||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
} else {
|
} else {
|
||||||
activityIndicator.stopAnimating()
|
activityIndicator.stopAnimating()
|
||||||
@ -57,12 +61,19 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
|
||||||
tableView.dataSource = self
|
tableDataSource = UITableViewDiffableDataSource<ModuleTableSection, ModuleTableRow>(
|
||||||
|
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.delegate = self
|
||||||
tableView.backgroundColor = .clear
|
tableView.backgroundColor = .clear
|
||||||
tableView.register(
|
tableView.register(
|
||||||
@ -85,36 +96,45 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
|||||||
Task { await reloadContent() }
|
Task { await reloadContent() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 绑定 ViewModel 数据变更回调,子类在 `reloadContent` 完成后调用。
|
||||||
func bindViewModel(onChange: (() -> Void)?) {
|
func bindViewModel(onChange: (() -> Void)?) {
|
||||||
viewModelReloadHandler = onChange
|
viewModelReloadHandler = onChange
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadTable() {
|
/// 通过 Diffable snapshot 刷新列表,替代 reloadData。
|
||||||
tableView.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 }
|
func tableRowCount() -> Int { 0 }
|
||||||
|
|
||||||
|
/// 构建 Diffable snapshot,子类一般无需覆写。
|
||||||
|
func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<ModuleTableSection, ModuleTableRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<ModuleTableSection, ModuleTableRow>()
|
||||||
|
for section in 0..<numberOfTableSections() {
|
||||||
|
snapshot.appendSections([section])
|
||||||
|
let rows = (0..<tableRowCount(in: section)).map {
|
||||||
|
moduleTableRowID(section: section, row: $0)
|
||||||
|
}
|
||||||
|
snapshot.appendItems(rows, toSection: section)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置指定行的 Cell 内容,子类覆写以填充业务数据。
|
||||||
func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {}
|
func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {}
|
||||||
|
|
||||||
func didSelectTableRow(at indexPath: IndexPath) {}
|
/// 提供 Cell,子类可覆写以自定义多 section 展示。
|
||||||
|
func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||||
func reloadContent() async {}
|
|
||||||
|
|
||||||
@objc private func handleRefresh() {
|
|
||||||
Task {
|
|
||||||
await reloadContent()
|
|
||||||
refreshControl.endRefreshing()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
||||||
tableRowCount()
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
||||||
guard let cell = tableView.dequeueReusableCell(
|
guard let cell = tableView.dequeueReusableCell(
|
||||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
@ -125,19 +145,46 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
|||||||
return cell
|
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) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
didSelectTableRow(at: indexPath)
|
didSelectTableRow(at: indexPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UITableView 代理:行即将展示,用于分页加载等。
|
||||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
willDisplayTableRow(at: indexPath)
|
willDisplayTableRow(at: indexPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// willDisplayTableRow 回调处理。
|
||||||
func willDisplayTableRow(at indexPath: IndexPath) {}
|
func willDisplayTableRow(at indexPath: IndexPath) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ModuleTableViewController` 扩展,提供 ViewModel 绑定便捷方法。
|
||||||
extension ModuleTableViewController {
|
extension ModuleTableViewController {
|
||||||
|
/// 将 ViewModel 的 onChange 与列表刷新联动,并在绑定时立即刷新一次。
|
||||||
func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) {
|
func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) {
|
||||||
if let bindable = viewModel as? ViewModelBindable {
|
if let bindable = viewModel as? ViewModelBindable {
|
||||||
bindable.onChange = { [weak self] in
|
bindable.onChange = { [weak self] in
|
||||||
@ -146,6 +193,7 @@ extension ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reload()
|
reload()
|
||||||
|
reloadTable(animated: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,3 +202,140 @@ extension ModuleTableViewController {
|
|||||||
protocol ViewModelBindable: AnyObject {
|
protocol ViewModelBindable: AnyObject {
|
||||||
var onChange: (() -> Void)? { get set }
|
var onChange: (() -> Void)? { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 简单固定行列表基类,使用 UITableView Diffable,适用于设置、详情等页面。
|
||||||
|
class SimpleTableDiffableViewController: UIViewController, UITableViewDelegate {
|
||||||
|
let tableView: UITableView
|
||||||
|
|
||||||
|
private(set) var tableDataSource: UITableViewDiffableDataSource<SimpleTableSection, SimpleTableRow>!
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
|
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<SimpleTableSection, SimpleTableRow> {
|
||||||
|
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<SimpleTableSection, SimpleTableRow>(
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import UIKit
|
|||||||
final class CloudStorageViewController: ModuleTableViewController {
|
final class CloudStorageViewController: ModuleTableViewController {
|
||||||
private let viewModel = CloudStorageViewModel()
|
private let viewModel = CloudStorageViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "云盘"
|
title = "云盘"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -23,22 +24,27 @@ final class CloudStorageViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.files.count }
|
override func tableRowCount() -> Int { viewModel.files.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let file = viewModel.files[indexPath.row]
|
let file = viewModel.files[indexPath.row]
|
||||||
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
|
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(api: services.assetsAPI)
|
await viewModel.reload(api: services.assetsAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// willDisplayTableRow 回调处理。
|
||||||
override func willDisplayTableRow(at indexPath: IndexPath) {
|
override func willDisplayTableRow(at indexPath: IndexPath) {
|
||||||
guard indexPath.row >= viewModel.files.count - 2 else { return }
|
guard indexPath.row >= viewModel.files.count - 2 else { return }
|
||||||
Task { await viewModel.loadMore(api: services.assetsAPI) }
|
Task { await viewModel.loadMore(api: services.assetsAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// openTransit相关逻辑。
|
||||||
@objc private func openTransit() {
|
@objc private func openTransit() {
|
||||||
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
|
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
|
||||||
}
|
}
|
||||||
@ -50,22 +56,27 @@ extension CloudStorageViewModel: ViewModelBindable {}
|
|||||||
final class CloudStorageTransitViewController: ModuleTableViewController {
|
final class CloudStorageTransitViewController: ModuleTableViewController {
|
||||||
private let store = CloudTransferStore.shared
|
private let store = CloudTransferStore.shared
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "传输记录"
|
title = "传输记录"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
store.onChange = { [weak self] in self?.reloadTable() }
|
store.onChange = { [weak self] in self?.reloadTable() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { store.records.count }
|
override func tableRowCount() -> Int { store.records.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let record = store.records[indexPath.row]
|
let record = store.records[indexPath.row]
|
||||||
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
|
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {}
|
override func reloadContent() async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MediaLibraryKindRoute,业务类型定义。
|
||||||
enum MediaLibraryKindRoute {
|
enum MediaLibraryKindRoute {
|
||||||
case material
|
case material
|
||||||
case sample
|
case sample
|
||||||
@ -76,6 +87,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
|||||||
private let kind: MediaLibraryKindRoute
|
private let kind: MediaLibraryKindRoute
|
||||||
private let viewModel: MediaLibraryViewModel
|
private let viewModel: MediaLibraryViewModel
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(kind: MediaLibraryKindRoute = .material) {
|
init(kind: MediaLibraryKindRoute = .material) {
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
switch kind {
|
switch kind {
|
||||||
@ -92,6 +104,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = kind == .sample ? "样片库" : "素材库"
|
title = kind == .sample ? "样片库" : "素材库"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -104,17 +117,21 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.items.count }
|
override func tableRowCount() -> Int { viewModel.items.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let item = viewModel.items[indexPath.row]
|
let item = viewModel.items[indexPath.row]
|
||||||
cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt)
|
cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(api: services.assetsAPI)
|
await viewModel.reload(api: services.assetsAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// open上传相关逻辑。
|
||||||
@objc private func openUpload() {
|
@objc private func openUpload() {
|
||||||
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
|
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
|
||||||
}
|
}
|
||||||
@ -128,6 +145,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
|||||||
private let viewModel = MediaLibraryEditorViewModel()
|
private let viewModel = MediaLibraryEditorViewModel()
|
||||||
private let nameField = UITextField()
|
private let nameField = UITextField()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(kind: MediaLibraryKindRoute) {
|
init(kind: MediaLibraryKindRoute) {
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -138,6 +156,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = kind == .sample ? "上传样片" : "上传素材"
|
title = kind == .sample ? "上传样片" : "上传素材"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -154,17 +173,21 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.projects.count }
|
override func tableRowCount() -> Int { viewModel.projects.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let project = viewModel.projects[indexPath.row]
|
let project = viewModel.projects[indexPath.row]
|
||||||
cell.configure(title: project.name, subtitle: project.statusName)
|
cell.configure(title: project.name, subtitle: project.statusName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
|
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提交。
|
||||||
@objc private func submit() {
|
@objc private func submit() {
|
||||||
viewModel.name = nameField.text ?? ""
|
viewModel.name = nameField.text ?? ""
|
||||||
Task {
|
Task {
|
||||||
@ -186,19 +209,23 @@ extension MediaLibraryEditorViewModel: ViewModelBindable {}
|
|||||||
final class AlbumListViewController: ModuleTableViewController {
|
final class AlbumListViewController: ModuleTableViewController {
|
||||||
private let viewModel = AlbumListViewModel()
|
private let viewModel = AlbumListViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "相册"
|
title = "相册"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let folder = viewModel.folders[indexPath.row]
|
let folder = viewModel.folders[indexPath.row]
|
||||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
|
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||||
}
|
}
|
||||||
@ -210,19 +237,23 @@ extension AlbumListViewModel: ViewModelBindable {}
|
|||||||
final class AlbumTrailerViewController: ModuleTableViewController {
|
final class AlbumTrailerViewController: ModuleTableViewController {
|
||||||
private let viewModel = AlbumTrailerViewModel()
|
private let viewModel = AlbumTrailerViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "相册预告"
|
title = "相册预告"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let folder = viewModel.folders[indexPath.row]
|
let folder = viewModel.folders[indexPath.row]
|
||||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
|
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,14 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - Diffable 标识
|
||||||
|
|
||||||
|
private typealias AccountSelectionSection = Int
|
||||||
|
private typealias AccountSelectionItem = String
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。
|
/// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。
|
||||||
final class AccountSelectionViewController: UIViewController {
|
final class AccountSelectionViewController: UIViewController, UITableViewDelegate {
|
||||||
|
|
||||||
private let payload: AccountSelectionPayload
|
private let payload: AccountSelectionPayload
|
||||||
private var isLoading: Bool
|
private var isLoading: Bool
|
||||||
@ -17,9 +22,13 @@ final class AccountSelectionViewController: UIViewController {
|
|||||||
|
|
||||||
private var selectedAccountId: String?
|
private var selectedAccountId: String?
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动账号列表与选中态刷新。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>!
|
||||||
private let confirmButton = UIButton(type: .system)
|
private let confirmButton = UIButton(type: .system)
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(
|
init(
|
||||||
payload: AccountSelectionPayload,
|
payload: AccountSelectionPayload,
|
||||||
isLoading: Bool,
|
isLoading: Bool,
|
||||||
@ -38,6 +47,7 @@ final class AccountSelectionViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "选择账号"
|
title = "选择账号"
|
||||||
@ -47,9 +57,47 @@ final class AccountSelectionViewController: UIViewController {
|
|||||||
|
|
||||||
selectedAccountId = payload.accounts.first?.id
|
selectedAccountId = payload.accounts.first?.id
|
||||||
configureTableView()
|
configureTableView()
|
||||||
|
configureTableDataSource()
|
||||||
configureBottomBar()
|
configureBottomBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>(
|
||||||
|
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<AccountSelectionSection, AccountSelectionItem> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem>()
|
||||||
|
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) {
|
func updateLoading(_ loading: Bool) {
|
||||||
isLoading = loading
|
isLoading = loading
|
||||||
isModalInPresentation = loading
|
isModalInPresentation = loading
|
||||||
@ -65,10 +113,10 @@ final class AccountSelectionViewController: UIViewController {
|
|||||||
selectedAccount != nil && !isLoading
|
selectedAccount != nil && !isLoading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置TableView展示内容。
|
||||||
private func configureTableView() {
|
private func configureTableView() {
|
||||||
tableView.backgroundColor = .clear
|
tableView.backgroundColor = .clear
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.dataSource = self
|
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
|
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
@ -78,6 +126,7 @@ final class AccountSelectionViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置BottomBar展示内容。
|
||||||
private func configureBottomBar() {
|
private func configureBottomBar() {
|
||||||
bottomBar.backgroundColor = .white
|
bottomBar.backgroundColor = .white
|
||||||
|
|
||||||
@ -112,37 +161,26 @@ final class AccountSelectionViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击cancel的处理逻辑。
|
||||||
@objc private func cancelTapped() {
|
@objc private func cancelTapped() {
|
||||||
onCancel()
|
onCancel()
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击confirm的处理逻辑。
|
||||||
@objc private func confirmTapped() {
|
@objc private func confirmTapped() {
|
||||||
guard let selectedAccount else { return }
|
guard let selectedAccount else { return }
|
||||||
onConfirm(selectedAccount)
|
onConfirm(selectedAccount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate {
|
extension AccountSelectionViewController {
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
/// UITableView 代理:处理行选中。
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
selectedAccountId = payload.accounts[indexPath.row].id
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
tableView.reloadData()
|
guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
selectedAccountId = item
|
||||||
|
applyTableSnapshot(animated: false, reconfigure: true)
|
||||||
confirmButton.isEnabled = canConfirm
|
confirmButton.isEnabled = canConfirm
|
||||||
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
|
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
|
||||||
}
|
}
|
||||||
@ -161,6 +199,7 @@ private final class AccountSelectionCell: UITableViewCell {
|
|||||||
private let currentTag = UILabel()
|
private let currentTag = UILabel()
|
||||||
private let checkmark = UIImageView()
|
private let checkmark = UIImageView()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
@ -257,6 +296,7 @@ private final class AccountSelectionCell: UITableViewCell {
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置展示内容。
|
||||||
func configure(account: AccountSwitchAccount, selected: Bool) {
|
func configure(account: AccountSwitchAccount, selected: Bool) {
|
||||||
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
|
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
|
||||||
subtitleLabel.text = account.subtitle
|
subtitleLabel.text = account.subtitle
|
||||||
|
|||||||
@ -26,6 +26,7 @@ final class LoginViewController: UIViewController {
|
|||||||
private let privacyPolicyButton = UIButton(type: .system)
|
private let privacyPolicyButton = UIButton(type: .system)
|
||||||
private let loginButton = UIButton(type: .system)
|
private let loginButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(services: AppServices) {
|
init(services: AppServices) {
|
||||||
self.services = services
|
self.services = services
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -36,6 +37,7 @@ final class LoginViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor(hex: 0x0B1220)
|
view.backgroundColor = UIColor(hex: 0x0B1220)
|
||||||
@ -44,6 +46,7 @@ final class LoginViewController: UIViewController {
|
|||||||
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
|
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Views展示内容。
|
||||||
private func configureViews() {
|
private func configureViews() {
|
||||||
backgroundImageView.contentMode = .scaleAspectFill
|
backgroundImageView.contentMode = .scaleAspectFill
|
||||||
backgroundImageView.clipsToBounds = true
|
backgroundImageView.clipsToBounds = true
|
||||||
@ -161,6 +164,7 @@ final class LoginViewController: UIViewController {
|
|||||||
view.addGestureRecognizer(tap)
|
view.addGestureRecognizer(tap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置AgreementText展示内容。
|
||||||
private func configureAgreementText() {
|
private func configureAgreementText() {
|
||||||
privacyLabel.numberOfLines = 0
|
privacyLabel.numberOfLines = 0
|
||||||
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||||
@ -197,6 +201,7 @@ final class LoginViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 绑定ViewModel回调或数据。
|
||||||
private func bindViewModel() {
|
private func bindViewModel() {
|
||||||
viewModel.onChange = { [weak self] in
|
viewModel.onChange = { [weak self] in
|
||||||
self?.renderViewModel()
|
self?.renderViewModel()
|
||||||
@ -204,6 +209,7 @@ final class LoginViewController: UIViewController {
|
|||||||
renderViewModel()
|
renderViewModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// render视图模型相关逻辑。
|
||||||
private func renderViewModel() {
|
private func renderViewModel() {
|
||||||
if usernameField.textField.text != viewModel.username {
|
if usernameField.textField.text != viewModel.username {
|
||||||
usernameField.textField.text = viewModel.username
|
usernameField.textField.text = viewModel.username
|
||||||
@ -241,38 +247,46 @@ final class LoginViewController: UIViewController {
|
|||||||
|
|
||||||
private weak var accountSelectionController: AccountSelectionViewController?
|
private weak var accountSelectionController: AccountSelectionViewController?
|
||||||
|
|
||||||
|
/// username变更相关逻辑。
|
||||||
@objc private func usernameChanged() {
|
@objc private func usernameChanged() {
|
||||||
viewModel.username = usernameField.textField.text ?? ""
|
viewModel.username = usernameField.textField.text ?? ""
|
||||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||||
services.toastCenter.dismiss()
|
services.toastCenter.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// password变更相关逻辑。
|
||||||
@objc private func passwordChanged() {
|
@objc private func passwordChanged() {
|
||||||
viewModel.password = passwordField.textField.text ?? ""
|
viewModel.password = passwordField.textField.text ?? ""
|
||||||
services.toastCenter.dismiss()
|
services.toastCenter.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// togglePrivacy相关逻辑。
|
||||||
@objc private func togglePrivacy() {
|
@objc private func togglePrivacy() {
|
||||||
viewModel.privacyChecked.toggle()
|
viewModel.privacyChecked.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// openUserAgreement相关逻辑。
|
||||||
@objc private func openUserAgreement() {
|
@objc private func openUserAgreement() {
|
||||||
showToast("用户协议页面待接入")
|
showToast("用户协议页面待接入")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// openPrivacyPolicy相关逻辑。
|
||||||
@objc private func openPrivacyPolicy() {
|
@objc private func openPrivacyPolicy() {
|
||||||
showToast("隐私政策页面待接入")
|
showToast("隐私政策页面待接入")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击login的处理逻辑。
|
||||||
@objc private func loginTapped() {
|
@objc private func loginTapped() {
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
performLogin()
|
performLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// dismiss键盘相关逻辑。
|
||||||
@objc private func dismissKeyboard() {
|
@objc private func dismissKeyboard() {
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// perform登录相关逻辑。
|
||||||
private func performLogin() {
|
private func performLogin() {
|
||||||
if let validationError = viewModel.validateForLogin() {
|
if let validationError = viewModel.validateForLogin() {
|
||||||
if validationError == .privacyUnchecked {
|
if validationError == .privacyUnchecked {
|
||||||
@ -307,6 +321,7 @@ final class LoginViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出AgreementSheet页面。
|
||||||
private func presentAgreementSheet() {
|
private func presentAgreementSheet() {
|
||||||
let controller = LoginAgreementConsentViewController(
|
let controller = LoginAgreementConsentViewController(
|
||||||
onOpenAgreement: { [weak self] title in
|
onOpenAgreement: { [weak self] title in
|
||||||
@ -324,6 +339,7 @@ final class LoginViewController: UIViewController {
|
|||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出AccountSelection页面。
|
||||||
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
|
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
|
||||||
let controller = AccountSelectionViewController(
|
let controller = AccountSelectionViewController(
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@ -342,6 +358,7 @@ final class LoginViewController: UIViewController {
|
|||||||
present(navigation, animated: true)
|
present(navigation, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// select账号相关逻辑。
|
||||||
private func selectAccount(_ account: AccountSwitchAccount) {
|
private func selectAccount(_ account: AccountSwitchAccount) {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -359,6 +376,7 @@ final class LoginViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// complete登录相关逻辑。
|
||||||
private func completeLogin(with response: V9AuthResponse) async {
|
private func completeLogin(with response: V9AuthResponse) async {
|
||||||
do {
|
do {
|
||||||
try await services.authSessionCoordinator.completeLogin(
|
try await services.authSessionCoordinator.completeLogin(
|
||||||
@ -378,6 +396,7 @@ final class LoginViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension LoginViewController: UITextFieldDelegate {
|
extension LoginViewController: UITextFieldDelegate {
|
||||||
|
/// text字段ShouldReturn相关逻辑。
|
||||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
if textField === usernameField.textField {
|
if textField === usernameField.textField {
|
||||||
passwordField.textField.becomeFirstResponder()
|
passwordField.textField.becomeFirstResponder()
|
||||||
@ -396,6 +415,7 @@ private final class LoginInputField: UIView {
|
|||||||
private let toggleButton = UIButton(type: .custom)
|
private let toggleButton = UIButton(type: .custom)
|
||||||
private var isSecure = false
|
private var isSecure = false
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(iconName: String, placeholder: String, isSecure: Bool) {
|
init(iconName: String, placeholder: String, isSecure: Bool) {
|
||||||
self.isSecure = isSecure
|
self.isSecure = isSecure
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
@ -450,12 +470,14 @@ private final class LoginInputField: UIView {
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// setSecureEntry相关逻辑。
|
||||||
func setSecureEntry(_ secure: Bool) {
|
func setSecureEntry(_ secure: Bool) {
|
||||||
textField.isSecureTextEntry = secure
|
textField.isSecureTextEntry = secure
|
||||||
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
|
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
|
||||||
toggleButton.setImage(UIImage(named: imageName), for: .normal)
|
toggleButton.setImage(UIImage(named: imageName), for: .normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// toggleVisibility相关逻辑。
|
||||||
@objc private func toggleVisibility() {
|
@objc private func toggleVisibility() {
|
||||||
onToggleVisibility?()
|
onToggleVisibility?()
|
||||||
}
|
}
|
||||||
@ -467,6 +489,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
|||||||
private let onOpenAgreement: (String) -> Void
|
private let onOpenAgreement: (String) -> Void
|
||||||
private let onAgreeAndContinue: () -> Void
|
private let onAgreeAndContinue: () -> Void
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
|
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
|
||||||
self.onOpenAgreement = onOpenAgreement
|
self.onOpenAgreement = onOpenAgreement
|
||||||
self.onAgreeAndContinue = onAgreeAndContinue
|
self.onAgreeAndContinue = onAgreeAndContinue
|
||||||
@ -477,6 +500,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
@ -524,6 +548,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击continue的处理逻辑。
|
||||||
@objc private func continueTapped() {
|
@objc private func continueTapped() {
|
||||||
dismiss(animated: true) { [onAgreeAndContinue] in
|
dismiss(animated: true) { [onAgreeAndContinue] in
|
||||||
onAgreeAndContinue()
|
onAgreeAndContinue()
|
||||||
|
|||||||
@ -50,6 +50,7 @@ enum HomeMenuRouting {
|
|||||||
viewController.navigationController?.pushViewController(target, animated: true)
|
viewController.navigationController?.pushViewController(target, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push Placeholder页面。
|
||||||
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
|
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
|
||||||
viewController.navigationController?.pushViewController(
|
viewController.navigationController?.pushViewController(
|
||||||
FeaturePlaceholderViewController(title: title, uri: uri),
|
FeaturePlaceholderViewController(title: title, uri: uri),
|
||||||
|
|||||||
@ -6,6 +6,19 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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 {
|
final class HomeMoreFunctionsViewController: UIViewController {
|
||||||
|
|
||||||
@ -13,29 +26,38 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
|||||||
private let commonMenuStore = HomeCommonMenuStore()
|
private let commonMenuStore = HomeCommonMenuStore()
|
||||||
private var commonURIs: [String] = []
|
private var commonURIs: [String] = []
|
||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var collectionView: UICollectionView = {
|
||||||
let table = UITableView(frame: .zero, style: .grouped)
|
let layout = makeCollectionLayout()
|
||||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
table.separatorStyle = .none
|
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
table.dataSource = self
|
collection.delegate = self
|
||||||
table.delegate = self
|
collection.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
|
||||||
table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID)
|
collection.register(
|
||||||
return table
|
CollectionSectionHeaderView.self,
|
||||||
|
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||||
|
withReuseIdentifier: CollectionSectionHeaderView.reuseID
|
||||||
|
)
|
||||||
|
return collection
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动常用应用与更多功能两个网格 section。
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<HomeMoreSection, HomeMoreItem>!
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "全部功能"
|
title = "全部功能"
|
||||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
|
||||||
view.addSubview(tableView)
|
configureDataSource()
|
||||||
tableView.snp.makeConstraints { make in
|
view.addSubview(collectionView)
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.onChange = { [weak self] in
|
viewModel.onChange = { [weak self] in
|
||||||
self?.tableView.reloadData()
|
self?.applySnapshot()
|
||||||
}
|
}
|
||||||
rebuildMenus()
|
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<HomeMoreSection, HomeMoreItem>(
|
||||||
|
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<HomeMoreSection, HomeMoreItem>()
|
||||||
|
|
||||||
|
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() {
|
private func rebuildMenus() {
|
||||||
let services = appServices
|
let services = appServices
|
||||||
viewModel.buildMenus(
|
viewModel.buildMenus(
|
||||||
@ -51,7 +162,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
|||||||
currentRoleId: services.permissionContext.currentRole?.id
|
currentRoleId: services.permissionContext.currentRole?.id
|
||||||
)
|
)
|
||||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||||
tableView.reloadData()
|
applySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var commonItems: [HomeMenuItem] {
|
private var commonItems: [HomeMenuItem] {
|
||||||
@ -62,6 +173,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
|||||||
viewModel.menuItems.filter { !isCommonURI($0.uri) }
|
viewModel.menuItems.filter { !isCommonURI($0.uri) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按 URI 解析并返回可用菜单项。
|
||||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||||
@ -73,20 +185,23 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断 URI 是否已在常用应用中。
|
||||||
private func isCommonURI(_ uri: String) -> Bool {
|
private func isCommonURI(_ uri: String) -> Bool {
|
||||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||||
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
|
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换菜单项的常用应用状态。
|
||||||
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
|
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
|
||||||
if isCommon {
|
if isCommon {
|
||||||
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
|
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
|
||||||
} else {
|
} else {
|
||||||
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
|
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
|
||||||
}
|
}
|
||||||
tableView.reloadData()
|
applySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开菜单对应页面。
|
||||||
private func openMenu(_ item: HomeMenuItem) {
|
private func openMenu(_ item: HomeMenuItem) {
|
||||||
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||||
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
|
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
|
||||||
@ -94,108 +209,24 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate {
|
// MARK: - UICollectionViewDelegate
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
extension HomeMoreFunctionsViewController: UICollectionViewDelegate {
|
||||||
|
/// 点击网格项打开对应功能页(加减按钮由 Cell 内部处理)。
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
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 allItems = commonItems + moreItems
|
||||||
let width = (collectionView.bounds.width - 28) / 3
|
guard let menuItem = allItems.first(where: { $0.uri == uri }) else { return }
|
||||||
return CGSize(width: width, height: 112)
|
openMenu(menuItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Menu Item Cell
|
||||||
|
|
||||||
|
/// 全部功能页菜单网格单项 Cell,支持常用应用加减操作。
|
||||||
private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||||
static let reuseID = "HomeMoreMenuItemCell"
|
static let reuseID = "HomeMoreMenuItemCell"
|
||||||
|
|
||||||
@ -204,6 +235,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
|||||||
private let toggleButton = UIButton(type: .system)
|
private let toggleButton = UIButton(type: .system)
|
||||||
private var onToggle: (() -> Void)?
|
private var onToggle: (() -> Void)?
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
contentView.backgroundColor = .white
|
contentView.backgroundColor = .white
|
||||||
@ -243,6 +275,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置菜单项展示内容与常用应用切换按钮。
|
||||||
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
|
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
|
||||||
self.onToggle = onToggle
|
self.onToggle = onToggle
|
||||||
titleLabel.text = item.title
|
titleLabel.text = item.title
|
||||||
@ -254,7 +287,15 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
|||||||
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
|
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击加减按钮切换常用应用状态。
|
||||||
@objc private func toggleTapped() {
|
@objc private func toggleTapped() {
|
||||||
onToggle?()
|
onToggle?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 安全下标,避免 section 越界。
|
||||||
|
private extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ final class HomePlaceholderViewController: UIViewController {
|
|||||||
private let pageTitle: String
|
private let pageTitle: String
|
||||||
private let uri: String?
|
private let uri: String?
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(title: String, uri: String? = nil) {
|
init(title: String, uri: String? = nil) {
|
||||||
pageTitle = title
|
pageTitle = title
|
||||||
self.uri = uri
|
self.uri = uri
|
||||||
@ -24,6 +25,7 @@ final class HomePlaceholderViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = pageTitle
|
title = pageTitle
|
||||||
|
|||||||
@ -6,6 +6,26 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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 {
|
final class HomeViewController: UIViewController {
|
||||||
|
|
||||||
@ -34,40 +54,55 @@ final class HomeViewController: UIViewController {
|
|||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var collectionView: UICollectionView = {
|
||||||
let table = UITableView(frame: .zero, style: .grouped)
|
let layout = makeCollectionLayout()
|
||||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
table.separatorStyle = .none
|
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
table.showsVerticalScrollIndicator = false
|
collection.showsVerticalScrollIndicator = false
|
||||||
table.dataSource = self
|
collection.delegate = self
|
||||||
table.delegate = self
|
collection.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseID)
|
||||||
table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID)
|
collection.register(HomeLocationReportCell.self, forCellWithReuseIdentifier: HomeLocationReportCell.reuseID)
|
||||||
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
collection.register(HomeStoreInfoCell.self, forCellWithReuseIdentifier: HomeStoreInfoCell.reuseID)
|
||||||
return table
|
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<HomeSection, HomeItem>!
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
setupTopBar()
|
setupTopBar()
|
||||||
setupTableView()
|
configureDataSource()
|
||||||
|
setupCollectionView()
|
||||||
bindViewModel()
|
bindViewModel()
|
||||||
rebuildMenus()
|
rebuildMenus()
|
||||||
observeContextChanges()
|
observeContextChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图即将展示,刷新可见状态。
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||||
startCountdownTimerIfNeeded()
|
startCountdownTimerIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图即将消失,保存或清理临时状态。
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
countdownTimer?.invalidate()
|
countdownTimer?.invalidate()
|
||||||
countdownTimer = nil
|
countdownTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化 TopBar 相关 UI 或状态。
|
||||||
private func setupTopBar() {
|
private func setupTopBar() {
|
||||||
let topBar = UIView()
|
let topBar = UIView()
|
||||||
topBar.backgroundColor = .white
|
topBar.backgroundColor = .white
|
||||||
@ -85,20 +120,160 @@ final class HomeViewController: UIViewController {
|
|||||||
updateScenicTitle()
|
updateScenicTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupTableView() {
|
/// 初始化 CollectionView 布局约束。
|
||||||
view.addSubview(tableView)
|
private func setupCollectionView() {
|
||||||
tableView.snp.makeConstraints { make in
|
view.addSubview(collectionView)
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bindViewModel() {
|
/// 构建 Compositional Layout,按 section 类型分配全宽卡片或三列网格。
|
||||||
viewModel.onChange = { [weak self] in
|
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||||
self?.tableView.reloadData()
|
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<HomeSection, HomeItem>(
|
||||||
|
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<HomeSection, HomeItem>()
|
||||||
|
|
||||||
|
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() {
|
private func observeContextChanges() {
|
||||||
let services = appServices
|
let services = appServices
|
||||||
services.permissionContext.onChange = { [weak self] in
|
services.permissionContext.onChange = { [weak self] in
|
||||||
@ -106,10 +281,11 @@ final class HomeViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
services.accountContext.onChange = { [weak self] in
|
services.accountContext.onChange = { [weak self] in
|
||||||
self?.updateScenicTitle()
|
self?.updateScenicTitle()
|
||||||
self?.tableView.reloadData()
|
self?.applySnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按当前角色权限重建首页菜单与常用应用。
|
||||||
private func rebuildMenus() {
|
private func rebuildMenus() {
|
||||||
let services = appServices
|
let services = appServices
|
||||||
viewModel.buildMenus(
|
viewModel.buildMenus(
|
||||||
@ -117,9 +293,10 @@ final class HomeViewController: UIViewController {
|
|||||||
currentRoleId: services.permissionContext.currentRole?.id
|
currentRoleId: services.permissionContext.currentRole?.id
|
||||||
)
|
)
|
||||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||||
tableView.reloadData()
|
applySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新顶部景区名称展示。
|
||||||
private func updateScenicTitle() {
|
private func updateScenicTitle() {
|
||||||
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
|
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
|
||||||
scenicButton.configuration?.attributedTitle = AttributedString(
|
scenicButton.configuration?.attributedTitle = AttributedString(
|
||||||
@ -130,25 +307,30 @@ final class HomeViewController: UIViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前登录角色 ID。
|
||||||
private var currentRoleId: Int? {
|
private var currentRoleId: Int? {
|
||||||
appServices.permissionContext.currentRole?.id
|
appServices.permissionContext.currentRole?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 部分精简角色不展示工作状态与位置上报区块。
|
||||||
private var shouldShowWorkStatus: Bool {
|
private var shouldShowWorkStatus: Bool {
|
||||||
guard let currentRoleId else { return true }
|
guard let currentRoleId else { return true }
|
||||||
return !minimalTopRoleIds.contains(currentRoleId)
|
return !minimalTopRoleIds.contains(currentRoleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否为门店管理员角色(roleId = 46)。
|
||||||
private var isStoreManager: Bool {
|
private var isStoreManager: Bool {
|
||||||
currentRoleId == 46
|
currentRoleId == 46
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 常用应用展示项:优先用户自定义,不足 3 个时取权限菜单前 3 项,末尾固定「更多功能」。
|
||||||
private var displayMenuItems: [HomeMenuItem] {
|
private var displayMenuItems: [HomeMenuItem] {
|
||||||
let selected = commonURIs.compactMap { menuItem(for: $0) }
|
let selected = commonURIs.compactMap { menuItem(for: $0) }
|
||||||
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
||||||
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按 URI 解析并返回可用菜单项。
|
||||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
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
|
private func countdownText(seconds: Int) -> String {
|
||||||
let minutes = (secondsUntilReport % 3_600) / 60
|
let hours = seconds / 3_600
|
||||||
let seconds = secondsUntilReport % 60
|
let minutes = (seconds % 3_600) / 60
|
||||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
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() {
|
private func startCountdownTimerIfNeeded() {
|
||||||
countdownTimer?.invalidate()
|
countdownTimer?.invalidate()
|
||||||
guard isOnline else { return }
|
guard isOnline else { return }
|
||||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
|
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
|
||||||
self.secondsUntilReport -= 1
|
self.secondsUntilReport -= 1
|
||||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
self.applySnapshot(animated: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击景区名称,跳转景区选择页。
|
||||||
@objc private func scenicTapped() {
|
@objc private func scenicTapped() {
|
||||||
HomeMenuRouting.push(.scenicSelection, from: self)
|
HomeMenuRouting.push(.scenicSelection, from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换在线 / 离线状态。
|
||||||
@objc private func onlineTapped() {
|
@objc private func onlineTapped() {
|
||||||
let message = isOnline
|
let message = isOnline
|
||||||
? "是否确认切换为离线状态?离线后将暂停位置上报。"
|
? "是否确认切换为离线状态?离线后将暂停位置上报。"
|
||||||
@ -200,147 +387,137 @@ final class HomeViewController: UIViewController {
|
|||||||
} else {
|
} else {
|
||||||
self.countdownTimer?.invalidate()
|
self.countdownTimer?.invalidate()
|
||||||
}
|
}
|
||||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
self.applySnapshot()
|
||||||
})
|
})
|
||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 选择位置上报提前提醒时间。
|
||||||
@objc private func reminderTapped() {
|
@objc private func reminderTapped() {
|
||||||
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
|
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
|
||||||
for minute in [0, 5, 10, 15, 30] {
|
for minute in [0, 5, 10, 15, 30] {
|
||||||
let title = minute == 0 ? "不提醒" : "\(minute)分钟"
|
let title = minute == 0 ? "不提醒" : "\(minute)分钟"
|
||||||
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||||
self?.reminderMinutes = minute
|
self?.reminderMinutes = minute
|
||||||
self?.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
self?.applySnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
present(sheet, animated: true)
|
present(sheet, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跳转位置上报页面。
|
||||||
@objc private func locationReportTapped() {
|
@objc private func locationReportTapped() {
|
||||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
|
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跳转立即收款页面。
|
||||||
@objc private func paymentTapped() {
|
@objc private func paymentTapped() {
|
||||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
|
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跳转提交任务页面。
|
||||||
@objc private func taskCreateTapped() {
|
@objc private func taskCreateTapped() {
|
||||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
|
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableView
|
// MARK: - UICollectionViewDelegate
|
||||||
|
|
||||||
extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
extension HomeViewController: UICollectionViewDelegate {
|
||||||
func numberOfSections(in tableView: UITableView) -> Int {
|
/// 点击常用应用网格项,执行菜单路由。
|
||||||
var count = 1
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
if shouldShowWorkStatus { count += 2 }
|
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 }
|
case .menu(let uri) = item,
|
||||||
return count
|
let menuItem = displayMenuItems.first(where: { $0.uri == uri })
|
||||||
|
else { return }
|
||||||
|
HomeMenuRouting.openMenu(menuItem, from: self)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
// MARK: - Card Cells
|
||||||
1
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
/// 在线状态卡片 Cell,展示在线切换、倒计时与提醒设置。
|
||||||
if section == tableView.numberOfSections - 1 {
|
private final class HomeWorkStatusCell: UICollectionViewCell {
|
||||||
return "常用应用"
|
static let reuseID = "HomeWorkStatusCell"
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
private let cardView = UIView()
|
||||||
if indexPath.section == tableView.numberOfSections - 1 {
|
private let onlineButton = UIButton(type: .system)
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell
|
private let clockLabel = UILabel()
|
||||||
cell.configure(items: displayMenuItems) { [weak self] item in
|
private let reminderButton = UIButton(type: .system)
|
||||||
self.flatMap { HomeMenuRouting.openMenu(item, from: $0) }
|
|
||||||
}
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
/// 初始化实例。
|
||||||
cell.selectionStyle = .none
|
override init(frame: CGRect) {
|
||||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
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.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.layer.cornerRadius = 4
|
||||||
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
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.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||||
clockLabel.textColor = AppDesignUIKit.primary
|
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.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||||
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
|
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
|
||||||
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
|
||||||
|
|
||||||
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
|
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
|
||||||
stack.axis = .horizontal
|
stack.axis = .horizontal
|
||||||
stack.distribution = .equalSpacing
|
stack.distribution = .equalSpacing
|
||||||
stack.alignment = .center
|
stack.alignment = .center
|
||||||
card.addSubview(stack)
|
cardView.addSubview(stack)
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
stack.snp.makeConstraints { make in
|
stack.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(15)
|
make.edges.equalToSuperview().inset(15)
|
||||||
}
|
}
|
||||||
return card
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeLocationCard() -> UIView {
|
@available(*, unavailable)
|
||||||
let card = makeCardView()
|
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()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = "立即上报"
|
titleLabel.text = "立即上报"
|
||||||
@ -356,15 +533,16 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
textStack.axis = .vertical
|
textStack.axis = .vertical
|
||||||
textStack.spacing = AppMetrics.Spacing.xxSmall
|
textStack.spacing = AppMetrics.Spacing.xxSmall
|
||||||
|
|
||||||
let actionButton = UIButton(type: .system)
|
|
||||||
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
|
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
|
||||||
actionButton.tintColor = .white
|
actionButton.tintColor = .white
|
||||||
actionButton.backgroundColor = AppDesignUIKit.primary
|
actionButton.backgroundColor = AppDesignUIKit.primary
|
||||||
actionButton.layer.cornerRadius = 46
|
actionButton.layer.cornerRadius = 46
|
||||||
actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside)
|
|
||||||
|
|
||||||
card.addSubview(textStack)
|
cardView.addSubview(textStack)
|
||||||
card.addSubview(actionButton)
|
cardView.addSubview(actionButton)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
textStack.snp.makeConstraints { make in
|
textStack.snp.makeConstraints { make in
|
||||||
make.leading.top.bottom.equalToSuperview().inset(15)
|
make.leading.top.bottom.equalToSuperview().inset(15)
|
||||||
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
|
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
|
||||||
@ -374,72 +552,38 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
make.width.height.equalTo(92)
|
make.width.height.equalTo(92)
|
||||||
}
|
}
|
||||||
return card
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeQuickActionsRow() -> UIView {
|
@available(*, unavailable)
|
||||||
let stack = UIStackView()
|
required init?(coder: NSCoder) {
|
||||||
stack.axis = .horizontal
|
fatalError("init(coder:) has not been implemented")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView {
|
/// 配置立即上报按钮点击回调。
|
||||||
let card = makeCardView()
|
func configure(onReportTap: @escaping () -> Void) {
|
||||||
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
|
actionButton.removeAction(identifiedBy: UIAction.Identifier("report"), for: .touchUpInside)
|
||||||
|
actionButton.addAction(UIAction(identifier: UIAction.Identifier("report")) { _ in onReportTap() }, for: .touchUpInside)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeStoreCard(_ store: BusinessScope) -> UIView {
|
/// 门店信息卡片 Cell,展示当前门店名称与营业状态。
|
||||||
let card = makeCardView()
|
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)
|
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.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||||
scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
|
scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||||
scenicLabel.numberOfLines = 2
|
scenicLabel.numberOfLines = 2
|
||||||
@ -457,8 +601,11 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
textStack.axis = .vertical
|
textStack.axis = .vertical
|
||||||
textStack.spacing = AppMetrics.Spacing.xSmall
|
textStack.spacing = AppMetrics.Spacing.xSmall
|
||||||
|
|
||||||
card.addSubview(textStack)
|
cardView.addSubview(textStack)
|
||||||
card.addSubview(statusLabel)
|
cardView.addSubview(statusLabel)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
textStack.snp.makeConstraints { make in
|
textStack.snp.makeConstraints { make in
|
||||||
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||||
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
|
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
|
||||||
@ -468,39 +615,36 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
make.width.greaterThanOrEqualTo(52)
|
make.width.greaterThanOrEqualTo(52)
|
||||||
make.height.equalTo(22)
|
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 {
|
private let stackView = UIStackView()
|
||||||
static let reuseID = "HomeMenuGridCell"
|
|
||||||
|
|
||||||
private var onSelect: ((HomeMenuItem) -> Void)?
|
/// 初始化实例。
|
||||||
private var items: [HomeMenuItem] = []
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
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)
|
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
selectionStyle = .none
|
stackView.axis = .horizontal
|
||||||
contentView.addSubview(collectionView)
|
stackView.spacing = AppMetrics.Spacing.small
|
||||||
collectionView.snp.makeConstraints { make in
|
stackView.distribution = .fillEqually
|
||||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
contentView.addSubview(stackView)
|
||||||
make.height.equalTo(220)
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -509,40 +653,87 @@ private final class HomeMenuGridCell: UITableViewCell {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) {
|
/// 配置三个快捷操作按钮与点击回调。
|
||||||
self.items = items
|
func configure(
|
||||||
self.onSelect = onSelect
|
isOnline: Bool,
|
||||||
collectionView.reloadData()
|
onPaymentTap: @escaping () -> Void,
|
||||||
}
|
onTaskCreateTap: @escaping () -> Void,
|
||||||
}
|
onOnlineTap: @escaping () -> Void
|
||||||
|
) {
|
||||||
extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
stackView.addArrangedSubview(quickActionCard(
|
||||||
items.count
|
icon: "qrcode",
|
||||||
}
|
title: "立即收款",
|
||||||
|
active: false,
|
||||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
action: onPaymentTap
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell
|
))
|
||||||
cell.configure(item: items[indexPath.item])
|
stackView.addArrangedSubview(quickActionCard(
|
||||||
return cell
|
icon: "checklist.checked",
|
||||||
}
|
title: "提交任务",
|
||||||
|
active: false,
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
action: onTaskCreateTap
|
||||||
onSelect?(items[indexPath.item])
|
))
|
||||||
}
|
stackView.addArrangedSubview(quickActionCard(
|
||||||
|
icon: isOnline ? "wifi" : "wifi.slash",
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
title: isOnline ? "在线" : "离线",
|
||||||
let width = (collectionView.bounds.width - 30) / 3
|
active: isOnline,
|
||||||
return CGSize(width: width, height: 102)
|
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 {
|
private final class HomeMenuItemCell: UICollectionViewCell {
|
||||||
static let reuseID = "HomeMenuItemCell"
|
static let reuseID = "HomeMenuItemCell"
|
||||||
|
|
||||||
private let iconView = UIImageView()
|
private let iconView = UIImageView()
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
contentView.backgroundColor = .white
|
contentView.backgroundColor = .white
|
||||||
@ -574,6 +765,7 @@ private final class HomeMenuItemCell: UICollectionViewCell {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置菜单项图标与标题。
|
||||||
func configure(item: HomeMenuItem) {
|
func configure(item: HomeMenuItem) {
|
||||||
titleLabel.text = item.title
|
titleLabel.text = item.title
|
||||||
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
|
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
|
||||||
@ -581,3 +773,10 @@ private final class HomeMenuItemCell: UICollectionViewCell {
|
|||||||
iconView.tintColor = AppDesign.primary
|
iconView.tintColor = AppDesign.primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 安全下标,避免 section 越界。
|
||||||
|
private extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ final class PhotographerInviteViewController: UIViewController {
|
|||||||
private let rulesLabel = UILabel()
|
private let rulesLabel = UILabel()
|
||||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "邀请摄影师"
|
title = "邀请摄影师"
|
||||||
@ -29,6 +30,7 @@ final class PhotographerInviteViewController: UIViewController {
|
|||||||
Task { await viewModel.reload(api: services.inviteAPI) }
|
Task { await viewModel.reload(api: services.inviteAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化UI相关 UI 或状态。
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
navigationItem.rightBarButtonItems = [
|
navigationItem.rightBarButtonItems = [
|
||||||
UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)),
|
UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)),
|
||||||
@ -64,6 +66,7 @@ final class PhotographerInviteViewController: UIViewController {
|
|||||||
contentStack.addArrangedSubview(rulesLabel)
|
contentStack.addArrangedSubview(rulesLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// render 业务逻辑。
|
||||||
private func render() {
|
private func render() {
|
||||||
activityIndicator.isHidden = !viewModel.loading
|
activityIndicator.isHidden = !viewModel.loading
|
||||||
if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
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 copyCode() { viewModel.copyInviteCode() }
|
||||||
|
/// 复制链接。
|
||||||
@objc private func copyURL() { viewModel.copyInviteUrl() }
|
@objc private func copyURL() { viewModel.copyInviteUrl() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +92,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
|||||||
private let viewModel = InviteRecordViewModel()
|
private let viewModel = InviteRecordViewModel()
|
||||||
private let summaryLabel = UILabel()
|
private let summaryLabel = UILabel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "邀请记录"
|
title = "邀请记录"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -103,6 +109,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化Header相关 UI 或状态。
|
||||||
private func setupHeader() {
|
private func setupHeader() {
|
||||||
summaryLabel.font = .systemFont(ofSize: 14)
|
summaryLabel.font = .systemFont(ofSize: 14)
|
||||||
summaryLabel.textColor = AppDesign.textSecondary
|
summaryLabel.textColor = AppDesign.textSecondary
|
||||||
@ -112,10 +119,12 @@ final class InviteRecordViewController: ModuleTableViewController {
|
|||||||
tableView.tableHeaderView = summaryLabel
|
tableView.tableHeaderView = summaryLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int {
|
override func tableRowCount() -> Int {
|
||||||
viewModel.displayRows.count
|
viewModel.displayRows.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let row = viewModel.displayRows[indexPath.row]
|
let row = viewModel.displayRows[indexPath.row]
|
||||||
cell.configure(
|
cell.configure(
|
||||||
@ -125,6 +134,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(
|
await viewModel.reload(
|
||||||
inviteAPI: services.inviteAPI,
|
inviteAPI: services.inviteAPI,
|
||||||
@ -134,11 +144,13 @@ final class InviteRecordViewController: ModuleTableViewController {
|
|||||||
updateSummary()
|
updateSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新Summary状态。
|
||||||
private func updateSummary() {
|
private func updateSummary() {
|
||||||
summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)"
|
summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)"
|
||||||
navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户"
|
navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// toggleTab相关逻辑。
|
||||||
@objc private func toggleTab() {
|
@objc private func toggleTab() {
|
||||||
Task {
|
Task {
|
||||||
let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite
|
let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite
|
||||||
|
|||||||
@ -61,6 +61,7 @@ final class PhotographerInviteViewModel {
|
|||||||
return UIImage(cgImage: cgImage)
|
return UIImage(cgImage: cgImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空。
|
||||||
private func clear() {
|
private func clear() {
|
||||||
inviteCode = ""
|
inviteCode = ""
|
||||||
inviteUrl = ""
|
inviteUrl = ""
|
||||||
@ -128,6 +129,7 @@ final class InviteRecordViewModel {
|
|||||||
await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
|
await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载InviteRows数据。
|
||||||
private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws {
|
private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws {
|
||||||
let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize)
|
let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize)
|
||||||
let mapped = users.map {
|
let mapped = users.map {
|
||||||
@ -152,6 +154,7 @@ final class InviteRecordViewModel {
|
|||||||
hasMore = users.count >= invitePageSize
|
hasMore = users.count >= invitePageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载RewardRows数据。
|
||||||
private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws {
|
private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws {
|
||||||
let response = try await api.walletEarningDetail(
|
let response = try await api.walletEarningDetail(
|
||||||
startDate: "2025-01-01",
|
startDate: "2025-01-01",
|
||||||
@ -183,6 +186,7 @@ final class InviteRecordViewModel {
|
|||||||
hasMore = rewardRows.count < response.total && !mapped.isEmpty
|
hasMore = rewardRows.count < response.total && !mapped.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空。
|
||||||
private func clear() {
|
private func clear() {
|
||||||
totalRewardText = "¥ 0.00"
|
totalRewardText = "¥ 0.00"
|
||||||
withdrawableText = "¥ 0.00"
|
withdrawableText = "¥ 0.00"
|
||||||
|
|||||||
@ -10,17 +10,29 @@ import Foundation
|
|||||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol LiveServing {
|
protocol LiveServing {
|
||||||
|
/// 直播列表相关逻辑。
|
||||||
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
|
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
|
||||||
|
/// 直播详情相关逻辑。
|
||||||
func liveDetail(liveId: Int) async throws -> LiveEntity
|
func liveDetail(liveId: Int) async throws -> LiveEntity
|
||||||
|
/// 直播创建相关逻辑。
|
||||||
func liveCreate(_ request: LiveCreateRequest) async throws
|
func liveCreate(_ request: LiveCreateRequest) async throws
|
||||||
|
/// 直播启动相关逻辑。
|
||||||
func liveStart(liveId: Int) async throws
|
func liveStart(liveId: Int) async throws
|
||||||
|
/// 直播停止相关逻辑。
|
||||||
func liveStop(liveId: Int) async throws
|
func liveStop(liveId: Int) async throws
|
||||||
|
/// 直播Finish相关逻辑。
|
||||||
func liveFinish(liveId: Int) async throws
|
func liveFinish(liveId: Int) async throws
|
||||||
|
/// 直播Set推送Mode相关逻辑。
|
||||||
func liveSetPushMode(liveId: Int, mode: Int) async throws
|
func liveSetPushMode(liveId: Int, mode: Int) async throws
|
||||||
|
/// 直播相册列表相关逻辑。
|
||||||
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
||||||
|
/// 直播相册创建文件夹相关逻辑。
|
||||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
|
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
|
||||||
|
/// 直播相册删除文件夹相关逻辑。
|
||||||
func liveAlbumDeleteFolder(folderId: Int) async throws
|
func liveAlbumDeleteFolder(folderId: Int) async throws
|
||||||
|
/// 直播相册文件夹详情相关逻辑。
|
||||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
|
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
|
||||||
|
/// 直播相册删除Files相关逻辑。
|
||||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
|
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct LiveListResponse: Decodable {
|
|||||||
let page: Int
|
let page: Int
|
||||||
let pageSize: Int
|
let pageSize: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case items
|
case items
|
||||||
case total
|
case total
|
||||||
@ -21,6 +22,7 @@ struct LiveListResponse: Decodable {
|
|||||||
case pageSize = "page_size"
|
case pageSize = "page_size"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
|
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
|
||||||
self.items = items
|
self.items = items
|
||||||
self.total = total
|
self.total = total
|
||||||
@ -28,6 +30,7 @@ struct LiveListResponse: Decodable {
|
|||||||
self.pageSize = pageSize
|
self.pageSize = pageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
|
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
|
||||||
@ -57,6 +60,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
let manualPushState: Int
|
let manualPushState: Int
|
||||||
let viewsCount: Int
|
let viewsCount: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case title
|
case title
|
||||||
@ -77,6 +81,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
case viewsCount = "views_count"
|
case viewsCount = "views_count"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(
|
init(
|
||||||
id: Int = 0,
|
id: Int = 0,
|
||||||
title: String = "",
|
title: String = "",
|
||||||
@ -115,6 +120,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
self.viewsCount = viewsCount
|
self.viewsCount = viewsCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||||
@ -151,6 +157,7 @@ struct LiveCreateRequest: Encodable, Equatable {
|
|||||||
let title: String
|
let title: String
|
||||||
let coverImg: String
|
let coverImg: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicId = "scenic_id"
|
case scenicId = "scenic_id"
|
||||||
case title
|
case title
|
||||||
@ -162,6 +169,7 @@ struct LiveCreateRequest: Encodable, Equatable {
|
|||||||
struct LiveControlRequest: Encodable, Equatable {
|
struct LiveControlRequest: Encodable, Equatable {
|
||||||
let liveId: Int
|
let liveId: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case liveId = "live_id"
|
case liveId = "live_id"
|
||||||
}
|
}
|
||||||
@ -172,6 +180,7 @@ struct LivePushModeRequest: Encodable, Equatable {
|
|||||||
let liveId: Int
|
let liveId: Int
|
||||||
let manualPushMode: Int
|
let manualPushMode: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case liveId = "live_id"
|
case liveId = "live_id"
|
||||||
case manualPushMode = "manual_push_mode"
|
case manualPushMode = "manual_push_mode"
|
||||||
@ -186,6 +195,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
|||||||
let total: Int
|
let total: Int
|
||||||
let totalPages: Int
|
let totalPages: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case items
|
case items
|
||||||
case page
|
case page
|
||||||
@ -194,6 +204,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
|||||||
case totalPages = "total_pages"
|
case totalPages = "total_pages"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
|
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
|
||||||
self.items = items
|
self.items = items
|
||||||
self.page = page
|
self.page = page
|
||||||
@ -202,6 +213,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
|||||||
self.totalPages = totalPages
|
self.totalPages = totalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
|
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
|
||||||
@ -220,6 +232,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
|||||||
let creator: String
|
let creator: String
|
||||||
let items: [LiveAlbumFileItem]
|
let items: [LiveAlbumFileItem]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case albumId = "album_id"
|
case albumId = "album_id"
|
||||||
@ -228,6 +241,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
|||||||
case items
|
case items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
|
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.albumId = albumId
|
self.albumId = albumId
|
||||||
@ -236,6 +250,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
|||||||
self.items = items
|
self.items = items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||||
@ -254,6 +269,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
|||||||
let size: Int64
|
let size: Int64
|
||||||
let coverImg: String?
|
let coverImg: String?
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case url
|
case url
|
||||||
@ -262,6 +278,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
|||||||
case coverImg = "cover_img"
|
case coverImg = "cover_img"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
|
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.url = url
|
self.url = url
|
||||||
@ -270,6 +287,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
|||||||
self.coverImg = coverImg
|
self.coverImg = coverImg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||||
@ -295,6 +313,7 @@ struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
|
|||||||
let name: String
|
let name: String
|
||||||
let items: [LiveAlbumCreateFileItem]
|
let items: [LiveAlbumCreateFileItem]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicId = "scenic_id"
|
case scenicId = "scenic_id"
|
||||||
case name
|
case name
|
||||||
@ -309,6 +328,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
|||||||
let size: Int64
|
let size: Int64
|
||||||
let coverImg: String?
|
let coverImg: String?
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case url
|
case url
|
||||||
case type
|
case type
|
||||||
@ -321,6 +341,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
|||||||
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
|
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
|
||||||
let folderId: Int
|
let folderId: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case folderId = "folder_id"
|
case folderId = "folder_id"
|
||||||
}
|
}
|
||||||
@ -331,6 +352,7 @@ struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
|
|||||||
let folderId: Int
|
let folderId: Int
|
||||||
let fileIds: [Int]
|
let fileIds: [Int]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case folderId = "folder_id"
|
case folderId = "folder_id"
|
||||||
case fileIds = "file_ids"
|
case fileIds = "file_ids"
|
||||||
@ -346,6 +368,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
|||||||
let size: Int64
|
let size: Int64
|
||||||
var uploadedURL: String?
|
var uploadedURL: String?
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
|
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.data = data
|
self.data = data
|
||||||
@ -359,6 +382,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension KeyedDecodingContainer {
|
private extension KeyedDecodingContainer {
|
||||||
|
/// 直播解码宽松字符串相关逻辑。
|
||||||
func liveDecodeLossyString(forKey key: Key) throws -> String {
|
func liveDecodeLossyString(forKey key: Key) throws -> String {
|
||||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
@ -378,6 +402,7 @@ private extension KeyedDecodingContainer {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 直播解码宽松整数相关逻辑。
|
||||||
func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
|
func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -11,14 +11,17 @@ import UIKit
|
|||||||
final class LiveManagementViewController: ModuleTableViewController {
|
final class LiveManagementViewController: ModuleTableViewController {
|
||||||
private let viewModel = LiveManagementViewModel()
|
private let viewModel = LiveManagementViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "直播管理"
|
title = "直播管理"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.items.count }
|
override func tableRowCount() -> Int { viewModel.items.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let item = viewModel.items[indexPath.row]
|
let item = viewModel.items[indexPath.row]
|
||||||
cell.configure(
|
cell.configure(
|
||||||
@ -28,10 +31,12 @@ final class LiveManagementViewController: ModuleTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table视图相关逻辑。
|
||||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||||
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
|
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
|
||||||
@ -44,19 +49,23 @@ extension LiveManagementViewModel: ViewModelBindable {}
|
|||||||
final class LiveAlbumViewController: ModuleTableViewController {
|
final class LiveAlbumViewController: ModuleTableViewController {
|
||||||
private let viewModel = LiveAlbumViewModel()
|
private let viewModel = LiveAlbumViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "直播相册"
|
title = "直播相册"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let folder = viewModel.folders[indexPath.row]
|
let folder = viewModel.folders[indexPath.row]
|
||||||
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张")
|
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,12 @@ import Foundation
|
|||||||
|
|
||||||
/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。
|
/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。
|
||||||
enum LivePlaybackURLResolver {
|
enum LivePlaybackURLResolver {
|
||||||
|
/// playable链接相关逻辑。
|
||||||
static func playableURL(from live: LiveEntity) -> URL? {
|
static func playableURL(from live: LiveEntity) -> URL? {
|
||||||
playableURL(from: live.playbackURLCandidates)
|
playableURL(from: live.playbackURLCandidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// playable链接相关逻辑。
|
||||||
static func playableURL(from candidates: [String]) -> URL? {
|
static func playableURL(from candidates: [String]) -> URL? {
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
if let url = playableURL(from: candidate) {
|
if let url = playableURL(from: candidate) {
|
||||||
@ -23,6 +25,7 @@ enum LivePlaybackURLResolver {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// playable链接相关逻辑。
|
||||||
static func playableURL(from rawValue: String) -> URL? {
|
static func playableURL(from rawValue: String) -> URL? {
|
||||||
let value = rawValue.liveTrimmed
|
let value = rawValue.liveTrimmed
|
||||||
guard !value.isEmpty, let url = URL(string: value) else { return nil }
|
guard !value.isEmpty, let url = URL(string: value) else { return nil }
|
||||||
@ -65,6 +68,7 @@ final class LivePlaybackViewModel {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(live: LiveEntity? = nil, urlString: String? = nil) {
|
init(live: LiveEntity? = nil, urlString: String? = nil) {
|
||||||
if let live {
|
if let live {
|
||||||
load(live: live)
|
load(live: live)
|
||||||
@ -73,14 +77,17 @@ final class LivePlaybackViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载数据。
|
||||||
func load(live: LiveEntity) {
|
func load(live: LiveEntity) {
|
||||||
load(url: LivePlaybackURLResolver.playableURL(from: live))
|
load(url: LivePlaybackURLResolver.playableURL(from: live))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载数据。
|
||||||
func load(urlString: String) {
|
func load(urlString: String) {
|
||||||
load(url: LivePlaybackURLResolver.playableURL(from: urlString))
|
load(url: LivePlaybackURLResolver.playableURL(from: urlString))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// play 业务逻辑。
|
||||||
func play() {
|
func play() {
|
||||||
guard let url = playableURL else { return }
|
guard let url = playableURL else { return }
|
||||||
if player == nil {
|
if player == nil {
|
||||||
@ -90,12 +97,14 @@ final class LivePlaybackViewModel {
|
|||||||
state = .playing(url)
|
state = .playing(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pause 业务逻辑。
|
||||||
func pause() {
|
func pause() {
|
||||||
guard let url = playableURL else { return }
|
guard let url = playableURL else { return }
|
||||||
player?.pause()
|
player?.pause()
|
||||||
state = .ready(url)
|
state = .ready(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新。
|
||||||
func reload() {
|
func reload() {
|
||||||
guard let url = playableURL else { return }
|
guard let url = playableURL else { return }
|
||||||
releasePlayer()
|
releasePlayer()
|
||||||
@ -103,6 +112,7 @@ final class LivePlaybackViewModel {
|
|||||||
state = .ready(url)
|
state = .ready(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// release 业务逻辑。
|
||||||
func release() {
|
func release() {
|
||||||
releasePlayer()
|
releasePlayer()
|
||||||
if let url = playableURL {
|
if let url = playableURL {
|
||||||
@ -112,6 +122,7 @@ final class LivePlaybackViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载数据。
|
||||||
private func load(url: URL?) {
|
private func load(url: URL?) {
|
||||||
releasePlayer()
|
releasePlayer()
|
||||||
guard let url else {
|
guard let url else {
|
||||||
@ -124,6 +135,7 @@ final class LivePlaybackViewModel {
|
|||||||
state = .ready(url)
|
state = .ready(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// releasePlayer相关逻辑。
|
||||||
private func releasePlayer() {
|
private func releasePlayer() {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
player = nil
|
player = nil
|
||||||
|
|||||||
@ -47,25 +47,34 @@ enum LivePushNetworkState: Equatable {
|
|||||||
protocol LivePushAdapter {
|
protocol LivePushAdapter {
|
||||||
var name: String { get }
|
var name: String { get }
|
||||||
var isAvailable: Bool { get }
|
var isAvailable: Bool { get }
|
||||||
|
/// prepare 业务逻辑。
|
||||||
func prepare(pushURL: URL) async throws
|
func prepare(pushURL: URL) async throws
|
||||||
|
/// start 业务逻辑。
|
||||||
func start() async throws
|
func start() async throws
|
||||||
|
/// stop 业务逻辑。
|
||||||
func stop() async throws
|
func stop() async throws
|
||||||
|
/// dispose 业务逻辑。
|
||||||
func dispose() async
|
func dispose() async
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 权限提供者协议,隔离 AVFoundation 便于测试。
|
/// 权限提供者协议,隔离 AVFoundation 便于测试。
|
||||||
protocol LivePermissionProviding {
|
protocol LivePermissionProviding {
|
||||||
|
/// camera权限相关逻辑。
|
||||||
func cameraPermission() async -> LivePushPermissionState
|
func cameraPermission() async -> LivePushPermissionState
|
||||||
|
/// microphone权限相关逻辑。
|
||||||
func microphonePermission() async -> LivePushPermissionState
|
func microphonePermission() async -> LivePushPermissionState
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 网络监听协议,隔离 NWPathMonitor 便于测试。
|
/// 网络监听协议,隔离 NWPathMonitor 便于测试。
|
||||||
protocol LiveNetworkMonitoring: AnyObject {
|
protocol LiveNetworkMonitoring: AnyObject {
|
||||||
var currentState: LivePushNetworkState { get }
|
var currentState: LivePushNetworkState { get }
|
||||||
|
/// start 业务逻辑。
|
||||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
|
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
|
||||||
|
/// stop 业务逻辑。
|
||||||
func stop()
|
func stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LivePushReadiness错误类型定义。
|
||||||
enum LivePushReadinessError: LocalizedError, Equatable {
|
enum LivePushReadinessError: LocalizedError, Equatable {
|
||||||
case missingPushURL
|
case missingPushURL
|
||||||
case invalidPushURL
|
case invalidPushURL
|
||||||
@ -89,32 +98,41 @@ enum LivePushReadinessError: LocalizedError, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UnsupportedLivePushAdapter,业务类型定义。
|
||||||
struct UnsupportedLivePushAdapter: LivePushAdapter {
|
struct UnsupportedLivePushAdapter: LivePushAdapter {
|
||||||
let name = "未接入推流 SDK"
|
let name = "未接入推流 SDK"
|
||||||
let isAvailable = false
|
let isAvailable = false
|
||||||
|
|
||||||
|
/// prepare 业务逻辑。
|
||||||
func prepare(pushURL: URL) async throws {
|
func prepare(pushURL: URL) async throws {
|
||||||
throw LivePushReadinessError.sdkUnavailable
|
throw LivePushReadinessError.sdkUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// start 业务逻辑。
|
||||||
func start() async throws {
|
func start() async throws {
|
||||||
throw LivePushReadinessError.sdkUnavailable
|
throw LivePushReadinessError.sdkUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// stop 业务逻辑。
|
||||||
func stop() async throws {}
|
func stop() async throws {}
|
||||||
|
|
||||||
|
/// dispose 业务逻辑。
|
||||||
func dispose() async {}
|
func dispose() async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SystemLivePermission能力提供者,封装外部依赖。
|
||||||
struct SystemLivePermissionProvider: LivePermissionProviding {
|
struct SystemLivePermissionProvider: LivePermissionProviding {
|
||||||
|
/// camera权限相关逻辑。
|
||||||
func cameraPermission() async -> LivePushPermissionState {
|
func cameraPermission() async -> LivePushPermissionState {
|
||||||
await permission(for: .video)
|
await permission(for: .video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// microphone权限相关逻辑。
|
||||||
func microphonePermission() async -> LivePushPermissionState {
|
func microphonePermission() async -> LivePushPermissionState {
|
||||||
await permission(for: .audio)
|
await permission(for: .audio)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// permission 业务逻辑。
|
||||||
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
|
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
|
||||||
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
||||||
case .authorized:
|
case .authorized:
|
||||||
@ -130,6 +148,7 @@ struct SystemLivePermissionProvider: LivePermissionProviding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SystemLiveNetworkMonitor 类型定义。
|
||||||
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||||
|
|
||||||
var onChange: (() -> Void)?
|
var onChange: (() -> Void)?
|
||||||
@ -137,6 +156,7 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
|||||||
private let queue = DispatchQueue(label: "com.suixinkan.live.network")
|
private let queue = DispatchQueue(label: "com.suixinkan.live.network")
|
||||||
private(set) var currentState: LivePushNetworkState = .unknown
|
private(set) var currentState: LivePushNetworkState = .unknown
|
||||||
|
|
||||||
|
/// start 业务逻辑。
|
||||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
||||||
monitor.pathUpdateHandler = { [weak self] path in
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
let state = Self.state(from: path)
|
let state = Self.state(from: path)
|
||||||
@ -146,10 +166,12 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
|||||||
monitor.start(queue: queue)
|
monitor.start(queue: queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// stop 业务逻辑。
|
||||||
func stop() {
|
func stop() {
|
||||||
monitor.cancel()
|
monitor.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// state 业务逻辑。
|
||||||
private static func state(from path: NWPath) -> LivePushNetworkState {
|
private static func state(from path: NWPath) -> LivePushNetworkState {
|
||||||
guard path.status == .satisfied else { return .unavailable }
|
guard path.status == .satisfied else { return .unavailable }
|
||||||
if path.usesInterfaceType(.wifi) { return .wifi }
|
if path.usesInterfaceType(.wifi) { return .wifi }
|
||||||
@ -177,6 +199,7 @@ final class LivePushReadinessViewModel {
|
|||||||
private let adapter: any LivePushAdapter
|
private let adapter: any LivePushAdapter
|
||||||
private var pushURL: URL?
|
private var pushURL: URL?
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(
|
init(
|
||||||
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
|
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
|
||||||
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
|
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
|
||||||
@ -190,6 +213,7 @@ final class LivePushReadinessViewModel {
|
|||||||
self.networkState = networkMonitor.currentState
|
self.networkState = networkMonitor.currentState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置展示内容。
|
||||||
func configure(pushURL rawValue: String) {
|
func configure(pushURL rawValue: String) {
|
||||||
let value = rawValue.liveTrimmed
|
let value = rawValue.liveTrimmed
|
||||||
guard !value.isEmpty else {
|
guard !value.isEmpty else {
|
||||||
@ -206,6 +230,7 @@ final class LivePushReadinessViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// startMonitoring相关逻辑。
|
||||||
func startMonitoring() {
|
func startMonitoring() {
|
||||||
networkState = networkMonitor.currentState
|
networkState = networkMonitor.currentState
|
||||||
networkMonitor.start { [weak self] state in
|
networkMonitor.start { [weak self] state in
|
||||||
@ -215,10 +240,12 @@ final class LivePushReadinessViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// stopMonitoring相关逻辑。
|
||||||
func stopMonitoring() {
|
func stopMonitoring() {
|
||||||
networkMonitor.stop()
|
networkMonitor.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// refreshPermissions相关逻辑。
|
||||||
func refreshPermissions() async {
|
func refreshPermissions() async {
|
||||||
async let camera = permissionProvider.cameraPermission()
|
async let camera = permissionProvider.cameraPermission()
|
||||||
async let microphone = permissionProvider.microphonePermission()
|
async let microphone = permissionProvider.microphonePermission()
|
||||||
@ -226,6 +253,7 @@ final class LivePushReadinessViewModel {
|
|||||||
microphonePermission = await microphone
|
microphonePermission = await microphone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// runDiagnostics相关逻辑。
|
||||||
func runDiagnostics() throws {
|
func runDiagnostics() throws {
|
||||||
do {
|
do {
|
||||||
try validateReadiness()
|
try validateReadiness()
|
||||||
@ -240,6 +268,7 @@ final class LivePushReadinessViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// prepare 业务逻辑。
|
||||||
func prepare() async throws {
|
func prepare() async throws {
|
||||||
try validateReadiness()
|
try validateReadiness()
|
||||||
guard let pushURL else { throw LivePushReadinessError.missingPushURL }
|
guard let pushURL else { throw LivePushReadinessError.missingPushURL }
|
||||||
@ -253,6 +282,7 @@ final class LivePushReadinessViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// start推送相关逻辑。
|
||||||
func startPush() async throws {
|
func startPush() async throws {
|
||||||
try validateReadiness()
|
try validateReadiness()
|
||||||
guard adapter.isAvailable else {
|
guard adapter.isAvailable else {
|
||||||
@ -265,11 +295,13 @@ final class LivePushReadinessViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// stop推送相关逻辑。
|
||||||
func stopPush() async {
|
func stopPush() async {
|
||||||
try? await adapter.stop()
|
try? await adapter.stop()
|
||||||
running = false
|
running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// dispose 业务逻辑。
|
||||||
func dispose() async {
|
func dispose() async {
|
||||||
stopMonitoring()
|
stopMonitoring()
|
||||||
await adapter.dispose()
|
await adapter.dispose()
|
||||||
@ -277,6 +309,7 @@ final class LivePushReadinessViewModel {
|
|||||||
prepared = false
|
prepared = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验Readiness输入或状态。
|
||||||
private func validateReadiness() throws {
|
private func validateReadiness() throws {
|
||||||
guard pushURL != nil else {
|
guard pushURL != nil else {
|
||||||
throw LivePushReadinessError.missingPushURL
|
throw LivePushReadinessError.missingPushURL
|
||||||
|
|||||||
@ -104,6 +104,7 @@ final class LiveManagementViewModel {
|
|||||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载Page数据。
|
||||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||||
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
|
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||||
if page == 1 {
|
if page == 1 {
|
||||||
@ -118,6 +119,7 @@ final class LiveManagementViewModel {
|
|||||||
hasMore = items.count < total
|
hasMore = items.count < total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置状态。
|
||||||
private func reset() {
|
private func reset() {
|
||||||
clearListAndDetail()
|
clearListAndDetail()
|
||||||
loading = false
|
loading = false
|
||||||
@ -125,6 +127,7 @@ final class LiveManagementViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空列表并详情。
|
||||||
private func clearListAndDetail() {
|
private func clearListAndDetail() {
|
||||||
items = []
|
items = []
|
||||||
detail = nil
|
detail = nil
|
||||||
@ -143,6 +146,7 @@ final class LiveDetailViewModel {
|
|||||||
var actionInFlight = false { didSet { onChange?() } }
|
var actionInFlight = false { didSet { onChange?() } }
|
||||||
var errorMessage: String? { didSet { onChange?() } }
|
var errorMessage: String? { didSet { onChange?() } }
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(detail: LiveEntity) {
|
init(detail: LiveEntity) {
|
||||||
self.detail = detail
|
self.detail = detail
|
||||||
}
|
}
|
||||||
@ -265,6 +269,7 @@ final class LiveAlbumViewModel {
|
|||||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载Page数据。
|
||||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||||
let response = try await api.liveAlbumList(
|
let response = try await api.liveAlbumList(
|
||||||
scenicId: scenicId,
|
scenicId: scenicId,
|
||||||
@ -284,6 +289,7 @@ final class LiveAlbumViewModel {
|
|||||||
hasMore = folders.count < total
|
hasMore = folders.count < total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置状态。
|
||||||
private func reset() {
|
private func reset() {
|
||||||
clearFolders()
|
clearFolders()
|
||||||
loading = false
|
loading = false
|
||||||
@ -291,6 +297,7 @@ final class LiveAlbumViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空Folders。
|
||||||
private func clearFolders() {
|
private func clearFolders() {
|
||||||
folders = []
|
folders = []
|
||||||
page = 1
|
page = 1
|
||||||
@ -377,6 +384,7 @@ final class LiveAlbumPreviewViewModel {
|
|||||||
var loading = false { didSet { onChange?() } }
|
var loading = false { didSet { onChange?() } }
|
||||||
var errorMessage: String? { didSet { onChange?() } }
|
var errorMessage: String? { didSet { onChange?() } }
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||||||
self.folderId = folderId
|
self.folderId = folderId
|
||||||
currentIndex = max(startIndex, 0)
|
currentIndex = max(startIndex, 0)
|
||||||
|
|||||||
@ -39,6 +39,7 @@ struct LocationReportSubmitResponse: Decodable, Equatable {
|
|||||||
let expired: Int
|
let expired: Int
|
||||||
let status: Int
|
let status: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case staffId = "staff_id"
|
case staffId = "staff_id"
|
||||||
case expired
|
case expired
|
||||||
@ -73,6 +74,7 @@ struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
|
|||||||
let remark: String
|
let remark: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case staffId = "staff_id"
|
case staffId = "staff_id"
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import UIKit
|
|||||||
final class LocationReportViewController: ModuleTableViewController {
|
final class LocationReportViewController: ModuleTableViewController {
|
||||||
private let viewModel = LocationReportViewModel()
|
private let viewModel = LocationReportViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "位置上报"
|
title = "位置上报"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -23,17 +24,21 @@ final class LocationReportViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { }
|
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
|
section == 0 ? 3 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
/// section 标题。
|
||||||
|
override func tableSectionTitle(for section: Int) -> String? {
|
||||||
section == 0 ? "状态" : "操作"
|
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(
|
let cell = tableView.dequeueReusableCell(
|
||||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
@ -55,8 +60,8 @@ final class LocationReportViewController: ModuleTableViewController {
|
|||||||
return cell
|
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 {
|
if indexPath.section == 1 {
|
||||||
Task {
|
Task {
|
||||||
_ = await viewModel.setOnline(
|
_ = await viewModel.setOnline(
|
||||||
@ -69,10 +74,12 @@ final class LocationReportViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入")
|
viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提交Report。
|
||||||
@objc private func submitReport() {
|
@objc private func submitReport() {
|
||||||
Task {
|
Task {
|
||||||
_ = await viewModel.submit(
|
_ = await viewModel.submit(
|
||||||
@ -91,25 +98,30 @@ extension LocationReportViewModel: ViewModelBindable {}
|
|||||||
final class LocationReportHistoryViewController: ModuleTableViewController {
|
final class LocationReportHistoryViewController: ModuleTableViewController {
|
||||||
private let viewModel = LocationReportHistoryViewModel()
|
private let viewModel = LocationReportHistoryViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "上报历史"
|
title = "上报历史"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int {
|
override func tableRowCount() -> Int {
|
||||||
viewModel.items.count
|
viewModel.items.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let record = viewModel.items[indexPath.row]
|
let record = viewModel.items[indexPath.row]
|
||||||
cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt)
|
cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI)
|
await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table视图相关逻辑。
|
||||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||||
Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) }
|
Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) }
|
||||||
|
|||||||
@ -2,14 +2,9 @@
|
|||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
|
|
||||||
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
|
Main 模块负责登录后的主界面 Tab 容器。
|
||||||
|
|
||||||
主界面由 `MainTabsView` 承载:
|
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。
|
||||||
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。
|
|
||||||
- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。
|
|
||||||
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
|
|
||||||
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
|
|
||||||
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
|
|
||||||
|
|
||||||
## Tab 结构
|
## Tab 结构
|
||||||
|
|
||||||
@ -19,41 +14,33 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
|
|||||||
- `statistics`:数据
|
- `statistics`:数据
|
||||||
- `profile`:我的
|
- `profile`:我的
|
||||||
|
|
||||||
`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。
|
各 Tab 根页面已接入真实业务页面:`HomeViewController`、`OrdersViewController`、`StatisticsViewController`、`ProfileViewController`。
|
||||||
|
|
||||||
## 导航流程
|
## 导航流程
|
||||||
|
|
||||||
1. `MainTabsView` 从 Environment 读取 `AppRouter`。
|
1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源。
|
||||||
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。
|
2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步。
|
||||||
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。
|
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。
|
||||||
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。
|
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar,默认隐藏。
|
||||||
5. 路径绑定来自 `appRouter.binding(for:)`。
|
5. 跨 Tab 跳转通过 `AppRouter.select` 或 `AppRouter.selectOrders(entry:)` 处理。
|
||||||
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。
|
|
||||||
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
|
|
||||||
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
|
|
||||||
|
|
||||||
## 自定义 TabBar
|
## 订单角标
|
||||||
|
|
||||||
自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成:
|
|
||||||
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
|
|
||||||
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
|
|
||||||
- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。
|
|
||||||
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
|
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
|
||||||
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。
|
- 角标展示在订单 Tab 的 `tabBarItem.badgeValue` 上。
|
||||||
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。
|
- 账号景区或门店变化、切换到订单 Tab 时会刷新角标。
|
||||||
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。
|
|
||||||
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
|
|
||||||
|
|
||||||
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。
|
## 扫码核销
|
||||||
|
|
||||||
|
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
|
||||||
|
|
||||||
## 后续迁移规则
|
## 后续迁移规则
|
||||||
|
|
||||||
迁移新页面时:
|
迁移新页面时:
|
||||||
- 优先替换对应 Tab 的 RootView。
|
- 优先替换对应 Tab 的根 ViewController。
|
||||||
- 保持每个 Tab 自己的 `NavigationStack`。
|
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。
|
||||||
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
|
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
|
||||||
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。
|
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。
|
||||||
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
|
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
|
||||||
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
||||||
- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。
|
|
||||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||||
|
|||||||
@ -3,24 +3,22 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
import SnapKit
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。
|
/// 主 Tab 容器,使用系统 `UITabBarController` 展示四个一级入口。
|
||||||
final class MainTabBarController: UIViewController {
|
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
|
||||||
|
|
||||||
private let services: AppServices
|
private let services: AppServices
|
||||||
private let badgeViewModel = MainTabBadgeViewModel()
|
private let badgeViewModel = MainTabBadgeViewModel()
|
||||||
private let contentContainer = UIView()
|
|
||||||
private let customTabBar = CustomMainTabBarView()
|
|
||||||
|
|
||||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||||
private var loadedTabs: Set<AppTab> = [.home]
|
private var isSyncingTabSelection = false
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(services: AppServices) {
|
init(services: AppServices) {
|
||||||
self.services = services
|
self.services = services
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .white
|
configureTabBarAppearance()
|
||||||
configureLayout()
|
configureTabs()
|
||||||
bindRouter()
|
bindRouter()
|
||||||
bindBadgeViewModel()
|
bindBadgeViewModel()
|
||||||
bindAccountContext()
|
bindAccountContext()
|
||||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
syncSelectedTabFromRouter(animated: false)
|
||||||
Task { await refreshOrderBadge() }
|
Task { await refreshOrderBadge() }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 控制自定义 TabBar 显隐,push 子页面时隐藏。
|
/// 配置系统 TabBar 外观。
|
||||||
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) {
|
private func configureTabBarAppearance() {
|
||||||
let updates = {
|
tabBar.tintColor = AppDesign.primary
|
||||||
self.customTabBar.alpha = hidden ? 0 : 1
|
tabBar.backgroundColor = .white
|
||||||
self.customTabBar.isUserInteractionEnabled = !hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
guard animated else {
|
|
||||||
updates()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureLayout() {
|
/// 为每个 Tab 创建独立导航栈并绑定 TabBarItem。
|
||||||
view.addSubview(contentContainer)
|
private func configureTabs() {
|
||||||
view.addSubview(customTabBar)
|
viewControllers = AppTab.allCases.map { tab in
|
||||||
|
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||||
customTabBar.onTabSelected = { [weak self] tab in
|
navigationController.tabBarItem = tab.makeTabBarItem()
|
||||||
self?.services.appRouter.select(tab)
|
tabNavigationControllers[tab] = navigationController
|
||||||
}
|
return navigationController
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 绑定 Router 回调,同步程序化 Tab 切换。
|
||||||
private func bindRouter() {
|
private func bindRouter() {
|
||||||
services.appRouter.onChange = { [weak self] in
|
services.appRouter.onChange = { [weak self] in
|
||||||
self?.handleRouterChange()
|
self?.handleRouterChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 绑定角标 ViewModel 回调。
|
||||||
private func bindBadgeViewModel() {
|
private func bindBadgeViewModel() {
|
||||||
badgeViewModel.onChange = { [weak self] in
|
badgeViewModel.onChange = { [weak self] in
|
||||||
self?.updateOrderBadge()
|
self?.updateOrderBadge()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 绑定账号上下文,景区或门店变化时刷新角标。
|
||||||
private func bindAccountContext() {
|
private func bindAccountContext() {
|
||||||
services.accountContext.onChange = { [weak self] in
|
services.accountContext.onChange = { [weak self] in
|
||||||
Task { await self?.refreshOrderBadge() }
|
Task { await self?.refreshOrderBadge() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理 Router 变更,保持选中 Tab 与 `AppRouter` 一致。
|
||||||
private func handleRouterChange() {
|
private func handleRouterChange() {
|
||||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
syncSelectedTabFromRouter(animated: false)
|
||||||
if services.appRouter.selectedTab == .orders {
|
if services.appRouter.selectedTab == .orders {
|
||||||
Task { await refreshOrderBadge() }
|
Task { await refreshOrderBadge() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func switchToTab(_ tab: AppTab, animated: Bool) {
|
/// 将 `AppRouter.selectedTab` 同步到系统 TabBar 选中项。
|
||||||
loadedTabs.insert(tab)
|
private func syncSelectedTabFromRouter(animated: Bool) {
|
||||||
customTabBar.selectedTab = tab
|
guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return }
|
||||||
|
guard selectedIndex != index else { return }
|
||||||
|
|
||||||
let navigationController = navigationController(for: tab)
|
isSyncingTabSelection = true
|
||||||
for child in children where child !== navigationController {
|
selectedIndex = index
|
||||||
child.willMove(toParent: nil)
|
isSyncingTabSelection = false
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新订单 Tab 待核销角标。
|
||||||
private func updateOrderBadge() {
|
private func updateOrderBadge() {
|
||||||
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
|
let badgeValue: String?
|
||||||
customTabBar.orderBadgeText = nil
|
if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
|
||||||
return
|
badgeValue = "\(count)"
|
||||||
|
} else {
|
||||||
|
badgeValue = nil
|
||||||
}
|
}
|
||||||
customTabBar.orderBadgeText = "\(count)"
|
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新订单 Tab 待核销角标数据。
|
||||||
private func refreshOrderBadge() async {
|
private func refreshOrderBadge() async {
|
||||||
await badgeViewModel.refreshPendingWriteOffCount(
|
await badgeViewModel.refreshPendingWriteOffCount(
|
||||||
api: services.ordersAPI,
|
api: services.ordersAPI,
|
||||||
@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentScanner() {
|
/// 用户点击 TabBar 时同步选中状态到 `AppRouter`。
|
||||||
let scanner = OrderCodeScannerViewController()
|
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
|
||||||
scanner.onScanResult = { [weak self] result in
|
guard !isSyncingTabSelection,
|
||||||
guard let self else { return }
|
let navigationController = viewController as? TabNavigationController else { return }
|
||||||
switch result {
|
|
||||||
case .success(let rawCode):
|
services.appRouter.select(navigationController.appTab)
|
||||||
dismiss(animated: true) {
|
if navigationController.appTab == .orders {
|
||||||
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode)
|
Task { await refreshOrderBadge() }
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
showToast(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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?()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ final class PlaceholderViewController: UIViewController {
|
|||||||
|
|
||||||
private let pageTitle: String
|
private let pageTitle: String
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(title: String) {
|
init(title: String) {
|
||||||
pageTitle = title
|
pageTitle = title
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct MessageListResponse: Decodable, Equatable {
|
|||||||
let lastId: Int
|
let lastId: Int
|
||||||
let items: [MessageEntity]
|
let items: [MessageEntity]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case hasMore = "has_more"
|
case hasMore = "has_more"
|
||||||
case lastId = "last_id"
|
case lastId = "last_id"
|
||||||
@ -47,6 +48,7 @@ struct MessageEntity: Decodable, Equatable, Identifiable {
|
|||||||
let createdAt: String
|
let createdAt: String
|
||||||
let isRead: Bool
|
let isRead: Bool
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case type
|
case type
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import UIKit
|
|||||||
final class MessageCenterViewController: ModuleTableViewController {
|
final class MessageCenterViewController: ModuleTableViewController {
|
||||||
private let viewModel = MessageCenterViewModel()
|
private let viewModel = MessageCenterViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "消息中心"
|
title = "消息中心"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -23,10 +24,12 @@ final class MessageCenterViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() }
|
wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int {
|
override func tableRowCount() -> Int {
|
||||||
viewModel.messages.count
|
viewModel.messages.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let item = viewModel.messages[indexPath.row]
|
let item = viewModel.messages[indexPath.row]
|
||||||
cell.configure(
|
cell.configure(
|
||||||
@ -37,6 +40,7 @@ final class MessageCenterViewController: ModuleTableViewController {
|
|||||||
cell.accessoryType = item.isRead ? .none : .disclosureIndicator
|
cell.accessoryType = item.isRead ? .none : .disclosureIndicator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// didSelectTableRow 回调处理。
|
||||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||||
let item = viewModel.messages[indexPath.row]
|
let item = viewModel.messages[indexPath.row]
|
||||||
Task {
|
Task {
|
||||||
@ -45,20 +49,24 @@ final class MessageCenterViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
isLoading = viewModel.messages.isEmpty
|
isLoading = viewModel.messages.isEmpty
|
||||||
await viewModel.reloadFirstPage(api: services.messageCenterAPI)
|
await viewModel.reloadFirstPage(api: services.messageCenterAPI)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mark全部Read相关逻辑。
|
||||||
@objc private func markAllRead() {
|
@objc private func markAllRead() {
|
||||||
Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) }
|
Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新BarButtons状态。
|
||||||
private func updateBarButtons() {
|
private func updateBarButtons() {
|
||||||
navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty
|
navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 展示Alert。
|
||||||
private func showAlert(title: String, message: String) {
|
private func showAlert(title: String, message: String) {
|
||||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "知道了", style: .default))
|
alert.addAction(UIAlertAction(title: "知道了", style: .default))
|
||||||
|
|||||||
@ -146,6 +146,7 @@ final class MessageCenterViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// deduplicated并排序相关逻辑。
|
||||||
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
|
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
|
||||||
var unique: [String: MessageItem] = [:]
|
var unique: [String: MessageItem] = [:]
|
||||||
for item in source {
|
for item in source {
|
||||||
@ -154,6 +155,7 @@ final class MessageCenterViewModel {
|
|||||||
return unique.values.sorted { $0.time > $1.time }
|
return unique.values.sorted { $0.time > $1.time }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空Messages。
|
||||||
private func clearMessages() {
|
private func clearMessages() {
|
||||||
messages = []
|
messages = []
|
||||||
hasMoreMessages = false
|
hasMoreMessages = false
|
||||||
|
|||||||
@ -10,7 +10,9 @@ import Foundation
|
|||||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol OperatingAreaServing {
|
protocol OperatingAreaServing {
|
||||||
|
/// store营业Area相关逻辑。
|
||||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
|
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||||
|
/// scenicAdmin营业Area相关逻辑。
|
||||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
|||||||
let typeText: String
|
let typeText: String
|
||||||
let auditStatusText: String
|
let auditStatusText: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
@ -40,6 +41,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
|||||||
case auditStatusText = "audit_status_text"
|
case auditStatusText = "audit_status_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(
|
init(
|
||||||
id: Int = 0,
|
id: Int = 0,
|
||||||
name: String = "",
|
name: String = "",
|
||||||
@ -56,6 +58,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
|||||||
self.auditStatusText = auditStatusText
|
self.auditStatusText = auditStatusText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
|
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
|
||||||
@ -76,6 +79,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
|||||||
case array([OperatingMapArea])
|
case array([OperatingMapArea])
|
||||||
case object([String: OperatingMapArea])
|
case object([String: OperatingMapArea])
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if container.decodeNil() {
|
if container.decodeNil() {
|
||||||
@ -106,6 +110,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
|||||||
return fromJSONObject(raw)
|
return fromJSONObject(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// fromJSONObject相关逻辑。
|
||||||
private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
|
private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
|
||||||
switch value {
|
switch value {
|
||||||
case is NSNull:
|
case is NSNull:
|
||||||
@ -140,6 +145,7 @@ struct OperatingFenceRing: Identifiable, Equatable {
|
|||||||
let points: [OperatingGeoPoint]
|
let points: [OperatingGeoPoint]
|
||||||
let isCurrentStore: Bool
|
let isCurrentStore: Bool
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
|
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
|
||||||
self.id = "\(itemId)-\(ringIndex)"
|
self.id = "\(itemId)-\(ringIndex)"
|
||||||
self.itemId = itemId
|
self.itemId = itemId
|
||||||
@ -184,6 +190,7 @@ enum OperatingAreaParser {
|
|||||||
return parseElement(area)
|
return parseElement(area)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析Element数据。
|
||||||
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
|
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
|
||||||
switch value {
|
switch value {
|
||||||
case .null, .bool, .number:
|
case .null, .bool, .number:
|
||||||
@ -199,6 +206,7 @@ enum OperatingAreaParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析Object数据。
|
||||||
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||||
let type: String?
|
let type: String?
|
||||||
if case let .string(rawType)? = object["type"] {
|
if case let .string(rawType)? = object["type"] {
|
||||||
@ -231,6 +239,7 @@ enum OperatingAreaParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析ArrayRoot数据。
|
||||||
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||||
guard let first = array.first else { return [] }
|
guard let first = array.first else { return [] }
|
||||||
switch first {
|
switch first {
|
||||||
@ -257,6 +266,7 @@ enum OperatingAreaParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析ObjectRing数据。
|
||||||
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||||
guard case let .object(object) = element else { return nil }
|
guard case let .object(object) = element else { return nil }
|
||||||
@ -265,6 +275,7 @@ enum OperatingAreaParser {
|
|||||||
return points.count >= 3 ? points : nil
|
return points.count >= 3 ? points : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析Ring数据。
|
||||||
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||||
switch element {
|
switch element {
|
||||||
@ -283,6 +294,7 @@ enum OperatingAreaParser {
|
|||||||
return points.count >= 3 ? points : nil
|
return points.count >= 3 ? points : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// point从对象相关逻辑。
|
||||||
private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? {
|
private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? {
|
||||||
let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue
|
let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue
|
||||||
let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue
|
let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue
|
||||||
@ -290,6 +302,7 @@ enum OperatingAreaParser {
|
|||||||
return OperatingGeoPoint(latitude: lat, longitude: lng)
|
return OperatingGeoPoint(latitude: lat, longitude: lng)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 规范化Pair格式。
|
||||||
private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint {
|
private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint {
|
||||||
if looksLikeLngLatPair(lng: a, lat: b) {
|
if looksLikeLngLatPair(lng: a, lat: b) {
|
||||||
return OperatingGeoPoint(latitude: b, longitude: a)
|
return OperatingGeoPoint(latitude: b, longitude: a)
|
||||||
@ -303,24 +316,29 @@ enum OperatingAreaParser {
|
|||||||
return OperatingGeoPoint(latitude: a, longitude: b)
|
return OperatingGeoPoint(latitude: a, longitude: b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// looksLikeLngLat坐标对相关逻辑。
|
||||||
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
|
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
|
||||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
|
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// looksLikeLatLng坐标对相关逻辑。
|
||||||
private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool {
|
private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool {
|
||||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng)
|
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 结构体,封装数据实体。
|
||||||
private struct OperatingDynamicCodingKey: CodingKey {
|
private struct OperatingDynamicCodingKey: CodingKey {
|
||||||
let stringValue: String
|
let stringValue: String
|
||||||
let intValue: Int?
|
let intValue: Int?
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init?(stringValue: String) {
|
init?(stringValue: String) {
|
||||||
self.stringValue = stringValue
|
self.stringValue = stringValue
|
||||||
intValue = nil
|
intValue = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init?(intValue: Int) {
|
init?(intValue: Int) {
|
||||||
stringValue = String(intValue)
|
stringValue = String(intValue)
|
||||||
self.intValue = intValue
|
self.intValue = intValue
|
||||||
@ -341,6 +359,7 @@ private extension OperatingMapArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension KeyedDecodingContainer {
|
private extension KeyedDecodingContainer {
|
||||||
|
/// operating解码宽松字符串相关逻辑。
|
||||||
func operatingDecodeLossyString(forKey key: Key) throws -> String {
|
func operatingDecodeLossyString(forKey key: Key) throws -> String {
|
||||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
@ -357,6 +376,7 @@ private extension KeyedDecodingContainer {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// operating解码宽松整数相关逻辑。
|
||||||
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
|
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -1,37 +1,146 @@
|
|||||||
//
|
//
|
||||||
// OperatingAreaViewController.swift
|
// OperatingAreaViewController.swift
|
||||||
// suixinkan
|
// suixinkan_ios
|
||||||
//
|
|
||||||
// Created by Codex on 2026/6/26.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 运营区域列表页。
|
/// 运营区域页:汇总信息、围栏地图与区域列表。
|
||||||
final class OperatingAreaViewController: ModuleTableViewController {
|
final class OperatingAreaViewController: UIViewController {
|
||||||
private let viewModel = OperatingAreaViewModel()
|
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<Int, String>!
|
||||||
|
private var services: AppServices { AppServices.shared }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "运营区域"
|
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
wireViewModel(viewModel) { }
|
title = "运营区域"
|
||||||
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||||
|
setupLayout()
|
||||||
|
wireViewModel()
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableRowCount() -> Int {
|
private func setupLayout() {
|
||||||
viewModel.items.count
|
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<Int, String>(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) {
|
private func wireViewModel() {
|
||||||
let item = viewModel.items[indexPath.row]
|
viewModel.onChange = { [weak self] in
|
||||||
cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count) 组")
|
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<Int, String>()
|
||||||
|
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(
|
await viewModel.reload(
|
||||||
api: services.operatingAreaAPI,
|
api: services.operatingAreaAPI,
|
||||||
accountContext: services.accountContext,
|
accountContext: services.accountContext,
|
||||||
permissionContext: services.permissionContext
|
permissionContext: services.permissionContext
|
||||||
)
|
)
|
||||||
|
if let message = viewModel.errorMessage {
|
||||||
|
services.toastCenter.show(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,7 @@ final class OperatingAreaViewModel {
|
|||||||
mode?.title ?? "运营区域"
|
mode?.title ?? "运营区域"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// apply 业务逻辑。
|
||||||
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
|
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
|
||||||
self.items = items
|
self.items = items
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
@ -139,6 +140,7 @@ final class OperatingAreaViewModel {
|
|||||||
blockReason = parsed.isEmpty ? .noParsableFenceData : nil
|
blockReason = parsed.isEmpty ? .noParsableFenceData : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// setBlocked相关逻辑。
|
||||||
private func setBlocked(_ reason: OperatingAreaBlockReason) {
|
private func setBlocked(_ reason: OperatingAreaBlockReason) {
|
||||||
resetLoadedData()
|
resetLoadedData()
|
||||||
blockReason = reason
|
blockReason = reason
|
||||||
@ -146,6 +148,7 @@ final class OperatingAreaViewModel {
|
|||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置LoadedData状态。
|
||||||
private func resetLoadedData() {
|
private func resetLoadedData() {
|
||||||
items = []
|
items = []
|
||||||
fenceRings = []
|
fenceRings = []
|
||||||
|
|||||||
@ -62,6 +62,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
let isNeedEdit: Bool
|
let isNeedEdit: Bool
|
||||||
let isRefined: Int
|
let isRefined: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case photogUid = "photog_uid"
|
case photogUid = "photog_uid"
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
@ -131,6 +132,7 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
|||||||
let retouchGiftPhotoNum: Int
|
let retouchGiftPhotoNum: Int
|
||||||
let retouchGiftVideoNum: Int
|
let retouchGiftVideoNum: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderPhotoNum = "order_photo_num"
|
case orderPhotoNum = "order_photo_num"
|
||||||
case orderVideoNum = "order_video_num"
|
case orderVideoNum = "order_video_num"
|
||||||
@ -163,6 +165,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
let orderStatusName: String
|
let orderStatusName: String
|
||||||
let orderVerificationTime: String
|
let orderVerificationTime: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
case orderVerificationStatus = "order_verification_status"
|
case orderVerificationStatus = "order_verification_status"
|
||||||
@ -192,6 +195,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
struct WriteOffRequest: Encodable {
|
struct WriteOffRequest: Encodable {
|
||||||
let orderNumber: String
|
let orderNumber: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
}
|
}
|
||||||
@ -207,6 +211,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
let statusName: String
|
let statusName: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
@ -234,6 +239,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
struct DepositOrderWriteOffRequest: Encodable, Equatable {
|
struct DepositOrderWriteOffRequest: Encodable, Equatable {
|
||||||
let orderNumber: String
|
let orderNumber: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
}
|
}
|
||||||
@ -244,6 +250,7 @@ struct DepositOrderRefundRequest: Encodable, Equatable {
|
|||||||
let orderNumber: String
|
let orderNumber: String
|
||||||
let refundReason: String
|
let refundReason: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
case refundReason = "refund_reason"
|
case refundReason = "refund_reason"
|
||||||
@ -275,6 +282,7 @@ struct OrderRefundRequest: Encodable, Equatable {
|
|||||||
let refundAmount: String
|
let refundAmount: String
|
||||||
let refundReason: String
|
let refundReason: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
case refundType = "refund_type"
|
case refundType = "refund_type"
|
||||||
@ -314,6 +322,7 @@ struct StoreOrderDetailResponse: Decodable, Equatable, Hashable {
|
|||||||
let remark: String
|
let remark: String
|
||||||
let multiTravel: StoreOrderMultiTravelInfo?
|
let multiTravel: StoreOrderMultiTravelInfo?
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
case orderType = "order_type"
|
case orderType = "order_type"
|
||||||
@ -362,6 +371,7 @@ struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable {
|
|||||||
let projectInfo: StoreOrderProjectInfo?
|
let projectInfo: StoreOrderProjectInfo?
|
||||||
let shootingList: [StoreOrderShootingListItem]
|
let shootingList: [StoreOrderShootingListItem]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case projectInfo = "project_info"
|
case projectInfo = "project_info"
|
||||||
case shootingList = "shooting_list"
|
case shootingList = "shooting_list"
|
||||||
@ -382,6 +392,7 @@ struct StoreOrderProjectInfo: Decodable, Equatable, Hashable {
|
|||||||
let singleSpotPhotoNum: Int
|
let singleSpotPhotoNum: Int
|
||||||
let singleSpotVideoNum: Int
|
let singleSpotVideoNum: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case settleSpotNum = "settle_spot_num"
|
case settleSpotNum = "settle_spot_num"
|
||||||
case singleSpotMaterialNum = "single_spot_material_num"
|
case singleSpotMaterialNum = "single_spot_material_num"
|
||||||
@ -412,6 +423,7 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable
|
|||||||
let startAvg: Double
|
let startAvg: Double
|
||||||
let start: Double
|
let start: Double
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicSpotId = "scenic_spot_id"
|
case scenicSpotId = "scenic_spot_id"
|
||||||
case photogUid = "photog_uid"
|
case photogUid = "photog_uid"
|
||||||
@ -447,6 +459,7 @@ struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
|
|||||||
let materialList: [OrderMediaFile]
|
let materialList: [OrderMediaFile]
|
||||||
let completeList: [OrderMediaFile]
|
let completeList: [OrderMediaFile]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicSpotId = "scenic_spot_id"
|
case scenicSpotId = "scenic_spot_id"
|
||||||
case scenicSpotName = "scenic_spot_name"
|
case scenicSpotName = "scenic_spot_name"
|
||||||
@ -480,6 +493,7 @@ struct StoreOrderComment: Decodable, Equatable, Hashable {
|
|||||||
let content: String
|
let content: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case starShooting = "star_shooting"
|
case starShooting = "star_shooting"
|
||||||
case starRetouching = "star_retouching"
|
case starRetouching = "star_retouching"
|
||||||
@ -522,6 +536,7 @@ struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case fileName = "file_name"
|
case fileName = "file_name"
|
||||||
case fileUrl = "file_url"
|
case fileUrl = "file_url"
|
||||||
@ -550,6 +565,7 @@ struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
|
|||||||
let projectTypeName: String
|
let projectTypeName: String
|
||||||
let photogSpotList: [PhotogSpotItem]
|
let photogSpotList: [PhotogSpotItem]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case projectName = "project_name"
|
case projectName = "project_name"
|
||||||
case projectType = "project_type"
|
case projectType = "project_type"
|
||||||
@ -583,6 +599,7 @@ struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
return nickname.isEmpty ? photogName : nickname
|
return nickname.isEmpty ? photogName : nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicSpotId = "scenic_spot_id"
|
case scenicSpotId = "scenic_spot_id"
|
||||||
case photogUid = "photog_uid"
|
case photogUid = "photog_uid"
|
||||||
@ -609,6 +626,7 @@ struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Ha
|
|||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
@ -629,6 +647,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
|||||||
let cloudFile: [MultiTravelCloudFileItem]
|
let cloudFile: [MultiTravelCloudFileItem]
|
||||||
let uploadFile: [MultiTravelUploadFileItem]
|
let uploadFile: [MultiTravelUploadFileItem]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
case scenicSpotId = "scenic_spot_id"
|
case scenicSpotId = "scenic_spot_id"
|
||||||
@ -641,6 +660,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
|||||||
struct MultiTravelCloudFileItem: Encodable, Equatable {
|
struct MultiTravelCloudFileItem: Encodable, Equatable {
|
||||||
let fileId: Int
|
let fileId: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case fileId = "file_id"
|
case fileId = "file_id"
|
||||||
}
|
}
|
||||||
@ -651,6 +671,7 @@ struct MultiTravelUploadFileItem: Encodable, Equatable {
|
|||||||
let fileName: String
|
let fileName: String
|
||||||
let fileUrl: String
|
let fileUrl: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case fileName = "file_name"
|
case fileName = "file_name"
|
||||||
case fileUrl = "file_url"
|
case fileUrl = "file_url"
|
||||||
|
|||||||
@ -6,32 +6,127 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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 let viewModel = DepositOrderListViewModel()
|
||||||
private var orderNumberField = UITextField()
|
private var orderNumberField = UITextField()
|
||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var tableView: UITableView = {
|
||||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
table.dataSource = self
|
|
||||||
table.delegate = self
|
table.delegate = self
|
||||||
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
|
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
|
||||||
return table
|
return table
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动押金订单列表与分页刷新。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<DepositListSection, DepositListItem>!
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "押金订单"
|
title = "押金订单"
|
||||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
|
configureTableDataSource()
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
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()
|
setupHeader()
|
||||||
Task { await reload(showLoading: true) }
|
Task { await reload(showLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<DepositListSection, DepositListItem>(
|
||||||
|
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<DepositListSection, DepositListItem> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<DepositListSection, DepositListItem>()
|
||||||
|
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() {
|
private func setupHeader() {
|
||||||
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
|
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
|
||||||
orderNumberField.placeholder = "请输入押金订单号"
|
orderNumberField.placeholder = "请输入押金订单号"
|
||||||
@ -55,12 +150,14 @@ final class DepositOrderListViewController: UIViewController {
|
|||||||
tableView.tableHeaderView = header
|
tableView.tableHeaderView = header
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// open详情相关逻辑。
|
||||||
@objc private func openDetail() {
|
@objc private func openDetail() {
|
||||||
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !text.isEmpty else { return }
|
guard !text.isEmpty else { return }
|
||||||
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
|
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新。
|
||||||
private func reload(showLoading: Bool) async {
|
private func reload(showLoading: Bool) async {
|
||||||
if showLoading {
|
if showLoading {
|
||||||
await appServices.globalLoading.withLoading {
|
await appServices.globalLoading.withLoading {
|
||||||
@ -72,6 +169,7 @@ final class DepositOrderListViewController: UIViewController {
|
|||||||
if let message = viewModel.errorMessage { showToast(message) }
|
if let message = viewModel.errorMessage { showToast(message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// writeOff相关逻辑。
|
||||||
private func writeOff(_ item: DepositOrderListItem) {
|
private func writeOff(_ item: DepositOrderListItem) {
|
||||||
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
|
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
@ -86,6 +184,7 @@ final class DepositOrderListViewController: UIViewController {
|
|||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// refund 业务逻辑。
|
||||||
private func refund(_ item: DepositOrderListItem) {
|
private func refund(_ item: DepositOrderListItem) {
|
||||||
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
|
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
|
||||||
alert.addTextField { $0.placeholder = "退款原因" }
|
alert.addTextField { $0.placeholder = "退款原因" }
|
||||||
@ -101,43 +200,25 @@ final class DepositOrderListViewController: UIViewController {
|
|||||||
})
|
})
|
||||||
present(alert, animated: true)
|
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? {
|
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
|
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
/// UITableView 代理:分页加载。
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
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 {
|
Task {
|
||||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
|
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DepositOrder列表或网格 Cell,负责单项内容展示。
|
||||||
private final class DepositOrderCell: UITableViewCell {
|
private final class DepositOrderCell: UITableViewCell {
|
||||||
static let reuseID = "DepositOrderCell"
|
static let reuseID = "DepositOrderCell"
|
||||||
var onDetail: (() -> Void)?
|
var onDetail: (() -> Void)?
|
||||||
@ -148,6 +229,7 @@ private final class DepositOrderCell: UITableViewCell {
|
|||||||
private let statusLabel = UILabel()
|
private let statusLabel = UILabel()
|
||||||
private let amountLabel = UILabel()
|
private let amountLabel = UILabel()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
@ -182,14 +264,18 @@ private final class DepositOrderCell: UITableViewCell {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 配置展示内容。
|
||||||
func configure(item: DepositOrderListItem, isOperating: Bool) {
|
func configure(item: DepositOrderListItem, isOperating: Bool) {
|
||||||
titleLabel.text = item.orderNumber
|
titleLabel.text = item.orderNumber
|
||||||
statusLabel.text = isOperating ? "处理中" : item.statusName
|
statusLabel.text = isOperating ? "处理中" : item.statusName
|
||||||
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
|
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击detail的处理逻辑。
|
||||||
@objc private func detailTapped() { onDetail?() }
|
@objc private func detailTapped() { onDetail?() }
|
||||||
|
/// 点击writeOff的处理逻辑。
|
||||||
@objc private func writeOffTapped() { onWriteOff?() }
|
@objc private func writeOffTapped() { onWriteOff?() }
|
||||||
|
/// 点击refund的处理逻辑。
|
||||||
@objc private func refundTapped() { onRefund?() }
|
@objc private func refundTapped() { onRefund?() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,11 +287,14 @@ final class DepositOrderDetailViewController: UIViewController {
|
|||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var tableView: UITableView = {
|
||||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
table.dataSource = self
|
|
||||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
return table
|
return table
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动详情字段展示。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>!
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(orderNumber: String) {
|
init(orderNumber: String) {
|
||||||
self.orderNumber = orderNumber
|
self.orderNumber = orderNumber
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -214,14 +303,66 @@ final class DepositOrderDetailViewController: UIViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "押金订单详情"
|
title = "押金订单详情"
|
||||||
|
configureTableDataSource()
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
Task { await loadDetail() }
|
Task { await loadDetail() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>(
|
||||||
|
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<DepositDetailSection, DepositDetailRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<DepositDetailSection, DepositDetailRow>()
|
||||||
|
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 {
|
private func loadDetail() async {
|
||||||
await appServices.globalLoading.withLoading {
|
await appServices.globalLoading.withLoading {
|
||||||
await viewModel.load(
|
await viewModel.load(
|
||||||
@ -230,41 +371,13 @@ final class DepositOrderDetailViewController: UIViewController {
|
|||||||
orderNumber: orderNumber
|
orderNumber: orderNumber
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
tableView.reloadData()
|
applyTableSnapshot()
|
||||||
if let message = viewModel.errorMessage { showToast(message) }
|
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 orderNumber: String
|
||||||
private let scenicSpotId: Int
|
private let scenicSpotId: Int
|
||||||
@ -273,10 +386,14 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
|||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var tableView: UITableView = {
|
||||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
table.dataSource = self
|
table.delegate = self
|
||||||
return table
|
return table
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动拍摄摘要与素材列表刷新。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>!
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
|
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
|
||||||
self.orderNumber = orderNumber
|
self.orderNumber = orderNumber
|
||||||
self.scenicSpotId = scenicSpotId
|
self.scenicSpotId = scenicSpotId
|
||||||
@ -287,14 +404,75 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "押金拍摄信息"
|
title = "押金拍摄信息"
|
||||||
|
configureTableDataSource()
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||||
Task { await loadDetail() }
|
Task { await loadDetail() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>(
|
||||||
|
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<DepositShootingSection, DepositShootingRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<DepositShootingSection, DepositShootingRow>()
|
||||||
|
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 {
|
private func loadDetail() async {
|
||||||
await appServices.globalLoading.withLoading {
|
await appServices.globalLoading.withLoading {
|
||||||
await viewModel.load(
|
await viewModel.load(
|
||||||
@ -305,56 +483,25 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
|||||||
photogUid: photogUid
|
photogUid: photogUid
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
tableView.reloadData()
|
applyTableSnapshot()
|
||||||
if let message = viewModel.errorMessage { showToast(message) }
|
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? {
|
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
guard viewModel.detail != nil else { return nil }
|
guard viewModel.detail != nil,
|
||||||
switch section {
|
let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||||
case 1: return "素材"
|
switch sectionID {
|
||||||
case 2: return "成片"
|
case DepositShootingSectionID.material: return "素材"
|
||||||
|
case DepositShootingSectionID.complete: return "成片"
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
/// 安全下标,避免 section 越界。
|
||||||
guard let detail = viewModel.detail else {
|
private extension Array {
|
||||||
let cell = UITableViewCell()
|
subscript(safe index: Int) -> Element? {
|
||||||
cell.textLabel?.text = viewModel.errorMessage ?? "加载中..."
|
indices.contains(index) ? self[index] : nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
|||||||
|
|
||||||
private let scannerController = OrderScannerCaptureViewController()
|
private let scannerController = OrderScannerCaptureViewController()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
@ -49,6 +50,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// embed扫码相关逻辑。
|
||||||
private func embedScanner() {
|
private func embedScanner() {
|
||||||
scannerController.onScanResult = { [weak self] result in
|
scannerController.onScanResult = { [weak self] result in
|
||||||
self?.onScanResult?(result)
|
self?.onScanResult?(result)
|
||||||
@ -77,6 +79,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 展示UnavailablePlaceholder。
|
||||||
private func showUnavailablePlaceholder() {
|
private func showUnavailablePlaceholder() {
|
||||||
let stack = UIStackView()
|
let stack = UIStackView()
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
@ -109,6 +112,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击close的处理逻辑。
|
||||||
@objc private func closeTapped() {
|
@objc private func closeTapped() {
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
@ -145,6 +149,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
@ -152,17 +157,20 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
checkPermissionAndStart()
|
checkPermissionAndStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 子视图布局完成后调整依赖 frame 的 UI。
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
previewLayer?.frame = view.layer.bounds
|
previewLayer?.frame = view.layer.bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图即将消失,保存或清理临时状态。
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
updateTorch(enabled: false)
|
updateTorch(enabled: false)
|
||||||
stopSession()
|
stopSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// check权限并启动相关逻辑。
|
||||||
private func checkPermissionAndStart() {
|
private func checkPermissionAndStart() {
|
||||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
case .authorized:
|
case .authorized:
|
||||||
@ -185,6 +193,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 启动Session流程。
|
||||||
private func startSession() {
|
private func startSession() {
|
||||||
hasFinishedScan = false
|
hasFinishedScan = false
|
||||||
guard !session.isRunning else { return }
|
guard !session.isRunning else { return }
|
||||||
@ -199,11 +208,13 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 停止Session流程。
|
||||||
private func stopSession() {
|
private func stopSession() {
|
||||||
guard session.isRunning else { return }
|
guard session.isRunning else { return }
|
||||||
session.stopRunning()
|
session.stopRunning()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建ScannerButton实例。
|
||||||
private static func makeScannerButton(title: String) -> UIButton {
|
private static func makeScannerButton(title: String) -> UIButton {
|
||||||
var config = UIButton.Configuration.filled()
|
var config = UIButton.Configuration.filled()
|
||||||
config.title = title
|
config.title = title
|
||||||
@ -213,6 +224,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
return UIButton(configuration: config)
|
return UIButton(configuration: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Session展示内容。
|
||||||
private func configureSession() throws {
|
private func configureSession() throws {
|
||||||
if previewLayer != nil { return }
|
if previewLayer != nil { return }
|
||||||
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
||||||
@ -242,6 +254,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
torchButton.isEnabled = videoDevice.hasTorch
|
torchButton.isEnabled = videoDevice.hasTorch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// metadataOutput相关逻辑。
|
||||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||||
guard !hasFinishedScan else { return }
|
guard !hasFinishedScan else { return }
|
||||||
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||||
@ -253,6 +266,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
onScanResult?(.success(code))
|
onScanResult?(.success(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化Controls相关 UI 或状态。
|
||||||
private func setupControls() {
|
private func setupControls() {
|
||||||
view.addSubview(scanFrameView)
|
view.addSubview(scanFrameView)
|
||||||
view.addSubview(torchButton)
|
view.addSubview(torchButton)
|
||||||
@ -273,10 +287,12 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// toggleTorch相关逻辑。
|
||||||
@objc private func toggleTorch() {
|
@objc private func toggleTorch() {
|
||||||
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
|
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新Torch状态。
|
||||||
private func updateTorch(enabled: Bool) {
|
private func updateTorch(enabled: Bool) {
|
||||||
guard let device = videoDevice, device.hasTorch else { return }
|
guard let device = videoDevice, device.hasTorch else { return }
|
||||||
do {
|
do {
|
||||||
@ -293,6 +309,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// restart扫码相关逻辑。
|
||||||
@objc private func restartScan() {
|
@objc private func restartScan() {
|
||||||
hasFinishedScan = false
|
hasFinishedScan = false
|
||||||
updateTorch(enabled: false)
|
updateTorch(enabled: false)
|
||||||
|
|||||||
@ -6,8 +6,45 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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 item: OrderEntity
|
||||||
private let viewModel: OrderDetailViewModel
|
private let viewModel: OrderDetailViewModel
|
||||||
@ -15,12 +52,15 @@ final class StoreOrderDetailViewController: UIViewController {
|
|||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var tableView: UITableView = {
|
||||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
table.dataSource = self
|
|
||||||
table.delegate = self
|
table.delegate = self
|
||||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
return table
|
return table
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动多 section 订单详情刷新。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>!
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(item: OrderEntity) {
|
init(item: OrderEntity) {
|
||||||
self.item = item
|
self.item = item
|
||||||
self.viewModel = OrderDetailViewModel(item: item)
|
self.viewModel = OrderDetailViewModel(item: item)
|
||||||
@ -30,31 +70,176 @@ final class StoreOrderDetailViewController: UIViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "订单详情"
|
title = "订单详情"
|
||||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
|
configureTableDataSource()
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
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() }
|
Task { await loadDetail() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>(
|
||||||
|
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<StoreOrderDetailSection, StoreOrderDetailRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<StoreOrderDetailSection, StoreOrderDetailRow>()
|
||||||
|
|
||||||
|
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 {
|
private func loadDetail() async {
|
||||||
await appServices.globalLoading.withLoading(message: "加载详情中...") {
|
await appServices.globalLoading.withLoading(message: "加载详情中...") {
|
||||||
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
|
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
|
||||||
}
|
}
|
||||||
|
applyTableSnapshot(reconfigure: true)
|
||||||
if let error = viewModel.errorMessage {
|
if let error = viewModel.errorMessage {
|
||||||
showToast(error)
|
showToast(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 复制订单Number。
|
||||||
private func copyOrderNumber() {
|
private func copyOrderNumber() {
|
||||||
UIPasteboard.general.string = viewModel.display.orderNumber
|
UIPasteboard.general.string = viewModel.display.orderNumber
|
||||||
showToast("订单号已复制")
|
showToast("订单号已复制")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出Refund页面。
|
||||||
private func presentRefund() {
|
private func presentRefund() {
|
||||||
refundViewModel.begin(item: item)
|
refundViewModel.begin(item: item)
|
||||||
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
|
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
|
||||||
@ -75,99 +260,34 @@ final class StoreOrderDetailViewController: UIViewController {
|
|||||||
})
|
})
|
||||||
present(alert, animated: true)
|
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? {
|
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
switch section {
|
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||||
case 1: "订单信息"
|
switch sectionID {
|
||||||
case 2: "支付信息"
|
case StoreOrderDetailSectionID.orderInfo: return "订单信息"
|
||||||
case 3: "客户与项目"
|
case StoreOrderDetailSectionID.payment: return "支付信息"
|
||||||
case 4 where !viewModel.shootingList.isEmpty: "拍摄点"
|
case StoreOrderDetailSectionID.customer: return "客户与项目"
|
||||||
case 5: "后续功能"
|
case StoreOrderDetailSectionID.shooting: return "拍摄点"
|
||||||
default: nil
|
case StoreOrderDetailSectionID.actions: return "后续功能"
|
||||||
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
/// UITableView 代理:处理行选中。
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return }
|
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
guard indexPath.section == 5 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
|
let orderNumber = viewModel.display.orderNumber
|
||||||
switch indexPath.row {
|
switch index {
|
||||||
case 0:
|
case 0:
|
||||||
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
|
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
|
||||||
case 1 where item.orderType == 19:
|
case 1 where item.orderType == 19:
|
||||||
@ -178,7 +298,8 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
|
|||||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
|
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
|
||||||
case 4:
|
case 4:
|
||||||
presentRefund()
|
presentRefund()
|
||||||
default: break
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,65 +309,79 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
|
|||||||
return payTime
|
return payTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// empty至Zero相关逻辑。
|
||||||
private func emptyToZero(_ value: String) -> String {
|
private func emptyToZero(_ value: String) -> String {
|
||||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
|
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 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) {
|
init(item: WriteOffOrderItem) {
|
||||||
self.item = item
|
self.item = item
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(style: .insetGrouped)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "核销详情"
|
title = "核销详情"
|
||||||
view.addSubview(tableView)
|
tableView.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension WriteOffOrderDetailViewController: UITableViewDataSource {
|
/// 构建 Diffable snapshot。
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 }
|
override func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow>()
|
||||||
|
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 {
|
/// 配置 Cell 展示内容。
|
||||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
|
||||||
cell.selectionStyle = .none
|
cell.selectionStyle = .none
|
||||||
switch indexPath.row {
|
switch row {
|
||||||
case 0:
|
case WriteOffDetailRowID.orderNumber:
|
||||||
cell.textLabel?.text = "订单号"
|
cell.textLabel?.text = "订单号"
|
||||||
cell.detailTextLabel?.text = item.orderNumber
|
cell.detailTextLabel?.text = item.orderNumber
|
||||||
case 1:
|
case WriteOffDetailRowID.project:
|
||||||
cell.textLabel?.text = "项目"
|
cell.textLabel?.text = "项目"
|
||||||
cell.detailTextLabel?.text = item.projectName
|
cell.detailTextLabel?.text = item.projectName
|
||||||
case 2:
|
case WriteOffDetailRowID.phone:
|
||||||
cell.textLabel?.text = "手机号"
|
cell.textLabel?.text = "手机号"
|
||||||
cell.detailTextLabel?.text = item.userPhone
|
cell.detailTextLabel?.text = item.userPhone
|
||||||
case 3:
|
case WriteOffDetailRowID.amount:
|
||||||
cell.textLabel?.text = "金额"
|
cell.textLabel?.text = "金额"
|
||||||
cell.detailTextLabel?.text = "¥\(item.orderAmount)"
|
cell.detailTextLabel?.text = "¥\(item.orderAmount)"
|
||||||
case 4:
|
case WriteOffDetailRowID.status:
|
||||||
cell.textLabel?.text = "状态"
|
cell.textLabel?.text = "状态"
|
||||||
cell.detailTextLabel?.text = item.orderStatusName
|
cell.detailTextLabel?.text = item.orderStatusName
|
||||||
default:
|
case WriteOffDetailRowID.verificationTime:
|
||||||
cell.textLabel?.text = "核销时间"
|
cell.textLabel?.text = "核销时间"
|
||||||
cell.detailTextLabel?.text = item.orderVerificationTime
|
cell.detailTextLabel?.text = item.orderVerificationTime
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return cell
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
private let localListLabel = UILabel()
|
private let localListLabel = UILabel()
|
||||||
private let submitButton = UIButton(type: .system)
|
private let submitButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(initialOrderNumber: String) {
|
init(initialOrderNumber: String) {
|
||||||
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
|
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -27,6 +28,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "任务上传"
|
title = "任务上传"
|
||||||
@ -36,12 +38,14 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 绑定ViewModel回调或数据。
|
||||||
private func bindViewModel() {
|
private func bindViewModel() {
|
||||||
viewModel.onChange = { [weak self] in
|
viewModel.onChange = { [weak self] in
|
||||||
self?.applyViewModel()
|
self?.applyViewModel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化Form相关 UI 或状态。
|
||||||
private func setupForm() {
|
private func setupForm() {
|
||||||
orderField.borderStyle = .roundedRect
|
orderField.borderStyle = .roundedRect
|
||||||
orderField.placeholder = "关联订单号"
|
orderField.placeholder = "关联订单号"
|
||||||
@ -90,6 +94,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
applyViewModel()
|
applyViewModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// labeled行相关逻辑。
|
||||||
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
|
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = title
|
label.text = title
|
||||||
@ -100,6 +105,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// apply视图模型相关逻辑。
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
orderField.text = viewModel.orderNumber
|
orderField.text = viewModel.orderNumber
|
||||||
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
|
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
|
||||||
@ -123,14 +129,17 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
|
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// order变更相关逻辑。
|
||||||
@objc private func orderChanged() {
|
@objc private func orderChanged() {
|
||||||
viewModel.orderNumber = orderField.text ?? ""
|
viewModel.orderNumber = orderField.text ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Spots展示。
|
||||||
@objc private func refreshSpots() {
|
@objc private func refreshSpots() {
|
||||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// select打卡点相关逻辑。
|
||||||
@objc private func selectSpot() {
|
@objc private func selectSpot() {
|
||||||
guard !viewModel.spots.isEmpty else { return }
|
guard !viewModel.spots.isEmpty else { return }
|
||||||
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
|
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
|
||||||
@ -144,6 +153,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
present(sheet, animated: true)
|
present(sheet, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pick本地Files相关逻辑。
|
||||||
@objc private func pickLocalFiles() {
|
@objc private func pickLocalFiles() {
|
||||||
var config = PHPickerConfiguration()
|
var config = PHPickerConfiguration()
|
||||||
config.selectionLimit = 9
|
config.selectionLimit = 9
|
||||||
@ -153,6 +163,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
present(picker, animated: true)
|
present(picker, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提交Tapped。
|
||||||
@objc private func submitTapped() {
|
@objc private func submitTapped() {
|
||||||
Task {
|
Task {
|
||||||
let success = await viewModel.submit(
|
let success = await viewModel.submit(
|
||||||
@ -172,6 +183,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||||||
|
/// picker 业务逻辑。
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
picker.dismiss(animated: true)
|
picker.dismiss(animated: true)
|
||||||
guard !results.isEmpty else { return }
|
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 orderNumber: String
|
||||||
private let viewModel = HistoricalShootingInfoViewModel()
|
private let viewModel = HistoricalShootingInfoViewModel()
|
||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var tableView: UITableView = {
|
||||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
table.dataSource = self
|
|
||||||
table.delegate = self
|
table.delegate = self
|
||||||
return table
|
return table
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动历史拍摄摘要与点位列表刷新。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>!
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(orderNumber: String) {
|
init(orderNumber: String) {
|
||||||
self.orderNumber = orderNumber
|
self.orderNumber = orderNumber
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -220,49 +254,89 @@ final class HistoricalShootingInfoViewController: UIViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "历史拍摄"
|
title = "历史拍摄"
|
||||||
|
configureTableDataSource()
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
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() }
|
Task { await loadData() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>(
|
||||||
|
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<HistoricalShootingSection, HistoricalShootingRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<HistoricalShootingSection, HistoricalShootingRow>()
|
||||||
|
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 {
|
private func loadData() async {
|
||||||
await appServices.globalLoading.withLoading {
|
await appServices.globalLoading.withLoading {
|
||||||
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
|
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
|
||||||
}
|
}
|
||||||
|
applyTableSnapshot()
|
||||||
if let message = viewModel.errorMessage { showToast(message) }
|
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? {
|
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
section == 1 ? "拍摄点位" : nil
|
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||||
}
|
return sectionID == HistoricalShootingSectionID.spots ? "拍摄点位" : nil
|
||||||
|
}
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
}
|
||||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
|
||||||
cell.selectionStyle = .none
|
/// 安全下标,避免 section 越界。
|
||||||
if indexPath.section == 0 {
|
private extension Array {
|
||||||
cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型"
|
subscript(safe index: Int) -> Element? {
|
||||||
cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName
|
indices.contains(index) ? self[index] : nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,31 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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 根页面,展示订单管理和核销订单两个子入口及对应列表。
|
/// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。
|
||||||
final class OrdersViewController: UIViewController {
|
final class OrdersViewController: UIViewController {
|
||||||
|
|
||||||
@ -13,36 +38,43 @@ final class OrdersViewController: UIViewController {
|
|||||||
private var manualOrderNumber = ""
|
private var manualOrderNumber = ""
|
||||||
private var scanHintMessage: String?
|
private var scanHintMessage: String?
|
||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var collectionView: UICollectionView = {
|
||||||
let table = UITableView(frame: .zero, style: .grouped)
|
let layout = makeCollectionLayout()
|
||||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
table.separatorStyle = .none
|
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
table.dataSource = self
|
collection.delegate = self
|
||||||
table.delegate = self
|
collection.register(OrdersHeaderCell.self, forCellWithReuseIdentifier: OrdersHeaderCell.reuseID)
|
||||||
table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID)
|
collection.register(OrdersFilterCell.self, forCellWithReuseIdentifier: OrdersFilterCell.reuseID)
|
||||||
table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID)
|
collection.register(OrdersWriteOffActionCell.self, forCellWithReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
||||||
table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID)
|
collection.register(OrderEntityCell.self, forCellWithReuseIdentifier: OrderEntityCell.reuseID)
|
||||||
table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID)
|
collection.register(WriteOffOrderCell.self, forCellWithReuseIdentifier: WriteOffOrderCell.reuseID)
|
||||||
table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
collection.register(OrdersEmptyStateCell.self, forCellWithReuseIdentifier: OrdersEmptyStateCell.reuseID)
|
||||||
return table
|
return collection
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var dataSource: UICollectionViewDiffableDataSource<OrdersSection, OrdersItem> = {
|
||||||
|
UICollectionViewDiffableDataSource<OrdersSection, OrdersItem>(collectionView: collectionView) { [weak self] collectionView, indexPath, item in
|
||||||
|
self?.cell(for: collectionView, at: indexPath, item: item) ?? UICollectionViewCell()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var refreshControl = UIRefreshControl()
|
private lazy var refreshControl = UIRefreshControl()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "订单"
|
title = "订单"
|
||||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||||
|
|
||||||
view.addSubview(tableView)
|
view.addSubview(collectionView)
|
||||||
tableView.snp.makeConstraints { make in
|
collectionView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
tableView.refreshControl = refreshControl
|
collectionView.refreshControl = refreshControl
|
||||||
|
|
||||||
viewModel.onChange = { [weak self] in
|
viewModel.onChange = { [weak self] in
|
||||||
self?.tableView.reloadData()
|
self?.applySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
appServices.appRouter.onChange = { [weak self] in
|
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<OrdersSection, OrdersItem>()
|
||||||
|
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<OrdersSection, OrdersItem>) {
|
||||||
|
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<OrdersSection, OrdersItem>) {
|
||||||
|
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() {
|
@objc private func refreshPulled() {
|
||||||
Task {
|
Task {
|
||||||
await reload(showLoading: false)
|
await reload(showLoading: false)
|
||||||
@ -75,6 +303,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
private var currentStoreId: Int? { appServices.accountContext.currentStore?.id }
|
private var currentStoreId: Int? { appServices.accountContext.currentStore?.id }
|
||||||
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
||||||
|
|
||||||
|
/// 重新加载当前子入口对应的订单数据。
|
||||||
private func reload(showLoading: Bool) async {
|
private func reload(showLoading: Bool) async {
|
||||||
do {
|
do {
|
||||||
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") {
|
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") {
|
||||||
@ -91,6 +320,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 滚动到底部时加载下一页。
|
||||||
private func loadMore() async {
|
private func loadMore() async {
|
||||||
do {
|
do {
|
||||||
if viewModel.selectedEntry == .storeOrders {
|
if viewModel.selectedEntry == .storeOrders {
|
||||||
@ -111,6 +341,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换订单管理 / 核销订单子入口。
|
||||||
private func switchEntry(_ entry: OrdersEntry) {
|
private func switchEntry(_ entry: OrdersEntry) {
|
||||||
guard viewModel.selectedEntry != entry else { return }
|
guard viewModel.selectedEntry != entry else { return }
|
||||||
viewModel.selectedEntry = entry
|
viewModel.selectedEntry = entry
|
||||||
@ -118,6 +349,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
Task { await reload(showLoading: true) }
|
Task { await reload(showLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提交订单核销请求。
|
||||||
private func verify(orderNumber: String) async {
|
private func verify(orderNumber: String) async {
|
||||||
guard let scenicId = currentScenicId else { return }
|
guard let scenicId = currentScenicId else { return }
|
||||||
do {
|
do {
|
||||||
@ -135,12 +367,14 @@ final class OrdersViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 消费路由中暂存的扫码结果(核销入口)。
|
||||||
private func consumePendingScanCodeIfNeeded() async {
|
private func consumePendingScanCodeIfNeeded() async {
|
||||||
guard viewModel.selectedEntry == .verificationOrders,
|
guard viewModel.selectedEntry == .verificationOrders,
|
||||||
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
|
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
|
||||||
handleScanResult(code)
|
handleScanResult(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析扫码结果并引导核销或提示未匹配。
|
||||||
private func handleScanResult(_ rawCode: String) {
|
private func handleScanResult(_ rawCode: String) {
|
||||||
guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||||
showToast("未识别到有效订单号")
|
showToast("未识别到有效订单号")
|
||||||
@ -150,10 +384,11 @@ final class OrdersViewController: UIViewController {
|
|||||||
confirmVerify(orderNumber: parsed.orderNumber)
|
confirmVerify(orderNumber: parsed.orderNumber)
|
||||||
} else {
|
} else {
|
||||||
scanHintMessage = "扫码成功,当前列表未找到该订单"
|
scanHintMessage = "扫码成功,当前列表未找到该订单"
|
||||||
tableView.reloadData()
|
applySnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出核销确认对话框。
|
||||||
private func confirmVerify(orderNumber: String) {
|
private func confirmVerify(orderNumber: String) {
|
||||||
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
|
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
@ -163,6 +398,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开订单号扫码页。
|
||||||
private func presentScanner() {
|
private func presentScanner() {
|
||||||
let scanner = OrderCodeScannerViewController()
|
let scanner = OrderCodeScannerViewController()
|
||||||
scanner.onScanResult = { [weak self, weak scanner] result in
|
scanner.onScanResult = { [weak self, weak scanner] result in
|
||||||
@ -180,6 +416,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
present(nav, animated: true)
|
present(nav, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出时间筛选快捷选项。
|
||||||
private func presentDateFilter() {
|
private func presentDateFilter() {
|
||||||
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
|
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
|
||||||
@ -198,6 +435,7 @@ final class OrdersViewController: UIViewController {
|
|||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出订单状态筛选 ActionSheet。
|
||||||
private func presentStatusFilter() {
|
private func presentStatusFilter() {
|
||||||
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
|
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
|
||||||
for filter in OrderFilters.statusFilters {
|
for filter in OrderFilters.statusFilters {
|
||||||
@ -211,143 +449,51 @@ final class OrdersViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OrdersViewController: UITableViewDataSource, UITableViewDelegate {
|
// MARK: - UICollectionViewDelegate
|
||||||
func numberOfSections(in tableView: UITableView) -> Int {
|
|
||||||
guard currentScenicId != nil else { return 1 }
|
|
||||||
return viewModel.selectedEntry == .storeOrders ? 3 : 3
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
extension OrdersViewController: UICollectionViewDelegate {
|
||||||
if section == 0 { return 1 }
|
/// 点击订单卡片进入详情。
|
||||||
if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 }
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
if currentScenicId == nil { return 1 }
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
if viewModel.selectedEntry == .storeOrders {
|
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1)
|
if item.hasPrefix("orders:store:") {
|
||||||
}
|
let orderNumber = String(item.dropFirst("orders:store:".count))
|
||||||
return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1)
|
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
||||||
}
|
HomeMenuRouting.pushOrders(.storeDetail(order), from: self)
|
||||||
|
} else if item.hasPrefix("orders:writeoff:") {
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
||||||
if indexPath.section == 0 {
|
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell
|
HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
let isLast: Bool
|
||||||
if viewModel.selectedEntry == .storeOrders {
|
if item.hasPrefix("orders:store:") {
|
||||||
isLast = indexPath.row == viewModel.storeOrders.count - 1
|
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 {
|
} 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
|
// MARK: - Cells
|
||||||
|
|
||||||
private final class OrdersHeaderCell: UITableViewCell {
|
/// 订单页头部 Cell,展示子入口切换与统计摘要。
|
||||||
|
private final class OrdersHeaderCell: UICollectionViewCell {
|
||||||
static let reuseID = "OrdersHeaderCell"
|
static let reuseID = "OrdersHeaderCell"
|
||||||
private var onSelectEntry: ((OrdersEntry) -> Void)?
|
private var onSelectEntry: ((OrdersEntry) -> Void)?
|
||||||
|
|
||||||
@ -356,16 +502,16 @@ private final class OrdersHeaderCell: UITableViewCell {
|
|||||||
private let leftPill = UILabel()
|
private let leftPill = UILabel()
|
||||||
private let rightPill = UILabel()
|
private let rightPill = UILabel()
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
/// 初始化 Cell 与子视图布局。
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
override init(frame: CGRect) {
|
||||||
selectionStyle = .none
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
let card = UIView()
|
let card = UIView()
|
||||||
card.backgroundColor = .white
|
card.backgroundColor = .white
|
||||||
card.layer.cornerRadius = 8
|
card.layer.cornerRadius = 8
|
||||||
contentView.addSubview(card)
|
contentView.addSubview(card)
|
||||||
card.snp.makeConstraints { make in
|
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()
|
let segmentBackground = UIView()
|
||||||
@ -410,7 +556,14 @@ private final class OrdersHeaderCell: UITableViewCell {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
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
|
self.onSelectEntry = onSelectEntry
|
||||||
updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders)
|
updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders)
|
||||||
updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders)
|
updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders)
|
||||||
@ -423,6 +576,7 @@ private final class OrdersHeaderCell: UITableViewCell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新分段按钮选中样式。
|
||||||
private func updateSegment(_ button: UIButton, title: String, selected: Bool) {
|
private func updateSegment(_ button: UIButton, title: String, selected: Bool) {
|
||||||
button.setTitle(title, for: .normal)
|
button.setTitle(title, for: .normal)
|
||||||
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)
|
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
|
button.layer.cornerRadius = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换到订单管理入口。
|
||||||
@objc private func storeTapped() { onSelectEntry?(.storeOrders) }
|
@objc private func storeTapped() { onSelectEntry?(.storeOrders) }
|
||||||
|
/// 切换到核销订单入口。
|
||||||
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
|
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
/// 订单筛选栏 Cell,承载状态 / 时间与手机号搜索。
|
||||||
|
private final class OrdersFilterCell: UICollectionViewCell, UITextFieldDelegate {
|
||||||
static let reuseID = "OrdersFilterCell"
|
static let reuseID = "OrdersFilterCell"
|
||||||
private let phoneField = UITextField()
|
private let phoneField = UITextField()
|
||||||
private var onPhoneChange: ((String) -> Void)?
|
private var onPhoneChange: ((String) -> Void)?
|
||||||
private var onSearch: (() -> Void)?
|
private var onSearch: (() -> Void)?
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
/// 初始化 Cell 与子视图布局。
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
override init(frame: CGRect) {
|
||||||
selectionStyle = .none
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
let card = UIView()
|
let card = UIView()
|
||||||
card.backgroundColor = .white
|
card.backgroundColor = .white
|
||||||
card.layer.cornerRadius = 8
|
card.layer.cornerRadius = 8
|
||||||
contentView.addSubview(card)
|
contentView.addSubview(card)
|
||||||
card.snp.makeConstraints { make in
|
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.delegate = self
|
||||||
phoneField.keyboardType = .phonePad
|
phoneField.keyboardType = .phonePad
|
||||||
@ -470,32 +627,44 @@ private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
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
|
phoneField.text = phone
|
||||||
self.onPhoneChange = onPhoneChange
|
self.onPhoneChange = onPhoneChange
|
||||||
self.onSearch = onSearch
|
self.onSearch = onSearch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 手机号输入变化时同步到 ViewModel。
|
||||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||||
onPhoneChange?(textField.text ?? "")
|
onPhoneChange?(textField.text ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate {
|
/// 核销操作栏 Cell,承载扫码与手动输入核销。
|
||||||
|
private final class OrdersWriteOffActionCell: UICollectionViewCell, UITextFieldDelegate {
|
||||||
static let reuseID = "OrdersWriteOffActionCell"
|
static let reuseID = "OrdersWriteOffActionCell"
|
||||||
private let manualField = UITextField()
|
private let manualField = UITextField()
|
||||||
private var onManualChange: ((String) -> Void)?
|
private var onManualChange: ((String) -> Void)?
|
||||||
private var onVerify: (() -> Void)?
|
private var onVerify: (() -> Void)?
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
/// 初始化 Cell 与子视图布局。
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
override init(frame: CGRect) {
|
||||||
selectionStyle = .none
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
let card = UIView()
|
let card = UIView()
|
||||||
card.backgroundColor = .white
|
card.backgroundColor = .white
|
||||||
card.layer.cornerRadius = 8
|
card.layer.cornerRadius = 8
|
||||||
contentView.addSubview(card)
|
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.delegate = self
|
||||||
manualField.placeholder = "手动输入订单号"
|
manualField.placeholder = "手动输入订单号"
|
||||||
@ -516,33 +685,45 @@ private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelega
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
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
|
manualField.text = manualOrderNumber
|
||||||
self.onManualChange = onManualChange
|
self.onManualChange = onManualChange
|
||||||
self.onVerify = onVerify
|
self.onVerify = onVerify
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 手动订单号输入变化时同步到页面状态。
|
||||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||||
onManualChange?(textField.text ?? "")
|
onManualChange?(textField.text ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class OrderEntityCell: UITableViewCell {
|
/// 订单管理列表 Cell,展示单笔订单摘要。
|
||||||
|
private final class OrderEntityCell: UICollectionViewCell {
|
||||||
static let reuseID = "OrderEntityCell"
|
static let reuseID = "OrderEntityCell"
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let statusLabel = UILabel()
|
private let statusLabel = UILabel()
|
||||||
private let amountLabel = UILabel()
|
private let amountLabel = UILabel()
|
||||||
private let phoneLabel = UILabel()
|
private let phoneLabel = UILabel()
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
/// 初始化 Cell 与子视图布局。
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
override init(frame: CGRect) {
|
||||||
selectionStyle = .default
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
let card = UIView()
|
let card = UIView()
|
||||||
card.backgroundColor = .white
|
card.backgroundColor = .white
|
||||||
card.layer.cornerRadius = 8
|
card.layer.cornerRadius = 8
|
||||||
contentView.addSubview(card)
|
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)
|
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||||
@ -563,6 +744,7 @@ private final class OrderEntityCell: UITableViewCell {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 配置订单卡片展示内容。
|
||||||
func configure(item: OrderEntity) {
|
func configure(item: OrderEntity) {
|
||||||
titleLabel.text = item.orderNumber
|
titleLabel.text = item.orderNumber
|
||||||
statusLabel.text = item.orderStatusName
|
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"
|
static let reuseID = "WriteOffOrderCell"
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let subtitleLabel = UILabel()
|
private let subtitleLabel = UILabel()
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
/// 初始化 Cell 与子视图布局。
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
let card = UIView()
|
let card = UIView()
|
||||||
card.backgroundColor = .white
|
card.backgroundColor = .white
|
||||||
card.layer.cornerRadius = 8
|
card.layer.cornerRadius = 8
|
||||||
contentView.addSubview(card)
|
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)
|
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||||
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||||
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
||||||
@ -597,8 +783,34 @@ private final class WriteOffOrderCell: UITableViewCell {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 配置核销订单卡片展示内容。
|
||||||
func configure(item: WriteOffOrderItem, isVerifying: Bool) {
|
func configure(item: WriteOffOrderItem, isVerifying: Bool) {
|
||||||
titleLabel.text = item.orderNumber
|
titleLabel.text = item.orderNumber
|
||||||
subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -120,6 +120,7 @@ final class DepositOrderListViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置State状态。
|
||||||
private func resetState() {
|
private func resetState() {
|
||||||
orders = []
|
orders = []
|
||||||
total = 0
|
total = 0
|
||||||
@ -170,6 +171,7 @@ final class DepositOrderDetailViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置状态。
|
||||||
private func reset(message: String) {
|
private func reset(message: String) {
|
||||||
detail = nil
|
detail = nil
|
||||||
loading = false
|
loading = false
|
||||||
@ -220,6 +222,7 @@ final class DepositOrderShootingInfoViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置状态。
|
||||||
private func reset(message: String) {
|
private func reset(message: String) {
|
||||||
detail = nil
|
detail = nil
|
||||||
loading = false
|
loading = false
|
||||||
@ -324,10 +327,12 @@ final class OrderRefundViewModel {
|
|||||||
return moneyText(value)
|
return moneyText(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// decimal值相关逻辑。
|
||||||
private static func decimalValue(_ text: String) -> Decimal {
|
private static func decimalValue(_ text: String) -> Decimal {
|
||||||
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
|
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// money文本相关逻辑。
|
||||||
private static func moneyText(_ value: Decimal) -> String {
|
private static func moneyText(_ value: Decimal) -> String {
|
||||||
let number = NSDecimalNumber(decimal: value)
|
let number = NSDecimalNumber(decimal: value)
|
||||||
let handler = NSDecimalNumberHandler(
|
let handler = NSDecimalNumberHandler(
|
||||||
@ -379,6 +384,7 @@ final class HistoricalShootingInfoViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置状态。
|
||||||
private func reset(message: String) {
|
private func reset(message: String) {
|
||||||
projectName = ""
|
projectName = ""
|
||||||
projectTypeName = ""
|
projectTypeName = ""
|
||||||
@ -520,6 +526,7 @@ final class MultiTravelTaskUploadViewModel {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验BeforeSubmit输入或状态。
|
||||||
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
|
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
|
||||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard let scenicId, scenicId > 0 else {
|
guard let scenicId, scenicId > 0 else {
|
||||||
@ -549,6 +556,7 @@ final class MultiTravelTaskUploadViewModel {
|
|||||||
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
|
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// upload本地Files相关逻辑。
|
||||||
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
|
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
|
||||||
var uploadFiles: [MultiTravelUploadFileItem] = []
|
var uploadFiles: [MultiTravelUploadFileItem] = []
|
||||||
for index in selectedLocalFiles.indices {
|
for index in selectedLocalFiles.indices {
|
||||||
@ -583,11 +591,13 @@ final class MultiTravelTaskUploadViewModel {
|
|||||||
return uploadFiles
|
return uploadFiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新LocalFileProgress状态。
|
||||||
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
||||||
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
||||||
selectedLocalFiles[index].progress = progress
|
selectedLocalFiles[index].progress = progress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置SpotSelection状态。
|
||||||
private func resetSpotSelection() {
|
private func resetSpotSelection() {
|
||||||
spots = []
|
spots = []
|
||||||
selectedSpotId = nil
|
selectedSpotId = nil
|
||||||
@ -595,11 +605,13 @@ final class MultiTravelTaskUploadViewModel {
|
|||||||
isLoadingSpots = false
|
isLoadingSpots = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// fileType相关逻辑。
|
||||||
private static func fileType(for fileName: String) -> Int {
|
private static func fileType(for fileName: String) -> Int {
|
||||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||||
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Submit上下文,持有跨页面共享状态。
|
||||||
private struct SubmitContext {
|
private struct SubmitContext {
|
||||||
let scenicId: Int
|
let scenicId: Int
|
||||||
let orderNumber: String
|
let orderNumber: String
|
||||||
|
|||||||
@ -18,6 +18,7 @@ final class PaymentCollectionViewController: UIViewController {
|
|||||||
private let remarkField = UITextField()
|
private let remarkField = UITextField()
|
||||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "收款"
|
title = "收款"
|
||||||
@ -27,6 +28,7 @@ final class PaymentCollectionViewController: UIViewController {
|
|||||||
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
|
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化UI相关 UI 或状态。
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
qrImageView.contentMode = .scaleAspectFit
|
qrImageView.contentMode = .scaleAspectFit
|
||||||
statusLabel.numberOfLines = 0
|
statusLabel.numberOfLines = 0
|
||||||
@ -56,18 +58,21 @@ final class PaymentCollectionViewController: UIViewController {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// render 业务逻辑。
|
||||||
private func render() {
|
private func render() {
|
||||||
qrImageView.image = viewModel.qrImage
|
qrImageView.image = viewModel.qrImage
|
||||||
statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status)
|
statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status)
|
||||||
if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// apply金额相关逻辑。
|
||||||
@objc private func applyAmount() {
|
@objc private func applyAmount() {
|
||||||
viewModel.amountText = amountField.text ?? ""
|
viewModel.amountText = amountField.text ?? ""
|
||||||
viewModel.remarkText = remarkField.text ?? ""
|
viewModel.remarkText = remarkField.text ?? ""
|
||||||
_ = viewModel.applyDynamicAmount()
|
_ = viewModel.applyDynamicAmount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// togglePolling相关逻辑。
|
||||||
@objc private func togglePolling() {
|
@objc private func togglePolling() {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.pollUntilPaymentDetected(
|
await viewModel.pollUntilPaymentDetected(
|
||||||
|
|||||||
@ -10,9 +10,13 @@ import Foundation
|
|||||||
/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。
|
/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol PilotCertificationServing {
|
protocol PilotCertificationServing {
|
||||||
|
/// 飞手详情相关逻辑。
|
||||||
func flyerDetail() async throws -> FlyerDetailResponse
|
func flyerDetail() async throws -> FlyerDetailResponse
|
||||||
|
/// 飞手Send验证码相关逻辑。
|
||||||
func flyerSendCode(phone: String) async throws
|
func flyerSendCode(phone: String) async throws
|
||||||
|
/// 飞手提交相关逻辑。
|
||||||
func flyerApply(_ request: FlyerApplyRequest) async throws
|
func flyerApply(_ request: FlyerApplyRequest) async throws
|
||||||
|
/// 飞手编辑相关逻辑。
|
||||||
func flyerEdit(_ request: FlyerEditRequest) async throws
|
func flyerEdit(_ request: FlyerEditRequest) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
|||||||
let realnameStatusText: String
|
let realnameStatusText: String
|
||||||
let certificationLogs: [FlyerCertificationLogItem]
|
let certificationLogs: [FlyerCertificationLogItem]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name = "flyer_nickname"
|
case name = "flyer_nickname"
|
||||||
@ -72,6 +73,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
|||||||
case certificationLogs = "flyers_certification_logs"
|
case certificationLogs = "flyers_certification_logs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(
|
init(
|
||||||
id: Int = 0,
|
id: Int = 0,
|
||||||
name: String = "",
|
name: String = "",
|
||||||
@ -118,6 +120,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
|||||||
self.certificationLogs = certificationLogs
|
self.certificationLogs = certificationLogs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||||
@ -155,6 +158,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
|||||||
let remark: String
|
let remark: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case flyerId = "flyer_id"
|
case flyerId = "flyer_id"
|
||||||
@ -166,6 +170,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
|||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(
|
init(
|
||||||
id: Int = 0,
|
id: Int = 0,
|
||||||
flyerId: Int = 0,
|
flyerId: Int = 0,
|
||||||
@ -186,6 +191,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
|||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||||
@ -218,6 +224,7 @@ struct FlyerApplyRequest: Encodable, Equatable {
|
|||||||
let contactPhone: String
|
let contactPhone: String
|
||||||
let code: String
|
let code: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case realnameStatus = "realname_status"
|
case realnameStatus = "realname_status"
|
||||||
@ -248,6 +255,7 @@ struct FlyerEditRequest: Encodable, Equatable {
|
|||||||
let contactPhone: String
|
let contactPhone: String
|
||||||
let code: String
|
let code: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
@ -277,6 +285,7 @@ enum PilotCertificationValidationError: LocalizedError, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension KeyedDecodingContainer {
|
private extension KeyedDecodingContainer {
|
||||||
|
/// pilot解码宽松字符串相关逻辑。
|
||||||
func pilotDecodeLossyString(forKey key: Key) throws -> String {
|
func pilotDecodeLossyString(forKey key: Key) throws -> String {
|
||||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
@ -293,6 +302,7 @@ private extension KeyedDecodingContainer {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pilot解码宽松整数相关逻辑。
|
||||||
func pilotDecodeLossyInt(forKey key: Key) throws -> Int? {
|
func pilotDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -13,6 +13,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
|||||||
private let viewModel = PilotCertificationViewModel()
|
private let viewModel = PilotCertificationViewModel()
|
||||||
private let statusLabel = UILabel()
|
private let statusLabel = UILabel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "飞手认证"
|
title = "飞手认证"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -26,6 +27,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { [weak self] in self?.updateHeader() }
|
wireViewModel(viewModel) { [weak self] in self?.updateHeader() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化Header相关 UI 或状态。
|
||||||
private func setupHeader() {
|
private func setupHeader() {
|
||||||
statusLabel.numberOfLines = 0
|
statusLabel.numberOfLines = 0
|
||||||
statusLabel.font = .systemFont(ofSize: 14)
|
statusLabel.font = .systemFont(ofSize: 14)
|
||||||
@ -34,17 +36,21 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
|||||||
tableView.tableHeaderView = statusLabel
|
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
|
section == 0 ? formRows.count : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
/// section 标题。
|
||||||
|
override func tableSectionTitle(for section: Int) -> String? {
|
||||||
section == 0 ? "认证信息" : "操作"
|
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(
|
let cell = tableView.dequeueReusableCell(
|
||||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
@ -58,8 +64,8 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
|||||||
return cell
|
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 {
|
if indexPath.section == 1 {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -72,11 +78,13 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI)
|
await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI)
|
||||||
updateHeader()
|
updateHeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提交。
|
||||||
@objc private func submit() {
|
@objc private func submit() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -102,6 +110,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新Header状态。
|
||||||
private func updateHeader() {
|
private func updateHeader() {
|
||||||
let status = viewModel.auditStatusText
|
let status = viewModel.auditStatusText
|
||||||
statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")"
|
statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||||||
protocol PilotRealNameServing {
|
protocol PilotRealNameServing {
|
||||||
|
/// realNameInfo相关逻辑。
|
||||||
func realNameInfo() async throws -> RealNameInfoResponse
|
func realNameInfo() async throws -> RealNameInfoResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,6 +168,7 @@ final class PilotCertificationViewModel {
|
|||||||
flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 }
|
flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// apply 业务逻辑。
|
||||||
private func apply(_ flyer: FlyerDetailResponse) {
|
private func apply(_ flyer: FlyerDetailResponse) {
|
||||||
self.flyer = flyer
|
self.flyer = flyer
|
||||||
name = flyer.name
|
name = flyer.name
|
||||||
@ -183,6 +185,7 @@ final class PilotCertificationViewModel {
|
|||||||
uploadProgress = nil
|
uploadProgress = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// upload待处理Certificate图片相关逻辑。
|
||||||
private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||||||
guard let data = pendingCertificateImageData else { return }
|
guard let data = pendingCertificateImageData else { return }
|
||||||
guard let scenicId, scenicId > 0 else {
|
guard let scenicId, scenicId > 0 else {
|
||||||
@ -203,6 +206,7 @@ final class PilotCertificationViewModel {
|
|||||||
pendingCertificateFileName = nil
|
pendingCertificateFileName = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建ApplyRequest实例。
|
||||||
private func makeApplyRequest() -> FlyerApplyRequest {
|
private func makeApplyRequest() -> FlyerApplyRequest {
|
||||||
FlyerApplyRequest(
|
FlyerApplyRequest(
|
||||||
name: name.trimmedForPilot,
|
name: name.trimmedForPilot,
|
||||||
@ -219,6 +223,7 @@ final class PilotCertificationViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建EditRequest实例。
|
||||||
private func makeEditRequest() -> FlyerEditRequest {
|
private func makeEditRequest() -> FlyerEditRequest {
|
||||||
let apply = makeApplyRequest()
|
let apply = makeApplyRequest()
|
||||||
return FlyerEditRequest(
|
return FlyerEditRequest(
|
||||||
@ -237,6 +242,7 @@ final class PilotCertificationViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// statusName相关逻辑。
|
||||||
private static func statusName(for status: Int) -> String {
|
private static func statusName(for status: Int) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case 1: "审核中"
|
case 1: "审核中"
|
||||||
@ -254,6 +260,7 @@ final class PilotCertificationViewModel {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// date 业务逻辑。
|
||||||
private static func date(from value: String) -> Date? {
|
private static func date(from value: String) -> Date? {
|
||||||
let text = String(value.prefix(10))
|
let text = String(value.prefix(10))
|
||||||
guard !text.isEmpty else { return nil }
|
guard !text.isEmpty else { return nil }
|
||||||
@ -261,9 +268,11 @@ final class PilotCertificationViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PilotEmptyRealName服务协议,定义模块对外能力。
|
||||||
private struct PilotEmptyRealNameServing: PilotRealNameServing {
|
private struct PilotEmptyRealNameServing: PilotRealNameServing {
|
||||||
let info: RealNameInfo?
|
let info: RealNameInfo?
|
||||||
|
|
||||||
|
/// realNameInfo相关逻辑。
|
||||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||||
RealNameInfoResponse(realNameInfo: info)
|
RealNameInfoResponse(realNameInfo: info)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,18 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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()
|
private let viewModel = AccountSwitchViewModel()
|
||||||
|
|
||||||
@ -15,7 +25,6 @@ final class AccountSwitchViewController: UIViewController {
|
|||||||
let table = UITableView(frame: .zero, style: .plain)
|
let table = UITableView(frame: .zero, style: .plain)
|
||||||
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||||
table.separatorStyle = .none
|
table.separatorStyle = .none
|
||||||
table.dataSource = self
|
|
||||||
table.delegate = self
|
table.delegate = self
|
||||||
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
||||||
return table
|
return table
|
||||||
@ -27,11 +36,16 @@ final class AccountSwitchViewController: UIViewController {
|
|||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动账号列表与选中态刷新。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>!
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "账号切换"
|
title = "账号切换"
|
||||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||||
|
|
||||||
|
configureTableDataSource()
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
view.addSubview(confirmButton)
|
view.addSubview(confirmButton)
|
||||||
tableView.snp.makeConstraints { make in
|
tableView.snp.makeConstraints { make in
|
||||||
@ -45,18 +59,75 @@ final class AccountSwitchViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModel.onChange = { [weak self] in
|
viewModel.onChange = { [weak self] in
|
||||||
self?.tableView.reloadData()
|
self?.applyTableSnapshot(reconfigure: true)
|
||||||
self?.updateConfirmButton()
|
self?.updateConfirmButton()
|
||||||
}
|
}
|
||||||
Task { await loadAccounts() }
|
Task { await loadAccounts() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>(
|
||||||
|
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<AccountSwitchSection, AccountSwitchItem> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem>()
|
||||||
|
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() {
|
private func updateConfirmButton() {
|
||||||
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||||
confirmButton.isEnabled = enabled
|
confirmButton.isEnabled = enabled
|
||||||
confirmButton.alpha = enabled ? 1 : 0.5
|
confirmButton.alpha = enabled ? 1 : 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载Accounts数据。
|
||||||
private func loadAccounts(force: Bool = false) async {
|
private func loadAccounts(force: Bool = false) async {
|
||||||
do {
|
do {
|
||||||
try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
|
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
|
return current.userId.isEmpty ? nil : current.userId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断isCurrentAccount条件。
|
||||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||||
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击confirm的处理逻辑。
|
||||||
@objc private func confirmTapped() {
|
@objc private func confirmTapped() {
|
||||||
guard let account = viewModel.selectedAccount else { return }
|
guard let account = viewModel.selectedAccount else { return }
|
||||||
if account.isCurrent || isCurrentAccount(account) {
|
if account.isCurrent || isCurrentAccount(account) {
|
||||||
@ -95,6 +168,7 @@ final class AccountSwitchViewController: UIViewController {
|
|||||||
Task { await switchAccount(account) }
|
Task { await switchAccount(account) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// switch账号相关逻辑。
|
||||||
private func switchAccount(_ account: AccountSwitchAccount) async {
|
private func switchAccount(_ account: AccountSwitchAccount) async {
|
||||||
do {
|
do {
|
||||||
let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
|
let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
|
||||||
@ -121,53 +195,29 @@ final class AccountSwitchViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// non空相关逻辑。
|
||||||
private func nonEmpty(_ value: String?) -> String? {
|
private func nonEmpty(_ value: String?) -> String? {
|
||||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return text.isEmpty ? nil : text
|
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) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
guard indexPath.row < viewModel.accounts.count else { return }
|
guard let item = tableDataSource.itemIdentifier(for: indexPath),
|
||||||
viewModel.select(viewModel.accounts[indexPath.row])
|
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 {
|
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 {
|
private final class AccountSwitchCell: UITableViewCell {
|
||||||
static let reuseID = "AccountSwitchCell"
|
static let reuseID = "AccountSwitchCell"
|
||||||
|
|
||||||
@ -178,6 +228,7 @@ private final class AccountSwitchCell: UITableViewCell {
|
|||||||
private let tagLabel = UILabel()
|
private let tagLabel = UILabel()
|
||||||
private let checkView = UIImageView()
|
private let checkView = UIImageView()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
@ -245,6 +296,7 @@ private final class AccountSwitchCell: UITableViewCell {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 配置展示内容。
|
||||||
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
||||||
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
titleLabel.text = title.isEmpty ? account.accountTypeLabel : title
|
titleLabel.text = title.isEmpty ? account.accountTypeLabel : title
|
||||||
|
|||||||
@ -7,8 +7,29 @@ import PhotosUI
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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()
|
private let viewModel = ProfileViewModel()
|
||||||
|
|
||||||
@ -19,18 +40,22 @@ final class ProfileViewController: UIViewController {
|
|||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var tableView: UITableView = {
|
||||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
table.dataSource = self
|
|
||||||
table.delegate = self
|
table.delegate = self
|
||||||
return table
|
return table
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var refreshControl = UIRefreshControl()
|
private lazy var refreshControl = UIRefreshControl()
|
||||||
|
|
||||||
|
/// Diffable 数据源,驱动个人信息列表刷新。
|
||||||
|
private var tableDataSource: UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>!
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "个人信息"
|
title = "个人信息"
|
||||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||||
setupHeader()
|
setupHeader()
|
||||||
|
configureTableDataSource()
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
tableView.snp.makeConstraints { make in
|
tableView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
|
||||||
@ -49,6 +74,74 @@ final class ProfileViewController: UIViewController {
|
|||||||
Task { await reloadProfile(showToast: false) }
|
Task { await reloadProfile(showToast: false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置 Diffable 数据源。
|
||||||
|
private func configureTableDataSource() {
|
||||||
|
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
|
||||||
|
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<ProfileTableSection, ProfileTableRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<ProfileTableSection, ProfileTableRow>()
|
||||||
|
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() {
|
private func setupHeader() {
|
||||||
let header = UIView()
|
let header = UIView()
|
||||||
header.backgroundColor = .white
|
header.backgroundColor = .white
|
||||||
@ -101,6 +194,7 @@ final class ProfileViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// apply视图模型相关逻辑。
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
nicknameLabel.text = viewModel.displayNickname
|
nicknameLabel.text = viewModel.displayNickname
|
||||||
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
|
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.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
|
||||||
editButton.isEnabled = !viewModel.isSaving
|
editButton.isEnabled = !viewModel.isSaving
|
||||||
tableView.reloadData()
|
applyTableSnapshot(animated: false, reconfigure: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Pulled展示。
|
||||||
@objc private func refreshPulled() {
|
@objc private func refreshPulled() {
|
||||||
Task {
|
Task {
|
||||||
await reloadProfile(showToast: true)
|
await reloadProfile(showToast: true)
|
||||||
@ -121,6 +216,7 @@ final class ProfileViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Profile。
|
||||||
private func reloadProfile(showToast: Bool) async {
|
private func reloadProfile(showToast: Bool) async {
|
||||||
do {
|
do {
|
||||||
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
|
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
|
||||||
@ -131,6 +227,7 @@ final class ProfileViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击edit的处理逻辑。
|
||||||
@objc private func editTapped() {
|
@objc private func editTapped() {
|
||||||
if viewModel.isEditingProfile {
|
if viewModel.isEditingProfile {
|
||||||
Task { await saveProfileEdits() }
|
Task { await saveProfileEdits() }
|
||||||
@ -140,6 +237,7 @@ final class ProfileViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出NicknameEditor页面。
|
||||||
private func presentNicknameEditor() {
|
private func presentNicknameEditor() {
|
||||||
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
|
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
|
||||||
alert.addTextField { [weak self] field in
|
alert.addTextField { [weak self] field in
|
||||||
@ -157,6 +255,7 @@ final class ProfileViewController: UIViewController {
|
|||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// save个人中心Edits相关逻辑。
|
||||||
private func saveProfileEdits() async {
|
private func saveProfileEdits() async {
|
||||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||||
showToast("请先选择景区")
|
showToast("请先选择景区")
|
||||||
@ -176,6 +275,7 @@ final class ProfileViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pick头像相关逻辑。
|
||||||
@objc private func pickAvatar() {
|
@objc private func pickAvatar() {
|
||||||
var config = PHPickerConfiguration()
|
var config = PHPickerConfiguration()
|
||||||
config.filter = .images
|
config.filter = .images
|
||||||
@ -185,6 +285,7 @@ final class ProfileViewController: UIViewController {
|
|||||||
present(picker, animated: true)
|
present(picker, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击logout的处理逻辑。
|
||||||
@objc private func logoutTapped() {
|
@objc private func logoutTapped() {
|
||||||
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
|
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
@ -201,58 +302,19 @@ final class ProfileViewController: UIViewController {
|
|||||||
})
|
})
|
||||||
present(alert, animated: true)
|
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) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
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()
|
logoutTapped()
|
||||||
return
|
case ProfileRowID.account:
|
||||||
}
|
|
||||||
switch indexPath.row {
|
|
||||||
case 1:
|
|
||||||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
||||||
case 3:
|
case ProfileRowID.realName:
|
||||||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
||||||
case 4:
|
case ProfileRowID.settings:
|
||||||
HomeMenuRouting.pushProfile(.settings, from: self)
|
HomeMenuRouting.pushProfile(.settings, from: self)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@ -261,6 +323,7 @@ extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController: PHPickerViewControllerDelegate {
|
extension ProfileViewController: PHPickerViewControllerDelegate {
|
||||||
|
/// picker 业务逻辑。
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
picker.dismiss(animated: true)
|
picker.dismiss(animated: true)
|
||||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||||
|
|||||||
@ -25,6 +25,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
private let startDatePicker = UIDatePicker()
|
private let startDatePicker = UIDatePicker()
|
||||||
private let endDatePicker = UIDatePicker()
|
private let endDatePicker = UIDatePicker()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "实名认证"
|
title = "实名认证"
|
||||||
@ -34,6 +35,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
Task { await loadInfo() }
|
Task { await loadInfo() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化Form相关 UI 或状态。
|
||||||
private func setupForm() {
|
private func setupForm() {
|
||||||
contentStack.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
contentStack.spacing = 16
|
contentStack.spacing = 16
|
||||||
@ -107,6 +109,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
contentStack.addArrangedSubview(submitButton)
|
contentStack.addArrangedSubview(submitButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建SectionTitle实例。
|
||||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = text
|
label.text = text
|
||||||
@ -114,6 +117,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// labeled字段相关逻辑。
|
||||||
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
|
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = title
|
label.text = title
|
||||||
@ -125,6 +129,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
return stack
|
return stack
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// apply视图模型相关逻辑。
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
realNameField.text = viewModel.realName
|
realNameField.text = viewModel.realName
|
||||||
idCardField.text = viewModel.idCardNo
|
idCardField.text = viewModel.idCardNo
|
||||||
@ -163,6 +168,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载Info数据。
|
||||||
private func loadInfo() async {
|
private func loadInfo() async {
|
||||||
do {
|
do {
|
||||||
try await appServices.globalLoading.withLoading(message: "加载中...") {
|
try await appServices.globalLoading.withLoading(message: "加载中...") {
|
||||||
@ -173,10 +179,12 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// longValid变更相关逻辑。
|
||||||
@objc private func longValidChanged() {
|
@objc private func longValidChanged() {
|
||||||
viewModel.isLongValid = longValidSwitch.isOn
|
viewModel.isLongValid = longValidSwitch.isOn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击send验证码的处理逻辑。
|
||||||
@objc private func sendCodeTapped() {
|
@objc private func sendCodeTapped() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -187,6 +195,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提交Tapped。
|
||||||
@objc private func submitTapped() {
|
@objc private func submitTapped() {
|
||||||
viewModel.realName = realNameField.text ?? ""
|
viewModel.realName = realNameField.text ?? ""
|
||||||
viewModel.idCardNo = idCardField.text ?? ""
|
viewModel.idCardNo = idCardField.text ?? ""
|
||||||
@ -215,16 +224,19 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
|
|
||||||
private var pendingSide: RealNameImageSide = .front
|
private var pendingSide: RealNameImageSide = .front
|
||||||
|
|
||||||
|
/// pickFront相关逻辑。
|
||||||
@objc private func pickFront() {
|
@objc private func pickFront() {
|
||||||
pendingSide = .front
|
pendingSide = .front
|
||||||
presentImagePicker()
|
presentImagePicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pickBack相关逻辑。
|
||||||
@objc private func pickBack() {
|
@objc private func pickBack() {
|
||||||
pendingSide = .back
|
pendingSide = .back
|
||||||
presentImagePicker()
|
presentImagePicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 弹出ImagePicker页面。
|
||||||
private func presentImagePicker() {
|
private func presentImagePicker() {
|
||||||
var config = PHPickerConfiguration()
|
var config = PHPickerConfiguration()
|
||||||
config.filter = .images
|
config.filter = .images
|
||||||
@ -236,6 +248,7 @@ final class RealNameAuthViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||||
|
/// picker 业务逻辑。
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
picker.dismiss(animated: true)
|
picker.dismiss(animated: true)
|
||||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||||
@ -253,6 +266,7 @@ extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension UILabel {
|
private extension UILabel {
|
||||||
|
/// 初始化实例。
|
||||||
convenience init(text: String) {
|
convenience init(text: String) {
|
||||||
self.init()
|
self.init()
|
||||||
self.text = text
|
self.text = text
|
||||||
|
|||||||
@ -7,64 +7,81 @@ import SnapKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
import WebKit
|
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 var copiedDownloadLink = false
|
||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private let rowActions: [String: SettingsRowAction] = [
|
||||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
SettingsRowID.about: .agreement(.about),
|
||||||
table.dataSource = self
|
SettingsRowID.version: .version,
|
||||||
table.delegate = self
|
SettingsRowID.download: .download,
|
||||||
return table
|
SettingsRowID.userAgreement: .agreement(.userAgreement),
|
||||||
}()
|
SettingsRowID.privacyPolicy: .agreement(.privacyPolicy)
|
||||||
|
|
||||||
private let rows: [(title: String, action: SettingsRowAction)] = [
|
|
||||||
("关于我们", .agreement(.about)),
|
|
||||||
("系统版本", .version),
|
|
||||||
("App下载", .download),
|
|
||||||
("用户协议", .agreement(.userAgreement)),
|
|
||||||
("隐私政策", .agreement(.privacyPolicy))
|
|
||||||
]
|
]
|
||||||
|
|
||||||
private enum SettingsRowAction {
|
private let rowTitles: [String: String] = [
|
||||||
case agreement(AgreementPage)
|
SettingsRowID.about: "关于我们",
|
||||||
case version
|
SettingsRowID.version: "系统版本",
|
||||||
case download
|
SettingsRowID.download: "App下载",
|
||||||
}
|
SettingsRowID.userAgreement: "用户协议",
|
||||||
|
SettingsRowID.privacyPolicy: "隐私政策"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = "设置"
|
title = "设置"
|
||||||
view.addSubview(tableView)
|
|
||||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var downloadLink: String {
|
/// 构建 Diffable snapshot。
|
||||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
override func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems([
|
||||||
|
SettingsRowID.about,
|
||||||
|
SettingsRowID.version,
|
||||||
|
SettingsRowID.download,
|
||||||
|
SettingsRowID.userAgreement,
|
||||||
|
SettingsRowID.privacyPolicy
|
||||||
|
], toSection: 0)
|
||||||
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copyDownloadLink() {
|
/// section 页脚文案。
|
||||||
UIPasteboard.general.string = downloadLink
|
override func sectionFooter(for section: SimpleTableSection) -> String? {
|
||||||
copiedDownloadLink = true
|
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
/// 配置 Cell 展示内容。
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count }
|
override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
|
||||||
|
cell.textLabel?.text = rowTitles[row]
|
||||||
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.textLabel?.textColor = UIColor(hex: 0x4B5563)
|
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:
|
case .version:
|
||||||
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
|
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
|
||||||
cell.selectionStyle = .none
|
cell.selectionStyle = .none
|
||||||
@ -74,12 +91,12 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
case .agreement:
|
case .agreement:
|
||||||
cell.accessoryType = .disclosureIndicator
|
cell.accessoryType = .disclosureIndicator
|
||||||
}
|
}
|
||||||
return cell
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
/// 处理行选中。
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
override func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {
|
||||||
switch rows[indexPath.row].action {
|
guard let action = rowActions[row] else { return }
|
||||||
|
switch action {
|
||||||
case .agreement(let page):
|
case .agreement(let page):
|
||||||
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
||||||
case .download:
|
case .download:
|
||||||
@ -89,8 +106,25 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
private var downloadLink: String {
|
||||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
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 webView = WKWebView(frame: .zero)
|
||||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(page: AgreementPage) {
|
init(page: AgreementPage) {
|
||||||
self.page = page
|
self.page = page
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -109,6 +144,7 @@ final class AgreementViewController: UIViewController {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = page.title
|
title = page.title
|
||||||
@ -124,10 +160,12 @@ final class AgreementViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension AgreementViewController: WKNavigationDelegate {
|
extension AgreementViewController: WKNavigationDelegate {
|
||||||
|
/// web视图相关逻辑。
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
loadingIndicator.stopAnimating()
|
loadingIndicator.stopAnimating()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// web视图相关逻辑。
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
loadingIndicator.stopAnimating()
|
loadingIndicator.stopAnimating()
|
||||||
showToast(error.localizedDescription)
|
showToast(error.localizedDescription)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import UIKit
|
|||||||
final class ProjectManagementViewController: ModuleTableViewController {
|
final class ProjectManagementViewController: ModuleTableViewController {
|
||||||
private let viewModel = ProjectManagementViewModel()
|
private let viewModel = ProjectManagementViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "项目管理"
|
title = "项目管理"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -23,13 +24,16 @@ final class ProjectManagementViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.items.count }
|
override func tableRowCount() -> Int { viewModel.items.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let item = viewModel.items[indexPath.row]
|
let item = viewModel.items[indexPath.row]
|
||||||
cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)")
|
cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// didSelectTableRow 回调处理。
|
||||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||||
let item = viewModel.items[indexPath.row]
|
let item = viewModel.items[indexPath.row]
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
@ -38,10 +42,12 @@ final class ProjectManagementViewController: ModuleTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId)
|
await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建项目。
|
||||||
@objc private func createProject() {
|
@objc private func createProject() {
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
ProjectEditorViewController(projectId: nil),
|
ProjectEditorViewController(projectId: nil),
|
||||||
@ -56,19 +62,23 @@ extension ProjectManagementViewModel: ViewModelBindable {}
|
|||||||
final class StoreProjectManagementViewController: ModuleTableViewController {
|
final class StoreProjectManagementViewController: ModuleTableViewController {
|
||||||
private let viewModel = StoreProjectManagementViewModel()
|
private let viewModel = StoreProjectManagementViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "店铺项目"
|
title = "店铺项目"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.filteredItems.count }
|
override func tableRowCount() -> Int { viewModel.filteredItems.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let item = viewModel.filteredItems[indexPath.row]
|
let item = viewModel.filteredItems[indexPath.row]
|
||||||
cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)")
|
cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// didSelectTableRow 回调处理。
|
||||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||||
let item = viewModel.filteredItems[indexPath.row]
|
let item = viewModel.filteredItems[indexPath.row]
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
@ -77,6 +87,7 @@ final class StoreProjectManagementViewController: ModuleTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(api: services.projectAPI, userId: services.userId)
|
await viewModel.reload(api: services.projectAPI, userId: services.userId)
|
||||||
}
|
}
|
||||||
@ -91,6 +102,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
|||||||
private let photographerViewModel = ProjectManagementViewModel()
|
private let photographerViewModel = ProjectManagementViewModel()
|
||||||
private let storeViewModel = StoreProjectManagementViewModel()
|
private let storeViewModel = StoreProjectManagementViewModel()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(projectId: Int, storeMode: Bool) {
|
init(projectId: Int, storeMode: Bool) {
|
||||||
self.projectId = projectId
|
self.projectId = projectId
|
||||||
self.storeMode = storeMode
|
self.storeMode = storeMode
|
||||||
@ -102,6 +114,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "项目详情"
|
title = "项目详情"
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
@ -116,10 +129,12 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
|||||||
storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail
|
storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int {
|
override func tableRowCount() -> Int {
|
||||||
detail == nil ? 0 : 4
|
detail == nil ? 0 : 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
guard let detail else { return }
|
guard let detail else { return }
|
||||||
switch indexPath.row {
|
switch indexPath.row {
|
||||||
@ -130,6 +145,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
if storeMode {
|
if storeMode {
|
||||||
await storeViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
await storeViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
||||||
@ -147,6 +163,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
|||||||
private let priceField = UITextField()
|
private let priceField = UITextField()
|
||||||
private let projectId: Int?
|
private let projectId: Int?
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(projectId: Int?) {
|
init(projectId: Int?) {
|
||||||
self.projectId = projectId
|
self.projectId = projectId
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
@ -157,6 +174,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = projectId == nil ? "新建项目" : "编辑项目"
|
title = projectId == nil ? "新建项目" : "编辑项目"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -170,6 +188,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() }
|
wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化Header相关 UI 或状态。
|
||||||
private func setupHeader() {
|
private func setupHeader() {
|
||||||
nameField.placeholder = "项目名称"
|
nameField.placeholder = "项目名称"
|
||||||
nameField.borderStyle = .roundedRect
|
nameField.borderStyle = .roundedRect
|
||||||
@ -187,8 +206,10 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
|||||||
tableView.tableHeaderView = stack
|
tableView.tableHeaderView = stack
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { 0 }
|
override func tableRowCount() -> Int { 0 }
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
guard let projectId else { return }
|
guard let projectId else { return }
|
||||||
if let detail = try? await services.projectAPI.projectDetail(id: projectId) {
|
if let detail = try? await services.projectAPI.projectDetail(id: projectId) {
|
||||||
@ -197,12 +218,14 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按需fill表单。
|
||||||
private func fillFormIfNeeded() {
|
private func fillFormIfNeeded() {
|
||||||
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
||||||
if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText }
|
if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText }
|
||||||
if priceField.text?.isEmpty != false { priceField.text = viewModel.price }
|
if priceField.text?.isEmpty != false { priceField.text = viewModel.price }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// save 业务逻辑。
|
||||||
@objc private func save() {
|
@objc private func save() {
|
||||||
viewModel.name = nameField.text ?? ""
|
viewModel.name = nameField.text ?? ""
|
||||||
viewModel.descriptionText = descriptionField.text ?? ""
|
viewModel.descriptionText = descriptionField.text ?? ""
|
||||||
@ -231,6 +254,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
|||||||
private let viewModel = StoreProjectEditorViewModel(mode: .create)
|
private let viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||||
private let nameField = UITextField()
|
private let nameField = UITextField()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(projectId: Int?) {
|
init(projectId: Int?) {
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
@ -240,6 +264,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "店铺项目"
|
title = "店铺项目"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -256,18 +281,22 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.scenicList.count }
|
override func tableRowCount() -> Int { viewModel.scenicList.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let scenic = viewModel.scenicList[indexPath.row]
|
let scenic = viewModel.scenicList[indexPath.row]
|
||||||
cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil)
|
cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
guard let userId = Int(services.userId ?? "") else { return }
|
guard let userId = Int(services.userId ?? "") else { return }
|
||||||
await viewModel.loadScenicList(api: services.projectAPI, userId: userId)
|
await viewModel.loadScenicList(api: services.projectAPI, userId: userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// save 业务逻辑。
|
||||||
@objc private func save() {
|
@objc private func save() {
|
||||||
viewModel.name = nameField.text ?? ""
|
viewModel.name = nameField.text ?? ""
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@ -130,6 +130,7 @@ final class ProjectManagementViewModel {
|
|||||||
return value.isEmpty ? nil : value
|
return value.isEmpty ? nil : value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重置List状态。
|
||||||
private func resetList() {
|
private func resetList() {
|
||||||
items = []
|
items = []
|
||||||
loading = false
|
loading = false
|
||||||
@ -139,6 +140,7 @@ final class ProjectManagementViewModel {
|
|||||||
total = 0
|
total = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// sorted 业务逻辑。
|
||||||
private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||||
values.sorted { lhs, rhs in
|
values.sorted { lhs, rhs in
|
||||||
if lhs.status != rhs.status { return lhs.status < rhs.status }
|
if lhs.status != rhs.status { return lhs.status < rhs.status }
|
||||||
@ -146,6 +148,7 @@ final class ProjectManagementViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// deduplicated 业务逻辑。
|
||||||
private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||||
var result: [Int: PhotographerProjectItem] = [:]
|
var result: [Int: PhotographerProjectItem] = [:]
|
||||||
values.forEach { result[$0.id] = $0 }
|
values.forEach { result[$0.id] = $0 }
|
||||||
@ -278,6 +281,7 @@ final class ProjectEditorViewModel {
|
|||||||
return labels.isEmpty ? nil : labels
|
return labels.isEmpty ? nil : labels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// resolveCover链接相关逻辑。
|
||||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||||
if let coverImage {
|
if let coverImage {
|
||||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in
|
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
|
return existingCoverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// resolveCarouselURLs相关逻辑。
|
||||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||||
var urls = existingCarouselURLs
|
var urls = existingCarouselURLs
|
||||||
for image in carouselImages {
|
for image in carouselImages {
|
||||||
@ -298,11 +303,13 @@ final class ProjectEditorViewModel {
|
|||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 规范化dMoney格式。
|
||||||
private func normalizedMoney(_ value: String) -> String? {
|
private func normalizedMoney(_ value: String) -> String? {
|
||||||
let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||||
return amount > 0 ? String(format: "%.2f", amount) : nil
|
return amount > 0 ? String(format: "%.2f", amount) : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 记录校验失败并返回 false。
|
||||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
return false
|
return false
|
||||||
@ -511,6 +518,7 @@ final class StoreProjectEditorViewModel {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// resolveCover链接相关逻辑。
|
||||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||||
if let coverImage {
|
if let coverImage {
|
||||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in }
|
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in }
|
||||||
@ -518,6 +526,7 @@ final class StoreProjectEditorViewModel {
|
|||||||
return existingCoverURL
|
return existingCoverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// resolveCarouselURLs相关逻辑。
|
||||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||||
var urls = existingCarouselURLs
|
var urls = existingCarouselURLs
|
||||||
for image in carouselImages {
|
for image in carouselImages {
|
||||||
@ -527,6 +536,7 @@ final class StoreProjectEditorViewModel {
|
|||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提交MultiPointProject。
|
||||||
private func submitMultiPointProject(
|
private func submitMultiPointProject(
|
||||||
userId: Int,
|
userId: Int,
|
||||||
api: any ProjectServing,
|
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 {
|
private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws {
|
||||||
let scenicId = selectedScenicIds.first ?? 0
|
let scenicId = selectedScenicIds.first ?? 0
|
||||||
let storeId = selectedStoreId ?? 0
|
let storeId = selectedStoreId ?? 0
|
||||||
@ -617,6 +628,7 @@ final class StoreProjectEditorViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 记录校验失败并返回 false。
|
||||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -36,6 +36,7 @@ struct PunchPointRegion: Codable, Hashable {
|
|||||||
var address: String
|
var address: String
|
||||||
var scenicSpotStr: String?
|
var scenicSpotStr: String?
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case lat
|
case lat
|
||||||
case lot
|
case lot
|
||||||
@ -80,6 +81,7 @@ struct PunchPointItem: Decodable, Hashable, Identifiable {
|
|||||||
let auditTime: String
|
let auditTime: String
|
||||||
let auditRemark: String
|
let auditRemark: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case scenicAreaId = "scenic_area_id"
|
case scenicAreaId = "scenic_area_id"
|
||||||
@ -167,6 +169,7 @@ struct AddPunchPointRequest: Encodable, Equatable {
|
|||||||
let scenicSpotStr: String
|
let scenicSpotStr: String
|
||||||
let guideImages: [String]
|
let guideImages: [String]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicAreaId = "scenic_area_id"
|
case scenicAreaId = "scenic_area_id"
|
||||||
case name
|
case name
|
||||||
@ -187,6 +190,7 @@ struct EditPunchPointRequest: Encodable, Equatable {
|
|||||||
let scenicSpotStr: String
|
let scenicSpotStr: String
|
||||||
let guideImages: [String]
|
let guideImages: [String]
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case scenicAreaId = "scenic_area_id"
|
case scenicAreaId = "scenic_area_id"
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
// Created by Codex on 2026/6/26.
|
// Created by Codex on 2026/6/26.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -18,6 +19,7 @@ private extension PunchPointItem {
|
|||||||
final class PunchPointListViewController: ModuleTableViewController {
|
final class PunchPointListViewController: ModuleTableViewController {
|
||||||
private let viewModel = PunchPointListViewModel()
|
private let viewModel = PunchPointListViewModel()
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = "打卡点"
|
title = "打卡点"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -30,13 +32,16 @@ final class PunchPointListViewController: ModuleTableViewController {
|
|||||||
wireViewModel(viewModel) { }
|
wireViewModel(viewModel) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int { viewModel.items.count }
|
override func tableRowCount() -> Int { viewModel.items.count }
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let item = viewModel.items[indexPath.row]
|
let item = viewModel.items[indexPath.row]
|
||||||
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
|
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// didSelectTableRow 回调处理。
|
||||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||||
let item = viewModel.items[indexPath.row]
|
let item = viewModel.items[indexPath.row]
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
@ -45,10 +50,12 @@ final class PunchPointListViewController: ModuleTableViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
|
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建打卡Point。
|
||||||
@objc private func createPunchPoint() {
|
@objc private func createPunchPoint() {
|
||||||
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
|
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
|
||||||
}
|
}
|
||||||
@ -62,6 +69,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
|||||||
private let summary: PunchPointItem?
|
private let summary: PunchPointItem?
|
||||||
private let viewModel = PunchPointListViewModel()
|
private let viewModel = PunchPointListViewModel()
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(punchPointId: Int, summary: PunchPointItem?) {
|
init(punchPointId: Int, summary: PunchPointItem?) {
|
||||||
self.punchPointId = punchPointId
|
self.punchPointId = punchPointId
|
||||||
self.summary = summary
|
self.summary = summary
|
||||||
@ -73,6 +81,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
title = summary?.name ?? "打卡点详情"
|
title = summary?.name ?? "打卡点详情"
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
@ -88,11 +97,13 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// table行Count相关逻辑。
|
||||||
override func tableRowCount() -> Int {
|
override func tableRowCount() -> Int {
|
||||||
guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
|
guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
|
||||||
return 4
|
return 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 配置Cell展示内容。
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||||
let detail = viewModel.selectedDetail ?? summary
|
let detail = viewModel.selectedDetail ?? summary
|
||||||
guard let detail else { return }
|
guard let detail else { return }
|
||||||
@ -104,10 +115,12 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 刷新Content。
|
||||||
override func reloadContent() async {
|
override func reloadContent() async {
|
||||||
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
|
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 展示QR。
|
||||||
@objc private func showQR() {
|
@objc private func showQR() {
|
||||||
guard let detail = viewModel.selectedDetail ?? summary else { return }
|
guard let detail = viewModel.selectedDetail ?? summary else { return }
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
@ -118,10 +131,17 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 打卡点编辑页。
|
/// 打卡点编辑页。
|
||||||
final class PunchPointEditorViewController: ModuleTableViewController {
|
final class PunchPointEditorViewController: UIViewController {
|
||||||
private let viewModel = PunchPointEditorViewModel()
|
private let viewModel = PunchPointEditorViewModel()
|
||||||
private let nameField = UITextField()
|
|
||||||
private let punchPointId: Int?
|
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?) {
|
init(punchPointId: Int?) {
|
||||||
self.punchPointId = punchPointId
|
self.punchPointId = punchPointId
|
||||||
@ -134,48 +154,126 @@ final class PunchPointEditorViewController: ModuleTableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
|
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
|
||||||
|
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
title: "保存",
|
title: "保存",
|
||||||
style: .done,
|
style: .done,
|
||||||
target: self,
|
target: self,
|
||||||
action: #selector(save)
|
action: #selector(save)
|
||||||
)
|
)
|
||||||
super.viewDidLoad()
|
|
||||||
nameField.placeholder = "打卡点名称"
|
configureFields()
|
||||||
nameField.borderStyle = .roundedRect
|
layoutForm()
|
||||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
wireViewModel()
|
||||||
tableView.tableHeaderView = nameField
|
|
||||||
wireViewModel(viewModel) { [weak self] in
|
mapPicker.onLocationPicked = { [weak self] lat, lng, address in
|
||||||
if self?.nameField.text?.isEmpty != false {
|
self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address)
|
||||||
self?.nameField.text = self?.viewModel.name
|
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 }
|
private func configureFields() {
|
||||||
|
[nameField, addressField, latitudeField, longitudeField].forEach {
|
||||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
$0.borderStyle = .roundedRect
|
||||||
cell.configure(title: "地址", subtitle: viewModel.address)
|
$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 {
|
private func layoutForm() {
|
||||||
guard let punchPointId else { return }
|
let locateButton = UIButton(type: .system)
|
||||||
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
|
locateButton.setTitle("使用当前位置", for: .normal)
|
||||||
viewModel.apply(detail)
|
locateButton.addTarget(self, action: #selector(useCurrentLocation), for: .touchUpInside)
|
||||||
nameField.text = viewModel.name
|
|
||||||
|
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() {
|
@objc private func save() {
|
||||||
viewModel.name = nameField.text ?? ""
|
viewModel.name = nameField.text ?? ""
|
||||||
|
viewModel.address = addressField.text ?? ""
|
||||||
|
viewModel.latitudeText = latitudeField.text ?? ""
|
||||||
|
viewModel.longitudeText = longitudeField.text ?? ""
|
||||||
Task {
|
Task {
|
||||||
let success = await viewModel.submit(
|
let success = await viewModel.submit(
|
||||||
scenicId: services.currentScenicId,
|
scenicId: services.currentScenicId,
|
||||||
api: services.punchPointAPI,
|
api: services.punchPointAPI,
|
||||||
uploadService: services.ossUploadService
|
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 pageTitle: String
|
||||||
private let qrURL: String
|
private let qrURL: String
|
||||||
|
|
||||||
|
/// 初始化实例。
|
||||||
init(title: String, qrURL: String) {
|
init(title: String, qrURL: String) {
|
||||||
pageTitle = title
|
pageTitle = title
|
||||||
self.qrURL = qrURL
|
self.qrURL = qrURL
|
||||||
@ -198,6 +297,7 @@ final class PunchPointQRViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = pageTitle
|
title = pageTitle
|
||||||
|
|||||||
@ -10,17 +10,29 @@ import Foundation
|
|||||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ScenicQueueServing: AnyObject {
|
protocol ScenicQueueServing: AnyObject {
|
||||||
|
/// scenic排队统计相关逻辑。
|
||||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData
|
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
|
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
|
||||||
|
/// socket令牌相关逻辑。
|
||||||
func socketToken() async throws -> SocketTokenResponse
|
func socketToken() async throws -> SocketTokenResponse
|
||||||
|
/// scenic排队Call相关逻辑。
|
||||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
|
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
|
||||||
|
/// scenic排队Pass相关逻辑。
|
||||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
|
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
|
||||||
|
/// scenic排队Finish相关逻辑。
|
||||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
|
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
|
||||||
|
/// scenic排队RequeueInsertBefore相关逻辑。
|
||||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
|
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
|
||||||
|
/// scenic排队UserMark相关逻辑。
|
||||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
|
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
|
||||||
|
/// scenic排队Setting相关逻辑。
|
||||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
|
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
|
||||||
|
/// scenic排队保存Setting相关逻辑。
|
||||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
|
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
|
||||||
|
/// scenic排队Shoot排队QRCode相关逻辑。
|
||||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
|
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
|
||||||
|
/// scenic排队SettingChangeLog相关逻辑。
|
||||||
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
|
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ struct ScenicQueueStatsData: Decodable, Equatable {
|
|||||||
let avgWaitMin: Double
|
let avgWaitMin: Double
|
||||||
let time: String
|
let time: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case queueCount = "queue_count"
|
case queueCount = "queue_count"
|
||||||
case avgWaitMin = "avg_wait_min"
|
case avgWaitMin = "avg_wait_min"
|
||||||
@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
|
|||||||
let list: ScenicQueueHomeListBlock?
|
let list: ScenicQueueHomeListBlock?
|
||||||
let time: String?
|
let time: String?
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case stats
|
case stats
|
||||||
case list
|
case list
|
||||||
@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
|
|||||||
struct ScenicQueueHomeStats: Decodable, Equatable {
|
struct ScenicQueueHomeStats: Decodable, Equatable {
|
||||||
let type: Int
|
let type: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case type
|
case type
|
||||||
}
|
}
|
||||||
@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable {
|
|||||||
let page: Int
|
let page: Int
|
||||||
let pageSize: Int
|
let pageSize: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case list
|
case list
|
||||||
case total
|
case total
|
||||||
@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
|||||||
isMissRequeue == 1
|
isMissRequeue == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case queueCode = "queue_code"
|
case queueCode = "queue_code"
|
||||||
@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
|||||||
case missRequeueText = "is_miss_requeue_text"
|
case missRequeueText = "is_miss_requeue_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum AlternateCodingKeys: String, CodingKey {
|
enum AlternateCodingKeys: String, CodingKey {
|
||||||
case queueNo = "queue_no"
|
case queueNo = "queue_no"
|
||||||
case queueNumber = "queue_number"
|
case queueNumber = "queue_number"
|
||||||
@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
|||||||
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
|
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 格式化排队时间。
|
||||||
private static func formatQueueTime(_ raw: String) -> String {
|
private static func formatQueueTime(_ raw: String) -> String {
|
||||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty else { return "--" }
|
guard !text.isEmpty else { return "--" }
|
||||||
@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable {
|
|||||||
struct SocketTokenResponse: Decodable, Equatable {
|
struct SocketTokenResponse: Decodable, Equatable {
|
||||||
let socketToken: String
|
let socketToken: String
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case socketToken = "socket_token"
|
case socketToken = "socket_token"
|
||||||
}
|
}
|
||||||
@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable {
|
|||||||
let statusText: String
|
let statusText: String
|
||||||
let calledAt: String?
|
let calledAt: String?
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case status
|
case status
|
||||||
@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
|
|||||||
let recordId: Int64
|
let recordId: Int64
|
||||||
let operatorId: Int
|
let operatorId: Int
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case recordId = "record_id"
|
case recordId = "record_id"
|
||||||
case operatorId = "operator_id"
|
case operatorId = "operator_id"
|
||||||
@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable {
|
|||||||
let queueBanDays: Int?
|
let queueBanDays: Int?
|
||||||
let operatorId: Int?
|
let operatorId: Int?
|
||||||
|
|
||||||
|
/// 枚举,定义相关常量或状态。
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case uid
|
case uid
|
||||||
case scenicId = "scenic_id"
|
case scenicId = "scenic_id"
|
||||||
@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension KeyedDecodingContainer {
|
private extension KeyedDecodingContainer {
|
||||||
|
/// 解码宽松字符串。
|
||||||
func decodeLossyString(forKey key: Key) throws -> String {
|
func decodeLossyString(forKey key: Key) throws -> String {
|
||||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
@ -449,6 +461,7 @@ private extension KeyedDecodingContainer {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解码宽松整数。
|
||||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
@ -467,6 +480,7 @@ private extension KeyedDecodingContainer {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解码宽松Double。
|
||||||
func decodeLossyDouble(forKey key: Key) throws -> Double? {
|
func decodeLossyDouble(forKey key: Key) throws -> Double? {
|
||||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||||
return value
|
return value
|
||||||
@ -484,6 +498,7 @@ private extension KeyedDecodingContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension String {
|
private extension String {
|
||||||
|
/// non空或默认相关逻辑。
|
||||||
func nonEmptyOrDefault(_ fallback: String) -> String {
|
func nonEmptyOrDefault(_ fallback: String) -> String {
|
||||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return text.isEmpty ? fallback : text
|
return text.isEmpty ? fallback : text
|
||||||
|
|||||||