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>
This commit is contained in:
2026-06-26 15:16:12 +08:00
parent 9edf993432
commit d99a5b1bf8
124 changed files with 5195 additions and 1536 deletions

View File

@ -47,6 +47,12 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、
- View 层只负责展示与事件转发,业务逻辑放在 ViewModel - View 层只负责展示与事件转发,业务逻辑放在 ViewModel
- ViewModel 不直接持有 UIView / UIViewController 引用 - ViewModel 不直接持有 UIView / UIViewController 引用
### Swift 并发
- 工程已将编译选项 **`SWIFT_DEFAULT_ACTOR_ISOLATION`** 设置为 **`nonisolated`**(见 Xcode Build Settings
- 即类型与方法**默认不**隔离到 `@MainActor`;需要主线程/UI 相关逻辑时,再显式标注 `@MainActor` 或使用 `MainActor.assumeIsolated` / `await MainActor.run`
- 仅在确实需要脱离默认隔离(如 delegate 回调、静态工具方法)时再显式写 `nonisolated`,避免冗余标注
### 注释 ### 注释
- 定义的**类**、**结构体**、**方法**均须添加注释,说明其职责、用途或行为 - 定义的**类**、**结构体**、**方法**均须添加注释,说明其职责、用途或行为
@ -117,5 +123,6 @@ View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、
- 同步进度详见 [功能同步Checklist.md](功能同步Checklist.md) - 同步进度详见 [功能同步Checklist.md](功能同步Checklist.md)
- 参考工程:`../suixinkan_ios_new`SwiftUI - 参考工程:`../suixinkan_ios_new`SwiftUI
- 高德 Key`suixinkan_ios/Info.plist``AMapAPIKey` 填入控制台 Key真机地图生效
- 构建:`xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios build` - 构建:`xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios build`
- 测试:`xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios -destination 'platform=iOS Simulator,name=iPhone 17'` - 测试:`xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios -destination 'platform=iOS Simulator,name=iPhone 17'`

75
Podfile
View File

@ -6,15 +6,90 @@ target 'suixinkan_ios' do
pod 'SnapKit', '~> 5.7' pod 'SnapKit', '~> 5.7'
pod 'Kingfisher', '~> 8.0' pod 'Kingfisher', '~> 8.0'
# 高德地图 SDK仅真机构建时链接模拟器通过 post_install 剥离
pod 'AMap3DMap-NO-IDFA', '~> 11.1'
pod 'AMapSearch-NO-IDFA', '~> 9.7'
pod 'AMapLocation-NO-IDFA', '~> 2.11'
target 'suixinkan_iosTests' do target 'suixinkan_iosTests' do
inherit! :search_paths inherit! :search_paths
end end
end end
SIMULATOR_OTHER_LDFLAGS = <<~FLAGS.squish
-ObjC -l"c++" -l"z"
-framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony"
-framework "CoreText" -framework "ExternalAccessory" -framework "GLKit"
-framework "OpenGLES" -framework "QuartzCore" -framework "Security"
-framework "SystemConfiguration"
FLAGS
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
end end
end end
%w[Pods-suixinkan_ios Pods-suixinkan_iosTests].each do |target_name|
support_dir = File.join(installer.sandbox.root, 'Target Support Files', target_name)
Dir.glob(File.join(support_dir, '*.xcconfig')).each do |path|
content = File.read(path)
content.gsub!("EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64\n", '')
unless content.include?('OTHER_LDFLAGS[sdk=iphonesimulator*]')
content << "\nOTHER_LDFLAGS[sdk=iphonesimulator*] = #{SIMULATOR_OTHER_LDFLAGS}\n"
content << "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*] = $(inherited)\n"
end
File.write(path, content)
end
end
installer.pods_project.targets.each do |target|
next unless target.name.start_with?('AMap')
target.build_configurations.each do |config|
config.build_settings['SUPPORTED_PLATFORMS'] = 'iphoneos'
end
end
resources_script = File.join(
installer.sandbox.root,
'Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh'
)
if File.exist?(resources_script)
content = File.read(resources_script)
unless content.include?('SKIP_AMAP_RESOURCES_FOR_SIMULATOR')
content.sub!(
"RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt",
<<~SHELL
# SKIP_AMAP_RESOURCES_FOR_SIMULATOR
if [ "${EFFECTIVE_PLATFORM_NAME}" = "-iphonesimulator" ]; then
exit 0
fi
RESOURCES_TO_COPY=${PODS_ROOT}/resources-to-copy-${TARGETNAME}.txt
SHELL
)
File.write(resources_script, content)
end
end
installer.aggregate_targets.each do |aggregate_target|
aggregate_target.user_project.native_targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = ''
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
next unless target.name == 'suixinkan_ios'
existing = config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] || '$(inherited)'
unless existing.to_s.include?('AMAP_ENABLED')
config.build_settings['SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]'] = "#{existing} AMAP_ENABLED"
end
end
end
aggregate_target.user_project.save
end
end end

View File

@ -1,20 +1,38 @@
PODS: PODS:
- AMap3DMap-NO-IDFA (11.2.000):
- AMapFoundation-NO-IDFA (>= 1.9.0)
- AMapFoundation-NO-IDFA (1.9.0)
- AMapLocation-NO-IDFA (2.12.0):
- AMapFoundation-NO-IDFA (>= 1.9.0)
- AMapSearch-NO-IDFA (9.8.0):
- AMapFoundation-NO-IDFA (>= 1.9.0)
- Kingfisher (8.10.0) - Kingfisher (8.10.0)
- SnapKit (5.7.1) - SnapKit (5.7.1)
DEPENDENCIES: DEPENDENCIES:
- AMap3DMap-NO-IDFA (~> 11.1)
- AMapLocation-NO-IDFA (~> 2.11)
- AMapSearch-NO-IDFA (~> 9.7)
- Kingfisher (~> 8.0) - Kingfisher (~> 8.0)
- SnapKit (~> 5.7) - SnapKit (~> 5.7)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- AMap3DMap-NO-IDFA
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA
- Kingfisher - Kingfisher
- SnapKit - SnapKit
SPEC CHECKSUMS: SPEC CHECKSUMS:
AMap3DMap-NO-IDFA: f53ee0cb33db83c1a08856b5995e53a0c2096158
AMapFoundation-NO-IDFA: a2e3c895398d7ee757278e1a0a8f9359da4b146e
AMapLocation-NO-IDFA: 7cd8fc837ea41edfbf4d937cd20572e277b77d18
AMapSearch-NO-IDFA: c0afd2a69a076d4228becda4401dbe4a279a03ef
Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
PODFILE CHECKSUM: dda5ec1556392b8b6a9747637c579bd75423177c PODFILE CHECKSUM: 8f89212788a9d27c878d93b811a6eaa818955f8c
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View 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())

View 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())

View File

@ -134,6 +134,7 @@
9331714B2FEE49BE00240726 /* Frameworks */, 9331714B2FEE49BE00240726 /* Frameworks */,
9331714C2FEE49BE00240726 /* Resources */, 9331714C2FEE49BE00240726 /* Resources */,
FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */, FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */,
367681C4AC079C922280F774 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@ -234,6 +235,27 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
367681C4AC079C922280F774 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-resources.sh\"\n";
showEnvVarsInLog = 0;
};
A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = { A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -337,6 +359,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = suixinkan_ios/Info.plist; INFOPLIST_FILE = suixinkan_ios/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@ -356,8 +379,9 @@
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED";
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -375,6 +399,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = suixinkan_ios/Info.plist; INFOPLIST_FILE = suixinkan_ios/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@ -394,8 +419,9 @@
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos*]" = "$(inherited) AMAP_ENABLED";
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -533,6 +559,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -549,7 +576,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -568,6 +595,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -584,7 +612,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

