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
|
||||
- ViewModel 不直接持有 UIView / UIViewController 引用
|
||||
|
||||
### Swift 并发
|
||||
|
||||
- 工程已将编译选项 **`SWIFT_DEFAULT_ACTOR_ISOLATION`** 设置为 **`nonisolated`**(见 Xcode Build Settings)
|
||||
- 即类型与方法**默认不**隔离到 `@MainActor`;需要主线程/UI 相关逻辑时,再显式标注 `@MainActor` 或使用 `MainActor.assumeIsolated` / `await MainActor.run`
|
||||
- 仅在确实需要脱离默认隔离(如 delegate 回调、静态工具方法)时再显式写 `nonisolated`,避免冗余标注
|
||||
|
||||
### 注释
|
||||
|
||||
- 定义的**类**、**结构体**、**方法**均须添加注释,说明其职责、用途或行为
|
||||
@ -117,5 +123,6 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、
|
||||
|
||||
- 同步进度详见 [功能同步Checklist.md](功能同步Checklist.md)
|
||||
- 参考工程:`../suixinkan_ios_new`(SwiftUI)
|
||||
- 高德 Key:在 `suixinkan_ios/Info.plist` 的 `AMapAPIKey` 填入控制台 Key(真机地图生效)
|
||||
- 构建:`xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios build`
|
||||
- 测试:`xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios -destination 'platform=iOS Simulator,name=iPhone 17'`
|
||||
|
||||
75
Podfile
@ -6,15 +6,90 @@ target 'suixinkan_ios' do
|
||||
pod 'SnapKit', '~> 5.7'
|
||||
pod 'Kingfisher', '~> 8.0'
|
||||
|
||||
# 高德地图 SDK:仅真机构建时链接;模拟器通过 post_install 剥离
|
||||
pod 'AMap3DMap-NO-IDFA', '~> 11.1'
|
||||
pod 'AMapSearch-NO-IDFA', '~> 9.7'
|
||||
pod 'AMapLocation-NO-IDFA', '~> 2.11'
|
||||
|
||||
target 'suixinkan_iosTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish
|
||||
-ObjC -l"c++" -l"z"
|
||||
-framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony"
|
||||
-framework "CoreText" -framework "ExternalAccessory" -framework "GLKit"
|
||||
-framework "OpenGLES" -framework "QuartzCore" -framework "Security"
|
||||
-framework "SystemConfiguration"
|
||||
FLAGS
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
|
||||
end
|
||||
end
|
||||
|
||||
%w[Pods-suixinkan_ios Pods-suixinkan_iosTests].each do |target_name|
|
||||
support_dir = File.join(installer.sandbox.root, 'Target Support Files', target_name)
|
||||
Dir.glob(File.join(support_dir, '*.xcconfig')).each do |path|
|
||||
content = File.read(path)
|
||||
content.gsub!("EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64\n", '')
|
||||
|
||||
unless content.include?('OTHER_LDFLAGS[sdk=iphonesimulator*]')
|
||||
content << "\nOTHER_LDFLAGS[sdk=iphonesimulator*] = #{SIMULATOR_OTHER_LDFLAGS}\n"
|
||||
content << "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*] = $(inherited)\n"
|
||||
end
|
||||
|
||||
File.write(path, content)
|
||||
end
|
||||
end
|
||||
|
||||
installer.pods_project.targets.each do |target|
|
||||
next unless target.name.start_with?('AMap')
|
||||
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['SUPPORTED_PLATFORMS'] = 'iphoneos'
|
||||
end
|
||||
end
|
||||
|
||||
resources_script = File.join(
|
||||
installer.sandbox.root,
|
||||
'Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh'
|
||||
)
|
||||
if File.exist?(resources_script)
|
||||
content = File.read(resources_script)
|
||||
unless content.include?('SKIP_AMAP_RESOURCES_FOR_SIMULATOR')
|
||||
content.sub!(
|
||||
"RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt",
|
||||
<<~SHELL
|
||||
# SKIP_AMAP_RESOURCES_FOR_SIMULATOR
|
||||
if [ "${EFFECTIVE_PLATFORM_NAME}" = "-iphonesimulator" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt
|
||||
SHELL
|
||||
)
|
||||
File.write(resources_script, content)
|
||||
end
|
||||
end
|
||||
|
||||
installer.aggregate_targets.each do |aggregate_target|
|
||||
aggregate_target.user_project.native_targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = ''
|
||||
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
|
||||
|
||||
next unless target.name == 'suixinkan_ios'
|
||||
|
||||
existing = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] || '$(inherited)'
|
||||
unless existing.to_s.include?('AMAP_ENABLED')
|
||||
config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] = "#{existing} AMAP_ENABLED"
|
||||
end
|
||||
end
|
||||
end
|
||||
aggregate_target.user_project.save
|
||||
end
|
||||
end
|
||||
|
||||
20
Podfile.lock
@ -1,20 +1,38 @@
|
||||
PODS:
|
||||
- AMap3DMap-NO-IDFA (11.2.000):
|
||||
- AMapFoundation-NO-IDFA (>= 1.9.0)
|
||||
- AMapFoundation-NO-IDFA (1.9.0)
|
||||
- AMapLocation-NO-IDFA (2.12.0):
|
||||
- AMapFoundation-NO-IDFA (>= 1.9.0)
|
||||
- AMapSearch-NO-IDFA (9.8.0):
|
||||
- AMapFoundation-NO-IDFA (>= 1.9.0)
|
||||
- Kingfisher (8.10.0)
|
||||
- SnapKit (5.7.1)
|
||||
|
||||
DEPENDENCIES:
|
||||
- AMap3DMap-NO-IDFA (~> 11.1)
|
||||
- AMapLocation-NO-IDFA (~> 2.11)
|
||||
- AMapSearch-NO-IDFA (~> 9.7)
|
||||
- Kingfisher (~> 8.0)
|
||||
- SnapKit (~> 5.7)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- AMap3DMap-NO-IDFA
|
||||
- AMapFoundation-NO-IDFA
|
||||
- AMapLocation-NO-IDFA
|
||||
- AMapSearch-NO-IDFA
|
||||
- Kingfisher
|
||||
- SnapKit
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
AMap3DMap-NO-IDFA: f53ee0cb33db83c1a08856b5995e53a0c2096158
|
||||
AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e
|
||||
AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18
|
||||
AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef
|
||||
Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
|
||||
PODFILE CHECKSUM: dda5ec1556392b8b6a9747637c579bd75423177c
|
||||
PODFILE CHECKSUM: 8f89212788a9d27c878d93b811a6eaa818955f8c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
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 */,
|
||||
9331714C2FEE49BE00240726 /* Resources */,
|
||||
FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */,
|
||||
367681C4AC079C922280F774 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -234,6 +235,27 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
367681C4AC079C922280F774 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -337,6 +359,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = suixinkan_ios/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
@ -356,8 +379,9 @@
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -375,6 +399,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = suixinkan_ios/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
@ -394,8 +419,9 @@
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -533,6 +559,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -549,7 +576,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -568,6 +595,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -584,7 +612,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@ -13,8 +13,9 @@ import AMapLocationKit
|
||||
import AMapSearchKit
|
||||
import MAMapKit
|
||||
|
||||
/// 高德地图 SDK 初始化封装,真机构建时配置 Key 与隐私合规。
|
||||
enum AMapBootstrap {
|
||||
/// 在 App 启动时调用一次。将 `apiKey` 替换为高德控制台申请的 Key。
|
||||
/// 在 App 启动时调用一次,配置 API Key 并声明隐私政策已展示与同意。
|
||||
static func configure(apiKey: String) {
|
||||
guard !apiKey.isEmpty else { return }
|
||||
|
||||
@ -30,7 +31,9 @@ enum AMapBootstrap {
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// 模拟器构建占位,不链接高德 SDK。
|
||||
enum AMapBootstrap {
|
||||
/// 空实现,保持调用方编译通过。
|
||||
static func configure(apiKey: String) {}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -67,6 +67,7 @@ final class AppServices {
|
||||
accountContext.profile?.userId
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
private init() {
|
||||
let client = APIClient()
|
||||
let tokenStore = SessionTokenStore()
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
#if DEBUG
|
||||
import UIKit
|
||||
|
||||
/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开自定义 TabBar 下 XCUITest 难以触发的 push。
|
||||
/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开 Tab 切换与 push 链路。
|
||||
@MainActor
|
||||
enum AppUITestRouteDriver {
|
||||
private static var didApply = false
|
||||
|
||||
@ -164,6 +164,7 @@ enum AppRouteViewControllerFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将首页路由编码为 URI 字符串。
|
||||
private static func homeRouteURI(_ route: HomeRoute) -> String {
|
||||
if case let .modulePlaceholder(uri, _) = route {
|
||||
return uri
|
||||
@ -175,6 +176,7 @@ enum AppRouteViewControllerFactory {
|
||||
@MainActor
|
||||
/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。
|
||||
enum HomeRouteViewControllerFactory {
|
||||
/// 创建实例。
|
||||
static func make(for route: HomeRoute) -> UIViewController {
|
||||
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
||||
}
|
||||
|
||||
@ -29,19 +29,47 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
/// 选中态 TabBar 图标资源名。
|
||||
var selectedImageName: String {
|
||||
switch self {
|
||||
case .home:
|
||||
"house"
|
||||
"TabHomeSelected"
|
||||
case .orders:
|
||||
"doc.text"
|
||||
"TabOrderSelected"
|
||||
case .statistics:
|
||||
"chart.bar"
|
||||
"TabDataSelected"
|
||||
case .profile:
|
||||
"person"
|
||||
"TabProfileSelected"
|
||||
}
|
||||
}
|
||||
|
||||
/// 未选中态 TabBar 图标资源名。
|
||||
var unselectedImageName: String {
|
||||
switch self {
|
||||
case .home:
|
||||
"TabHomeUnselected"
|
||||
case .orders:
|
||||
"TabOrderUnselected"
|
||||
case .statistics:
|
||||
"TabDataUnselected"
|
||||
case .profile:
|
||||
"TabProfileUnselected"
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建系统 TabBarItem,供 `UITabBarController` 使用。
|
||||
func makeTabBarItem() -> UITabBarItem {
|
||||
let unselectedImage = UIImage(named: unselectedImageName)?.withRenderingMode(.alwaysOriginal)
|
||||
let selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal)
|
||||
let item = UITabBarItem(
|
||||
title: title,
|
||||
image: unselectedImage,
|
||||
selectedImage: selectedImage
|
||||
)
|
||||
item.accessibilityIdentifier = "main.tab.\(rawValue)"
|
||||
return item
|
||||
}
|
||||
|
||||
/// 构建 Tab 根页面 ViewController。
|
||||
func makeRootViewController(services: AppServices) -> UIViewController {
|
||||
switch self {
|
||||
|
||||
@ -53,6 +53,7 @@ final class AppRouter {
|
||||
|
||||
private let routers: [AppTab: RouterPath]
|
||||
|
||||
/// 初始化实例。
|
||||
init() {
|
||||
var builtRouters: [AppTab: RouterPath] = [:]
|
||||
for tab in AppTab.allCases {
|
||||
|
||||
@ -13,8 +13,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
private let services: AppServices
|
||||
private var isSyncingStack = false
|
||||
|
||||
weak var tabBarHost: MainTabBarController?
|
||||
|
||||
/// 初始化实例。
|
||||
init(tab: AppTab, services: AppServices) {
|
||||
self.appTab = tab
|
||||
self.services = services
|
||||
@ -43,15 +42,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
pushViewController(viewController, animated: animated)
|
||||
}
|
||||
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
willShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
let isRoot = viewController === viewControllers.first
|
||||
tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated)
|
||||
}
|
||||
|
||||
/// navigationController相关逻辑。
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
didShow viewController: UIViewController,
|
||||
@ -60,6 +51,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
syncRouterPathFromStack()
|
||||
}
|
||||
|
||||
/// 注册NavigationBridge。
|
||||
private func registerNavigationBridge() {
|
||||
switch appTab {
|
||||
case .home:
|
||||
@ -71,6 +63,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步FromRouterPath状态。
|
||||
private func syncFromRouterPath() {
|
||||
guard !isSyncingStack else { return }
|
||||
|
||||
@ -79,6 +72,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
let currentDepth = max(viewControllers.count - 1, 0)
|
||||
|
||||
if targetDepth > currentDepth {
|
||||
// Router 路径变深:按路径依次 push 缺失页面
|
||||
for index in currentDepth..<targetDepth {
|
||||
let route = routerPath.path[index]
|
||||
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
|
||||
@ -89,6 +83,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
}
|
||||
|
||||
if targetDepth < currentDepth {
|
||||
// Router 路径变浅:pop 至目标深度
|
||||
if targetDepth == 0 {
|
||||
popToRootViewController(animated: true)
|
||||
} else if targetDepth < viewControllers.count {
|
||||
@ -97,6 +92,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步RouterPathFromStack状态。
|
||||
private func syncRouterPathFromStack() {
|
||||
guard !isSyncingStack else { return }
|
||||
|
||||
|
||||
@ -106,6 +106,7 @@ final class ToastOverlayView: UIView {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 配置Views展示内容。
|
||||
private func configureViews() {
|
||||
bannerView.backgroundColor = AppDesign.primary
|
||||
bannerView.isHidden = true
|
||||
@ -134,6 +135,7 @@ final class ToastOverlayView: UIView {
|
||||
])
|
||||
}
|
||||
|
||||
/// 刷新Presentation展示。
|
||||
private func refreshPresentation(animated: Bool) {
|
||||
guard let message = toastCenter?.message, !message.isEmpty else {
|
||||
hideBanner(animated: animated)
|
||||
@ -144,6 +146,7 @@ final class ToastOverlayView: UIView {
|
||||
showBanner(animated: animated)
|
||||
}
|
||||
|
||||
/// 展示Banner。
|
||||
private func showBanner(animated: Bool) {
|
||||
guard bannerView.isHidden || bannerView.alpha < 1 else { return }
|
||||
|
||||
@ -162,6 +165,7 @@ final class ToastOverlayView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏Banner。
|
||||
private func hideBanner(animated: Bool) {
|
||||
guard !bannerView.isHidden else { return }
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ final class RootViewController: UIViewController {
|
||||
private var currentChild: UIViewController?
|
||||
private let restoringView = UIView()
|
||||
|
||||
/// 初始化实例。
|
||||
init(services: AppServices = .shared) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -24,6 +25,7 @@ final class RootViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 初始化视图、监听登录阶段变化并启动会话恢复。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@ -38,11 +40,13 @@ final class RootViewController: UIViewController {
|
||||
configurePushNotifications()
|
||||
}
|
||||
|
||||
/// 窗口展示后将 Toast 与全局 Loading 挂载到 window 层级。
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
attachGlobalOverlays()
|
||||
}
|
||||
|
||||
/// 冷启动时恢复本地会话,并在已登录时申请推送权限。
|
||||
private func bootstrapSession() {
|
||||
Task {
|
||||
await services.globalLoading.withLoading {
|
||||
@ -62,16 +66,19 @@ final class RootViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置推送管理器与路由跳转回调。
|
||||
private func configurePushNotifications() {
|
||||
services.configurePushNotifications()
|
||||
}
|
||||
|
||||
/// 将全局 Toast 与 Loading 视图附加到当前 window。
|
||||
private func attachGlobalOverlays() {
|
||||
guard let window = view.window else { return }
|
||||
services.toastCenter.attachToWindow(window)
|
||||
services.globalLoading.attachToWindow(window)
|
||||
}
|
||||
|
||||
/// 登录阶段变化时切换子页面,并在登出时清理各上下文状态。
|
||||
private func handleSessionPhaseChange() {
|
||||
mountChild(for: services.appSession.phase)
|
||||
|
||||
@ -82,6 +89,7 @@ final class RootViewController: UIViewController {
|
||||
}
|
||||
Task { await reloadScenicSpotContextIfNeeded() }
|
||||
case .loggedOut:
|
||||
// 登出后重置账号、权限、路由与排队运行时,避免残留旧状态
|
||||
services.accountContext.reset()
|
||||
services.permissionContext.reset()
|
||||
services.scenicSpotContext.reset()
|
||||
@ -93,6 +101,7 @@ final class RootViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功后按当前景区 ID 刷新景区上下文。
|
||||
private func reloadScenicSpotContextIfNeeded() async {
|
||||
guard services.appSession.isLoggedIn else {
|
||||
services.scenicSpotContext.reset()
|
||||
@ -104,6 +113,7 @@ final class RootViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 按登录阶段挂载对应子控制器:登录页、恢复占位或主 Tab。
|
||||
private func mountChild(for phase: AuthPhase) {
|
||||
let nextChild: UIViewController
|
||||
switch phase {
|
||||
@ -130,6 +140,7 @@ final class RootViewController: UIViewController {
|
||||
currentChild = nextChild
|
||||
}
|
||||
|
||||
/// 会话恢复阶段的空白占位页,避免闪烁登录页。
|
||||
private func restoringViewController() -> UIViewController {
|
||||
let controller = UIViewController()
|
||||
controller.view.backgroundColor = .systemBackground
|
||||
|
||||
@ -2,35 +2,62 @@
|
||||
// AppDelegate.swift
|
||||
// suixinkan_ios
|
||||
//
|
||||
// Created by hanqiu on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 应用入口,承接 APNs 回调并在启动时初始化高德 SDK。
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
/// 应用启动:重置 UI 测试状态并配置高德 Key。
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
AppUITestLaunchState.resetIfNeeded()
|
||||
if let key = Bundle.main.object(forInfoDictionaryKey: "AMapAPIKey") as? String {
|
||||
AMapBootstrap.configure(apiKey: key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions
|
||||
) -> UISceneConfiguration {
|
||||
UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<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.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.handleDeviceToken(deviceToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.handleRegistrationError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||
) {
|
||||
let payload = PushPayload(userInfo: userInfo)
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.handleRemoteNotification(payload)
|
||||
completionHandler(.newData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@ -12,6 +13,7 @@
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@ -23,6 +25,7 @@
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 配置Views展示内容。
|
||||
private func configureViews(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
|
||||
@ -9,6 +9,7 @@ import CoreGraphics
|
||||
|
||||
/// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。
|
||||
final class AppMetrics {
|
||||
/// 初始化实例。
|
||||
private init() {}
|
||||
|
||||
/// 字体尺寸实体,统一维护 App 内常用字号。
|
||||
|
||||
@ -119,6 +119,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 配置Views展示内容。
|
||||
private func configureViews() {
|
||||
isUserInteractionEnabled = true
|
||||
accessibilityLabel = "加载中"
|
||||
@ -171,6 +172,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
])
|
||||
}
|
||||
|
||||
/// 刷新Presentation展示。
|
||||
private func refreshPresentation(animated: Bool) {
|
||||
let shouldShow = state.isVisible
|
||||
let updates = {
|
||||
@ -237,6 +239,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
|
||||
/// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。
|
||||
#if canImport(Lottie)
|
||||
/// 加载Animation数据。
|
||||
private static func loadAnimation() -> LottieAnimation? {
|
||||
if let animation = LottieAnimation.named("loading") {
|
||||
return animation
|
||||
@ -250,6 +253,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
|
||||
return nil
|
||||
}
|
||||
#else
|
||||
/// 加载Animation数据。
|
||||
private static func loadAnimation() -> Any? {
|
||||
nil
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ final class ForegroundLocationProvider: NSObject {
|
||||
private let geocoder = CLGeocoder()
|
||||
private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
||||
|
||||
/// 初始化实例。
|
||||
override init() {
|
||||
super.init()
|
||||
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 uploadedTokenDefaultsKey = "apns_uploaded_token"
|
||||
|
||||
/// 初始化实例。
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
@ -99,6 +100,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
route(from: payload)
|
||||
}
|
||||
|
||||
/// user通知Center相关逻辑。
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
@ -111,6 +113,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
completionHandler([.banner, .list, .sound, .badge])
|
||||
}
|
||||
|
||||
/// user通知Center相关逻辑。
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
@ -123,6 +126,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据执行路由跳转。
|
||||
private func route(from payload: PushPayload) {
|
||||
guard let router else { return }
|
||||
|
||||
@ -142,6 +146,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
|
||||
}
|
||||
}
|
||||
|
||||
/// 导航至HomeRoute页面。
|
||||
private func navigateHomeRoute(_ route: HomeRoute) {
|
||||
guard let router else { return }
|
||||
router.select(.home)
|
||||
|
||||
@ -9,6 +9,7 @@ import Foundation
|
||||
|
||||
/// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。
|
||||
enum APNsDeviceToken {
|
||||
/// 将二进制数据转为十六进制字符串。
|
||||
static func hexString(from data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
@ -16,6 +17,7 @@ enum APNsDeviceToken {
|
||||
|
||||
/// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。
|
||||
struct PushPayload: Sendable {
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum Route: Sendable, Equatable {
|
||||
case payment
|
||||
case order
|
||||
@ -64,6 +66,7 @@ struct PushPayload: Sendable {
|
||||
return .messageCenter
|
||||
}
|
||||
|
||||
/// mergeJSON相关逻辑。
|
||||
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
|
||||
if let dict = value as? [String: Any] {
|
||||
for (key, value) in dict {
|
||||
@ -84,6 +87,7 @@ struct PushPayload: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全地将任意值转为字符串。
|
||||
nonisolated private static func stringValue(_ value: Any?) -> String {
|
||||
switch value {
|
||||
case let string as String:
|
||||
|
||||
@ -9,6 +9,7 @@ import Foundation
|
||||
|
||||
/// 排队语音播报内容。
|
||||
struct ScenicQueueAnnouncement: Equatable {
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum Kind: Equatable {
|
||||
case newTickets(count: Int)
|
||||
case calledTicket(id: Int64)
|
||||
|
||||
@ -69,6 +69,7 @@ final class ScenicQueueRuntime {
|
||||
self.scenePhase = scenePhase
|
||||
|
||||
guard !suspendedByQueueScreen else {
|
||||
// 排队页自行监听时,避免双通道轮询
|
||||
stop()
|
||||
return
|
||||
}
|
||||
@ -86,6 +87,7 @@ final class ScenicQueueRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
// 景区或打卡点切换时重置播报状态,避免跨点误报
|
||||
if lastScenicId != scenicId || lastSpotId != spotId {
|
||||
resetSnapshot()
|
||||
lastScenicId = scenicId
|
||||
@ -93,6 +95,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
|
||||
if scenePhase == .background, !backgroundPollingEnabled {
|
||||
// 用户关闭后台轮询时,进入后台即停止
|
||||
stop()
|
||||
return
|
||||
}
|
||||
@ -126,6 +129,7 @@ final class ScenicQueueRuntime {
|
||||
Task { await pollOnce() }
|
||||
}
|
||||
|
||||
/// 启动IfNeeded流程。
|
||||
private func startIfNeeded() {
|
||||
guard pollTask == nil else { return }
|
||||
isMonitoring = true
|
||||
@ -137,6 +141,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行Loop循环或任务。
|
||||
private func runLoop() async {
|
||||
while !Task.isCancelled {
|
||||
await pollOnce()
|
||||
@ -145,6 +150,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询Once数据。
|
||||
private func pollOnce() async {
|
||||
guard let api, let scenicId else { return }
|
||||
let spotId = selectedSpotId()
|
||||
@ -168,6 +174,7 @@ final class ScenicQueueRuntime {
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
)
|
||||
// 并行拉取统计与排队列表,减少单次轮询耗时
|
||||
let (stats, home) = try await (statsData, homeData)
|
||||
handle(stats: stats, tickets: home.list?.list ?? [])
|
||||
lastError = nil
|
||||
@ -176,6 +183,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理相关事件。
|
||||
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
@ -190,6 +198,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询IntervalSeconds数据。
|
||||
private func pollIntervalSeconds() -> UInt64 {
|
||||
switch scenePhase {
|
||||
case .active:
|
||||
@ -203,11 +212,13 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取用户当前选中的打卡点 ID。
|
||||
private func selectedSpotId() -> Int {
|
||||
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
||||
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
|
||||
}
|
||||
|
||||
/// 重置Snapshot状态。
|
||||
private func resetSnapshot() {
|
||||
announcementState.reset()
|
||||
lastQueueCount = 0
|
||||
@ -215,6 +226,7 @@ final class ScenicQueueRuntime {
|
||||
lastSpokenText = ""
|
||||
}
|
||||
|
||||
/// 申请后台任务,保证进入后台时仍可短时轮询。
|
||||
private func beginBackgroundTask() {
|
||||
guard backgroundTaskId == .invalid else { return }
|
||||
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
|
||||
@ -225,6 +237,7 @@ final class ScenicQueueRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 结束后台任务。
|
||||
private func endBackgroundTask() {
|
||||
guard backgroundTaskId != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(backgroundTaskId)
|
||||
@ -238,6 +251,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
/// 初始化实例。
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
@ -265,6 +279,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||||
resumePending()
|
||||
}
|
||||
|
||||
/// 入队等待处理。
|
||||
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
|
||||
if replacePending {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
@ -279,14 +294,17 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
/// speechSynthesizer相关逻辑。
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in self.resumePending() }
|
||||
}
|
||||
|
||||
/// speechSynthesizer相关逻辑。
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in self.resumePending() }
|
||||
}
|
||||
|
||||
/// 恢复Pending流程。
|
||||
private func resumePending() {
|
||||
let continuations = pendingContinuations
|
||||
pendingContinuations.removeAll()
|
||||
|
||||
@ -74,6 +74,7 @@ final class ScenicQueueSocketClient {
|
||||
webSocketTask = nil
|
||||
}
|
||||
|
||||
/// 持续接收 WebSocket 消息并分发处理。
|
||||
private func receiveLoop(
|
||||
task: URLSessionWebSocketTask,
|
||||
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
|
||||
@ -130,6 +131,7 @@ final class ScenicQueueSocketClient {
|
||||
return ScenicQueueSocketMessage(code: code, data: socketData)
|
||||
}
|
||||
|
||||
/// 安全地将任意值转为字符串。
|
||||
private static func stringValue(_ value: Any?) -> String {
|
||||
switch value {
|
||||
case let value as String:
|
||||
@ -143,6 +145,7 @@ final class ScenicQueueSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// int值相关逻辑。
|
||||
private static func intValue(_ value: Any?) -> Int? {
|
||||
if let value = value as? Int { return value }
|
||||
if let value = value as? NSNumber { return value.intValue }
|
||||
@ -150,6 +153,7 @@ final class ScenicQueueSocketClient {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// int64值相关逻辑。
|
||||
private static func int64Value(_ value: Any?) -> Int64? {
|
||||
if let value = value as? Int64 { return value }
|
||||
if let value = value as? Int { return Int64(value) }
|
||||
|
||||
@ -12,6 +12,7 @@ final class FeaturePlaceholderViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let uri: String
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String, uri: String) {
|
||||
self.pageTitle = title
|
||||
self.uri = uri
|
||||
@ -23,6 +24,7 @@ final class FeaturePlaceholderViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import Kingfisher
|
||||
import UIKit
|
||||
|
||||
/// `UIImageView` 远程图片加载扩展,统一走 Kingfisher 缓存与占位策略。
|
||||
extension UIImageView {
|
||||
/// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。
|
||||
func loadRemoteImage(
|
||||
@ -68,11 +69,13 @@ extension UIImageView {
|
||||
kf.cancelDownloadTask()
|
||||
}
|
||||
|
||||
/// 规范化dURLString格式。
|
||||
private func normalizedURLString(from urlString: String?) -> String? {
|
||||
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// avatarPlaceholder相关逻辑。
|
||||
private static func avatarPlaceholder(
|
||||
systemImageName: String,
|
||||
iconSize: CGFloat,
|
||||
@ -98,6 +101,7 @@ extension UIImageView {
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIImageView` 头像圆角辅助扩展。
|
||||
extension UIImageView {
|
||||
/// 在布局变化后刷新圆形头像圆角。
|
||||
func refreshRemoteAvatarCornerRadius() {
|
||||
|
||||
@ -23,6 +23,7 @@ private final class ViewModelBindingToken {
|
||||
private weak var owner: AnyObject?
|
||||
private let handler: () -> Void
|
||||
|
||||
/// 初始化实例。
|
||||
init(owner: AnyObject, handler: @escaping () -> Void) {
|
||||
self.owner = owner
|
||||
self.handler = handler
|
||||
@ -33,17 +34,22 @@ private final class ViewModelBindingToken {
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIViewController` 通用 UI 与全局反馈扩展。
|
||||
extension UIViewController {
|
||||
/// 全局依赖容器快捷访问。
|
||||
var appServices: AppServices { AppServices.shared }
|
||||
|
||||
/// 展示全局 Toast 提示。
|
||||
func showToast(_ message: String) {
|
||||
appServices.toastCenter.show(message)
|
||||
}
|
||||
|
||||
/// 展示全局 Loading 遮罩。
|
||||
func showGlobalLoading(_ message: String = "") {
|
||||
appServices.globalLoading.show(message: message)
|
||||
}
|
||||
|
||||
/// 隐藏GlobalLoading。
|
||||
func hideGlobalLoading() {
|
||||
appServices.globalLoading.hide()
|
||||
}
|
||||
|
||||
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 {
|
||||
static let reuseIdentifier = "TitleSubtitleTableViewCell"
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .default
|
||||
@ -28,6 +29,7 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 填充标题、副标题与详情文本,副标题优先于详情展示。
|
||||
func configure(title: String, subtitle: String? = nil, detail: String? = nil) {
|
||||
textLabel?.text = title
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
@ -38,18 +40,20 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 模块列表页基类,封装 UITableView、下拉刷新和 ViewModel onChange 绑定。
|
||||
@MainActor
|
||||
class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
/// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。
|
||||
class ModuleTableViewController: UIViewController, UITableViewDelegate {
|
||||
let services = AppServices.shared
|
||||
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private var viewModelReloadHandler: (() -> Void)?
|
||||
|
||||
/// Diffable 数据源,驱动列表展示与动画刷新。
|
||||
private(set) var tableDataSource: UITableViewDiffableDataSource<ModuleTableSection, ModuleTableRow>!
|
||||
|
||||
var isLoading = false {
|
||||
didSet {
|
||||
if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 {
|
||||
if isLoading, tableDataSource.snapshot().numberOfItems == 0 {
|
||||
activityIndicator.startAnimating()
|
||||
} else {
|
||||
activityIndicator.stopAnimating()
|
||||
@ -57,12 +61,19 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
||||
}
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
tableView.dataSource = self
|
||||
tableDataSource = UITableViewDiffableDataSource<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.backgroundColor = .clear
|
||||
tableView.register(
|
||||
@ -85,36 +96,45 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
||||
Task { await reloadContent() }
|
||||
}
|
||||
|
||||
/// 绑定 ViewModel 数据变更回调,子类在 `reloadContent` 完成后调用。
|
||||
func bindViewModel(onChange: (() -> Void)?) {
|
||||
viewModelReloadHandler = onChange
|
||||
}
|
||||
|
||||
func reloadTable() {
|
||||
tableView.reloadData()
|
||||
/// 通过 Diffable snapshot 刷新列表,替代 reloadData。
|
||||
func reloadTable(animated: Bool = true) {
|
||||
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
|
||||
}
|
||||
|
||||
/// 返回 section 数量,子类可覆写以支持多 section。
|
||||
func numberOfTableSections() -> Int { 1 }
|
||||
|
||||
/// 返回指定 section 行数,子类必须覆写。
|
||||
func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? tableRowCount() : 0
|
||||
}
|
||||
|
||||
/// 返回列表行数(单 section 场景),子类覆写。
|
||||
func tableRowCount() -> Int { 0 }
|
||||
|
||||
/// 构建 Diffable snapshot,子类一般无需覆写。
|
||||
func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<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 didSelectTableRow(at indexPath: IndexPath) {}
|
||||
|
||||
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 {
|
||||
/// 提供 Cell,子类可覆写以自定义多 section 展示。
|
||||
func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -125,19 +145,46 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
|
||||
return cell
|
||||
}
|
||||
|
||||
/// 处理行选中事件,子类覆写以响应跳转或操作。
|
||||
func didSelectTableRow(at indexPath: IndexPath) {}
|
||||
|
||||
/// 加载或刷新页面数据,子类覆写以实现网络请求与状态更新。
|
||||
func reloadContent() async {}
|
||||
|
||||
/// section 标题,子类覆写以展示分组标题。
|
||||
func tableSectionTitle(for section: Int) -> String? { nil }
|
||||
|
||||
/// 下拉刷新触发,重新加载内容并结束刷新动画。
|
||||
@objc private func handleRefresh() {
|
||||
Task {
|
||||
await reloadContent()
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
/// UITableView 代理:展示 section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
tableSectionTitle(for: section)
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
didSelectTableRow(at: indexPath)
|
||||
}
|
||||
|
||||
/// UITableView 代理:行即将展示,用于分页加载等。
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
willDisplayTableRow(at: indexPath)
|
||||
}
|
||||
|
||||
/// willDisplayTableRow 回调处理。
|
||||
func willDisplayTableRow(at indexPath: IndexPath) {}
|
||||
}
|
||||
|
||||
/// `ModuleTableViewController` 扩展,提供 ViewModel 绑定便捷方法。
|
||||
extension ModuleTableViewController {
|
||||
/// 将 ViewModel 的 onChange 与列表刷新联动,并在绑定时立即刷新一次。
|
||||
func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) {
|
||||
if let bindable = viewModel as? ViewModelBindable {
|
||||
bindable.onChange = { [weak self] in
|
||||
@ -146,6 +193,7 @@ extension ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
reload()
|
||||
reloadTable(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,3 +202,140 @@ extension ModuleTableViewController {
|
||||
protocol ViewModelBindable: AnyObject {
|
||||
var onChange: (() -> Void)? { get set }
|
||||
}
|
||||
|
||||
/// 简单固定行列表基类,使用 UITableView Diffable,适用于设置、详情等页面。
|
||||
class SimpleTableDiffableViewController: UIViewController, UITableViewDelegate {
|
||||
let tableView: UITableView
|
||||
|
||||
private(set) var tableDataSource: UITableViewDiffableDataSource<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 {
|
||||
private let viewModel = CloudStorageViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "云盘"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,22 +24,27 @@ final class CloudStorageViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.files.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let file = viewModel.files[indexPath.row]
|
||||
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
/// willDisplayTableRow 回调处理。
|
||||
override func willDisplayTableRow(at indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.files.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.assetsAPI) }
|
||||
}
|
||||
|
||||
/// openTransit相关逻辑。
|
||||
@objc private func openTransit() {
|
||||
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
|
||||
}
|
||||
@ -50,22 +56,27 @@ extension CloudStorageViewModel: ViewModelBindable {}
|
||||
final class CloudStorageTransitViewController: ModuleTableViewController {
|
||||
private let store = CloudTransferStore.shared
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "传输记录"
|
||||
super.viewDidLoad()
|
||||
store.onChange = { [weak self] in self?.reloadTable() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { store.records.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = store.records[indexPath.row]
|
||||
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {}
|
||||
}
|
||||
|
||||
/// MediaLibraryKindRoute,业务类型定义。
|
||||
enum MediaLibraryKindRoute {
|
||||
case material
|
||||
case sample
|
||||
@ -76,6 +87,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
||||
private let kind: MediaLibraryKindRoute
|
||||
private let viewModel: MediaLibraryViewModel
|
||||
|
||||
/// 初始化实例。
|
||||
init(kind: MediaLibraryKindRoute = .material) {
|
||||
self.kind = kind
|
||||
switch kind {
|
||||
@ -92,6 +104,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "样片库" : "素材库"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -104,17 +117,21 @@ final class MediaLibraryViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI)
|
||||
}
|
||||
|
||||
/// open上传相关逻辑。
|
||||
@objc private func openUpload() {
|
||||
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
|
||||
}
|
||||
@ -128,6 +145,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
private let viewModel = MediaLibraryEditorViewModel()
|
||||
private let nameField = UITextField()
|
||||
|
||||
/// 初始化实例。
|
||||
init(kind: MediaLibraryKindRoute) {
|
||||
self.kind = kind
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -138,6 +156,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = kind == .sample ? "上传样片" : "上传素材"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -154,17 +173,21 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.projects.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let project = viewModel.projects[indexPath.row]
|
||||
cell.configure(title: project.name, subtitle: project.statusName)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
@ -186,19 +209,23 @@ extension MediaLibraryEditorViewModel: ViewModelBindable {}
|
||||
final class AlbumListViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumListViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
@ -210,19 +237,23 @@ extension AlbumListViewModel: ViewModelBindable {}
|
||||
final class AlbumTrailerViewController: ModuleTableViewController {
|
||||
private let viewModel = AlbumTrailerViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "相册预告"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
@ -6,9 +6,14 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
private typealias AccountSelectionSection = Int
|
||||
private typealias AccountSelectionItem = String
|
||||
|
||||
@MainActor
|
||||
/// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。
|
||||
final class AccountSelectionViewController: UIViewController {
|
||||
final class AccountSelectionViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let payload: AccountSelectionPayload
|
||||
private var isLoading: Bool
|
||||
@ -17,9 +22,13 @@ final class AccountSelectionViewController: UIViewController {
|
||||
|
||||
private var selectedAccountId: String?
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
/// Diffable 数据源,驱动账号列表与选中态刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>!
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private let bottomBar = UIView()
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
payload: AccountSelectionPayload,
|
||||
isLoading: Bool,
|
||||
@ -38,6 +47,7 @@ final class AccountSelectionViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "选择账号"
|
||||
@ -47,9 +57,47 @@ final class AccountSelectionViewController: UIViewController {
|
||||
|
||||
selectedAccountId = payload.accounts.first?.id
|
||||
configureTableView()
|
||||
configureTableDataSource()
|
||||
configureBottomBar()
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<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) {
|
||||
isLoading = loading
|
||||
isModalInPresentation = loading
|
||||
@ -65,10 +113,10 @@ final class AccountSelectionViewController: UIViewController {
|
||||
selectedAccount != nil && !isLoading
|
||||
}
|
||||
|
||||
/// 配置TableView展示内容。
|
||||
private func configureTableView() {
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
|
||||
view.addSubview(tableView)
|
||||
@ -78,6 +126,7 @@ final class AccountSelectionViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置BottomBar展示内容。
|
||||
private func configureBottomBar() {
|
||||
bottomBar.backgroundColor = .white
|
||||
|
||||
@ -112,37 +161,26 @@ final class AccountSelectionViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击cancel的处理逻辑。
|
||||
@objc private func cancelTapped() {
|
||||
onCancel()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
/// 点击confirm的处理逻辑。
|
||||
@objc private func confirmTapped() {
|
||||
guard let selectedAccount else { return }
|
||||
onConfirm(selectedAccount)
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
payload.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: AccountSelectionCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? AccountSelectionCell else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
let account = payload.accounts[indexPath.row]
|
||||
cell.configure(account: account, selected: account.id == selectedAccountId)
|
||||
return cell
|
||||
}
|
||||
|
||||
extension AccountSelectionViewController {
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
selectedAccountId = payload.accounts[indexPath.row].id
|
||||
tableView.reloadData()
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
selectedAccountId = item
|
||||
applyTableSnapshot(animated: false, reconfigure: true)
|
||||
confirmButton.isEnabled = canConfirm
|
||||
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
|
||||
}
|
||||
@ -161,6 +199,7 @@ private final class AccountSelectionCell: UITableViewCell {
|
||||
private let currentTag = UILabel()
|
||||
private let checkmark = UIImageView()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
@ -257,6 +296,7 @@ private final class AccountSelectionCell: UITableViewCell {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(account: AccountSwitchAccount, selected: Bool) {
|
||||
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
|
||||
subtitleLabel.text = account.subtitle
|
||||
|
||||
@ -26,6 +26,7 @@ final class LoginViewController: UIViewController {
|
||||
private let privacyPolicyButton = UIButton(type: .system)
|
||||
private let loginButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化实例。
|
||||
init(services: AppServices) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -36,6 +37,7 @@ final class LoginViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0x0B1220)
|
||||
@ -44,6 +46,7 @@ final class LoginViewController: UIViewController {
|
||||
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
|
||||
}
|
||||
|
||||
/// 配置Views展示内容。
|
||||
private func configureViews() {
|
||||
backgroundImageView.contentMode = .scaleAspectFill
|
||||
backgroundImageView.clipsToBounds = true
|
||||
@ -161,6 +164,7 @@ final class LoginViewController: UIViewController {
|
||||
view.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
/// 配置AgreementText展示内容。
|
||||
private func configureAgreementText() {
|
||||
privacyLabel.numberOfLines = 0
|
||||
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
@ -197,6 +201,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定ViewModel回调或数据。
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.renderViewModel()
|
||||
@ -204,6 +209,7 @@ final class LoginViewController: UIViewController {
|
||||
renderViewModel()
|
||||
}
|
||||
|
||||
/// render视图模型相关逻辑。
|
||||
private func renderViewModel() {
|
||||
if usernameField.textField.text != viewModel.username {
|
||||
usernameField.textField.text = viewModel.username
|
||||
@ -241,38 +247,46 @@ final class LoginViewController: UIViewController {
|
||||
|
||||
private weak var accountSelectionController: AccountSelectionViewController?
|
||||
|
||||
/// username变更相关逻辑。
|
||||
@objc private func usernameChanged() {
|
||||
viewModel.username = usernameField.textField.text ?? ""
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
services.toastCenter.dismiss()
|
||||
}
|
||||
|
||||
/// password变更相关逻辑。
|
||||
@objc private func passwordChanged() {
|
||||
viewModel.password = passwordField.textField.text ?? ""
|
||||
services.toastCenter.dismiss()
|
||||
}
|
||||
|
||||
/// togglePrivacy相关逻辑。
|
||||
@objc private func togglePrivacy() {
|
||||
viewModel.privacyChecked.toggle()
|
||||
}
|
||||
|
||||
/// openUserAgreement相关逻辑。
|
||||
@objc private func openUserAgreement() {
|
||||
showToast("用户协议页面待接入")
|
||||
}
|
||||
|
||||
/// openPrivacyPolicy相关逻辑。
|
||||
@objc private func openPrivacyPolicy() {
|
||||
showToast("隐私政策页面待接入")
|
||||
}
|
||||
|
||||
/// 点击login的处理逻辑。
|
||||
@objc private func loginTapped() {
|
||||
view.endEditing(true)
|
||||
performLogin()
|
||||
}
|
||||
|
||||
/// dismiss键盘相关逻辑。
|
||||
@objc private func dismissKeyboard() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
/// perform登录相关逻辑。
|
||||
private func performLogin() {
|
||||
if let validationError = viewModel.validateForLogin() {
|
||||
if validationError == .privacyUnchecked {
|
||||
@ -307,6 +321,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹出AgreementSheet页面。
|
||||
private func presentAgreementSheet() {
|
||||
let controller = LoginAgreementConsentViewController(
|
||||
onOpenAgreement: { [weak self] title in
|
||||
@ -324,6 +339,7 @@ final class LoginViewController: UIViewController {
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出AccountSelection页面。
|
||||
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
|
||||
let controller = AccountSelectionViewController(
|
||||
payload: payload,
|
||||
@ -342,6 +358,7 @@ final class LoginViewController: UIViewController {
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
|
||||
/// select账号相关逻辑。
|
||||
private func selectAccount(_ account: AccountSwitchAccount) {
|
||||
Task {
|
||||
do {
|
||||
@ -359,6 +376,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// complete登录相关逻辑。
|
||||
private func completeLogin(with response: V9AuthResponse) async {
|
||||
do {
|
||||
try await services.authSessionCoordinator.completeLogin(
|
||||
@ -378,6 +396,7 @@ final class LoginViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension LoginViewController: UITextFieldDelegate {
|
||||
/// text字段ShouldReturn相关逻辑。
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
if textField === usernameField.textField {
|
||||
passwordField.textField.becomeFirstResponder()
|
||||
@ -396,6 +415,7 @@ private final class LoginInputField: UIView {
|
||||
private let toggleButton = UIButton(type: .custom)
|
||||
private var isSecure = false
|
||||
|
||||
/// 初始化实例。
|
||||
init(iconName: String, placeholder: String, isSecure: Bool) {
|
||||
self.isSecure = isSecure
|
||||
super.init(frame: .zero)
|
||||
@ -450,12 +470,14 @@ private final class LoginInputField: UIView {
|
||||
nil
|
||||
}
|
||||
|
||||
/// setSecureEntry相关逻辑。
|
||||
func setSecureEntry(_ secure: Bool) {
|
||||
textField.isSecureTextEntry = secure
|
||||
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
|
||||
toggleButton.setImage(UIImage(named: imageName), for: .normal)
|
||||
}
|
||||
|
||||
/// toggleVisibility相关逻辑。
|
||||
@objc private func toggleVisibility() {
|
||||
onToggleVisibility?()
|
||||
}
|
||||
@ -467,6 +489,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
||||
private let onOpenAgreement: (String) -> Void
|
||||
private let onAgreeAndContinue: () -> Void
|
||||
|
||||
/// 初始化实例。
|
||||
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
|
||||
self.onOpenAgreement = onOpenAgreement
|
||||
self.onAgreeAndContinue = onAgreeAndContinue
|
||||
@ -477,6 +500,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
||||
nil
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@ -524,6 +548,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击continue的处理逻辑。
|
||||
@objc private func continueTapped() {
|
||||
dismiss(animated: true) { [onAgreeAndContinue] in
|
||||
onAgreeAndContinue()
|
||||
|
||||
@ -50,6 +50,7 @@ enum HomeMenuRouting {
|
||||
viewController.navigationController?.pushViewController(target, animated: true)
|
||||
}
|
||||
|
||||
/// Push Placeholder页面。
|
||||
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
|
||||
viewController.navigationController?.pushViewController(
|
||||
FeaturePlaceholderViewController(title: title, uri: uri),
|
||||
|
||||
@ -6,6 +6,19 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
/// 全部功能页 section 标识,区分常用应用与更多功能网格。
|
||||
private enum HomeMoreSection: Hashable {
|
||||
case commonApps
|
||||
case moreFunctions
|
||||
}
|
||||
|
||||
/// 全部功能页 item 标识,以菜单 URI 作为唯一键。
|
||||
private enum HomeMoreItem: Hashable {
|
||||
case menu(uri: String)
|
||||
}
|
||||
|
||||
/// 首页全部功能页,支持常用应用增删和更多功能网格展示。
|
||||
final class HomeMoreFunctionsViewController: UIViewController {
|
||||
|
||||
@ -13,29 +26,38 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
private var commonURIs: [String] = []
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID)
|
||||
return table
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = makeCollectionLayout()
|
||||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||
collection.delegate = self
|
||||
collection.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
|
||||
collection.register(
|
||||
CollectionSectionHeaderView.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID
|
||||
)
|
||||
return collection
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动常用应用与更多功能两个网格 section。
|
||||
private var dataSource: UICollectionViewDiffableDataSource<HomeMoreSection, HomeMoreItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "全部功能"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
configureDataSource()
|
||||
view.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.applySnapshot()
|
||||
}
|
||||
rebuildMenus()
|
||||
|
||||
@ -44,6 +66,95 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 Compositional Layout,两个 section 均为带标题的三列网格。
|
||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self,
|
||||
let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex]
|
||||
else {
|
||||
return CollectionDiffableLayout.gridSection(itemHeight: 112)
|
||||
}
|
||||
let gridInsets = NSDirectionalEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
leading: AppMetrics.Spacing.mediumLarge,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
trailing: AppMetrics.Spacing.mediumLarge
|
||||
)
|
||||
let grid = CollectionDiffableLayout.gridSection(
|
||||
columns: 3,
|
||||
itemHeight: 112,
|
||||
interItemSpacing: 14,
|
||||
lineSpacing: AppMetrics.Spacing.mediumLarge,
|
||||
contentInsets: gridInsets
|
||||
)
|
||||
switch section {
|
||||
case .commonApps:
|
||||
return CollectionDiffableLayout.addHeader(to: grid, height: 36)
|
||||
case .moreFunctions:
|
||||
return CollectionDiffableLayout.addHeader(to: grid, height: 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册 Diffable 数据源与 Cell 配置闭包。
|
||||
private func configureDataSource() {
|
||||
dataSource = UICollectionViewDiffableDataSource<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() {
|
||||
let services = appServices
|
||||
viewModel.buildMenus(
|
||||
@ -51,7 +162,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
currentRoleId: services.permissionContext.currentRole?.id
|
||||
)
|
||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
|
||||
private var commonItems: [HomeMenuItem] {
|
||||
@ -62,6 +173,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
viewModel.menuItems.filter { !isCommonURI($0.uri) }
|
||||
}
|
||||
|
||||
/// 按 URI 解析并返回可用菜单项。
|
||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
@ -73,20 +185,23 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断 URI 是否已在常用应用中。
|
||||
private func isCommonURI(_ uri: String) -> Bool {
|
||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
|
||||
}
|
||||
|
||||
/// 切换菜单项的常用应用状态。
|
||||
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
|
||||
if isCommon {
|
||||
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
|
||||
} else {
|
||||
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
|
||||
}
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
|
||||
/// 打开菜单对应页面。
|
||||
private func openMenu(_ item: HomeMenuItem) {
|
||||
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
|
||||
@ -94,108 +209,24 @@ final class HomeMoreFunctionsViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "常用应用" : "更多功能"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell
|
||||
let isCommon = indexPath.section == 0
|
||||
let items = isCommon ? commonItems : moreItems
|
||||
cell.configure(items: items, isCommon: isCommon) { [weak self] item in
|
||||
self?.openMenu(item)
|
||||
} onToggle: { [weak self] item in
|
||||
self?.toggleCommon(item, isCommon: isCommon)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let count = indexPath.section == 0 ? commonItems.count : moreItems.count
|
||||
let rows = max(1, Int(ceil(Double(count) / 3.0)))
|
||||
return CGFloat(rows) * 124 + 8
|
||||
}
|
||||
}
|
||||
|
||||
private final class HomeMoreMenuGridCell: UITableViewCell {
|
||||
static let reuseID = "HomeMoreMenuGridCell"
|
||||
|
||||
private var items: [HomeMenuItem] = []
|
||||
private var isCommon = false
|
||||
private var onSelect: ((HomeMenuItem) -> Void)?
|
||||
private var onToggle: ((HomeMenuItem) -> Void)?
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 14
|
||||
layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = .clear
|
||||
view.isScrollEnabled = false
|
||||
view.dataSource = self
|
||||
view.delegate = self
|
||||
view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(
|
||||
items: [HomeMenuItem],
|
||||
isCommon: Bool,
|
||||
onSelect: @escaping (HomeMenuItem) -> Void,
|
||||
onToggle: @escaping (HomeMenuItem) -> Void
|
||||
) {
|
||||
self.items = items
|
||||
self.isCommon = isCommon
|
||||
self.onSelect = onSelect
|
||||
self.onToggle = onToggle
|
||||
collectionView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell
|
||||
let item = items[indexPath.item]
|
||||
cell.configure(item: item, isCommon: isCommon) { [weak self] in
|
||||
self?.onToggle?(item)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
extension HomeMoreFunctionsViewController: UICollectionViewDelegate {
|
||||
/// 点击网格项打开对应功能页(加减按钮由 Cell 内部处理)。
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
onSelect?(items[indexPath.item])
|
||||
}
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case .menu(let uri) = item
|
||||
else { return }
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let width = (collectionView.bounds.width - 28) / 3
|
||||
return CGSize(width: width, height: 112)
|
||||
let allItems = commonItems + moreItems
|
||||
guard let menuItem = allItems.first(where: { $0.uri == uri }) else { return }
|
||||
openMenu(menuItem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Item Cell
|
||||
|
||||
/// 全部功能页菜单网格单项 Cell,支持常用应用加减操作。
|
||||
private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeMoreMenuItemCell"
|
||||
|
||||
@ -204,6 +235,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
private let toggleButton = UIButton(type: .system)
|
||||
private var onToggle: (() -> Void)?
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
@ -243,6 +275,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置菜单项展示内容与常用应用切换按钮。
|
||||
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
|
||||
self.onToggle = onToggle
|
||||
titleLabel.text = item.title
|
||||
@ -254,7 +287,15 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
|
||||
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
|
||||
}
|
||||
|
||||
/// 点击加减按钮切换常用应用状态。
|
||||
@objc private func toggleTapped() {
|
||||
onToggle?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ final class HomePlaceholderViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let uri: String?
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String, uri: String? = nil) {
|
||||
pageTitle = title
|
||||
self.uri = uri
|
||||
@ -24,6 +25,7 @@ final class HomePlaceholderViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = pageTitle
|
||||
|
||||
@ -6,6 +6,26 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
/// 首页 section 标识,按角色动态展示工作状态、门店与常用应用网格。
|
||||
private enum HomeSection: Hashable {
|
||||
case workStatus
|
||||
case locationReport
|
||||
case storeInfo
|
||||
case quickActions
|
||||
case commonMenus
|
||||
}
|
||||
|
||||
/// 首页 item 标识;工作状态类 item 携带展示数据以支持倒计时 diff 刷新。
|
||||
private enum HomeItem: Hashable {
|
||||
case workStatus(isOnline: Bool, secondsUntilReport: Int, reminderMinutes: Int)
|
||||
case locationReport
|
||||
case storeInfo(storeID: Int)
|
||||
case quickActions(isOnline: Bool)
|
||||
case menu(uri: String)
|
||||
}
|
||||
|
||||
/// 首页工作台,展示景区头部、工作状态、位置上报卡片和常用应用网格。
|
||||
final class HomeViewController: UIViewController {
|
||||
|
||||
@ -34,40 +54,55 @@ final class HomeViewController: UIViewController {
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.showsVerticalScrollIndicator = false
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID)
|
||||
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||
return table
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = makeCollectionLayout()
|
||||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||
collection.showsVerticalScrollIndicator = false
|
||||
collection.delegate = self
|
||||
collection.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseID)
|
||||
collection.register(HomeLocationReportCell.self, forCellWithReuseIdentifier: HomeLocationReportCell.reuseID)
|
||||
collection.register(HomeStoreInfoCell.self, forCellWithReuseIdentifier: HomeStoreInfoCell.reuseID)
|
||||
collection.register(HomeQuickActionsCell.self, forCellWithReuseIdentifier: HomeQuickActionsCell.reuseID)
|
||||
collection.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
|
||||
collection.register(
|
||||
CollectionSectionHeaderView.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
withReuseIdentifier: CollectionSectionHeaderView.reuseID
|
||||
)
|
||||
return collection
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动多 section 卡片与常用应用网格。
|
||||
private var dataSource: UICollectionViewDiffableDataSource<HomeSection, HomeItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
setupTopBar()
|
||||
setupTableView()
|
||||
configureDataSource()
|
||||
setupCollectionView()
|
||||
bindViewModel()
|
||||
rebuildMenus()
|
||||
observeContextChanges()
|
||||
}
|
||||
|
||||
/// 视图即将展示,刷新可见状态。
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||
startCountdownTimerIfNeeded()
|
||||
}
|
||||
|
||||
/// 视图即将消失,保存或清理临时状态。
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = nil
|
||||
}
|
||||
|
||||
/// 初始化 TopBar 相关 UI 或状态。
|
||||
private func setupTopBar() {
|
||||
let topBar = UIView()
|
||||
topBar.backgroundColor = .white
|
||||
@ -85,20 +120,160 @@ final class HomeViewController: UIViewController {
|
||||
updateScenicTitle()
|
||||
}
|
||||
|
||||
private func setupTableView() {
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
/// 初始化 CollectionView 布局约束。
|
||||
private func setupCollectionView() {
|
||||
view.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
/// 构建 Compositional Layout,按 section 类型分配全宽卡片或三列网格。
|
||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self,
|
||||
let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex]
|
||||
else {
|
||||
return CollectionDiffableLayout.fullWidthSection()
|
||||
}
|
||||
switch section {
|
||||
case .workStatus:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 100)
|
||||
case .locationReport:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 148)
|
||||
case .storeInfo:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 88)
|
||||
case .quickActions:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 118)
|
||||
case .commonMenus:
|
||||
return CollectionDiffableLayout.addHeader(
|
||||
to: CollectionDiffableLayout.gridSection(itemHeight: 102),
|
||||
height: 36
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册 Diffable 数据源与 Cell 配置闭包。
|
||||
private func configureDataSource() {
|
||||
dataSource = UICollectionViewDiffableDataSource<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() {
|
||||
let services = appServices
|
||||
services.permissionContext.onChange = { [weak self] in
|
||||
@ -106,10 +281,11 @@ final class HomeViewController: UIViewController {
|
||||
}
|
||||
services.accountContext.onChange = { [weak self] in
|
||||
self?.updateScenicTitle()
|
||||
self?.tableView.reloadData()
|
||||
self?.applySnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/// 按当前角色权限重建首页菜单与常用应用。
|
||||
private func rebuildMenus() {
|
||||
let services = appServices
|
||||
viewModel.buildMenus(
|
||||
@ -117,9 +293,10 @@ final class HomeViewController: UIViewController {
|
||||
currentRoleId: services.permissionContext.currentRole?.id
|
||||
)
|
||||
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
|
||||
/// 刷新顶部景区名称展示。
|
||||
private func updateScenicTitle() {
|
||||
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
|
||||
scenicButton.configuration?.attributedTitle = AttributedString(
|
||||
@ -130,25 +307,30 @@ final class HomeViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前登录角色 ID。
|
||||
private var currentRoleId: Int? {
|
||||
appServices.permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
/// 部分精简角色不展示工作状态与位置上报区块。
|
||||
private var shouldShowWorkStatus: Bool {
|
||||
guard let currentRoleId else { return true }
|
||||
return !minimalTopRoleIds.contains(currentRoleId)
|
||||
}
|
||||
|
||||
/// 是否为门店管理员角色(roleId = 46)。
|
||||
private var isStoreManager: Bool {
|
||||
currentRoleId == 46
|
||||
}
|
||||
|
||||
/// 常用应用展示项:优先用户自定义,不足 3 个时取权限菜单前 3 项,末尾固定「更多功能」。
|
||||
private var displayMenuItems: [HomeMenuItem] {
|
||||
let selected = commonURIs.compactMap { menuItem(for: $0) }
|
||||
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
||||
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
}
|
||||
|
||||
/// 按 URI 解析并返回可用菜单项。
|
||||
private func menuItem(for uri: String) -> HomeMenuItem? {
|
||||
let availableURIs = Set(viewModel.menuItems.map(\.uri))
|
||||
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
@ -160,31 +342,36 @@ final class HomeViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
private var countdownDisplay: String {
|
||||
let hours = secondsUntilReport / 3_600
|
||||
let minutes = (secondsUntilReport % 3_600) / 60
|
||||
let seconds = secondsUntilReport % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
/// 格式化倒计时展示文本。
|
||||
private func countdownText(seconds: Int) -> String {
|
||||
let hours = seconds / 3_600
|
||||
let minutes = (seconds % 3_600) / 60
|
||||
let secs = seconds % 60
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secs))"
|
||||
}
|
||||
|
||||
private var reminderText: String {
|
||||
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
|
||||
/// 格式化提前提醒展示文本。
|
||||
private func reminderText(minutes: Int) -> String {
|
||||
minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
|
||||
}
|
||||
|
||||
/// 在线状态下启动位置上报倒计时。
|
||||
private func startCountdownTimerIfNeeded() {
|
||||
countdownTimer?.invalidate()
|
||||
guard isOnline else { return }
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
|
||||
self.secondsUntilReport -= 1
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
self.applySnapshot(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击景区名称,跳转景区选择页。
|
||||
@objc private func scenicTapped() {
|
||||
HomeMenuRouting.push(.scenicSelection, from: self)
|
||||
}
|
||||
|
||||
/// 切换在线 / 离线状态。
|
||||
@objc private func onlineTapped() {
|
||||
let message = isOnline
|
||||
? "是否确认切换为离线状态?离线后将暂停位置上报。"
|
||||
@ -200,147 +387,137 @@ final class HomeViewController: UIViewController {
|
||||
} else {
|
||||
self.countdownTimer?.invalidate()
|
||||
}
|
||||
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
self.applySnapshot()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 选择位置上报提前提醒时间。
|
||||
@objc private func reminderTapped() {
|
||||
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
|
||||
for minute in [0, 5, 10, 15, 30] {
|
||||
let title = minute == 0 ? "不提醒" : "\(minute)分钟"
|
||||
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.reminderMinutes = minute
|
||||
self?.tableView.reloadSections(IndexSet(integer: 0), with: .none)
|
||||
self?.applySnapshot()
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
/// 跳转位置上报页面。
|
||||
@objc private func locationReportTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
|
||||
}
|
||||
|
||||
/// 跳转立即收款页面。
|
||||
@objc private func paymentTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
|
||||
}
|
||||
|
||||
/// 跳转提交任务页面。
|
||||
@objc private func taskCreateTapped() {
|
||||
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableView
|
||||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
var count = 1
|
||||
if shouldShowWorkStatus { count += 2 }
|
||||
if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 }
|
||||
return count
|
||||
extension HomeViewController: UICollectionViewDelegate {
|
||||
/// 点击常用应用网格项,执行菜单路由。
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case .menu(let uri) = item,
|
||||
let menuItem = displayMenuItems.first(where: { $0.uri == uri })
|
||||
else { return }
|
||||
HomeMenuRouting.openMenu(menuItem, from: self)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
1
|
||||
}
|
||||
// MARK: - Card Cells
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
if section == tableView.numberOfSections - 1 {
|
||||
return "常用应用"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
/// 在线状态卡片 Cell,展示在线切换、倒计时与提醒设置。
|
||||
private final class HomeWorkStatusCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeWorkStatusCell"
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.section == tableView.numberOfSections - 1 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell
|
||||
cell.configure(items: displayMenuItems) { [weak self] item in
|
||||
self.flatMap { HomeMenuRouting.openMenu(item, from: $0) }
|
||||
}
|
||||
return cell
|
||||
}
|
||||
private let cardView = UIView()
|
||||
private let onlineButton = UIButton(type: .system)
|
||||
private let clockLabel = UILabel()
|
||||
private let reminderButton = UIButton(type: .system)
|
||||
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
||||
cell.selectionStyle = .none
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
if shouldShowWorkStatus {
|
||||
if indexPath.section == 0 {
|
||||
cell.contentView.addSubview(makeStatusCard())
|
||||
} else if indexPath.section == 1 {
|
||||
cell.contentView.addSubview(makeLocationCard())
|
||||
} else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore {
|
||||
cell.contentView.addSubview(makeStoreCard(store))
|
||||
} else {
|
||||
cell.contentView.addSubview(makeQuickActionsRow())
|
||||
}
|
||||
} else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 {
|
||||
cell.contentView.addSubview(makeStoreCard(store))
|
||||
}
|
||||
|
||||
cell.contentView.subviews.first?.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(
|
||||
top: AppMetrics.Spacing.xxSmall,
|
||||
left: AppMetrics.Spacing.pageHorizontal,
|
||||
bottom: AppMetrics.Spacing.xxSmall,
|
||||
right: AppMetrics.Spacing.pageHorizontal
|
||||
))
|
||||
}
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if indexPath.section == tableView.numberOfSections - 1 { return 240 }
|
||||
if shouldShowWorkStatus {
|
||||
switch indexPath.section {
|
||||
case 0: return 100
|
||||
case 1: return 148
|
||||
case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88
|
||||
default: return 118
|
||||
}
|
||||
}
|
||||
if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 }
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
private func makeStatusCard() -> UIView {
|
||||
let card = makeCardView()
|
||||
|
||||
let onlineButton = UIButton(type: .system)
|
||||
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
|
||||
onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
|
||||
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
|
||||
onlineButton.layer.cornerRadius = 4
|
||||
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
|
||||
|
||||
let clockLabel = UILabel()
|
||||
clockLabel.text = " \(countdownDisplay)"
|
||||
clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
clockLabel.textColor = AppDesignUIKit.primary
|
||||
|
||||
let reminderButton = UIButton(type: .system)
|
||||
reminderButton.setTitle(" \(reminderText)", for: .normal)
|
||||
reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
|
||||
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
|
||||
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
|
||||
stack.axis = .horizontal
|
||||
stack.distribution = .equalSpacing
|
||||
stack.alignment = .center
|
||||
card.addSubview(stack)
|
||||
cardView.addSubview(stack)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(15)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeLocationCard() -> UIView {
|
||||
let card = makeCardView()
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置在线状态、倒计时与提醒,并绑定点击回调。
|
||||
func configure(
|
||||
isOnline: Bool,
|
||||
countdownText: String,
|
||||
reminderText: String,
|
||||
onOnlineTap: @escaping () -> Void,
|
||||
onReminderTap: @escaping () -> Void
|
||||
) {
|
||||
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
|
||||
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
|
||||
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
|
||||
clockLabel.text = " \(countdownText)"
|
||||
reminderButton.setTitle(" \(reminderText)", for: .normal)
|
||||
|
||||
onlineButton.removeAction(identifiedBy: UIAction.Identifier("online"), for: .touchUpInside)
|
||||
reminderButton.removeAction(identifiedBy: UIAction.Identifier("reminder"), for: .touchUpInside)
|
||||
onlineButton.addAction(UIAction(identifier: UIAction.Identifier("online")) { _ in onOnlineTap() }, for: .touchUpInside)
|
||||
reminderButton.addAction(UIAction(identifier: UIAction.Identifier("reminder")) { _ in onReminderTap() }, for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报卡片 Cell,展示打卡范围提示与立即上报入口。
|
||||
private final class HomeLocationReportCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeLocationReportCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let actionButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "立即上报"
|
||||
@ -356,15 +533,16 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = AppMetrics.Spacing.xxSmall
|
||||
|
||||
let actionButton = UIButton(type: .system)
|
||||
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
|
||||
actionButton.tintColor = .white
|
||||
actionButton.backgroundColor = AppDesignUIKit.primary
|
||||
actionButton.layer.cornerRadius = 46
|
||||
actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside)
|
||||
|
||||
card.addSubview(textStack)
|
||||
card.addSubview(actionButton)
|
||||
cardView.addSubview(textStack)
|
||||
cardView.addSubview(actionButton)
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
textStack.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(15)
|
||||
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
|
||||
@ -374,72 +552,38 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(92)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeQuickActionsRow() -> UIView {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
stack.distribution = .fillEqually
|
||||
|
||||
stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped)))
|
||||
stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped)))
|
||||
stack.addArrangedSubview(quickActionButton(
|
||||
icon: isOnline ? "wifi" : "wifi.slash",
|
||||
title: isOnline ? "在线" : "离线",
|
||||
action: #selector(onlineTapped),
|
||||
active: isOnline
|
||||
))
|
||||
return stack
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView {
|
||||
let card = makeCardView()
|
||||
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
|
||||
|
||||
let iconView = UIImageView(image: UIImage(systemName: icon))
|
||||
iconView.tintColor = AppDesignUIKit.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
label.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, label])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.xSmall
|
||||
stack.alignment = .center
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
/// 配置立即上报按钮点击回调。
|
||||
func configure(onReportTap: @escaping () -> Void) {
|
||||
actionButton.removeAction(identifiedBy: UIAction.Identifier("report"), for: .touchUpInside)
|
||||
actionButton.addAction(UIAction(identifier: UIAction.Identifier("report")) { _ in onReportTap() }, for: .touchUpInside)
|
||||
}
|
||||
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
|
||||
}
|
||||
/// 门店信息卡片 Cell,展示当前门店名称与营业状态。
|
||||
private final class HomeStoreInfoCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeStoreInfoCell"
|
||||
|
||||
private func makeStoreCard(_ store: BusinessScope) -> UIView {
|
||||
let card = makeCardView()
|
||||
private let cardView = UIView()
|
||||
private let nameLabel = UILabel()
|
||||
private let scenicLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
contentView.addSubview(cardView)
|
||||
|
||||
let nameLabel = UILabel()
|
||||
nameLabel.text = store.name
|
||||
nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold)
|
||||
|
||||
let scenicLabel = UILabel()
|
||||
scenicLabel.text = appServices.accountContext.currentScenic?.name
|
||||
scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
scenicLabel.numberOfLines = 2
|
||||
@ -457,8 +601,11 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = AppMetrics.Spacing.xSmall
|
||||
|
||||
card.addSubview(textStack)
|
||||
card.addSubview(statusLabel)
|
||||
cardView.addSubview(textStack)
|
||||
cardView.addSubview(statusLabel)
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
textStack.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
|
||||
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
|
||||
@ -468,39 +615,36 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
make.width.greaterThanOrEqualTo(52)
|
||||
make.height.equalTo(22)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置门店名称与所属景区。
|
||||
func configure(storeName: String, scenicName: String?) {
|
||||
nameLabel.text = storeName
|
||||
scenicLabel.text = scenicName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu Grid Cell
|
||||
/// 快捷操作行 Cell,展示收款、任务与在线状态入口。
|
||||
private final class HomeQuickActionsCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeQuickActionsCell"
|
||||
|
||||
private final class HomeMenuGridCell: UITableViewCell {
|
||||
static let reuseID = "HomeMenuGridCell"
|
||||
private let stackView = UIStackView()
|
||||
|
||||
private var onSelect: ((HomeMenuItem) -> Void)?
|
||||
private var items: [HomeMenuItem] = []
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 15
|
||||
layout.minimumLineSpacing = 15
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = .clear
|
||||
view.isScrollEnabled = false
|
||||
view.dataSource = self
|
||||
view.delegate = self
|
||||
view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
make.height.equalTo(220)
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = AppMetrics.Spacing.small
|
||||
stackView.distribution = .fillEqually
|
||||
contentView.addSubview(stackView)
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@ -509,40 +653,87 @@ private final class HomeMenuGridCell: UITableViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) {
|
||||
self.items = items
|
||||
self.onSelect = onSelect
|
||||
collectionView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell
|
||||
cell.configure(item: items[indexPath.item])
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
onSelect?(items[indexPath.item])
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let width = (collectionView.bounds.width - 30) / 3
|
||||
return CGSize(width: width, height: 102)
|
||||
/// 配置三个快捷操作按钮与点击回调。
|
||||
func configure(
|
||||
isOnline: Bool,
|
||||
onPaymentTap: @escaping () -> Void,
|
||||
onTaskCreateTap: @escaping () -> Void,
|
||||
onOnlineTap: @escaping () -> Void
|
||||
) {
|
||||
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
stackView.addArrangedSubview(quickActionCard(
|
||||
icon: "qrcode",
|
||||
title: "立即收款",
|
||||
active: false,
|
||||
action: onPaymentTap
|
||||
))
|
||||
stackView.addArrangedSubview(quickActionCard(
|
||||
icon: "checklist.checked",
|
||||
title: "提交任务",
|
||||
active: false,
|
||||
action: onTaskCreateTap
|
||||
))
|
||||
stackView.addArrangedSubview(quickActionCard(
|
||||
icon: isOnline ? "wifi" : "wifi.slash",
|
||||
title: isOnline ? "在线" : "离线",
|
||||
active: isOnline,
|
||||
action: onOnlineTap
|
||||
))
|
||||
}
|
||||
|
||||
/// 构建单个快捷操作卡片。
|
||||
private func quickActionCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
active: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> UIView {
|
||||
let card = UIView()
|
||||
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
|
||||
card.layer.cornerRadius = 8
|
||||
|
||||
let iconView = UIImageView(image: UIImage(systemName: icon))
|
||||
iconView.tintColor = AppDesignUIKit.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
label.textAlignment = .center
|
||||
|
||||
let innerStack = UIStackView(arrangedSubviews: [iconView, label])
|
||||
innerStack.axis = .vertical
|
||||
innerStack.spacing = AppMetrics.Spacing.xSmall
|
||||
innerStack.alignment = .center
|
||||
card.addSubview(innerStack)
|
||||
innerStack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.addAction(UIAction { _ in action() }, for: .touchUpInside)
|
||||
card.addSubview(button)
|
||||
button.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
card.snp.makeConstraints { make in
|
||||
make.height.equalTo(102)
|
||||
}
|
||||
return card
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页菜单网格单项 Cell,负责图标与标题展示。
|
||||
private final class HomeMenuItemCell: UICollectionViewCell {
|
||||
static let reuseID = "HomeMenuItemCell"
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
@ -574,6 +765,7 @@ private final class HomeMenuItemCell: UICollectionViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置菜单项图标与标题。
|
||||
func configure(item: HomeMenuItem) {
|
||||
titleLabel.text = item.title
|
||||
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
|
||||
@ -581,3 +773,10 @@ private final class HomeMenuItemCell: UICollectionViewCell {
|
||||
iconView.tintColor = AppDesign.primary
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
private let rulesLabel = UILabel()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "邀请摄影师"
|
||||
@ -29,6 +30,7 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
Task { await viewModel.reload(api: services.inviteAPI) }
|
||||
}
|
||||
|
||||
/// 初始化UI相关 UI 或状态。
|
||||
private func setupUI() {
|
||||
navigationItem.rightBarButtonItems = [
|
||||
UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)),
|
||||
@ -64,6 +66,7 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
contentStack.addArrangedSubview(rulesLabel)
|
||||
}
|
||||
|
||||
/// render 业务逻辑。
|
||||
private func render() {
|
||||
activityIndicator.isHidden = !viewModel.loading
|
||||
if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||
@ -76,7 +79,9 @@ final class PhotographerInviteViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制验证码。
|
||||
@objc private func copyCode() { viewModel.copyInviteCode() }
|
||||
/// 复制链接。
|
||||
@objc private func copyURL() { viewModel.copyInviteUrl() }
|
||||
}
|
||||
|
||||
@ -87,6 +92,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
private let viewModel = InviteRecordViewModel()
|
||||
private let summaryLabel = UILabel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "邀请记录"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -103,6 +109,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
summaryLabel.font = .systemFont(ofSize: 14)
|
||||
summaryLabel.textColor = AppDesign.textSecondary
|
||||
@ -112,10 +119,12 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = summaryLabel
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.displayRows.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let row = viewModel.displayRows[indexPath.row]
|
||||
cell.configure(
|
||||
@ -125,6 +134,7 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(
|
||||
inviteAPI: services.inviteAPI,
|
||||
@ -134,11 +144,13 @@ final class InviteRecordViewController: ModuleTableViewController {
|
||||
updateSummary()
|
||||
}
|
||||
|
||||
/// 更新Summary状态。
|
||||
private func updateSummary() {
|
||||
summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)"
|
||||
navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户"
|
||||
}
|
||||
|
||||
/// toggleTab相关逻辑。
|
||||
@objc private func toggleTab() {
|
||||
Task {
|
||||
let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite
|
||||
|
||||
@ -61,6 +61,7 @@ final class PhotographerInviteViewModel {
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
/// 清空。
|
||||
private func clear() {
|
||||
inviteCode = ""
|
||||
inviteUrl = ""
|
||||
@ -128,6 +129,7 @@ final class InviteRecordViewModel {
|
||||
await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
|
||||
}
|
||||
|
||||
/// 加载InviteRows数据。
|
||||
private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws {
|
||||
let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize)
|
||||
let mapped = users.map {
|
||||
@ -152,6 +154,7 @@ final class InviteRecordViewModel {
|
||||
hasMore = users.count >= invitePageSize
|
||||
}
|
||||
|
||||
/// 加载RewardRows数据。
|
||||
private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws {
|
||||
let response = try await api.walletEarningDetail(
|
||||
startDate: "2025-01-01",
|
||||
@ -183,6 +186,7 @@ final class InviteRecordViewModel {
|
||||
hasMore = rewardRows.count < response.total && !mapped.isEmpty
|
||||
}
|
||||
|
||||
/// 清空。
|
||||
private func clear() {
|
||||
totalRewardText = "¥ 0.00"
|
||||
withdrawableText = "¥ 0.00"
|
||||
|
||||
@ -10,17 +10,29 @@ import Foundation
|
||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||
@MainActor
|
||||
protocol LiveServing {
|
||||
/// 直播列表相关逻辑。
|
||||
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
|
||||
/// 直播详情相关逻辑。
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity
|
||||
/// 直播创建相关逻辑。
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws
|
||||
/// 直播启动相关逻辑。
|
||||
func liveStart(liveId: Int) async throws
|
||||
/// 直播停止相关逻辑。
|
||||
func liveStop(liveId: Int) async throws
|
||||
/// 直播Finish相关逻辑。
|
||||
func liveFinish(liveId: Int) async throws
|
||||
/// 直播Set推送Mode相关逻辑。
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws
|
||||
/// 直播相册列表相关逻辑。
|
||||
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
||||
/// 直播相册创建文件夹相关逻辑。
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
|
||||
/// 直播相册删除文件夹相关逻辑。
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws
|
||||
/// 直播相册文件夹详情相关逻辑。
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
|
||||
/// 直播相册删除Files相关逻辑。
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ struct LiveListResponse: Decodable {
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case total
|
||||
@ -21,6 +22,7 @@ struct LiveListResponse: Decodable {
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
|
||||
self.items = items
|
||||
self.total = total
|
||||
@ -28,6 +30,7 @@ struct LiveListResponse: Decodable {
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
|
||||
@ -57,6 +60,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
let manualPushState: Int
|
||||
let viewsCount: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
@ -77,6 +81,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
case viewsCount = "views_count"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
title: String = "",
|
||||
@ -115,6 +120,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
self.viewsCount = viewsCount
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -151,6 +157,7 @@ struct LiveCreateRequest: Encodable, Equatable {
|
||||
let title: String
|
||||
let coverImg: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case title
|
||||
@ -162,6 +169,7 @@ struct LiveCreateRequest: Encodable, Equatable {
|
||||
struct LiveControlRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
}
|
||||
@ -172,6 +180,7 @@ struct LivePushModeRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
let manualPushMode: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
case manualPushMode = "manual_push_mode"
|
||||
@ -186,6 +195,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
||||
let total: Int
|
||||
let totalPages: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case page
|
||||
@ -194,6 +204,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
||||
case totalPages = "total_pages"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
|
||||
self.items = items
|
||||
self.page = page
|
||||
@ -202,6 +213,7 @@ struct LiveAlbumFolderListResponse: Decodable {
|
||||
self.totalPages = totalPages
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
|
||||
@ -220,6 +232,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
let creator: String
|
||||
let items: [LiveAlbumFileItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case albumId = "album_id"
|
||||
@ -228,6 +241,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
case items
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
|
||||
self.id = id
|
||||
self.albumId = albumId
|
||||
@ -236,6 +250,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
self.items = items
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -254,6 +269,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
@ -262,6 +278,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
@ -270,6 +287,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
self.coverImg = coverImg
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -295,6 +313,7 @@ struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
|
||||
let name: String
|
||||
let items: [LiveAlbumCreateFileItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
@ -309,6 +328,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case type
|
||||
@ -321,6 +341,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
||||
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
}
|
||||
@ -331,6 +352,7 @@ struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
let fileIds: [Int]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
case fileIds = "file_ids"
|
||||
@ -346,6 +368,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
||||
let size: Int64
|
||||
var uploadedURL: String?
|
||||
|
||||
/// 初始化实例。
|
||||
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
|
||||
self.id = id
|
||||
self.data = data
|
||||
@ -359,6 +382,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 直播解码宽松字符串相关逻辑。
|
||||
func liveDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -378,6 +402,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 直播解码宽松整数相关逻辑。
|
||||
func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
|
||||
@ -11,14 +11,17 @@ import UIKit
|
||||
final class LiveManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "直播管理"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(
|
||||
@ -28,10 +31,12 @@ final class LiveManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
/// table视图相关逻辑。
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
|
||||
@ -44,19 +49,23 @@ extension LiveManagementViewModel: ViewModelBindable {}
|
||||
final class LiveAlbumViewController: ModuleTableViewController {
|
||||
private let viewModel = LiveAlbumViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "直播相册"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.folders.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let folder = viewModel.folders[indexPath.row]
|
||||
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张")
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
@ -10,10 +10,12 @@ import Foundation
|
||||
|
||||
/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。
|
||||
enum LivePlaybackURLResolver {
|
||||
/// playable链接相关逻辑。
|
||||
static func playableURL(from live: LiveEntity) -> URL? {
|
||||
playableURL(from: live.playbackURLCandidates)
|
||||
}
|
||||
|
||||
/// playable链接相关逻辑。
|
||||
static func playableURL(from candidates: [String]) -> URL? {
|
||||
for candidate in candidates {
|
||||
if let url = playableURL(from: candidate) {
|
||||
@ -23,6 +25,7 @@ enum LivePlaybackURLResolver {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// playable链接相关逻辑。
|
||||
static func playableURL(from rawValue: String) -> URL? {
|
||||
let value = rawValue.liveTrimmed
|
||||
guard !value.isEmpty, let url = URL(string: value) else { return nil }
|
||||
@ -65,6 +68,7 @@ final class LivePlaybackViewModel {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(live: LiveEntity? = nil, urlString: String? = nil) {
|
||||
if let live {
|
||||
load(live: live)
|
||||
@ -73,14 +77,17 @@ final class LivePlaybackViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载数据。
|
||||
func load(live: LiveEntity) {
|
||||
load(url: LivePlaybackURLResolver.playableURL(from: live))
|
||||
}
|
||||
|
||||
/// 加载数据。
|
||||
func load(urlString: String) {
|
||||
load(url: LivePlaybackURLResolver.playableURL(from: urlString))
|
||||
}
|
||||
|
||||
/// play 业务逻辑。
|
||||
func play() {
|
||||
guard let url = playableURL else { return }
|
||||
if player == nil {
|
||||
@ -90,12 +97,14 @@ final class LivePlaybackViewModel {
|
||||
state = .playing(url)
|
||||
}
|
||||
|
||||
/// pause 业务逻辑。
|
||||
func pause() {
|
||||
guard let url = playableURL else { return }
|
||||
player?.pause()
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
/// 刷新。
|
||||
func reload() {
|
||||
guard let url = playableURL else { return }
|
||||
releasePlayer()
|
||||
@ -103,6 +112,7 @@ final class LivePlaybackViewModel {
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
/// release 业务逻辑。
|
||||
func release() {
|
||||
releasePlayer()
|
||||
if let url = playableURL {
|
||||
@ -112,6 +122,7 @@ final class LivePlaybackViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载数据。
|
||||
private func load(url: URL?) {
|
||||
releasePlayer()
|
||||
guard let url else {
|
||||
@ -124,6 +135,7 @@ final class LivePlaybackViewModel {
|
||||
state = .ready(url)
|
||||
}
|
||||
|
||||
/// releasePlayer相关逻辑。
|
||||
private func releasePlayer() {
|
||||
player?.pause()
|
||||
player = nil
|
||||
|
||||
@ -47,25 +47,34 @@ enum LivePushNetworkState: Equatable {
|
||||
protocol LivePushAdapter {
|
||||
var name: String { get }
|
||||
var isAvailable: Bool { get }
|
||||
/// prepare 业务逻辑。
|
||||
func prepare(pushURL: URL) async throws
|
||||
/// start 业务逻辑。
|
||||
func start() async throws
|
||||
/// stop 业务逻辑。
|
||||
func stop() async throws
|
||||
/// dispose 业务逻辑。
|
||||
func dispose() async
|
||||
}
|
||||
|
||||
/// 权限提供者协议,隔离 AVFoundation 便于测试。
|
||||
protocol LivePermissionProviding {
|
||||
/// camera权限相关逻辑。
|
||||
func cameraPermission() async -> LivePushPermissionState
|
||||
/// microphone权限相关逻辑。
|
||||
func microphonePermission() async -> LivePushPermissionState
|
||||
}
|
||||
|
||||
/// 网络监听协议,隔离 NWPathMonitor 便于测试。
|
||||
protocol LiveNetworkMonitoring: AnyObject {
|
||||
var currentState: LivePushNetworkState { get }
|
||||
/// start 业务逻辑。
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
|
||||
/// stop 业务逻辑。
|
||||
func stop()
|
||||
}
|
||||
|
||||
/// LivePushReadiness错误类型定义。
|
||||
enum LivePushReadinessError: LocalizedError, Equatable {
|
||||
case missingPushURL
|
||||
case invalidPushURL
|
||||
@ -89,32 +98,41 @@ enum LivePushReadinessError: LocalizedError, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// UnsupportedLivePushAdapter,业务类型定义。
|
||||
struct UnsupportedLivePushAdapter: LivePushAdapter {
|
||||
let name = "未接入推流 SDK"
|
||||
let isAvailable = false
|
||||
|
||||
/// prepare 业务逻辑。
|
||||
func prepare(pushURL: URL) async throws {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
|
||||
/// start 业务逻辑。
|
||||
func start() async throws {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
|
||||
/// stop 业务逻辑。
|
||||
func stop() async throws {}
|
||||
|
||||
/// dispose 业务逻辑。
|
||||
func dispose() async {}
|
||||
}
|
||||
|
||||
/// SystemLivePermission能力提供者,封装外部依赖。
|
||||
struct SystemLivePermissionProvider: LivePermissionProviding {
|
||||
/// camera权限相关逻辑。
|
||||
func cameraPermission() async -> LivePushPermissionState {
|
||||
await permission(for: .video)
|
||||
}
|
||||
|
||||
/// microphone权限相关逻辑。
|
||||
func microphonePermission() async -> LivePushPermissionState {
|
||||
await permission(for: .audio)
|
||||
}
|
||||
|
||||
/// permission 业务逻辑。
|
||||
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
|
||||
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
||||
case .authorized:
|
||||
@ -130,6 +148,7 @@ struct SystemLivePermissionProvider: LivePermissionProviding {
|
||||
}
|
||||
}
|
||||
|
||||
/// SystemLiveNetworkMonitor 类型定义。
|
||||
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
@ -137,6 +156,7 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
private let queue = DispatchQueue(label: "com.suixinkan.live.network")
|
||||
private(set) var currentState: LivePushNetworkState = .unknown
|
||||
|
||||
/// start 业务逻辑。
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let state = Self.state(from: path)
|
||||
@ -146,10 +166,12 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
/// stop 业务逻辑。
|
||||
func stop() {
|
||||
monitor.cancel()
|
||||
}
|
||||
|
||||
/// state 业务逻辑。
|
||||
private static func state(from path: NWPath) -> LivePushNetworkState {
|
||||
guard path.status == .satisfied else { return .unavailable }
|
||||
if path.usesInterfaceType(.wifi) { return .wifi }
|
||||
@ -177,6 +199,7 @@ final class LivePushReadinessViewModel {
|
||||
private let adapter: any LivePushAdapter
|
||||
private var pushURL: URL?
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
|
||||
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
|
||||
@ -190,6 +213,7 @@ final class LivePushReadinessViewModel {
|
||||
self.networkState = networkMonitor.currentState
|
||||
}
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(pushURL rawValue: String) {
|
||||
let value = rawValue.liveTrimmed
|
||||
guard !value.isEmpty else {
|
||||
@ -206,6 +230,7 @@ final class LivePushReadinessViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// startMonitoring相关逻辑。
|
||||
func startMonitoring() {
|
||||
networkState = networkMonitor.currentState
|
||||
networkMonitor.start { [weak self] state in
|
||||
@ -215,10 +240,12 @@ final class LivePushReadinessViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// stopMonitoring相关逻辑。
|
||||
func stopMonitoring() {
|
||||
networkMonitor.stop()
|
||||
}
|
||||
|
||||
/// refreshPermissions相关逻辑。
|
||||
func refreshPermissions() async {
|
||||
async let camera = permissionProvider.cameraPermission()
|
||||
async let microphone = permissionProvider.microphonePermission()
|
||||
@ -226,6 +253,7 @@ final class LivePushReadinessViewModel {
|
||||
microphonePermission = await microphone
|
||||
}
|
||||
|
||||
/// runDiagnostics相关逻辑。
|
||||
func runDiagnostics() throws {
|
||||
do {
|
||||
try validateReadiness()
|
||||
@ -240,6 +268,7 @@ final class LivePushReadinessViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// prepare 业务逻辑。
|
||||
func prepare() async throws {
|
||||
try validateReadiness()
|
||||
guard let pushURL else { throw LivePushReadinessError.missingPushURL }
|
||||
@ -253,6 +282,7 @@ final class LivePushReadinessViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// start推送相关逻辑。
|
||||
func startPush() async throws {
|
||||
try validateReadiness()
|
||||
guard adapter.isAvailable else {
|
||||
@ -265,11 +295,13 @@ final class LivePushReadinessViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// stop推送相关逻辑。
|
||||
func stopPush() async {
|
||||
try? await adapter.stop()
|
||||
running = false
|
||||
}
|
||||
|
||||
/// dispose 业务逻辑。
|
||||
func dispose() async {
|
||||
stopMonitoring()
|
||||
await adapter.dispose()
|
||||
@ -277,6 +309,7 @@ final class LivePushReadinessViewModel {
|
||||
prepared = false
|
||||
}
|
||||
|
||||
/// 校验Readiness输入或状态。
|
||||
private func validateReadiness() throws {
|
||||
guard pushURL != nil else {
|
||||
throw LivePushReadinessError.missingPushURL
|
||||
|
||||
@ -104,6 +104,7 @@ final class LiveManagementViewModel {
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 加载Page数据。
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||
if page == 1 {
|
||||
@ -118,6 +119,7 @@ final class LiveManagementViewModel {
|
||||
hasMore = items.count < total
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset() {
|
||||
clearListAndDetail()
|
||||
loading = false
|
||||
@ -125,6 +127,7 @@ final class LiveManagementViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 清空列表并详情。
|
||||
private func clearListAndDetail() {
|
||||
items = []
|
||||
detail = nil
|
||||
@ -143,6 +146,7 @@ final class LiveDetailViewModel {
|
||||
var actionInFlight = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 初始化实例。
|
||||
init(detail: LiveEntity) {
|
||||
self.detail = detail
|
||||
}
|
||||
@ -265,6 +269,7 @@ final class LiveAlbumViewModel {
|
||||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||||
}
|
||||
|
||||
/// 加载Page数据。
|
||||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||||
let response = try await api.liveAlbumList(
|
||||
scenicId: scenicId,
|
||||
@ -284,6 +289,7 @@ final class LiveAlbumViewModel {
|
||||
hasMore = folders.count < total
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset() {
|
||||
clearFolders()
|
||||
loading = false
|
||||
@ -291,6 +297,7 @@ final class LiveAlbumViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 清空Folders。
|
||||
private func clearFolders() {
|
||||
folders = []
|
||||
page = 1
|
||||
@ -377,6 +384,7 @@ final class LiveAlbumPreviewViewModel {
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 初始化实例。
|
||||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
currentIndex = max(startIndex, 0)
|
||||
|
||||
@ -39,6 +39,7 @@ struct LocationReportSubmitResponse: Decodable, Equatable {
|
||||
let expired: Int
|
||||
let status: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case staffId = "staff_id"
|
||||
case expired
|
||||
@ -73,6 +74,7 @@ struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case staffId = "staff_id"
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class LocationReportViewController: ModuleTableViewController {
|
||||
private let viewModel = LocationReportViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "位置上报"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,17 +24,21 @@ final class LocationReportViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
/// 返回列表 section 数量。
|
||||
override func numberOfTableSections() -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
/// 返回指定 section 行数。
|
||||
override func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? 3 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
/// section 标题。
|
||||
override func tableSectionTitle(for section: Int) -> String? {
|
||||
section == 0 ? "状态" : "操作"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
/// 提供自定义 Cell。
|
||||
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -55,8 +60,8 @@ final class LocationReportViewController: ModuleTableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
/// 处理行选中。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
if indexPath.section == 1 {
|
||||
Task {
|
||||
_ = await viewModel.setOnline(
|
||||
@ -69,10 +74,12 @@ final class LocationReportViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入")
|
||||
}
|
||||
|
||||
/// 提交Report。
|
||||
@objc private func submitReport() {
|
||||
Task {
|
||||
_ = await viewModel.submit(
|
||||
@ -91,25 +98,30 @@ extension LocationReportViewModel: ViewModelBindable {}
|
||||
final class LocationReportHistoryViewController: ModuleTableViewController {
|
||||
private let viewModel = LocationReportHistoryViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "上报历史"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.items.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let record = viewModel.items[indexPath.row]
|
||||
cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI)
|
||||
}
|
||||
|
||||
/// table视图相关逻辑。
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.items.count - 2 else { return }
|
||||
Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) }
|
||||
|
||||
@ -2,14 +2,9 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
|
||||
Main 模块负责登录后的主界面 Tab 容器。
|
||||
|
||||
主界面由 `MainTabsView` 承载:
|
||||
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。
|
||||
- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。
|
||||
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
|
||||
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
|
||||
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
|
||||
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。
|
||||
|
||||
## Tab 结构
|
||||
|
||||
@ -19,41 +14,33 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
|
||||
- `statistics`:数据
|
||||
- `profile`:我的
|
||||
|
||||
`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。
|
||||
各 Tab 根页面已接入真实业务页面:`HomeViewController`、`OrdersViewController`、`StatisticsViewController`、`ProfileViewController`。
|
||||
|
||||
## 导航流程
|
||||
|
||||
1. `MainTabsView` 从 Environment 读取 `AppRouter`。
|
||||
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。
|
||||
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。
|
||||
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。
|
||||
5. 路径绑定来自 `appRouter.binding(for:)`。
|
||||
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。
|
||||
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
|
||||
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
|
||||
1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源。
|
||||
2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步。
|
||||
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。
|
||||
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar,默认隐藏。
|
||||
5. 跨 Tab 跳转通过 `AppRouter.select` 或 `AppRouter.selectOrders(entry:)` 处理。
|
||||
|
||||
## 自定义 TabBar
|
||||
## 订单角标
|
||||
|
||||
自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成:
|
||||
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
|
||||
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
|
||||
- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。
|
||||
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
|
||||
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。
|
||||
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。
|
||||
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。
|
||||
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
|
||||
- 角标展示在订单 Tab 的 `tabBarItem.badgeValue` 上。
|
||||
- 账号景区或门店变化、切换到订单 Tab 时会刷新角标。
|
||||
|
||||
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。
|
||||
## 扫码核销
|
||||
|
||||
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
|
||||
|
||||
## 后续迁移规则
|
||||
|
||||
迁移新页面时:
|
||||
- 优先替换对应 Tab 的 RootView。
|
||||
- 保持每个 Tab 自己的 `NavigationStack`。
|
||||
- 优先替换对应 Tab 的根 ViewController。
|
||||
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。
|
||||
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
|
||||
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。
|
||||
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
|
||||
- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。
|
||||
- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。
|
||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||
|
||||
@ -3,24 +3,22 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。
|
||||
final class MainTabBarController: UIViewController {
|
||||
/// 主 Tab 容器,使用系统 `UITabBarController` 展示四个一级入口。
|
||||
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
|
||||
|
||||
private let services: AppServices
|
||||
private let badgeViewModel = MainTabBadgeViewModel()
|
||||
private let contentContainer = UIView()
|
||||
private let customTabBar = CustomMainTabBarView()
|
||||
|
||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||
private var loadedTabs: Set<AppTab> = [.home]
|
||||
private var isSyncingTabSelection = false
|
||||
|
||||
/// 初始化实例。
|
||||
init(services: AppServices) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
delegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .white
|
||||
configureLayout()
|
||||
configureTabBarAppearance()
|
||||
configureTabs()
|
||||
bindRouter()
|
||||
bindBadgeViewModel()
|
||||
bindAccountContext()
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
syncSelectedTabFromRouter(animated: false)
|
||||
Task { await refreshOrderBadge() }
|
||||
#if DEBUG
|
||||
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 控制自定义 TabBar 显隐,push 子页面时隐藏。
|
||||
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) {
|
||||
let updates = {
|
||||
self.customTabBar.alpha = hidden ? 0 : 1
|
||||
self.customTabBar.isUserInteractionEnabled = !hidden
|
||||
/// 配置系统 TabBar 外观。
|
||||
private func configureTabBarAppearance() {
|
||||
tabBar.tintColor = AppDesign.primary
|
||||
tabBar.backgroundColor = .white
|
||||
}
|
||||
|
||||
guard animated else {
|
||||
updates()
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates)
|
||||
}
|
||||
|
||||
private func configureLayout() {
|
||||
view.addSubview(contentContainer)
|
||||
view.addSubview(customTabBar)
|
||||
|
||||
customTabBar.onTabSelected = { [weak self] tab in
|
||||
self?.services.appRouter.select(tab)
|
||||
}
|
||||
customTabBar.onScanTapped = { [weak self] in
|
||||
self?.presentScanner()
|
||||
}
|
||||
|
||||
contentContainer.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(customTabBar.snp.top)
|
||||
}
|
||||
|
||||
customTabBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
make.height.equalTo(72)
|
||||
/// 为每个 Tab 创建独立导航栈并绑定 TabBarItem。
|
||||
private func configureTabs() {
|
||||
viewControllers = AppTab.allCases.map { tab in
|
||||
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||
navigationController.tabBarItem = tab.makeTabBarItem()
|
||||
tabNavigationControllers[tab] = navigationController
|
||||
return navigationController
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定 Router 回调,同步程序化 Tab 切换。
|
||||
private func bindRouter() {
|
||||
services.appRouter.onChange = { [weak self] in
|
||||
self?.handleRouterChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定角标 ViewModel 回调。
|
||||
private func bindBadgeViewModel() {
|
||||
badgeViewModel.onChange = { [weak self] in
|
||||
self?.updateOrderBadge()
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定账号上下文,景区或门店变化时刷新角标。
|
||||
private func bindAccountContext() {
|
||||
services.accountContext.onChange = { [weak self] in
|
||||
Task { await self?.refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 Router 变更,保持选中 Tab 与 `AppRouter` 一致。
|
||||
private func handleRouterChange() {
|
||||
switchToTab(services.appRouter.selectedTab, animated: false)
|
||||
syncSelectedTabFromRouter(animated: false)
|
||||
if services.appRouter.selectedTab == .orders {
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToTab(_ tab: AppTab, animated: Bool) {
|
||||
loadedTabs.insert(tab)
|
||||
customTabBar.selectedTab = tab
|
||||
/// 将 `AppRouter.selectedTab` 同步到系统 TabBar 选中项。
|
||||
private func syncSelectedTabFromRouter(animated: Bool) {
|
||||
guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return }
|
||||
guard selectedIndex != index else { return }
|
||||
|
||||
let navigationController = navigationController(for: tab)
|
||||
for child in children where child !== navigationController {
|
||||
child.willMove(toParent: nil)
|
||||
child.view.removeFromSuperview()
|
||||
child.removeFromParent()
|
||||
}
|
||||
|
||||
addChild(navigationController)
|
||||
contentContainer.addSubview(navigationController.view)
|
||||
navigationController.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
navigationController.didMove(toParent: self)
|
||||
|
||||
let isRoot = navigationController.viewControllers.count <= 1
|
||||
setCustomTabBarHidden(!isRoot, animated: animated)
|
||||
}
|
||||
|
||||
private func navigationController(for tab: AppTab) -> TabNavigationController {
|
||||
if let existing = tabNavigationControllers[tab] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let navigationController = TabNavigationController(tab: tab, services: services)
|
||||
navigationController.tabBarHost = self
|
||||
tabNavigationControllers[tab] = navigationController
|
||||
return navigationController
|
||||
isSyncingTabSelection = true
|
||||
selectedIndex = index
|
||||
isSyncingTabSelection = false
|
||||
}
|
||||
|
||||
/// 更新订单 Tab 待核销角标。
|
||||
private func updateOrderBadge() {
|
||||
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
|
||||
customTabBar.orderBadgeText = nil
|
||||
return
|
||||
let badgeValue: String?
|
||||
if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
|
||||
badgeValue = "\(count)"
|
||||
} else {
|
||||
badgeValue = nil
|
||||
}
|
||||
customTabBar.orderBadgeText = "\(count)"
|
||||
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
||||
}
|
||||
|
||||
/// 刷新订单 Tab 待核销角标数据。
|
||||
private func refreshOrderBadge() async {
|
||||
await badgeViewModel.refreshPendingWriteOffCount(
|
||||
api: services.ordersAPI,
|
||||
@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let rawCode):
|
||||
dismiss(animated: true) {
|
||||
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode)
|
||||
}
|
||||
case .failure(let error):
|
||||
showToast(error.localizedDescription)
|
||||
/// 用户点击 TabBar 时同步选中状态到 `AppRouter`。
|
||||
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
|
||||
guard !isSyncingTabSelection,
|
||||
let navigationController = viewController as? TabNavigationController else { return }
|
||||
|
||||
services.appRouter.select(navigationController.appTab)
|
||||
if navigationController.appTab == .orders {
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
let navigation = UINavigationController(rootViewController: scanner)
|
||||
navigation.modalPresentationStyle = .fullScreen
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。
|
||||
private final class CustomMainTabBarView: UIView {
|
||||
|
||||
var selectedTab: AppTab = .home {
|
||||
didSet { refreshSelection() }
|
||||
}
|
||||
|
||||
var orderBadgeText: String? {
|
||||
didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil }
|
||||
}
|
||||
|
||||
var onTabSelected: ((AppTab) -> Void)?
|
||||
var onScanTapped: (() -> Void)?
|
||||
|
||||
private struct TabItem {
|
||||
let tab: AppTab
|
||||
let title: String
|
||||
let selectedImage: String
|
||||
let unselectedImage: String
|
||||
}
|
||||
|
||||
private let items: [TabItem] = [
|
||||
.init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"),
|
||||
.init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"),
|
||||
.init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"),
|
||||
.init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected")
|
||||
]
|
||||
|
||||
private var tabButtons: [AppTab: UIButton] = [:]
|
||||
private var tabTitleLabels: [AppTab: UILabel] = [:]
|
||||
private let ordersBadgeLabel = UILabel()
|
||||
private let topDivider = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
configureViews()
|
||||
refreshSelection()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04)
|
||||
addSubview(topDivider)
|
||||
topDivider.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
let leftStack = UIStackView()
|
||||
leftStack.axis = .horizontal
|
||||
leftStack.distribution = .fillEqually
|
||||
leftStack.spacing = 0
|
||||
|
||||
let rightStack = UIStackView()
|
||||
rightStack.axis = .horizontal
|
||||
rightStack.distribution = .fillEqually
|
||||
rightStack.spacing = 0
|
||||
|
||||
for (index, item) in items.enumerated() {
|
||||
let buttonContainer = makeTabButton(for: item)
|
||||
if index < 2 {
|
||||
leftStack.addArrangedSubview(buttonContainer)
|
||||
} else {
|
||||
rightStack.addArrangedSubview(buttonContainer)
|
||||
}
|
||||
}
|
||||
|
||||
let scanButton = UIButton(type: .system)
|
||||
scanButton.backgroundColor = AppDesign.primary
|
||||
scanButton.layer.cornerRadius = 29
|
||||
scanButton.tintColor = .white
|
||||
scanButton.setImage(
|
||||
UIImage(systemName: "qrcode.viewfinder")?.withConfiguration(
|
||||
UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
|
||||
),
|
||||
for: .normal
|
||||
)
|
||||
scanButton.accessibilityLabel = "扫码核销"
|
||||
scanButton.accessibilityIdentifier = "main.scan"
|
||||
scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside)
|
||||
|
||||
let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack])
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 0
|
||||
addSubview(row)
|
||||
|
||||
scanButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(74)
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
|
||||
row.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(22)
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
leftStack.snp.makeConstraints { make in
|
||||
make.width.equalTo(rightStack)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTabButton(for item: TabItem) -> UIView {
|
||||
let container = UIView()
|
||||
|
||||
let button = UIButton(type: .custom)
|
||||
button.accessibilityLabel = item.title
|
||||
button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)"
|
||||
button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0
|
||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||
tabButtons[item.tab] = button
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = item.title
|
||||
titleLabel.font = .systemFont(ofSize: 12)
|
||||
titleLabel.textAlignment = .center
|
||||
tabTitleLabels[item.tab] = titleLabel
|
||||
|
||||
container.addSubview(button)
|
||||
container.addSubview(titleLabel)
|
||||
|
||||
button.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button.snp.bottom).offset(3)
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.lessThanOrEqualToSuperview()
|
||||
}
|
||||
|
||||
if item.tab == .orders {
|
||||
ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
|
||||
ordersBadgeLabel.textColor = .white
|
||||
ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444)
|
||||
ordersBadgeLabel.textAlignment = .center
|
||||
ordersBadgeLabel.layer.cornerRadius = 7.5
|
||||
ordersBadgeLabel.clipsToBounds = true
|
||||
ordersBadgeLabel.isHidden = true
|
||||
container.addSubview(ordersBadgeLabel)
|
||||
ordersBadgeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(button).offset(-4)
|
||||
make.leading.equalTo(button.snp.trailing).offset(-6)
|
||||
make.height.equalTo(15)
|
||||
make.width.greaterThanOrEqualTo(15)
|
||||
}
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
private func refreshSelection() {
|
||||
for item in items {
|
||||
let isSelected = item.tab == selectedTab
|
||||
let imageName = isSelected ? item.selectedImage : item.unselectedImage
|
||||
tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal)
|
||||
tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3)
|
||||
tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func tabTapped(_ sender: UIButton) {
|
||||
guard sender.tag >= 0, sender.tag < items.count else { return }
|
||||
onTabSelected?(items[sender.tag].tab)
|
||||
}
|
||||
|
||||
@objc private func scanTapped() {
|
||||
onScanTapped?()
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ final class PlaceholderViewController: UIViewController {
|
||||
|
||||
private let pageTitle: String
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String) {
|
||||
pageTitle = title
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
|
||||
@ -14,6 +14,7 @@ struct MessageListResponse: Decodable, Equatable {
|
||||
let lastId: Int
|
||||
let items: [MessageEntity]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hasMore = "has_more"
|
||||
case lastId = "last_id"
|
||||
@ -47,6 +48,7 @@ struct MessageEntity: Decodable, Equatable, Identifiable {
|
||||
let createdAt: String
|
||||
let isRead: Bool
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class MessageCenterViewController: ModuleTableViewController {
|
||||
private let viewModel = MessageCenterViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "消息中心"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,10 +24,12 @@ final class MessageCenterViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.messages.count
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.messages[indexPath.row]
|
||||
cell.configure(
|
||||
@ -37,6 +40,7 @@ final class MessageCenterViewController: ModuleTableViewController {
|
||||
cell.accessoryType = item.isRead ? .none : .disclosureIndicator
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.messages[indexPath.row]
|
||||
Task {
|
||||
@ -45,20 +49,24 @@ final class MessageCenterViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
isLoading = viewModel.messages.isEmpty
|
||||
await viewModel.reloadFirstPage(api: services.messageCenterAPI)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
/// mark全部Read相关逻辑。
|
||||
@objc private func markAllRead() {
|
||||
Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) }
|
||||
}
|
||||
|
||||
/// 更新BarButtons状态。
|
||||
private func updateBarButtons() {
|
||||
navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty
|
||||
}
|
||||
|
||||
/// 展示Alert。
|
||||
private func showAlert(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "知道了", style: .default))
|
||||
|
||||
@ -146,6 +146,7 @@ final class MessageCenterViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// deduplicated并排序相关逻辑。
|
||||
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
|
||||
var unique: [String: MessageItem] = [:]
|
||||
for item in source {
|
||||
@ -154,6 +155,7 @@ final class MessageCenterViewModel {
|
||||
return unique.values.sorted { $0.time > $1.time }
|
||||
}
|
||||
|
||||
/// 清空Messages。
|
||||
private func clearMessages() {
|
||||
messages = []
|
||||
hasMoreMessages = false
|
||||
|
||||
@ -10,7 +10,9 @@ import Foundation
|
||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||
@MainActor
|
||||
protocol OperatingAreaServing {
|
||||
/// store营业Area相关逻辑。
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
/// scenicAdmin营业Area相关逻辑。
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
let typeText: String
|
||||
let auditStatusText: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -40,6 +41,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
case auditStatusText = "audit_status_text"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
@ -56,6 +58,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
|
||||
self.auditStatusText = auditStatusText
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -76,6 +79,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
||||
case array([OperatingMapArea])
|
||||
case object([String: OperatingMapArea])
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
@ -106,6 +110,7 @@ enum OperatingMapArea: Decodable, Equatable {
|
||||
return fromJSONObject(raw)
|
||||
}
|
||||
|
||||
/// fromJSONObject相关逻辑。
|
||||
private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
|
||||
switch value {
|
||||
case is NSNull:
|
||||
@ -140,6 +145,7 @@ struct OperatingFenceRing: Identifiable, Equatable {
|
||||
let points: [OperatingGeoPoint]
|
||||
let isCurrentStore: Bool
|
||||
|
||||
/// 初始化实例。
|
||||
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
|
||||
self.id = "\(itemId)-\(ringIndex)"
|
||||
self.itemId = itemId
|
||||
@ -184,6 +190,7 @@ enum OperatingAreaParser {
|
||||
return parseElement(area)
|
||||
}
|
||||
|
||||
/// 解析Element数据。
|
||||
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
|
||||
switch value {
|
||||
case .null, .bool, .number:
|
||||
@ -199,6 +206,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析Object数据。
|
||||
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
let type: String?
|
||||
if case let .string(rawType)? = object["type"] {
|
||||
@ -231,6 +239,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析ArrayRoot数据。
|
||||
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
|
||||
guard let first = array.first else { return [] }
|
||||
switch first {
|
||||
@ -257,6 +266,7 @@ enum OperatingAreaParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析ObjectRing数据。
|
||||
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
guard case let .object(object) = element else { return nil }
|
||||
@ -265,6 +275,7 @@ enum OperatingAreaParser {
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
/// 解析Ring数据。
|
||||
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
|
||||
let points = array.compactMap { element -> OperatingGeoPoint? in
|
||||
switch element {
|
||||
@ -283,6 +294,7 @@ enum OperatingAreaParser {
|
||||
return points.count >= 3 ? points : nil
|
||||
}
|
||||
|
||||
/// point从对象相关逻辑。
|
||||
private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? {
|
||||
let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue
|
||||
let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue
|
||||
@ -290,6 +302,7 @@ enum OperatingAreaParser {
|
||||
return OperatingGeoPoint(latitude: lat, longitude: lng)
|
||||
}
|
||||
|
||||
/// 规范化Pair格式。
|
||||
private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint {
|
||||
if looksLikeLngLatPair(lng: a, lat: b) {
|
||||
return OperatingGeoPoint(latitude: b, longitude: a)
|
||||
@ -303,24 +316,29 @@ enum OperatingAreaParser {
|
||||
return OperatingGeoPoint(latitude: a, longitude: b)
|
||||
}
|
||||
|
||||
/// looksLikeLngLat坐标对相关逻辑。
|
||||
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
|
||||
}
|
||||
|
||||
/// looksLikeLatLng坐标对相关逻辑。
|
||||
private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool {
|
||||
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng)
|
||||
}
|
||||
}
|
||||
|
||||
/// 结构体,封装数据实体。
|
||||
private struct OperatingDynamicCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
let intValue: Int?
|
||||
|
||||
/// 初始化实例。
|
||||
init?(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
intValue = nil
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init?(intValue: Int) {
|
||||
stringValue = String(intValue)
|
||||
self.intValue = intValue
|
||||
@ -341,6 +359,7 @@ private extension OperatingMapArea {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// operating解码宽松字符串相关逻辑。
|
||||
func operatingDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -357,6 +376,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// operating解码宽松整数相关逻辑。
|
||||
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
|
||||
@ -1,37 +1,146 @@
|
||||
//
|
||||
// OperatingAreaViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
// suixinkan_ios
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 运营区域列表页。
|
||||
final class OperatingAreaViewController: ModuleTableViewController {
|
||||
/// 运营区域页:汇总信息、围栏地图与区域列表。
|
||||
final class OperatingAreaViewController: UIViewController {
|
||||
private let viewModel = OperatingAreaViewModel()
|
||||
private let mapView = OperatingAreaMapView()
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private let summaryLabel = UILabel()
|
||||
private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash")
|
||||
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, String>!
|
||||
private var services: AppServices { AppServices.shared }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "运营区域"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
title = "运营区域"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupLayout()
|
||||
wireViewModel()
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.items.count
|
||||
private func setupLayout() {
|
||||
summaryLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
summaryLabel.textColor = AppDesign.textSecondary
|
||||
summaryLabel.numberOfLines = 0
|
||||
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.height.equalTo(320)
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count) 组")
|
||||
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
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private func wireViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.render()
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
api: services.operatingAreaAPI,
|
||||
accountContext: services.accountContext,
|
||||
permissionContext: services.permissionContext
|
||||
)
|
||||
if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -110,6 +110,7 @@ final class OperatingAreaViewModel {
|
||||
mode?.title ?? "运营区域"
|
||||
}
|
||||
|
||||
/// apply 业务逻辑。
|
||||
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
|
||||
self.items = items
|
||||
if items.isEmpty {
|
||||
@ -139,6 +140,7 @@ final class OperatingAreaViewModel {
|
||||
blockReason = parsed.isEmpty ? .noParsableFenceData : nil
|
||||
}
|
||||
|
||||
/// setBlocked相关逻辑。
|
||||
private func setBlocked(_ reason: OperatingAreaBlockReason) {
|
||||
resetLoadedData()
|
||||
blockReason = reason
|
||||
@ -146,6 +148,7 @@ final class OperatingAreaViewModel {
|
||||
loading = false
|
||||
}
|
||||
|
||||
/// 重置LoadedData状态。
|
||||
private func resetLoadedData() {
|
||||
items = []
|
||||
fenceRings = []
|
||||
|
||||
@ -62,6 +62,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
case orderNumber = "order_number"
|
||||
@ -131,6 +132,7 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
||||
let retouchGiftPhotoNum: Int
|
||||
let retouchGiftVideoNum: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderPhotoNum = "order_photo_num"
|
||||
case orderVideoNum = "order_video_num"
|
||||
@ -163,6 +165,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let orderStatusName: String
|
||||
let orderVerificationTime: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderVerificationStatus = "order_verification_status"
|
||||
@ -192,6 +195,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
struct WriteOffRequest: Encodable {
|
||||
let orderNumber: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
@ -207,6 +211,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let statusName: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNumber = "order_number"
|
||||
@ -234,6 +239,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
struct DepositOrderWriteOffRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
@ -244,6 +250,7 @@ struct DepositOrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundReason: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundReason = "refund_reason"
|
||||
@ -275,6 +282,7 @@ struct OrderRefundRequest: Encodable, Equatable {
|
||||
let refundAmount: String
|
||||
let refundReason: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundType = "refund_type"
|
||||
@ -314,6 +322,7 @@ struct StoreOrderDetailResponse: Decodable, Equatable, Hashable {
|
||||
let remark: String
|
||||
let multiTravel: StoreOrderMultiTravelInfo?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderType = "order_type"
|
||||
@ -362,6 +371,7 @@ struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable {
|
||||
let projectInfo: StoreOrderProjectInfo?
|
||||
let shootingList: [StoreOrderShootingListItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectInfo = "project_info"
|
||||
case shootingList = "shooting_list"
|
||||
@ -382,6 +392,7 @@ struct StoreOrderProjectInfo: Decodable, Equatable, Hashable {
|
||||
let singleSpotPhotoNum: Int
|
||||
let singleSpotVideoNum: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case settleSpotNum = "settle_spot_num"
|
||||
case singleSpotMaterialNum = "single_spot_material_num"
|
||||
@ -412,6 +423,7 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable
|
||||
let startAvg: Double
|
||||
let start: Double
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
@ -447,6 +459,7 @@ struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
|
||||
let materialList: [OrderMediaFile]
|
||||
let completeList: [OrderMediaFile]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
@ -480,6 +493,7 @@ struct StoreOrderComment: Decodable, Equatable, Hashable {
|
||||
let content: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case starShooting = "star_shooting"
|
||||
case starRetouching = "star_retouching"
|
||||
@ -522,6 +536,7 @@ struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
|
||||
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
@ -550,6 +565,7 @@ struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
|
||||
let projectTypeName: String
|
||||
let photogSpotList: [PhotogSpotItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case projectType = "project_type"
|
||||
@ -583,6 +599,7 @@ struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
return nickname.isEmpty ? photogName : nickname
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
@ -609,6 +626,7 @@ struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Ha
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -629,6 +647,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
||||
let cloudFile: [MultiTravelCloudFileItem]
|
||||
let uploadFile: [MultiTravelUploadFileItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
@ -641,6 +660,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
||||
struct MultiTravelCloudFileItem: Encodable, Equatable {
|
||||
let fileId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileId = "file_id"
|
||||
}
|
||||
@ -651,6 +671,7 @@ struct MultiTravelUploadFileItem: Encodable, Equatable {
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
|
||||
@ -6,32 +6,127 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Deposit List Diffable 标识
|
||||
|
||||
private typealias DepositListSection = Int
|
||||
private typealias DepositListItem = String
|
||||
|
||||
/// 押金列表空态 / 加载态标识。
|
||||
private enum DepositListItemID {
|
||||
static let placeholder = "depositList:placeholder"
|
||||
}
|
||||
|
||||
// MARK: - Deposit Detail Diffable 标识
|
||||
|
||||
private typealias DepositDetailSection = Int
|
||||
private typealias DepositDetailRow = String
|
||||
|
||||
/// 押金详情行标识。
|
||||
private enum DepositDetailRowID {
|
||||
static let placeholder = "depositDetail:placeholder"
|
||||
static func field(_ index: Int) -> String { "depositDetail:field:\(index)" }
|
||||
}
|
||||
|
||||
// MARK: - Deposit Shooting Diffable 标识
|
||||
|
||||
private typealias DepositShootingSection = Int
|
||||
private typealias DepositShootingRow = String
|
||||
|
||||
/// 押金拍摄信息 section 索引。
|
||||
private enum DepositShootingSectionID {
|
||||
static let summary = 0
|
||||
static let material = 1
|
||||
static let complete = 2
|
||||
}
|
||||
|
||||
/// 押金拍摄信息行标识。
|
||||
private enum DepositShootingRowID {
|
||||
static let placeholder = "depositShooting:placeholder"
|
||||
static let scenicSpot = "depositShooting:scenicSpot"
|
||||
static let rating = "depositShooting:rating"
|
||||
static func material(_ index: Int) -> String { "depositShooting:material:\(index)" }
|
||||
static func complete(_ index: Int) -> String { "depositShooting:complete:\(index)" }
|
||||
}
|
||||
|
||||
/// 押金订单列表页,支持查询、分页、核销和退款。
|
||||
final class DepositOrderListViewController: UIViewController {
|
||||
final class DepositOrderListViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let viewModel = DepositOrderListViewModel()
|
||||
private var orderNumberField = UITextField()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动押金订单列表与分页刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<DepositListSection, DepositListItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
|
||||
setupHeader()
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<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() {
|
||||
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
|
||||
orderNumberField.placeholder = "请输入押金订单号"
|
||||
@ -55,12 +150,14 @@ final class DepositOrderListViewController: UIViewController {
|
||||
tableView.tableHeaderView = header
|
||||
}
|
||||
|
||||
/// open详情相关逻辑。
|
||||
@objc private func openDetail() {
|
||||
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !text.isEmpty else { return }
|
||||
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
|
||||
}
|
||||
|
||||
/// 刷新。
|
||||
private func reload(showLoading: Bool) async {
|
||||
if showLoading {
|
||||
await appServices.globalLoading.withLoading {
|
||||
@ -72,6 +169,7 @@ final class DepositOrderListViewController: UIViewController {
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
|
||||
/// writeOff相关逻辑。
|
||||
private func writeOff(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
@ -86,6 +184,7 @@ final class DepositOrderListViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// refund 业务逻辑。
|
||||
private func refund(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
@ -101,43 +200,25 @@ final class DepositOrderListViewController: UIViewController {
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
max(viewModel.orders.count, 1)
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.orders.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单"
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
|
||||
let item = viewModel.orders[indexPath.row]
|
||||
cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber)
|
||||
cell.onDetail = { [weak self] in
|
||||
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) }
|
||||
}
|
||||
cell.onWriteOff = { [weak self] in self?.writeOff(item) }
|
||||
cell.onRefund = { [weak self] in self?.refund(item) }
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:分页加载。
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return }
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath),
|
||||
item != DepositListItemID.placeholder,
|
||||
item == viewModel.orders.last?.orderNumber,
|
||||
viewModel.hasMore else { return }
|
||||
Task {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DepositOrder列表或网格 Cell,负责单项内容展示。
|
||||
private final class DepositOrderCell: UITableViewCell {
|
||||
static let reuseID = "DepositOrderCell"
|
||||
var onDetail: (() -> Void)?
|
||||
@ -148,6 +229,7 @@ private final class DepositOrderCell: UITableViewCell {
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
@ -182,14 +264,18 @@ private final class DepositOrderCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(item: DepositOrderListItem, isOperating: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = isOperating ? "处理中" : item.statusName
|
||||
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
|
||||
}
|
||||
|
||||
/// 点击detail的处理逻辑。
|
||||
@objc private func detailTapped() { onDetail?() }
|
||||
/// 点击writeOff的处理逻辑。
|
||||
@objc private func writeOffTapped() { onWriteOff?() }
|
||||
/// 点击refund的处理逻辑。
|
||||
@objc private func refundTapped() { onRefund?() }
|
||||
}
|
||||
|
||||
@ -201,11 +287,14 @@ final class DepositOrderDetailViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动详情字段展示。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -214,37 +303,26 @@ final class DepositOrderDetailViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单详情"
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
api: appServices.ordersAPI,
|
||||
storeId: appServices.accountContext.currentStore?.id,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
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 {
|
||||
/// 配置 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 = viewModel.detail else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情")
|
||||
guard let detail = self.viewModel.detail else {
|
||||
cell.textLabel?.text = self.viewModel.errorMessage ?? (self.viewModel.loading ? "加载中..." : "暂无详情")
|
||||
return cell
|
||||
}
|
||||
let rows: [(String, String)] = [
|
||||
@ -257,14 +335,49 @@ extension DepositOrderDetailViewController: UITableViewDataSource {
|
||||
("创建时间", detail.createdAt),
|
||||
("备注", detail.remark)
|
||||
]
|
||||
cell.textLabel?.text = rows[indexPath.row].0
|
||||
cell.detailTextLabel?.text = rows[indexPath.row].1
|
||||
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 {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
api: appServices.ordersAPI,
|
||||
storeId: appServices.accountContext.currentStore?.id,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
applyTableSnapshot()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金拍摄信息页。
|
||||
final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
final class DepositOrderShootingInfoViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let orderNumber: String
|
||||
private let scenicSpotId: Int
|
||||
@ -273,10 +386,14 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动拍摄摘要与素材列表刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
|
||||
self.orderNumber = orderNumber
|
||||
self.scenicSpotId = scenicSpotId
|
||||
@ -287,14 +404,75 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金拍摄信息"
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<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 {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
@ -305,56 +483,25 @@ final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
photogUid: photogUid
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
applyTableSnapshot()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderShootingInfoViewController: UITableViewDataSource {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
viewModel.detail == nil ? 1 : 3
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
guard let detail = viewModel.detail else { return 1 }
|
||||
switch section {
|
||||
case 0: return 2
|
||||
case 1: return detail.materialList.count
|
||||
default: return detail.completeList.count
|
||||
}
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard viewModel.detail != nil else { return nil }
|
||||
switch section {
|
||||
case 1: return "素材"
|
||||
case 2: return "成片"
|
||||
guard viewModel.detail != nil,
|
||||
let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||
switch sectionID {
|
||||
case DepositShootingSectionID.material: return "素材"
|
||||
case DepositShootingSectionID.complete: return "成片"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let detail = viewModel.detail else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "加载中..."
|
||||
return cell
|
||||
}
|
||||
if indexPath.section == 0 {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.row == 0 {
|
||||
cell.textLabel?.text = "打卡点"
|
||||
cell.detailTextLabel?.text = detail.scenicSpotName
|
||||
} else {
|
||||
cell.textLabel?.text = "拍摄评分"
|
||||
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--"
|
||||
}
|
||||
return cell
|
||||
}
|
||||
let files = indexPath.section == 1 ? detail.materialList : detail.completeList
|
||||
let media = files[indexPath.row]
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
cell.textLabel?.text = media.fileName
|
||||
cell.detailTextLabel?.text = media.fileUrl
|
||||
return cell
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
|
||||
private let scannerController = OrderScannerCaptureViewController()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
@ -49,6 +50,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// embed扫码相关逻辑。
|
||||
private func embedScanner() {
|
||||
scannerController.onScanResult = { [weak self] result in
|
||||
self?.onScanResult?(result)
|
||||
@ -77,6 +79,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示UnavailablePlaceholder。
|
||||
private func showUnavailablePlaceholder() {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
@ -109,6 +112,7 @@ final class OrderCodeScannerViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击close的处理逻辑。
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
@ -145,6 +149,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
return button
|
||||
}()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
@ -152,17 +157,20 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
checkPermissionAndStart()
|
||||
}
|
||||
|
||||
/// 子视图布局完成后调整依赖 frame 的 UI。
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.layer.bounds
|
||||
}
|
||||
|
||||
/// 视图即将消失,保存或清理临时状态。
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
updateTorch(enabled: false)
|
||||
stopSession()
|
||||
}
|
||||
|
||||
/// check权限并启动相关逻辑。
|
||||
private func checkPermissionAndStart() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
@ -185,6 +193,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动Session流程。
|
||||
private func startSession() {
|
||||
hasFinishedScan = false
|
||||
guard !session.isRunning else { return }
|
||||
@ -199,11 +208,13 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止Session流程。
|
||||
private func stopSession() {
|
||||
guard session.isRunning else { return }
|
||||
session.stopRunning()
|
||||
}
|
||||
|
||||
/// 创建ScannerButton实例。
|
||||
private static func makeScannerButton(title: String) -> UIButton {
|
||||
var config = UIButton.Configuration.filled()
|
||||
config.title = title
|
||||
@ -213,6 +224,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
return UIButton(configuration: config)
|
||||
}
|
||||
|
||||
/// 配置Session展示内容。
|
||||
private func configureSession() throws {
|
||||
if previewLayer != nil { return }
|
||||
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
||||
@ -242,6 +254,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
torchButton.isEnabled = videoDevice.hasTorch
|
||||
}
|
||||
|
||||
/// metadataOutput相关逻辑。
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard !hasFinishedScan else { return }
|
||||
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
@ -253,6 +266,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
onScanResult?(.success(code))
|
||||
}
|
||||
|
||||
/// 初始化Controls相关 UI 或状态。
|
||||
private func setupControls() {
|
||||
view.addSubview(scanFrameView)
|
||||
view.addSubview(torchButton)
|
||||
@ -273,10 +287,12 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// toggleTorch相关逻辑。
|
||||
@objc private func toggleTorch() {
|
||||
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
|
||||
}
|
||||
|
||||
/// 更新Torch状态。
|
||||
private func updateTorch(enabled: Bool) {
|
||||
guard let device = videoDevice, device.hasTorch else { return }
|
||||
do {
|
||||
@ -293,6 +309,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
|
||||
}
|
||||
}
|
||||
|
||||
/// restart扫码相关逻辑。
|
||||
@objc private func restartScan() {
|
||||
hasFinishedScan = false
|
||||
updateTorch(enabled: false)
|
||||
|
||||
@ -6,8 +6,45 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Store Order Diffable 标识
|
||||
|
||||
private typealias StoreOrderDetailSection = Int
|
||||
private typealias StoreOrderDetailRow = String
|
||||
|
||||
/// 门店订单详情 section 索引。
|
||||
private enum StoreOrderDetailSectionID {
|
||||
static let context = 0
|
||||
static let orderInfo = 1
|
||||
static let payment = 2
|
||||
static let customer = 3
|
||||
static let shooting = 4
|
||||
static let actions = 5
|
||||
}
|
||||
|
||||
/// 门店订单详情行标识。
|
||||
private enum StoreOrderDetailRowID {
|
||||
static let context = "storeDetail:context"
|
||||
static func orderInfo(_ index: Int) -> String { "storeDetail:orderInfo:\(index)" }
|
||||
static func payment(_ index: Int) -> String { "storeDetail:payment:\(index)" }
|
||||
static func customer(_ index: Int) -> String { "storeDetail:customer:\(index)" }
|
||||
static func shooting(_ id: String) -> String { "storeDetail:shooting:\(id)" }
|
||||
static func action(_ index: Int) -> String { "storeDetail:action:\(index)" }
|
||||
}
|
||||
|
||||
// MARK: - Write-Off Diffable 标识
|
||||
|
||||
/// 核销详情行标识。
|
||||
private enum WriteOffDetailRowID {
|
||||
static let orderNumber = "writeOff:orderNumber"
|
||||
static let project = "writeOff:project"
|
||||
static let phone = "writeOff:phone"
|
||||
static let amount = "writeOff:amount"
|
||||
static let status = "writeOff:status"
|
||||
static let verificationTime = "writeOff:verificationTime"
|
||||
}
|
||||
|
||||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||||
final class StoreOrderDetailViewController: UIViewController {
|
||||
final class StoreOrderDetailViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let item: OrderEntity
|
||||
private let viewModel: OrderDetailViewModel
|
||||
@ -15,12 +52,15 @@ final class StoreOrderDetailViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动多 section 订单详情刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
self.viewModel = OrderDetailViewModel(item: item)
|
||||
@ -30,31 +70,176 @@ final class StoreOrderDetailViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单详情"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<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 {
|
||||
await appServices.globalLoading.withLoading(message: "加载详情中...") {
|
||||
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
|
||||
}
|
||||
applyTableSnapshot(reconfigure: true)
|
||||
if let error = viewModel.errorMessage {
|
||||
showToast(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制订单Number。
|
||||
private func copyOrderNumber() {
|
||||
UIPasteboard.general.string = viewModel.display.orderNumber
|
||||
showToast("订单号已复制")
|
||||
}
|
||||
|
||||
/// 弹出Refund页面。
|
||||
private func presentRefund() {
|
||||
refundViewModel.begin(item: item)
|
||||
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
|
||||
@ -75,99 +260,34 @@ final class StoreOrderDetailViewController: UIViewController {
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 6 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0: return viewModel.contextMessage == nil ? 0 : 1
|
||||
case 1: return 7
|
||||
case 2: return 4
|
||||
case 3: return 4
|
||||
case 4: return viewModel.shootingList.count
|
||||
case 5: return 5
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
switch section {
|
||||
case 1: "订单信息"
|
||||
case 2: "支付信息"
|
||||
case 3: "客户与项目"
|
||||
case 4 where !viewModel.shootingList.isEmpty: "拍摄点"
|
||||
case 5: "后续功能"
|
||||
default: nil
|
||||
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
|
||||
switch sectionID {
|
||||
case StoreOrderDetailSectionID.orderInfo: return "订单信息"
|
||||
case StoreOrderDetailSectionID.payment: return "支付信息"
|
||||
case StoreOrderDetailSectionID.customer: return "客户与项目"
|
||||
case StoreOrderDetailSectionID.shooting: return "拍摄点"
|
||||
case StoreOrderDetailSectionID.actions: return "后续功能"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
let display = viewModel.display
|
||||
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
cell.textLabel?.text = viewModel.contextMessage
|
||||
cell.textLabel?.numberOfLines = 0
|
||||
case 1:
|
||||
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.orderNumber
|
||||
case 1: cell.detailTextLabel?.text = display.orderStatusName
|
||||
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
|
||||
case 3: cell.detailTextLabel?.text = display.createdAt
|
||||
case 4: cell.detailTextLabel?.text = displayPayTime
|
||||
case 5: cell.detailTextLabel?.text = display.completeTime
|
||||
default:
|
||||
cell.textLabel?.textColor = AppDesignUIKit.primary
|
||||
cell.selectionStyle = .default
|
||||
}
|
||||
case 2:
|
||||
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
|
||||
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
|
||||
case 2: cell.detailTextLabel?.text = display.payTypeName
|
||||
default: cell.detailTextLabel?.text = "\(display.userId)"
|
||||
}
|
||||
case 3:
|
||||
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.phone
|
||||
case 1: cell.detailTextLabel?.text = display.projectName
|
||||
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
|
||||
default: cell.detailTextLabel?.text = display.remark
|
||||
}
|
||||
case 4:
|
||||
let shooting = viewModel.shootingList[indexPath.row]
|
||||
cell.textLabel?.text = shooting.scenicSpotName
|
||||
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
|
||||
case 5:
|
||||
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
|
||||
cell.textLabel?.text = actions[indexPath.row]
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
if indexPath.row == 4, !refundViewModel.canRefund(item) {
|
||||
cell.isUserInteractionEnabled = false
|
||||
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
|
||||
}
|
||||
default: break
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return }
|
||||
guard indexPath.section == 5 else { return }
|
||||
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
|
||||
if row == StoreOrderDetailRowID.orderInfo(6) {
|
||||
copyOrderNumber()
|
||||
return
|
||||
}
|
||||
|
||||
guard row.hasPrefix("storeDetail:action:") else { return }
|
||||
let index = Int(row.split(separator: ":").last ?? "") ?? -1
|
||||
let orderNumber = viewModel.display.orderNumber
|
||||
switch indexPath.row {
|
||||
switch index {
|
||||
case 0:
|
||||
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
|
||||
case 1 where item.orderType == 19:
|
||||
@ -178,7 +298,8 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
|
||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
|
||||
case 4:
|
||||
presentRefund()
|
||||
default: break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,65 +309,79 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
|
||||
return payTime
|
||||
}
|
||||
|
||||
/// empty至Zero相关逻辑。
|
||||
private func emptyToZero(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单详情页,展示核销列表项摘要信息。
|
||||
final class WriteOffOrderDetailViewController: UIViewController {
|
||||
final class WriteOffOrderDetailViewController: SimpleTableDiffableViewController {
|
||||
|
||||
private let item: WriteOffOrderItem
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
/// 初始化实例。
|
||||
init(item: WriteOffOrderItem) {
|
||||
self.item = item
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
super.init(style: .insetGrouped)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "核销详情"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
tableView.backgroundColor = AppDesignUIKit.pageBackground
|
||||
}
|
||||
|
||||
extension WriteOffOrderDetailViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 }
|
||||
/// 构建 Diffable snapshot。
|
||||
override func buildSnapshot() -> NSDiffableDataSourceSnapshot<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 {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
/// 配置 Cell 展示内容。
|
||||
override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
|
||||
cell.selectionStyle = .none
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
switch row {
|
||||
case WriteOffDetailRowID.orderNumber:
|
||||
cell.textLabel?.text = "订单号"
|
||||
cell.detailTextLabel?.text = item.orderNumber
|
||||
case 1:
|
||||
case WriteOffDetailRowID.project:
|
||||
cell.textLabel?.text = "项目"
|
||||
cell.detailTextLabel?.text = item.projectName
|
||||
case 2:
|
||||
case WriteOffDetailRowID.phone:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = item.userPhone
|
||||
case 3:
|
||||
case WriteOffDetailRowID.amount:
|
||||
cell.textLabel?.text = "金额"
|
||||
cell.detailTextLabel?.text = "¥\(item.orderAmount)"
|
||||
case 4:
|
||||
case WriteOffDetailRowID.status:
|
||||
cell.textLabel?.text = "状态"
|
||||
cell.detailTextLabel?.text = item.orderStatusName
|
||||
default:
|
||||
case WriteOffDetailRowID.verificationTime:
|
||||
cell.textLabel?.text = "核销时间"
|
||||
cell.detailTextLabel?.text = item.orderVerificationTime
|
||||
}
|
||||
return cell
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
private let localListLabel = UILabel()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化实例。
|
||||
init(initialOrderNumber: String) {
|
||||
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -27,6 +28,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "任务上传"
|
||||
@ -36,12 +38,14 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
/// 绑定ViewModel回调或数据。
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.applyViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化Form相关 UI 或状态。
|
||||
private func setupForm() {
|
||||
orderField.borderStyle = .roundedRect
|
||||
orderField.placeholder = "关联订单号"
|
||||
@ -90,6 +94,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
/// labeled行相关逻辑。
|
||||
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
@ -100,6 +105,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
return row
|
||||
}
|
||||
|
||||
/// apply视图模型相关逻辑。
|
||||
private func applyViewModel() {
|
||||
orderField.text = viewModel.orderNumber
|
||||
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
|
||||
@ -123,14 +129,17 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
|
||||
}
|
||||
|
||||
/// order变更相关逻辑。
|
||||
@objc private func orderChanged() {
|
||||
viewModel.orderNumber = orderField.text ?? ""
|
||||
}
|
||||
|
||||
/// 刷新Spots展示。
|
||||
@objc private func refreshSpots() {
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
/// select打卡点相关逻辑。
|
||||
@objc private func selectSpot() {
|
||||
guard !viewModel.spots.isEmpty else { return }
|
||||
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
|
||||
@ -144,6 +153,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
/// pick本地Files相关逻辑。
|
||||
@objc private func pickLocalFiles() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.selectionLimit = 9
|
||||
@ -153,6 +163,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
/// 提交Tapped。
|
||||
@objc private func submitTapped() {
|
||||
Task {
|
||||
let success = await viewModel.submit(
|
||||
@ -172,6 +183,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||||
/// picker 业务逻辑。
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
@ -199,19 +211,41 @@ extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Historical Shooting Diffable 标识
|
||||
|
||||
private typealias HistoricalShootingSection = Int
|
||||
private typealias HistoricalShootingRow = String
|
||||
|
||||
/// 历史拍摄 section 索引。
|
||||
private enum HistoricalShootingSectionID {
|
||||
static let summary = 0
|
||||
static let spots = 1
|
||||
}
|
||||
|
||||
/// 历史拍摄行标识。
|
||||
private enum HistoricalShootingRowID {
|
||||
static let project = "historicalShooting:project"
|
||||
static let projectType = "historicalShooting:projectType"
|
||||
static let empty = "historicalShooting:empty"
|
||||
static func spot(_ id: String) -> String { "historicalShooting:spot:\(id)" }
|
||||
}
|
||||
|
||||
/// 历史拍摄信息页。
|
||||
final class HistoricalShootingInfoViewController: UIViewController {
|
||||
final class HistoricalShootingInfoViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let orderNumber: String
|
||||
private let viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动历史拍摄摘要与点位列表刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>!
|
||||
|
||||
/// 初始化实例。
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -220,49 +254,89 @@ final class HistoricalShootingInfoViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "历史拍摄"
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
|
||||
Task { await loadData() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<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 {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
|
||||
}
|
||||
applyTableSnapshot()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 2 : max(viewModel.spots.count, 1)
|
||||
}
|
||||
|
||||
/// UITableView 代理:section 标题。
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 1 ? "拍摄点位" : nil
|
||||
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
|
||||
if indexPath.section == 0 {
|
||||
cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型"
|
||||
cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName
|
||||
return cell
|
||||
}
|
||||
guard !viewModel.spots.isEmpty else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄"
|
||||
return cell
|
||||
}
|
||||
let spot = viewModel.spots[indexPath.row]
|
||||
cell.textLabel?.text = spot.scenicSpotName
|
||||
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
|
||||
return cell
|
||||
/// 安全下标,避免 section 越界。
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,31 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识(Int / String 避免 MainActor 默认隔离下 Hashable 冲突)
|
||||
|
||||
/// 订单列表 section 索引。
|
||||
private enum OrdersSectionID {
|
||||
static let header = 0
|
||||
static let toolbar = 1
|
||||
static let content = 2
|
||||
}
|
||||
|
||||
private typealias OrdersSection = Int
|
||||
|
||||
/// 订单列表 item 字符串标识。
|
||||
private enum OrdersItemID {
|
||||
static let header = "orders:header"
|
||||
static let filter = "orders:filter"
|
||||
static let writeOffAction = "orders:writeOffAction"
|
||||
static let missingContext = "orders:missingContext"
|
||||
static let emptyStore = "orders:empty:store"
|
||||
static let emptyWriteOff = "orders:empty:writeOff"
|
||||
static func storeOrder(_ orderNumber: String) -> String { "orders:store:\(orderNumber)" }
|
||||
static func writeOffOrder(_ orderNumber: String) -> String { "orders:writeoff:\(orderNumber)" }
|
||||
}
|
||||
|
||||
private typealias OrdersItem = String
|
||||
|
||||
/// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。
|
||||
final class OrdersViewController: UIViewController {
|
||||
|
||||
@ -13,36 +38,43 @@ final class OrdersViewController: UIViewController {
|
||||
private var manualOrderNumber = ""
|
||||
private var scanHintMessage: String?
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID)
|
||||
table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID)
|
||||
table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID)
|
||||
table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID)
|
||||
table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
||||
return table
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let layout = makeCollectionLayout()
|
||||
let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collection.backgroundColor = AppDesignUIKit.pageBackground
|
||||
collection.delegate = self
|
||||
collection.register(OrdersHeaderCell.self, forCellWithReuseIdentifier: OrdersHeaderCell.reuseID)
|
||||
collection.register(OrdersFilterCell.self, forCellWithReuseIdentifier: OrdersFilterCell.reuseID)
|
||||
collection.register(OrdersWriteOffActionCell.self, forCellWithReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
||||
collection.register(OrderEntityCell.self, forCellWithReuseIdentifier: OrderEntityCell.reuseID)
|
||||
collection.register(WriteOffOrderCell.self, forCellWithReuseIdentifier: WriteOffOrderCell.reuseID)
|
||||
collection.register(OrdersEmptyStateCell.self, forCellWithReuseIdentifier: OrdersEmptyStateCell.reuseID)
|
||||
return collection
|
||||
}()
|
||||
|
||||
private lazy var dataSource: UICollectionViewDiffableDataSource<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()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
view.addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
collectionView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.applySnapshot()
|
||||
}
|
||||
|
||||
appServices.appRouter.onChange = { [weak self] in
|
||||
@ -64,6 +96,202 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 Compositional Layout,按 section 类型选用全宽布局。
|
||||
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self else {
|
||||
return CollectionDiffableLayout.fullWidthSection()
|
||||
}
|
||||
let section = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]
|
||||
switch section {
|
||||
case OrdersSectionID.header:
|
||||
return CollectionDiffableLayout.fullWidthSection(height: 120)
|
||||
case OrdersSectionID.toolbar:
|
||||
let height: CGFloat = self.viewModel.selectedEntry == .storeOrders ? 130 : 150
|
||||
return CollectionDiffableLayout.fullWidthSection(height: height)
|
||||
case OrdersSectionID.content:
|
||||
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 88)
|
||||
default:
|
||||
return CollectionDiffableLayout.fullWidthSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前 ViewModel 与页面状态构建并应用 Diffable 快照。
|
||||
private func applySnapshot(animatingDifferences: Bool = true) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<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() {
|
||||
Task {
|
||||
await reload(showLoading: false)
|
||||
@ -75,6 +303,7 @@ final class OrdersViewController: UIViewController {
|
||||
private var currentStoreId: Int? { appServices.accountContext.currentStore?.id }
|
||||
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
||||
|
||||
/// 重新加载当前子入口对应的订单数据。
|
||||
private func reload(showLoading: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") {
|
||||
@ -91,6 +320,7 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动到底部时加载下一页。
|
||||
private func loadMore() async {
|
||||
do {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
@ -111,6 +341,7 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换订单管理 / 核销订单子入口。
|
||||
private func switchEntry(_ entry: OrdersEntry) {
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
@ -118,6 +349,7 @@ final class OrdersViewController: UIViewController {
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
/// 提交订单核销请求。
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
@ -135,12 +367,14 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 消费路由中暂存的扫码结果(核销入口)。
|
||||
private func consumePendingScanCodeIfNeeded() async {
|
||||
guard viewModel.selectedEntry == .verificationOrders,
|
||||
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
|
||||
handleScanResult(code)
|
||||
}
|
||||
|
||||
/// 解析扫码结果并引导核销或提示未匹配。
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||
showToast("未识别到有效订单号")
|
||||
@ -150,10 +384,11 @@ final class OrdersViewController: UIViewController {
|
||||
confirmVerify(orderNumber: parsed.orderNumber)
|
||||
} else {
|
||||
scanHintMessage = "扫码成功,当前列表未找到该订单"
|
||||
tableView.reloadData()
|
||||
applySnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹出核销确认对话框。
|
||||
private func confirmVerify(orderNumber: String) {
|
||||
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
@ -163,6 +398,7 @@ final class OrdersViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 打开订单号扫码页。
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self, weak scanner] result in
|
||||
@ -180,6 +416,7 @@ final class OrdersViewController: UIViewController {
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出时间筛选快捷选项。
|
||||
private func presentDateFilter() {
|
||||
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
|
||||
@ -198,6 +435,7 @@ final class OrdersViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出订单状态筛选 ActionSheet。
|
||||
private func presentStatusFilter() {
|
||||
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
|
||||
for filter in OrderFilters.statusFilters {
|
||||
@ -211,143 +449,51 @@ final class OrdersViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
extension OrdersViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
guard currentScenicId != nil else { return 1 }
|
||||
return viewModel.selectedEntry == .storeOrders ? 3 : 3
|
||||
}
|
||||
// MARK: - UICollectionViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == 0 { return 1 }
|
||||
if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 }
|
||||
if currentScenicId == nil { return 1 }
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.section == 0 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell
|
||||
let services = appServices
|
||||
cell.configure(
|
||||
selectedEntry: viewModel.selectedEntry,
|
||||
scenicName: services.accountContext.currentScenic?.name ?? "--",
|
||||
storeTotal: viewModel.storeTotal,
|
||||
writeOffTotal: viewModel.writeOffTotal,
|
||||
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if currentScenicId == nil {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(360)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if indexPath.section == 1 {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell
|
||||
let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
|
||||
cell.configure(
|
||||
statusTitle: statusTitle,
|
||||
phone: viewModel.searchPhone,
|
||||
onStatus: { [weak self] in self?.presentStatusFilter() },
|
||||
onDate: { [weak self] in self?.presentDateFilter() },
|
||||
onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text },
|
||||
onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell
|
||||
cell.configure(
|
||||
manualOrderNumber: manualOrderNumber,
|
||||
isVerifying: viewModel.isVerifying,
|
||||
hint: scanHintMessage,
|
||||
onScan: { [weak self] in self?.presentScanner() },
|
||||
onManualChange: { [weak self] text in self?.manualOrderNumber = text },
|
||||
onVerify: { [weak self] in
|
||||
guard let self, !self.manualOrderNumber.isEmpty else { return }
|
||||
self.confirmVerify(orderNumber: self.manualOrderNumber)
|
||||
}
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
if viewModel.storeOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.storeOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell
|
||||
cell.configure(item: item)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.writeOffOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.writeOffOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell
|
||||
cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 2 else { return }
|
||||
if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count {
|
||||
HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self)
|
||||
} else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count {
|
||||
HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self)
|
||||
extension OrdersViewController: UICollectionViewDelegate {
|
||||
/// 点击订单卡片进入详情。
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
if item.hasPrefix("orders:store:") {
|
||||
let orderNumber = String(item.dropFirst("orders:store:".count))
|
||||
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
||||
HomeMenuRouting.pushOrders(.storeDetail(order), from: self)
|
||||
} else if item.hasPrefix("orders:writeoff:") {
|
||||
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
||||
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
|
||||
HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 2 else { return }
|
||||
/// 列表即将展示最后一项时触发分页加载。
|
||||
func collectionView(
|
||||
_ collectionView: UICollectionView,
|
||||
willDisplay cell: UICollectionViewCell,
|
||||
forItemAt indexPath: IndexPath
|
||||
) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
let isLast: Bool
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
isLast = indexPath.row == viewModel.storeOrders.count - 1
|
||||
if item.hasPrefix("orders:store:") {
|
||||
let orderNumber = String(item.dropFirst("orders:store:".count))
|
||||
isLast = orderNumber == viewModel.storeOrders.last?.orderNumber
|
||||
} else if item.hasPrefix("orders:writeoff:") {
|
||||
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
|
||||
isLast = orderNumber == viewModel.writeOffOrders.last?.orderNumber
|
||||
} else {
|
||||
isLast = indexPath.row == viewModel.writeOffOrders.count - 1
|
||||
isLast = false
|
||||
}
|
||||
if isLast { Task { await loadMore() } }
|
||||
if isLast {
|
||||
Task { await loadMore() }
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if indexPath.section == 0 { return 120 }
|
||||
if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 }
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cells
|
||||
|
||||
private final class OrdersHeaderCell: UITableViewCell {
|
||||
/// 订单页头部 Cell,展示子入口切换与统计摘要。
|
||||
private final class OrdersHeaderCell: UICollectionViewCell {
|
||||
static let reuseID = "OrdersHeaderCell"
|
||||
private var onSelectEntry: ((OrdersEntry) -> Void)?
|
||||
|
||||
@ -356,16 +502,16 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
private let leftPill = UILabel()
|
||||
private let rightPill = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
|
||||
let segmentBackground = UIView()
|
||||
@ -410,7 +556,14 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) {
|
||||
/// 配置头部展示内容与入口切换回调。
|
||||
func configure(
|
||||
selectedEntry: OrdersEntry,
|
||||
scenicName: String,
|
||||
storeTotal: Int,
|
||||
writeOffTotal: Int,
|
||||
onSelectEntry: @escaping (OrdersEntry) -> Void
|
||||
) {
|
||||
self.onSelectEntry = onSelectEntry
|
||||
updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders)
|
||||
updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders)
|
||||
@ -423,6 +576,7 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新分段按钮选中样式。
|
||||
private func updateSegment(_ button: UIButton, title: String, selected: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)
|
||||
@ -431,26 +585,29 @@ private final class OrdersHeaderCell: UITableViewCell {
|
||||
button.layer.cornerRadius = 6
|
||||
}
|
||||
|
||||
/// 切换到订单管理入口。
|
||||
@objc private func storeTapped() { onSelectEntry?(.storeOrders) }
|
||||
/// 切换到核销订单入口。
|
||||
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
|
||||
}
|
||||
|
||||
private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
||||
/// 订单筛选栏 Cell,承载状态 / 时间与手机号搜索。
|
||||
private final class OrdersFilterCell: UICollectionViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersFilterCell"
|
||||
private let phoneField = UITextField()
|
||||
private var onPhoneChange: ((String) -> Void)?
|
||||
private var onSearch: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
phoneField.delegate = self
|
||||
phoneField.keyboardType = .phonePad
|
||||
@ -470,32 +627,44 @@ private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) {
|
||||
/// 配置筛选栏展示内容与交互回调。
|
||||
func configure(
|
||||
statusTitle: String,
|
||||
phone: String,
|
||||
onStatus: @escaping () -> Void,
|
||||
onDate: @escaping () -> Void,
|
||||
onPhoneChange: @escaping (String) -> Void,
|
||||
onSearch: @escaping () -> Void
|
||||
) {
|
||||
phoneField.text = phone
|
||||
self.onPhoneChange = onPhoneChange
|
||||
self.onSearch = onSearch
|
||||
}
|
||||
|
||||
/// 手机号输入变化时同步到 ViewModel。
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onPhoneChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate {
|
||||
/// 核销操作栏 Cell,承载扫码与手动输入核销。
|
||||
private final class OrdersWriteOffActionCell: UICollectionViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersWriteOffActionCell"
|
||||
private let manualField = UITextField()
|
||||
private var onManualChange: ((String) -> Void)?
|
||||
private var onVerify: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
|
||||
manualField.delegate = self
|
||||
manualField.placeholder = "手动输入订单号"
|
||||
@ -516,33 +685,45 @@ private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelega
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) {
|
||||
/// 配置核销操作栏展示内容与交互回调。
|
||||
func configure(
|
||||
manualOrderNumber: String,
|
||||
isVerifying: Bool,
|
||||
hint: String?,
|
||||
onScan: @escaping () -> Void,
|
||||
onManualChange: @escaping (String) -> Void,
|
||||
onVerify: @escaping () -> Void
|
||||
) {
|
||||
manualField.text = manualOrderNumber
|
||||
self.onManualChange = onManualChange
|
||||
self.onVerify = onVerify
|
||||
}
|
||||
|
||||
/// 手动订单号输入变化时同步到页面状态。
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onManualChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrderEntityCell: UITableViewCell {
|
||||
/// 订单管理列表 Cell,展示单笔订单摘要。
|
||||
private final class OrderEntityCell: UICollectionViewCell {
|
||||
static let reuseID = "OrderEntityCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .default
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||
@ -563,6 +744,7 @@ private final class OrderEntityCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置订单卡片展示内容。
|
||||
func configure(item: OrderEntity) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = item.orderStatusName
|
||||
@ -571,19 +753,23 @@ private final class OrderEntityCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private final class WriteOffOrderCell: UITableViewCell {
|
||||
/// 核销订单列表 Cell,展示单笔核销订单摘要。
|
||||
private final class WriteOffOrderCell: UICollectionViewCell {
|
||||
static let reuseID = "WriteOffOrderCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
/// 初始化 Cell 与子视图布局。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
||||
@ -597,8 +783,34 @@ private final class WriteOffOrderCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置核销订单卡片展示内容。
|
||||
func configure(item: WriteOffOrderItem, isVerifying: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单列表空态 Cell,复用通用空态视图。
|
||||
private final class OrdersEmptyStateCell: UICollectionViewCell {
|
||||
static let reuseID = "OrdersEmptyStateCell"
|
||||
private var heightConstraint: Constraint?
|
||||
|
||||
/// 初始化 Cell。
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 嵌入空态视图并设置占位高度。
|
||||
func embed(_ emptyView: UIView, preferredHeight: CGFloat) {
|
||||
contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
contentView.addSubview(emptyView)
|
||||
emptyView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
heightConstraint = make.height.equalTo(preferredHeight).constraint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +120,7 @@ final class DepositOrderListViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置State状态。
|
||||
private func resetState() {
|
||||
orders = []
|
||||
total = 0
|
||||
@ -170,6 +171,7 @@ final class DepositOrderDetailViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
@ -220,6 +222,7 @@ final class DepositOrderShootingInfoViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
@ -324,10 +327,12 @@ final class OrderRefundViewModel {
|
||||
return moneyText(value)
|
||||
}
|
||||
|
||||
/// decimal值相关逻辑。
|
||||
private static func decimalValue(_ text: String) -> Decimal {
|
||||
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
}
|
||||
|
||||
/// money文本相关逻辑。
|
||||
private static func moneyText(_ value: Decimal) -> String {
|
||||
let number = NSDecimalNumber(decimal: value)
|
||||
let handler = NSDecimalNumberHandler(
|
||||
@ -379,6 +384,7 @@ final class HistoricalShootingInfoViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 重置状态。
|
||||
private func reset(message: String) {
|
||||
projectName = ""
|
||||
projectTypeName = ""
|
||||
@ -520,6 +526,7 @@ final class MultiTravelTaskUploadViewModel {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 校验BeforeSubmit输入或状态。
|
||||
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
@ -549,6 +556,7 @@ final class MultiTravelTaskUploadViewModel {
|
||||
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
|
||||
}
|
||||
|
||||
/// upload本地Files相关逻辑。
|
||||
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
|
||||
var uploadFiles: [MultiTravelUploadFileItem] = []
|
||||
for index in selectedLocalFiles.indices {
|
||||
@ -583,11 +591,13 @@ final class MultiTravelTaskUploadViewModel {
|
||||
return uploadFiles
|
||||
}
|
||||
|
||||
/// 更新LocalFileProgress状态。
|
||||
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
||||
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
||||
selectedLocalFiles[index].progress = progress
|
||||
}
|
||||
|
||||
/// 重置SpotSelection状态。
|
||||
private func resetSpotSelection() {
|
||||
spots = []
|
||||
selectedSpotId = nil
|
||||
@ -595,11 +605,13 @@ final class MultiTravelTaskUploadViewModel {
|
||||
isLoadingSpots = false
|
||||
}
|
||||
|
||||
/// fileType相关逻辑。
|
||||
private static func fileType(for fileName: String) -> Int {
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
||||
}
|
||||
|
||||
/// Submit上下文,持有跨页面共享状态。
|
||||
private struct SubmitContext {
|
||||
let scenicId: Int
|
||||
let orderNumber: String
|
||||
|
||||
@ -18,6 +18,7 @@ final class PaymentCollectionViewController: UIViewController {
|
||||
private let remarkField = UITextField()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "收款"
|
||||
@ -27,6 +28,7 @@ final class PaymentCollectionViewController: UIViewController {
|
||||
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
/// 初始化UI相关 UI 或状态。
|
||||
private func setupUI() {
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
statusLabel.numberOfLines = 0
|
||||
@ -56,18 +58,21 @@ final class PaymentCollectionViewController: UIViewController {
|
||||
]
|
||||
}
|
||||
|
||||
/// render 业务逻辑。
|
||||
private func render() {
|
||||
qrImageView.image = viewModel.qrImage
|
||||
statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status)
|
||||
if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
|
||||
}
|
||||
|
||||
/// apply金额相关逻辑。
|
||||
@objc private func applyAmount() {
|
||||
viewModel.amountText = amountField.text ?? ""
|
||||
viewModel.remarkText = remarkField.text ?? ""
|
||||
_ = viewModel.applyDynamicAmount()
|
||||
}
|
||||
|
||||
/// togglePolling相关逻辑。
|
||||
@objc private func togglePolling() {
|
||||
Task {
|
||||
await viewModel.pollUntilPaymentDetected(
|
||||
|
||||
@ -10,9 +10,13 @@ import Foundation
|
||||
/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。
|
||||
@MainActor
|
||||
protocol PilotCertificationServing {
|
||||
/// 飞手详情相关逻辑。
|
||||
func flyerDetail() async throws -> FlyerDetailResponse
|
||||
/// 飞手Send验证码相关逻辑。
|
||||
func flyerSendCode(phone: String) async throws
|
||||
/// 飞手提交相关逻辑。
|
||||
func flyerApply(_ request: FlyerApplyRequest) async throws
|
||||
/// 飞手编辑相关逻辑。
|
||||
func flyerEdit(_ request: FlyerEditRequest) async throws
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +48,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
||||
let realnameStatusText: String
|
||||
let certificationLogs: [FlyerCertificationLogItem]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name = "flyer_nickname"
|
||||
@ -72,6 +73,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
||||
case certificationLogs = "flyers_certification_logs"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
@ -118,6 +120,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
|
||||
self.certificationLogs = certificationLogs
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -155,6 +158,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case flyerId = "flyer_id"
|
||||
@ -166,6 +170,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(
|
||||
id: Int = 0,
|
||||
flyerId: Int = 0,
|
||||
@ -186,6 +191,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 初始化实例。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
|
||||
@ -218,6 +224,7 @@ struct FlyerApplyRequest: Encodable, Equatable {
|
||||
let contactPhone: String
|
||||
let code: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case realnameStatus = "realname_status"
|
||||
@ -248,6 +255,7 @@ struct FlyerEditRequest: Encodable, Equatable {
|
||||
let contactPhone: String
|
||||
let code: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
@ -277,6 +285,7 @@ enum PilotCertificationValidationError: LocalizedError, Equatable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// pilot解码宽松字符串相关逻辑。
|
||||
func pilotDecodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -293,6 +302,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// pilot解码宽松整数相关逻辑。
|
||||
func pilotDecodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
|
||||
@ -13,6 +13,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
private let viewModel = PilotCertificationViewModel()
|
||||
private let statusLabel = UILabel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "飞手认证"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -26,6 +27,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateHeader() }
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.font = .systemFont(ofSize: 14)
|
||||
@ -34,17 +36,21 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = statusLabel
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
/// 返回列表 section 数量。
|
||||
override func numberOfTableSections() -> Int { 2 }
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
/// 返回指定 section 行数。
|
||||
override func tableRowCount(in section: Int) -> Int {
|
||||
section == 0 ? formRows.count : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
/// section 标题。
|
||||
override func tableSectionTitle(for section: Int) -> String? {
|
||||
section == 0 ? "认证信息" : "操作"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
/// 提供自定义 Cell。
|
||||
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
@ -58,8 +64,8 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
/// 处理行选中。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
if indexPath.section == 1 {
|
||||
Task {
|
||||
do {
|
||||
@ -72,11 +78,13 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI)
|
||||
updateHeader()
|
||||
}
|
||||
|
||||
/// 提交。
|
||||
@objc private func submit() {
|
||||
Task {
|
||||
do {
|
||||
@ -102,6 +110,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
|
||||
]
|
||||
}
|
||||
|
||||
/// 更新Header状态。
|
||||
private func updateHeader() {
|
||||
let status = viewModel.auditStatusText
|
||||
statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")"
|
||||
|
||||
@ -10,6 +10,7 @@ import Foundation
|
||||
@MainActor
|
||||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||||
protocol PilotRealNameServing {
|
||||
/// realNameInfo相关逻辑。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse
|
||||
}
|
||||
|
||||
@ -167,6 +168,7 @@ final class PilotCertificationViewModel {
|
||||
flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 }
|
||||
}
|
||||
|
||||
/// apply 业务逻辑。
|
||||
private func apply(_ flyer: FlyerDetailResponse) {
|
||||
self.flyer = flyer
|
||||
name = flyer.name
|
||||
@ -183,6 +185,7 @@ final class PilotCertificationViewModel {
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
/// upload待处理Certificate图片相关逻辑。
|
||||
private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||||
guard let data = pendingCertificateImageData else { return }
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
@ -203,6 +206,7 @@ final class PilotCertificationViewModel {
|
||||
pendingCertificateFileName = nil
|
||||
}
|
||||
|
||||
/// 创建ApplyRequest实例。
|
||||
private func makeApplyRequest() -> FlyerApplyRequest {
|
||||
FlyerApplyRequest(
|
||||
name: name.trimmedForPilot,
|
||||
@ -219,6 +223,7 @@ final class PilotCertificationViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建EditRequest实例。
|
||||
private func makeEditRequest() -> FlyerEditRequest {
|
||||
let apply = makeApplyRequest()
|
||||
return FlyerEditRequest(
|
||||
@ -237,6 +242,7 @@ final class PilotCertificationViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// statusName相关逻辑。
|
||||
private static func statusName(for status: Int) -> String {
|
||||
switch status {
|
||||
case 1: "审核中"
|
||||
@ -254,6 +260,7 @@ final class PilotCertificationViewModel {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// date 业务逻辑。
|
||||
private static func date(from value: String) -> Date? {
|
||||
let text = String(value.prefix(10))
|
||||
guard !text.isEmpty else { return nil }
|
||||
@ -261,9 +268,11 @@ final class PilotCertificationViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// PilotEmptyRealName服务协议,定义模块对外能力。
|
||||
private struct PilotEmptyRealNameServing: PilotRealNameServing {
|
||||
let info: RealNameInfo?
|
||||
|
||||
/// realNameInfo相关逻辑。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
RealNameInfoResponse(realNameInfo: info)
|
||||
}
|
||||
|
||||
@ -6,8 +6,18 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
private typealias AccountSwitchSection = Int
|
||||
private typealias AccountSwitchItem = String
|
||||
|
||||
/// 账号切换列表空态标识。
|
||||
private enum AccountSwitchItemID {
|
||||
static let empty = "accountSwitch:empty"
|
||||
}
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
final class AccountSwitchViewController: UIViewController {
|
||||
final class AccountSwitchViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let viewModel = AccountSwitchViewModel()
|
||||
|
||||
@ -15,7 +25,6 @@ final class AccountSwitchViewController: UIViewController {
|
||||
let table = UITableView(frame: .zero, style: .plain)
|
||||
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
||||
return table
|
||||
@ -27,11 +36,16 @@ final class AccountSwitchViewController: UIViewController {
|
||||
return button
|
||||
}()
|
||||
|
||||
/// Diffable 数据源,驱动账号列表与选中态刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "账号切换"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(confirmButton)
|
||||
tableView.snp.makeConstraints { make in
|
||||
@ -45,18 +59,75 @@ final class AccountSwitchViewController: UIViewController {
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.applyTableSnapshot(reconfigure: true)
|
||||
self?.updateConfirmButton()
|
||||
}
|
||||
Task { await loadAccounts() }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<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() {
|
||||
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||
confirmButton.isEnabled = enabled
|
||||
confirmButton.alpha = enabled ? 1 : 0.5
|
||||
}
|
||||
|
||||
/// 加载Accounts数据。
|
||||
private func loadAccounts(force: Bool = false) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
|
||||
@ -82,10 +153,12 @@ final class AccountSwitchViewController: UIViewController {
|
||||
return current.userId.isEmpty ? nil : current.userId
|
||||
}
|
||||
|
||||
/// 判断isCurrentAccount条件。
|
||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||
}
|
||||
|
||||
/// 点击confirm的处理逻辑。
|
||||
@objc private func confirmTapped() {
|
||||
guard let account = viewModel.selectedAccount else { return }
|
||||
if account.isCurrent || isCurrentAccount(account) {
|
||||
@ -95,6 +168,7 @@ final class AccountSwitchViewController: UIViewController {
|
||||
Task { await switchAccount(account) }
|
||||
}
|
||||
|
||||
/// switch账号相关逻辑。
|
||||
private func switchAccount(_ account: AccountSwitchAccount) async {
|
||||
do {
|
||||
let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
|
||||
@ -121,53 +195,29 @@ final class AccountSwitchViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// non空相关逻辑。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.accounts.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(
|
||||
title: "暂无可切换账号",
|
||||
message: "当前登录账号下没有其他可切换账号。",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark"
|
||||
)
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
|
||||
let account = viewModel.accounts[indexPath.row]
|
||||
cell.configure(
|
||||
account: account,
|
||||
selected: viewModel.selectedAccountId == account.id,
|
||||
isCurrent: account.isCurrent || isCurrentAccount(account)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.row < viewModel.accounts.count else { return }
|
||||
viewModel.select(viewModel.accounts[indexPath.row])
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath),
|
||||
item != AccountSwitchItemID.empty,
|
||||
let account = viewModel.accounts.first(where: { $0.id == item }) else { return }
|
||||
viewModel.select(account)
|
||||
}
|
||||
|
||||
/// UITableView 代理:行高。
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
viewModel.accounts.isEmpty ? 280 : 92
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return 92 }
|
||||
return item == AccountSwitchItemID.empty ? 280 : 92
|
||||
}
|
||||
}
|
||||
|
||||
/// AccountSwitch列表或网格 Cell,负责单项内容展示。
|
||||
private final class AccountSwitchCell: UITableViewCell {
|
||||
static let reuseID = "AccountSwitchCell"
|
||||
|
||||
@ -178,6 +228,7 @@ private final class AccountSwitchCell: UITableViewCell {
|
||||
private let tagLabel = UILabel()
|
||||
private let checkView = UIImageView()
|
||||
|
||||
/// 初始化实例。
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
@ -245,6 +296,7 @@ private final class AccountSwitchCell: UITableViewCell {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 配置展示内容。
|
||||
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
||||
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
titleLabel.text = title.isEmpty ? account.accountTypeLabel : title
|
||||
|
||||
@ -7,8 +7,29 @@ import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
private typealias ProfileTableSection = Int
|
||||
private typealias ProfileTableRow = String
|
||||
|
||||
/// 个人信息页 section 索引。
|
||||
private enum ProfileSectionID {
|
||||
static let info = 0
|
||||
static let logout = 1
|
||||
}
|
||||
|
||||
/// 个人信息页行标识。
|
||||
private enum ProfileRowID {
|
||||
static let name = "profile:name"
|
||||
static let account = "profile:account"
|
||||
static let phone = "profile:phone"
|
||||
static let realName = "profile:realName"
|
||||
static let settings = "profile:settings"
|
||||
static let logout = "profile:logout"
|
||||
}
|
||||
|
||||
/// 个人信息页,展示用户资料、账号状态和设置入口。
|
||||
final class ProfileViewController: UIViewController {
|
||||
final class ProfileViewController: UIViewController, UITableViewDelegate {
|
||||
|
||||
private let viewModel = ProfileViewModel()
|
||||
|
||||
@ -19,18 +40,22 @@ final class ProfileViewController: UIViewController {
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
/// Diffable 数据源,驱动个人信息列表刷新。
|
||||
private var tableDataSource: UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>!
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "个人信息"
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
setupHeader()
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
|
||||
@ -49,6 +74,74 @@ final class ProfileViewController: UIViewController {
|
||||
Task { await reloadProfile(showToast: false) }
|
||||
}
|
||||
|
||||
/// 配置 Diffable 数据源。
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource<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() {
|
||||
let header = UIView()
|
||||
header.backgroundColor = .white
|
||||
@ -101,6 +194,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// apply视图模型相关逻辑。
|
||||
private func applyViewModel() {
|
||||
nicknameLabel.text = viewModel.displayNickname
|
||||
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
|
||||
@ -111,9 +205,10 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
|
||||
editButton.isEnabled = !viewModel.isSaving
|
||||
tableView.reloadData()
|
||||
applyTableSnapshot(animated: false, reconfigure: true)
|
||||
}
|
||||
|
||||
/// 刷新Pulled展示。
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reloadProfile(showToast: true)
|
||||
@ -121,6 +216,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Profile。
|
||||
private func reloadProfile(showToast: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
|
||||
@ -131,6 +227,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击edit的处理逻辑。
|
||||
@objc private func editTapped() {
|
||||
if viewModel.isEditingProfile {
|
||||
Task { await saveProfileEdits() }
|
||||
@ -140,6 +237,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 弹出NicknameEditor页面。
|
||||
private func presentNicknameEditor() {
|
||||
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { [weak self] field in
|
||||
@ -157,6 +255,7 @@ final class ProfileViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// save个人中心Edits相关逻辑。
|
||||
private func saveProfileEdits() async {
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
@ -176,6 +275,7 @@ final class ProfileViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// pick头像相关逻辑。
|
||||
@objc private func pickAvatar() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
@ -185,6 +285,7 @@ final class ProfileViewController: UIViewController {
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
/// 点击logout的处理逻辑。
|
||||
@objc private func logoutTapped() {
|
||||
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
@ -201,58 +302,19 @@ final class ProfileViewController: UIViewController {
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 5 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = "退出登录"
|
||||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||||
cell.textLabel?.textAlignment = .center
|
||||
return cell
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.textLabel?.text = "姓名"
|
||||
cell.detailTextLabel?.text = viewModel.displayRealName
|
||||
case 1:
|
||||
cell.textLabel?.text = "当前账号"
|
||||
cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
case 2:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = viewModel.displayPhone
|
||||
case 3:
|
||||
cell.textLabel?.text = "实名认证"
|
||||
cell.detailTextLabel?.text = viewModel.realNameStatusText
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
default:
|
||||
cell.textLabel?.text = "设置"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default
|
||||
return cell
|
||||
}
|
||||
|
||||
/// UITableView 代理:处理行选中。
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1 {
|
||||
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch row {
|
||||
case ProfileRowID.logout:
|
||||
logoutTapped()
|
||||
return
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 1:
|
||||
case ProfileRowID.account:
|
||||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
||||
case 3:
|
||||
case ProfileRowID.realName:
|
||||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
||||
case 4:
|
||||
case ProfileRowID.settings:
|
||||
HomeMenuRouting.pushProfile(.settings, from: self)
|
||||
default:
|
||||
break
|
||||
@ -261,6 +323,7 @@ extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
}
|
||||
|
||||
extension ProfileViewController: PHPickerViewControllerDelegate {
|
||||
/// picker 业务逻辑。
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
|
||||
@ -25,6 +25,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
private let startDatePicker = UIDatePicker()
|
||||
private let endDatePicker = UIDatePicker()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "实名认证"
|
||||
@ -34,6 +35,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
Task { await loadInfo() }
|
||||
}
|
||||
|
||||
/// 初始化Form相关 UI 或状态。
|
||||
private func setupForm() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
@ -107,6 +109,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
contentStack.addArrangedSubview(submitButton)
|
||||
}
|
||||
|
||||
/// 创建SectionTitle实例。
|
||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
@ -114,6 +117,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
return label
|
||||
}
|
||||
|
||||
/// labeled字段相关逻辑。
|
||||
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
@ -125,6 +129,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
return stack
|
||||
}
|
||||
|
||||
/// apply视图模型相关逻辑。
|
||||
private func applyViewModel() {
|
||||
realNameField.text = viewModel.realName
|
||||
idCardField.text = viewModel.idCardNo
|
||||
@ -163,6 +168,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载Info数据。
|
||||
private func loadInfo() async {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "加载中...") {
|
||||
@ -173,10 +179,12 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// longValid变更相关逻辑。
|
||||
@objc private func longValidChanged() {
|
||||
viewModel.isLongValid = longValidSwitch.isOn
|
||||
}
|
||||
|
||||
/// 点击send验证码的处理逻辑。
|
||||
@objc private func sendCodeTapped() {
|
||||
Task {
|
||||
do {
|
||||
@ -187,6 +195,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交Tapped。
|
||||
@objc private func submitTapped() {
|
||||
viewModel.realName = realNameField.text ?? ""
|
||||
viewModel.idCardNo = idCardField.text ?? ""
|
||||
@ -215,16 +224,19 @@ final class RealNameAuthViewController: UIViewController {
|
||||
|
||||
private var pendingSide: RealNameImageSide = .front
|
||||
|
||||
/// pickFront相关逻辑。
|
||||
@objc private func pickFront() {
|
||||
pendingSide = .front
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
/// pickBack相关逻辑。
|
||||
@objc private func pickBack() {
|
||||
pendingSide = .back
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
/// 弹出ImagePicker页面。
|
||||
private func presentImagePicker() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
@ -236,6 +248,7 @@ final class RealNameAuthViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||
/// picker 业务逻辑。
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
@ -253,6 +266,7 @@ extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
/// 初始化实例。
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
|
||||
@ -7,64 +7,81 @@ import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// 设置中心页面。
|
||||
final class SettingsViewController: UIViewController {
|
||||
// MARK: - Diffable 标识
|
||||
|
||||
private var copiedDownloadLink = false
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private let rows: [(title: String, action: SettingsRowAction)] = [
|
||||
("关于我们", .agreement(.about)),
|
||||
("系统版本", .version),
|
||||
("App下载", .download),
|
||||
("用户协议", .agreement(.userAgreement)),
|
||||
("隐私政策", .agreement(.privacyPolicy))
|
||||
]
|
||||
/// 设置页行标识。
|
||||
private 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: SimpleTableDiffableViewController {
|
||||
|
||||
private var copiedDownloadLink = false
|
||||
|
||||
private let rowActions: [String: SettingsRowAction] = [
|
||||
SettingsRowID.about: .agreement(.about),
|
||||
SettingsRowID.version: .version,
|
||||
SettingsRowID.download: .download,
|
||||
SettingsRowID.userAgreement: .agreement(.userAgreement),
|
||||
SettingsRowID.privacyPolicy: .agreement(.privacyPolicy)
|
||||
]
|
||||
|
||||
private let rowTitles: [String: String] = [
|
||||
SettingsRowID.about: "关于我们",
|
||||
SettingsRowID.version: "系统版本",
|
||||
SettingsRowID.download: "App下载",
|
||||
SettingsRowID.userAgreement: "用户协议",
|
||||
SettingsRowID.privacyPolicy: "隐私政策"
|
||||
]
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "设置"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
/// 构建 Diffable snapshot。
|
||||
override func buildSnapshot() -> NSDiffableDataSourceSnapshot<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() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
showToast("下载链接已复制")
|
||||
tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.copiedDownloadLink = false
|
||||
self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
}
|
||||
}
|
||||
/// section 页脚文案。
|
||||
override func sectionFooter(for section: SimpleTableSection) -> String? {
|
||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
}
|
||||
|
||||
extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
let row = rows[indexPath.row]
|
||||
cell.textLabel?.text = row.title
|
||||
/// 配置 Cell 展示内容。
|
||||
override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
|
||||
cell.textLabel?.text = rowTitles[row]
|
||||
cell.textLabel?.textColor = UIColor(hex: 0x4B5563)
|
||||
switch row.action {
|
||||
cell.detailTextLabel?.text = nil
|
||||
cell.detailTextLabel?.textColor = nil
|
||||
cell.accessoryType = .none
|
||||
cell.selectionStyle = .default
|
||||
|
||||
guard let action = rowActions[row] else { return }
|
||||
switch action {
|
||||
case .version:
|
||||
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
|
||||
cell.selectionStyle = .none
|
||||
@ -74,12 +91,12 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
case .agreement:
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch rows[indexPath.row].action {
|
||||
/// 处理行选中。
|
||||
override func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {
|
||||
guard let action = rowActions[row] else { return }
|
||||
switch action {
|
||||
case .agreement(let page):
|
||||
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
||||
case .download:
|
||||
@ -89,8 +106,25 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
}
|
||||
|
||||
/// 复制下载链接并刷新对应行展示。
|
||||
private func copyDownloadLink() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
showToast("下载链接已复制")
|
||||
var snapshot = tableDataSource.snapshot()
|
||||
snapshot.reconfigureItems([SettingsRowID.download])
|
||||
tableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.copiedDownloadLink = false
|
||||
var snapshot = self.tableDataSource.snapshot()
|
||||
snapshot.reconfigureItems([SettingsRowID.download])
|
||||
self.tableDataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,6 +135,7 @@ final class AgreementViewController: UIViewController {
|
||||
private let webView = WKWebView(frame: .zero)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
/// 初始化实例。
|
||||
init(page: AgreementPage) {
|
||||
self.page = page
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -109,6 +144,7 @@ final class AgreementViewController: UIViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = page.title
|
||||
@ -124,10 +160,12 @@ final class AgreementViewController: UIViewController {
|
||||
}
|
||||
|
||||
extension AgreementViewController: WKNavigationDelegate {
|
||||
/// web视图相关逻辑。
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
/// web视图相关逻辑。
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadingIndicator.stopAnimating()
|
||||
showToast(error.localizedDescription)
|
||||
|
||||
@ -11,6 +11,7 @@ import UIKit
|
||||
final class ProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = ProjectManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "项目管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -23,13 +24,16 @@ final class ProjectManagementViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
@ -38,10 +42,12 @@ final class ProjectManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
/// 创建项目。
|
||||
@objc private func createProject() {
|
||||
navigationController?.pushViewController(
|
||||
ProjectEditorViewController(projectId: nil),
|
||||
@ -56,19 +62,23 @@ extension ProjectManagementViewModel: ViewModelBindable {}
|
||||
final class StoreProjectManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectManagementViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.filteredItems.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)")
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.filteredItems[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
@ -77,6 +87,7 @@ final class StoreProjectManagementViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(api: services.projectAPI, userId: services.userId)
|
||||
}
|
||||
@ -91,6 +102,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
private let photographerViewModel = ProjectManagementViewModel()
|
||||
private let storeViewModel = StoreProjectManagementViewModel()
|
||||
|
||||
/// 初始化实例。
|
||||
init(projectId: Int, storeMode: Bool) {
|
||||
self.projectId = projectId
|
||||
self.storeMode = storeMode
|
||||
@ -102,6 +114,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "项目详情"
|
||||
super.viewDidLoad()
|
||||
@ -116,10 +129,12 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
detail == nil ? 0 : 4
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
guard let detail else { return }
|
||||
switch indexPath.row {
|
||||
@ -130,6 +145,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
if storeMode {
|
||||
await storeViewModel.loadDetail(id: projectId, api: services.projectAPI)
|
||||
@ -147,6 +163,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
private let priceField = UITextField()
|
||||
private let projectId: Int?
|
||||
|
||||
/// 初始化实例。
|
||||
init(projectId: Int?) {
|
||||
self.projectId = projectId
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
@ -157,6 +174,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = projectId == nil ? "新建项目" : "编辑项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -170,6 +188,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() }
|
||||
}
|
||||
|
||||
/// 初始化Header相关 UI 或状态。
|
||||
private func setupHeader() {
|
||||
nameField.placeholder = "项目名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
@ -187,8 +206,10 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { 0 }
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
guard let projectId else { return }
|
||||
if let detail = try? await services.projectAPI.projectDetail(id: projectId) {
|
||||
@ -197,12 +218,14 @@ final class ProjectEditorViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需fill表单。
|
||||
private func fillFormIfNeeded() {
|
||||
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
||||
if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText }
|
||||
if priceField.text?.isEmpty != false { priceField.text = viewModel.price }
|
||||
}
|
||||
|
||||
/// save 业务逻辑。
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
viewModel.descriptionText = descriptionField.text ?? ""
|
||||
@ -231,6 +254,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
private let viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
private let nameField = UITextField()
|
||||
|
||||
/// 初始化实例。
|
||||
init(projectId: Int?) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
@ -240,6 +264,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "店铺项目"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -256,18 +281,22 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.scenicList.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let scenic = viewModel.scenicList[indexPath.row]
|
||||
cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
guard let userId = Int(services.userId ?? "") else { return }
|
||||
await viewModel.loadScenicList(api: services.projectAPI, userId: userId)
|
||||
}
|
||||
|
||||
/// save 业务逻辑。
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
Task {
|
||||
|
||||
@ -130,6 +130,7 @@ final class ProjectManagementViewModel {
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
/// 重置List状态。
|
||||
private func resetList() {
|
||||
items = []
|
||||
loading = false
|
||||
@ -139,6 +140,7 @@ final class ProjectManagementViewModel {
|
||||
total = 0
|
||||
}
|
||||
|
||||
/// sorted 业务逻辑。
|
||||
private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||
values.sorted { lhs, rhs in
|
||||
if lhs.status != rhs.status { return lhs.status < rhs.status }
|
||||
@ -146,6 +148,7 @@ final class ProjectManagementViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// deduplicated 业务逻辑。
|
||||
private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
|
||||
var result: [Int: PhotographerProjectItem] = [:]
|
||||
values.forEach { result[$0.id] = $0 }
|
||||
@ -278,6 +281,7 @@ final class ProjectEditorViewModel {
|
||||
return labels.isEmpty ? nil : labels
|
||||
}
|
||||
|
||||
/// resolveCover链接相关逻辑。
|
||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||
if let coverImage {
|
||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in
|
||||
@ -287,6 +291,7 @@ final class ProjectEditorViewModel {
|
||||
return existingCoverURL
|
||||
}
|
||||
|
||||
/// resolveCarouselURLs相关逻辑。
|
||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||
var urls = existingCarouselURLs
|
||||
for image in carouselImages {
|
||||
@ -298,11 +303,13 @@ final class ProjectEditorViewModel {
|
||||
return urls
|
||||
}
|
||||
|
||||
/// 规范化dMoney格式。
|
||||
private func normalizedMoney(_ value: String) -> String? {
|
||||
let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||
return amount > 0 ? String(format: "%.2f", amount) : nil
|
||||
}
|
||||
|
||||
/// 记录校验失败并返回 false。
|
||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
@ -511,6 +518,7 @@ final class StoreProjectEditorViewModel {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// resolveCover链接相关逻辑。
|
||||
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
|
||||
if let coverImage {
|
||||
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in }
|
||||
@ -518,6 +526,7 @@ final class StoreProjectEditorViewModel {
|
||||
return existingCoverURL
|
||||
}
|
||||
|
||||
/// resolveCarouselURLs相关逻辑。
|
||||
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
|
||||
var urls = existingCarouselURLs
|
||||
for image in carouselImages {
|
||||
@ -527,6 +536,7 @@ final class StoreProjectEditorViewModel {
|
||||
return urls
|
||||
}
|
||||
|
||||
/// 提交MultiPointProject。
|
||||
private func submitMultiPointProject(
|
||||
userId: Int,
|
||||
api: any ProjectServing,
|
||||
@ -586,6 +596,7 @@ final class StoreProjectEditorViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交OfflineProject。
|
||||
private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws {
|
||||
let scenicId = selectedScenicIds.first ?? 0
|
||||
let storeId = selectedStoreId ?? 0
|
||||
@ -617,6 +628,7 @@ final class StoreProjectEditorViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录校验失败并返回 false。
|
||||
private func fail(_ error: ProjectEditorError) -> Bool {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
|
||||
@ -36,6 +36,7 @@ struct PunchPointRegion: Codable, Hashable {
|
||||
var address: String
|
||||
var scenicSpotStr: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lat
|
||||
case lot
|
||||
@ -80,6 +81,7 @@ struct PunchPointItem: Decodable, Hashable, Identifiable {
|
||||
let auditTime: String
|
||||
let auditRemark: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
@ -167,6 +169,7 @@ struct AddPunchPointRequest: Encodable, Equatable {
|
||||
let scenicSpotStr: String
|
||||
let guideImages: [String]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case name
|
||||
@ -187,6 +190,7 @@ struct EditPunchPointRequest: Encodable, Equatable {
|
||||
let scenicSpotStr: String
|
||||
let guideImages: [String]
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -18,6 +19,7 @@ private extension PunchPointItem {
|
||||
final class PunchPointListViewController: ModuleTableViewController {
|
||||
private let viewModel = PunchPointListViewModel()
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = "打卡点"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -30,13 +32,16 @@ final class PunchPointListViewController: ModuleTableViewController {
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
|
||||
}
|
||||
|
||||
/// didSelectTableRow 回调处理。
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
@ -45,10 +50,12 @@ final class PunchPointListViewController: ModuleTableViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
|
||||
}
|
||||
|
||||
/// 创建打卡Point。
|
||||
@objc private func createPunchPoint() {
|
||||
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
|
||||
}
|
||||
@ -62,6 +69,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
private let summary: PunchPointItem?
|
||||
private let viewModel = PunchPointListViewModel()
|
||||
|
||||
/// 初始化实例。
|
||||
init(punchPointId: Int, summary: PunchPointItem?) {
|
||||
self.punchPointId = punchPointId
|
||||
self.summary = summary
|
||||
@ -73,6 +81,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
title = summary?.name ?? "打卡点详情"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
@ -88,11 +97,13 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// table行Count相关逻辑。
|
||||
override func tableRowCount() -> Int {
|
||||
guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
|
||||
return 4
|
||||
}
|
||||
|
||||
/// 配置Cell展示内容。
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let detail = viewModel.selectedDetail ?? summary
|
||||
guard let detail else { return }
|
||||
@ -104,10 +115,12 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Content。
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
|
||||
}
|
||||
|
||||
/// 展示QR。
|
||||
@objc private func showQR() {
|
||||
guard let detail = viewModel.selectedDetail ?? summary else { return }
|
||||
navigationController?.pushViewController(
|
||||
@ -118,10 +131,17 @@ final class PunchPointDetailViewController: ModuleTableViewController {
|
||||
}
|
||||
|
||||
/// 打卡点编辑页。
|
||||
final class PunchPointEditorViewController: ModuleTableViewController {
|
||||
final class PunchPointEditorViewController: UIViewController {
|
||||
private let viewModel = PunchPointEditorViewModel()
|
||||
private let nameField = UITextField()
|
||||
private let punchPointId: Int?
|
||||
private let nameField = UITextField()
|
||||
private let addressField = UITextField()
|
||||
private let latitudeField = UITextField()
|
||||
private let longitudeField = UITextField()
|
||||
private let mapPicker = PunchPointMapPickerView()
|
||||
private let locationProvider = ForegroundLocationProvider()
|
||||
|
||||
private var services: AppServices { AppServices.shared }
|
||||
|
||||
init(punchPointId: Int?) {
|
||||
self.punchPointId = punchPointId
|
||||
@ -134,48 +154,126 @@ final class PunchPointEditorViewController: ModuleTableViewController {
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "保存",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(save)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
nameField.placeholder = "打卡点名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableHeaderView = nameField
|
||||
wireViewModel(viewModel) { [weak self] in
|
||||
if self?.nameField.text?.isEmpty != false {
|
||||
self?.nameField.text = self?.viewModel.name
|
||||
}
|
||||
}
|
||||
|
||||
configureFields()
|
||||
layoutForm()
|
||||
wireViewModel()
|
||||
|
||||
mapPicker.onLocationPicked = { [weak self] lat, lng, address in
|
||||
self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address)
|
||||
self?.syncFieldsFromViewModel()
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { 1 }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
cell.configure(title: "地址", subtitle: viewModel.address)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
Task {
|
||||
guard let punchPointId else { return }
|
||||
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
|
||||
viewModel.apply(detail)
|
||||
nameField.text = viewModel.name
|
||||
syncFieldsFromViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureFields() {
|
||||
[nameField, addressField, latitudeField, longitudeField].forEach {
|
||||
$0.borderStyle = .roundedRect
|
||||
$0.font = .systemFont(ofSize: AppMetrics.FontSize.body)
|
||||
}
|
||||
nameField.placeholder = "打卡点名称"
|
||||
addressField.placeholder = "地址"
|
||||
latitudeField.placeholder = "纬度"
|
||||
longitudeField.placeholder = "经度"
|
||||
latitudeField.keyboardType = .decimalPad
|
||||
longitudeField.keyboardType = .decimalPad
|
||||
}
|
||||
|
||||
private func layoutForm() {
|
||||
let locateButton = UIButton(type: .system)
|
||||
locateButton.setTitle("使用当前位置", for: .normal)
|
||||
locateButton.addTarget(self, action: #selector(useCurrentLocation), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
nameField,
|
||||
mapPicker,
|
||||
addressField,
|
||||
latitudeField,
|
||||
longitudeField,
|
||||
locateButton
|
||||
])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
|
||||
view.addSubview(stack)
|
||||
nameField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
mapPicker.snp.makeConstraints { make in make.height.equalTo(220) }
|
||||
addressField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
latitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
longitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
locateButton.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.medium)
|
||||
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func wireViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.syncFieldsFromViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
private func syncFieldsFromViewModel() {
|
||||
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
|
||||
addressField.text = viewModel.address
|
||||
latitudeField.text = viewModel.latitudeText
|
||||
longitudeField.text = viewModel.longitudeText
|
||||
if let lat = Double(viewModel.latitudeText), let lng = Double(viewModel.longitudeText) {
|
||||
mapPicker.setCoordinate(latitude: lat, longitude: lng)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func useCurrentLocation() {
|
||||
Task {
|
||||
do {
|
||||
let result = try await locationProvider.requestCurrentLocation()
|
||||
viewModel.applyLocation(
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
address: result.address
|
||||
)
|
||||
syncFieldsFromViewModel()
|
||||
} catch {
|
||||
services.toastCenter.show("定位失败:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func save() {
|
||||
viewModel.name = nameField.text ?? ""
|
||||
viewModel.address = addressField.text ?? ""
|
||||
viewModel.latitudeText = latitudeField.text ?? ""
|
||||
viewModel.longitudeText = longitudeField.text ?? ""
|
||||
Task {
|
||||
let success = await viewModel.submit(
|
||||
scenicId: services.currentScenicId,
|
||||
api: services.punchPointAPI,
|
||||
uploadService: services.ossUploadService
|
||||
)
|
||||
if success { navigationController?.popViewController(animated: true) }
|
||||
if success {
|
||||
services.toastCenter.show("保存成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.errorMessage {
|
||||
services.toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -187,6 +285,7 @@ final class PunchPointQRViewController: UIViewController {
|
||||
private let pageTitle: String
|
||||
private let qrURL: String
|
||||
|
||||
/// 初始化实例。
|
||||
init(title: String, qrURL: String) {
|
||||
pageTitle = title
|
||||
self.qrURL = qrURL
|
||||
@ -198,6 +297,7 @@ final class PunchPointQRViewController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后的 UI 初始化与数据绑定。
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = pageTitle
|
||||
|
||||
@ -10,17 +10,29 @@ import Foundation
|
||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||
@MainActor
|
||||
protocol ScenicQueueServing: AnyObject {
|
||||
/// scenic排队统计相关逻辑。
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData
|
||||
/// scenic排队Home相关逻辑。
|
||||
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
|
||||
/// socket令牌相关逻辑。
|
||||
func socketToken() async throws -> SocketTokenResponse
|
||||
/// scenic排队Call相关逻辑。
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
|
||||
/// scenic排队Pass相关逻辑。
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
|
||||
/// scenic排队Finish相关逻辑。
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
|
||||
/// scenic排队RequeueInsertBefore相关逻辑。
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
|
||||
/// scenic排队UserMark相关逻辑。
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
|
||||
/// scenic排队Setting相关逻辑。
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
|
||||
/// scenic排队保存Setting相关逻辑。
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
|
||||
/// scenic排队Shoot排队QRCode相关逻辑。
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
|
||||
/// scenic排队SettingChangeLog相关逻辑。
|
||||
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ struct ScenicQueueStatsData: Decodable, Equatable {
|
||||
let avgWaitMin: Double
|
||||
let time: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case queueCount = "queue_count"
|
||||
case avgWaitMin = "avg_wait_min"
|
||||
@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
|
||||
let list: ScenicQueueHomeListBlock?
|
||||
let time: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case stats
|
||||
case list
|
||||
@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
|
||||
struct ScenicQueueHomeStats: Decodable, Equatable {
|
||||
let type: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
}
|
||||
@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable {
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case list
|
||||
case total
|
||||
@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
||||
isMissRequeue == 1
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case queueCode = "queue_code"
|
||||
@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
||||
case missRequeueText = "is_miss_requeue_text"
|
||||
}
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum AlternateCodingKeys: String, CodingKey {
|
||||
case queueNo = "queue_no"
|
||||
case queueNumber = "queue_number"
|
||||
@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
|
||||
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 格式化排队时间。
|
||||
private static func formatQueueTime(_ raw: String) -> String {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return "--" }
|
||||
@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable {
|
||||
struct SocketTokenResponse: Decodable, Equatable {
|
||||
let socketToken: String
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case socketToken = "socket_token"
|
||||
}
|
||||
@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable {
|
||||
let statusText: String
|
||||
let calledAt: String?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case status
|
||||
@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
|
||||
let recordId: Int64
|
||||
let operatorId: Int
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case recordId = "record_id"
|
||||
case operatorId = "operator_id"
|
||||
@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable {
|
||||
let queueBanDays: Int?
|
||||
let operatorId: Int?
|
||||
|
||||
/// 枚举,定义相关常量或状态。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid
|
||||
case scenicId = "scenic_id"
|
||||
@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 解码宽松字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
@ -449,6 +461,7 @@ private extension KeyedDecodingContainer {
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 解码宽松整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
@ -467,6 +480,7 @@ private extension KeyedDecodingContainer {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 解码宽松Double。
|
||||
func decodeLossyDouble(forKey key: Key) throws -> Double? {
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return value
|
||||
@ -484,6 +498,7 @@ private extension KeyedDecodingContainer {
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// non空或默认相关逻辑。
|
||||
func nonEmptyOrDefault(_ fallback: String) -> String {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? fallback : text
|
||||
|
||||