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

75
Podfile
View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
}
/// Views
private func configureViews(
title: String,
systemImage: String,

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,24 +3,22 @@
// suixinkan
//
import SnapKit
import UIKit
@MainActor
/// Tab 使
final class MainTabBarController: UIViewController {
/// Tab 使 `UITabBarController`
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
private let services: AppServices
private let badgeViewModel = MainTabBadgeViewModel()
private let contentContainer = UIView()
private let customTabBar = CustomMainTabBarView()
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
private var loadedTabs: Set<AppTab> = [.home]
private var isSyncingTabSelection = false
///
init(services: AppServices) {
self.services = services
super.init(nibName: nil, bundle: nil)
delegate = self
}
@available(*, unavailable)
@ -28,124 +26,88 @@ final class MainTabBarController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
configureLayout()
configureTabBarAppearance()
configureTabs()
bindRouter()
bindBadgeViewModel()
bindAccountContext()
switchToTab(services.appRouter.selectedTab, animated: false)
syncSelectedTabFromRouter(animated: false)
Task { await refreshOrderBadge() }
#if DEBUG
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
#endif
}
/// TabBar push
func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) {
let updates = {
self.customTabBar.alpha = hidden ? 0 : 1
self.customTabBar.isUserInteractionEnabled = !hidden
/// TabBar
private func configureTabBarAppearance() {
tabBar.tintColor = AppDesign.primary
tabBar.backgroundColor = .white
}
guard animated else {
updates()
return
}
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates)
}
private func configureLayout() {
view.addSubview(contentContainer)
view.addSubview(customTabBar)
customTabBar.onTabSelected = { [weak self] tab in
self?.services.appRouter.select(tab)
}
customTabBar.onScanTapped = { [weak self] in
self?.presentScanner()
}
contentContainer.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(customTabBar.snp.top)
}
customTabBar.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(72)
/// Tab TabBarItem
private func configureTabs() {
viewControllers = AppTab.allCases.map { tab in
let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarItem = tab.makeTabBarItem()
tabNavigationControllers[tab] = navigationController
return navigationController
}
}
/// Router Tab
private func bindRouter() {
services.appRouter.onChange = { [weak self] in
self?.handleRouterChange()
}
}
/// ViewModel
private func bindBadgeViewModel() {
badgeViewModel.onChange = { [weak self] in
self?.updateOrderBadge()
}
}
///
private func bindAccountContext() {
services.accountContext.onChange = { [weak self] in
Task { await self?.refreshOrderBadge() }
}
}
/// Router Tab `AppRouter`
private func handleRouterChange() {
switchToTab(services.appRouter.selectedTab, animated: false)
syncSelectedTabFromRouter(animated: false)
if services.appRouter.selectedTab == .orders {
Task { await refreshOrderBadge() }
}
}
private func switchToTab(_ tab: AppTab, animated: Bool) {
loadedTabs.insert(tab)
customTabBar.selectedTab = tab
/// `AppRouter.selectedTab` TabBar
private func syncSelectedTabFromRouter(animated: Bool) {
guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return }
guard selectedIndex != index else { return }
let navigationController = navigationController(for: tab)
for child in children where child !== navigationController {
child.willMove(toParent: nil)
child.view.removeFromSuperview()
child.removeFromParent()
}
addChild(navigationController)
contentContainer.addSubview(navigationController.view)
navigationController.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
navigationController.didMove(toParent: self)
let isRoot = navigationController.viewControllers.count <= 1
setCustomTabBarHidden(!isRoot, animated: animated)
}
private func navigationController(for tab: AppTab) -> TabNavigationController {
if let existing = tabNavigationControllers[tab] {
return existing
}
let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarHost = self
tabNavigationControllers[tab] = navigationController
return navigationController
isSyncingTabSelection = true
selectedIndex = index
isSyncingTabSelection = false
}
/// Tab
private func updateOrderBadge() {
guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else {
customTabBar.orderBadgeText = nil
return
let badgeValue: String?
if let count = badgeViewModel.pendingWriteOffCount, count > 0 {
badgeValue = "\(count)"
} else {
badgeValue = nil
}
customTabBar.orderBadgeText = "\(count)"
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
}
/// Tab
private func refreshOrderBadge() async {
await badgeViewModel.refreshPendingWriteOffCount(
api: services.ordersAPI,
@ -154,198 +116,14 @@ final class MainTabBarController: UIViewController {
)
}
private func presentScanner() {
let scanner = OrderCodeScannerViewController()
scanner.onScanResult = { [weak self] result in
guard let self else { return }
switch result {
case .success(let rawCode):
dismiss(animated: true) {
self.services.appRouter.routeToOrderVerification(scannedCode: rawCode)
/// TabBar `AppRouter`
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard !isSyncingTabSelection,
let navigationController = viewController as? TabNavigationController else { return }
services.appRouter.select(navigationController.appTab)
if navigationController.appTab == .orders {
Task { await refreshOrderBadge() }
}
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
///
init(title: String) {
pageTitle = title
super.init(nibName: nil, bundle: nil)
@ -21,6 +22,7 @@ final class PlaceholderViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,37 +1,146 @@
//
// OperatingAreaViewController.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
// suixinkan_ios
//
import SnapKit
import UIKit
///
final class OperatingAreaViewController: ModuleTableViewController {
///
final class OperatingAreaViewController: UIViewController {
private let viewModel = OperatingAreaViewModel()
private let mapView = OperatingAreaMapView()
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
private let summaryLabel = UILabel()
private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash")
private var dataSource: UITableViewDiffableDataSource<Int, String>!
private var services: AppServices { AppServices.shared }
/// UI
override func viewDidLoad() {
title = "运营区域"
super.viewDidLoad()
wireViewModel(viewModel) { }
title = "运营区域"
view.backgroundColor = UIColor(hex: 0xF5F7FA)
setupLayout()
wireViewModel()
Task { await reload(showLoading: true) }
}
override func tableRowCount() -> Int {
viewModel.items.count
private func setupLayout() {
summaryLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
summaryLabel.textColor = AppDesign.textSecondary
summaryLabel.numberOfLines = 0
mapView.snp.makeConstraints { make in
make.height.equalTo(320)
}
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count)")
blockReasonView.isHidden = true
tableView.register(TitleSubtitleTableViewCell.self, forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(onPullRefresh), for: .valueChanged)
dataSource = UITableViewDiffableDataSource<Int, String>(tableView: tableView) { [weak self] tableView, indexPath, itemID in
guard let self else { return UITableViewCell() }
let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath
) as! TitleSubtitleTableViewCell
guard let item = self.viewModel.items.first(where: { String($0.id) == itemID }) else {
return cell
}
let ringCount = self.viewModel.fenceRings.filter { $0.itemId == item.id }.count
cell.configure(
title: item.name.isEmpty ? "未命名区域" : item.name,
subtitle: [item.typeText, item.statusText].filter { !$0.isEmpty }.joined(separator: " · "),
detail: "围栏 \(ringCount)"
)
return cell
}
override func reloadContent() async {
let headerStack = UIStackView(arrangedSubviews: [summaryLabel, blockReasonView, mapView])
headerStack.axis = .vertical
headerStack.spacing = AppMetrics.Spacing.medium
let headerContainer = UIView()
headerContainer.addSubview(headerStack)
headerStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
}
headerContainer.layoutIfNeeded()
let height = headerStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height
headerContainer.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: height + 24)
tableView.tableHeaderView = headerContainer
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func wireViewModel() {
viewModel.onChange = { [weak self] in
self?.render()
}
}
private func render() {
summaryLabel.text = "\(viewModel.title) · \(scopeText()) · 区域 \(viewModel.summary.areaCount) · 围栏 \(viewModel.summary.fenceCount)"
if let reason = viewModel.blockReason {
blockReasonView.isHidden = false
blockReasonView.update(title: reason.message, systemImage: "mappin.slash")
mapView.isHidden = true
} else {
blockReasonView.isHidden = true
mapView.isHidden = false
mapView.rings = viewModel.fenceRings
}
applyTableSnapshot()
}
/// Diffable snapshot
private func applyTableSnapshot(animated: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.items.map { String($0.id) }, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: animated)
}
private func scopeText() -> String {
switch viewModel.mode {
case .storeAdmin:
return services.accountContext.currentStore?.name ?? "当前店铺"
case .scenicAdmin:
return services.accountContext.currentScenic?.name ?? "当前景区"
case nil:
return services.accountContext.currentScenic?.name
?? services.accountContext.currentStore?.name
?? "未选择业务范围"
}
}
@objc private func onPullRefresh() {
Task {
await reload(showLoading: false)
tableView.refreshControl?.endRefreshing()
}
}
private func reload(showLoading: Bool) async {
if showLoading { services.globalLoading.show() }
defer { if showLoading { services.globalLoading.hide() } }
await viewModel.reload(
api: services.operatingAreaAPI,
accountContext: services.accountContext,
permissionContext: services.permissionContext
)
if let message = viewModel.errorMessage {
services.toastCenter.show(message)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
// Created by Codex on 2026/6/26.
//
import CoreLocation
import SnapKit
import UIKit
@ -18,6 +19,7 @@ private extension PunchPointItem {
final class PunchPointListViewController: ModuleTableViewController {
private let viewModel = PunchPointListViewModel()
/// UI
override func viewDidLoad() {
title = "打卡点"
navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -30,13 +32,16 @@ final class PunchPointListViewController: ModuleTableViewController {
wireViewModel(viewModel) { }
}
/// tableCount
override func tableRowCount() -> Int { viewModel.items.count }
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel)
}
/// didSelectTableRow
override func didSelectTableRow(at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
navigationController?.pushViewController(
@ -45,10 +50,12 @@ final class PunchPointListViewController: ModuleTableViewController {
)
}
/// Content
override func reloadContent() async {
await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI)
}
/// Point
@objc private func createPunchPoint() {
navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true)
}
@ -62,6 +69,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
private let summary: PunchPointItem?
private let viewModel = PunchPointListViewModel()
///
init(punchPointId: Int, summary: PunchPointItem?) {
self.punchPointId = punchPointId
self.summary = summary
@ -73,6 +81,7 @@ final class PunchPointDetailViewController: ModuleTableViewController {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
title = summary?.name ?? "打卡点详情"
navigationItem.rightBarButtonItem = UIBarButtonItem(
@ -88,11 +97,13 @@ final class PunchPointDetailViewController: ModuleTableViewController {
}
}
/// tableCount
override func tableRowCount() -> Int {
guard viewModel.selectedDetail != nil || summary != nil else { return 0 }
return 4
}
/// Cell
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let detail = viewModel.selectedDetail ?? summary
guard let detail else { return }
@ -104,10 +115,12 @@ final class PunchPointDetailViewController: ModuleTableViewController {
}
}
/// Content
override func reloadContent() async {
await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI)
}
/// QR
@objc private func showQR() {
guard let detail = viewModel.selectedDetail ?? summary else { return }
navigationController?.pushViewController(
@ -118,10 +131,17 @@ final class PunchPointDetailViewController: ModuleTableViewController {
}
///
final class PunchPointEditorViewController: ModuleTableViewController {
final class PunchPointEditorViewController: UIViewController {
private let viewModel = PunchPointEditorViewModel()
private let nameField = UITextField()
private let punchPointId: Int?
private let nameField = UITextField()
private let addressField = UITextField()
private let latitudeField = UITextField()
private let longitudeField = UITextField()
private let mapPicker = PunchPointMapPickerView()
private let locationProvider = ForegroundLocationProvider()
private var services: AppServices { AppServices.shared }
init(punchPointId: Int?) {
self.punchPointId = punchPointId
@ -134,48 +154,126 @@ final class PunchPointEditorViewController: ModuleTableViewController {
}
override func viewDidLoad() {
super.viewDidLoad()
title = punchPointId == nil ? "新建打卡点" : "编辑打卡点"
view.backgroundColor = UIColor(hex: 0xF5F7FA)
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "保存",
style: .done,
target: self,
action: #selector(save)
)
super.viewDidLoad()
nameField.placeholder = "打卡点名称"
nameField.borderStyle = .roundedRect
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
tableView.tableHeaderView = nameField
wireViewModel(viewModel) { [weak self] in
if self?.nameField.text?.isEmpty != false {
self?.nameField.text = self?.viewModel.name
}
}
configureFields()
layoutForm()
wireViewModel()
mapPicker.onLocationPicked = { [weak self] lat, lng, address in
self?.viewModel.applyLocation(latitude: lat, longitude: lng, address: address)
self?.syncFieldsFromViewModel()
}
override func tableRowCount() -> Int { 1 }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
cell.configure(title: "地址", subtitle: viewModel.address)
}
override func reloadContent() async {
Task {
guard let punchPointId else { return }
if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) {
viewModel.apply(detail)
nameField.text = viewModel.name
syncFieldsFromViewModel()
}
}
}
private func configureFields() {
[nameField, addressField, latitudeField, longitudeField].forEach {
$0.borderStyle = .roundedRect
$0.font = .systemFont(ofSize: AppMetrics.FontSize.body)
}
nameField.placeholder = "打卡点名称"
addressField.placeholder = "地址"
latitudeField.placeholder = "纬度"
longitudeField.placeholder = "经度"
latitudeField.keyboardType = .decimalPad
longitudeField.keyboardType = .decimalPad
}
private func layoutForm() {
let locateButton = UIButton(type: .system)
locateButton.setTitle("使用当前位置", for: .normal)
locateButton.addTarget(self, action: #selector(useCurrentLocation), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [
nameField,
mapPicker,
addressField,
latitudeField,
longitudeField,
locateButton
])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.small
view.addSubview(stack)
nameField.snp.makeConstraints { make in make.height.equalTo(44) }
mapPicker.snp.makeConstraints { make in make.height.equalTo(220) }
addressField.snp.makeConstraints { make in make.height.equalTo(44) }
latitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
longitudeField.snp.makeConstraints { make in make.height.equalTo(44) }
locateButton.snp.makeConstraints { make in make.height.equalTo(44) }
stack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.medium)
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
}
}
private func wireViewModel() {
viewModel.onChange = { [weak self] in
self?.syncFieldsFromViewModel()
}
}
private func syncFieldsFromViewModel() {
if nameField.text?.isEmpty != false { nameField.text = viewModel.name }
addressField.text = viewModel.address
latitudeField.text = viewModel.latitudeText
longitudeField.text = viewModel.longitudeText
if let lat = Double(viewModel.latitudeText), let lng = Double(viewModel.longitudeText) {
mapPicker.setCoordinate(latitude: lat, longitude: lng)
}
}
@objc private func useCurrentLocation() {
Task {
do {
let result = try await locationProvider.requestCurrentLocation()
viewModel.applyLocation(
latitude: result.latitude,
longitude: result.longitude,
address: result.address
)
syncFieldsFromViewModel()
} catch {
services.toastCenter.show("定位失败:\(error.localizedDescription)")
}
}
}
@objc private func save() {
viewModel.name = nameField.text ?? ""
viewModel.address = addressField.text ?? ""
viewModel.latitudeText = latitudeField.text ?? ""
viewModel.longitudeText = longitudeField.text ?? ""
Task {
let success = await viewModel.submit(
scenicId: services.currentScenicId,
api: services.punchPointAPI,
uploadService: services.ossUploadService
)
if success { navigationController?.popViewController(animated: true) }
if success {
services.toastCenter.show("保存成功")
navigationController?.popViewController(animated: true)
} else if let message = viewModel.errorMessage {
services.toastCenter.show(message)
}
}
}
}
@ -187,6 +285,7 @@ final class PunchPointQRViewController: UIViewController {
private let pageTitle: String
private let qrURL: String
///
init(title: String, qrURL: String) {
pageTitle = title
self.qrURL = qrURL
@ -198,6 +297,7 @@ final class PunchPointQRViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
title = pageTitle

View File

@ -10,17 +10,29 @@ import Foundation
/// token
@MainActor
protocol ScenicQueueServing: AnyObject {
/// scenic
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
/// socket
func socketToken() async throws -> SocketTokenResponse
/// scenicCall
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
/// scenicPass
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
/// scenicFinish
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
/// scenicRequeueInsertBefore
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
/// scenicUserMark
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
/// scenicSetting
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
/// scenicSetting
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
/// scenicShootQRCode
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
/// scenicSettingChangeLog
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 time: String
///
enum CodingKeys: String, CodingKey {
case queueCount = "queue_count"
case avgWaitMin = "avg_wait_min"
@ -41,6 +42,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
let list: ScenicQueueHomeListBlock?
let time: String?
///
enum CodingKeys: String, CodingKey {
case stats
case list
@ -73,6 +75,7 @@ struct ScenicQueueHomeData: Decodable, Equatable {
struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int
///
enum CodingKeys: String, CodingKey {
case type
}
@ -96,6 +99,7 @@ struct ScenicQueueHomeListBlock: Decodable, Equatable {
let page: Int
let pageSize: Int
///
enum CodingKeys: String, CodingKey {
case list
case total
@ -174,6 +178,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
isMissRequeue == 1
}
///
enum CodingKeys: String, CodingKey {
case id
case queueCode = "queue_code"
@ -198,6 +203,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
case missRequeueText = "is_miss_requeue_text"
}
///
enum AlternateCodingKeys: String, CodingKey {
case queueNo = "queue_no"
case queueNumber = "queue_number"
@ -289,6 +295,7 @@ struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
}
///
private static func formatQueueTime(_ raw: String) -> String {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return "--" }
@ -317,6 +324,7 @@ struct ScenicQueueActionRequest: Encodable {
struct SocketTokenResponse: Decodable, Equatable {
let socketToken: String
///
enum CodingKeys: String, CodingKey {
case socketToken = "socket_token"
}
@ -340,6 +348,7 @@ struct ScenicQueueActionData: Decodable, Equatable {
let statusText: String
let calledAt: String?
///
enum CodingKeys: String, CodingKey {
case id
case status
@ -375,6 +384,7 @@ struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
let recordId: Int64
let operatorId: Int
///
enum CodingKeys: String, CodingKey {
case recordId = "record_id"
case operatorId = "operator_id"
@ -389,6 +399,7 @@ struct ScenicQueueUserMarkRequest: Encodable, Equatable {
let queueBanDays: Int?
let operatorId: Int?
///
enum CodingKeys: String, CodingKey {
case uid
case scenicId = "scenic_id"
@ -433,6 +444,7 @@ struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
}
private extension KeyedDecodingContainer {
///
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
@ -449,6 +461,7 @@ private extension KeyedDecodingContainer {
return ""
}
///
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
@ -467,6 +480,7 @@ private extension KeyedDecodingContainer {
return nil
}
/// Double
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value
@ -484,6 +498,7 @@ private extension KeyedDecodingContainer {
}
private extension String {
/// non
func nonEmptyOrDefault(_ fallback: String) -> String {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? fallback : text

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