@ -13,8 +13,9 @@ import AMapLocationKit
import AMapSearchKit import AMapSearchKit
import MAMapKit import MAMapKit
/// SDK Key
enum AMapBootstrap { enum AMapBootstrap {
/// App `apiKey` Key /// App API Key
static func configure(apiKey: String) { static func configure(apiKey: String) {
guard !apiKey.isEmpty else { return } guard !apiKey.isEmpty else { return }
@ -30,7 +31,9 @@ enum AMapBootstrap {
} }
} }
#else #else
/// SDK
enum AMapBootstrap { enum AMapBootstrap {
///
static func configure(apiKey: String) {} static func configure(apiKey: String) {}
} }
#endif #endif

View File

@ -67,6 +67,7 @@ final class AppServices {
accountContext.profile?.userId accountContext.profile?.userId
} }
///
private init() { private init() {
let client = APIClient() let client = APIClient()
let tokenStore = SessionTokenStore() let tokenStore = SessionTokenStore()

View File

@ -8,7 +8,7 @@
#if DEBUG #if DEBUG
import UIKit import UIKit
/// UI Test TabBar XCUITest push /// UI Test Tab push
@MainActor @MainActor
enum AppUITestRouteDriver { enum AppUITestRouteDriver {
private static var didApply = false private static var didApply = false

View File

@ -164,6 +164,7 @@ enum AppRouteViewControllerFactory {
} }
} }
/// URI
private static func homeRouteURI(_ route: HomeRoute) -> String { private static func homeRouteURI(_ route: HomeRoute) -> String {
if case let .modulePlaceholder(uri, _) = route { if case let .modulePlaceholder(uri, _) = route {
return uri return uri
@ -175,6 +176,7 @@ enum AppRouteViewControllerFactory {
@MainActor @MainActor
/// ViewController `UIKitAppNavigation` /// ViewController `UIKitAppNavigation`
enum HomeRouteViewControllerFactory { enum HomeRouteViewControllerFactory {
///
static func make(for route: HomeRoute) -> UIViewController { static func make(for route: HomeRoute) -> UIViewController {
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared) AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
} }

View File

@ -29,19 +29,47 @@ enum AppTab: String, CaseIterable, Identifiable, Hashable {
} }
} }
var systemImage: String { /// TabBar
var selectedImageName: String {
switch self { switch self {
case .home: case .home:
"house" "TabHomeSelected"
case .orders: case .orders:
"doc.text" "TabOrderSelected"
case .statistics: case .statistics:
"chart.bar" "TabDataSelected"
case .profile: case .profile:
"person" "TabProfileSelected"
} }
} }
/// TabBar
var unselectedImageName: String {
switch self {
case .home:
"TabHomeUnselected"
case .orders:
"TabOrderUnselected"
case .statistics:
"TabDataUnselected"
case .profile:
"TabProfileUnselected"
}
}
/// TabBarItem `UITabBarController` 使
func makeTabBarItem() -> UITabBarItem {
let unselectedImage = UIImage(named: unselectedImageName)?.withRenderingMode(.alwaysOriginal)
let selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal)
let item = UITabBarItem(
title: title,
image: unselectedImage,
selectedImage: selectedImage
)
item.accessibilityIdentifier = "main.tab.\(rawValue)"
return item
}
/// Tab ViewController /// Tab ViewController
func makeRootViewController(services: AppServices) -> UIViewController { func makeRootViewController(services: AppServices) -> UIViewController {
switch self { switch self {

View File

@ -53,6 +53,7 @@ final class AppRouter {
private let routers: [AppTab: RouterPath] private let routers: [AppTab: RouterPath]
///
init() { init() {
var builtRouters: [AppTab: RouterPath] = [:] var builtRouters: [AppTab: RouterPath] = [:]
for tab in AppTab.allCases { for tab in AppTab.allCases {

View File

@ -13,8 +13,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
private let services: AppServices private let services: AppServices
private var isSyncingStack = false private var isSyncingStack = false
weak var tabBarHost: MainTabBarController? ///
init(tab: AppTab, services: AppServices) { init(tab: AppTab, services: AppServices) {
self.appTab = tab self.appTab = tab
self.services = services self.services = services
@ -43,15 +42,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
pushViewController(viewController, animated: animated) pushViewController(viewController, animated: animated)
} }
func navigationController( /// navigationController
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
let isRoot = viewController === viewControllers.first
tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated)
}
func navigationController( func navigationController(
_ navigationController: UINavigationController, _ navigationController: UINavigationController,
didShow viewController: UIViewController, didShow viewController: UIViewController,
@ -60,6 +51,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
syncRouterPathFromStack() syncRouterPathFromStack()
} }
/// NavigationBridge
private func registerNavigationBridge() { private func registerNavigationBridge() {
switch appTab { switch appTab {
case .home: case .home:
@ -71,6 +63,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
} }
} }
/// FromRouterPath
private func syncFromRouterPath() { private func syncFromRouterPath() {
guard !isSyncingStack else { return } guard !isSyncingStack else { return }
@ -79,6 +72,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
let currentDepth = max(viewControllers.count - 1, 0) let currentDepth = max(viewControllers.count - 1, 0)
if targetDepth > currentDepth { if targetDepth > currentDepth {
// Router push
for index in currentDepth..<targetDepth { for index in currentDepth..<targetDepth {
let route = routerPath.path[index] let route = routerPath.path[index]
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services) let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
@ -89,6 +83,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
} }
if targetDepth < currentDepth { if targetDepth < currentDepth {
// Router pop
if targetDepth == 0 { if targetDepth == 0 {
popToRootViewController(animated: true) popToRootViewController(animated: true)
} else if targetDepth < viewControllers.count { } else if targetDepth < viewControllers.count {
@ -97,6 +92,7 @@ final class TabNavigationController: UINavigationController, UINavigationControl
} }
} }
/// RouterPathFromStack
private func syncRouterPathFromStack() { private func syncRouterPathFromStack() {
guard !isSyncingStack else { return } guard !isSyncingStack else { return }

View File

@ -106,6 +106,7 @@ final class ToastOverlayView: UIView {
nil nil
} }
/// Views
private func configureViews() { private func configureViews() {
bannerView.backgroundColor = AppDesign.primary bannerView.backgroundColor = AppDesign.primary
bannerView.isHidden = true bannerView.isHidden = true
@ -134,6 +135,7 @@ final class ToastOverlayView: UIView {
]) ])
} }
/// Presentation
private func refreshPresentation(animated: Bool) { private func refreshPresentation(animated: Bool) {
guard let message = toastCenter?.message, !message.isEmpty else { guard let message = toastCenter?.message, !message.isEmpty else {
hideBanner(animated: animated) hideBanner(animated: animated)
@ -144,6 +146,7 @@ final class ToastOverlayView: UIView {
showBanner(animated: animated) showBanner(animated: animated)
} }
/// Banner
private func showBanner(animated: Bool) { private func showBanner(animated: Bool) {
guard bannerView.isHidden || bannerView.alpha < 1 else { return } guard bannerView.isHidden || bannerView.alpha < 1 else { return }
@ -162,6 +165,7 @@ final class ToastOverlayView: UIView {
} }
} }
/// Banner
private func hideBanner(animated: Bool) { private func hideBanner(animated: Bool) {
guard !bannerView.isHidden else { return } guard !bannerView.isHidden else { return }

View File

@ -14,6 +14,7 @@ final class RootViewController: UIViewController {
private var currentChild: UIViewController? private var currentChild: UIViewController?
private let restoringView = UIView() private let restoringView = UIView()
///
init(services: AppServices = .shared) { init(services: AppServices = .shared) {
self.services = services self.services = services
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -24,6 +25,7 @@ final class RootViewController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
///
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
@ -38,11 +40,13 @@ final class RootViewController: UIViewController {
configurePushNotifications() configurePushNotifications()
} }
/// Toast Loading window
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
attachGlobalOverlays() attachGlobalOverlays()
} }
///
private func bootstrapSession() { private func bootstrapSession() {
Task { Task {
await services.globalLoading.withLoading { await services.globalLoading.withLoading {
@ -62,16 +66,19 @@ final class RootViewController: UIViewController {
} }
} }
///
private func configurePushNotifications() { private func configurePushNotifications() {
services.configurePushNotifications() services.configurePushNotifications()
} }
/// Toast Loading window
private func attachGlobalOverlays() { private func attachGlobalOverlays() {
guard let window = view.window else { return } guard let window = view.window else { return }
services.toastCenter.attachToWindow(window) services.toastCenter.attachToWindow(window)
services.globalLoading.attachToWindow(window) services.globalLoading.attachToWindow(window)
} }
///
private func handleSessionPhaseChange() { private func handleSessionPhaseChange() {
mountChild(for: services.appSession.phase) mountChild(for: services.appSession.phase)
@ -82,6 +89,7 @@ final class RootViewController: UIViewController {
} }
Task { await reloadScenicSpotContextIfNeeded() } Task { await reloadScenicSpotContextIfNeeded() }
case .loggedOut: case .loggedOut:
//
services.accountContext.reset() services.accountContext.reset()
services.permissionContext.reset() services.permissionContext.reset()
services.scenicSpotContext.reset() services.scenicSpotContext.reset()
@ -93,6 +101,7 @@ final class RootViewController: UIViewController {
} }
} }
/// ID
private func reloadScenicSpotContextIfNeeded() async { private func reloadScenicSpotContextIfNeeded() async {
guard services.appSession.isLoggedIn else { guard services.appSession.isLoggedIn else {
services.scenicSpotContext.reset() services.scenicSpotContext.reset()
@ -104,6 +113,7 @@ final class RootViewController: UIViewController {
) )
} }
/// Tab
private func mountChild(for phase: AuthPhase) { private func mountChild(for phase: AuthPhase) {
let nextChild: UIViewController let nextChild: UIViewController
switch phase { switch phase {
@ -130,6 +140,7 @@ final class RootViewController: UIViewController {
currentChild = nextChild currentChild = nextChild
} }
///
private func restoringViewController() -> UIViewController { private func restoringViewController() -> UIViewController {
let controller = UIViewController() let controller = UIViewController()
controller.view.backgroundColor = .systemBackground controller.view.backgroundColor = .systemBackground

View File

@ -2,35 +2,62 @@
// AppDelegate.swift // AppDelegate.swift
// suixinkan_ios // suixinkan_ios
// //
// Created by hanqiu on 2026/6/26.
//
import UIKit import UIKit
/// APNs SDK
@main @main
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
/// UI Key
func application(
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { _ application: UIApplication,
// Override point for customization after application launch. didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
AppUITestLaunchState.resetIfNeeded()
if let key = Bundle.main.object(forInfoDictionaryKey: "AMapAPIKey") as? String {
AMapBootstrap.configure(apiKey: key)
}
return true return true
} }
// MARK: UISceneSession Lifecycle func application(
_ application: UIApplication,
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { configurationForConnecting connectingSceneSession: UISceneSession,
// Called when a new scene session is being created. options: UIScene.ConnectionOptions
// Use this method to select a configuration to create the new scene with. ) -> UISceneConfiguration {
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
} }
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) { func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {}
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. func application(
// Use this method to release any resources that were specific to the discarded scenes, as they will not return. _ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { @MainActor in
PushNotificationManager.shared.handleDeviceToken(deviceToken)
}
} }
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
Task { @MainActor in
PushNotificationManager.shared.handleRegistrationError(error)
}
} }
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
let payload = PushPayload(userInfo: userInfo)
Task { @MainActor in
PushNotificationManager.shared.handleRemoteNotification(payload)
completionHandler(.newData)
}
}
}

View File

@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "icon_1024.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@ -12,6 +13,7 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "icon_1024.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@ -23,6 +25,7 @@
"value" : "tinted" "value" : "tinted"
} }
], ],
"filename" : "icon_1024.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -50,6 +50,7 @@ final class AppContentUnavailableView: UIView {
actionsContainer.isHidden = actions.isEmpty actionsContainer.isHidden = actions.isEmpty
} }
/// Views
private func configureViews( private func configureViews(
title: String, title: String,
systemImage: String, systemImage: String,

View File

@ -9,6 +9,7 @@ import CoreGraphics
/// App /// App
final class AppMetrics { final class AppMetrics {
///
private init() {} private init() {}
/// App /// App

View File

@ -119,6 +119,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
nil nil
} }
/// Views
private func configureViews() { private func configureViews() {
isUserInteractionEnabled = true isUserInteractionEnabled = true
accessibilityLabel = "加载中" accessibilityLabel = "加载中"
@ -171,6 +172,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
]) ])
} }
/// Presentation
private func refreshPresentation(animated: Bool) { private func refreshPresentation(animated: Bool) {
let shouldShow = state.isVisible let shouldShow = state.isVisible
let updates = { let updates = {
@ -237,6 +239,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
/// loading Resources /// loading Resources
#if canImport(Lottie) #if canImport(Lottie)
/// Animation
private static func loadAnimation() -> LottieAnimation? { private static func loadAnimation() -> LottieAnimation? {
if let animation = LottieAnimation.named("loading") { if let animation = LottieAnimation.named("loading") {
return animation return animation
@ -250,6 +253,7 @@ fileprivate final class GlobalLoadingOverlayView: UIView {
return nil return nil
} }
#else #else
/// Animation
private static func loadAnimation() -> Any? { private static func loadAnimation() -> Any? {
nil nil
} }

View File

@ -22,6 +22,7 @@ final class ForegroundLocationProvider: NSObject {
private let geocoder = CLGeocoder() private let geocoder = CLGeocoder()
private var continuation: CheckedContinuation<ForegroundLocationResult, Error>? private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
///
override init() { override init() {
super.init() super.init()
manager.delegate = self manager.delegate = self

View File

@ -0,0 +1,16 @@
# Core/Map 模块
## 职责
封装高德地图 UIKit 组件,供运营区域围栏展示与打卡点编辑选点使用。
## 组件
- `OperatingAreaMapView`:绘制运营围栏 polygon模拟器降级为坐标摘要。
- `PunchPointMapPickerView`:打卡点编辑页地图点选;模拟器提示使用「当前位置」。
## 构建说明
- 真机构建:`AMAP_ENABLED` + CocoaPods 高德 SDK。
- 模拟器:不链接 AMap自动展示文字/坐标兜底 UI。
-`Info.plist` 配置 `AMapAPIKey` 为高德控制台 Key需与 Bundle ID 绑定)。

View 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")
}
}

View 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

View File

@ -21,6 +21,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
private let tokenDefaultsKey = "apns_device_token" private let tokenDefaultsKey = "apns_device_token"
private let uploadedTokenDefaultsKey = "apns_uploaded_token" private let uploadedTokenDefaultsKey = "apns_uploaded_token"
///
override init() { override init() {
super.init() super.init()
} }
@ -99,6 +100,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
route(from: payload) route(from: payload)
} }
/// userCenter
nonisolated func userNotificationCenter( nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
willPresent notification: UNNotification, willPresent notification: UNNotification,
@ -111,6 +113,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
completionHandler([.banner, .list, .sound, .badge]) completionHandler([.banner, .list, .sound, .badge])
} }
/// userCenter
nonisolated func userNotificationCenter( nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
@ -123,6 +126,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
} }
} }
///
private func route(from payload: PushPayload) { private func route(from payload: PushPayload) {
guard let router else { return } guard let router else { return }
@ -142,6 +146,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
} }
} }
/// HomeRoute
private func navigateHomeRoute(_ route: HomeRoute) { private func navigateHomeRoute(_ route: HomeRoute) {
guard let router else { return } guard let router else { return }
router.select(.home) router.select(.home)

View File

@ -9,6 +9,7 @@ import Foundation
/// APNs token Data /// APNs token Data
enum APNsDeviceToken { enum APNsDeviceToken {
///
static func hexString(from data: Data) -> String { static func hexString(from data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined() data.map { String(format: "%02x", $0) }.joined()
} }
@ -16,6 +17,7 @@ enum APNsDeviceToken {
/// payload extras/data /// payload extras/data
struct PushPayload: Sendable { struct PushPayload: Sendable {
///
enum Route: Sendable, Equatable { enum Route: Sendable, Equatable {
case payment case payment
case order case order
@ -64,6 +66,7 @@ struct PushPayload: Sendable {
return .messageCenter return .messageCenter
} }
/// mergeJSON
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) { nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
if let dict = value as? [String: Any] { if let dict = value as? [String: Any] {
for (key, value) in dict { for (key, value) in dict {
@ -84,6 +87,7 @@ struct PushPayload: Sendable {
} }
} }
///
nonisolated private static func stringValue(_ value: Any?) -> String { nonisolated private static func stringValue(_ value: Any?) -> String {
switch value { switch value {
case let string as String: case let string as String:

View File

@ -9,6 +9,7 @@ import Foundation
/// ///
struct ScenicQueueAnnouncement: Equatable { struct ScenicQueueAnnouncement: Equatable {
///
enum Kind: Equatable { enum Kind: Equatable {
case newTickets(count: Int) case newTickets(count: Int)
case calledTicket(id: Int64) case calledTicket(id: Int64)

View File

@ -69,6 +69,7 @@ final class ScenicQueueRuntime {
self.scenePhase = scenePhase self.scenePhase = scenePhase
guard !suspendedByQueueScreen else { guard !suspendedByQueueScreen else {
//
stop() stop()
return return
} }
@ -86,6 +87,7 @@ final class ScenicQueueRuntime {
return return
} }
//
if lastScenicId != scenicId || lastSpotId != spotId { if lastScenicId != scenicId || lastSpotId != spotId {
resetSnapshot() resetSnapshot()
lastScenicId = scenicId lastScenicId = scenicId
@ -93,6 +95,7 @@ final class ScenicQueueRuntime {
} }
if scenePhase == .background, !backgroundPollingEnabled { if scenePhase == .background, !backgroundPollingEnabled {
//
stop() stop()
return return
} }
@ -126,6 +129,7 @@ final class ScenicQueueRuntime {
Task { await pollOnce() } Task { await pollOnce() }
} }
/// IfNeeded
private func startIfNeeded() { private func startIfNeeded() {
guard pollTask == nil else { return } guard pollTask == nil else { return }
isMonitoring = true isMonitoring = true
@ -137,6 +141,7 @@ final class ScenicQueueRuntime {
} }
} }
/// Loop
private func runLoop() async { private func runLoop() async {
while !Task.isCancelled { while !Task.isCancelled {
await pollOnce() await pollOnce()
@ -145,6 +150,7 @@ final class ScenicQueueRuntime {
} }
} }
/// Once
private func pollOnce() async { private func pollOnce() async {
guard let api, let scenicId else { return } guard let api, let scenicId else { return }
let spotId = selectedSpotId() let spotId = selectedSpotId()
@ -168,6 +174,7 @@ final class ScenicQueueRuntime {
page: 1, page: 1,
pageSize: 20 pageSize: 20
) )
//
let (stats, home) = try await (statsData, homeData) let (stats, home) = try await (statsData, homeData)
handle(stats: stats, tickets: home.list?.list ?? []) handle(stats: stats, tickets: home.list?.list ?? [])
lastError = nil lastError = nil
@ -176,6 +183,7 @@ final class ScenicQueueRuntime {
} }
} }
///
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) { private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss" formatter.dateFormat = "HH:mm:ss"
@ -190,6 +198,7 @@ final class ScenicQueueRuntime {
} }
} }
/// IntervalSeconds
private func pollIntervalSeconds() -> UInt64 { private func pollIntervalSeconds() -> UInt64 {
switch scenePhase { switch scenePhase {
case .active: case .active:
@ -203,11 +212,13 @@ final class ScenicQueueRuntime {
} }
} }
/// ID
private func selectedSpotId() -> Int { private func selectedSpotId() -> Int {
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) ?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
} }
/// Snapshot
private func resetSnapshot() { private func resetSnapshot() {
announcementState.reset() announcementState.reset()
lastQueueCount = 0 lastQueueCount = 0
@ -215,6 +226,7 @@ final class ScenicQueueRuntime {
lastSpokenText = "" lastSpokenText = ""
} }
///
private func beginBackgroundTask() { private func beginBackgroundTask() {
guard backgroundTaskId == .invalid else { return } guard backgroundTaskId == .invalid else { return }
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
@ -225,6 +237,7 @@ final class ScenicQueueRuntime {
} }
} }
///
private func endBackgroundTask() { private func endBackgroundTask() {
guard backgroundTaskId != .invalid else { return } guard backgroundTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTaskId) UIApplication.shared.endBackgroundTask(backgroundTaskId)
@ -238,6 +251,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
private let synthesizer = AVSpeechSynthesizer() private let synthesizer = AVSpeechSynthesizer()
private var pendingContinuations: [CheckedContinuation<Void, Never>] = [] private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
///
override init() { override init() {
super.init() super.init()
synthesizer.delegate = self synthesizer.delegate = self
@ -265,6 +279,7 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
resumePending() resumePending()
} }
///
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) { private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
if replacePending { if replacePending {
synthesizer.stopSpeaking(at: .immediate) synthesizer.stopSpeaking(at: .immediate)
@ -279,14 +294,17 @@ final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
synthesizer.speak(utterance) synthesizer.speak(utterance)
} }
/// speechSynthesizer
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor in self.resumePending() } Task { @MainActor in self.resumePending() }
} }
/// speechSynthesizer
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
Task { @MainActor in self.resumePending() } Task { @MainActor in self.resumePending() }
} }
/// Pending
private func resumePending() { private func resumePending() {
let continuations = pendingContinuations let continuations = pendingContinuations
pendingContinuations.removeAll() pendingContinuations.removeAll()

View File

@ -74,6 +74,7 @@ final class ScenicQueueSocketClient {
webSocketTask = nil webSocketTask = nil
} }
/// WebSocket
private func receiveLoop( private func receiveLoop(
task: URLSessionWebSocketTask, task: URLSessionWebSocketTask,
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
@ -130,6 +131,7 @@ final class ScenicQueueSocketClient {
return ScenicQueueSocketMessage(code: code, data: socketData) return ScenicQueueSocketMessage(code: code, data: socketData)
} }
///
private static func stringValue(_ value: Any?) -> String { private static func stringValue(_ value: Any?) -> String {
switch value { switch value {
case let value as String: case let value as String:
@ -143,6 +145,7 @@ final class ScenicQueueSocketClient {
} }
} }
/// int
private static func intValue(_ value: Any?) -> Int? { private static func intValue(_ value: Any?) -> Int? {
if let value = value as? Int { return value } if let value = value as? Int { return value }
if let value = value as? NSNumber { return value.intValue } if let value = value as? NSNumber { return value.intValue }
@ -150,6 +153,7 @@ final class ScenicQueueSocketClient {
return nil return nil
} }
/// int64
private static func int64Value(_ value: Any?) -> Int64? { private static func int64Value(_ value: Any?) -> Int64? {
if let value = value as? Int64 { return value } if let value = value as? Int64 { return value }
if let value = value as? Int { return Int64(value) } if let value = value as? Int { return Int64(value) }

View File

@ -12,6 +12,7 @@ final class FeaturePlaceholderViewController: UIViewController {
private let pageTitle: String private let pageTitle: String
private let uri: String private let uri: String
///
init(title: String, uri: String) { init(title: String, uri: String) {
self.pageTitle = title self.pageTitle = title
self.uri = uri self.uri = uri
@ -23,6 +24,7 @@ final class FeaturePlaceholderViewController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA) view.backgroundColor = UIColor(hex: 0xF5F7FA)

View File

@ -8,6 +8,7 @@
import Kingfisher import Kingfisher
import UIKit import UIKit
/// `UIImageView` Kingfisher
extension UIImageView { extension UIImageView {
/// 使 Kingfisher 退 /// 使 Kingfisher 退
func loadRemoteImage( func loadRemoteImage(
@ -68,11 +69,13 @@ extension UIImageView {
kf.cancelDownloadTask() kf.cancelDownloadTask()
} }
/// dURLString
private func normalizedURLString(from urlString: String?) -> String? { private func normalizedURLString(from urlString: String?) -> String? {
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text return text.isEmpty ? nil : text
} }
/// avatarPlaceholder
private static func avatarPlaceholder( private static func avatarPlaceholder(
systemImageName: String, systemImageName: String,
iconSize: CGFloat, iconSize: CGFloat,
@ -98,6 +101,7 @@ extension UIImageView {
} }
} }
/// `UIImageView`
extension UIImageView { extension UIImageView {
/// ///
func refreshRemoteAvatarCornerRadius() { func refreshRemoteAvatarCornerRadius() {

View File

@ -23,6 +23,7 @@ private final class ViewModelBindingToken {
private weak var owner: AnyObject? private weak var owner: AnyObject?
private let handler: () -> Void private let handler: () -> Void
///
init(owner: AnyObject, handler: @escaping () -> Void) { init(owner: AnyObject, handler: @escaping () -> Void) {
self.owner = owner self.owner = owner
self.handler = handler self.handler = handler
@ -33,17 +34,22 @@ private final class ViewModelBindingToken {
} }
} }
/// `UIViewController` UI
extension UIViewController { extension UIViewController {
/// 访
var appServices: AppServices { AppServices.shared } var appServices: AppServices { AppServices.shared }
/// Toast
func showToast(_ message: String) { func showToast(_ message: String) {
appServices.toastCenter.show(message) appServices.toastCenter.show(message)
} }
/// Loading
func showGlobalLoading(_ message: String = "") { func showGlobalLoading(_ message: String = "") {
appServices.globalLoading.show(message: message) appServices.globalLoading.show(message: message)
} }
/// GlobalLoading
func hideGlobalLoading() { func hideGlobalLoading() {
appServices.globalLoading.hide() appServices.globalLoading.hide()
} }

View 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
}
}

View File

@ -12,6 +12,7 @@ import UIKit
final class TitleSubtitleTableViewCell: UITableViewCell { final class TitleSubtitleTableViewCell: UITableViewCell {
static let reuseIdentifier = "TitleSubtitleTableViewCell" static let reuseIdentifier = "TitleSubtitleTableViewCell"
///
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
selectionStyle = .default selectionStyle = .default
@ -28,6 +29,7 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
///
func configure(title: String, subtitle: String? = nil, detail: String? = nil) { func configure(title: String, subtitle: String? = nil, detail: String? = nil) {
textLabel?.text = title textLabel?.text = title
if let subtitle, !subtitle.isEmpty { if let subtitle, !subtitle.isEmpty {
@ -38,18 +40,20 @@ final class TitleSubtitleTableViewCell: UITableViewCell {
} }
} }
/// UITableView ViewModel onChange /// UITableView Diffable ViewModel onChange
@MainActor class ModuleTableViewController: UIViewController, UITableViewDelegate {
class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
let services = AppServices.shared let services = AppServices.shared
let tableView = UITableView(frame: .zero, style: .insetGrouped) let tableView = UITableView(frame: .zero, style: .insetGrouped)
private let refreshControl = UIRefreshControl() private let refreshControl = UIRefreshControl()
private let activityIndicator = UIActivityIndicatorView(style: .medium) private let activityIndicator = UIActivityIndicatorView(style: .medium)
private var viewModelReloadHandler: (() -> Void)? private var viewModelReloadHandler: (() -> Void)?
/// Diffable
private(set) var tableDataSource: UITableViewDiffableDataSource<ModuleTableSection, ModuleTableRow>!
var isLoading = false { var isLoading = false {
didSet { didSet {
if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 { if isLoading, tableDataSource.snapshot().numberOfItems == 0 {
activityIndicator.startAnimating() activityIndicator.startAnimating()
} else { } else {
activityIndicator.stopAnimating() activityIndicator.stopAnimating()
@ -57,12 +61,19 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
} }
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA) view.backgroundColor = UIColor(hex: 0xF5F7FA)
navigationItem.largeTitleDisplayMode = .never navigationItem.largeTitleDisplayMode = .never
tableView.dataSource = self tableDataSource = UITableViewDiffableDataSource<ModuleTableSection, ModuleTableRow>(
tableView: tableView
) { [weak self] (tableView: UITableView, indexPath: IndexPath, row: ModuleTableRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
return self.tableCell(for: indexPath, row: row, in: tableView)
}
tableView.delegate = self tableView.delegate = self
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.register( tableView.register(
@ -85,36 +96,45 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
Task { await reloadContent() } Task { await reloadContent() }
} }
/// ViewModel `reloadContent`
func bindViewModel(onChange: (() -> Void)?) { func bindViewModel(onChange: (() -> Void)?) {
viewModelReloadHandler = onChange viewModelReloadHandler = onChange
} }
func reloadTable() { /// Diffable snapshot reloadData
tableView.reloadData() func reloadTable(animated: Bool = true) {
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
} }
/// section section
func numberOfTableSections() -> Int { 1 }
/// section
func tableRowCount(in section: Int) -> Int {
section == 0 ? tableRowCount() : 0
}
/// section
func tableRowCount() -> Int { 0 } func tableRowCount() -> Int { 0 }
/// Diffable snapshot
func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<ModuleTableSection, ModuleTableRow> {
var snapshot = NSDiffableDataSourceSnapshot<ModuleTableSection, ModuleTableRow>()
for section in 0..<numberOfTableSections() {
snapshot.appendSections([section])
let rows = (0..<tableRowCount(in: section)).map {
moduleTableRowID(section: section, row: $0)
}
snapshot.appendItems(rows, toSection: section)
}
return snapshot
}
/// Cell
func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {} func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {}
func didSelectTableRow(at indexPath: IndexPath) {} /// Cell section
func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
func reloadContent() async {}
@objc private func handleRefresh() {
Task {
await reloadContent()
refreshControl.endRefreshing()
}
}
func numberOfSections(in tableView: UITableView) -> Int { 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tableRowCount()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell( guard let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath for: indexPath
@ -125,19 +145,46 @@ class ModuleTableViewController: UIViewController, UITableViewDataSource, UITabl
return cell return cell
} }
///
func didSelectTableRow(at indexPath: IndexPath) {}
///
func reloadContent() async {}
/// section
func tableSectionTitle(for section: Int) -> String? { nil }
///
@objc private func handleRefresh() {
Task {
await reloadContent()
refreshControl.endRefreshing()
}
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
tableSectionTitle(for: section)
}
/// UITableView
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
didSelectTableRow(at: indexPath) didSelectTableRow(at: indexPath)
} }
/// UITableView
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
willDisplayTableRow(at: indexPath) willDisplayTableRow(at: indexPath)
} }
/// willDisplayTableRow
func willDisplayTableRow(at indexPath: IndexPath) {} func willDisplayTableRow(at indexPath: IndexPath) {}
} }
/// `ModuleTableViewController` ViewModel 便
extension ModuleTableViewController { extension ModuleTableViewController {
/// ViewModel onChange
func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) { func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) {
if let bindable = viewModel as? ViewModelBindable { if let bindable = viewModel as? ViewModelBindable {
bindable.onChange = { [weak self] in bindable.onChange = { [weak self] in
@ -146,6 +193,7 @@ extension ModuleTableViewController {
} }
} }
reload() reload()
reloadTable(animated: false)
} }
} }
@ -154,3 +202,140 @@ extension ModuleTableViewController {
protocol ViewModelBindable: AnyObject { protocol ViewModelBindable: AnyObject {
var onChange: (() -> Void)? { get set } var onChange: (() -> Void)? { get set }
} }
/// 使 UITableView Diffable
class SimpleTableDiffableViewController: UIViewController, UITableViewDelegate {
let tableView: UITableView
private(set) var tableDataSource: UITableViewDiffableDataSource<SimpleTableSection, SimpleTableRow>!
///
init(style: UITableView.Style = .insetGrouped) {
tableView = UITableView(frame: .zero, style: style)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// snapshot
func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
NSDiffableDataSourceSnapshot()
}
/// Cell
func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {}
///
func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {}
/// section
func sectionTitle(for section: SimpleTableSection) -> String? { nil }
/// section
func sectionFooter(for section: SimpleTableSection) -> String? { nil }
/// snapshot
func applySnapshot(animated: Bool = true) {
tableDataSource.apply(buildSnapshot(), animatingDifferences: animated)
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
tableDataSource = UITableViewDiffableDataSource<SimpleTableSection, SimpleTableRow>(
tableView: tableView
) { [weak self] (_: UITableView, indexPath: IndexPath, row: SimpleTableRow) -> UITableViewCell? in
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
self?.configureCell(cell, row: row, at: indexPath)
return cell
}
tableView.delegate = self
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
applySnapshot(animated: false)
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
return sectionTitle(for: sectionID)
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
return sectionFooter(for: sectionID)
}
/// UITableView
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
didSelectRow(row, at: indexPath)
}
}
/// section
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
/// UICollectionView Diffable
class ModuleCollectionViewController: UIViewController {
let services = AppServices.shared
let collectionView: UICollectionView
private let refreshControl = UIRefreshControl()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
///
init(collectionViewLayout: UICollectionViewLayout) {
collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA)
navigationItem.largeTitleDisplayMode = .never
collectionView.backgroundColor = .clear
collectionView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
activityIndicator.hidesWhenStopped = true
view.addSubview(collectionView)
view.addSubview(activityIndicator)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
activityIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
Task { await reloadContent() }
}
///
func reloadContent() async {}
///
@objc private func handleRefresh() {
Task {
await reloadContent()
refreshControl.endRefreshing()
}
}
}

View File

@ -11,6 +11,7 @@ import UIKit
final class CloudStorageViewController: ModuleTableViewController { final class CloudStorageViewController: ModuleTableViewController {
private let viewModel = CloudStorageViewModel() private let viewModel = CloudStorageViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "云盘" title = "云盘"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -23,22 +24,27 @@ final class CloudStorageViewController: ModuleTableViewController {
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.files.count } override func tableRowCount() -> Int { viewModel.files.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let file = viewModel.files[indexPath.row] let file = viewModel.files[indexPath.row]
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)") cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(api: services.assetsAPI) await viewModel.reload(api: services.assetsAPI)
} }
/// willDisplayTableRow
override func willDisplayTableRow(at indexPath: IndexPath) { override func willDisplayTableRow(at indexPath: IndexPath) {
guard indexPath.row >= viewModel.files.count - 2 else { return } guard indexPath.row >= viewModel.files.count - 2 else { return }
Task { await viewModel.loadMore(api: services.assetsAPI) } Task { await viewModel.loadMore(api: services.assetsAPI) }
} }
/// openTransit
@objc private func openTransit() { @objc private func openTransit() {
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true) navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
} }
@ -50,22 +56,27 @@ extension CloudStorageViewModel: ViewModelBindable {}
final class CloudStorageTransitViewController: ModuleTableViewController { final class CloudStorageTransitViewController: ModuleTableViewController {
private let store = CloudTransferStore.shared private let store = CloudTransferStore.shared
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "传输记录" title = "传输记录"
super.viewDidLoad() super.viewDidLoad()
store.onChange = { [weak self] in self?.reloadTable() } store.onChange = { [weak self] in self?.reloadTable() }
} }
/// tableCount
override func tableRowCount() -> Int { store.records.count } override func tableRowCount() -> Int { store.records.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let record = store.records[indexPath.row] let record = store.records[indexPath.row]
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%") cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
} }
/// Content
override func reloadContent() async {} override func reloadContent() async {}
} }
/// MediaLibraryKindRoute
enum MediaLibraryKindRoute { enum MediaLibraryKindRoute {
case material case material
case sample case sample
@ -76,6 +87,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
private let kind: MediaLibraryKindRoute private let kind: MediaLibraryKindRoute
private let viewModel: MediaLibraryViewModel private let viewModel: MediaLibraryViewModel
///
init(kind: MediaLibraryKindRoute = .material) { init(kind: MediaLibraryKindRoute = .material) {
self.kind = kind self.kind = kind
switch kind { switch kind {
@ -92,6 +104,7 @@ final class MediaLibraryViewController: ModuleTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = kind == .sample ? "样片库" : "素材库" title = kind == .sample ? "样片库" : "素材库"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -104,17 +117,21 @@ final class MediaLibraryViewController: ModuleTableViewController {
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.items.count } override func tableRowCount() -> Int { viewModel.items.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row] let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt) cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt)
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(api: services.assetsAPI) await viewModel.reload(api: services.assetsAPI)
} }
/// open
@objc private func openUpload() { @objc private func openUpload() {
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true) navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
} }
@ -128,6 +145,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
private let viewModel = MediaLibraryEditorViewModel() private let viewModel = MediaLibraryEditorViewModel()
private let nameField = UITextField() private let nameField = UITextField()
///
init(kind: MediaLibraryKindRoute) { init(kind: MediaLibraryKindRoute) {
self.kind = kind self.kind = kind
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -138,6 +156,7 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = kind == .sample ? "上传样片" : "上传素材" title = kind == .sample ? "上传样片" : "上传素材"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -154,17 +173,21 @@ final class MediaLibraryUploadViewController: ModuleTableViewController {
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.projects.count } override func tableRowCount() -> Int { viewModel.projects.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let project = viewModel.projects[indexPath.row] let project = viewModel.projects[indexPath.row]
cell.configure(title: project.name, subtitle: project.statusName) cell.configure(title: project.name, subtitle: project.statusName)
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI) await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
} }
///
@objc private func submit() { @objc private func submit() {
viewModel.name = nameField.text ?? "" viewModel.name = nameField.text ?? ""
Task { Task {
@ -186,19 +209,23 @@ extension MediaLibraryEditorViewModel: ViewModelBindable {}
final class AlbumListViewController: ModuleTableViewController { final class AlbumListViewController: ModuleTableViewController {
private let viewModel = AlbumListViewModel() private let viewModel = AlbumListViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "相册" title = "相册"
super.viewDidLoad() super.viewDidLoad()
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.folders.count } override func tableRowCount() -> Int { viewModel.folders.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let folder = viewModel.folders[indexPath.row] let folder = viewModel.folders[indexPath.row]
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount)") cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount)")
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId) await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
} }
@ -210,19 +237,23 @@ extension AlbumListViewModel: ViewModelBindable {}
final class AlbumTrailerViewController: ModuleTableViewController { final class AlbumTrailerViewController: ModuleTableViewController {
private let viewModel = AlbumTrailerViewModel() private let viewModel = AlbumTrailerViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "相册预告" title = "相册预告"
super.viewDidLoad() super.viewDidLoad()
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.folders.count } override func tableRowCount() -> Int { viewModel.folders.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let folder = viewModel.folders[indexPath.row] let folder = viewModel.folders[indexPath.row]
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount)") cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount)")
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId) await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
} }

View File

@ -6,9 +6,14 @@
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Diffable
private typealias AccountSelectionSection = Int
private typealias AccountSelectionItem = String
@MainActor @MainActor
/// / /// /
final class AccountSelectionViewController: UIViewController { final class AccountSelectionViewController: UIViewController, UITableViewDelegate {
private let payload: AccountSelectionPayload private let payload: AccountSelectionPayload
private var isLoading: Bool private var isLoading: Bool
@ -17,9 +22,13 @@ final class AccountSelectionViewController: UIViewController {
private var selectedAccountId: String? private var selectedAccountId: String?
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>!
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private let bottomBar = UIView() private let bottomBar = UIView()
///
init( init(
payload: AccountSelectionPayload, payload: AccountSelectionPayload,
isLoading: Bool, isLoading: Bool,
@ -38,6 +47,7 @@ final class AccountSelectionViewController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "选择账号" title = "选择账号"
@ -47,9 +57,47 @@ final class AccountSelectionViewController: UIViewController {
selectedAccountId = payload.accounts.first?.id selectedAccountId = payload.accounts.first?.id
configureTableView() configureTableView()
configureTableDataSource()
configureBottomBar() configureBottomBar()
} }
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>(
tableView: tableView
) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSelectionItem) -> UITableViewCell? in
guard let self,
let cell = tableView.dequeueReusableCell(
withIdentifier: AccountSelectionCell.reuseIdentifier,
for: indexPath
) as? AccountSelectionCell,
let account = self.payload.accounts.first(where: { $0.id == item }) else {
return UITableViewCell()
}
cell.configure(account: account, selected: account.id == self.selectedAccountId)
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem> {
var snapshot = NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem>()
snapshot.appendSections([0])
snapshot.appendItems(payload.accounts.map(\.id), toSection: 0)
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Loading
func updateLoading(_ loading: Bool) { func updateLoading(_ loading: Bool) {
isLoading = loading isLoading = loading
isModalInPresentation = loading isModalInPresentation = loading
@ -65,10 +113,10 @@ final class AccountSelectionViewController: UIViewController {
selectedAccount != nil && !isLoading selectedAccount != nil && !isLoading
} }
/// TableView
private func configureTableView() { private func configureTableView() {
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.dataSource = self
tableView.delegate = self tableView.delegate = self
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier) tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
view.addSubview(tableView) view.addSubview(tableView)
@ -78,6 +126,7 @@ final class AccountSelectionViewController: UIViewController {
} }
} }
/// BottomBar
private func configureBottomBar() { private func configureBottomBar() {
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
@ -112,37 +161,26 @@ final class AccountSelectionViewController: UIViewController {
} }
} }
/// cancel
@objc private func cancelTapped() { @objc private func cancelTapped() {
onCancel() onCancel()
dismiss(animated: true) dismiss(animated: true)
} }
/// confirm
@objc private func confirmTapped() { @objc private func confirmTapped() {
guard let selectedAccount else { return } guard let selectedAccount else { return }
onConfirm(selectedAccount) onConfirm(selectedAccount)
} }
} }
extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate { extension AccountSelectionViewController {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { /// UITableView
payload.accounts.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: AccountSelectionCell.reuseIdentifier,
for: indexPath
) as? AccountSelectionCell else {
return UITableViewCell()
}
let account = payload.accounts[indexPath.row]
cell.configure(account: account, selected: account.id == selectedAccountId)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
selectedAccountId = payload.accounts[indexPath.row].id tableView.deselectRow(at: indexPath, animated: true)
tableView.reloadData() guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return }
selectedAccountId = item
applyTableSnapshot(animated: false, reconfigure: true)
confirmButton.isEnabled = canConfirm confirmButton.isEnabled = canConfirm
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6) confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
} }
@ -161,6 +199,7 @@ private final class AccountSelectionCell: UITableViewCell {
private let currentTag = UILabel() private let currentTag = UILabel()
private let checkmark = UIImageView() private let checkmark = UIImageView()
///
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none selectionStyle = .none
@ -257,6 +296,7 @@ private final class AccountSelectionCell: UITableViewCell {
nil nil
} }
///
func configure(account: AccountSwitchAccount, selected: Bool) { func configure(account: AccountSwitchAccount, selected: Bool) {
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
subtitleLabel.text = account.subtitle subtitleLabel.text = account.subtitle

View File

@ -26,6 +26,7 @@ final class LoginViewController: UIViewController {
private let privacyPolicyButton = UIButton(type: .system) private let privacyPolicyButton = UIButton(type: .system)
private let loginButton = UIButton(type: .system) private let loginButton = UIButton(type: .system)
///
init(services: AppServices) { init(services: AppServices) {
self.services = services self.services = services
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -36,6 +37,7 @@ final class LoginViewController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0x0B1220) view.backgroundColor = UIColor(hex: 0x0B1220)
@ -44,6 +46,7 @@ final class LoginViewController: UIViewController {
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences()) viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
} }
/// Views
private func configureViews() { private func configureViews() {
backgroundImageView.contentMode = .scaleAspectFill backgroundImageView.contentMode = .scaleAspectFill
backgroundImageView.clipsToBounds = true backgroundImageView.clipsToBounds = true
@ -161,6 +164,7 @@ final class LoginViewController: UIViewController {
view.addGestureRecognizer(tap) view.addGestureRecognizer(tap)
} }
/// AgreementText
private func configureAgreementText() { private func configureAgreementText() {
privacyLabel.numberOfLines = 0 privacyLabel.numberOfLines = 0
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
@ -197,6 +201,7 @@ final class LoginViewController: UIViewController {
} }
} }
/// ViewModel
private func bindViewModel() { private func bindViewModel() {
viewModel.onChange = { [weak self] in viewModel.onChange = { [weak self] in
self?.renderViewModel() self?.renderViewModel()
@ -204,6 +209,7 @@ final class LoginViewController: UIViewController {
renderViewModel() renderViewModel()
} }
/// render
private func renderViewModel() { private func renderViewModel() {
if usernameField.textField.text != viewModel.username { if usernameField.textField.text != viewModel.username {
usernameField.textField.text = viewModel.username usernameField.textField.text = viewModel.username
@ -241,38 +247,46 @@ final class LoginViewController: UIViewController {
private weak var accountSelectionController: AccountSelectionViewController? private weak var accountSelectionController: AccountSelectionViewController?
/// username
@objc private func usernameChanged() { @objc private func usernameChanged() {
viewModel.username = usernameField.textField.text ?? "" viewModel.username = usernameField.textField.text ?? ""
viewModel.normalizeUsernameCountryCodeIfNeeded() viewModel.normalizeUsernameCountryCodeIfNeeded()
services.toastCenter.dismiss() services.toastCenter.dismiss()
} }
/// password
@objc private func passwordChanged() { @objc private func passwordChanged() {
viewModel.password = passwordField.textField.text ?? "" viewModel.password = passwordField.textField.text ?? ""
services.toastCenter.dismiss() services.toastCenter.dismiss()
} }
/// togglePrivacy
@objc private func togglePrivacy() { @objc private func togglePrivacy() {
viewModel.privacyChecked.toggle() viewModel.privacyChecked.toggle()
} }
/// openUserAgreement
@objc private func openUserAgreement() { @objc private func openUserAgreement() {
showToast("用户协议页面待接入") showToast("用户协议页面待接入")
} }
/// openPrivacyPolicy
@objc private func openPrivacyPolicy() { @objc private func openPrivacyPolicy() {
showToast("隐私政策页面待接入") showToast("隐私政策页面待接入")
} }
/// login
@objc private func loginTapped() { @objc private func loginTapped() {
view.endEditing(true) view.endEditing(true)
performLogin() performLogin()
} }
/// dismiss
@objc private func dismissKeyboard() { @objc private func dismissKeyboard() {
view.endEditing(true) view.endEditing(true)
} }
/// perform
private func performLogin() { private func performLogin() {
if let validationError = viewModel.validateForLogin() { if let validationError = viewModel.validateForLogin() {
if validationError == .privacyUnchecked { if validationError == .privacyUnchecked {
@ -307,6 +321,7 @@ final class LoginViewController: UIViewController {
} }
} }
/// AgreementSheet
private func presentAgreementSheet() { private func presentAgreementSheet() {
let controller = LoginAgreementConsentViewController( let controller = LoginAgreementConsentViewController(
onOpenAgreement: { [weak self] title in onOpenAgreement: { [weak self] title in
@ -324,6 +339,7 @@ final class LoginViewController: UIViewController {
present(controller, animated: true) present(controller, animated: true)
} }
/// AccountSelection
private func presentAccountSelection(_ payload: AccountSelectionPayload) { private func presentAccountSelection(_ payload: AccountSelectionPayload) {
let controller = AccountSelectionViewController( let controller = AccountSelectionViewController(
payload: payload, payload: payload,
@ -342,6 +358,7 @@ final class LoginViewController: UIViewController {
present(navigation, animated: true) present(navigation, animated: true)
} }
/// select
private func selectAccount(_ account: AccountSwitchAccount) { private func selectAccount(_ account: AccountSwitchAccount) {
Task { Task {
do { do {
@ -359,6 +376,7 @@ final class LoginViewController: UIViewController {
} }
} }
/// complete
private func completeLogin(with response: V9AuthResponse) async { private func completeLogin(with response: V9AuthResponse) async {
do { do {
try await services.authSessionCoordinator.completeLogin( try await services.authSessionCoordinator.completeLogin(
@ -378,6 +396,7 @@ final class LoginViewController: UIViewController {
} }
extension LoginViewController: UITextFieldDelegate { extension LoginViewController: UITextFieldDelegate {
/// textShouldReturn
func textFieldShouldReturn(_ textField: UITextField) -> Bool { func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField === usernameField.textField { if textField === usernameField.textField {
passwordField.textField.becomeFirstResponder() passwordField.textField.becomeFirstResponder()
@ -396,6 +415,7 @@ private final class LoginInputField: UIView {
private let toggleButton = UIButton(type: .custom) private let toggleButton = UIButton(type: .custom)
private var isSecure = false private var isSecure = false
///
init(iconName: String, placeholder: String, isSecure: Bool) { init(iconName: String, placeholder: String, isSecure: Bool) {
self.isSecure = isSecure self.isSecure = isSecure
super.init(frame: .zero) super.init(frame: .zero)
@ -450,12 +470,14 @@ private final class LoginInputField: UIView {
nil nil
} }
/// setSecureEntry
func setSecureEntry(_ secure: Bool) { func setSecureEntry(_ secure: Bool) {
textField.isSecureTextEntry = secure textField.isSecureTextEntry = secure
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible" let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
toggleButton.setImage(UIImage(named: imageName), for: .normal) toggleButton.setImage(UIImage(named: imageName), for: .normal)
} }
/// toggleVisibility
@objc private func toggleVisibility() { @objc private func toggleVisibility() {
onToggleVisibility?() onToggleVisibility?()
} }
@ -467,6 +489,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
private let onOpenAgreement: (String) -> Void private let onOpenAgreement: (String) -> Void
private let onAgreeAndContinue: () -> Void private let onAgreeAndContinue: () -> Void
///
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) { init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
self.onOpenAgreement = onOpenAgreement self.onOpenAgreement = onOpenAgreement
self.onAgreeAndContinue = onAgreeAndContinue self.onAgreeAndContinue = onAgreeAndContinue
@ -477,6 +500,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
nil nil
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
@ -524,6 +548,7 @@ private final class LoginAgreementConsentViewController: UIViewController {
} }
} }
/// continue
@objc private func continueTapped() { @objc private func continueTapped() {
dismiss(animated: true) { [onAgreeAndContinue] in dismiss(animated: true) { [onAgreeAndContinue] in
onAgreeAndContinue() onAgreeAndContinue()

View File

@ -50,6 +50,7 @@ enum HomeMenuRouting {
viewController.navigationController?.pushViewController(target, animated: true) viewController.navigationController?.pushViewController(target, animated: true)
} }
/// Push Placeholder
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) { private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
viewController.navigationController?.pushViewController( viewController.navigationController?.pushViewController(
FeaturePlaceholderViewController(title: title, uri: uri), FeaturePlaceholderViewController(title: title, uri: uri),

View File

@ -6,6 +6,19 @@
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Diffable
/// section
private enum HomeMoreSection: Hashable {
case commonApps
case moreFunctions
}
/// item URI
private enum HomeMoreItem: Hashable {
case menu(uri: String)
}
/// ///
final class HomeMoreFunctionsViewController: UIViewController { final class HomeMoreFunctionsViewController: UIViewController {
@ -13,29 +26,38 @@ final class HomeMoreFunctionsViewController: UIViewController {
private let commonMenuStore = HomeCommonMenuStore() private let commonMenuStore = HomeCommonMenuStore()
private var commonURIs: [String] = [] private var commonURIs: [String] = []
private lazy var tableView: UITableView = { private lazy var collectionView: UICollectionView = {
let table = UITableView(frame: .zero, style: .grouped) let layout = makeCollectionLayout()
table.backgroundColor = AppDesignUIKit.pageBackground let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
table.separatorStyle = .none collection.backgroundColor = AppDesignUIKit.pageBackground
table.dataSource = self collection.delegate = self
table.delegate = self collection.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID) collection.register(
return table CollectionSectionHeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: CollectionSectionHeaderView.reuseID
)
return collection
}() }()
/// Diffable section
private var dataSource: UICollectionViewDiffableDataSource<HomeMoreSection, HomeMoreItem>!
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "全部功能" title = "全部功能"
view.backgroundColor = AppDesignUIKit.pageBackground view.backgroundColor = AppDesignUIKit.pageBackground
navigationItem.largeTitleDisplayMode = .never navigationItem.largeTitleDisplayMode = .never
view.addSubview(tableView) configureDataSource()
tableView.snp.makeConstraints { make in view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()
} }
viewModel.onChange = { [weak self] in viewModel.onChange = { [weak self] in
self?.tableView.reloadData() self?.applySnapshot()
} }
rebuildMenus() rebuildMenus()
@ -44,6 +66,95 @@ final class HomeMoreFunctionsViewController: UIViewController {
} }
} }
/// Compositional Layout section
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
guard let self,
let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex]
else {
return CollectionDiffableLayout.gridSection(itemHeight: 112)
}
let gridInsets = NSDirectionalEdgeInsets(
top: AppMetrics.Spacing.xxSmall,
leading: AppMetrics.Spacing.mediumLarge,
bottom: AppMetrics.Spacing.xxSmall,
trailing: AppMetrics.Spacing.mediumLarge
)
let grid = CollectionDiffableLayout.gridSection(
columns: 3,
itemHeight: 112,
interItemSpacing: 14,
lineSpacing: AppMetrics.Spacing.mediumLarge,
contentInsets: gridInsets
)
switch section {
case .commonApps:
return CollectionDiffableLayout.addHeader(to: grid, height: 36)
case .moreFunctions:
return CollectionDiffableLayout.addHeader(to: grid, height: 36)
}
}
}
/// Diffable Cell
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<HomeMoreSection, HomeMoreItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
guard let self,
case .menu(let uri) = item,
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section]
else { return UICollectionViewCell() }
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeMoreMenuItemCell.reuseID,
for: indexPath
) as! HomeMoreMenuItemCell
let isCommon = section == .commonApps
let items = isCommon ? self.commonItems : self.moreItems
guard let menuItem = items.first(where: { $0.uri == uri }) else { return cell }
cell.configure(item: menuItem, isCommon: isCommon) { [weak self] in
self?.toggleCommon(menuItem, isCommon: isCommon)
}
return cell
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard let self,
kind == UICollectionView.elementKindSectionHeader,
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section],
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: CollectionSectionHeaderView.reuseID,
for: indexPath
) as? CollectionSectionHeaderView
else { return nil }
let title = section == .commonApps ? "常用应用" : "更多功能"
header.configure(title: title)
return header
}
}
/// snapshot diff
private func applySnapshot(animated: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<HomeMoreSection, HomeMoreItem>()
snapshot.appendSections([.commonApps, .moreFunctions])
snapshot.appendItems(
commonItems.map { HomeMoreItem.menu(uri: $0.uri) },
toSection: .commonApps
)
snapshot.appendItems(
moreItems.map { HomeMoreItem.menu(uri: $0.uri) },
toSection: .moreFunctions
)
dataSource.apply(snapshot, animatingDifferences: animated)
}
///
private func rebuildMenus() { private func rebuildMenus() {
let services = appServices let services = appServices
viewModel.buildMenus( viewModel.buildMenus(
@ -51,7 +162,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
currentRoleId: services.permissionContext.currentRole?.id currentRoleId: services.permissionContext.currentRole?.id
) )
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
tableView.reloadData() applySnapshot()
} }
private var commonItems: [HomeMenuItem] { private var commonItems: [HomeMenuItem] {
@ -62,6 +173,7 @@ final class HomeMoreFunctionsViewController: UIViewController {
viewModel.menuItems.filter { !isCommonURI($0.uri) } viewModel.menuItems.filter { !isCommonURI($0.uri) }
} }
/// URI
private func menuItem(for uri: String) -> HomeMenuItem? { private func menuItem(for uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri)) let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
@ -73,20 +185,23 @@ final class HomeMoreFunctionsViewController: UIViewController {
) )
} }
/// URI
private func isCommonURI(_ uri: String) -> Bool { private func isCommonURI(_ uri: String) -> Bool {
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey } return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
} }
///
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) { private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
if isCommon { if isCommon {
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs) commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
} else { } else {
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems) commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
} }
tableView.reloadData() applySnapshot()
} }
///
private func openMenu(_ item: HomeMenuItem) { private func openMenu(_ item: HomeMenuItem) {
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title) let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return } if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
@ -94,108 +209,24 @@ final class HomeMoreFunctionsViewController: UIViewController {
} }
} }
extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate { // MARK: - UICollectionViewDelegate
func numberOfSections(in tableView: UITableView) -> Int { 2 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "常用应用" : "更多功能"
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell
let isCommon = indexPath.section == 0
let items = isCommon ? commonItems : moreItems
cell.configure(items: items, isCommon: isCommon) { [weak self] item in
self?.openMenu(item)
} onToggle: { [weak self] item in
self?.toggleCommon(item, isCommon: isCommon)
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let count = indexPath.section == 0 ? commonItems.count : moreItems.count
let rows = max(1, Int(ceil(Double(count) / 3.0)))
return CGFloat(rows) * 124 + 8
}
}
private final class HomeMoreMenuGridCell: UITableViewCell {
static let reuseID = "HomeMoreMenuGridCell"
private var items: [HomeMenuItem] = []
private var isCommon = false
private var onSelect: ((HomeMenuItem) -> Void)?
private var onToggle: ((HomeMenuItem) -> Void)?
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 14
layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .clear
view.isScrollEnabled = false
view.dataSource = self
view.delegate = self
view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
items: [HomeMenuItem],
isCommon: Bool,
onSelect: @escaping (HomeMenuItem) -> Void,
onToggle: @escaping (HomeMenuItem) -> Void
) {
self.items = items
self.isCommon = isCommon
self.onSelect = onSelect
self.onToggle = onToggle
collectionView.reloadData()
}
}
extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell
let item = items[indexPath.item]
cell.configure(item: item, isCommon: isCommon) { [weak self] in
self?.onToggle?(item)
}
return cell
}
extension HomeMoreFunctionsViewController: UICollectionViewDelegate {
/// Cell
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
onSelect?(items[indexPath.item]) guard let item = dataSource.itemIdentifier(for: indexPath),
} case .menu(let uri) = item
else { return }
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let allItems = commonItems + moreItems
let width = (collectionView.bounds.width - 28) / 3 guard let menuItem = allItems.first(where: { $0.uri == uri }) else { return }
return CGSize(width: width, height: 112) openMenu(menuItem)
} }
} }
// MARK: - Menu Item Cell
/// Cell
private final class HomeMoreMenuItemCell: UICollectionViewCell { private final class HomeMoreMenuItemCell: UICollectionViewCell {
static let reuseID = "HomeMoreMenuItemCell" static let reuseID = "HomeMoreMenuItemCell"
@ -204,6 +235,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
private let toggleButton = UIButton(type: .system) private let toggleButton = UIButton(type: .system)
private var onToggle: (() -> Void)? private var onToggle: (() -> Void)?
///
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
contentView.backgroundColor = .white contentView.backgroundColor = .white
@ -243,6 +275,7 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
///
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) { func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
self.onToggle = onToggle self.onToggle = onToggle
titleLabel.text = item.title titleLabel.text = item.title
@ -254,7 +287,15 @@ private final class HomeMoreMenuItemCell: UICollectionViewCell {
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
} }
///
@objc private func toggleTapped() { @objc private func toggleTapped() {
onToggle?() onToggle?()
} }
} }
/// section
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@ -13,6 +13,7 @@ final class HomePlaceholderViewController: UIViewController {
private let pageTitle: String private let pageTitle: String
private let uri: String? private let uri: String?
///
init(title: String, uri: String? = nil) { init(title: String, uri: String? = nil) {
pageTitle = title pageTitle = title
self.uri = uri self.uri = uri
@ -24,6 +25,7 @@ final class HomePlaceholderViewController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = pageTitle title = pageTitle

View File

@ -6,6 +6,26 @@
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Diffable
/// section
private enum HomeSection: Hashable {
case workStatus
case locationReport
case storeInfo
case quickActions
case commonMenus
}
/// item item diff
private enum HomeItem: Hashable {
case workStatus(isOnline: Bool, secondsUntilReport: Int, reminderMinutes: Int)
case locationReport
case storeInfo(storeID: Int)
case quickActions(isOnline: Bool)
case menu(uri: String)
}
/// ///
final class HomeViewController: UIViewController { final class HomeViewController: UIViewController {
@ -34,40 +54,55 @@ final class HomeViewController: UIViewController {
return button return button
}() }()
private lazy var tableView: UITableView = { private lazy var collectionView: UICollectionView = {
let table = UITableView(frame: .zero, style: .grouped) let layout = makeCollectionLayout()
table.backgroundColor = AppDesignUIKit.pageBackground let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
table.separatorStyle = .none collection.backgroundColor = AppDesignUIKit.pageBackground
table.showsVerticalScrollIndicator = false collection.showsVerticalScrollIndicator = false
table.dataSource = self collection.delegate = self
table.delegate = self collection.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseID)
table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID) collection.register(HomeLocationReportCell.self, forCellWithReuseIdentifier: HomeLocationReportCell.reuseID)
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell") collection.register(HomeStoreInfoCell.self, forCellWithReuseIdentifier: HomeStoreInfoCell.reuseID)
return table collection.register(HomeQuickActionsCell.self, forCellWithReuseIdentifier: HomeQuickActionsCell.reuseID)
collection.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
collection.register(
CollectionSectionHeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: CollectionSectionHeaderView.reuseID
)
return collection
}() }()
/// Diffable section
private var dataSource: UICollectionViewDiffableDataSource<HomeSection, HomeItem>!
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = AppDesignUIKit.pageBackground view.backgroundColor = AppDesignUIKit.pageBackground
setupTopBar() setupTopBar()
setupTableView() configureDataSource()
setupCollectionView()
bindViewModel() bindViewModel()
rebuildMenus() rebuildMenus()
observeContextChanges() observeContextChanges()
} }
///
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.setNavigationBarHidden(true, animated: animated)
startCountdownTimerIfNeeded() startCountdownTimerIfNeeded()
} }
///
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
countdownTimer?.invalidate() countdownTimer?.invalidate()
countdownTimer = nil countdownTimer = nil
} }
/// TopBar UI
private func setupTopBar() { private func setupTopBar() {
let topBar = UIView() let topBar = UIView()
topBar.backgroundColor = .white topBar.backgroundColor = .white
@ -85,20 +120,160 @@ final class HomeViewController: UIViewController {
updateScenicTitle() updateScenicTitle()
} }
private func setupTableView() { /// CollectionView
view.addSubview(tableView) private func setupCollectionView() {
tableView.snp.makeConstraints { make in view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(78) make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
} }
private func bindViewModel() { /// Compositional Layout section
viewModel.onChange = { [weak self] in private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
self?.tableView.reloadData() UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
guard let self,
let section = self.dataSource?.snapshot().sectionIdentifiers[safe: sectionIndex]
else {
return CollectionDiffableLayout.fullWidthSection()
}
switch section {
case .workStatus:
return CollectionDiffableLayout.fullWidthSection(height: 100)
case .locationReport:
return CollectionDiffableLayout.fullWidthSection(height: 148)
case .storeInfo:
return CollectionDiffableLayout.fullWidthSection(height: 88)
case .quickActions:
return CollectionDiffableLayout.fullWidthSection(height: 118)
case .commonMenus:
return CollectionDiffableLayout.addHeader(
to: CollectionDiffableLayout.gridSection(itemHeight: 102),
height: 36
)
}
} }
} }
/// Diffable Cell
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<HomeSection, HomeItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
guard let self else { return UICollectionViewCell() }
switch item {
case .workStatus(let online, let seconds, let reminder):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeWorkStatusCell.reuseID,
for: indexPath
) as! HomeWorkStatusCell
cell.configure(
isOnline: online,
countdownText: self.countdownText(seconds: seconds),
reminderText: self.reminderText(minutes: reminder),
onOnlineTap: { [weak self] in self?.onlineTapped() },
onReminderTap: { [weak self] in self?.reminderTapped() }
)
return cell
case .locationReport:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeLocationReportCell.reuseID,
for: indexPath
) as! HomeLocationReportCell
cell.configure(onReportTap: { [weak self] in self?.locationReportTapped() })
return cell
case .storeInfo(let storeID):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeStoreInfoCell.reuseID,
for: indexPath
) as! HomeStoreInfoCell
if let store = self.appServices.accountContext.currentStore, store.id == storeID {
cell.configure(
storeName: store.name,
scenicName: self.appServices.accountContext.currentScenic?.name
)
}
return cell
case .quickActions(let online):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeQuickActionsCell.reuseID,
for: indexPath
) as! HomeQuickActionsCell
cell.configure(
isOnline: online,
onPaymentTap: { [weak self] in self?.paymentTapped() },
onTaskCreateTap: { [weak self] in self?.taskCreateTapped() },
onOnlineTap: { [weak self] in self?.onlineTapped() }
)
return cell
case .menu(let uri):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeMenuItemCell.reuseID,
for: indexPath
) as! HomeMenuItemCell
if let menuItem = self.menuItem(for: uri) ?? self.displayMenuItems.first(where: { $0.uri == uri }) {
cell.configure(item: menuItem)
}
return cell
}
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard let self,
kind == UICollectionView.elementKindSectionHeader,
let section = self.dataSource.snapshot().sectionIdentifiers[safe: indexPath.section],
section == .commonMenus,
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: CollectionSectionHeaderView.reuseID,
for: indexPath
) as? CollectionSectionHeaderView
else { return nil }
header.configure(title: "常用应用")
return header
}
}
/// snapshot diff
private func applySnapshot(animated: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<HomeSection, HomeItem>()
if shouldShowWorkStatus {
snapshot.appendSections([.workStatus, .locationReport])
snapshot.appendItems(
[.workStatus(isOnline: isOnline, secondsUntilReport: secondsUntilReport, reminderMinutes: reminderMinutes)],
toSection: .workStatus
)
snapshot.appendItems([.locationReport], toSection: .locationReport)
}
if isStoreManager, let store = appServices.accountContext.currentStore {
snapshot.appendSections([.storeInfo])
snapshot.appendItems([.storeInfo(storeID: store.id)], toSection: .storeInfo)
}
if shouldShowWorkStatus {
snapshot.appendSections([.quickActions])
snapshot.appendItems([.quickActions(isOnline: isOnline)], toSection: .quickActions)
}
snapshot.appendSections([.commonMenus])
snapshot.appendItems(
displayMenuItems.map { HomeItem.menu(uri: $0.uri) },
toSection: .commonMenus
)
dataSource.apply(snapshot, animatingDifferences: animated)
}
/// ViewModel
private func bindViewModel() {
viewModel.onChange = { [weak self] in
self?.applySnapshot()
}
}
///
private func observeContextChanges() { private func observeContextChanges() {
let services = appServices let services = appServices
services.permissionContext.onChange = { [weak self] in services.permissionContext.onChange = { [weak self] in
@ -106,10 +281,11 @@ final class HomeViewController: UIViewController {
} }
services.accountContext.onChange = { [weak self] in services.accountContext.onChange = { [weak self] in
self?.updateScenicTitle() self?.updateScenicTitle()
self?.tableView.reloadData() self?.applySnapshot()
} }
} }
///
private func rebuildMenus() { private func rebuildMenus() {
let services = appServices let services = appServices
viewModel.buildMenus( viewModel.buildMenus(
@ -117,9 +293,10 @@ final class HomeViewController: UIViewController {
currentRoleId: services.permissionContext.currentRole?.id currentRoleId: services.permissionContext.currentRole?.id
) )
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
tableView.reloadData() applySnapshot()
} }
///
private func updateScenicTitle() { private func updateScenicTitle() {
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区" let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
scenicButton.configuration?.attributedTitle = AttributedString( scenicButton.configuration?.attributedTitle = AttributedString(
@ -130,25 +307,30 @@ final class HomeViewController: UIViewController {
) )
} }
/// ID
private var currentRoleId: Int? { private var currentRoleId: Int? {
appServices.permissionContext.currentRole?.id appServices.permissionContext.currentRole?.id
} }
///
private var shouldShowWorkStatus: Bool { private var shouldShowWorkStatus: Bool {
guard let currentRoleId else { return true } guard let currentRoleId else { return true }
return !minimalTopRoleIds.contains(currentRoleId) return !minimalTopRoleIds.contains(currentRoleId)
} }
/// roleId = 46
private var isStoreManager: Bool { private var isStoreManager: Bool {
currentRoleId == 46 currentRoleId == 46
} }
/// 3 3
private var displayMenuItems: [HomeMenuItem] { private var displayMenuItems: [HomeMenuItem] {
let selected = commonURIs.compactMap { menuItem(for: $0) } let selected = commonURIs.compactMap { menuItem(for: $0) }
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)] return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
} }
/// URI
private func menuItem(for uri: String) -> HomeMenuItem? { private func menuItem(for uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri)) let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
@ -160,31 +342,36 @@ final class HomeViewController: UIViewController {
) )
} }
private var countdownDisplay: String { ///
let hours = secondsUntilReport / 3_600 private func countdownText(seconds: Int) -> String {
let minutes = (secondsUntilReport % 3_600) / 60 let hours = seconds / 3_600
let seconds = secondsUntilReport % 60 let minutes = (seconds % 3_600) / 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" let secs = seconds % 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secs))"
} }
private var reminderText: String { ///
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟" private func reminderText(minutes: Int) -> String {
minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
} }
/// 线
private func startCountdownTimerIfNeeded() { private func startCountdownTimerIfNeeded() {
countdownTimer?.invalidate() countdownTimer?.invalidate()
guard isOnline else { return } guard isOnline else { return }
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return } guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
self.secondsUntilReport -= 1 self.secondsUntilReport -= 1
self.tableView.reloadSections(IndexSet(integer: 0), with: .none) self.applySnapshot(animated: false)
} }
} }
///
@objc private func scenicTapped() { @objc private func scenicTapped() {
HomeMenuRouting.push(.scenicSelection, from: self) HomeMenuRouting.push(.scenicSelection, from: self)
} }
/// 线 / 线
@objc private func onlineTapped() { @objc private func onlineTapped() {
let message = isOnline let message = isOnline
? "是否确认切换为离线状态?离线后将暂停位置上报。" ? "是否确认切换为离线状态?离线后将暂停位置上报。"
@ -200,147 +387,137 @@ final class HomeViewController: UIViewController {
} else { } else {
self.countdownTimer?.invalidate() self.countdownTimer?.invalidate()
} }
self.tableView.reloadSections(IndexSet(integer: 0), with: .none) self.applySnapshot()
}) })
present(alert, animated: true) present(alert, animated: true)
} }
///
@objc private func reminderTapped() { @objc private func reminderTapped() {
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet) let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
for minute in [0, 5, 10, 15, 30] { for minute in [0, 5, 10, 15, 30] {
let title = minute == 0 ? "不提醒" : "\(minute)分钟" let title = minute == 0 ? "不提醒" : "\(minute)分钟"
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
self?.reminderMinutes = minute self?.reminderMinutes = minute
self?.tableView.reloadSections(IndexSet(integer: 0), with: .none) self?.applySnapshot()
}) })
} }
sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true) present(sheet, animated: true)
} }
///
@objc private func locationReportTapped() { @objc private func locationReportTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self) HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
} }
///
@objc private func paymentTapped() { @objc private func paymentTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self) HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
} }
///
@objc private func taskCreateTapped() { @objc private func taskCreateTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self) HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
} }
} }
// MARK: - UITableView // MARK: - UICollectionViewDelegate
extension HomeViewController: UITableViewDataSource, UITableViewDelegate { extension HomeViewController: UICollectionViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { ///
var count = 1 func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if shouldShowWorkStatus { count += 2 } guard let item = dataSource.itemIdentifier(for: indexPath),
if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 } case .menu(let uri) = item,
return count let menuItem = displayMenuItems.first(where: { $0.uri == uri })
else { return }
HomeMenuRouting.openMenu(menuItem, from: self)
}
} }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { // MARK: - Card Cells
1
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { /// 线 Cell线
if section == tableView.numberOfSections - 1 { private final class HomeWorkStatusCell: UICollectionViewCell {
return "常用应用" static let reuseID = "HomeWorkStatusCell"
}
return nil
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { private let cardView = UIView()
if indexPath.section == tableView.numberOfSections - 1 { private let onlineButton = UIButton(type: .system)
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell private let clockLabel = UILabel()
cell.configure(items: displayMenuItems) { [weak self] item in private let reminderButton = UIButton(type: .system)
self.flatMap { HomeMenuRouting.openMenu(item, from: $0) }
}
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) ///
cell.selectionStyle = .none override init(frame: CGRect) {
cell.contentView.subviews.forEach { $0.removeFromSuperview() } super.init(frame: frame)
backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 8
contentView.addSubview(cardView)
if shouldShowWorkStatus {
if indexPath.section == 0 {
cell.contentView.addSubview(makeStatusCard())
} else if indexPath.section == 1 {
cell.contentView.addSubview(makeLocationCard())
} else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore {
cell.contentView.addSubview(makeStoreCard(store))
} else {
cell.contentView.addSubview(makeQuickActionsRow())
}
} else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 {
cell.contentView.addSubview(makeStoreCard(store))
}
cell.contentView.subviews.first?.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(
top: AppMetrics.Spacing.xxSmall,
left: AppMetrics.Spacing.pageHorizontal,
bottom: AppMetrics.Spacing.xxSmall,
right: AppMetrics.Spacing.pageHorizontal
))
}
cell.backgroundColor = .clear
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if indexPath.section == tableView.numberOfSections - 1 { return 240 }
if shouldShowWorkStatus {
switch indexPath.section {
case 0: return 100
case 1: return 148
case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88
default: return 118
}
}
if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 }
return UITableView.automaticDimension
}
private func makeStatusCard() -> UIView {
let card = makeCardView()
let onlineButton = UIButton(type: .system)
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
onlineButton.layer.cornerRadius = 4 onlineButton.layer.cornerRadius = 4
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
let clockLabel = UILabel()
clockLabel.text = " \(countdownDisplay)"
clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
clockLabel.textColor = AppDesignUIKit.primary clockLabel.textColor = AppDesignUIKit.primary
let reminderButton = UIButton(type: .system)
reminderButton.setTitle(" \(reminderText)", for: .normal)
reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal) reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton]) let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
stack.axis = .horizontal stack.axis = .horizontal
stack.distribution = .equalSpacing stack.distribution = .equalSpacing
stack.alignment = .center stack.alignment = .center
card.addSubview(stack) cardView.addSubview(stack)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
stack.snp.makeConstraints { make in stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15) make.edges.equalToSuperview().inset(15)
} }
return card
} }
private func makeLocationCard() -> UIView { @available(*, unavailable)
let card = makeCardView() required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 线
func configure(
isOnline: Bool,
countdownText: String,
reminderText: String,
onOnlineTap: @escaping () -> Void,
onReminderTap: @escaping () -> Void
) {
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
clockLabel.text = " \(countdownText)"
reminderButton.setTitle(" \(reminderText)", for: .normal)
onlineButton.removeAction(identifiedBy: UIAction.Identifier("online"), for: .touchUpInside)
reminderButton.removeAction(identifiedBy: UIAction.Identifier("reminder"), for: .touchUpInside)
onlineButton.addAction(UIAction(identifier: UIAction.Identifier("online")) { _ in onOnlineTap() }, for: .touchUpInside)
reminderButton.addAction(UIAction(identifier: UIAction.Identifier("reminder")) { _ in onReminderTap() }, for: .touchUpInside)
}
}
/// Cell
private final class HomeLocationReportCell: UICollectionViewCell {
static let reuseID = "HomeLocationReportCell"
private let cardView = UIView()
private let actionButton = UIButton(type: .system)
///
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 8
contentView.addSubview(cardView)
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.text = "立即上报" titleLabel.text = "立即上报"
@ -356,15 +533,16 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
textStack.axis = .vertical textStack.axis = .vertical
textStack.spacing = AppMetrics.Spacing.xxSmall textStack.spacing = AppMetrics.Spacing.xxSmall
let actionButton = UIButton(type: .system)
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal) actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
actionButton.tintColor = .white actionButton.tintColor = .white
actionButton.backgroundColor = AppDesignUIKit.primary actionButton.backgroundColor = AppDesignUIKit.primary
actionButton.layer.cornerRadius = 46 actionButton.layer.cornerRadius = 46
actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside)
card.addSubview(textStack) cardView.addSubview(textStack)
card.addSubview(actionButton) cardView.addSubview(actionButton)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
textStack.snp.makeConstraints { make in textStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(15) make.leading.top.bottom.equalToSuperview().inset(15)
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12) make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
@ -374,72 +552,38 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
make.centerY.equalToSuperview() make.centerY.equalToSuperview()
make.width.height.equalTo(92) make.width.height.equalTo(92)
} }
return card
} }
private func makeQuickActionsRow() -> UIView { @available(*, unavailable)
let stack = UIStackView() required init?(coder: NSCoder) {
stack.axis = .horizontal fatalError("init(coder:) has not been implemented")
stack.spacing = AppMetrics.Spacing.small
stack.distribution = .fillEqually
stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped)))
stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped)))
stack.addArrangedSubview(quickActionButton(
icon: isOnline ? "wifi" : "wifi.slash",
title: isOnline ? "在线" : "离线",
action: #selector(onlineTapped),
active: isOnline
))
return stack
} }
private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView { ///
let card = makeCardView() func configure(onReportTap: @escaping () -> Void) {
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white actionButton.removeAction(identifiedBy: UIAction.Identifier("report"), for: .touchUpInside)
actionButton.addAction(UIAction(identifier: UIAction.Identifier("report")) { _ in onReportTap() }, for: .touchUpInside)
let iconView = UIImageView(image: UIImage(systemName: icon))
iconView.tintColor = AppDesignUIKit.primary
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
label.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, label])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.xSmall
stack.alignment = .center
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
} }
iconView.snp.makeConstraints { make in
make.height.equalTo(34)
} }
let button = UIButton(type: .custom) /// Cell
button.addTarget(self, action: action, for: .touchUpInside) private final class HomeStoreInfoCell: UICollectionViewCell {
card.addSubview(button) static let reuseID = "HomeStoreInfoCell"
button.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
card.snp.makeConstraints { make in
make.height.equalTo(102)
}
return card
}
private func makeStoreCard(_ store: BusinessScope) -> UIView { private let cardView = UIView()
let card = makeCardView() private let nameLabel = UILabel()
private let scenicLabel = UILabel()
///
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 8
contentView.addSubview(cardView)
let nameLabel = UILabel()
nameLabel.text = store.name
nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold) nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold)
let scenicLabel = UILabel()
scenicLabel.text = appServices.accountContext.currentScenic?.name
scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
scenicLabel.textColor = UIColor(hex: 0x7B8EAA) scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
scenicLabel.numberOfLines = 2 scenicLabel.numberOfLines = 2
@ -457,8 +601,11 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
textStack.axis = .vertical textStack.axis = .vertical
textStack.spacing = AppMetrics.Spacing.xSmall textStack.spacing = AppMetrics.Spacing.xSmall
card.addSubview(textStack) cardView.addSubview(textStack)
card.addSubview(statusLabel) cardView.addSubview(statusLabel)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
textStack.snp.makeConstraints { make in textStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8) make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
@ -468,39 +615,36 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
make.width.greaterThanOrEqualTo(52) make.width.greaterThanOrEqualTo(52)
make.height.equalTo(22) make.height.equalTo(22)
} }
return card }
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func configure(storeName: String, scenicName: String?) {
nameLabel.text = storeName
scenicLabel.text = scenicName
} }
} }
// MARK: - Menu Grid Cell /// Cell线
private final class HomeQuickActionsCell: UICollectionViewCell {
static let reuseID = "HomeQuickActionsCell"
private final class HomeMenuGridCell: UITableViewCell { private let stackView = UIStackView()
static let reuseID = "HomeMenuGridCell"
private var onSelect: ((HomeMenuItem) -> Void)? ///
private var items: [HomeMenuItem] = [] override init(frame: CGRect) {
super.init(frame: frame)
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 15
layout.minimumLineSpacing = 15
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .clear
view.isScrollEnabled = false
view.dataSource = self
view.delegate = self
view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear backgroundColor = .clear
selectionStyle = .none stackView.axis = .horizontal
contentView.addSubview(collectionView) stackView.spacing = AppMetrics.Spacing.small
collectionView.snp.makeConstraints { make in stackView.distribution = .fillEqually
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) contentView.addSubview(stackView)
make.height.equalTo(220) stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
} }
} }
@ -509,40 +653,87 @@ private final class HomeMenuGridCell: UITableViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) { ///
self.items = items func configure(
self.onSelect = onSelect isOnline: Bool,
collectionView.reloadData() onPaymentTap: @escaping () -> Void,
} onTaskCreateTap: @escaping () -> Void,
} onOnlineTap: @escaping () -> Void
) {
extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { stackView.addArrangedSubview(quickActionCard(
items.count icon: "qrcode",
} title: "立即收款",
active: false,
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { action: onPaymentTap
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell ))
cell.configure(item: items[indexPath.item]) stackView.addArrangedSubview(quickActionCard(
return cell icon: "checklist.checked",
} title: "提交任务",
active: false,
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { action: onTaskCreateTap
onSelect?(items[indexPath.item]) ))
} stackView.addArrangedSubview(quickActionCard(
icon: isOnline ? "wifi" : "wifi.slash",
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { title: isOnline ? "在线" : "离线",
let width = (collectionView.bounds.width - 30) / 3 active: isOnline,
return CGSize(width: width, height: 102) action: onOnlineTap
))
}
///
private func quickActionCard(
icon: String,
title: String,
active: Bool,
action: @escaping () -> Void
) -> UIView {
let card = UIView()
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
card.layer.cornerRadius = 8
let iconView = UIImageView(image: UIImage(systemName: icon))
iconView.tintColor = AppDesignUIKit.primary
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
label.textAlignment = .center
let innerStack = UIStackView(arrangedSubviews: [iconView, label])
innerStack.axis = .vertical
innerStack.spacing = AppMetrics.Spacing.xSmall
innerStack.alignment = .center
card.addSubview(innerStack)
innerStack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.height.equalTo(34)
}
let button = UIButton(type: .custom)
button.addAction(UIAction { _ in action() }, for: .touchUpInside)
card.addSubview(button)
button.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
card.snp.makeConstraints { make in
make.height.equalTo(102)
}
return card
} }
} }
/// Cell
private final class HomeMenuItemCell: UICollectionViewCell { private final class HomeMenuItemCell: UICollectionViewCell {
static let reuseID = "HomeMenuItemCell" static let reuseID = "HomeMenuItemCell"
private let iconView = UIImageView() private let iconView = UIImageView()
private let titleLabel = UILabel() private let titleLabel = UILabel()
///
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
contentView.backgroundColor = .white contentView.backgroundColor = .white
@ -574,6 +765,7 @@ private final class HomeMenuItemCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
///
func configure(item: HomeMenuItem) { func configure(item: HomeMenuItem) {
titleLabel.text = item.title titleLabel.text = item.title
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri)) let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
@ -581,3 +773,10 @@ private final class HomeMenuItemCell: UICollectionViewCell {
iconView.tintColor = AppDesign.primary iconView.tintColor = AppDesign.primary
} }
} }
/// section
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View File

@ -20,6 +20,7 @@ final class PhotographerInviteViewController: UIViewController {
private let rulesLabel = UILabel() private let rulesLabel = UILabel()
private let activityIndicator = UIActivityIndicatorView(style: .medium) private let activityIndicator = UIActivityIndicatorView(style: .medium)
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "邀请摄影师" title = "邀请摄影师"
@ -29,6 +30,7 @@ final class PhotographerInviteViewController: UIViewController {
Task { await viewModel.reload(api: services.inviteAPI) } Task { await viewModel.reload(api: services.inviteAPI) }
} }
/// UI UI
private func setupUI() { private func setupUI() {
navigationItem.rightBarButtonItems = [ navigationItem.rightBarButtonItems = [
UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)), UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)),
@ -64,6 +66,7 @@ final class PhotographerInviteViewController: UIViewController {
contentStack.addArrangedSubview(rulesLabel) contentStack.addArrangedSubview(rulesLabel)
} }
/// render
private func render() { private func render() {
activityIndicator.isHidden = !viewModel.loading activityIndicator.isHidden = !viewModel.loading
if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
@ -76,7 +79,9 @@ final class PhotographerInviteViewController: UIViewController {
} }
} }
///
@objc private func copyCode() { viewModel.copyInviteCode() } @objc private func copyCode() { viewModel.copyInviteCode() }
///
@objc private func copyURL() { viewModel.copyInviteUrl() } @objc private func copyURL() { viewModel.copyInviteUrl() }
} }
@ -87,6 +92,7 @@ final class InviteRecordViewController: ModuleTableViewController {
private let viewModel = InviteRecordViewModel() private let viewModel = InviteRecordViewModel()
private let summaryLabel = UILabel() private let summaryLabel = UILabel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "邀请记录" title = "邀请记录"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -103,6 +109,7 @@ final class InviteRecordViewController: ModuleTableViewController {
} }
} }
/// Header UI
private func setupHeader() { private func setupHeader() {
summaryLabel.font = .systemFont(ofSize: 14) summaryLabel.font = .systemFont(ofSize: 14)
summaryLabel.textColor = AppDesign.textSecondary summaryLabel.textColor = AppDesign.textSecondary
@ -112,10 +119,12 @@ final class InviteRecordViewController: ModuleTableViewController {
tableView.tableHeaderView = summaryLabel tableView.tableHeaderView = summaryLabel
} }
/// tableCount
override func tableRowCount() -> Int { override func tableRowCount() -> Int {
viewModel.displayRows.count viewModel.displayRows.count
} }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let row = viewModel.displayRows[indexPath.row] let row = viewModel.displayRows[indexPath.row]
cell.configure( cell.configure(
@ -125,6 +134,7 @@ final class InviteRecordViewController: ModuleTableViewController {
) )
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload( await viewModel.reload(
inviteAPI: services.inviteAPI, inviteAPI: services.inviteAPI,
@ -134,11 +144,13 @@ final class InviteRecordViewController: ModuleTableViewController {
updateSummary() updateSummary()
} }
/// Summary
private func updateSummary() { private func updateSummary() {
summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)" summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)"
navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户" navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户"
} }
/// toggleTab
@objc private func toggleTab() { @objc private func toggleTab() {
Task { Task {
let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite

View File

@ -61,6 +61,7 @@ final class PhotographerInviteViewModel {
return UIImage(cgImage: cgImage) return UIImage(cgImage: cgImage)
} }
///
private func clear() { private func clear() {
inviteCode = "" inviteCode = ""
inviteUrl = "" inviteUrl = ""
@ -128,6 +129,7 @@ final class InviteRecordViewModel {
await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true) await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
} }
/// InviteRows
private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws { private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws {
let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize) let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize)
let mapped = users.map { let mapped = users.map {
@ -152,6 +154,7 @@ final class InviteRecordViewModel {
hasMore = users.count >= invitePageSize hasMore = users.count >= invitePageSize
} }
/// RewardRows
private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws { private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws {
let response = try await api.walletEarningDetail( let response = try await api.walletEarningDetail(
startDate: "2025-01-01", startDate: "2025-01-01",
@ -183,6 +186,7 @@ final class InviteRecordViewModel {
hasMore = rewardRows.count < response.total && !mapped.isEmpty hasMore = rewardRows.count < response.total && !mapped.isEmpty
} }
///
private func clear() { private func clear() {
totalRewardText = "¥ 0.00" totalRewardText = "¥ 0.00"
withdrawableText = "¥ 0.00" withdrawableText = "¥ 0.00"

View File

@ -10,17 +10,29 @@ import Foundation
/// ///
@MainActor @MainActor
protocol LiveServing { protocol LiveServing {
///
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
///
func liveDetail(liveId: Int) async throws -> LiveEntity func liveDetail(liveId: Int) async throws -> LiveEntity
///
func liveCreate(_ request: LiveCreateRequest) async throws func liveCreate(_ request: LiveCreateRequest) async throws
///
func liveStart(liveId: Int) async throws func liveStart(liveId: Int) async throws
///
func liveStop(liveId: Int) async throws func liveStop(liveId: Int) async throws
/// Finish
func liveFinish(liveId: Int) async throws func liveFinish(liveId: Int) async throws
/// SetMode
func liveSetPushMode(liveId: Int, mode: Int) async throws func liveSetPushMode(liveId: Int, mode: Int) async throws
///
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
///
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
///
func liveAlbumDeleteFolder(folderId: Int) async throws func liveAlbumDeleteFolder(folderId: Int) async throws
///
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
/// Files
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
} }

View File

@ -14,6 +14,7 @@ struct LiveListResponse: Decodable {
let page: Int let page: Int
let pageSize: Int let pageSize: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case items case items
case total case total
@ -21,6 +22,7 @@ struct LiveListResponse: Decodable {
case pageSize = "page_size" case pageSize = "page_size"
} }
///
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) { init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
self.items = items self.items = items
self.total = total self.total = total
@ -28,6 +30,7 @@ struct LiveListResponse: Decodable {
self.pageSize = pageSize self.pageSize = pageSize
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? [] items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
@ -57,6 +60,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
let manualPushState: Int let manualPushState: Int
let viewsCount: Int let viewsCount: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case title case title
@ -77,6 +81,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
case viewsCount = "views_count" case viewsCount = "views_count"
} }
///
init( init(
id: Int = 0, id: Int = 0,
title: String = "", title: String = "",
@ -115,6 +120,7 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
self.viewsCount = viewsCount self.viewsCount = viewsCount
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
@ -151,6 +157,7 @@ struct LiveCreateRequest: Encodable, Equatable {
let title: String let title: String
let coverImg: String let coverImg: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id" case scenicId = "scenic_id"
case title case title
@ -162,6 +169,7 @@ struct LiveCreateRequest: Encodable, Equatable {
struct LiveControlRequest: Encodable, Equatable { struct LiveControlRequest: Encodable, Equatable {
let liveId: Int let liveId: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case liveId = "live_id" case liveId = "live_id"
} }
@ -172,6 +180,7 @@ struct LivePushModeRequest: Encodable, Equatable {
let liveId: Int let liveId: Int
let manualPushMode: Int let manualPushMode: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case liveId = "live_id" case liveId = "live_id"
case manualPushMode = "manual_push_mode" case manualPushMode = "manual_push_mode"
@ -186,6 +195,7 @@ struct LiveAlbumFolderListResponse: Decodable {
let total: Int let total: Int
let totalPages: Int let totalPages: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case items case items
case page case page
@ -194,6 +204,7 @@ struct LiveAlbumFolderListResponse: Decodable {
case totalPages = "total_pages" case totalPages = "total_pages"
} }
///
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) { init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
self.items = items self.items = items
self.page = page self.page = page
@ -202,6 +213,7 @@ struct LiveAlbumFolderListResponse: Decodable {
self.totalPages = totalPages self.totalPages = totalPages
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? [] items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
@ -220,6 +232,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
let creator: String let creator: String
let items: [LiveAlbumFileItem] let items: [LiveAlbumFileItem]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case albumId = "album_id" case albumId = "album_id"
@ -228,6 +241,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
case items case items
} }
///
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) { init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
self.id = id self.id = id
self.albumId = albumId self.albumId = albumId
@ -236,6 +250,7 @@ struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
self.items = items self.items = items
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
@ -254,6 +269,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
let size: Int64 let size: Int64
let coverImg: String? let coverImg: String?
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case url case url
@ -262,6 +278,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
case coverImg = "cover_img" case coverImg = "cover_img"
} }
///
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) { init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
self.id = id self.id = id
self.url = url self.url = url
@ -270,6 +287,7 @@ struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
self.coverImg = coverImg self.coverImg = coverImg
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
@ -295,6 +313,7 @@ struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
let name: String let name: String
let items: [LiveAlbumCreateFileItem] let items: [LiveAlbumCreateFileItem]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id" case scenicId = "scenic_id"
case name case name
@ -309,6 +328,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
let size: Int64 let size: Int64
let coverImg: String? let coverImg: String?
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case url case url
case type case type
@ -321,6 +341,7 @@ struct LiveAlbumCreateFileItem: Encodable, Equatable {
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable { struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
let folderId: Int let folderId: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case folderId = "folder_id" case folderId = "folder_id"
} }
@ -331,6 +352,7 @@ struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
let folderId: Int let folderId: Int
let fileIds: [Int] let fileIds: [Int]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case folderId = "folder_id" case folderId = "folder_id"
case fileIds = "file_ids" case fileIds = "file_ids"
@ -346,6 +368,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
let size: Int64 let size: Int64
var uploadedURL: String? var uploadedURL: String?
///
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) { init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
self.id = id self.id = id
self.data = data self.data = data
@ -359,6 +382,7 @@ struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
} }
private extension KeyedDecodingContainer { private extension KeyedDecodingContainer {
///
func liveDecodeLossyString(forKey key: Key) throws -> String { func liveDecodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) { if let value = try? decodeIfPresent(String.self, forKey: key) {
return value return value
@ -378,6 +402,7 @@ private extension KeyedDecodingContainer {
return "" return ""
} }
///
func liveDecodeLossyInt(forKey key: Key) throws -> Int? { func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value return value

View File

@ -11,14 +11,17 @@ import UIKit
final class LiveManagementViewController: ModuleTableViewController { final class LiveManagementViewController: ModuleTableViewController {
private let viewModel = LiveManagementViewModel() private let viewModel = LiveManagementViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "直播管理" title = "直播管理"
super.viewDidLoad() super.viewDidLoad()
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.items.count } override func tableRowCount() -> Int { viewModel.items.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row] let item = viewModel.items[indexPath.row]
cell.configure( cell.configure(
@ -28,10 +31,12 @@ final class LiveManagementViewController: ModuleTableViewController {
) )
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
} }
/// table
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.row >= viewModel.items.count - 2 else { return } guard indexPath.row >= viewModel.items.count - 2 else { return }
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) } Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
@ -44,19 +49,23 @@ extension LiveManagementViewModel: ViewModelBindable {}
final class LiveAlbumViewController: ModuleTableViewController { final class LiveAlbumViewController: ModuleTableViewController {
private let viewModel = LiveAlbumViewModel() private let viewModel = LiveAlbumViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "直播相册" title = "直播相册"
super.viewDidLoad() super.viewDidLoad()
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.folders.count } override func tableRowCount() -> Int { viewModel.folders.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let folder = viewModel.folders[indexPath.row] let folder = viewModel.folders[indexPath.row]
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count)") cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count)")
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
} }

View File

@ -10,10 +10,12 @@ import Foundation
/// RTMP /// RTMP
enum LivePlaybackURLResolver { enum LivePlaybackURLResolver {
/// playable
static func playableURL(from live: LiveEntity) -> URL? { static func playableURL(from live: LiveEntity) -> URL? {
playableURL(from: live.playbackURLCandidates) playableURL(from: live.playbackURLCandidates)
} }
/// playable
static func playableURL(from candidates: [String]) -> URL? { static func playableURL(from candidates: [String]) -> URL? {
for candidate in candidates { for candidate in candidates {
if let url = playableURL(from: candidate) { if let url = playableURL(from: candidate) {
@ -23,6 +25,7 @@ enum LivePlaybackURLResolver {
return nil return nil
} }
/// playable
static func playableURL(from rawValue: String) -> URL? { static func playableURL(from rawValue: String) -> URL? {
let value = rawValue.liveTrimmed let value = rawValue.liveTrimmed
guard !value.isEmpty, let url = URL(string: value) else { return nil } guard !value.isEmpty, let url = URL(string: value) else { return nil }
@ -65,6 +68,7 @@ final class LivePlaybackViewModel {
return false return false
} }
///
init(live: LiveEntity? = nil, urlString: String? = nil) { init(live: LiveEntity? = nil, urlString: String? = nil) {
if let live { if let live {
load(live: live) load(live: live)
@ -73,14 +77,17 @@ final class LivePlaybackViewModel {
} }
} }
///
func load(live: LiveEntity) { func load(live: LiveEntity) {
load(url: LivePlaybackURLResolver.playableURL(from: live)) load(url: LivePlaybackURLResolver.playableURL(from: live))
} }
///
func load(urlString: String) { func load(urlString: String) {
load(url: LivePlaybackURLResolver.playableURL(from: urlString)) load(url: LivePlaybackURLResolver.playableURL(from: urlString))
} }
/// play
func play() { func play() {
guard let url = playableURL else { return } guard let url = playableURL else { return }
if player == nil { if player == nil {
@ -90,12 +97,14 @@ final class LivePlaybackViewModel {
state = .playing(url) state = .playing(url)
} }
/// pause
func pause() { func pause() {
guard let url = playableURL else { return } guard let url = playableURL else { return }
player?.pause() player?.pause()
state = .ready(url) state = .ready(url)
} }
///
func reload() { func reload() {
guard let url = playableURL else { return } guard let url = playableURL else { return }
releasePlayer() releasePlayer()
@ -103,6 +112,7 @@ final class LivePlaybackViewModel {
state = .ready(url) state = .ready(url)
} }
/// release
func release() { func release() {
releasePlayer() releasePlayer()
if let url = playableURL { if let url = playableURL {
@ -112,6 +122,7 @@ final class LivePlaybackViewModel {
} }
} }
///
private func load(url: URL?) { private func load(url: URL?) {
releasePlayer() releasePlayer()
guard let url else { guard let url else {
@ -124,6 +135,7 @@ final class LivePlaybackViewModel {
state = .ready(url) state = .ready(url)
} }
/// releasePlayer
private func releasePlayer() { private func releasePlayer() {
player?.pause() player?.pause()
player = nil player = nil

View File

@ -47,25 +47,34 @@ enum LivePushNetworkState: Equatable {
protocol LivePushAdapter { protocol LivePushAdapter {
var name: String { get } var name: String { get }
var isAvailable: Bool { get } var isAvailable: Bool { get }
/// prepare
func prepare(pushURL: URL) async throws func prepare(pushURL: URL) async throws
/// start
func start() async throws func start() async throws
/// stop
func stop() async throws func stop() async throws
/// dispose
func dispose() async func dispose() async
} }
/// AVFoundation 便 /// AVFoundation 便
protocol LivePermissionProviding { protocol LivePermissionProviding {
/// camera
func cameraPermission() async -> LivePushPermissionState func cameraPermission() async -> LivePushPermissionState
/// microphone
func microphonePermission() async -> LivePushPermissionState func microphonePermission() async -> LivePushPermissionState
} }
/// NWPathMonitor 便 /// NWPathMonitor 便
protocol LiveNetworkMonitoring: AnyObject { protocol LiveNetworkMonitoring: AnyObject {
var currentState: LivePushNetworkState { get } var currentState: LivePushNetworkState { get }
/// start
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
/// stop
func stop() func stop()
} }
/// LivePushReadiness
enum LivePushReadinessError: LocalizedError, Equatable { enum LivePushReadinessError: LocalizedError, Equatable {
case missingPushURL case missingPushURL
case invalidPushURL case invalidPushURL
@ -89,32 +98,41 @@ enum LivePushReadinessError: LocalizedError, Equatable {
} }
} }
/// UnsupportedLivePushAdapter
struct UnsupportedLivePushAdapter: LivePushAdapter { struct UnsupportedLivePushAdapter: LivePushAdapter {
let name = "未接入推流 SDK" let name = "未接入推流 SDK"
let isAvailable = false let isAvailable = false
/// prepare
func prepare(pushURL: URL) async throws { func prepare(pushURL: URL) async throws {
throw LivePushReadinessError.sdkUnavailable throw LivePushReadinessError.sdkUnavailable
} }
/// start
func start() async throws { func start() async throws {
throw LivePushReadinessError.sdkUnavailable throw LivePushReadinessError.sdkUnavailable
} }
/// stop
func stop() async throws {} func stop() async throws {}
/// dispose
func dispose() async {} func dispose() async {}
} }
/// SystemLivePermission
struct SystemLivePermissionProvider: LivePermissionProviding { struct SystemLivePermissionProvider: LivePermissionProviding {
/// camera
func cameraPermission() async -> LivePushPermissionState { func cameraPermission() async -> LivePushPermissionState {
await permission(for: .video) await permission(for: .video)
} }
/// microphone
func microphonePermission() async -> LivePushPermissionState { func microphonePermission() async -> LivePushPermissionState {
await permission(for: .audio) await permission(for: .audio)
} }
/// permission
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState { private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
switch AVCaptureDevice.authorizationStatus(for: mediaType) { switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized: case .authorized:
@ -130,6 +148,7 @@ struct SystemLivePermissionProvider: LivePermissionProviding {
} }
} }
/// SystemLiveNetworkMonitor
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
var onChange: (() -> Void)? var onChange: (() -> Void)?
@ -137,6 +156,7 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
private let queue = DispatchQueue(label: "com.suixinkan.live.network") private let queue = DispatchQueue(label: "com.suixinkan.live.network")
private(set) var currentState: LivePushNetworkState = .unknown private(set) var currentState: LivePushNetworkState = .unknown
/// start
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) { func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
monitor.pathUpdateHandler = { [weak self] path in monitor.pathUpdateHandler = { [weak self] path in
let state = Self.state(from: path) let state = Self.state(from: path)
@ -146,10 +166,12 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
monitor.start(queue: queue) monitor.start(queue: queue)
} }
/// stop
func stop() { func stop() {
monitor.cancel() monitor.cancel()
} }
/// state
private static func state(from path: NWPath) -> LivePushNetworkState { private static func state(from path: NWPath) -> LivePushNetworkState {
guard path.status == .satisfied else { return .unavailable } guard path.status == .satisfied else { return .unavailable }
if path.usesInterfaceType(.wifi) { return .wifi } if path.usesInterfaceType(.wifi) { return .wifi }
@ -177,6 +199,7 @@ final class LivePushReadinessViewModel {
private let adapter: any LivePushAdapter private let adapter: any LivePushAdapter
private var pushURL: URL? private var pushURL: URL?
///
init( init(
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(), permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(), networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
@ -190,6 +213,7 @@ final class LivePushReadinessViewModel {
self.networkState = networkMonitor.currentState self.networkState = networkMonitor.currentState
} }
///
func configure(pushURL rawValue: String) { func configure(pushURL rawValue: String) {
let value = rawValue.liveTrimmed let value = rawValue.liveTrimmed
guard !value.isEmpty else { guard !value.isEmpty else {
@ -206,6 +230,7 @@ final class LivePushReadinessViewModel {
errorMessage = nil errorMessage = nil
} }
/// startMonitoring
func startMonitoring() { func startMonitoring() {
networkState = networkMonitor.currentState networkState = networkMonitor.currentState
networkMonitor.start { [weak self] state in networkMonitor.start { [weak self] state in
@ -215,10 +240,12 @@ final class LivePushReadinessViewModel {
} }
} }
/// stopMonitoring
func stopMonitoring() { func stopMonitoring() {
networkMonitor.stop() networkMonitor.stop()
} }
/// refreshPermissions
func refreshPermissions() async { func refreshPermissions() async {
async let camera = permissionProvider.cameraPermission() async let camera = permissionProvider.cameraPermission()
async let microphone = permissionProvider.microphonePermission() async let microphone = permissionProvider.microphonePermission()
@ -226,6 +253,7 @@ final class LivePushReadinessViewModel {
microphonePermission = await microphone microphonePermission = await microphone
} }
/// runDiagnostics
func runDiagnostics() throws { func runDiagnostics() throws {
do { do {
try validateReadiness() try validateReadiness()
@ -240,6 +268,7 @@ final class LivePushReadinessViewModel {
errorMessage = nil errorMessage = nil
} }
/// prepare
func prepare() async throws { func prepare() async throws {
try validateReadiness() try validateReadiness()
guard let pushURL else { throw LivePushReadinessError.missingPushURL } guard let pushURL else { throw LivePushReadinessError.missingPushURL }
@ -253,6 +282,7 @@ final class LivePushReadinessViewModel {
} }
} }
/// start
func startPush() async throws { func startPush() async throws {
try validateReadiness() try validateReadiness()
guard adapter.isAvailable else { guard adapter.isAvailable else {
@ -265,11 +295,13 @@ final class LivePushReadinessViewModel {
errorMessage = nil errorMessage = nil
} }
/// stop
func stopPush() async { func stopPush() async {
try? await adapter.stop() try? await adapter.stop()
running = false running = false
} }
/// dispose
func dispose() async { func dispose() async {
stopMonitoring() stopMonitoring()
await adapter.dispose() await adapter.dispose()
@ -277,6 +309,7 @@ final class LivePushReadinessViewModel {
prepared = false prepared = false
} }
/// Readiness
private func validateReadiness() throws { private func validateReadiness() throws {
guard pushURL != nil else { guard pushURL != nil else {
throw LivePushReadinessError.missingPushURL throw LivePushReadinessError.missingPushURL

View File

@ -104,6 +104,7 @@ final class LiveManagementViewModel {
await reload(api: api, scenicId: scenicId, showLoading: false) await reload(api: api, scenicId: scenicId, showLoading: false)
} }
/// Page
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize) let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
if page == 1 { if page == 1 {
@ -118,6 +119,7 @@ final class LiveManagementViewModel {
hasMore = items.count < total hasMore = items.count < total
} }
///
private func reset() { private func reset() {
clearListAndDetail() clearListAndDetail()
loading = false loading = false
@ -125,6 +127,7 @@ final class LiveManagementViewModel {
errorMessage = nil errorMessage = nil
} }
///
private func clearListAndDetail() { private func clearListAndDetail() {
items = [] items = []
detail = nil detail = nil
@ -143,6 +146,7 @@ final class LiveDetailViewModel {
var actionInFlight = false { didSet { onChange?() } } var actionInFlight = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } }
///
init(detail: LiveEntity) { init(detail: LiveEntity) {
self.detail = detail self.detail = detail
} }
@ -265,6 +269,7 @@ final class LiveAlbumViewModel {
await reload(api: api, scenicId: scenicId, showLoading: false) await reload(api: api, scenicId: scenicId, showLoading: false)
} }
/// Page
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
let response = try await api.liveAlbumList( let response = try await api.liveAlbumList(
scenicId: scenicId, scenicId: scenicId,
@ -284,6 +289,7 @@ final class LiveAlbumViewModel {
hasMore = folders.count < total hasMore = folders.count < total
} }
///
private func reset() { private func reset() {
clearFolders() clearFolders()
loading = false loading = false
@ -291,6 +297,7 @@ final class LiveAlbumViewModel {
errorMessage = nil errorMessage = nil
} }
/// Folders
private func clearFolders() { private func clearFolders() {
folders = [] folders = []
page = 1 page = 1
@ -377,6 +384,7 @@ final class LiveAlbumPreviewViewModel {
var loading = false { didSet { onChange?() } } var loading = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } }
///
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) { init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
self.folderId = folderId self.folderId = folderId
currentIndex = max(startIndex, 0) currentIndex = max(startIndex, 0)

View File

@ -39,6 +39,7 @@ struct LocationReportSubmitResponse: Decodable, Equatable {
let expired: Int let expired: Int
let status: Int let status: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case staffId = "staff_id" case staffId = "staff_id"
case expired case expired
@ -73,6 +74,7 @@ struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
let remark: String let remark: String
let createdAt: String let createdAt: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case staffId = "staff_id" case staffId = "staff_id"

View File

@ -11,6 +11,7 @@ import UIKit
final class LocationReportViewController: ModuleTableViewController { final class LocationReportViewController: ModuleTableViewController {
private let viewModel = LocationReportViewModel() private let viewModel = LocationReportViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "位置上报" title = "位置上报"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -23,17 +24,21 @@ final class LocationReportViewController: ModuleTableViewController {
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
override func numberOfSections(in tableView: UITableView) -> Int { 2 } /// section
override func numberOfTableSections() -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { /// section
override func tableRowCount(in section: Int) -> Int {
section == 0 ? 3 : 1 section == 0 ? 3 : 1
} }
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { /// section
override func tableSectionTitle(for section: Int) -> String? {
section == 0 ? "状态" : "操作" section == 0 ? "状态" : "操作"
} }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { /// Cell
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
let cell = tableView.dequeueReusableCell( let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath for: indexPath
@ -55,8 +60,8 @@ final class LocationReportViewController: ModuleTableViewController {
return cell return cell
} }
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { ///
tableView.deselectRow(at: indexPath, animated: true) override func didSelectTableRow(at indexPath: IndexPath) {
if indexPath.section == 1 { if indexPath.section == 1 {
Task { Task {
_ = await viewModel.setOnline( _ = await viewModel.setOnline(
@ -69,10 +74,12 @@ final class LocationReportViewController: ModuleTableViewController {
} }
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入") viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入")
} }
/// Report
@objc private func submitReport() { @objc private func submitReport() {
Task { Task {
_ = await viewModel.submit( _ = await viewModel.submit(
@ -91,25 +98,30 @@ extension LocationReportViewModel: ViewModelBindable {}
final class LocationReportHistoryViewController: ModuleTableViewController { final class LocationReportHistoryViewController: ModuleTableViewController {
private let viewModel = LocationReportHistoryViewModel() private let viewModel = LocationReportHistoryViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "上报历史" title = "上报历史"
super.viewDidLoad() super.viewDidLoad()
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { override func tableRowCount() -> Int {
viewModel.items.count viewModel.items.count
} }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let record = viewModel.items[indexPath.row] let record = viewModel.items[indexPath.row]
cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt) cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt)
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI) await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI)
} }
/// table
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.row >= viewModel.items.count - 2 else { return } guard indexPath.row >= viewModel.items.count - 2 else { return }
Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) } Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) }

View File

@ -2,14 +2,9 @@
## 模块职责 ## 模块职责
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口 Main 模块负责登录后的主界面 Tab 容器。
主界面由 `MainTabsView` 承载: 主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`,导航路径由 `AppRouter` 单独保存。
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`
- 默认使用自定义 TabBar如需切回系统实现将配置改为 `.system`
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
## Tab 结构 ## Tab 结构
@ -19,41 +14,33 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
- `statistics`:数据 - `statistics`:数据
- `profile`:我的 - `profile`:我的
`HomeRootView` 已接入真实的 `HomeView``OrdersRootView` 已接入真实的 `OrdersView``StatisticsRootView` 已接入真实的 `StatisticsView``ProfileRootView` 已接入真实的 `ProfileView` 各 Tab 根页面已接入真实业务页面:`HomeViewController``OrdersViewController``StatisticsViewController``ProfileViewController`
## 导航流程 ## 导航流程
1. `MainTabsView` 从 Environment 读取 `AppRouter` 1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView``SystemMainTabsView` 2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态 3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController`
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)` 4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar默认隐藏
5. 路径绑定来自 `appRouter.binding(for:)` 5. 跨 Tab 跳转通过 `AppRouter.select` `AppRouter.selectOrders(entry:)` 处理
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
## 自定义 TabBar ## 订单角标
自定义 TabBar 由 `CustomMainTabsView``CustomMainTabBar` 组成:
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
- `CustomMainTabBar` 只负责展示 UI不直接读取账号、订单 API 或业务上下文。
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。 - `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage` - 角标展示在订单 Tab 的 `tabBarItem.badgeValue`
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果 - 账号景区或门店变化、切换到订单 Tab 时会刷新角标
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar也不会保留底部占位高度。
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。 ## 扫码核销
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
## 后续迁移规则 ## 后续迁移规则
迁移新页面时: 迁移新页面时:
- 优先替换对应 Tab 的 RootView - 优先替换对应 Tab 的根 ViewController
- 保持每个 Tab 自己的 `NavigationStack` - 保持每个 Tab 自己的 `TabNavigationController` 导航栈
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。 - 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)``AppRouter.routeToOrderVerification(scannedCode:)` - 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)``AppRouter.routeToOrderVerification(scannedCode:)`
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。 - Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
- 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。 - 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。
- 不要把业务状态塞进自定义 TabBar 组件TabBar 只接收绑定、文案和动作回调。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。 - 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

@ -3,24 +3,22 @@
// suixinkan // suixinkan
// //
import SnapKit
import UIKit import UIKit
@MainActor @MainActor
/// Tab 使 /// Tab 使 `UITabBarController`
final class MainTabBarController: UIViewController { final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
private let services: AppServices private let services: AppServices
private let badgeViewModel = MainTabBadgeViewModel() private let badgeViewModel = MainTabBadgeViewModel()
private let contentContainer = UIView()
private let customTabBar = CustomMainTabBarView()
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:] private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
private var loadedTabs: Set<AppTab> = [.home] private var isSyncingTabSelection = false
///
init(services: AppServices) { init(services: AppServices) {
self.services = services self.services = services
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
delegate = self
} }
@available(*, unavailable) @available(*, unavailable)
@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .white configureTabBarAppearance()
configureLayout() configureTabs()
bindRouter() bindRouter()
bindBadgeViewModel() bindBadgeViewModel()
bindAccountContext() bindAccountContext()
switchToTab(services.appRouter.selectedTab, animated: false) syncSelectedTabFromRouter(animated: false)
Task { await refreshOrderBadge() } Task { await refreshOrderBadge() }
#if DEBUG #if DEBUG
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) } Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
#endif #endif
} }
/// TabBar push /// TabBar
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) { private func configureTabBarAppearance() {
let updates = { tabBar.tintColor = AppDesign.primary
self.customTabBar.alpha = hidden ? 0 : 1 tabBar.backgroundColor = .white
self.customTabBar.isUserInteractionEnabled = !hidden
} }
guard animated else { /// Tab TabBarItem
updates() private func configureTabs() {
return viewControllers = AppTab.allCases.map { tab in
} let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarItem = tab.makeTabBarItem()
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates) tabNavigationControllers[tab] = navigationController
} return navigationController
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)
} }
} }
/// Router Tab
private func bindRouter() { private func bindRouter() {
services.appRouter.onChange = { [weak self] in services.appRouter.onChange = { [weak self] in
self?.handleRouterChange() self?.handleRouterChange()
} }
} }
/// ViewModel
private func bindBadgeViewModel() { private func bindBadgeViewModel() {
badgeViewModel.onChange = { [weak self] in badgeViewModel.onChange = { [weak self] in
self?.updateOrderBadge() self?.updateOrderBadge()
} }
} }
///
private func bindAccountContext() { private func bindAccountContext() {
services.accountContext.onChange = { [weak self] in services.accountContext.onChange = { [weak self] in
Task { await self?.refreshOrderBadge() } Task { await self?.refreshOrderBadge() }
} }
} }
/// Router Tab `AppRouter`
private func handleRouterChange() { private func handleRouterChange() {
switchToTab(services.appRouter.selectedTab, animated: false) syncSelectedTabFromRouter(animated: false)
if services.appRouter.selectedTab == .orders { if services.appRouter.selectedTab == .orders {
Task { await refreshOrderBadge() } Task { await refreshOrderBadge() }
} }
} }
private func switchToTab(_ tab: AppTab, animated: Bool) { /// `AppRouter.selectedTab` TabBar
loadedTabs.insert(tab) private func syncSelectedTabFromRouter(animated: Bool) {
customTabBar.selectedTab = tab guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return }
guard selectedIndex != index else { return }
let navigationController = navigationController(for: tab) isSyncingTabSelection = true
for child in children where child !== navigationController { selectedIndex = index
child.willMove(toParent: nil) isSyncingTabSelection = false
child.view.removeFromSuperview()
child.removeFromParent()
}
addChild(navigationController)
contentContainer.addSubview(navigationController.view)
navigationController.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
navigationController.didMove(toParent: self)
let isRoot = navigationController.viewControllers.count <= 1
setCustomTabBarHidden(!isRoot, animated: animated)
}
private func navigationController(for tab: AppTab) -> TabNavigationController {
if let existing = tabNavigationControllers[tab] {
return existing
}
let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarHost = self
tabNavigationControllers[tab] = navigationController
return navigationController
} }
/// Tab
private func updateOrderBadge() { private func updateOrderBadge() {
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else { let badgeValue: String?
customTabBar.orderBadgeText = nil if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
return badgeValue = "\(count)"
} else {
badgeValue = nil
} }
customTabBar.orderBadgeText = "\(count)" tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
} }
/// Tab
private func refreshOrderBadge() async { private func refreshOrderBadge() async {
await badgeViewModel.refreshPendingWriteOffCount( await badgeViewModel.refreshPendingWriteOffCount(
api: services.ordersAPI, api: services.ordersAPI,
@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController {
) )
} }
private func presentScanner() { /// TabBar `AppRouter`
let scanner = OrderCodeScannerViewController() func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
scanner.onScanResult = { [weak self] result in guard !isSyncingTabSelection,
guard let self else { return } let navigationController = viewController as? TabNavigationController else { return }
switch result {
case .success(let rawCode): services.appRouter.select(navigationController.appTab)
dismiss(animated: true) { if navigationController.appTab == .orders {
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode) Task { await refreshOrderBadge() }
}
case .failure(let error):
showToast(error.localizedDescription)
} }
} }
let navigation = UINavigationController(rootViewController: scanner)
navigation.modalPresentationStyle = .fullScreen
present(navigation, animated: true)
}
}
/// TabBar
private final class CustomMainTabBarView: UIView {
var selectedTab: AppTab = .home {
didSet { refreshSelection() }
}
var orderBadgeText: String? {
didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil }
}
var onTabSelected: ((AppTab) -> Void)?
var onScanTapped: (() -> Void)?
private struct TabItem {
let tab: AppTab
let title: String
let selectedImage: String
let unselectedImage: String
}
private let items: [TabItem] = [
.init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"),
.init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"),
.init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"),
.init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected")
]
private var tabButtons: [AppTab: UIButton] = [:]
private var tabTitleLabels: [AppTab: UILabel] = [:]
private let ordersBadgeLabel = UILabel()
private let topDivider = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
configureViews()
refreshSelection()
}
required init?(coder: NSCoder) {
nil
}
private func configureViews() {
topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04)
addSubview(topDivider)
topDivider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
let leftStack = UIStackView()
leftStack.axis = .horizontal
leftStack.distribution = .fillEqually
leftStack.spacing = 0
let rightStack = UIStackView()
rightStack.axis = .horizontal
rightStack.distribution = .fillEqually
rightStack.spacing = 0
for (index, item) in items.enumerated() {
let buttonContainer = makeTabButton(for: item)
if index < 2 {
leftStack.addArrangedSubview(buttonContainer)
} else {
rightStack.addArrangedSubview(buttonContainer)
}
}
let scanButton = UIButton(type: .system)
scanButton.backgroundColor = AppDesign.primary
scanButton.layer.cornerRadius = 29
scanButton.tintColor = .white
scanButton.setImage(
UIImage(systemName: "qrcode.viewfinder")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
),
for: .normal
)
scanButton.accessibilityLabel = "扫码核销"
scanButton.accessibilityIdentifier = "main.scan"
scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside)
let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack])
row.axis = .horizontal
row.alignment = .center
row.spacing = 0
addSubview(row)
scanButton.snp.makeConstraints { make in
make.width.equalTo(74)
make.height.equalTo(64)
}
row.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(22)
make.top.bottom.equalToSuperview()
}
leftStack.snp.makeConstraints { make in
make.width.equalTo(rightStack)
}
}
private func makeTabButton(for item: TabItem) -> UIView {
let container = UIView()
let button = UIButton(type: .custom)
button.accessibilityLabel = item.title
button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)"
button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
tabButtons[item.tab] = button
let titleLabel = UILabel()
titleLabel.text = item.title
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textAlignment = .center
tabTitleLabels[item.tab] = titleLabel
container.addSubview(button)
container.addSubview(titleLabel)
button.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.centerX.equalToSuperview()
make.width.height.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(button.snp.bottom).offset(3)
make.centerX.equalToSuperview()
make.bottom.lessThanOrEqualToSuperview()
}
if item.tab == .orders {
ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
ordersBadgeLabel.textColor = .white
ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444)
ordersBadgeLabel.textAlignment = .center
ordersBadgeLabel.layer.cornerRadius = 7.5
ordersBadgeLabel.clipsToBounds = true
ordersBadgeLabel.isHidden = true
container.addSubview(ordersBadgeLabel)
ordersBadgeLabel.snp.makeConstraints { make in
make.top.equalTo(button).offset(-4)
make.leading.equalTo(button.snp.trailing).offset(-6)
make.height.equalTo(15)
make.width.greaterThanOrEqualTo(15)
}
}
return container
}
private func refreshSelection() {
for item in items {
let isSelected = item.tab == selectedTab
let imageName = isSelected ? item.selectedImage : item.unselectedImage
tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal)
tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3)
tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular)
}
}
@objc private func tabTapped(_ sender: UIButton) {
guard sender.tag >= 0, sender.tag < items.count else { return }
onTabSelected?(items[sender.tag].tab)
}
@objc private func scanTapped() {
onScanTapped?()
}
} }

View File

@ -11,6 +11,7 @@ final class PlaceholderViewController: UIViewController {
private let pageTitle: String private let pageTitle: String
///
init(title: String) { init(title: String) {
pageTitle = title pageTitle = title
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA) view.backgroundColor = UIColor(hex: 0xF5F7FA)

View File

@ -14,6 +14,7 @@ struct MessageListResponse: Decodable, Equatable {
let lastId: Int let lastId: Int
let items: [MessageEntity] let items: [MessageEntity]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case hasMore = "has_more" case hasMore = "has_more"
case lastId = "last_id" case lastId = "last_id"
@ -47,6 +48,7 @@ struct MessageEntity: Decodable, Equatable, Identifiable {
let createdAt: String let createdAt: String
let isRead: Bool let isRead: Bool
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case type case type

View File

@ -11,6 +11,7 @@ import UIKit
final class MessageCenterViewController: ModuleTableViewController { final class MessageCenterViewController: ModuleTableViewController {
private let viewModel = MessageCenterViewModel() private let viewModel = MessageCenterViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "消息中心" title = "消息中心"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -23,10 +24,12 @@ final class MessageCenterViewController: ModuleTableViewController {
wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() } wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() }
} }
/// tableCount
override func tableRowCount() -> Int { override func tableRowCount() -> Int {
viewModel.messages.count viewModel.messages.count
} }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.messages[indexPath.row] let item = viewModel.messages[indexPath.row]
cell.configure( cell.configure(
@ -37,6 +40,7 @@ final class MessageCenterViewController: ModuleTableViewController {
cell.accessoryType = item.isRead ? .none : .disclosureIndicator cell.accessoryType = item.isRead ? .none : .disclosureIndicator
} }
/// didSelectTableRow
override func didSelectTableRow(at indexPath: IndexPath) { override func didSelectTableRow(at indexPath: IndexPath) {
let item = viewModel.messages[indexPath.row] let item = viewModel.messages[indexPath.row]
Task { Task {
@ -45,20 +49,24 @@ final class MessageCenterViewController: ModuleTableViewController {
} }
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
isLoading = viewModel.messages.isEmpty isLoading = viewModel.messages.isEmpty
await viewModel.reloadFirstPage(api: services.messageCenterAPI) await viewModel.reloadFirstPage(api: services.messageCenterAPI)
isLoading = false isLoading = false
} }
/// markRead
@objc private func markAllRead() { @objc private func markAllRead() {
Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) } Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) }
} }
/// BarButtons
private func updateBarButtons() { private func updateBarButtons() {
navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty
} }
/// Alert
private func showAlert(title: String, message: String) { private func showAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "知道了", style: .default)) alert.addAction(UIAlertAction(title: "知道了", style: .default))

View File

@ -146,6 +146,7 @@ final class MessageCenterViewModel {
} }
} }
/// deduplicated
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] { private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
var unique: [String: MessageItem] = [:] var unique: [String: MessageItem] = [:]
for item in source { for item in source {
@ -154,6 +155,7 @@ final class MessageCenterViewModel {
return unique.values.sorted { $0.time > $1.time } return unique.values.sorted { $0.time > $1.time }
} }
/// Messages
private func clearMessages() { private func clearMessages() {
messages = [] messages = []
hasMoreMessages = false hasMoreMessages = false

View File

@ -10,7 +10,9 @@ import Foundation
/// ///
@MainActor @MainActor
protocol OperatingAreaServing { protocol OperatingAreaServing {
/// storeArea
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem> func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem>
/// scenicAdminArea
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem> func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
} }

View File

@ -31,6 +31,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
let typeText: String let typeText: String
let auditStatusText: String let auditStatusText: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case name case name
@ -40,6 +41,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
case auditStatusText = "audit_status_text" case auditStatusText = "audit_status_text"
} }
///
init( init(
id: Int = 0, id: Int = 0,
name: String = "", name: String = "",
@ -56,6 +58,7 @@ struct OperatingAreaItem: Decodable, Equatable, Identifiable {
self.auditStatusText = auditStatusText self.auditStatusText = auditStatusText
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0 id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0
@ -76,6 +79,7 @@ enum OperatingMapArea: Decodable, Equatable {
case array([OperatingMapArea]) case array([OperatingMapArea])
case object([String: OperatingMapArea]) case object([String: OperatingMapArea])
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
if container.decodeNil() { if container.decodeNil() {
@ -106,6 +110,7 @@ enum OperatingMapArea: Decodable, Equatable {
return fromJSONObject(raw) return fromJSONObject(raw)
} }
/// fromJSONObject
private static func fromJSONObject(_ value: Any) -> OperatingMapArea { private static func fromJSONObject(_ value: Any) -> OperatingMapArea {
switch value { switch value {
case is NSNull: case is NSNull:
@ -140,6 +145,7 @@ struct OperatingFenceRing: Identifiable, Equatable {
let points: [OperatingGeoPoint] let points: [OperatingGeoPoint]
let isCurrentStore: Bool let isCurrentStore: Bool
///
init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) { init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) {
self.id = "\(itemId)-\(ringIndex)" self.id = "\(itemId)-\(ringIndex)"
self.itemId = itemId self.itemId = itemId
@ -184,6 +190,7 @@ enum OperatingAreaParser {
return parseElement(area) return parseElement(area)
} }
/// Element
private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] { private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] {
switch value { switch value {
case .null, .bool, .number: case .null, .bool, .number:
@ -199,6 +206,7 @@ enum OperatingAreaParser {
} }
} }
/// Object
private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] { private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] {
let type: String? let type: String?
if case let .string(rawType)? = object["type"] { if case let .string(rawType)? = object["type"] {
@ -231,6 +239,7 @@ enum OperatingAreaParser {
} }
} }
/// ArrayRoot
private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] { private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] {
guard let first = array.first else { return [] } guard let first = array.first else { return [] }
switch first { switch first {
@ -257,6 +266,7 @@ enum OperatingAreaParser {
} }
} }
/// ObjectRing
private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
let points = array.compactMap { element -> OperatingGeoPoint? in let points = array.compactMap { element -> OperatingGeoPoint? in
guard case let .object(object) = element else { return nil } guard case let .object(object) = element else { return nil }
@ -265,6 +275,7 @@ enum OperatingAreaParser {
return points.count >= 3 ? points : nil return points.count >= 3 ? points : nil
} }
/// Ring
private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? {
let points = array.compactMap { element -> OperatingGeoPoint? in let points = array.compactMap { element -> OperatingGeoPoint? in
switch element { switch element {
@ -283,6 +294,7 @@ enum OperatingAreaParser {
return points.count >= 3 ? points : nil return points.count >= 3 ? points : nil
} }
/// point
private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? { private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? {
let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue
let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue
@ -290,6 +302,7 @@ enum OperatingAreaParser {
return OperatingGeoPoint(latitude: lat, longitude: lng) return OperatingGeoPoint(latitude: lat, longitude: lng)
} }
/// Pair
private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint { private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint {
if looksLikeLngLatPair(lng: a, lat: b) { if looksLikeLngLatPair(lng: a, lat: b) {
return OperatingGeoPoint(latitude: b, longitude: a) return OperatingGeoPoint(latitude: b, longitude: a)
@ -303,24 +316,29 @@ enum OperatingAreaParser {
return OperatingGeoPoint(latitude: a, longitude: b) return OperatingGeoPoint(latitude: a, longitude: b)
} }
/// looksLikeLngLat
private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool { private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool {
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat) (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat)
} }
/// looksLikeLatLng
private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool { private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool {
(-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng) (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng)
} }
} }
///
private struct OperatingDynamicCodingKey: CodingKey { private struct OperatingDynamicCodingKey: CodingKey {
let stringValue: String let stringValue: String
let intValue: Int? let intValue: Int?
///
init?(stringValue: String) { init?(stringValue: String) {
self.stringValue = stringValue self.stringValue = stringValue
intValue = nil intValue = nil
} }
///
init?(intValue: Int) { init?(intValue: Int) {
stringValue = String(intValue) stringValue = String(intValue)
self.intValue = intValue self.intValue = intValue
@ -341,6 +359,7 @@ private extension OperatingMapArea {
} }
private extension KeyedDecodingContainer { private extension KeyedDecodingContainer {
/// operating
func operatingDecodeLossyString(forKey key: Key) throws -> String { func operatingDecodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) { if let value = try? decodeIfPresent(String.self, forKey: key) {
return value return value
@ -357,6 +376,7 @@ private extension KeyedDecodingContainer {
return "" return ""
} }
/// operating
func operatingDecodeLossyInt(forKey key: Key) throws -> Int? { func operatingDecodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value return value

View File

@ -1,37 +1,146 @@
// //
// OperatingAreaViewController.swift // OperatingAreaViewController.swift
// suixinkan // suixinkan_ios
//
// Created by Codex on 2026/6/26.
// //
import SnapKit
import UIKit import UIKit
/// ///
final class OperatingAreaViewController: ModuleTableViewController { final class OperatingAreaViewController: UIViewController {
private let viewModel = OperatingAreaViewModel() private let viewModel = OperatingAreaViewModel()
private let mapView = OperatingAreaMapView()
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
private let summaryLabel = UILabel()
private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash")
private var dataSource: UITableViewDiffableDataSource<Int, String>!
private var services: AppServices { AppServices.shared }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "运营区域"
super.viewDidLoad() super.viewDidLoad()
wireViewModel(viewModel) { } title = "运营区域"
view.backgroundColor = UIColor(hex: 0xF5F7FA)
setupLayout()
wireViewModel()
Task { await reload(showLoading: true) }
} }
override func tableRowCount() -> Int { private func setupLayout() {
viewModel.items.count summaryLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
summaryLabel.textColor = AppDesign.textSecondary
summaryLabel.numberOfLines = 0
mapView.snp.makeConstraints { make in
make.height.equalTo(320)
} }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { blockReasonView.isHidden = true
let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count)") 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( await viewModel.reload(
api: services.operatingAreaAPI, api: services.operatingAreaAPI,
accountContext: services.accountContext, accountContext: services.accountContext,
permissionContext: services.permissionContext permissionContext: services.permissionContext
) )
if let message = viewModel.errorMessage {
services.toastCenter.show(message)
}
} }
} }

View File

@ -110,6 +110,7 @@ final class OperatingAreaViewModel {
mode?.title ?? "运营区域" mode?.title ?? "运营区域"
} }
/// apply
private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) { private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) {
self.items = items self.items = items
if items.isEmpty { if items.isEmpty {
@ -139,6 +140,7 @@ final class OperatingAreaViewModel {
blockReason = parsed.isEmpty ? .noParsableFenceData : nil blockReason = parsed.isEmpty ? .noParsableFenceData : nil
} }
/// setBlocked
private func setBlocked(_ reason: OperatingAreaBlockReason) { private func setBlocked(_ reason: OperatingAreaBlockReason) {
resetLoadedData() resetLoadedData()
blockReason = reason blockReason = reason
@ -146,6 +148,7 @@ final class OperatingAreaViewModel {
loading = false loading = false
} }
/// LoadedData
private func resetLoadedData() { private func resetLoadedData() {
items = [] items = []
fenceRings = [] fenceRings = []

View File

@ -62,6 +62,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
let isNeedEdit: Bool let isNeedEdit: Bool
let isRefined: Int let isRefined: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case photogUid = "photog_uid" case photogUid = "photog_uid"
case orderNumber = "order_number" case orderNumber = "order_number"
@ -131,6 +132,7 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
let retouchGiftPhotoNum: Int let retouchGiftPhotoNum: Int
let retouchGiftVideoNum: Int let retouchGiftVideoNum: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderPhotoNum = "order_photo_num" case orderPhotoNum = "order_photo_num"
case orderVideoNum = "order_video_num" case orderVideoNum = "order_video_num"
@ -163,6 +165,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
let orderStatusName: String let orderStatusName: String
let orderVerificationTime: String let orderVerificationTime: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderNumber = "order_number" case orderNumber = "order_number"
case orderVerificationStatus = "order_verification_status" case orderVerificationStatus = "order_verification_status"
@ -192,6 +195,7 @@ struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
struct WriteOffRequest: Encodable { struct WriteOffRequest: Encodable {
let orderNumber: String let orderNumber: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderNumber = "order_number" case orderNumber = "order_number"
} }
@ -207,6 +211,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
let statusName: String let statusName: String
let createdAt: String let createdAt: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case orderNumber = "order_number" case orderNumber = "order_number"
@ -234,6 +239,7 @@ struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
struct DepositOrderWriteOffRequest: Encodable, Equatable { struct DepositOrderWriteOffRequest: Encodable, Equatable {
let orderNumber: String let orderNumber: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderNumber = "order_number" case orderNumber = "order_number"
} }
@ -244,6 +250,7 @@ struct DepositOrderRefundRequest: Encodable, Equatable {
let orderNumber: String let orderNumber: String
let refundReason: String let refundReason: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderNumber = "order_number" case orderNumber = "order_number"
case refundReason = "refund_reason" case refundReason = "refund_reason"
@ -275,6 +282,7 @@ struct OrderRefundRequest: Encodable, Equatable {
let refundAmount: String let refundAmount: String
let refundReason: String let refundReason: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderNumber = "order_number" case orderNumber = "order_number"
case refundType = "refund_type" case refundType = "refund_type"
@ -314,6 +322,7 @@ struct StoreOrderDetailResponse: Decodable, Equatable, Hashable {
let remark: String let remark: String
let multiTravel: StoreOrderMultiTravelInfo? let multiTravel: StoreOrderMultiTravelInfo?
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderNumber = "order_number" case orderNumber = "order_number"
case orderType = "order_type" case orderType = "order_type"
@ -362,6 +371,7 @@ struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable {
let projectInfo: StoreOrderProjectInfo? let projectInfo: StoreOrderProjectInfo?
let shootingList: [StoreOrderShootingListItem] let shootingList: [StoreOrderShootingListItem]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case projectInfo = "project_info" case projectInfo = "project_info"
case shootingList = "shooting_list" case shootingList = "shooting_list"
@ -382,6 +392,7 @@ struct StoreOrderProjectInfo: Decodable, Equatable, Hashable {
let singleSpotPhotoNum: Int let singleSpotPhotoNum: Int
let singleSpotVideoNum: Int let singleSpotVideoNum: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case settleSpotNum = "settle_spot_num" case settleSpotNum = "settle_spot_num"
case singleSpotMaterialNum = "single_spot_material_num" case singleSpotMaterialNum = "single_spot_material_num"
@ -412,6 +423,7 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable
let startAvg: Double let startAvg: Double
let start: Double let start: Double
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case scenicSpotId = "scenic_spot_id" case scenicSpotId = "scenic_spot_id"
case photogUid = "photog_uid" case photogUid = "photog_uid"
@ -447,6 +459,7 @@ struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
let materialList: [OrderMediaFile] let materialList: [OrderMediaFile]
let completeList: [OrderMediaFile] let completeList: [OrderMediaFile]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case scenicSpotId = "scenic_spot_id" case scenicSpotId = "scenic_spot_id"
case scenicSpotName = "scenic_spot_name" case scenicSpotName = "scenic_spot_name"
@ -480,6 +493,7 @@ struct StoreOrderComment: Decodable, Equatable, Hashable {
let content: String let content: String
let createdAt: String let createdAt: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case starShooting = "star_shooting" case starShooting = "star_shooting"
case starRetouching = "star_retouching" case starRetouching = "star_retouching"
@ -522,6 +536,7 @@ struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
} }
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case fileName = "file_name" case fileName = "file_name"
case fileUrl = "file_url" case fileUrl = "file_url"
@ -550,6 +565,7 @@ struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
let projectTypeName: String let projectTypeName: String
let photogSpotList: [PhotogSpotItem] let photogSpotList: [PhotogSpotItem]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case projectName = "project_name" case projectName = "project_name"
case projectType = "project_type" case projectType = "project_type"
@ -583,6 +599,7 @@ struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
return nickname.isEmpty ? photogName : nickname return nickname.isEmpty ? photogName : nickname
} }
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case scenicSpotId = "scenic_spot_id" case scenicSpotId = "scenic_spot_id"
case photogUid = "photog_uid" case photogUid = "photog_uid"
@ -609,6 +626,7 @@ struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Ha
let id: Int let id: Int
let name: String let name: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case name case name
@ -629,6 +647,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
let cloudFile: [MultiTravelCloudFileItem] let cloudFile: [MultiTravelCloudFileItem]
let uploadFile: [MultiTravelUploadFileItem] let uploadFile: [MultiTravelUploadFileItem]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case orderNumber = "order_number" case orderNumber = "order_number"
case scenicSpotId = "scenic_spot_id" case scenicSpotId = "scenic_spot_id"
@ -641,6 +660,7 @@ struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
struct MultiTravelCloudFileItem: Encodable, Equatable { struct MultiTravelCloudFileItem: Encodable, Equatable {
let fileId: Int let fileId: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case fileId = "file_id" case fileId = "file_id"
} }
@ -651,6 +671,7 @@ struct MultiTravelUploadFileItem: Encodable, Equatable {
let fileName: String let fileName: String
let fileUrl: String let fileUrl: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case fileName = "file_name" case fileName = "file_name"
case fileUrl = "file_url" case fileUrl = "file_url"

View File

@ -6,32 +6,127 @@
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Deposit List Diffable
private typealias DepositListSection = Int
private typealias DepositListItem = String
/// /
private enum DepositListItemID {
static let placeholder = "depositList:placeholder"
}
// MARK: - Deposit Detail Diffable
private typealias DepositDetailSection = Int
private typealias DepositDetailRow = String
///
private enum DepositDetailRowID {
static let placeholder = "depositDetail:placeholder"
static func field(_ index: Int) -> String { "depositDetail:field:\(index)" }
}
// MARK: - Deposit Shooting Diffable
private typealias DepositShootingSection = Int
private typealias DepositShootingRow = String
/// section
private enum DepositShootingSectionID {
static let summary = 0
static let material = 1
static let complete = 2
}
///
private enum DepositShootingRowID {
static let placeholder = "depositShooting:placeholder"
static let scenicSpot = "depositShooting:scenicSpot"
static let rating = "depositShooting:rating"
static func material(_ index: Int) -> String { "depositShooting:material:\(index)" }
static func complete(_ index: Int) -> String { "depositShooting:complete:\(index)" }
}
/// 退 /// 退
final class DepositOrderListViewController: UIViewController { final class DepositOrderListViewController: UIViewController, UITableViewDelegate {
private let viewModel = DepositOrderListViewModel() private let viewModel = DepositOrderListViewModel()
private var orderNumberField = UITextField() private var orderNumberField = UITextField()
private lazy var tableView: UITableView = { private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped) let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self table.delegate = self
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID) table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
return table return table
}() }()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<DepositListSection, DepositListItem>!
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "押金订单" title = "押金订单"
view.backgroundColor = AppDesignUIKit.pageBackground view.backgroundColor = AppDesignUIKit.pageBackground
configureTableDataSource()
view.addSubview(tableView) view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
viewModel.onChange = { [weak self] in self?.tableView.reloadData() } viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
setupHeader() setupHeader()
Task { await reload(showLoading: true) } Task { await reload(showLoading: true) }
} }
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<DepositListSection, DepositListItem>(
tableView: tableView
) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: DepositListItem) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
if item == DepositListItemID.placeholder {
let cell = UITableViewCell()
cell.textLabel?.text = self.viewModel.loading ? "加载中..." : "暂无押金订单"
cell.selectionStyle = .none
return cell
}
guard let orderItem = self.viewModel.orders.first(where: { $0.orderNumber == item }) else {
return UITableViewCell()
}
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber)
cell.onDetail = { [weak self] in
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: orderItem.orderNumber), from: $0) }
}
cell.onWriteOff = { [weak self] in self?.writeOff(orderItem) }
cell.onRefund = { [weak self] in self?.refund(orderItem) }
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositListSection, DepositListItem> {
var snapshot = NSDiffableDataSourceSnapshot<DepositListSection, DepositListItem>()
snapshot.appendSections([0])
if viewModel.orders.isEmpty {
snapshot.appendItems([DepositListItemID.placeholder], toSection: 0)
} else {
snapshot.appendItems(viewModel.orders.map(\.orderNumber), toSection: 0)
}
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Header UI
private func setupHeader() { private func setupHeader() {
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120)) let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
orderNumberField.placeholder = "请输入押金订单号" orderNumberField.placeholder = "请输入押金订单号"
@ -55,12 +150,14 @@ final class DepositOrderListViewController: UIViewController {
tableView.tableHeaderView = header tableView.tableHeaderView = header
} }
/// open
@objc private func openDetail() { @objc private func openDetail() {
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self) HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
} }
///
private func reload(showLoading: Bool) async { private func reload(showLoading: Bool) async {
if showLoading { if showLoading {
await appServices.globalLoading.withLoading { await appServices.globalLoading.withLoading {
@ -72,6 +169,7 @@ final class DepositOrderListViewController: UIViewController {
if let message = viewModel.errorMessage { showToast(message) } if let message = viewModel.errorMessage { showToast(message) }
} }
/// writeOff
private func writeOff(_ item: DepositOrderListItem) { private func writeOff(_ item: DepositOrderListItem) {
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert) let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -86,6 +184,7 @@ final class DepositOrderListViewController: UIViewController {
present(alert, animated: true) present(alert, animated: true)
} }
/// refund
private func refund(_ item: DepositOrderListItem) { private func refund(_ item: DepositOrderListItem) {
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert) let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
alert.addTextField { $0.placeholder = "退款原因" } alert.addTextField { $0.placeholder = "退款原因" }
@ -101,43 +200,25 @@ final class DepositOrderListViewController: UIViewController {
}) })
present(alert, animated: true) present(alert, animated: true)
} }
}
extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
max(viewModel.orders.count, 1)
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)" "押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { /// UITableView
guard !viewModel.orders.isEmpty else {
let cell = UITableViewCell()
cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单"
cell.selectionStyle = .none
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
let item = viewModel.orders[indexPath.row]
cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber)
cell.onDetail = { [weak self] in
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) }
}
cell.onWriteOff = { [weak self] in self?.writeOff(item) }
cell.onRefund = { [weak self] in self?.refund(item) }
return cell
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return } guard let item = tableDataSource.itemIdentifier(for: indexPath),
item != DepositListItemID.placeholder,
item == viewModel.orders.last?.orderNumber,
viewModel.hasMore else { return }
Task { Task {
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false) await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
} }
} }
} }
/// DepositOrder Cell
private final class DepositOrderCell: UITableViewCell { private final class DepositOrderCell: UITableViewCell {
static let reuseID = "DepositOrderCell" static let reuseID = "DepositOrderCell"
var onDetail: (() -> Void)? var onDetail: (() -> Void)?
@ -148,6 +229,7 @@ private final class DepositOrderCell: UITableViewCell {
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let amountLabel = UILabel() private let amountLabel = UILabel()
///
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none selectionStyle = .none
@ -182,14 +264,18 @@ private final class DepositOrderCell: UITableViewCell {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
///
func configure(item: DepositOrderListItem, isOperating: Bool) { func configure(item: DepositOrderListItem, isOperating: Bool) {
titleLabel.text = item.orderNumber titleLabel.text = item.orderNumber
statusLabel.text = isOperating ? "处理中" : item.statusName statusLabel.text = isOperating ? "处理中" : item.statusName
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)" amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
} }
/// detail
@objc private func detailTapped() { onDetail?() } @objc private func detailTapped() { onDetail?() }
/// writeOff
@objc private func writeOffTapped() { onWriteOff?() } @objc private func writeOffTapped() { onWriteOff?() }
/// refund
@objc private func refundTapped() { onRefund?() } @objc private func refundTapped() { onRefund?() }
} }
@ -201,11 +287,14 @@ final class DepositOrderDetailViewController: UIViewController {
private lazy var tableView: UITableView = { private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped) let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.backgroundColor = AppDesignUIKit.pageBackground table.backgroundColor = AppDesignUIKit.pageBackground
return table return table
}() }()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>!
///
init(orderNumber: String) { init(orderNumber: String) {
self.orderNumber = orderNumber self.orderNumber = orderNumber
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -214,37 +303,26 @@ final class DepositOrderDetailViewController: UIViewController {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "押金订单详情" title = "押金订单详情"
configureTableDataSource()
view.addSubview(tableView) view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
Task { await loadDetail() } Task { await loadDetail() }
} }
private func loadDetail() async { /// Diffable
await appServices.globalLoading.withLoading { private func configureTableDataSource() {
await viewModel.load( tableDataSource = UITableViewDiffableDataSource<DepositDetailSection, DepositDetailRow>(
api: appServices.ordersAPI, tableView: tableView
storeId: appServices.accountContext.currentStore?.id, ) { [weak self] (_: UITableView, _: IndexPath, row: DepositDetailRow) -> UITableViewCell? in
orderNumber: orderNumber guard let self else { return UITableViewCell() }
)
}
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 {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none cell.selectionStyle = .none
guard let detail = viewModel.detail else { guard let detail = self.viewModel.detail else {
cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情") cell.textLabel?.text = self.viewModel.errorMessage ?? (self.viewModel.loading ? "加载中..." : "暂无详情")
return cell return cell
} }
let rows: [(String, String)] = [ let rows: [(String, String)] = [
@ -257,14 +335,49 @@ extension DepositOrderDetailViewController: UITableViewDataSource {
("创建时间", detail.createdAt), ("创建时间", detail.createdAt),
("备注", detail.remark) ("备注", detail.remark)
] ]
cell.textLabel?.text = rows[indexPath.row].0 let index = Int(row.split(separator: ":").last ?? "") ?? 0
cell.detailTextLabel?.text = rows[indexPath.row].1 if index < rows.count {
cell.textLabel?.text = rows[index].0
cell.detailTextLabel?.text = rows[index].1
}
return cell 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 orderNumber: String
private let scenicSpotId: Int private let scenicSpotId: Int
@ -273,10 +386,14 @@ final class DepositOrderShootingInfoViewController: UIViewController {
private lazy var tableView: UITableView = { private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped) let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self table.delegate = self
return table return table
}() }()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>!
///
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) { init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
self.orderNumber = orderNumber self.orderNumber = orderNumber
self.scenicSpotId = scenicSpotId self.scenicSpotId = scenicSpotId
@ -287,14 +404,75 @@ final class DepositOrderShootingInfoViewController: UIViewController {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "押金拍摄信息" title = "押金拍摄信息"
configureTableDataSource()
view.addSubview(tableView) view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
Task { await loadDetail() } Task { await loadDetail() }
} }
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<DepositShootingSection, DepositShootingRow>(
tableView: tableView
) { [weak self] (_: UITableView, indexPath: IndexPath, row: DepositShootingRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
guard let detail = self.viewModel.detail else {
let cell = UITableViewCell()
cell.textLabel?.text = self.viewModel.errorMessage ?? "加载中..."
return cell
}
if row == DepositShootingRowID.scenicSpot || row == DepositShootingRowID.rating {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
if row == DepositShootingRowID.scenicSpot {
cell.textLabel?.text = "打卡点"
cell.detailTextLabel?.text = detail.scenicSpotName
} else {
cell.textLabel?.text = "拍摄评分"
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting)" } ?? "--"
}
return cell
}
let files: [OrderMediaFile]
if row.hasPrefix("depositShooting:material:") {
files = detail.materialList
} else {
files = detail.completeList
}
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let media = files[index]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
cell.textLabel?.text = media.fileName
cell.detailTextLabel?.text = media.fileUrl
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<DepositShootingSection, DepositShootingRow> {
var snapshot = NSDiffableDataSourceSnapshot<DepositShootingSection, DepositShootingRow>()
guard let detail = viewModel.detail else {
snapshot.appendSections([0])
snapshot.appendItems([DepositShootingRowID.placeholder], toSection: 0)
return snapshot
}
snapshot.appendSections([DepositShootingSectionID.summary, DepositShootingSectionID.material, DepositShootingSectionID.complete])
snapshot.appendItems([DepositShootingRowID.scenicSpot, DepositShootingRowID.rating], toSection: DepositShootingSectionID.summary)
snapshot.appendItems(detail.materialList.indices.map { DepositShootingRowID.material($0) }, toSection: DepositShootingSectionID.material)
snapshot.appendItems(detail.completeList.indices.map { DepositShootingRowID.complete($0) }, toSection: DepositShootingSectionID.complete)
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true) {
tableDataSource.apply(buildTableSnapshot(), animatingDifferences: animated)
}
/// Detail
private func loadDetail() async { private func loadDetail() async {
await appServices.globalLoading.withLoading { await appServices.globalLoading.withLoading {
await viewModel.load( await viewModel.load(
@ -305,56 +483,25 @@ final class DepositOrderShootingInfoViewController: UIViewController {
photogUid: photogUid photogUid: photogUid
) )
} }
tableView.reloadData() applyTableSnapshot()
if let message = viewModel.errorMessage { showToast(message) } if let message = viewModel.errorMessage { showToast(message) }
} }
}
extension DepositOrderShootingInfoViewController: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
viewModel.detail == nil ? 1 : 3
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let detail = viewModel.detail else { return 1 }
switch section {
case 0: return 2
case 1: return detail.materialList.count
default: return detail.completeList.count
}
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
guard viewModel.detail != nil else { return nil } guard viewModel.detail != nil,
switch section { let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
case 1: return "素材" switch sectionID {
case 2: return "成片" case DepositShootingSectionID.material: return "素材"
case DepositShootingSectionID.complete: return "成片"
default: return nil default: return nil
} }
} }
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { /// section
guard let detail = viewModel.detail else { private extension Array {
let cell = UITableViewCell() subscript(safe index: Int) -> Element? {
cell.textLabel?.text = viewModel.errorMessage ?? "加载中..." indices.contains(index) ? self[index] : nil
return cell
}
if indexPath.section == 0 {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
if indexPath.row == 0 {
cell.textLabel?.text = "打卡点"
cell.detailTextLabel?.text = detail.scenicSpotName
} else {
cell.textLabel?.text = "拍摄评分"
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting)" } ?? "--"
}
return cell
}
let files = indexPath.section == 1 ? detail.materialList : detail.completeList
let media = files[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
cell.textLabel?.text = media.fileName
cell.detailTextLabel?.text = media.fileUrl
return cell
} }
} }

View File

@ -32,6 +32,7 @@ final class OrderCodeScannerViewController: UIViewController {
private let scannerController = OrderScannerCaptureViewController() private let scannerController = OrderScannerCaptureViewController()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .black view.backgroundColor = .black
@ -49,6 +50,7 @@ final class OrderCodeScannerViewController: UIViewController {
#endif #endif
} }
/// embed
private func embedScanner() { private func embedScanner() {
scannerController.onScanResult = { [weak self] result in scannerController.onScanResult = { [weak self] result in
self?.onScanResult?(result) self?.onScanResult?(result)
@ -77,6 +79,7 @@ final class OrderCodeScannerViewController: UIViewController {
} }
} }
/// UnavailablePlaceholder
private func showUnavailablePlaceholder() { private func showUnavailablePlaceholder() {
let stack = UIStackView() let stack = UIStackView()
stack.axis = .vertical stack.axis = .vertical
@ -109,6 +112,7 @@ final class OrderCodeScannerViewController: UIViewController {
} }
} }
/// close
@objc private func closeTapped() { @objc private func closeTapped() {
dismiss(animated: true) dismiss(animated: true)
} }
@ -145,6 +149,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
return button return button
}() }()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .black view.backgroundColor = .black
@ -152,17 +157,20 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
checkPermissionAndStart() checkPermissionAndStart()
} }
/// frame UI
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
previewLayer?.frame = view.layer.bounds previewLayer?.frame = view.layer.bounds
} }
///
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
updateTorch(enabled: false) updateTorch(enabled: false)
stopSession() stopSession()
} }
/// check
private func checkPermissionAndStart() { private func checkPermissionAndStart() {
switch AVCaptureDevice.authorizationStatus(for: .video) { switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: case .authorized:
@ -185,6 +193,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
} }
} }
/// Session
private func startSession() { private func startSession() {
hasFinishedScan = false hasFinishedScan = false
guard !session.isRunning else { return } guard !session.isRunning else { return }
@ -199,11 +208,13 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
} }
} }
/// Session
private func stopSession() { private func stopSession() {
guard session.isRunning else { return } guard session.isRunning else { return }
session.stopRunning() session.stopRunning()
} }
/// ScannerButton
private static func makeScannerButton(title: String) -> UIButton { private static func makeScannerButton(title: String) -> UIButton {
var config = UIButton.Configuration.filled() var config = UIButton.Configuration.filled()
config.title = title config.title = title
@ -213,6 +224,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
return UIButton(configuration: config) return UIButton(configuration: config)
} }
/// Session
private func configureSession() throws { private func configureSession() throws {
if previewLayer != nil { return } if previewLayer != nil { return }
guard let videoDevice = AVCaptureDevice.default(for: .video) else { guard let videoDevice = AVCaptureDevice.default(for: .video) else {
@ -242,6 +254,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
torchButton.isEnabled = videoDevice.hasTorch torchButton.isEnabled = videoDevice.hasTorch
} }
/// metadataOutput
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard !hasFinishedScan else { return } guard !hasFinishedScan else { return }
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
@ -253,6 +266,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
onScanResult?(.success(code)) onScanResult?(.success(code))
} }
/// Controls UI
private func setupControls() { private func setupControls() {
view.addSubview(scanFrameView) view.addSubview(scanFrameView)
view.addSubview(torchButton) view.addSubview(torchButton)
@ -273,10 +287,12 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
} }
} }
/// toggleTorch
@objc private func toggleTorch() { @objc private func toggleTorch() {
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false)) updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
} }
/// Torch
private func updateTorch(enabled: Bool) { private func updateTorch(enabled: Bool) {
guard let device = videoDevice, device.hasTorch else { return } guard let device = videoDevice, device.hasTorch else { return }
do { do {
@ -293,6 +309,7 @@ final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetada
} }
} }
/// restart
@objc private func restartScan() { @objc private func restartScan() {
hasFinishedScan = false hasFinishedScan = false
updateTorch(enabled: false) updateTorch(enabled: false)

View File

@ -6,8 +6,45 @@
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Store Order Diffable
private typealias StoreOrderDetailSection = Int
private typealias StoreOrderDetailRow = String
/// section
private enum StoreOrderDetailSectionID {
static let context = 0
static let orderInfo = 1
static let payment = 2
static let customer = 3
static let shooting = 4
static let actions = 5
}
///
private enum StoreOrderDetailRowID {
static let context = "storeDetail:context"
static func orderInfo(_ index: Int) -> String { "storeDetail:orderInfo:\(index)" }
static func payment(_ index: Int) -> String { "storeDetail:payment:\(index)" }
static func customer(_ index: Int) -> String { "storeDetail:customer:\(index)" }
static func shooting(_ id: String) -> String { "storeDetail:shooting:\(id)" }
static func action(_ index: Int) -> String { "storeDetail:action:\(index)" }
}
// MARK: - Write-Off Diffable
///
private enum WriteOffDetailRowID {
static let orderNumber = "writeOff:orderNumber"
static let project = "writeOff:project"
static let phone = "writeOff:phone"
static let amount = "writeOff:amount"
static let status = "writeOff:status"
static let verificationTime = "writeOff:verificationTime"
}
/// ///
final class StoreOrderDetailViewController: UIViewController { final class StoreOrderDetailViewController: UIViewController, UITableViewDelegate {
private let item: OrderEntity private let item: OrderEntity
private let viewModel: OrderDetailViewModel private let viewModel: OrderDetailViewModel
@ -15,12 +52,15 @@ final class StoreOrderDetailViewController: UIViewController {
private lazy var tableView: UITableView = { private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped) let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self table.delegate = self
table.backgroundColor = AppDesignUIKit.pageBackground table.backgroundColor = AppDesignUIKit.pageBackground
return table return table
}() }()
/// Diffable section
private var tableDataSource: UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>!
///
init(item: OrderEntity) { init(item: OrderEntity) {
self.item = item self.item = item
self.viewModel = OrderDetailViewModel(item: item) self.viewModel = OrderDetailViewModel(item: item)
@ -30,31 +70,176 @@ final class StoreOrderDetailViewController: UIViewController {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "订单详情" title = "订单详情"
view.backgroundColor = AppDesignUIKit.pageBackground view.backgroundColor = AppDesignUIKit.pageBackground
configureTableDataSource()
view.addSubview(tableView) view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
viewModel.onChange = { [weak self] in self?.tableView.reloadData() } viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
Task { await loadDetail() } Task { await loadDetail() }
} }
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<StoreOrderDetailSection, StoreOrderDetailRow>(
tableView: tableView
) { [weak self] (_: UITableView, indexPath: IndexPath, row: StoreOrderDetailRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
return self.configureDetailCell(row: row)
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<StoreOrderDetailSection, StoreOrderDetailRow> {
var snapshot = NSDiffableDataSourceSnapshot<StoreOrderDetailSection, StoreOrderDetailRow>()
if viewModel.contextMessage != nil {
snapshot.appendSections([StoreOrderDetailSectionID.context])
snapshot.appendItems([StoreOrderDetailRowID.context], toSection: StoreOrderDetailSectionID.context)
}
snapshot.appendSections([StoreOrderDetailSectionID.orderInfo])
snapshot.appendItems((0..<7).map { StoreOrderDetailRowID.orderInfo($0) }, toSection: StoreOrderDetailSectionID.orderInfo)
snapshot.appendSections([StoreOrderDetailSectionID.payment])
snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.payment($0) }, toSection: StoreOrderDetailSectionID.payment)
snapshot.appendSections([StoreOrderDetailSectionID.customer])
snapshot.appendItems((0..<4).map { StoreOrderDetailRowID.customer($0) }, toSection: StoreOrderDetailSectionID.customer)
if !viewModel.shootingList.isEmpty {
snapshot.appendSections([StoreOrderDetailSectionID.shooting])
let shootingRows = viewModel.shootingList.enumerated().map { index, shooting in
StoreOrderDetailRowID.shooting("\(shooting.id)-\(index)")
}
snapshot.appendItems(shootingRows, toSection: StoreOrderDetailSectionID.shooting)
}
snapshot.appendSections([StoreOrderDetailSectionID.actions])
snapshot.appendItems((0..<5).map { StoreOrderDetailRowID.action($0) }, toSection: StoreOrderDetailSectionID.actions)
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Cell
private func configureDetailCell(row: StoreOrderDetailRow) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
cell.textLabel?.numberOfLines = 1
cell.textLabel?.textColor = nil
cell.accessoryType = .none
cell.isUserInteractionEnabled = true
let display = viewModel.display
if row == StoreOrderDetailRowID.context {
cell.textLabel?.text = viewModel.contextMessage
cell.textLabel?.numberOfLines = 0
return cell
}
if row.hasPrefix("storeDetail:orderInfo:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
cell.textLabel?.text = rows[index]
switch index {
case 0: cell.detailTextLabel?.text = display.orderNumber
case 1: cell.detailTextLabel?.text = display.orderStatusName
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
case 3: cell.detailTextLabel?.text = display.createdAt
case 4: cell.detailTextLabel?.text = display.payTime
case 5: cell.detailTextLabel?.text = display.completeTime
default:
cell.textLabel?.textColor = AppDesignUIKit.primary
cell.selectionStyle = .default
}
return cell
}
if row.hasPrefix("storeDetail:payment:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
cell.textLabel?.text = rows[index]
switch index {
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
case 2: cell.detailTextLabel?.text = display.payTypeName
default: cell.detailTextLabel?.text = "\(display.userId)"
}
return cell
}
if row.hasPrefix("storeDetail:customer:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
cell.textLabel?.text = rows[index]
switch index {
case 0: cell.detailTextLabel?.text = display.phone
case 1: cell.detailTextLabel?.text = display.projectName
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
default: cell.detailTextLabel?.text = display.remark
}
return cell
}
if row.hasPrefix("storeDetail:shooting:") {
let suffix = row.replacingOccurrences(of: "storeDetail:shooting:", with: "")
let indexPart = suffix.split(separator: "-").last.flatMap { Int($0) } ?? 0
if indexPart < viewModel.shootingList.count {
let shooting = viewModel.shootingList[indexPart]
cell.textLabel?.text = shooting.scenicSpotName
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
}
return cell
}
if row.hasPrefix("storeDetail:action:") {
let index = Int(row.split(separator: ":").last ?? "") ?? 0
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
cell.textLabel?.text = actions[index]
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
if index == 4, !refundViewModel.canRefund(item) {
cell.isUserInteractionEnabled = false
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
}
return cell
}
return cell
}
/// Detail
private func loadDetail() async { private func loadDetail() async {
await appServices.globalLoading.withLoading(message: "加载详情中...") { await appServices.globalLoading.withLoading(message: "加载详情中...") {
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id) await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
} }
applyTableSnapshot(reconfigure: true)
if let error = viewModel.errorMessage { if let error = viewModel.errorMessage {
showToast(error) showToast(error)
} }
} }
/// Number
private func copyOrderNumber() { private func copyOrderNumber() {
UIPasteboard.general.string = viewModel.display.orderNumber UIPasteboard.general.string = viewModel.display.orderNumber
showToast("订单号已复制") showToast("订单号已复制")
} }
/// Refund
private func presentRefund() { private func presentRefund() {
refundViewModel.begin(item: item) refundViewModel.begin(item: item)
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert) let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
@ -75,99 +260,34 @@ final class StoreOrderDetailViewController: UIViewController {
}) })
present(alert, animated: true) present(alert, animated: true)
} }
}
extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { 6 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: return viewModel.contextMessage == nil ? 0 : 1
case 1: return 7
case 2: return 4
case 3: return 4
case 4: return viewModel.shootingList.count
case 5: return 5
default: return 0
}
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section { guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
case 1: "订单信息" switch sectionID {
case 2: "支付信息" case StoreOrderDetailSectionID.orderInfo: return "订单信息"
case 3: "客户与项目" case StoreOrderDetailSectionID.payment: return "支付信息"
case 4 where !viewModel.shootingList.isEmpty: "拍摄点" case StoreOrderDetailSectionID.customer: return "客户与项目"
case 5: "后续功能" case StoreOrderDetailSectionID.shooting: return "拍摄点"
default: nil case StoreOrderDetailSectionID.actions: return "后续功能"
default: return nil
} }
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { /// UITableView
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
let display = viewModel.display
switch indexPath.section {
case 0:
cell.textLabel?.text = viewModel.contextMessage
cell.textLabel?.numberOfLines = 0
case 1:
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
cell.textLabel?.text = rows[indexPath.row]
switch indexPath.row {
case 0: cell.detailTextLabel?.text = display.orderNumber
case 1: cell.detailTextLabel?.text = display.orderStatusName
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
case 3: cell.detailTextLabel?.text = display.createdAt
case 4: cell.detailTextLabel?.text = displayPayTime
case 5: cell.detailTextLabel?.text = display.completeTime
default:
cell.textLabel?.textColor = AppDesignUIKit.primary
cell.selectionStyle = .default
}
case 2:
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
cell.textLabel?.text = rows[indexPath.row]
switch indexPath.row {
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
case 2: cell.detailTextLabel?.text = display.payTypeName
default: cell.detailTextLabel?.text = "\(display.userId)"
}
case 3:
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
cell.textLabel?.text = rows[indexPath.row]
switch indexPath.row {
case 0: cell.detailTextLabel?.text = display.phone
case 1: cell.detailTextLabel?.text = display.projectName
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
default: cell.detailTextLabel?.text = display.remark
}
case 4:
let shooting = viewModel.shootingList[indexPath.row]
cell.textLabel?.text = shooting.scenicSpotName
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
case 5:
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
cell.textLabel?.text = actions[indexPath.row]
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
if indexPath.row == 4, !refundViewModel.canRefund(item) {
cell.isUserInteractionEnabled = false
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
}
default: break
}
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return } guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
guard indexPath.section == 5 else { return }
if row == StoreOrderDetailRowID.orderInfo(6) {
copyOrderNumber()
return
}
guard row.hasPrefix("storeDetail:action:") else { return }
let index = Int(row.split(separator: ":").last ?? "") ?? -1
let orderNumber = viewModel.display.orderNumber let orderNumber = viewModel.display.orderNumber
switch indexPath.row { switch index {
case 0: case 0:
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self) HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
case 1 where item.orderType == 19: case 1 where item.orderType == 19:
@ -178,7 +298,8 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self) HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
case 4: case 4:
presentRefund() presentRefund()
default: break default:
break
} }
} }
@ -188,65 +309,79 @@ extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDele
return payTime return payTime
} }
/// emptyZero
private func emptyToZero(_ value: String) -> String { private func emptyToZero(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
} }
} }
/// section
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
/// ///
final class WriteOffOrderDetailViewController: UIViewController { final class WriteOffOrderDetailViewController: SimpleTableDiffableViewController {
private let item: WriteOffOrderItem private let item: WriteOffOrderItem
private lazy var tableView: UITableView = { ///
let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.backgroundColor = AppDesignUIKit.pageBackground
return table
}()
init(item: WriteOffOrderItem) { init(item: WriteOffOrderItem) {
self.item = item self.item = item
super.init(nibName: nil, bundle: nil) super.init(style: .insetGrouped)
} }
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "核销详情" title = "核销详情"
view.addSubview(tableView) tableView.backgroundColor = AppDesignUIKit.pageBackground
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
} }
extension WriteOffOrderDetailViewController: UITableViewDataSource { /// Diffable snapshot
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 } override func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
var snapshot = NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow>()
snapshot.appendSections([0])
snapshot.appendItems([
WriteOffDetailRowID.orderNumber,
WriteOffDetailRowID.project,
WriteOffDetailRowID.phone,
WriteOffDetailRowID.amount,
WriteOffDetailRowID.status,
WriteOffDetailRowID.verificationTime
], toSection: 0)
return snapshot
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { /// Cell
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
cell.selectionStyle = .none cell.selectionStyle = .none
switch indexPath.row { switch row {
case 0: case WriteOffDetailRowID.orderNumber:
cell.textLabel?.text = "订单号" cell.textLabel?.text = "订单号"
cell.detailTextLabel?.text = item.orderNumber cell.detailTextLabel?.text = item.orderNumber
case 1: case WriteOffDetailRowID.project:
cell.textLabel?.text = "项目" cell.textLabel?.text = "项目"
cell.detailTextLabel?.text = item.projectName cell.detailTextLabel?.text = item.projectName
case 2: case WriteOffDetailRowID.phone:
cell.textLabel?.text = "手机号" cell.textLabel?.text = "手机号"
cell.detailTextLabel?.text = item.userPhone cell.detailTextLabel?.text = item.userPhone
case 3: case WriteOffDetailRowID.amount:
cell.textLabel?.text = "金额" cell.textLabel?.text = "金额"
cell.detailTextLabel?.text = "¥\(item.orderAmount)" cell.detailTextLabel?.text = "¥\(item.orderAmount)"
case 4: case WriteOffDetailRowID.status:
cell.textLabel?.text = "状态" cell.textLabel?.text = "状态"
cell.detailTextLabel?.text = item.orderStatusName cell.detailTextLabel?.text = item.orderStatusName
default: case WriteOffDetailRowID.verificationTime:
cell.textLabel?.text = "核销时间" cell.textLabel?.text = "核销时间"
cell.detailTextLabel?.text = item.orderVerificationTime cell.detailTextLabel?.text = item.orderVerificationTime
} default:
return cell break
}
} }
} }

View File

@ -19,6 +19,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
private let localListLabel = UILabel() private let localListLabel = UILabel()
private let submitButton = UIButton(type: .system) private let submitButton = UIButton(type: .system)
///
init(initialOrderNumber: String) { init(initialOrderNumber: String) {
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber) self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -27,6 +28,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "任务上传" title = "任务上传"
@ -36,12 +38,14 @@ final class MultiTravelTaskUploadViewController: UIViewController {
Task { await viewModel.loadSpots(api: appServices.ordersAPI) } Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
} }
/// ViewModel
private func bindViewModel() { private func bindViewModel() {
viewModel.onChange = { [weak self] in viewModel.onChange = { [weak self] in
self?.applyViewModel() self?.applyViewModel()
} }
} }
/// Form UI
private func setupForm() { private func setupForm() {
orderField.borderStyle = .roundedRect orderField.borderStyle = .roundedRect
orderField.placeholder = "关联订单号" orderField.placeholder = "关联订单号"
@ -90,6 +94,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
applyViewModel() applyViewModel()
} }
/// labeled
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView { private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
let label = UILabel() let label = UILabel()
label.text = title label.text = title
@ -100,6 +105,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
return row return row
} }
/// apply
private func applyViewModel() { private func applyViewModel() {
orderField.text = viewModel.orderNumber orderField.text = viewModel.orderNumber
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal) spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
@ -123,14 +129,17 @@ final class MultiTravelTaskUploadViewController: UIViewController {
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材" submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
} }
/// order
@objc private func orderChanged() { @objc private func orderChanged() {
viewModel.orderNumber = orderField.text ?? "" viewModel.orderNumber = orderField.text ?? ""
} }
/// Spots
@objc private func refreshSpots() { @objc private func refreshSpots() {
Task { await viewModel.loadSpots(api: appServices.ordersAPI) } Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
} }
/// select
@objc private func selectSpot() { @objc private func selectSpot() {
guard !viewModel.spots.isEmpty else { return } guard !viewModel.spots.isEmpty else { return }
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet) let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
@ -144,6 +153,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
present(sheet, animated: true) present(sheet, animated: true)
} }
/// pickFiles
@objc private func pickLocalFiles() { @objc private func pickLocalFiles() {
var config = PHPickerConfiguration() var config = PHPickerConfiguration()
config.selectionLimit = 9 config.selectionLimit = 9
@ -153,6 +163,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
present(picker, animated: true) present(picker, animated: true)
} }
/// Tapped
@objc private func submitTapped() { @objc private func submitTapped() {
Task { Task {
let success = await viewModel.submit( let success = await viewModel.submit(
@ -172,6 +183,7 @@ final class MultiTravelTaskUploadViewController: UIViewController {
} }
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate { extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
/// picker
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true) picker.dismiss(animated: true)
guard !results.isEmpty else { return } guard !results.isEmpty else { return }
@ -199,19 +211,41 @@ extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
} }
} }
// MARK: - Historical Shooting Diffable
private typealias HistoricalShootingSection = Int
private typealias HistoricalShootingRow = String
/// section
private enum HistoricalShootingSectionID {
static let summary = 0
static let spots = 1
}
///
private enum HistoricalShootingRowID {
static let project = "historicalShooting:project"
static let projectType = "historicalShooting:projectType"
static let empty = "historicalShooting:empty"
static func spot(_ id: String) -> String { "historicalShooting:spot:\(id)" }
}
/// ///
final class HistoricalShootingInfoViewController: UIViewController { final class HistoricalShootingInfoViewController: UIViewController, UITableViewDelegate {
private let orderNumber: String private let orderNumber: String
private let viewModel = HistoricalShootingInfoViewModel() private let viewModel = HistoricalShootingInfoViewModel()
private lazy var tableView: UITableView = { private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped) let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self table.delegate = self
return table return table
}() }()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>!
///
init(orderNumber: String) { init(orderNumber: String) {
self.orderNumber = orderNumber self.orderNumber = orderNumber
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -220,49 +254,89 @@ final class HistoricalShootingInfoViewController: UIViewController {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "历史拍摄" title = "历史拍摄"
configureTableDataSource()
view.addSubview(tableView) view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
viewModel.onChange = { [weak self] in self?.tableView.reloadData() } viewModel.onChange = { [weak self] in self?.applyTableSnapshot(reconfigure: true) }
Task { await loadData() } Task { await loadData() }
} }
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<HistoricalShootingSection, HistoricalShootingRow>(
tableView: tableView
) { [weak self] (_: UITableView, _: IndexPath, row: HistoricalShootingRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
cell.selectionStyle = .none
switch row {
case HistoricalShootingRowID.project:
cell.textLabel?.text = "项目"
cell.detailTextLabel?.text = self.viewModel.projectName
case HistoricalShootingRowID.projectType:
cell.textLabel?.text = "项目类型"
cell.detailTextLabel?.text = self.viewModel.projectTypeName
case HistoricalShootingRowID.empty:
cell.textLabel?.text = self.viewModel.errorMessage ?? "暂无历史拍摄"
default:
if row.hasPrefix("historicalShooting:spot:") {
let id = row.replacingOccurrences(of: "historicalShooting:spot:", with: "")
if let spot = self.viewModel.spots.first(where: { $0.id == id }) {
cell.textLabel?.text = spot.scenicSpotName
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
}
}
}
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<HistoricalShootingSection, HistoricalShootingRow> {
var snapshot = NSDiffableDataSourceSnapshot<HistoricalShootingSection, HistoricalShootingRow>()
snapshot.appendSections([HistoricalShootingSectionID.summary, HistoricalShootingSectionID.spots])
snapshot.appendItems([HistoricalShootingRowID.project, HistoricalShootingRowID.projectType], toSection: HistoricalShootingSectionID.summary)
if viewModel.spots.isEmpty {
snapshot.appendItems([HistoricalShootingRowID.empty], toSection: HistoricalShootingSectionID.spots)
} else {
snapshot.appendItems(viewModel.spots.map { HistoricalShootingRowID.spot($0.id) }, toSection: HistoricalShootingSectionID.spots)
}
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Data
private func loadData() async { private func loadData() async {
await appServices.globalLoading.withLoading { await appServices.globalLoading.withLoading {
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber) await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
} }
applyTableSnapshot()
if let message = viewModel.errorMessage { showToast(message) } if let message = viewModel.errorMessage { showToast(message) }
} }
}
extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { 2 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 2 : max(viewModel.spots.count, 1)
}
/// UITableView section
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 1 ? "拍摄点位" : nil guard let sectionID = tableDataSource.snapshot().sectionIdentifiers[safe: section] else { return nil }
return sectionID == HistoricalShootingSectionID.spots ? "拍摄点位" : nil
}
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { /// section
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) private extension Array {
cell.selectionStyle = .none subscript(safe index: Int) -> Element? {
if indexPath.section == 0 { indices.contains(index) ? self[index] : nil
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
} }
} }

View File

@ -6,6 +6,31 @@
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Diffable Int / String MainActor Hashable
/// section
private enum OrdersSectionID {
static let header = 0
static let toolbar = 1
static let content = 2
}
private typealias OrdersSection = Int
/// item
private enum OrdersItemID {
static let header = "orders:header"
static let filter = "orders:filter"
static let writeOffAction = "orders:writeOffAction"
static let missingContext = "orders:missingContext"
static let emptyStore = "orders:empty:store"
static let emptyWriteOff = "orders:empty:writeOff"
static func storeOrder(_ orderNumber: String) -> String { "orders:store:\(orderNumber)" }
static func writeOffOrder(_ orderNumber: String) -> String { "orders:writeoff:\(orderNumber)" }
}
private typealias OrdersItem = String
/// Tab /// Tab
final class OrdersViewController: UIViewController { final class OrdersViewController: UIViewController {
@ -13,36 +38,43 @@ final class OrdersViewController: UIViewController {
private var manualOrderNumber = "" private var manualOrderNumber = ""
private var scanHintMessage: String? private var scanHintMessage: String?
private lazy var tableView: UITableView = { private lazy var collectionView: UICollectionView = {
let table = UITableView(frame: .zero, style: .grouped) let layout = makeCollectionLayout()
table.backgroundColor = AppDesignUIKit.pageBackground let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
table.separatorStyle = .none collection.backgroundColor = AppDesignUIKit.pageBackground
table.dataSource = self collection.delegate = self
table.delegate = self collection.register(OrdersHeaderCell.self, forCellWithReuseIdentifier: OrdersHeaderCell.reuseID)
table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID) collection.register(OrdersFilterCell.self, forCellWithReuseIdentifier: OrdersFilterCell.reuseID)
table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID) collection.register(OrdersWriteOffActionCell.self, forCellWithReuseIdentifier: OrdersWriteOffActionCell.reuseID)
table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID) collection.register(OrderEntityCell.self, forCellWithReuseIdentifier: OrderEntityCell.reuseID)
table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID) collection.register(WriteOffOrderCell.self, forCellWithReuseIdentifier: WriteOffOrderCell.reuseID)
table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID) collection.register(OrdersEmptyStateCell.self, forCellWithReuseIdentifier: OrdersEmptyStateCell.reuseID)
return table return collection
}()
private lazy var dataSource: UICollectionViewDiffableDataSource<OrdersSection, OrdersItem> = {
UICollectionViewDiffableDataSource<OrdersSection, OrdersItem>(collectionView: collectionView) { [weak self] collectionView, indexPath, item in
self?.cell(for: collectionView, at: indexPath, item: item) ?? UICollectionViewCell()
}
}() }()
private lazy var refreshControl = UIRefreshControl() private lazy var refreshControl = UIRefreshControl()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "订单" title = "订单"
view.backgroundColor = AppDesignUIKit.pageBackground view.backgroundColor = AppDesignUIKit.pageBackground
view.addSubview(tableView) view.addSubview(collectionView)
tableView.snp.makeConstraints { make in collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()
} }
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
tableView.refreshControl = refreshControl collectionView.refreshControl = refreshControl
viewModel.onChange = { [weak self] in viewModel.onChange = { [weak self] in
self?.tableView.reloadData() self?.applySnapshot()
} }
appServices.appRouter.onChange = { [weak self] in appServices.appRouter.onChange = { [weak self] in
@ -64,6 +96,202 @@ final class OrdersViewController: UIViewController {
} }
} }
/// Compositional Layout section
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
guard let self else {
return CollectionDiffableLayout.fullWidthSection()
}
let section = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]
switch section {
case OrdersSectionID.header:
return CollectionDiffableLayout.fullWidthSection(height: 120)
case OrdersSectionID.toolbar:
let height: CGFloat = self.viewModel.selectedEntry == .storeOrders ? 130 : 150
return CollectionDiffableLayout.fullWidthSection(height: height)
case OrdersSectionID.content:
return CollectionDiffableLayout.fullWidthSection(estimatedHeight: 88)
default:
return CollectionDiffableLayout.fullWidthSection()
}
}
}
/// ViewModel Diffable
private func applySnapshot(animatingDifferences: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>()
snapshot.appendSections([OrdersSectionID.header])
snapshot.appendItems([OrdersItemID.header], toSection: OrdersSectionID.header)
guard currentScenicId != nil else {
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
return
}
snapshot.appendSections([OrdersSectionID.toolbar, OrdersSectionID.content])
if viewModel.selectedEntry == .storeOrders {
snapshot.appendItems([OrdersItemID.filter], toSection: OrdersSectionID.toolbar)
appendStoreOrderItems(to: &snapshot)
} else {
snapshot.appendItems([OrdersItemID.writeOffAction], toSection: OrdersSectionID.toolbar)
appendWriteOffOrderItems(to: &snapshot)
}
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
/// item
private func appendStoreOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>) {
if viewModel.storeOrders.isEmpty {
guard !(viewModel.loading && viewModel.storeOrders.isEmpty) else { return }
snapshot.appendItems([OrdersItemID.emptyStore], toSection: OrdersSectionID.content)
return
}
snapshot.appendItems(
viewModel.storeOrders.map { OrdersItemID.storeOrder($0.orderNumber) },
toSection: OrdersSectionID.content
)
}
/// item
private func appendWriteOffOrderItems(to snapshot: inout NSDiffableDataSourceSnapshot<OrdersSection, OrdersItem>) {
if viewModel.writeOffOrders.isEmpty {
guard !(viewModel.loading && viewModel.writeOffOrders.isEmpty) else { return }
snapshot.appendItems([OrdersItemID.emptyWriteOff], toSection: OrdersSectionID.content)
return
}
snapshot.appendItems(
viewModel.writeOffOrders.map { OrdersItemID.writeOffOrder($0.orderNumber) },
toSection: OrdersSectionID.content
)
}
/// item dequeue Cell
private func cell(
for collectionView: UICollectionView,
at indexPath: IndexPath,
item: OrdersItem
) -> UICollectionViewCell {
switch item {
case OrdersItemID.header:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersHeaderCell.reuseID,
for: indexPath
) as! OrdersHeaderCell
cell.configure(
selectedEntry: viewModel.selectedEntry,
scenicName: appServices.accountContext.currentScenic?.name ?? "--",
storeTotal: viewModel.storeTotal,
writeOffTotal: viewModel.writeOffTotal,
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
)
return cell
case OrdersItemID.filter:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersFilterCell.reuseID,
for: indexPath
) as! OrdersFilterCell
let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
cell.configure(
statusTitle: statusTitle,
phone: viewModel.searchPhone,
onStatus: { [weak self] in self?.presentStatusFilter() },
onDate: { [weak self] in self?.presentDateFilter() },
onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text },
onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } }
)
return cell
case OrdersItemID.writeOffAction:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersWriteOffActionCell.reuseID,
for: indexPath
) as! OrdersWriteOffActionCell
cell.configure(
manualOrderNumber: manualOrderNumber,
isVerifying: viewModel.isVerifying,
hint: scanHintMessage,
onScan: { [weak self] in self?.presentScanner() },
onManualChange: { [weak self] text in self?.manualOrderNumber = text },
onVerify: { [weak self] in
guard let self, !self.manualOrderNumber.isEmpty else { return }
self.confirmVerify(orderNumber: self.manualOrderNumber)
}
)
return cell
case OrdersItemID.missingContext:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
for: indexPath
) as! OrdersEmptyStateCell
cell.embed(
makeEmptyStateView(
title: "缺少经营上下文",
message: "请先在首页选择景区后查看订单。",
systemImage: "mountain.2"
),
preferredHeight: 360
)
return cell
case OrdersItemID.emptyStore:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
for: indexPath
) as! OrdersEmptyStateCell
cell.embed(
makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray"),
preferredHeight: 260
)
return cell
case OrdersItemID.emptyWriteOff:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrdersEmptyStateCell.reuseID,
for: indexPath
) as! OrdersEmptyStateCell
cell.embed(
makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray"),
preferredHeight: 260
)
return cell
default:
if item.hasPrefix("orders:store:") {
let orderNumber = String(item.dropFirst("orders:store:".count))
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else {
return UICollectionViewCell()
}
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: OrderEntityCell.reuseID,
for: indexPath
) as! OrderEntityCell
cell.configure(item: order)
return cell
}
if item.hasPrefix("orders:writeoff:") {
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else {
return UICollectionViewCell()
}
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: WriteOffOrderCell.reuseID,
for: indexPath
) as! WriteOffOrderCell
cell.configure(
item: order,
isVerifying: viewModel.currentVerifyingOrderNumber == order.orderNumber
)
return cell
}
return UICollectionViewCell()
}
}
///
@objc private func refreshPulled() { @objc private func refreshPulled() {
Task { Task {
await reload(showLoading: false) await reload(showLoading: false)
@ -75,6 +303,7 @@ final class OrdersViewController: UIViewController {
private var currentStoreId: Int? { appServices.accountContext.currentStore?.id } private var currentStoreId: Int? { appServices.accountContext.currentStore?.id }
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
///
private func reload(showLoading: Bool) async { private func reload(showLoading: Bool) async {
do { do {
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") { try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") {
@ -91,6 +320,7 @@ final class OrdersViewController: UIViewController {
} }
} }
///
private func loadMore() async { private func loadMore() async {
do { do {
if viewModel.selectedEntry == .storeOrders { if viewModel.selectedEntry == .storeOrders {
@ -111,6 +341,7 @@ final class OrdersViewController: UIViewController {
} }
} }
/// /
private func switchEntry(_ entry: OrdersEntry) { private func switchEntry(_ entry: OrdersEntry) {
guard viewModel.selectedEntry != entry else { return } guard viewModel.selectedEntry != entry else { return }
viewModel.selectedEntry = entry viewModel.selectedEntry = entry
@ -118,6 +349,7 @@ final class OrdersViewController: UIViewController {
Task { await reload(showLoading: true) } Task { await reload(showLoading: true) }
} }
///
private func verify(orderNumber: String) async { private func verify(orderNumber: String) async {
guard let scenicId = currentScenicId else { return } guard let scenicId = currentScenicId else { return }
do { do {
@ -135,12 +367,14 @@ final class OrdersViewController: UIViewController {
} }
} }
///
private func consumePendingScanCodeIfNeeded() async { private func consumePendingScanCodeIfNeeded() async {
guard viewModel.selectedEntry == .verificationOrders, guard viewModel.selectedEntry == .verificationOrders,
let code = appServices.appRouter.consumePendingOrderScanCode() else { return } let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
handleScanResult(code) handleScanResult(code)
} }
///
private func handleScanResult(_ rawCode: String) { private func handleScanResult(_ rawCode: String) {
guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else { guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else {
showToast("未识别到有效订单号") showToast("未识别到有效订单号")
@ -150,10 +384,11 @@ final class OrdersViewController: UIViewController {
confirmVerify(orderNumber: parsed.orderNumber) confirmVerify(orderNumber: parsed.orderNumber)
} else { } else {
scanHintMessage = "扫码成功,当前列表未找到该订单" scanHintMessage = "扫码成功,当前列表未找到该订单"
tableView.reloadData() applySnapshot()
} }
} }
///
private func confirmVerify(orderNumber: String) { private func confirmVerify(orderNumber: String) {
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert) let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -163,6 +398,7 @@ final class OrdersViewController: UIViewController {
present(alert, animated: true) present(alert, animated: true)
} }
///
private func presentScanner() { private func presentScanner() {
let scanner = OrderCodeScannerViewController() let scanner = OrderCodeScannerViewController()
scanner.onScanResult = { [weak self, weak scanner] result in scanner.onScanResult = { [weak self, weak scanner] result in
@ -180,6 +416,7 @@ final class OrdersViewController: UIViewController {
present(nav, animated: true) present(nav, animated: true)
} }
///
private func presentDateFilter() { private func presentDateFilter() {
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert) let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
@ -198,6 +435,7 @@ final class OrdersViewController: UIViewController {
present(alert, animated: true) present(alert, animated: true)
} }
/// ActionSheet
private func presentStatusFilter() { private func presentStatusFilter() {
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet) let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
for filter in OrderFilters.statusFilters { for filter in OrderFilters.statusFilters {
@ -211,143 +449,51 @@ final class OrdersViewController: UIViewController {
} }
} }
extension OrdersViewController: UITableViewDataSource, UITableViewDelegate { // MARK: - UICollectionViewDelegate
func numberOfSections(in tableView: UITableView) -> Int {
guard currentScenicId != nil else { return 1 }
return viewModel.selectedEntry == .storeOrders ? 3 : 3
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { extension OrdersViewController: UICollectionViewDelegate {
if section == 0 { return 1 } ///
if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if currentScenicId == nil { return 1 } collectionView.deselectItem(at: indexPath, animated: true)
if viewModel.selectedEntry == .storeOrders { guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1) if item.hasPrefix("orders:store:") {
} let orderNumber = String(item.dropFirst("orders:store:".count))
return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1) guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
} HomeMenuRouting.pushOrders(.storeDetail(order), from: self)
} else if item.hasPrefix("orders:writeoff:") {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let orderNumber = String(item.dropFirst("orders:writeoff:".count))
if indexPath.section == 0 { guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self)
let services = appServices
cell.configure(
selectedEntry: viewModel.selectedEntry,
scenicName: services.accountContext.currentScenic?.name ?? "--",
storeTotal: viewModel.storeTotal,
writeOffTotal: viewModel.writeOffTotal,
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
)
return cell
}
if currentScenicId == nil {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2")
cell.contentView.addSubview(empty)
empty.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(360)
}
return cell
}
if indexPath.section == 1 {
if viewModel.selectedEntry == .storeOrders {
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell
let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
cell.configure(
statusTitle: statusTitle,
phone: viewModel.searchPhone,
onStatus: { [weak self] in self?.presentStatusFilter() },
onDate: { [weak self] in self?.presentDateFilter() },
onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text },
onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } }
)
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell
cell.configure(
manualOrderNumber: manualOrderNumber,
isVerifying: viewModel.isVerifying,
hint: scanHintMessage,
onScan: { [weak self] in self?.presentScanner() },
onManualChange: { [weak self] text in self?.manualOrderNumber = text },
onVerify: { [weak self] in
guard let self, !self.manualOrderNumber.isEmpty else { return }
self.confirmVerify(orderNumber: self.manualOrderNumber)
}
)
return cell
}
if viewModel.selectedEntry == .storeOrders {
if viewModel.storeOrders.isEmpty {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray")
cell.contentView.addSubview(empty)
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
return cell
}
let item = viewModel.storeOrders[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell
cell.configure(item: item)
return cell
}
if viewModel.writeOffOrders.isEmpty {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray")
cell.contentView.addSubview(empty)
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
return cell
}
let item = viewModel.writeOffOrders[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell
cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 2 else { return }
if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count {
HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self)
} else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count {
HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self)
} }
} }
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { ///
guard indexPath.section == 2 else { return } func collectionView(
_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath
) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
let isLast: Bool let isLast: Bool
if viewModel.selectedEntry == .storeOrders { if item.hasPrefix("orders:store:") {
isLast = indexPath.row == viewModel.storeOrders.count - 1 let orderNumber = String(item.dropFirst("orders:store:".count))
isLast = orderNumber == viewModel.storeOrders.last?.orderNumber
} else if item.hasPrefix("orders:writeoff:") {
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
isLast = orderNumber == viewModel.writeOffOrders.last?.orderNumber
} else { } else {
isLast = indexPath.row == viewModel.writeOffOrders.count - 1 isLast = false
} }
if isLast { Task { await loadMore() } } if isLast {
Task { await loadMore() }
} }
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if indexPath.section == 0 { return 120 }
if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 }
return UITableView.automaticDimension
} }
} }
// MARK: - Cells // MARK: - Cells
private final class OrdersHeaderCell: UITableViewCell { /// Cell
private final class OrdersHeaderCell: UICollectionViewCell {
static let reuseID = "OrdersHeaderCell" static let reuseID = "OrdersHeaderCell"
private var onSelectEntry: ((OrdersEntry) -> Void)? private var onSelectEntry: ((OrdersEntry) -> Void)?
@ -356,16 +502,16 @@ private final class OrdersHeaderCell: UITableViewCell {
private let leftPill = UILabel() private let leftPill = UILabel()
private let rightPill = UILabel() private let rightPill = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { /// Cell
super.init(style: style, reuseIdentifier: reuseIdentifier) override init(frame: CGRect) {
selectionStyle = .none super.init(frame: frame)
backgroundColor = .clear backgroundColor = .clear
let card = UIView() let card = UIView()
card.backgroundColor = .white card.backgroundColor = .white
card.layer.cornerRadius = 8 card.layer.cornerRadius = 8
contentView.addSubview(card) contentView.addSubview(card)
card.snp.makeConstraints { make in card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
} }
let segmentBackground = UIView() let segmentBackground = UIView()
@ -410,7 +556,14 @@ private final class OrdersHeaderCell: UITableViewCell {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) { ///
func configure(
selectedEntry: OrdersEntry,
scenicName: String,
storeTotal: Int,
writeOffTotal: Int,
onSelectEntry: @escaping (OrdersEntry) -> Void
) {
self.onSelectEntry = onSelectEntry self.onSelectEntry = onSelectEntry
updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders) updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders)
updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders) updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders)
@ -423,6 +576,7 @@ private final class OrdersHeaderCell: UITableViewCell {
} }
} }
///
private func updateSegment(_ button: UIButton, title: String, selected: Bool) { private func updateSegment(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal) button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium) button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)
@ -431,26 +585,29 @@ private final class OrdersHeaderCell: UITableViewCell {
button.layer.cornerRadius = 6 button.layer.cornerRadius = 6
} }
///
@objc private func storeTapped() { onSelectEntry?(.storeOrders) } @objc private func storeTapped() { onSelectEntry?(.storeOrders) }
///
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) } @objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
} }
private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate { /// Cell /
private final class OrdersFilterCell: UICollectionViewCell, UITextFieldDelegate {
static let reuseID = "OrdersFilterCell" static let reuseID = "OrdersFilterCell"
private let phoneField = UITextField() private let phoneField = UITextField()
private var onPhoneChange: ((String) -> Void)? private var onPhoneChange: ((String) -> Void)?
private var onSearch: (() -> Void)? private var onSearch: (() -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { /// Cell
super.init(style: style, reuseIdentifier: reuseIdentifier) override init(frame: CGRect) {
selectionStyle = .none super.init(frame: frame)
backgroundColor = .clear backgroundColor = .clear
let card = UIView() let card = UIView()
card.backgroundColor = .white card.backgroundColor = .white
card.layer.cornerRadius = 8 card.layer.cornerRadius = 8
contentView.addSubview(card) contentView.addSubview(card)
card.snp.makeConstraints { make in card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
} }
phoneField.delegate = self phoneField.delegate = self
phoneField.keyboardType = .phonePad phoneField.keyboardType = .phonePad
@ -470,32 +627,44 @@ private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) { ///
func configure(
statusTitle: String,
phone: String,
onStatus: @escaping () -> Void,
onDate: @escaping () -> Void,
onPhoneChange: @escaping (String) -> Void,
onSearch: @escaping () -> Void
) {
phoneField.text = phone phoneField.text = phone
self.onPhoneChange = onPhoneChange self.onPhoneChange = onPhoneChange
self.onSearch = onSearch self.onSearch = onSearch
} }
/// ViewModel
func textFieldDidChangeSelection(_ textField: UITextField) { func textFieldDidChangeSelection(_ textField: UITextField) {
onPhoneChange?(textField.text ?? "") onPhoneChange?(textField.text ?? "")
} }
} }
private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate { /// Cell
private final class OrdersWriteOffActionCell: UICollectionViewCell, UITextFieldDelegate {
static let reuseID = "OrdersWriteOffActionCell" static let reuseID = "OrdersWriteOffActionCell"
private let manualField = UITextField() private let manualField = UITextField()
private var onManualChange: ((String) -> Void)? private var onManualChange: ((String) -> Void)?
private var onVerify: (() -> Void)? private var onVerify: (() -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { /// Cell
super.init(style: style, reuseIdentifier: reuseIdentifier) override init(frame: CGRect) {
selectionStyle = .none super.init(frame: frame)
backgroundColor = .clear backgroundColor = .clear
let card = UIView() let card = UIView()
card.backgroundColor = .white card.backgroundColor = .white
card.layer.cornerRadius = 8 card.layer.cornerRadius = 8
contentView.addSubview(card) contentView.addSubview(card)
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
manualField.delegate = self manualField.delegate = self
manualField.placeholder = "手动输入订单号" manualField.placeholder = "手动输入订单号"
@ -516,33 +685,45 @@ private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelega
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) { ///
func configure(
manualOrderNumber: String,
isVerifying: Bool,
hint: String?,
onScan: @escaping () -> Void,
onManualChange: @escaping (String) -> Void,
onVerify: @escaping () -> Void
) {
manualField.text = manualOrderNumber manualField.text = manualOrderNumber
self.onManualChange = onManualChange self.onManualChange = onManualChange
self.onVerify = onVerify self.onVerify = onVerify
} }
///
func textFieldDidChangeSelection(_ textField: UITextField) { func textFieldDidChangeSelection(_ textField: UITextField) {
onManualChange?(textField.text ?? "") onManualChange?(textField.text ?? "")
} }
} }
private final class OrderEntityCell: UITableViewCell { /// Cell
private final class OrderEntityCell: UICollectionViewCell {
static let reuseID = "OrderEntityCell" static let reuseID = "OrderEntityCell"
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let amountLabel = UILabel() private let amountLabel = UILabel()
private let phoneLabel = UILabel() private let phoneLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { /// Cell
super.init(style: style, reuseIdentifier: reuseIdentifier) override init(frame: CGRect) {
selectionStyle = .default super.init(frame: frame)
backgroundColor = .clear backgroundColor = .clear
let card = UIView() let card = UIView()
card.backgroundColor = .white card.backgroundColor = .white
card.layer.cornerRadius = 8 card.layer.cornerRadius = 8
contentView.addSubview(card) contentView.addSubview(card)
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
@ -563,6 +744,7 @@ private final class OrderEntityCell: UITableViewCell {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
///
func configure(item: OrderEntity) { func configure(item: OrderEntity) {
titleLabel.text = item.orderNumber titleLabel.text = item.orderNumber
statusLabel.text = item.orderStatusName statusLabel.text = item.orderStatusName
@ -571,19 +753,23 @@ private final class OrderEntityCell: UITableViewCell {
} }
} }
private final class WriteOffOrderCell: UITableViewCell { /// Cell
private final class WriteOffOrderCell: UICollectionViewCell {
static let reuseID = "WriteOffOrderCell" static let reuseID = "WriteOffOrderCell"
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let subtitleLabel = UILabel() private let subtitleLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { /// Cell
super.init(style: style, reuseIdentifier: reuseIdentifier) override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear backgroundColor = .clear
let card = UIView() let card = UIView()
card.backgroundColor = .white card.backgroundColor = .white
card.layer.cornerRadius = 8 card.layer.cornerRadius = 8
contentView.addSubview(card) contentView.addSubview(card)
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
subtitleLabel.textColor = AppDesignUIKit.textSecondary subtitleLabel.textColor = AppDesignUIKit.textSecondary
@ -597,8 +783,34 @@ private final class WriteOffOrderCell: UITableViewCell {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
///
func configure(item: WriteOffOrderItem, isVerifying: Bool) { func configure(item: WriteOffOrderItem, isVerifying: Bool) {
titleLabel.text = item.orderNumber titleLabel.text = item.orderNumber
subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName) subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName)
} }
} }
/// Cell
private final class OrdersEmptyStateCell: UICollectionViewCell {
static let reuseID = "OrdersEmptyStateCell"
private var heightConstraint: Constraint?
/// Cell
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
///
func embed(_ emptyView: UIView, preferredHeight: CGFloat) {
contentView.subviews.forEach { $0.removeFromSuperview() }
contentView.addSubview(emptyView)
emptyView.snp.makeConstraints { make in
make.edges.equalToSuperview()
heightConstraint = make.height.equalTo(preferredHeight).constraint
}
}
}

View File

@ -120,6 +120,7 @@ final class DepositOrderListViewModel {
errorMessage = nil errorMessage = nil
} }
/// State
private func resetState() { private func resetState() {
orders = [] orders = []
total = 0 total = 0
@ -170,6 +171,7 @@ final class DepositOrderDetailViewModel {
errorMessage = nil errorMessage = nil
} }
///
private func reset(message: String) { private func reset(message: String) {
detail = nil detail = nil
loading = false loading = false
@ -220,6 +222,7 @@ final class DepositOrderShootingInfoViewModel {
errorMessage = nil errorMessage = nil
} }
///
private func reset(message: String) { private func reset(message: String) {
detail = nil detail = nil
loading = false loading = false
@ -324,10 +327,12 @@ final class OrderRefundViewModel {
return moneyText(value) return moneyText(value)
} }
/// decimal
private static func decimalValue(_ text: String) -> Decimal { private static func decimalValue(_ text: String) -> Decimal {
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0 Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
} }
/// money
private static func moneyText(_ value: Decimal) -> String { private static func moneyText(_ value: Decimal) -> String {
let number = NSDecimalNumber(decimal: value) let number = NSDecimalNumber(decimal: value)
let handler = NSDecimalNumberHandler( let handler = NSDecimalNumberHandler(
@ -379,6 +384,7 @@ final class HistoricalShootingInfoViewModel {
errorMessage = nil errorMessage = nil
} }
///
private func reset(message: String) { private func reset(message: String) {
projectName = "" projectName = ""
projectTypeName = "" projectTypeName = ""
@ -520,6 +526,7 @@ final class MultiTravelTaskUploadViewModel {
errorMessage = nil errorMessage = nil
} }
/// BeforeSubmit
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? { private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard let scenicId, scenicId > 0 else { guard let scenicId, scenicId > 0 else {
@ -549,6 +556,7 @@ final class MultiTravelTaskUploadViewModel {
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId) return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
} }
/// uploadFiles
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] { private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
var uploadFiles: [MultiTravelUploadFileItem] = [] var uploadFiles: [MultiTravelUploadFileItem] = []
for index in selectedLocalFiles.indices { for index in selectedLocalFiles.indices {
@ -583,11 +591,13 @@ final class MultiTravelTaskUploadViewModel {
return uploadFiles return uploadFiles
} }
/// LocalFileProgress
private func updateLocalFileProgress(id: UUID, progress: Int) { private func updateLocalFileProgress(id: UUID, progress: Int) {
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
selectedLocalFiles[index].progress = progress selectedLocalFiles[index].progress = progress
} }
/// SpotSelection
private func resetSpotSelection() { private func resetSpotSelection() {
spots = [] spots = []
selectedSpotId = nil selectedSpotId = nil
@ -595,11 +605,13 @@ final class MultiTravelTaskUploadViewModel {
isLoadingSpots = false isLoadingSpots = false
} }
/// fileType
private static func fileType(for fileName: String) -> Int { private static func fileType(for fileName: String) -> Int {
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
} }
/// Submit
private struct SubmitContext { private struct SubmitContext {
let scenicId: Int let scenicId: Int
let orderNumber: String let orderNumber: String

View File

@ -18,6 +18,7 @@ final class PaymentCollectionViewController: UIViewController {
private let remarkField = UITextField() private let remarkField = UITextField()
private let activityIndicator = UIActivityIndicatorView(style: .large) private let activityIndicator = UIActivityIndicatorView(style: .large)
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "收款" title = "收款"
@ -27,6 +28,7 @@ final class PaymentCollectionViewController: UIViewController {
Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) } Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) }
} }
/// UI UI
private func setupUI() { private func setupUI() {
qrImageView.contentMode = .scaleAspectFit qrImageView.contentMode = .scaleAspectFit
statusLabel.numberOfLines = 0 statusLabel.numberOfLines = 0
@ -56,18 +58,21 @@ final class PaymentCollectionViewController: UIViewController {
] ]
} }
/// render
private func render() { private func render() {
qrImageView.image = viewModel.qrImage qrImageView.image = viewModel.qrImage
statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status) statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status)
if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
} }
/// apply
@objc private func applyAmount() { @objc private func applyAmount() {
viewModel.amountText = amountField.text ?? "" viewModel.amountText = amountField.text ?? ""
viewModel.remarkText = remarkField.text ?? "" viewModel.remarkText = remarkField.text ?? ""
_ = viewModel.applyDynamicAmount() _ = viewModel.applyDynamicAmount()
} }
/// togglePolling
@objc private func togglePolling() { @objc private func togglePolling() {
Task { Task {
await viewModel.pollUntilPaymentDetected( await viewModel.pollUntilPaymentDetected(

View File

@ -10,9 +10,13 @@ import Foundation
/// ///
@MainActor @MainActor
protocol PilotCertificationServing { protocol PilotCertificationServing {
///
func flyerDetail() async throws -> FlyerDetailResponse func flyerDetail() async throws -> FlyerDetailResponse
/// Send
func flyerSendCode(phone: String) async throws func flyerSendCode(phone: String) async throws
///
func flyerApply(_ request: FlyerApplyRequest) async throws func flyerApply(_ request: FlyerApplyRequest) async throws
///
func flyerEdit(_ request: FlyerEditRequest) async throws func flyerEdit(_ request: FlyerEditRequest) async throws
} }

View File

@ -48,6 +48,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
let realnameStatusText: String let realnameStatusText: String
let certificationLogs: [FlyerCertificationLogItem] let certificationLogs: [FlyerCertificationLogItem]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case name = "flyer_nickname" case name = "flyer_nickname"
@ -72,6 +73,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
case certificationLogs = "flyers_certification_logs" case certificationLogs = "flyers_certification_logs"
} }
///
init( init(
id: Int = 0, id: Int = 0,
name: String = "", name: String = "",
@ -118,6 +120,7 @@ struct FlyerDetailResponse: Decodable, Equatable {
self.certificationLogs = certificationLogs self.certificationLogs = certificationLogs
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
@ -155,6 +158,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
let remark: String let remark: String
let createdAt: String let createdAt: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case flyerId = "flyer_id" case flyerId = "flyer_id"
@ -166,6 +170,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
case createdAt = "created_at" case createdAt = "created_at"
} }
///
init( init(
id: Int = 0, id: Int = 0,
flyerId: Int = 0, flyerId: Int = 0,
@ -186,6 +191,7 @@ struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
self.createdAt = createdAt self.createdAt = createdAt
} }
///
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
@ -218,6 +224,7 @@ struct FlyerApplyRequest: Encodable, Equatable {
let contactPhone: String let contactPhone: String
let code: String let code: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name
case realnameStatus = "realname_status" case realnameStatus = "realname_status"
@ -248,6 +255,7 @@ struct FlyerEditRequest: Encodable, Equatable {
let contactPhone: String let contactPhone: String
let code: String let code: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case name case name
@ -277,6 +285,7 @@ enum PilotCertificationValidationError: LocalizedError, Equatable {
} }
private extension KeyedDecodingContainer { private extension KeyedDecodingContainer {
/// pilot
func pilotDecodeLossyString(forKey key: Key) throws -> String { func pilotDecodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) { if let value = try? decodeIfPresent(String.self, forKey: key) {
return value return value
@ -293,6 +302,7 @@ private extension KeyedDecodingContainer {
return "" return ""
} }
/// pilot
func pilotDecodeLossyInt(forKey key: Key) throws -> Int? { func pilotDecodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value return value

View File

@ -13,6 +13,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
private let viewModel = PilotCertificationViewModel() private let viewModel = PilotCertificationViewModel()
private let statusLabel = UILabel() private let statusLabel = UILabel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "飞手认证" title = "飞手认证"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -26,6 +27,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
wireViewModel(viewModel) { [weak self] in self?.updateHeader() } wireViewModel(viewModel) { [weak self] in self?.updateHeader() }
} }
/// Header UI
private func setupHeader() { private func setupHeader() {
statusLabel.numberOfLines = 0 statusLabel.numberOfLines = 0
statusLabel.font = .systemFont(ofSize: 14) statusLabel.font = .systemFont(ofSize: 14)
@ -34,17 +36,21 @@ final class PilotCertificationViewController: ModuleTableViewController {
tableView.tableHeaderView = statusLabel tableView.tableHeaderView = statusLabel
} }
override func numberOfSections(in tableView: UITableView) -> Int { 2 } /// section
override func numberOfTableSections() -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { /// section
override func tableRowCount(in section: Int) -> Int {
section == 0 ? formRows.count : 1 section == 0 ? formRows.count : 1
} }
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { /// section
override func tableSectionTitle(for section: Int) -> String? {
section == 0 ? "认证信息" : "操作" section == 0 ? "认证信息" : "操作"
} }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { /// Cell
override func tableCell(for indexPath: IndexPath, row: ModuleTableRow, in tableView: UITableView) -> UITableViewCell {
let cell = tableView.dequeueReusableCell( let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath for: indexPath
@ -58,8 +64,8 @@ final class PilotCertificationViewController: ModuleTableViewController {
return cell return cell
} }
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { ///
tableView.deselectRow(at: indexPath, animated: true) override func didSelectTableRow(at indexPath: IndexPath) {
if indexPath.section == 1 { if indexPath.section == 1 {
Task { Task {
do { do {
@ -72,11 +78,13 @@ final class PilotCertificationViewController: ModuleTableViewController {
} }
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI) await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI)
updateHeader() updateHeader()
} }
///
@objc private func submit() { @objc private func submit() {
Task { Task {
do { do {
@ -102,6 +110,7 @@ final class PilotCertificationViewController: ModuleTableViewController {
] ]
} }
/// Header
private func updateHeader() { private func updateHeader() {
let status = viewModel.auditStatusText let status = viewModel.auditStatusText
statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")" statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")"

View File

@ -10,6 +10,7 @@ import Foundation
@MainActor @MainActor
/// 便 ViewModel /// 便 ViewModel
protocol PilotRealNameServing { protocol PilotRealNameServing {
/// realNameInfo
func realNameInfo() async throws -> RealNameInfoResponse func realNameInfo() async throws -> RealNameInfoResponse
} }
@ -167,6 +168,7 @@ final class PilotCertificationViewModel {
flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 } flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 }
} }
/// apply
private func apply(_ flyer: FlyerDetailResponse) { private func apply(_ flyer: FlyerDetailResponse) {
self.flyer = flyer self.flyer = flyer
name = flyer.name name = flyer.name
@ -183,6 +185,7 @@ final class PilotCertificationViewModel {
uploadProgress = nil uploadProgress = nil
} }
/// uploadCertificate
private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws { private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws {
guard let data = pendingCertificateImageData else { return } guard let data = pendingCertificateImageData else { return }
guard let scenicId, scenicId > 0 else { guard let scenicId, scenicId > 0 else {
@ -203,6 +206,7 @@ final class PilotCertificationViewModel {
pendingCertificateFileName = nil pendingCertificateFileName = nil
} }
/// ApplyRequest
private func makeApplyRequest() -> FlyerApplyRequest { private func makeApplyRequest() -> FlyerApplyRequest {
FlyerApplyRequest( FlyerApplyRequest(
name: name.trimmedForPilot, name: name.trimmedForPilot,
@ -219,6 +223,7 @@ final class PilotCertificationViewModel {
) )
} }
/// EditRequest
private func makeEditRequest() -> FlyerEditRequest { private func makeEditRequest() -> FlyerEditRequest {
let apply = makeApplyRequest() let apply = makeApplyRequest()
return FlyerEditRequest( return FlyerEditRequest(
@ -237,6 +242,7 @@ final class PilotCertificationViewModel {
) )
} }
/// statusName
private static func statusName(for status: Int) -> String { private static func statusName(for status: Int) -> String {
switch status { switch status {
case 1: "审核中" case 1: "审核中"
@ -254,6 +260,7 @@ final class PilotCertificationViewModel {
return formatter return formatter
}() }()
/// date
private static func date(from value: String) -> Date? { private static func date(from value: String) -> Date? {
let text = String(value.prefix(10)) let text = String(value.prefix(10))
guard !text.isEmpty else { return nil } guard !text.isEmpty else { return nil }
@ -261,9 +268,11 @@ final class PilotCertificationViewModel {
} }
} }
/// PilotEmptyRealName
private struct PilotEmptyRealNameServing: PilotRealNameServing { private struct PilotEmptyRealNameServing: PilotRealNameServing {
let info: RealNameInfo? let info: RealNameInfo?
/// realNameInfo
func realNameInfo() async throws -> RealNameInfoResponse { func realNameInfo() async throws -> RealNameInfoResponse {
RealNameInfoResponse(realNameInfo: info) RealNameInfoResponse(realNameInfo: info)
} }

View File

@ -6,8 +6,18 @@
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Diffable
private typealias AccountSwitchSection = Int
private typealias AccountSwitchItem = String
///
private enum AccountSwitchItemID {
static let empty = "accountSwitch:empty"
}
/// ///
final class AccountSwitchViewController: UIViewController { final class AccountSwitchViewController: UIViewController, UITableViewDelegate {
private let viewModel = AccountSwitchViewModel() private let viewModel = AccountSwitchViewModel()
@ -15,7 +25,6 @@ final class AccountSwitchViewController: UIViewController {
let table = UITableView(frame: .zero, style: .plain) let table = UITableView(frame: .zero, style: .plain)
table.backgroundColor = UIColor(hex: 0xF5F7FB) table.backgroundColor = UIColor(hex: 0xF5F7FB)
table.separatorStyle = .none table.separatorStyle = .none
table.dataSource = self
table.delegate = self table.delegate = self
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID) table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
return table return table
@ -27,11 +36,16 @@ final class AccountSwitchViewController: UIViewController {
return button return button
}() }()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>!
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "账号切换" title = "账号切换"
view.backgroundColor = UIColor(hex: 0xF5F7FB) view.backgroundColor = UIColor(hex: 0xF5F7FB)
configureTableDataSource()
view.addSubview(tableView) view.addSubview(tableView)
view.addSubview(confirmButton) view.addSubview(confirmButton)
tableView.snp.makeConstraints { make in tableView.snp.makeConstraints { make in
@ -45,18 +59,75 @@ final class AccountSwitchViewController: UIViewController {
} }
viewModel.onChange = { [weak self] in viewModel.onChange = { [weak self] in
self?.tableView.reloadData() self?.applyTableSnapshot(reconfigure: true)
self?.updateConfirmButton() self?.updateConfirmButton()
} }
Task { await loadAccounts() } Task { await loadAccounts() }
} }
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>(
tableView: tableView
) { [weak self] (tableView: UITableView, indexPath: IndexPath, item: AccountSwitchItem) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
if item == AccountSwitchItemID.empty {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
let empty = makeEmptyStateView(
title: "暂无可切换账号",
message: "当前登录账号下没有其他可切换账号。",
systemImage: "person.crop.circle.badge.exclamationmark"
)
cell.contentView.addSubview(empty)
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
return cell
}
guard let account = self.viewModel.accounts.first(where: { $0.id == item }) else {
return UITableViewCell()
}
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
cell.configure(
account: account,
selected: self.viewModel.selectedAccountId == account.id,
isCurrent: account.isCurrent || self.isCurrentAccount(account)
)
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem> {
var snapshot = NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem>()
snapshot.appendSections([0])
if viewModel.accounts.isEmpty, !viewModel.loading {
snapshot.appendItems([AccountSwitchItemID.empty], toSection: 0)
} else {
snapshot.appendItems(viewModel.accounts.map(\.id), toSection: 0)
}
return snapshot
}
/// snapshot
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// ConfirmButton
private func updateConfirmButton() { private func updateConfirmButton() {
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
confirmButton.isEnabled = enabled confirmButton.isEnabled = enabled
confirmButton.alpha = enabled ? 1 : 0.5 confirmButton.alpha = enabled ? 1 : 0.5
} }
/// Accounts
private func loadAccounts(force: Bool = false) async { private func loadAccounts(force: Bool = false) async {
do { do {
try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") { try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
@ -82,10 +153,12 @@ final class AccountSwitchViewController: UIViewController {
return current.userId.isEmpty ? nil : current.userId return current.userId.isEmpty ? nil : current.userId
} }
/// isCurrentAccount
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
viewModel.isCurrent(account, currentAccountId: currentAccountId) viewModel.isCurrent(account, currentAccountId: currentAccountId)
} }
/// confirm
@objc private func confirmTapped() { @objc private func confirmTapped() {
guard let account = viewModel.selectedAccount else { return } guard let account = viewModel.selectedAccount else { return }
if account.isCurrent || isCurrentAccount(account) { if account.isCurrent || isCurrentAccount(account) {
@ -95,6 +168,7 @@ final class AccountSwitchViewController: UIViewController {
Task { await switchAccount(account) } Task { await switchAccount(account) }
} }
/// switch
private func switchAccount(_ account: AccountSwitchAccount) async { private func switchAccount(_ account: AccountSwitchAccount) async {
do { do {
let response = try await appServices.globalLoading.withLoading(message: "切换中...") { let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
@ -121,53 +195,29 @@ final class AccountSwitchViewController: UIViewController {
} }
} }
/// non
private func nonEmpty(_ value: String?) -> String? { private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text return text.isEmpty ? nil : text
} }
}
extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard !viewModel.accounts.isEmpty else {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
let empty = makeEmptyStateView(
title: "暂无可切换账号",
message: "当前登录账号下没有其他可切换账号。",
systemImage: "person.crop.circle.badge.exclamationmark"
)
cell.contentView.addSubview(empty)
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
let account = viewModel.accounts[indexPath.row]
cell.configure(
account: account,
selected: viewModel.selectedAccountId == account.id,
isCurrent: account.isCurrent || isCurrentAccount(account)
)
return cell
}
/// UITableView
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.row < viewModel.accounts.count else { return } guard let item = tableDataSource.itemIdentifier(for: indexPath),
viewModel.select(viewModel.accounts[indexPath.row]) item != AccountSwitchItemID.empty,
let account = viewModel.accounts.first(where: { $0.id == item }) else { return }
viewModel.select(account)
} }
/// UITableView
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
viewModel.accounts.isEmpty ? 280 : 92 guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return 92 }
return item == AccountSwitchItemID.empty ? 280 : 92
} }
} }
/// AccountSwitch Cell
private final class AccountSwitchCell: UITableViewCell { private final class AccountSwitchCell: UITableViewCell {
static let reuseID = "AccountSwitchCell" static let reuseID = "AccountSwitchCell"
@ -178,6 +228,7 @@ private final class AccountSwitchCell: UITableViewCell {
private let tagLabel = UILabel() private let tagLabel = UILabel()
private let checkView = UIImageView() private let checkView = UIImageView()
///
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none selectionStyle = .none
@ -245,6 +296,7 @@ private final class AccountSwitchCell: UITableViewCell {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
///
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) { func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title.isEmpty ? account.accountTypeLabel : title titleLabel.text = title.isEmpty ? account.accountTypeLabel : title

View File

@ -7,8 +7,29 @@ import PhotosUI
import SnapKit import SnapKit
import UIKit import UIKit
// MARK: - Diffable
private typealias ProfileTableSection = Int
private typealias ProfileTableRow = String
/// section
private enum ProfileSectionID {
static let info = 0
static let logout = 1
}
///
private enum ProfileRowID {
static let name = "profile:name"
static let account = "profile:account"
static let phone = "profile:phone"
static let realName = "profile:realName"
static let settings = "profile:settings"
static let logout = "profile:logout"
}
/// ///
final class ProfileViewController: UIViewController { final class ProfileViewController: UIViewController, UITableViewDelegate {
private let viewModel = ProfileViewModel() private let viewModel = ProfileViewModel()
@ -19,18 +40,22 @@ final class ProfileViewController: UIViewController {
private lazy var tableView: UITableView = { private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .insetGrouped) let table = UITableView(frame: .zero, style: .insetGrouped)
table.dataSource = self
table.delegate = self table.delegate = self
return table return table
}() }()
private lazy var refreshControl = UIRefreshControl() private lazy var refreshControl = UIRefreshControl()
/// Diffable
private var tableDataSource: UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>!
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "个人信息" title = "个人信息"
view.backgroundColor = UIColor(hex: 0xF7FAFF) view.backgroundColor = UIColor(hex: 0xF7FAFF)
setupHeader() setupHeader()
configureTableDataSource()
view.addSubview(tableView) view.addSubview(tableView)
tableView.snp.makeConstraints { make in tableView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(150) make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
@ -49,6 +74,74 @@ final class ProfileViewController: UIViewController {
Task { await reloadProfile(showToast: false) } Task { await reloadProfile(showToast: false) }
} }
/// Diffable
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
tableView: tableView
) { [weak self] (_: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
switch row {
case ProfileRowID.logout:
cell.textLabel?.text = "退出登录"
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
cell.textLabel?.textAlignment = .center
cell.selectionStyle = .default
case ProfileRowID.name:
cell.textLabel?.text = "姓名"
cell.detailTextLabel?.text = self.viewModel.displayRealName
cell.selectionStyle = .none
case ProfileRowID.account:
cell.textLabel?.text = "当前账号"
cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.phone:
cell.textLabel?.text = "手机号"
cell.detailTextLabel?.text = self.viewModel.displayPhone
cell.selectionStyle = .none
case ProfileRowID.realName:
cell.textLabel?.text = "实名认证"
cell.detailTextLabel?.text = self.viewModel.realNameStatusText
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.settings:
cell.textLabel?.text = "设置"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
default:
break
}
return cell
}
applyTableSnapshot(animated: false)
}
/// Diffable snapshot
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<ProfileTableSection, ProfileTableRow> {
var snapshot = NSDiffableDataSourceSnapshot<ProfileTableSection, ProfileTableRow>()
snapshot.appendSections([ProfileSectionID.info, ProfileSectionID.logout])
snapshot.appendItems([
ProfileRowID.name,
ProfileRowID.account,
ProfileRowID.phone,
ProfileRowID.realName,
ProfileRowID.settings
], toSection: ProfileSectionID.info)
snapshot.appendItems([ProfileRowID.logout], toSection: ProfileSectionID.logout)
return snapshot
}
/// snapshot Cell
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
/// Header UI
private func setupHeader() { private func setupHeader() {
let header = UIView() let header = UIView()
header.backgroundColor = .white header.backgroundColor = .white
@ -101,6 +194,7 @@ final class ProfileViewController: UIViewController {
} }
} }
/// apply
private func applyViewModel() { private func applyViewModel() {
nicknameLabel.text = viewModel.displayNickname nicknameLabel.text = viewModel.displayNickname
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")" uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
@ -111,9 +205,10 @@ final class ProfileViewController: UIViewController {
} }
editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal) editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
editButton.isEnabled = !viewModel.isSaving editButton.isEnabled = !viewModel.isSaving
tableView.reloadData() applyTableSnapshot(animated: false, reconfigure: true)
} }
/// Pulled
@objc private func refreshPulled() { @objc private func refreshPulled() {
Task { Task {
await reloadProfile(showToast: true) await reloadProfile(showToast: true)
@ -121,6 +216,7 @@ final class ProfileViewController: UIViewController {
} }
} }
/// Profile
private func reloadProfile(showToast: Bool) async { private func reloadProfile(showToast: Bool) async {
do { do {
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") { try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
@ -131,6 +227,7 @@ final class ProfileViewController: UIViewController {
} }
} }
/// edit
@objc private func editTapped() { @objc private func editTapped() {
if viewModel.isEditingProfile { if viewModel.isEditingProfile {
Task { await saveProfileEdits() } Task { await saveProfileEdits() }
@ -140,6 +237,7 @@ final class ProfileViewController: UIViewController {
} }
} }
/// NicknameEditor
private func presentNicknameEditor() { private func presentNicknameEditor() {
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert) let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
alert.addTextField { [weak self] field in alert.addTextField { [weak self] field in
@ -157,6 +255,7 @@ final class ProfileViewController: UIViewController {
present(alert, animated: true) present(alert, animated: true)
} }
/// saveEdits
private func saveProfileEdits() async { private func saveProfileEdits() async {
guard let scenicId = appServices.accountContext.currentScenic?.id else { guard let scenicId = appServices.accountContext.currentScenic?.id else {
showToast("请先选择景区") showToast("请先选择景区")
@ -176,6 +275,7 @@ final class ProfileViewController: UIViewController {
} }
} }
/// pick
@objc private func pickAvatar() { @objc private func pickAvatar() {
var config = PHPickerConfiguration() var config = PHPickerConfiguration()
config.filter = .images config.filter = .images
@ -185,6 +285,7 @@ final class ProfileViewController: UIViewController {
present(picker, animated: true) present(picker, animated: true)
} }
/// logout
@objc private func logoutTapped() { @objc private func logoutTapped() {
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert) let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -201,58 +302,19 @@ final class ProfileViewController: UIViewController {
}) })
present(alert, animated: true) present(alert, animated: true)
} }
}
extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { 2 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 5 : 1
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
if indexPath.section == 1 {
cell.textLabel?.text = "退出登录"
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
cell.textLabel?.textAlignment = .center
return cell
}
switch indexPath.row {
case 0:
cell.textLabel?.text = "姓名"
cell.detailTextLabel?.text = viewModel.displayRealName
case 1:
cell.textLabel?.text = "当前账号"
cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--"
cell.accessoryType = .disclosureIndicator
case 2:
cell.textLabel?.text = "手机号"
cell.detailTextLabel?.text = viewModel.displayPhone
case 3:
cell.textLabel?.text = "实名认证"
cell.detailTextLabel?.text = viewModel.realNameStatusText
cell.accessoryType = .disclosureIndicator
default:
cell.textLabel?.text = "设置"
cell.accessoryType = .disclosureIndicator
}
cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default
return cell
}
/// UITableView
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1 { guard let row = tableDataSource.itemIdentifier(for: indexPath) else { return }
switch row {
case ProfileRowID.logout:
logoutTapped() logoutTapped()
return case ProfileRowID.account:
}
switch indexPath.row {
case 1:
HomeMenuRouting.pushProfile(.accountSwitch, from: self) HomeMenuRouting.pushProfile(.accountSwitch, from: self)
case 3: case ProfileRowID.realName:
HomeMenuRouting.pushProfile(.realNameAuth, from: self) HomeMenuRouting.pushProfile(.realNameAuth, from: self)
case 4: case ProfileRowID.settings:
HomeMenuRouting.pushProfile(.settings, from: self) HomeMenuRouting.pushProfile(.settings, from: self)
default: default:
break break
@ -261,6 +323,7 @@ extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
} }
extension ProfileViewController: PHPickerViewControllerDelegate { extension ProfileViewController: PHPickerViewControllerDelegate {
/// picker
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true) picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }

View File

@ -25,6 +25,7 @@ final class RealNameAuthViewController: UIViewController {
private let startDatePicker = UIDatePicker() private let startDatePicker = UIDatePicker()
private let endDatePicker = UIDatePicker() private let endDatePicker = UIDatePicker()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "实名认证" title = "实名认证"
@ -34,6 +35,7 @@ final class RealNameAuthViewController: UIViewController {
Task { await loadInfo() } Task { await loadInfo() }
} }
/// Form UI
private func setupForm() { private func setupForm() {
contentStack.axis = .vertical contentStack.axis = .vertical
contentStack.spacing = 16 contentStack.spacing = 16
@ -107,6 +109,7 @@ final class RealNameAuthViewController: UIViewController {
contentStack.addArrangedSubview(submitButton) contentStack.addArrangedSubview(submitButton)
} }
/// SectionTitle
private func makeSectionTitle(_ text: String) -> UILabel { private func makeSectionTitle(_ text: String) -> UILabel {
let label = UILabel() let label = UILabel()
label.text = text label.text = text
@ -114,6 +117,7 @@ final class RealNameAuthViewController: UIViewController {
return label return label
} }
/// labeled
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView { private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
let label = UILabel() let label = UILabel()
label.text = title label.text = title
@ -125,6 +129,7 @@ final class RealNameAuthViewController: UIViewController {
return stack return stack
} }
/// apply
private func applyViewModel() { private func applyViewModel() {
realNameField.text = viewModel.realName realNameField.text = viewModel.realName
idCardField.text = viewModel.idCardNo idCardField.text = viewModel.idCardNo
@ -163,6 +168,7 @@ final class RealNameAuthViewController: UIViewController {
} }
} }
/// Info
private func loadInfo() async { private func loadInfo() async {
do { do {
try await appServices.globalLoading.withLoading(message: "加载中...") { try await appServices.globalLoading.withLoading(message: "加载中...") {
@ -173,10 +179,12 @@ final class RealNameAuthViewController: UIViewController {
} }
} }
/// longValid
@objc private func longValidChanged() { @objc private func longValidChanged() {
viewModel.isLongValid = longValidSwitch.isOn viewModel.isLongValid = longValidSwitch.isOn
} }
/// send
@objc private func sendCodeTapped() { @objc private func sendCodeTapped() {
Task { Task {
do { do {
@ -187,6 +195,7 @@ final class RealNameAuthViewController: UIViewController {
} }
} }
/// Tapped
@objc private func submitTapped() { @objc private func submitTapped() {
viewModel.realName = realNameField.text ?? "" viewModel.realName = realNameField.text ?? ""
viewModel.idCardNo = idCardField.text ?? "" viewModel.idCardNo = idCardField.text ?? ""
@ -215,16 +224,19 @@ final class RealNameAuthViewController: UIViewController {
private var pendingSide: RealNameImageSide = .front private var pendingSide: RealNameImageSide = .front
/// pickFront
@objc private func pickFront() { @objc private func pickFront() {
pendingSide = .front pendingSide = .front
presentImagePicker() presentImagePicker()
} }
/// pickBack
@objc private func pickBack() { @objc private func pickBack() {
pendingSide = .back pendingSide = .back
presentImagePicker() presentImagePicker()
} }
/// ImagePicker
private func presentImagePicker() { private func presentImagePicker() {
var config = PHPickerConfiguration() var config = PHPickerConfiguration()
config.filter = .images config.filter = .images
@ -236,6 +248,7 @@ final class RealNameAuthViewController: UIViewController {
} }
extension RealNameAuthViewController: PHPickerViewControllerDelegate { extension RealNameAuthViewController: PHPickerViewControllerDelegate {
/// picker
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true) picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
@ -253,6 +266,7 @@ extension RealNameAuthViewController: PHPickerViewControllerDelegate {
} }
private extension UILabel { private extension UILabel {
///
convenience init(text: String) { convenience init(text: String) {
self.init() self.init()
self.text = text self.text = text

View File

@ -7,64 +7,81 @@ import SnapKit
import UIKit import UIKit
import WebKit import WebKit
/// // MARK: - Diffable
final class SettingsViewController: UIViewController {
private var copiedDownloadLink = false ///
private enum SettingsRowID {
private lazy var tableView: UITableView = { static let about = "settings:about"
let table = UITableView(frame: .zero, style: .insetGrouped) static let version = "settings:version"
table.dataSource = self static let download = "settings:download"
table.delegate = self static let userAgreement = "settings:userAgreement"
return table static let privacyPolicy = "settings:privacyPolicy"
}() }
private let rows: [(title: String, action: SettingsRowAction)] = [
("关于我们", .agreement(.about)),
("系统版本", .version),
("App下载", .download),
("用户协议", .agreement(.userAgreement)),
("隐私政策", .agreement(.privacyPolicy))
]
///
private enum SettingsRowAction { private enum SettingsRowAction {
case agreement(AgreementPage) case agreement(AgreementPage)
case version case version
case download 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = "设置" title = "设置"
view.addSubview(tableView)
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
} }
private var downloadLink: String { /// Diffable snapshot
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString override func buildSnapshot() -> NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow> {
var snapshot = NSDiffableDataSourceSnapshot<SimpleTableSection, SimpleTableRow>()
snapshot.appendSections([0])
snapshot.appendItems([
SettingsRowID.about,
SettingsRowID.version,
SettingsRowID.download,
SettingsRowID.userAgreement,
SettingsRowID.privacyPolicy
], toSection: 0)
return snapshot
} }
private func copyDownloadLink() { /// section
UIPasteboard.general.string = downloadLink override func sectionFooter(for section: SimpleTableSection) -> String? {
copiedDownloadLink = true "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
showToast("下载链接已复制")
tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.copiedDownloadLink = false
self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
}
}
} }
extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { /// Cell
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } override func configureCell(_ cell: UITableViewCell, row: SimpleTableRow, at indexPath: IndexPath) {
cell.textLabel?.text = rowTitles[row]
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
let row = rows[indexPath.row]
cell.textLabel?.text = row.title
cell.textLabel?.textColor = UIColor(hex: 0x4B5563) cell.textLabel?.textColor = UIColor(hex: 0x4B5563)
switch row.action { cell.detailTextLabel?.text = nil
cell.detailTextLabel?.textColor = nil
cell.accessoryType = .none
cell.selectionStyle = .default
guard let action = rowActions[row] else { return }
switch action {
case .version: case .version:
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText() cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
cell.selectionStyle = .none cell.selectionStyle = .none
@ -74,12 +91,12 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
case .agreement: case .agreement:
cell.accessoryType = .disclosureIndicator cell.accessoryType = .disclosureIndicator
} }
return cell
} }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { ///
tableView.deselectRow(at: indexPath, animated: true) override func didSelectRow(_ row: SimpleTableRow, at indexPath: IndexPath) {
switch rows[indexPath.row].action { guard let action = rowActions[row] else { return }
switch action {
case .agreement(let page): case .agreement(let page):
HomeMenuRouting.pushProfile(.agreement(page), from: self) HomeMenuRouting.pushProfile(.agreement(page), from: self)
case .download: case .download:
@ -89,8 +106,25 @@ extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
} }
} }
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { private var downloadLink: String {
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
}
///
private func copyDownloadLink() {
UIPasteboard.general.string = downloadLink
copiedDownloadLink = true
showToast("下载链接已复制")
var snapshot = tableDataSource.snapshot()
snapshot.reconfigureItems([SettingsRowID.download])
tableDataSource.apply(snapshot, animatingDifferences: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self else { return }
self.copiedDownloadLink = false
var snapshot = self.tableDataSource.snapshot()
snapshot.reconfigureItems([SettingsRowID.download])
self.tableDataSource.apply(snapshot, animatingDifferences: false)
}
} }
} }
@ -101,6 +135,7 @@ final class AgreementViewController: UIViewController {
private let webView = WKWebView(frame: .zero) private let webView = WKWebView(frame: .zero)
private let loadingIndicator = UIActivityIndicatorView(style: .large) private let loadingIndicator = UIActivityIndicatorView(style: .large)
///
init(page: AgreementPage) { init(page: AgreementPage) {
self.page = page self.page = page
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -109,6 +144,7 @@ final class AgreementViewController: UIViewController {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = page.title title = page.title
@ -124,10 +160,12 @@ final class AgreementViewController: UIViewController {
} }
extension AgreementViewController: WKNavigationDelegate { extension AgreementViewController: WKNavigationDelegate {
/// web
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
loadingIndicator.stopAnimating() loadingIndicator.stopAnimating()
} }
/// web
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
loadingIndicator.stopAnimating() loadingIndicator.stopAnimating()
showToast(error.localizedDescription) showToast(error.localizedDescription)

View File

@ -11,6 +11,7 @@ import UIKit
final class ProjectManagementViewController: ModuleTableViewController { final class ProjectManagementViewController: ModuleTableViewController {
private let viewModel = ProjectManagementViewModel() private let viewModel = ProjectManagementViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "项目管理" title = "项目管理"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -23,13 +24,16 @@ final class ProjectManagementViewController: ModuleTableViewController {
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.items.count } override func tableRowCount() -> Int { viewModel.items.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row] let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)") cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)")
} }
/// didSelectTableRow
override func didSelectTableRow(at indexPath: IndexPath) { override func didSelectTableRow(at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row] let item = viewModel.items[indexPath.row]
navigationController?.pushViewController( navigationController?.pushViewController(
@ -38,10 +42,12 @@ final class ProjectManagementViewController: ModuleTableViewController {
) )
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId) await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId)
} }
///
@objc private func createProject() { @objc private func createProject() {
navigationController?.pushViewController( navigationController?.pushViewController(
ProjectEditorViewController(projectId: nil), ProjectEditorViewController(projectId: nil),
@ -56,19 +62,23 @@ extension ProjectManagementViewModel: ViewModelBindable {}
final class StoreProjectManagementViewController: ModuleTableViewController { final class StoreProjectManagementViewController: ModuleTableViewController {
private let viewModel = StoreProjectManagementViewModel() private let viewModel = StoreProjectManagementViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "店铺项目" title = "店铺项目"
super.viewDidLoad() super.viewDidLoad()
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.filteredItems.count } override func tableRowCount() -> Int { viewModel.filteredItems.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.filteredItems[indexPath.row] let item = viewModel.filteredItems[indexPath.row]
cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)") cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)")
} }
/// didSelectTableRow
override func didSelectTableRow(at indexPath: IndexPath) { override func didSelectTableRow(at indexPath: IndexPath) {
let item = viewModel.filteredItems[indexPath.row] let item = viewModel.filteredItems[indexPath.row]
navigationController?.pushViewController( navigationController?.pushViewController(
@ -77,6 +87,7 @@ final class StoreProjectManagementViewController: ModuleTableViewController {
) )
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(api: services.projectAPI, userId: services.userId) await viewModel.reload(api: services.projectAPI, userId: services.userId)
} }
@ -91,6 +102,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
private let photographerViewModel = ProjectManagementViewModel() private let photographerViewModel = ProjectManagementViewModel()
private let storeViewModel = StoreProjectManagementViewModel() private let storeViewModel = StoreProjectManagementViewModel()
///
init(projectId: Int, storeMode: Bool) { init(projectId: Int, storeMode: Bool) {
self.projectId = projectId self.projectId = projectId
self.storeMode = storeMode self.storeMode = storeMode
@ -102,6 +114,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "项目详情" title = "项目详情"
super.viewDidLoad() super.viewDidLoad()
@ -116,10 +129,12 @@ final class ProjectDetailViewController: ModuleTableViewController {
storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail
} }
/// tableCount
override func tableRowCount() -> Int { override func tableRowCount() -> Int {
detail == nil ? 0 : 4 detail == nil ? 0 : 4
} }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
guard let detail else { return } guard let detail else { return }
switch indexPath.row { switch indexPath.row {
@ -130,6 +145,7 @@ final class ProjectDetailViewController: ModuleTableViewController {
} }
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
if storeMode { if storeMode {
await storeViewModel.loadDetail(id: projectId, api: services.projectAPI) await storeViewModel.loadDetail(id: projectId, api: services.projectAPI)
@ -147,6 +163,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
private let priceField = UITextField() private let priceField = UITextField()
private let projectId: Int? private let projectId: Int?
///
init(projectId: Int?) { init(projectId: Int?) {
self.projectId = projectId self.projectId = projectId
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -157,6 +174,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = projectId == nil ? "新建项目" : "编辑项目" title = projectId == nil ? "新建项目" : "编辑项目"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -170,6 +188,7 @@ final class ProjectEditorViewController: ModuleTableViewController {
wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() } wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() }
} }
/// Header UI
private func setupHeader() { private func setupHeader() {
nameField.placeholder = "项目名称" nameField.placeholder = "项目名称"
nameField.borderStyle = .roundedRect nameField.borderStyle = .roundedRect
@ -187,8 +206,10 @@ final class ProjectEditorViewController: ModuleTableViewController {
tableView.tableHeaderView = stack tableView.tableHeaderView = stack
} }
/// tableCount
override func tableRowCount() -> Int { 0 } override func tableRowCount() -> Int { 0 }
/// Content
override func reloadContent() async { override func reloadContent() async {
guard let projectId else { return } guard let projectId else { return }
if let detail = try? await services.projectAPI.projectDetail(id: projectId) { if let detail = try? await services.projectAPI.projectDetail(id: projectId) {
@ -197,12 +218,14 @@ final class ProjectEditorViewController: ModuleTableViewController {
} }
} }
/// fill
private func fillFormIfNeeded() { private func fillFormIfNeeded() {
if nameField.text?.isEmpty != false { nameField.text = viewModel.name } if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText } if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText }
if priceField.text?.isEmpty != false { priceField.text = viewModel.price } if priceField.text?.isEmpty != false { priceField.text = viewModel.price }
} }
/// save
@objc private func save() { @objc private func save() {
viewModel.name = nameField.text ?? "" viewModel.name = nameField.text ?? ""
viewModel.descriptionText = descriptionField.text ?? "" viewModel.descriptionText = descriptionField.text ?? ""
@ -231,6 +254,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
private let viewModel = StoreProjectEditorViewModel(mode: .create) private let viewModel = StoreProjectEditorViewModel(mode: .create)
private let nameField = UITextField() private let nameField = UITextField()
///
init(projectId: Int?) { init(projectId: Int?) {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -240,6 +264,7 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "店铺项目" title = "店铺项目"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -256,18 +281,22 @@ final class StoreProjectEditorViewController: ModuleTableViewController {
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.scenicList.count } override func tableRowCount() -> Int { viewModel.scenicList.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let scenic = viewModel.scenicList[indexPath.row] let scenic = viewModel.scenicList[indexPath.row]
cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil) cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil)
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
guard let userId = Int(services.userId ?? "") else { return } guard let userId = Int(services.userId ?? "") else { return }
await viewModel.loadScenicList(api: services.projectAPI, userId: userId) await viewModel.loadScenicList(api: services.projectAPI, userId: userId)
} }
/// save
@objc private func save() { @objc private func save() {
viewModel.name = nameField.text ?? "" viewModel.name = nameField.text ?? ""
Task { Task {

View File

@ -130,6 +130,7 @@ final class ProjectManagementViewModel {
return value.isEmpty ? nil : value return value.isEmpty ? nil : value
} }
/// List
private func resetList() { private func resetList() {
items = [] items = []
loading = false loading = false
@ -139,6 +140,7 @@ final class ProjectManagementViewModel {
total = 0 total = 0
} }
/// sorted
private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
values.sorted { lhs, rhs in values.sorted { lhs, rhs in
if lhs.status != rhs.status { return lhs.status < rhs.status } if lhs.status != rhs.status { return lhs.status < rhs.status }
@ -146,6 +148,7 @@ final class ProjectManagementViewModel {
} }
} }
/// deduplicated
private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
var result: [Int: PhotographerProjectItem] = [:] var result: [Int: PhotographerProjectItem] = [:]
values.forEach { result[$0.id] = $0 } values.forEach { result[$0.id] = $0 }
@ -278,6 +281,7 @@ final class ProjectEditorViewModel {
return labels.isEmpty ? nil : labels return labels.isEmpty ? nil : labels
} }
/// resolveCover
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
if let coverImage { if let coverImage {
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in
@ -287,6 +291,7 @@ final class ProjectEditorViewModel {
return existingCoverURL return existingCoverURL
} }
/// resolveCarouselURLs
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
var urls = existingCarouselURLs var urls = existingCarouselURLs
for image in carouselImages { for image in carouselImages {
@ -298,11 +303,13 @@ final class ProjectEditorViewModel {
return urls return urls
} }
/// dMoney
private func normalizedMoney(_ value: String) -> String? { private func normalizedMoney(_ value: String) -> String? {
let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
return amount > 0 ? String(format: "%.2f", amount) : nil return amount > 0 ? String(format: "%.2f", amount) : nil
} }
/// false
private func fail(_ error: ProjectEditorError) -> Bool { private func fail(_ error: ProjectEditorError) -> Bool {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
return false return false
@ -511,6 +518,7 @@ final class StoreProjectEditorViewModel {
return nil return nil
} }
/// resolveCover
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
if let coverImage { if let coverImage {
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in } return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in }
@ -518,6 +526,7 @@ final class StoreProjectEditorViewModel {
return existingCoverURL return existingCoverURL
} }
/// resolveCarouselURLs
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
var urls = existingCarouselURLs var urls = existingCarouselURLs
for image in carouselImages { for image in carouselImages {
@ -527,6 +536,7 @@ final class StoreProjectEditorViewModel {
return urls return urls
} }
/// MultiPointProject
private func submitMultiPointProject( private func submitMultiPointProject(
userId: Int, userId: Int,
api: any ProjectServing, api: any ProjectServing,
@ -586,6 +596,7 @@ final class StoreProjectEditorViewModel {
} }
} }
/// OfflineProject
private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws { private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws {
let scenicId = selectedScenicIds.first ?? 0 let scenicId = selectedScenicIds.first ?? 0
let storeId = selectedStoreId ?? 0 let storeId = selectedStoreId ?? 0
@ -617,6 +628,7 @@ final class StoreProjectEditorViewModel {
} }
} }
/// false
private func fail(_ error: ProjectEditorError) -> Bool { private func fail(_ error: ProjectEditorError) -> Bool {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
return false return false

View File

@ -36,6 +36,7 @@ struct PunchPointRegion: Codable, Hashable {
var address: String var address: String
var scenicSpotStr: String? var scenicSpotStr: String?
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case lat case lat
case lot case lot
@ -80,6 +81,7 @@ struct PunchPointItem: Decodable, Hashable, Identifiable {
let auditTime: String let auditTime: String
let auditRemark: String let auditRemark: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case scenicAreaId = "scenic_area_id" case scenicAreaId = "scenic_area_id"
@ -167,6 +169,7 @@ struct AddPunchPointRequest: Encodable, Equatable {
let scenicSpotStr: String let scenicSpotStr: String
let guideImages: [String] let guideImages: [String]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case scenicAreaId = "scenic_area_id" case scenicAreaId = "scenic_area_id"
case name case name
@ -187,6 +190,7 @@ struct EditPunchPointRequest: Encodable, Equatable {
let scenicSpotStr: String let scenicSpotStr: String
let guideImages: [String] let guideImages: [String]
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case scenicAreaId = "scenic_area_id" case scenicAreaId = "scenic_area_id"

View File

@ -5,6 +5,7 @@
// Created by Codex on 2026/6/26. // Created by Codex on 2026/6/26.
// //
import CoreLocation
import SnapKit import SnapKit
import UIKit import UIKit
@ -18,6 +19,7 @@ private extension PunchPointItem {
final class PunchPointListViewController: ModuleTableViewController { final class PunchPointListViewController: ModuleTableViewController {
private let viewModel = PunchPointListViewModel() private let viewModel = PunchPointListViewModel()
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = "打卡点" title = "打卡点"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -30,13 +32,16 @@ final class PunchPointListViewController: ModuleTableViewController {
wireViewModel(viewModel) { } wireViewModel(viewModel) { }
} }
/// tableCount
override func tableRowCount() -> Int { viewModel.items.count } override func tableRowCount() -> Int { viewModel.items.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row] let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel) cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
} }
/// didSelectTableRow
override func didSelectTableRow(at indexPath: IndexPath) { override func didSelectTableRow(at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row] let item = viewModel.items[indexPath.row]
navigationController?.pushViewController( navigationController?.pushViewController(
@ -45,10 +50,12 @@ final class PunchPointListViewController: ModuleTableViewController {
) )
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI) await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
} }
/// Point
@objc private func createPunchPoint() { @objc private func createPunchPoint() {
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true) navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
} }
@ -62,6 +69,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
private let summary: PunchPointItem? private let summary: PunchPointItem?
private let viewModel = PunchPointListViewModel() private let viewModel = PunchPointListViewModel()
///
init(punchPointId: Int, summary: PunchPointItem?) { init(punchPointId: Int, summary: PunchPointItem?) {
self.punchPointId = punchPointId self.punchPointId = punchPointId
self.summary = summary self.summary = summary
@ -73,6 +81,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
title = summary?.name ?? "打卡点详情" title = summary?.name ?? "打卡点详情"
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -88,11 +97,13 @@ final class PunchPointDetailViewController: ModuleTableViewController {
} }
} }
/// tableCount
override func tableRowCount() -> Int { override func tableRowCount() -> Int {
guard viewModel.selectedDetail != nil || summary != nil else { return 0 } guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
return 4 return 4
} }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let detail = viewModel.selectedDetail ?? summary let detail = viewModel.selectedDetail ?? summary
guard let detail else { return } guard let detail else { return }
@ -104,10 +115,12 @@ final class PunchPointDetailViewController: ModuleTableViewController {
} }
} }
/// Content
override func reloadContent() async { override func reloadContent() async {
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI) await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
} }
/// QR
@objc private func showQR() { @objc private func showQR() {
guard let detail = viewModel.selectedDetail ?? summary else { return } guard let detail = viewModel.selectedDetail ?? summary else { return }
navigationController?.pushViewController( navigationController?.pushViewController(
@ -118,10 +131,17 @@ final class PunchPointDetailViewController: ModuleTableViewController {
} }
/// ///
final class PunchPointEditorViewController: ModuleTableViewController { final class PunchPointEditorViewController: UIViewController {
private let viewModel = PunchPointEditorViewModel() private let viewModel = PunchPointEditorViewModel()
private let nameField = UITextField()
private let punchPointId: Int? private let punchPointId: Int?
private let nameField = UITextField()
private let addressField = UITextField()
private let latitudeField = UITextField()
private let longitudeField = UITextField()
private let mapPicker = PunchPointMapPickerView()
private let locationProvider = ForegroundLocationProvider()
private var services: AppServices { AppServices.shared }
init(punchPointId: Int?) { init(punchPointId: Int?) {
self.punchPointId = punchPointId self.punchPointId = punchPointId
@ -134,48 +154,126 @@ final class PunchPointEditorViewController: ModuleTableViewController {
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad()
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点" title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
view.backgroundColor = UIColor(hex: 0xF5F7FA)
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "保存", title: "保存",
style: .done, style: .done,
target: self, target: self,
action: #selector(save) action: #selector(save)
) )
super.viewDidLoad()
nameField.placeholder = "打卡点名称" configureFields()
nameField.borderStyle = .roundedRect layoutForm()
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) wireViewModel()
tableView.tableHeaderView = nameField
wireViewModel(viewModel) { [weak self] in mapPicker.onLocationPicked = { [weak self] lat, lng, address in
if self?.nameField.text?.isEmpty != false { self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address)
self?.nameField.text = self?.viewModel.name self?.syncFieldsFromViewModel()
}
}
} }
override func tableRowCount() -> Int { 1 } Task {
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
cell.configure(title: "地址", subtitle: viewModel.address)
}
override func reloadContent() async {
guard let punchPointId else { return } guard let punchPointId else { return }
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) { if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
viewModel.apply(detail) 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() { @objc private func save() {
viewModel.name = nameField.text ?? "" viewModel.name = nameField.text ?? ""
viewModel.address = addressField.text ?? ""
viewModel.latitudeText = latitudeField.text ?? ""
viewModel.longitudeText = longitudeField.text ?? ""
Task { Task {
let success = await viewModel.submit( let success = await viewModel.submit(
scenicId: services.currentScenicId, scenicId: services.currentScenicId,
api: services.punchPointAPI, api: services.punchPointAPI,
uploadService: services.ossUploadService uploadService: services.ossUploadService
) )
if success { navigationController?.popViewController(animated: true) } if success {
services.toastCenter.show("保存成功")
navigationController?.popViewController(animated: true)
} else if let message = viewModel.errorMessage {
services.toastCenter.show(message)
}
} }
} }
} }
@ -187,6 +285,7 @@ final class PunchPointQRViewController: UIViewController {
private let pageTitle: String private let pageTitle: String
private let qrURL: String private let qrURL: String
///
init(title: String, qrURL: String) { init(title: String, qrURL: String) {
pageTitle = title pageTitle = title
self.qrURL = qrURL self.qrURL = qrURL
@ -198,6 +297,7 @@ final class PunchPointQRViewController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// UI
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = pageTitle title = pageTitle

View File

@ -10,17 +10,29 @@ import Foundation
/// token /// token
@MainActor @MainActor
protocol ScenicQueueServing: AnyObject { protocol ScenicQueueServing: AnyObject {
/// scenic
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData
/// scenicHome
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
/// socket
func socketToken() async throws -> SocketTokenResponse func socketToken() async throws -> SocketTokenResponse
/// scenicCall
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
/// scenicPass
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
/// scenicFinish
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
/// scenicRequeueInsertBefore
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
/// scenicUserMark
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
/// scenicSetting
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
/// scenicSetting
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
/// scenicShootQRCode
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
/// scenicSettingChangeLog
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
} }

View File

@ -13,6 +13,7 @@ struct ScenicQueueStatsData: Decodable, Equatable {
let avgWaitMin: Double let avgWaitMin: Double
let time: String let time: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case queueCount = "queue_count" case queueCount = "queue_count"
case avgWaitMin = "avg_wait_min" case avgWaitMin = "avg_wait_min"
@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
let list: ScenicQueueHomeListBlock? let list: ScenicQueueHomeListBlock?
let time: String? let time: String?
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case stats case stats
case list case list
@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
struct ScenicQueueHomeStats: Decodable, Equatable { struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int let type: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case type case type
} }
@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable {
let page: Int let page: Int
let pageSize: Int let pageSize: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case list case list
case total case total
@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
isMissRequeue == 1 isMissRequeue == 1
} }
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case queueCode = "queue_code" case queueCode = "queue_code"
@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
case missRequeueText = "is_miss_requeue_text" case missRequeueText = "is_miss_requeue_text"
} }
///
enum AlternateCodingKeys: String, CodingKey { enum AlternateCodingKeys: String, CodingKey {
case queueNo = "queue_no" case queueNo = "queue_no"
case queueNumber = "queue_number" case queueNumber = "queue_number"
@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines) missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
} }
///
private static func formatQueueTime(_ raw: String) -> String { private static func formatQueueTime(_ raw: String) -> String {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return "--" } guard !text.isEmpty else { return "--" }
@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable {
struct SocketTokenResponse: Decodable, Equatable { struct SocketTokenResponse: Decodable, Equatable {
let socketToken: String let socketToken: String
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case socketToken = "socket_token" case socketToken = "socket_token"
} }
@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable {
let statusText: String let statusText: String
let calledAt: String? let calledAt: String?
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case status case status
@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
let recordId: Int64 let recordId: Int64
let operatorId: Int let operatorId: Int
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case recordId = "record_id" case recordId = "record_id"
case operatorId = "operator_id" case operatorId = "operator_id"
@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable {
let queueBanDays: Int? let queueBanDays: Int?
let operatorId: Int? let operatorId: Int?
///
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uid case uid
case scenicId = "scenic_id" case scenicId = "scenic_id"
@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
} }
private extension KeyedDecodingContainer { private extension KeyedDecodingContainer {
///
func decodeLossyString(forKey key: Key) throws -> String { func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) { if let value = try? decodeIfPresent(String.self, forKey: key) {
return value return value
@ -449,6 +461,7 @@ private extension KeyedDecodingContainer {
return "" return ""
} }
///
func decodeLossyInt(forKey key: Key) throws -> Int? { func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value return value
@ -467,6 +480,7 @@ private extension KeyedDecodingContainer {
return nil return nil
} }
/// Double
func decodeLossyDouble(forKey key: Key) throws -> Double? { func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) { if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value return value
@ -484,6 +498,7 @@ private extension KeyedDecodingContainer {
} }
private extension String { private extension String {
/// non
func nonEmptyOrDefault(_ fallback: String) -> String { func nonEmptyOrDefault(_ fallback: String) -> String {
let text = trimmingCharacters(in: .whitespacesAndNewlines) let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? fallback : text return text.isEmpty ? fallback : text

Some files were not shown because too many files have changed in this diff Show More