8 Commits

89 changed files with 5440 additions and 6010 deletions

16
Podfile
View File

@ -1,10 +1,17 @@
platform :ios, '16.0' platform :ios, '16.0'
project 'suixinkan.xcodeproj',
'Debug' => :debug,
'SimulatorDebug' => :debug,
'Release' => :release
target 'suixinkan' do target 'suixinkan' do
use_frameworks! use_frameworks!
pod 'AMapLocation-NO-IDFA' # 高德 SDK 未提供 arm64-simulator slice仅在真机配置中接入。
pod 'AMap3DMap-NO-IDFA' pod 'AMapFoundation-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMapSearch-NO-IDFA' pod 'AMapLocation-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMap3DMap-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMapSearch-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'UMCommon' pod 'UMCommon'
pod 'UMDevice' pod 'UMDevice'
pod 'UMAPM' pod 'UMAPM'
@ -24,6 +31,9 @@ post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
if target.name == 'Pods-suixinkanTests' && config.name == 'SimulatorDebug'
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = ''
end
end end
end end
end end

View File

@ -58,6 +58,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- AMap3DMap-NO-IDFA - AMap3DMap-NO-IDFA
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
@ -109,6 +110,6 @@ SPEC CHECKSUMS:
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412 PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

3
Pods/Manifest.lock generated
View File

@ -58,6 +58,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- AMap3DMap-NO-IDFA - AMap3DMap-NO-IDFA
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
@ -109,6 +110,6 @@ SPEC CHECKSUMS:
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412 PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh
${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework
${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework
${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework

View File

@ -0,0 +1,8 @@
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardCore.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardManagerSwift.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardNotification.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardReturnManager.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardToolbar.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardToolbarManager.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQTextInputViewNotification.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQTextView.framework

View File

@ -195,6 +195,16 @@ if [[ "$CONFIGURATION" == "Release" ]]; then
install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework" install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework" install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
fi fi
if [[ "$CONFIGURATION" == "SimulatorDebug" ]]; then
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
fi
if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
wait wait
fi fi

View File

@ -0,0 +1 @@
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh

View File

@ -0,0 +1,18 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = NO
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush" "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -ObjC -l"JCore" -l"JPush" -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "WebKit" -framework "WechatOpenSDK" -weak_framework "Network" -weak_framework "UserNotifications"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/JCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/JPush" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
PODS_ROOT = ${SRCROOT}/Pods
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
VALID_ARCHS = arm64 x86_64

View File

@ -0,0 +1,15 @@
ARCHS = $(ARCHS_STANDARD)
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = NO
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
OTHER_LDFLAGS = $(inherited) -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" -weak_framework "Network" -weak_framework "UserNotifications"
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
PODS_ROOT = ${SRCROOT}/Pods
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
VALID_ARCHS = arm64 x86_64

View File

@ -1,47 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.action { fill: #F6F8FB; stroke: #D5DEE9; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">照片预览与左右切换</text>
<rect x="42" y="92" width="205" height="104" rx="16" class="box"/>
<text x="144" y="126" text-anchor="middle" class="label">相册管理缩略图</text>
<text x="144" y="158" text-anchor="middle" class="small">点击任意照片</text>
<path d="M247 144 H326" class="line"/>
<rect x="326" y="78" width="300" height="132" rx="18" class="active"/>
<text x="476" y="115" text-anchor="middle" class="label">单一返回按钮 + 文件信息</text>
<text x="476" y="146" text-anchor="middle" class="small">文件名 / 文件大小 / 当前照片</text>
<text x="476" y="176" text-anchor="middle" class="small">底部AI修图、删除、刷新</text>
<path d="M626 144 H706" class="line"/>
<rect x="706" y="78" width="205" height="132" rx="18" class="box"/>
<text x="808" y="115" text-anchor="middle" class="label">左右滑动</text>
<text x="808" y="146" text-anchor="middle" class="small">切换上一张 / 下一张</text>
<text x="808" y="176" text-anchor="middle" class="small">首尾不循环</text>
<path d="M911 144 H990" class="line"/>
<rect x="990" y="92" width="168" height="104" rx="16" class="box"/>
<text x="1074" y="126" text-anchor="middle" class="label">同步刷新信息</text>
<text x="1074" y="158" text-anchor="middle" class="small">名称、大小、结果状态</text>
<rect x="42" y="258" width="344" height="112" rx="16" class="action"/>
<text x="214" y="292" text-anchor="middle" class="label">删除当前照片</text>
<text x="214" y="323" text-anchor="middle" class="small">删除后定位到相邻照片</text>
<text x="214" y="349" text-anchor="middle" class="small">最后一张删除后返回相册管理</text>
<rect x="428" y="258" width="344" height="112" rx="16" class="action"/>
<text x="600" y="292" text-anchor="middle" class="label">刷新当前照片</text>
<text x="600" y="323" text-anchor="middle" class="small">重新加载原图或最新修图结果</text>
<text x="600" y="349" text-anchor="middle" class="small">加载失败保留当前可见内容</text>
<rect x="814" y="258" width="344" height="112" rx="16" class="action"/>
<text x="986" y="292" text-anchor="middle" class="label">预览底部操作</text>
<text x="986" y="323" text-anchor="middle" class="small">保留 AI修图、删除、刷新</text>
<text x="986" y="349" text-anchor="middle" class="small">不展示打印按钮</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,44 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.danger { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">相册管理多选操作栏</text>
<rect x="42" y="92" width="215" height="110" rx="16" class="box"/>
<text x="149" y="127" text-anchor="middle" class="label">普通浏览状态</text>
<text x="149" y="160" text-anchor="middle" class="small">底部显示“上传照片”</text>
<path d="M257 147 H337" class="line"/>
<rect x="337" y="92" width="215" height="110" rx="16" class="active"/>
<text x="444" y="127" text-anchor="middle" class="label">点击右上角选择</text>
<text x="444" y="160" text-anchor="middle" class="small">缩略图出现单选圆圈</text>
<path d="M552 147 H632" class="line"/>
<rect x="632" y="92" width="225" height="110" rx="16" class="active"/>
<text x="744" y="127" text-anchor="middle" class="label">进入多选状态</text>
<text x="744" y="160" text-anchor="middle" class="small">上传按钮隐藏</text>
<text x="744" y="184" text-anchor="middle" class="small">显示 AI修图、删除</text>
<path d="M857 147 H937" class="line"/>
<rect x="937" y="92" width="221" height="110" rx="16" class="box"/>
<text x="1048" y="127" text-anchor="middle" class="label">选择至少 1 张</text>
<text x="1048" y="160" text-anchor="middle" class="small">两个操作按钮恢复可用</text>
<rect x="140" y="268" width="310" height="110" rx="16" class="active"/>
<text x="295" y="304" text-anchor="middle" class="label">点击 AI修图</text>
<text x="295" y="336" text-anchor="middle" class="small">进入批量 AI 修图弹窗</text>
<text x="295" y="360" text-anchor="middle" class="small">包含处理中照片时按钮置灰</text>
<rect x="750" y="268" width="310" height="110" rx="16" class="danger"/>
<text x="905" y="304" text-anchor="middle" class="label">点击删除</text>
<text x="905" y="336" text-anchor="middle" class="small">二次确认后删除选中素材</text>
<text x="905" y="360" text-anchor="middle" class="small">取消确认则保持原选择</text>
<path d="M744 202 V235 H295 V268" class="line"/>
<path d="M744 202 V235 H905 V268" class="line"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,31 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
</marker>
<style>
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
</style>
</defs>
<rect width="1080" height="300" fill="#F7F9FC"/>
<rect class="box" x="40" y="104" width="180" height="92"/>
<text class="title" x="130" y="142" text-anchor="middle">相册管理</text>
<text class="detail" x="130" y="170" text-anchor="middle">点击上传照片</text>
<line x1="220" y1="150" x2="292" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="active" x="305" y="70" width="230" height="160"/>
<text class="title" x="420" y="108" text-anchor="middle">选择传输模式</text>
<text class="detail" x="420" y="142" text-anchor="middle">边拍边传</text>
<text class="detail" x="420" y="174" text-anchor="middle">拍后传输</text>
<text class="detail" x="420" y="206" text-anchor="middle">取消:留在当前页</text>
<line x1="535" y1="118" x2="622" y2="78" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="535" y1="182" x2="622" y2="222" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="box" x="635" y="28" width="400" height="100"/>
<text class="title" x="835" y="68" text-anchor="middle">有线传输 · 边拍边传</text>
<text class="detail" x="835" y="96" text-anchor="middle">新照片传入后自动上传当前相册</text>
<rect class="box" x="635" y="172" width="400" height="100"/>
<text class="title" x="835" y="212" text-anchor="middle">有线传输 · 拍后传输</text>
<text class="detail" x="835" y="240" text-anchor="middle">拍摄完成后选择照片批量上传</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,56 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="470" viewBox="0 0 1200 470">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.optional { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="470" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">AI 修图弹窗:原图精修、氛围感与结果组合</text>
<rect x="42" y="92" width="190" height="116" rx="16" class="box"/>
<text x="137" y="127" text-anchor="middle" class="label">打开 AI修图</text>
<text x="137" y="158" text-anchor="middle" class="small">显示已选照片数量</text>
<text x="137" y="183" text-anchor="middle" class="small">内容区域可纵向滚动</text>
<path d="M232 150 H272" class="line"/>
<rect x="272" y="78" width="230" height="144" rx="18" class="active"/>
<text x="387" y="116" text-anchor="middle" class="label">原图精修</text>
<text x="387" y="148" text-anchor="middle" class="small">预设文字 + 对应效果图</text>
<text x="387" y="174" text-anchor="middle" class="small">横向滚动,始终单选</text>
<text x="387" y="200" text-anchor="middle" class="small">生成基础“精修后”结果</text>
<path d="M502 150 H542" class="line"/>
<rect x="542" y="78" width="230" height="144" rx="18" class="optional"/>
<text x="657" y="116" text-anchor="middle" class="label">氛围感修图(选填)</text>
<text x="657" y="148" text-anchor="middle" class="small">横向滚动,多样式单选</text>
<text x="657" y="174" text-anchor="middle" class="small">默认不选,重复点击可取消</text>
<text x="657" y="200" text-anchor="middle" class="small">额外生成独立氛围感结果</text>
<path d="M772 150 H812" class="line"/>
<rect x="812" y="92" width="170" height="116" rx="16" class="box"/>
<text x="897" y="127" text-anchor="middle" class="label">只读额度</text>
<text x="897" y="158" text-anchor="middle" class="small">显示剩余 9 张</text>
<text x="897" y="183" text-anchor="middle" class="small">不提供勾选操作</text>
<path d="M982 150 H1014" class="line"/>
<rect x="1014" y="92" width="144" height="116" rx="16" class="active"/>
<text x="1086" y="127" text-anchor="middle" class="label">点击确定</text>
<text x="1086" y="158" text-anchor="middle" class="small">提交样式组合</text>
<text x="1086" y="183" text-anchor="middle" class="small">开始处理</text>
<rect x="42" y="286" width="344" height="120" rx="16" class="box"/>
<text x="214" y="322" text-anchor="middle" class="label">点击取消 / 下拉关闭</text>
<text x="214" y="354" text-anchor="middle" class="small">不启动任务</text>
<text x="214" y="380" text-anchor="middle" class="small">不改变原图、旧结果或额度状态</text>
<rect x="428" y="286" width="344" height="120" rx="16" class="box"/>
<text x="600" y="322" text-anchor="middle" class="label">效果图预览</text>
<text x="600" y="354" text-anchor="middle" class="small">各样式卡片显示“预览”按钮</text>
<text x="600" y="380" text-anchor="middle" class="small">全屏查看,不改变当前选中项</text>
<rect x="814" y="286" width="344" height="120" rx="16" class="box"/>
<text x="986" y="322" text-anchor="middle" class="label">结果独立保存</text>
<text x="986" y="354" text-anchor="middle" class="small">原图 / 精修后 / 氛围感分别查看</text>
<text x="986" y="380" text-anchor="middle" class="small">再次确认后以新组合覆盖旧结果</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,31 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
</marker>
<style>
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
</style>
</defs>
<rect width="1080" height="300" fill="#F7F9FC"/>
<rect class="box" x="40" y="104" width="210" height="92"/>
<text class="title" x="145" y="142" text-anchor="middle">有线传输照片列表</text>
<text class="detail" x="145" y="170" text-anchor="middle">点击任意缩略图</text>
<line x1="250" y1="150" x2="320" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="active" x="333" y="70" width="260" height="160"/>
<text class="title" x="463" y="108" text-anchor="middle">多图全屏预览</text>
<text class="detail" x="463" y="142" text-anchor="middle">从被点击照片开始</text>
<text class="detail" x="463" y="174" text-anchor="middle">页码显示 当前张 / 总张数</text>
<text class="detail" x="463" y="206" text-anchor="middle">不可预览照片自动跳过</text>
<line x1="593" y1="112" x2="668" y2="74" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="593" y1="188" x2="668" y2="226" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="box" x="681" y="28" width="354" height="100"/>
<text class="title" x="858" y="68" text-anchor="middle">向右滑动</text>
<text class="detail" x="858" y="96" text-anchor="middle">查看上一张,第一张处停止</text>
<rect class="box" x="681" y="172" width="354" height="100"/>
<text class="title" x="858" y="212" text-anchor="middle">向左滑动</text>
<text class="detail" x="858" y="240" text-anchor="middle">查看下一张,最后一张处停止</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,49 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.processing { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.success { fill: #EEFAF3; stroke: #37A86B; stroke-width: 2.5; }
.failed { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2.5; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
.retry { fill: none; stroke: #E35D6A; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">AI 修图任务生命周期与覆盖规则</text>
<rect x="42" y="104" width="205" height="116" rx="16" class="box"/>
<text x="144" y="140" text-anchor="middle" class="label">可发起</text>
<text x="144" y="172" text-anchor="middle" class="small">未修图或已有旧结果</text>
<text x="144" y="198" text-anchor="middle" class="small">AI修图按钮可点击</text>
<path d="M247 162 H337" class="line"/>
<rect x="337" y="104" width="230" height="116" rx="16" class="processing"/>
<text x="452" y="140" text-anchor="middle" class="label">修图中</text>
<text x="452" y="172" text-anchor="middle" class="small">按钮置灰,禁止重复提交</text>
<text x="452" y="198" text-anchor="middle" class="small">离开页面后任务继续完成</text>
<path d="M567 142 H692" class="line"/>
<path d="M567 182 H692" class="retry"/>
<rect x="692" y="76" width="230" height="116" rx="16" class="success"/>
<text x="807" y="112" text-anchor="middle" class="label">成功</text>
<text x="807" y="144" text-anchor="middle" class="small">分别保存精修与氛围感结果</text>
<text x="807" y="170" text-anchor="middle" class="small">默认展示“精修后”</text>
<rect x="692" y="228" width="230" height="116" rx="16" class="failed"/>
<text x="807" y="264" text-anchor="middle" class="label">失败</text>
<text x="807" y="296" text-anchor="middle" class="small">显示“修图失败”</text>
<text x="807" y="322" text-anchor="middle" class="small">恢复 AI修图按钮可重试</text>
<path d="M922 134 H1012" class="line"/>
<rect x="1012" y="76" width="146" height="116" rx="16" class="box"/>
<text x="1085" y="112" text-anchor="middle" class="label">再次修图</text>
<text x="1085" y="144" text-anchor="middle" class="small">新组合完成后</text>
<text x="1085" y="170" text-anchor="middle" class="small">整组覆盖旧结果</text>
<path d="M1085 192 V405 H144 V220" class="line"/>
<path d="M807 344 V405 H452 V220" class="retry"/>
<rect x="337" y="380" width="230" height="82" rx="16" class="box"/>
<text x="452" y="414" text-anchor="middle" class="label">还原为原图</text>
<text x="452" y="440" text-anchor="middle" class="small">清除精修、氛围感、封面及失败状态</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.free { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2.5; }
.cover { fill: #FFF4E8; stroke: #F59E0B; stroke-width: 2.5; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">多图三类风格选择与封面生成</text>
<rect x="42" y="92" width="220" height="116" rx="16" class="box"/>
<text x="152" y="128" text-anchor="middle" class="label">统计选中照片数</text>
<text x="152" y="160" text-anchor="middle" class="small">进入批量 AI 修图前判断</text>
<text x="152" y="186" text-anchor="middle" class="small">阈值以当前需求为准</text>
<path d="M262 150 H342" class="line"/>
<rect x="342" y="76" width="230" height="148" rx="18" class="active"/>
<text x="457" y="112" text-anchor="middle" class="label">数量是否 ≥ 4 张?</text>
<text x="457" y="147" text-anchor="middle" class="small">否:显示精修 + 氛围感</text>
<text x="457" y="175" text-anchor="middle" class="small">是:再显示封面模板</text>
<text x="457" y="201" text-anchor="middle" class="small">弹窗自动使用更高高度</text>
<path d="M572 150 H652" class="line"/>
<rect x="652" y="76" width="250" height="148" rx="18" class="free"/>
<text x="777" y="112" text-anchor="middle" class="label">显示三个风格模块</text>
<text x="777" y="147" text-anchor="middle" class="small">原图精修:单选基础预设</text>
<text x="777" y="175" text-anchor="middle" class="small">氛围感:横向单选,可取消</text>
<text x="777" y="201" text-anchor="middle" class="small">封面:横向单选,可全屏预览</text>
<path d="M902 150 H982" class="line"/>
<rect x="982" y="92" width="176" height="116" rx="16" class="cover"/>
<text x="1070" y="128" text-anchor="middle" class="label">确认并生成</text>
<text x="1070" y="160" text-anchor="middle" class="small">每张保存独立结果</text>
<text x="1070" y="186" text-anchor="middle" class="small">第一张另作封面</text>
<rect x="42" y="300" width="344" height="126" rx="16" class="box"/>
<text x="214" y="336" text-anchor="middle" class="label">当前免费阶段</text>
<text x="214" y="368" text-anchor="middle" class="small">封面 quotaCost = 0不减少额度</text>
<text x="214" y="394" text-anchor="middle" class="small">弹窗显示“赠送 · 不占精修张数”</text>
<rect x="428" y="300" width="344" height="126" rx="16" class="box"/>
<text x="600" y="336" text-anchor="middle" class="label">未来收费阶段</text>
<text x="600" y="368" text-anchor="middle" class="small">服务端将 quotaCost 配置为正数</text>
<text x="600" y="394" text-anchor="middle" class="small">确认前展示实际消耗,成功后再扣减</text>
<rect x="814" y="300" width="344" height="126" rx="16" class="box"/>
<text x="986" y="336" text-anchor="middle" class="label">重复选择封面模板</text>
<text x="986" y="368" text-anchor="middle" class="small">新封面效果覆盖旧封面效果</text>
<text x="986" y="394" text-anchor="middle" class="small">失败时不把结果标记为已生成</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1,51 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="520" viewBox="0 0 1200 520">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8CA5"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 700 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.card { fill: #FFFFFF; stroke: #D5DEE9; stroke-width: 2; }
.line { fill: none; stroke: #7A8CA5; stroke-width: 2.3; marker-end: url(#arrow); }
.badge { font: 700 15px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #FFFFFF; }
</style>
</defs>
<rect width="1200" height="520" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">相册缩略图结果类型与状态优先级</text>
<rect x="42" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="58" y="114" width="70" height="28" rx="8" fill="#34C759"/>
<text x="93" y="134" text-anchor="middle" class="badge">已上传</text>
<text x="137" y="178" text-anchor="middle" class="label">普通原图</text>
<text x="137" y="210" text-anchor="middle" class="small">显示服务端缩略图</text>
<path d="M232 172 H282" class="line"/>
<rect x="282" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="298" y="114" width="70" height="28" rx="8" fill="#4D7CFE"/>
<text x="333" y="134" text-anchor="middle" class="badge">修图中</text>
<text x="377" y="178" text-anchor="middle" class="label">处理中</text>
<text x="377" y="210" text-anchor="middle" class="small">AI入口同步置灰</text>
<path d="M472 172 H522" class="line"/>
<rect x="522" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="538" y="114" width="70" height="28" rx="8" fill="#6750A4"/>
<text x="573" y="134" text-anchor="middle" class="badge">AI已修</text>
<text x="617" y="178" text-anchor="middle" class="label">普通修图结果</text>
<text x="617" y="210" text-anchor="middle" class="small">缩略图展示最新结果</text>
<path d="M712 172 H762" class="line"/>
<rect x="762" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="778" y="114" width="78" height="28" rx="8" fill="#F59E0B"/>
<text x="817" y="134" text-anchor="middle" class="badge">AI封面</text>
<text x="857" y="178" text-anchor="middle" class="label">封面结果</text>
<text x="857" y="210" text-anchor="middle" class="small">显示封面模板排版图</text>
<path d="M952 172 H1002" class="line"/>
<rect x="1002" y="98" width="156" height="148" rx="18" class="card"/>
<rect x="1018" y="114" width="88" height="28" rx="8" fill="#E53935"/>
<text x="1062" y="134" text-anchor="middle" class="badge">修图失败</text>
<text x="1080" y="178" text-anchor="middle" class="label">失败结果</text>
<text x="1080" y="210" text-anchor="middle" class="small">可再次修图</text>
<rect x="42" y="310" width="1116" height="150" rx="18" fill="#FFFFFF" stroke="#D5DEE9" stroke-width="2"/>
<text x="70" y="346" class="label">状态显示优先级</text>
<text x="70" y="382" class="small">修图中 修图失败 AI封面 AI已修 已上传。高优先级状态先展示,避免处理中或失败被旧成功结果遮盖。</text>
<text x="70" y="416" class="small">成功:缩略图切换为最新修图图或封面图;再次成功:覆盖旧结果;还原原图:回到“已上传”;删除素材:同步清除本地结果。</text>
<text x="70" y="446" class="small">当前 AI 修图、封面结果及角标为本地演示数据App 进程重启后会清空;服务端接入后应返回结果类型、状态、结果图地址和模板 ID。</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,261 +0,0 @@
# 相册管理与 AI 修图功能说明
> 文档日期2026-07-29
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
> 数据说明:相册和照片列表、有线相机连接及照片传输继续使用项目已有真实数据能力;传输模式选择保存在本地。原图精修结果、氛围感结果、封面模板、剩余 9 张额度及状态角标目前为本地可演示数据尚未接入服务端持久化App 进程重启后会清空。
## 1. 照片预览、左右滑动与底部操作
### 功能说明
用户在“相册管理”照片网格中点击任意缩略图,进入单张照片预览页。页面只保留一个返回按钮,导航区域展示当前照片文件名和文件大小;照片区域支持左右滑动查看同一相册内的上一张或下一张照片。
预览页统一使用黑色沉浸式自定义头部,不再混用系统导航栏:左侧为圆形返回按钮,中间展示文件名和文件大小,右侧展示“当前张数/总张数”。切换照片时三处信息同步更新。照片预览与原图精修、氛围感、封面模板预览都直接使用同一个全屏模态容器,不进入项目导航栈,因此不会叠加系统白色导航栏或出现两个返回按钮;关闭后回到进入前的相册管理页或修图弹窗。
底部固定展示以下三个操作,不展示打印按钮:
| 操作 | 点击结果 | 操作期间或异常处理 |
| --- | --- | --- |
| AI修图 | 打开当前照片的 AI 修图弹窗 | 当前照片处于“修图中”时按钮置灰,避免重复提交 |
| 删除 | 二次确认后删除当前照片 | 删除成功后自动定位到相邻照片;删除最后一张后返回相册管理 |
| 刷新 | 重新加载当前照片及最新修图结果 | 刷新失败时不清空当前已显示内容,可再次点击 |
左右滑动时同步刷新文件名、文件大小、原图/修图后状态和底部按钮状态。第一张继续向右滑、最后一张继续向左滑时保持当前照片,不循环跳转。
页面底部图标使用项目内 `TravelAlbumPreviewIcons` SVG 资源,确保 AI 修图、删除、刷新三个入口视觉一致。
### 状态流转图
![照片预览与左右切换流程](./assets/travel-album-ai/01-photo-preview-flow.svg)
### 本功能验收
- [ ] 从任意缩略图进入时只显示一个返回入口。
- [ ] 文件名和文件大小与当前照片一致。
- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。
- [ ] 左右滑动后照片和文件信息同步变化。
- [ ] 首尾照片不会越界或循环。
- [ ] 底部仅显示 AI修图、删除、刷新不显示打印。
- [ ] 删除当前照片后能正确定位或退出预览。
## 2. 上传照片与传输模式选择
### 功能说明
相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。
| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 |
| --- | --- | --- | --- |
| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 |
| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 |
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据不创建新的相册或假照片。
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
### 状态流转图
![上传照片与传输模式选择流程](./assets/travel-album-ai/02-upload-transfer-mode-flow.svg)
![有线传输缩略图预览流程](./assets/travel-album-ai/03-wired-transfer-preview-flow.svg)
### 本功能验收
- [ ] 点击“上传照片”后先显示底部传输模式弹窗,不直接进入下一页。
- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
- [ ] 本次选择不会被上一次保存的模式覆盖。
- [ ] 取消或关闭弹窗后仍停留在相册管理。
- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
- [ ] 预览可左右滑动查看当前 Tab 中的上一张和下一张,页码同步变化且首尾不循环。
- [ ] 批量选择状态点击照片只改变选择状态,不打开预览。
## 3. 相册管理多选与底部操作栏切换
### 功能说明
相册管理页普通状态下底部显示“上传照片”。用户点击右上角选择按钮后进入多选状态每张缩略图右上角显示选择圆圈底部“上传照片”隐藏替换为“AI修图”和“删除”。
| 多选状态 | 页面表现 | 可用操作 | 下一状态 |
| --- | --- | --- | --- |
| 未进入多选 | 不显示选择圆圈,底部显示上传照片 | 点击照片进入预览;点击右上角选择 | 进入多选 |
| 已进入但未选照片 | 显示未选圆圈AI修图和删除置灰 | 选择照片;再次点击右上角选择退出 | 已选择或退出 |
| 已选择照片 | 选中照片显示勾选AI修图和删除可用 | 发起批量修图或批量删除 | 弹窗、处理中或删除结果 |
| 选中项含修图中的照片 | 保留当前选择AI修图置灰 | 等待任务完成;删除仍按现有删除规则处理 | 任务结束后恢复 |
| 退出多选 | 清空本次选择,恢复上传照片 | 上传或进入照片预览 | 普通状态 |
批量删除必须二次确认;用户取消时不删除任何照片并保留当前选择。批量 AI 修图复用与单张预览相同的预设、任务状态和结果覆盖逻辑。
### 状态流转图
![相册管理多选操作栏流程](./assets/travel-album-ai/02-multi-select-flow.svg)
### 本功能验收
- [ ] 进入多选后上传按钮消失AI修图和删除出现。
- [ ] 未选择照片时两个批量按钮不可点击。
- [ ] 选择至少一张后两个批量按钮恢复可用。
- [ ] 包含修图中照片时不能再次发起 AI 修图。
- [ ] 退出多选后选择状态清空并恢复上传按钮。
## 4. AI 修图弹窗、原图精修、氛围感与按张收费模式
### 功能说明
用户从单张预览或相册多选操作栏点击“AI修图”后以同一个底部弹窗展示 AI 修图配置。标题与弹窗顶部保留足够间距,标题下方展示“已选择 N 张照片”。弹窗内容可纵向滚动,取消和确定按钮固定在底部,照片较多并出现封面模块时也不会遮挡选择项。
“原图精修”是基础修图模块,使用横向滚动单选列表,每个预设同时展示名称和对应效果图:
| 预设 | 当前效果用途 |
| --- | --- |
| 还原为原图 | 清除该照片已有修图结果和封面类型 |
| 写真-简约肖像 | 低饱和、提亮的人像效果 |
| 写真-清冷古风 | 清冷复古效果 |
| 旅拍-锦绣 | 提升饱和度和对比度 |
| 旅拍-远山 | 低饱和、柔和远景效果 |
| 旅拍-薄雾 | 提亮并降低对比度 |
| 油画-夏日 | 偏暖夏日效果 |
| 油画-浓郁 | 高饱和、高对比效果 |
原图精修默认选中“还原为原图”,用户点击其他预设后仅保留一个选中项。没有可用原图时,效果图位置显示稳定占位图,不影响弹窗选择。每张样式效果图右下角都有“预览”按钮,点击后全屏查看当前照片应用该样式的效果,不改变当前选中项。
“氛围感修图”位于原图精修下方,并明确标记为“选填”。该模块与原图精修保持一致,使用横向滚动效果图单选,当前本地演示包含日落暖阳、复古胶片、清透蓝调、森系薄雾、浓郁故事五种样式。默认不选中;点击一种样式后仅保留该项,再次点击当前样式即可取消。每个样式同样提供“预览”按钮。选中后不是把氛围效果叠加并合并到精修图中,而是为每张照片额外生成一张可独立查看的“氛围感”结果。
原图精修、氛围感、封面模板和“查看高清图”统一进入同一个沉浸式大图预览页。大图页不再套用系统导航栏,只显示一个返回按钮、当前样式名称和“双指缩放查看细节”提示;关闭后回到原弹窗或照片页,并保留关闭前的选择状态。
弹窗内的 Tips 会随当前组合实时变化:
| 当前选择 | 确认后的结果 |
| --- | --- |
| 仅原图精修 | 每张照片生成 1 个“精修后”结果 |
| 原图精修 + 氛围感修图 | 每张照片生成“精修后 + 氛围感”2 个独立结果 |
| 有封面、未选氛围感 | 每张照片生成精修结果,第一张选中照片额外生成封面 |
| 有封面、已选氛围感 | 每张照片生成精修和氛围感 2 个结果,第一张选中照片再额外生成封面 |
“修图模式”区域只读展示“AI精修 · 按张收费”和“剩余 9 张”,不再显示勾选圆圈,也不能点击选择。当前剩余数量为本地演示数据,不会真实扣减额度;未来接入服务端时由接口返回剩余额度,并以任务创建成功或服务端明确返回的扣减结果为准。
点击“确定”提交当前原图精修、氛围感和封面组合并关闭弹窗;点击“取消”或关闭弹窗时不创建任务、不改变旧修图结果,也不改变额度。
### 状态流转图
![AI 修图弹窗流程](./assets/travel-album-ai/03-ai-edit-sheet-flow.svg)
### 本功能验收
- [ ] 标题与弹窗顶部间距自然,不贴近顶部。
- [ ] 已选照片数量准确。
- [ ] 原图精修预设同时展示文字和效果图,并可横向滚动、保持单选。
- [ ] 原图精修、氛围感和封面模板效果图均可通过小按钮全屏预览,预览不改变选择。
- [ ] 所有大图预览使用同一黑色头部、单一返回入口,并支持双指缩放。
- [ ] 氛围感修图横向展示多个效果图,默认不选中、保持单选,可再次点击当前样式取消。
- [ ] 同时选择原图精修和氛围感时,每张照片生成两个独立结果。
- [ ] Tips 能根据氛围感和封面选择动态说明实际输出结果。
- [ ] 修图模式只展示按张收费与剩余张数,不提供选择操作。
- [ ] 取消弹窗不会创建任务或覆盖旧结果。
## 5. AI 修图任务状态、失败重试与结果覆盖
### 功能说明
用户确认修图后,所选照片立即进入“修图中”。任务未结束前,相关 AI 修图入口置灰;即使用户离开照片预览页,本地演示任务仍会继续完成,并通过共享结果状态刷新相册缩略图。
| 任务状态 | 进入条件 | 页面表现 | 可用操作 | 后续状态 |
| --- | --- | --- | --- | --- |
| 可发起 | 未修图、已有成功结果或上次任务已结束 | AI修图按钮可用 | 选择预设并确认 | 修图中 |
| 修图中 | 已提交预设,等待结果 | 按钮置灰;缩略图显示“修图中” | 查看其他照片、离开页面 | 成功或失败 |
| 成功 | 获取原图并完成本地效果处理 | 分别保存精修、氛围感和封面结果;默认展示“精修后”;按钮恢复 | 在已有结果间切换、再次修图、还原原图 | 新任务或已上传 |
| 失败 | 原图无法获取或处理失败 | 缩略图显示“修图失败”;按钮恢复 | 再次修图 | 修图中 |
成功后,照片预览页至少出现“原图 / 精修后”切换;本次选择了氛围感时增加“氛围感”,存在封面结果时增加“封面”。各结果互相独立,不把两个效果合成同一张图;有修图结果时还可以点击“查看高清图”。再次修图时,旧结果在新任务处理期间继续保留,新任务成功后整组替换为本次最新组合:本次未选择氛围感时会清除旧氛围感结果;新任务失败时保留旧成功图片,但状态优先显示“修图失败”,提醒用户本次任务未成功。
选择“还原为原图”会同时清除修图结果、失败状态和封面类型,缩略图恢复为“已上传”。删除照片时同步清理该照片的本地修图记录,避免已删除照片的结果残留。
### 状态流转图
![AI 修图任务状态流转](./assets/travel-album-ai/04-ai-task-state-flow.svg)
### 本功能验收
- [ ] 修图中不能重复点击 AI修图。
- [ ] 离开预览页后任务仍能结束并刷新缩略图。
- [ ] 成功后可切换查看原图、精修后以及本次实际生成的氛围感或封面。
- [ ] 同选原图精修和氛围感时两个结果均可独立查看。
- [ ] 再次修图成功后以本次结果组合覆盖旧结果。
- [ ] 失败后按钮恢复并允许重试。
- [ ] 还原原图后所有本地修图类型和结果被清除。
## 6. 选中 4 张及以上时的封面模板选择
### 功能说明
从相册管理多选照片并点击“AI修图”时系统根据选中照片数量决定是否展示“封面风格模板”
| 选中数量 | 弹窗表现 |
| --- | --- |
| 13 张 | 展示“原图精修 + 氛围感修图(选填)”两个模块,不展示封面模板 |
| ≥ 4 张 | 展示“原图精修 + 氛围感修图(选填)+ 封面模板”三个模块,并提高弹窗高度 |
封面模板为横向滚动单选,默认选中“旅行纪念册”。每个模板效果图右下角提供“预览”按钮,点击后全屏查看完整排版,不改变当前模板选择。当前提供:
- 旅行纪念册
- 简约留白
- 景区故事
- 电影胶片
每个模板展示名称和以第一张选中照片生成的排版效果图。用户确认后,所有选中照片先生成原图精修结果;选择了氛围感时,所有照片再各自生成一个氛围感结果;第一张选中照片另外生成独立封面并作为封面承载图。封面不会替代精修或氛围感结果,用户进入预览后可分别切换查看。
当前所有封面模板 `quotaCost = 0`,弹窗显示“赠送 · 不占精修张数”,不会减少剩余修图额度。后续收费时保留同一选择流程,由服务端为模板返回大于 0 的 `quotaCost`,客户端在确认前显示实际消耗,并以服务端成功结果为准扣减;收费能力目前尚未实现。
再次对不少于 4 张照片选择新封面模板并修图时,新封面效果覆盖旧封面效果。封面承载图处理失败时不标记为封面成功,缩略图显示“修图失败”并允许重试。选择“还原为原图”时清除封面效果和封面类型。
### 状态流转图
![多图封面模板选择流程](./assets/travel-album-ai/05-cover-template-flow.svg)
### 本功能验收
- [ ] 选中 3 张时不显示封面模板。
- [ ] 选中 4 张或更多时显示封面模板。
- [ ] 有封面模块时,弹窗同时完整显示原图精修、氛围感和封面三个风格模块。
- [ ] 封面模板可横向滚动且始终单选。
- [ ] 每个封面模板均可独立预览,关闭预览后保留原选择。
- [ ] 默认选中旅行纪念册。
- [ ] 当前显示赠送并且不消耗精修额度。
- [ ] 成功后第一张选中照片成为封面承载图。
- [ ] 再次生成封面时以新结果覆盖旧结果。
## 7. 相册缩略图结果类型与显示优先级
### 功能说明
相册管理缩略图左上角复用同一状态位,根据照片当前结果显示不同类型,让用户无需进入预览即可区分未修、处理中、已修、封面和失败照片。
| 缩略图状态 | 角标文案与颜色 | 缩略图内容 | 进入条件 | 可用操作 |
| --- | --- | --- | --- | --- |
| 普通原图 | 绿色“已上传” | 服务端原图或缩略图 | 没有本地修图结果 | 预览、选择、AI修图、删除 |
| 处理中 | 蓝色“修图中” | 有旧成功结果时继续显示旧结果,否则显示原图 | AI 任务未结束 | 预览、删除;不能再次修图 |
| 普通修图结果 | 紫色“AI已修” | 最新精修图片 | 原图精修成功,可同时存在氛围感结果 | 预览原图/精修后/氛围感、再次修图、还原 |
| 封面结果 | 橙色“AI封面” | 所选封面模板排版图 | 封面承载图生成成功 | 预览、再次修图、重新生成封面、还原 |
| 修图失败 | 红色“修图失败” | 有旧成功结果时保留旧图,否则显示原图 | 最近一次任务失败 | 再次修图、删除 |
状态显示优先级为:
`修图中 > 修图失败 > AI封面 > AI已修 > 已上传`
该优先级保证处理中或失败状态不会被历史成功结果遮盖。普通修图成功后显示“AI已修”封面模板成功后第一张选中照片显示封面排版图和“AI封面”其他照片继续显示“AI已修”。再次修图成功后缩略图切换为最新结果还原原图后回到“已上传”删除照片后对应本地状态一起删除。
当前状态和结果由内存数据源在相册管理与照片预览之间共享,尚不支持 App 重启后的恢复。服务端接入时至少需要返回素材 ID、任务状态、结果类型、结果图地址、原图精修预设 ID、是否生成氛围感、封面模板 ID、失败原因和额度消耗客户端才能恢复完全一致的缩略图状态。
### 状态流转图
![相册缩略图结果状态](./assets/travel-album-ai/06-thumbnail-state-flow.svg)
### 本功能验收
- [ ] 未修图照片显示“已上传”。
- [ ] 修图中照片显示“修图中”且不能重复提交。
- [ ] 普通成功结果显示“AI已修”和最新效果图。
- [ ] 封面成功结果显示“AI封面”和封面排版图。
- [ ] 最近一次任务失败时优先显示“修图失败”。
- [ ] 还原、覆盖和删除后缩略图状态同步更新。

View File

@ -36,6 +36,8 @@
93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C69C08A144C2D5D4A448B7D2 /* Pods_suixinkan.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkan.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C69C08A144C2D5D4A448B7D2 /* Pods_suixinkan.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkan.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkanTests.simulatordebug.xcconfig"; path = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.simulatordebug.xcconfig"; sourceTree = "<group>"; };
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan.simulatordebug.xcconfig"; path = "Target Support Files/Pods-suixinkan/Pods-suixinkan.simulatordebug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -111,6 +113,8 @@
1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */, 1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */,
83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */, 83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */,
6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */, 6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */,
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */,
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */,
); );
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
@ -432,7 +436,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010402; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@ -456,7 +460,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.4; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -498,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010402; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@ -522,7 +526,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.4; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -728,6 +732,150 @@
}; };
name = Debug; name = Debug;
}; };
D1A2B3C42FFB84950033C401 /* SimulatorDebug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */;
buildSettings = {
APS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = suixinkan/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版";
INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以拍摄任务素材、扫描订单核销二维码,并连接有线相机进行旅拍相册传输";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要访问麦克风以在拍摄素材视频时录制声音";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码、排队打卡点小程序码和云盘图片视频到相册";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证、银行卡照片和云盘上传素材";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
);
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkan;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphonesimulator;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphonesimulator;
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "suixinkan/suixinkan-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = SimulatorDebug;
};
D1A2B3C52FFB84950033C402 /* SimulatorDebug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphonesimulator;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = SimulatorDebug;
};
D1A2B3C62FFB84950033C403 /* SimulatorDebug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphonesimulator;
SUPPORTED_PLATFORMS = iphonesimulator;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan";
};
name = SimulatorDebug;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -735,6 +883,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
93FF17DB2FFB84950033C304 /* Debug */, 93FF17DB2FFB84950033C304 /* Debug */,
D1A2B3C52FFB84950033C402 /* SimulatorDebug */,
93FF17DC2FFB84950033C304 /* Release */, 93FF17DC2FFB84950033C304 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
@ -744,6 +893,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
93FF17D92FFB84950033C304 /* Debug */, 93FF17D92FFB84950033C304 /* Debug */,
D1A2B3C42FFB84950033C401 /* SimulatorDebug */,
93FF17DA2FFB84950033C304 /* Release */, 93FF17DA2FFB84950033C304 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
@ -753,6 +903,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A1B2C3DC2FFB84950033C305 /* Debug */, A1B2C3DC2FFB84950033C305 /* Debug */,
D1A2B3C62FFB84950033C403 /* SimulatorDebug */,
A1B2C3DA2FFB84950033C305 /* Release */, A1B2C3DA2FFB84950033C305 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "93FF17C42FFB84950033C304"
BuildableName = "suixinkan.app"
BlueprintName = "suixinkan"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "SimulatorDebug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "93FF17C42FFB84950033C304"
BuildableName = "suixinkan.app"
BlueprintName = "suixinkan"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1B2C3D32FFB84950033C305"
BuildableName = "suixinkanTests.xctest"
BlueprintName = "suixinkanTests"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "SimulatorDebug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "93FF17C42FFB84950033C304"
BuildableName = "suixinkan.app"
BlueprintName = "suixinkan"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "SimulatorDebug"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "93FF17C42FFB84950033C304"
BuildableName = "suixinkan.app"
BlueprintName = "suixinkan"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "SimulatorDebug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "travel_album_preview_ai_edit.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M764 824q-40 40 -97 40t-97 -40l-544 -542v-320q0 -25 17.5 -43t42.5 -18h340l528 538q41 40 41 97t-41 97zM338 -29h-179q-26 0 -44.5 18.5t-18.5 45.5v196q12 -3 31 -9t65 -29.5t77 -53.5q29 -27 63.5 -86t38.5 -82h-33zM885 463l-435 -438q-73 119 -124 171q-15 15 -39.5 32t-48 31t-45.5 26t-35 19l-13 6l448 446q24 24 60.5 24t60.5 -25l171 -171q25 -26 25 -61.5t-25 -59.5zM611 315l-43 43l32 31l42 -42zM692 397l-42 42l126 127l43 -43z"/>
</svg>

Before

Width:  |  Height:  |  Size: 541 B

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "travel_album_preview_delete.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M368 440v-279q0 -13 8.5 -22t20.5 -9t20 8q9 10 9 23v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM569 440v-279q0 -13 8.5 -22t20.5 -9t20.5 9t8.5 22v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM283 -109h442q57 0 97 42t40 102v606h95q12 0 20.5 9.5t8.5 22.5t-8.5 22t-20.5 9h-259l-1 25q0 53 -35 101t-81 48h-154q-45 0 -80 -48t-36 -101v-25h-251q-12 0 -20.5 -9t-8.5 -22t8.5 -22.5t20.5 -9.5h87v-606q0 -60 40 -102t96 -42zM369 730q1 27 18.5 55t37.5 30h2h154q21 -2 39 -30t19 -55l1 -26h-272zM205 641h599v-606q0 -34 -23 -57.5t-56 -23.5h-442q-33 0 -55.5 23.5t-22.5 57.5v606v0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 700 B

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "travel_album_preview_refresh.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M505.5 -127q-102.5 0 -196.5 39q-90 39 -159.5 108.5t-108.5 159.5q-39 94 -39 196.5t39 195.5q39 90 108.5 160t159.5 108q94 40 196.5 40t195.5 -40q90 -38 160 -108t108 -160q40 -93 40 -195.5t-40 -196.5q-38 -90 -108 -159.5t-160 -108.5q-93 -39 -195.5 -39zM505 819q-90 0 -172 -35q-80 -33 -141 -94.5t-95 -140.5q-35 -83 -35 -173t35 -172q34 -80 95 -141t141 -95q82 -35 172 -35t173 35q79 34 140.5 95t94.5 141q35 82 35 172t-35 173q-33 79 -94.5 140.5t-140.5 94.5q-83 35 -173 35zM595 472h-240l62 62q9 9 9 21.5t-9 21.5t-21.5 9t-21.5 -9l-113 -114q-9 -9 -9 -21.5t9 -21.5l113 -113q9 -9 21.5 -9t21.5 9t9 21.5t-9 21.5l-62 61h240q41 0 69.5 -28t28.5 -68t-28.5 -68.5t-69.5 -28.5h-221q-12 0 -21 -9t-9 -21.5t9 -21.5t21 -9h221q66 0 112 46.5t46 111.5t-46 111t-112 46z"/>
</svg>

Before

Width:  |  Height:  |  Size: 863 B

View File

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "purchased_alpine_lake.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 KiB

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "purchased_forest_waterfall.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "purchased_grassland_path.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "purchased_lakeside_flowers.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -70,10 +70,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .scenicUser AppStore.shared.session.accountType = .scenicUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.scenicName, user.nickname, user.realName, user.username user.scenicName, user.nickname, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName) AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.session.currentScenicName = user.scenicName AppStore.shared.session.currentScenicName = user.scenicName
@ -90,10 +90,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .storeUser AppStore.shared.session.accountType = .storeUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.storeName, user.scenicName, user.realName, user.userName, user.username user.storeName, user.scenicName, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName) AppStore.shared.session.userName = user.realName
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.avatar = user.avatar AppStore.shared.session.avatar = user.avatar
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId

View File

@ -196,7 +196,6 @@ struct V9ScenicUser: Decodable, Equatable {
let userId: Int let userId: Int
let scenicUserId: Int let scenicUserId: Int
let ssUserId: Int let ssUserId: Int
let username: String
let realName: String let realName: String
let nickname: String let nickname: String
let phone: String let phone: String
@ -212,7 +211,7 @@ struct V9ScenicUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
@ -238,8 +237,6 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id" case userId = "user_id"
case scenicUserId = "scenic_user_id" case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id" case ssUserId = "ss_user_id"
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name" case realName = "real_name"
case nickname case nickname
case phone case phone
@ -258,9 +255,6 @@ struct V9ScenicUser: Decodable, Equatable {
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0 ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
let preferredUsername = try container.decodeLossyString(forKey: .username)
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
username = preferredUsername.nonEmpty ?? legacyUsername
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname) nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
@ -281,8 +275,6 @@ struct V9StoreUser: Decodable, Equatable {
let id: Int let id: Int
let userId: Int let userId: Int
let storeUserId: Int let storeUserId: Int
let username: String
let userName: String
let realName: String let realName: String
let phone: String let phone: String
let avatar: String let avatar: String
@ -300,14 +292,14 @@ struct V9StoreUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount( AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue, accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId, businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
subtitle: joinAccountSubtitle(scenicName, roleName), subtitle: joinAccountSubtitle(scenicName, roleName),
phone: phone, phone: phone,
realName: realName, realName: realName,
@ -325,8 +317,6 @@ struct V9StoreUser: Decodable, Equatable {
case id case id
case userId = "user_id" case userId = "user_id"
case storeUserId = "store_user_id" case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name" case realName = "real_name"
case phone case phone
case avatar case avatar
@ -346,8 +336,6 @@ struct V9StoreUser: Decodable, Equatable {
id = try container.decodeLossyInt(forKey: .id) ?? 0 id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0 storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
username = try container.decodeLossyString(forKey: .username)
userName = try container.decodeLossyString(forKey: .userName)
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar) avatar = try container.decodeLossyString(forKey: .avatar)

View File

@ -0,0 +1,94 @@
//
// CommissionRateLogViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class CommissionRateLogViewModel {
private enum Constants {
static let pageSize = 10
}
private(set) var items: [CommissionRateLogEntity] = []
private(set) var isRefreshing = false
private(set) var isLoadingMore = false
private(set) var initialLoading = true
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let saleUserId: Int
private var total = 0
private var lastLoadedPage = 0
private var isLoading = false
init(saleUserId: Int) {
self.saleUserId = saleUserId
}
///
func refresh(api: OrderAPI) async {
await load(api: api, page: 1, append: false)
}
///
func loadMore(api: OrderAPI) async {
guard canLoadMore, !isRefreshing, !isLoadingMore else { return }
await load(api: api, page: lastLoadedPage + 1, append: true)
}
private func load(api: OrderAPI, page: Int, append: Bool) async {
guard saleUserId > 0 else {
initialLoading = false
onShowMessage?("获客员信息无效")
notifyStateChange()
return
}
guard !isLoading else { return }
isLoading = true
if append {
isLoadingMore = true
} else {
isRefreshing = true
}
notifyStateChange()
defer {
isLoading = false
initialLoading = false
if append {
isLoadingMore = false
} else {
isRefreshing = false
}
notifyStateChange()
}
do {
let response = try await api.saleUserCommissionRateLogs(
saleUserId: saleUserId,
page: page,
pageSize: Constants.pageSize
)
if append {
let existingIDs = Set(items.map(\.id))
items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) })
} else {
var seenIDs = Set<Int>()
items = response.list.filter { seenIDs.insert($0.id).inserted }
}
total = max(0, response.total)
lastLoadedPage = page
canLoadMore = items.count < total && !response.list.isEmpty
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -16,21 +16,24 @@ enum HomeMenuIconFactory {
private static let imageCache = NSCache<NSString, UIImage>() private static let imageCache = NSCache<NSString, UIImage>()
/// Assets SF Symbol /// Assets SF Symbol
static func image(named iconName: String) -> UIImage? { static func image(named iconName: String) -> UIImage? {
let cacheKey = iconName as NSString let cacheKey = iconName as NSString
if let cachedImage = imageCache.object(forKey: cacheKey) { if let cachedImage = imageCache.object(forKey: cacheKey) {
return cachedImage return cachedImage
} }
guard let sourceImage = UIImage(named: iconName) let image: UIImage?
?? UIImage(systemName: iconName, withConfiguration: symbolConfiguration) else { if let assetImage = UIImage(named: iconName) {
return nil image = renderOnFixedCanvas(assetImage)
} else {
image = UIImage(systemName: iconName, withConfiguration: symbolConfiguration)
} }
let normalizedImage = renderOnFixedCanvas(sourceImage) if let image {
imageCache.setObject(normalizedImage, forKey: cacheKey) imageCache.setObject(image, forKey: cacheKey)
return normalizedImage }
return image
} }
private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage { private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage {

View File

@ -20,6 +20,7 @@ enum AMapBootstrap {
defer { lock.unlock() } defer { lock.unlock() }
guard !isConfigured else { return true } guard !isConfigured else { return true }
#if !targetEnvironment(simulator)
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain) AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree) AMapLocationManager.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain) MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
@ -29,6 +30,7 @@ enum AMapBootstrap {
AMapServices.shared().apiKey = AMapConfig.apiKey AMapServices.shared().apiKey = AMapConfig.apiKey
AMapServices.shared().enableHTTPS = true AMapServices.shared().enableHTTPS = true
#endif
isConfigured = true isConfigured = true
return true return true
} }

View File

@ -3,16 +3,28 @@
// suixinkan // suixinkan
// //
import CoreLocation
import Foundation import Foundation
/// Android `GeocodeSearch` ///
@MainActor @MainActor
final class LocationGeocoder: NSObject, AMapSearchDelegate { protocol LocationAddressGeocoding: AnyObject {
///
func reverseGeocode(latitude: Double, longitude: Double) async -> String
}
/// 使使 Core Location
@MainActor
final class LocationGeocoder: NSObject, LocationAddressGeocoding {
nonisolated static let shared = LocationGeocoder() nonisolated static let shared = LocationGeocoder()
#if targetEnvironment(simulator)
private let geocoder = CLGeocoder()
#else
private var searchAPI: AMapSearchAPI? private var searchAPI: AMapSearchAPI?
private var continuation: CheckedContinuation<String, Never>? private var continuation: CheckedContinuation<String, Never>?
#endif
nonisolated private override init() { nonisolated private override init() {
super.init() super.init()
@ -20,6 +32,26 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
/// ///
func reverseGeocode(latitude: Double, longitude: Double) async -> String { func reverseGeocode(latitude: Double, longitude: Double) async -> String {
#if targetEnvironment(simulator)
let location = CLLocation(latitude: latitude, longitude: longitude)
guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else {
return ""
}
if let name = placemark.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
return name
}
return [
placemark.administrativeArea,
placemark.locality,
placemark.subLocality,
placemark.thoroughfare,
placemark.subThoroughfare,
]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined()
#else
do { do {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
} catch { } catch {
@ -40,8 +72,10 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
request.radius = 200 request.radius = 200
api.aMapReGoecodeSearch(request) api.aMapReGoecodeSearch(request)
} }
#endif
} }
#if !targetEnvironment(simulator)
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
let address = response?.regeocode?.formattedAddress ?? "" let address = response?.regeocode?.formattedAddress ?? ""
continuation?.resume(returning: address) continuation?.resume(returning: address)
@ -62,4 +96,9 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
searchAPI = api searchAPI = api
return api return api
} }
#endif
} }
#if !targetEnvironment(simulator)
extension LocationGeocoder: AMapSearchDelegate {}
#endif

View File

@ -6,7 +6,165 @@
import CoreLocation import CoreLocation
import Foundation import Foundation
/// `AMapLocationManager` Android `LocationProvider` /// Core Location 便
@MainActor
protocol CoreLocationManaging: AnyObject {
var authorizationStatus: CLAuthorizationStatus { get }
var desiredAccuracy: CLLocationAccuracy { get set }
var delegate: CLLocationManagerDelegate? { get set }
/// 使
func requestWhenInUseAuthorization()
///
func requestLocation()
}
extension CLLocationManager: CoreLocationManaging {}
/// Core Location
@MainActor
final class CoreLocationProvider: NSObject, LocationProviding, CLLocationManagerDelegate {
nonisolated static let shared = CoreLocationProvider()
private var appStore: AppStore = .shared
private var manager: any CoreLocationManaging = CLLocationManager()
private var geocoder: any LocationAddressGeocoding = LocationGeocoder.shared
private var permissionContinuation: CheckedContinuation<Void, Error>?
private var locationContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?
nonisolated override init() {
super.init()
}
/// 使 Core Location
init(
appStore: AppStore,
manager: any CoreLocationManaging,
geocoder: any LocationAddressGeocoding
) {
self.appStore = appStore
self.manager = manager
self.geocoder = geocoder
super.init()
manager.delegate = self
}
///
func requestSnapshot(
desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
) async throws -> HomeLocationSnapshot {
let coordinate = try await requestCoordinate(desiredAccuracy: desiredAccuracy)
let address = await geocoder.reverseGeocode(
latitude: coordinate.latitude,
longitude: coordinate.longitude
)
return HomeLocationSnapshot(
latitude: coordinate.latitude,
longitude: coordinate.longitude,
address: address
)
}
///
func requestCoordinate(
desiredAccuracy: CLLocationAccuracy
) async throws -> CLLocationCoordinate2D {
try requirePrivacyAgreement()
try await ensureLocationPermission()
guard locationContinuation == nil else {
throw LocationProviderError.locationFailed
}
manager.delegate = self
manager.desiredAccuracy = desiredAccuracy
return try await withCheckedThrowingContinuation { continuation in
locationContinuation = continuation
manager.requestLocation()
}
}
/// 使
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
guard appStore.session.privacyAgreementAccepted else { return "" }
return await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
}
///
func ensureLocationPermission() async throws {
manager.delegate = self
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
return
case .denied, .restricted:
throw LocationProviderError.permissionDenied
case .notDetermined:
guard permissionContinuation == nil else {
throw LocationProviderError.locationFailed
}
try await withCheckedThrowingContinuation { continuation in
permissionContinuation = continuation
manager.requestWhenInUseAuthorization()
}
@unknown default:
throw LocationProviderError.permissionDenied
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
resolvePermission(status: self.manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let continuation = locationContinuation else { return }
locationContinuation = nil
guard let coordinate = locations.last?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else {
continuation.resume(throwing: LocationProviderError.locationFailed)
return
}
continuation.resume(returning: coordinate)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
guard let continuation = locationContinuation else { return }
locationContinuation = nil
continuation.resume(throwing: LocationProviderError.locationFailed)
}
private func requirePrivacyAgreement() throws {
guard appStore.session.privacyAgreementAccepted else {
throw LocationProviderError.privacyNotAccepted
}
}
private func resolvePermission(status: CLAuthorizationStatus) {
guard let continuation = permissionContinuation else { return }
switch status {
case .authorizedAlways, .authorizedWhenInUse:
permissionContinuation = nil
continuation.resume()
case .denied, .restricted:
permissionContinuation = nil
continuation.resume(throwing: LocationProviderError.permissionDenied)
case .notDetermined:
break
@unknown default:
permissionContinuation = nil
continuation.resume(throwing: LocationProviderError.permissionDenied)
}
}
}
#if targetEnvironment(simulator)
/// 使 Core Location
typealias LocationProvider = CoreLocationProvider
#else
/// `AMapLocationManager` Android `LocationProvider`
@MainActor @MainActor
final class LocationProvider: NSObject, LocationProviding { final class LocationProvider: NSObject, LocationProviding {
@ -19,7 +177,7 @@ final class LocationProvider: NSObject, LocationProviding {
super.init() super.init()
} }
@MainActor ///
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot { func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@ -51,7 +209,7 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor ///
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@ -78,7 +236,7 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor /// 使
func reverseGeocode(latitude: Double, longitude: Double) async -> String { func reverseGeocode(latitude: Double, longitude: Double) async -> String {
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude) await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
} }
@ -110,3 +268,5 @@ final class LocationProvider: NSObject, LocationProviding {
return manager return manager
} }
} }
#endif

View File

@ -16,20 +16,25 @@ struct HomeLocationSnapshot: Sendable, Equatable {
/// 便 ViewModel mock /// 便 ViewModel mock
protocol LocationProviding: Sendable { protocol LocationProviding: Sendable {
/// ///
@MainActor
func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot
/// ///
@MainActor
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
/// ///
@MainActor
func reverseGeocode(latitude: Double, longitude: Double) async -> String func reverseGeocode(latitude: Double, longitude: Double) async -> String
} }
extension LocationProviding { extension LocationProviding {
/// 使 /// 使
@MainActor
func requestSnapshot() async throws -> HomeLocationSnapshot { func requestSnapshot() async throws -> HomeLocationSnapshot {
try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest) try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest)
} }
/// 使 /// 使
@MainActor
func requestCoordinate() async throws -> CLLocationCoordinate2D { func requestCoordinate() async throws -> CLLocationCoordinate2D {
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest) try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
} }

View File

@ -293,6 +293,28 @@ final class OrderAPI {
) )
} }
///
func saleUserCommissionRateLogs(
saleUserId: Int,
page: Int = 1,
pageSize: Int = 10
) async throws -> CommissionRateLogListResponse {
guard saleUserId > 0 else {
throw APIError.networkFailed("获客员信息无效")
}
return try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/commission-rate/logs",
queryItems: [
URLQueryItem(name: "sale_user_id", value: String(saleUserId)),
URLQueryItem(name: "page", value: String(max(1, page))),
URLQueryItem(name: "page_size", value: String(min(max(1, pageSize), 50))),
]
)
)
}
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
try await client.send( try await client.send(
APIRequest( APIRequest(

View File

@ -875,6 +875,93 @@ struct CooperativeSalerEntity: Decodable, Equatable {
} }
} }
///
struct CommissionRateLogListResponse: Decodable, Equatable {
let list: [CommissionRateLogEntity]
let total: Int
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case list
case total
case page
case pageSize = "page_size"
}
}
///
struct CommissionRateLogEntity: Decodable, Equatable, Hashable {
let id: Int
let bindingId: Int
let saleUserId: Int
let storeUserId: Int
let beforeRate: Int
let beforeRateLabel: String
let afterRate: Int
let afterRateLabel: String
let operatorType: String
let operatorId: Int
let operatorName: String
let remark: String
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case bindingId = "binding_id"
case saleUserId = "sale_user_id"
case storeUserId = "store_user_id"
case beforeRate = "before_rate"
case beforeRateLabel = "before_rate_label"
case afterRate = "after_rate"
case afterRateLabel = "after_rate_label"
case operatorType = "operator_type"
case operatorId = "operator_id"
case operatorName = "operator_name"
case remark
case createdAt = "created_at"
}
/// 使 label
var displayBeforeRate: String {
let label = beforeRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
return label.isEmpty ? "\(beforeRate)%" : label
}
/// 使 label
var displayAfterRate: String {
let label = afterRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
return label.isEmpty ? "\(afterRate)%" : label
}
///
var metadataLine: String {
formattedCreatedAt
}
///
var displayRemark: String? {
let value = remark.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
private var formattedCreatedAt: String {
let value = createdAt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return "" }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] {
formatter.dateFormat = format
if let date = formatter.date(from: value) {
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: date)
}
}
return value
}
}
struct ReferralOrderListResponse: Decodable { struct ReferralOrderListResponse: Decodable {
let total: Int let total: Int
let list: [ReferralOrderEntity] let list: [ReferralOrderEntity]

View File

@ -3,6 +3,7 @@
// suixinkan // suixinkan
// //
import CoreLocation
import UIKit import UIKit
/// Tab Android `MainTabViewModel.signIn` /// Tab Android `MainTabViewModel.signIn`

View File

@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
/// ///
enum PaymentAccountDisplayFormatter { enum PaymentAccountDisplayFormatter {
/// 使 ///
static func photographerName(userName: String, realName: String) -> String { static func photographerName(realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-" realName.trimmedNonEmpty ?? "-"
} }
/// ///

View File

@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = "" private var staticPayURL = ""
private var dynamicPayURL = "" private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching private let payPageConfigCache: PayPageConfigCaching
@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
qrImage != nil && !staticPayURL.isEmpty qrImage != nil && !staticPayURL.isEmpty
} }
///
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String { var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName scenicName.isEmpty ? "暂无景区" : scenicName
} }
@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
var displayPhotographerName: String { var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName( PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName realName: appStore.session.realName
) )
} }
@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
func loadPayCode(api: PaymentAPI) async { func loadPayCode(api: PaymentAPI) async {
guard !loading else { return } guard !loading else { return }
refreshLocalState()
loading = true loading = true
notifyStateChange() refreshLocalState()
defer { defer {
loading = false loading = false
hasCompletedPayCodeRequest = true
notifyStateChange() notifyStateChange()
} }

View File

@ -17,11 +17,11 @@ final class ProfileViewModel {
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var displayNickname: String { var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称" nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
} }
var displayRealName: String { var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--" nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
} }
var displayPhone: String { var displayPhone: String {
@ -37,6 +37,17 @@ final class ProfileViewModel {
return uid.isEmpty ? "--" : uid return uid.isEmpty ? "--" : uid
} }
///
var hasCachedBasicInfo: Bool {
[
AppStore.shared.session.userName,
AppStore.shared.session.realName,
AppStore.shared.session.phone,
AppStore.shared.session.avatar,
AppStore.shared.session.userId,
].contains { nonEmpty($0) != nil }
}
var accountDisplayName: String { var accountDisplayName: String {
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
@ -112,6 +123,7 @@ final class ProfileViewModel {
if !info.roleName.isEmpty { if !info.roleName.isEmpty {
AppStore.shared.session.roleName = info.roleName AppStore.shared.session.roleName = info.roleName
} }
notifyStateChange()
if showPhotographerFields { if showPhotographerFields {
async let realName = api.realNameInfo() async let realName = api.realNameInfo()

View File

@ -116,8 +116,6 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
/// Android `TravelAlbumMaterialEntity` /// Android `TravelAlbumMaterialEntity`
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable { struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
private static let bundledImageURLPrefix = "asset://"
let id: Int let id: Int
let userEquityTravelId: Int let userEquityTravelId: Int
let status: Int let status: Int
@ -177,18 +175,6 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
/// 使
static func bundledImageURL(assetName: String) -> String {
bundledImageURLPrefix + assetName
}
/// `nil`
var bundledImageAssetName: String? {
guard fileUrl.hasPrefix(Self.bundledImageURLPrefix) else { return nil }
let assetName = String(fileUrl.dropFirst(Self.bundledImageURLPrefix.count))
return assetName.isEmpty ? nil : assetName
}
} }
/// Android `TravelAlbumCreateRequest` /// Android `TravelAlbumCreateRequest`

View File

@ -1,316 +0,0 @@
//
// TravelAlbumPhotoPreviewModels.swift
// suixinkan
//
import Foundation
/// AI
struct TravelAlbumAIEditResultState: Equatable, Sendable {
/// AI
enum ThumbnailStatus: Equatable, Sendable {
case none
case processing
case edited
case cover
case failed
}
///
enum DisplayMode: Equatable, Sendable {
case original
case edited
case atmosphere
case cover
}
private(set) var isProcessing = false
private(set) var didFailLastAttempt = false
private(set) var appliedPresetID: String?
private(set) var appliedAtmosphereOptionID: String?
private(set) var appliedCoverTemplateID: String?
private(set) var displayMode: DisplayMode = .original
///
var hasEditedResult: Bool {
appliedPresetID != nil
}
/// AI
var canStartEditing: Bool {
!isProcessing
}
///
var isCover: Bool {
appliedCoverTemplateID != nil
}
///
var hasAtmosphereResult: Bool {
appliedAtmosphereOptionID != nil
}
///
var thumbnailStatus: ThumbnailStatus {
if isProcessing {
return .processing
}
if didFailLastAttempt {
return .failed
}
if isCover {
return .cover
}
return hasEditedResult ? .edited : .none
}
/// 退
mutating func startProcessing() {
guard !isProcessing else { return }
isProcessing = true
didFailLastAttempt = false
}
///
mutating func complete(
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil
) {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = presetID
appliedAtmosphereOptionID = atmosphereOptionID
appliedCoverTemplateID = coverTemplateID
displayMode = coverTemplateID == nil ? .edited : .cover
}
///
mutating func failProcessing() {
isProcessing = false
didFailLastAttempt = true
}
///
mutating func restoreOriginal() {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = nil
appliedAtmosphereOptionID = nil
appliedCoverTemplateID = nil
displayMode = .original
}
///
mutating func selectDisplayMode(_ mode: DisplayMode) {
guard hasEditedResult else {
displayMode = .original
return
}
if mode == .atmosphere, !hasAtmosphereResult {
displayMode = .edited
return
}
if mode == .cover, !isCover {
displayMode = .edited
return
}
displayMode = mode
}
}
///
struct TravelAlbumSelectionToolbarState: Equatable, Sendable {
let isSelectionMode: Bool
let selectedCount: Int
///
var showsUpload: Bool {
!isSelectionMode
}
/// AI
var showsSelectionActions: Bool {
isSelectionMode
}
///
var selectionActionsEnabled: Bool {
isSelectionMode && selectedCount > 0
}
}
///
struct TravelAlbumPhotoPreviewState: Equatable, Sendable {
private(set) var materials: [TravelAlbumMaterial]
private(set) var currentIndex: Int
init(materials: [TravelAlbumMaterial], initialIndex: Int) {
self.materials = materials
currentIndex = min(max(initialIndex, 0), max(materials.count - 1, 0))
}
///
var currentMaterial: TravelAlbumMaterial? {
materials.indices.contains(currentIndex) ? materials[currentIndex] : nil
}
///
@discardableResult
mutating func moveNext() -> Bool {
guard currentIndex + 1 < materials.count else { return false }
currentIndex += 1
return true
}
///
@discardableResult
mutating func movePrevious() -> Bool {
guard currentIndex > 0 else { return false }
currentIndex -= 1
return true
}
///
@discardableResult
mutating func removeCurrent() -> Bool {
guard materials.indices.contains(currentIndex) else { return false }
materials.remove(at: currentIndex)
if currentIndex >= materials.count {
currentIndex = max(materials.count - 1, 0)
}
return !materials.isEmpty
}
}
/// AI
struct TravelAlbumEditPreset: Hashable, Sendable, Identifiable {
///
enum Effect: String, Hashable, Sendable {
case original
case portrait
case vintage
case brocade
case distantMountain
case mist
case summer
case rich
}
let id: String
let title: String
let effect: Effect
/// AI
static let defaultOptions: [TravelAlbumEditPreset] = [
TravelAlbumEditPreset(id: "original", title: "还原为原图", effect: .original),
TravelAlbumEditPreset(id: "portrait", title: "写真-简约肖像", effect: .portrait),
TravelAlbumEditPreset(id: "vintage", title: "写真-清冷古风", effect: .vintage),
TravelAlbumEditPreset(id: "brocade", title: "旅拍-锦绣", effect: .brocade),
TravelAlbumEditPreset(id: "distant_mountain", title: "旅拍-远山", effect: .distantMountain),
TravelAlbumEditPreset(id: "mist", title: "旅拍-薄雾", effect: .mist),
TravelAlbumEditPreset(id: "summer", title: "油画-夏日", effect: .summer),
TravelAlbumEditPreset(id: "rich", title: "油画-浓郁", effect: .rich),
]
}
///
struct TravelAlbumAtmosphereEditOption: Hashable, Sendable, Identifiable {
let id: String
let title: String
let subtitle: String
let effect: TravelAlbumEditPreset.Effect
///
static let defaultOptions: [TravelAlbumAtmosphereEditOption] = [
TravelAlbumAtmosphereEditOption(
id: "warm_sun",
title: "日落暖阳",
subtitle: "温暖柔和",
effect: .summer
),
TravelAlbumAtmosphereEditOption(
id: "retro_film",
title: "复古胶片",
subtitle: "怀旧颗粒感",
effect: .vintage
),
TravelAlbumAtmosphereEditOption(
id: "clear_blue",
title: "清透蓝调",
subtitle: "通透轻盈",
effect: .mist
),
TravelAlbumAtmosphereEditOption(
id: "forest_mist",
title: "森系薄雾",
subtitle: "低饱和自然感",
effect: .distantMountain
),
TravelAlbumAtmosphereEditOption(
id: "vivid_story",
title: "浓郁故事",
subtitle: "高饱和电影感",
effect: .rich
),
]
///
static let defaultOption = defaultOptions[0]
}
/// AI 使
struct TravelAlbumCoverTemplate: Hashable, Sendable, Identifiable {
///
enum PreviewStyle: String, Hashable, Sendable {
case travelMemoir
case minimal
case scenicStory
case film
}
///
static let minimumPhotoCount = 4
let id: String
let title: String
let previewStyle: PreviewStyle
/// 0
let quotaCost: Int
///
var isFreeGift: Bool {
quotaCost == 0
}
///
static func shouldShow(for selectedPhotoCount: Int) -> Bool {
selectedPhotoCount >= minimumPhotoCount
}
///
static let defaultOptions: [TravelAlbumCoverTemplate] = [
TravelAlbumCoverTemplate(id: "travel_memoir", title: "旅行纪念册", previewStyle: .travelMemoir, quotaCost: 0),
TravelAlbumCoverTemplate(id: "minimal", title: "简约留白", previewStyle: .minimal, quotaCost: 0),
TravelAlbumCoverTemplate(id: "scenic_story", title: "景区故事", previewStyle: .scenicStory, quotaCost: 0),
TravelAlbumCoverTemplate(id: "film", title: "电影胶片", previewStyle: .film, quotaCost: 0),
]
}
/// AI
struct TravelAlbumAIEditSelection: Equatable, Sendable {
let refinedPreset: TravelAlbumEditPreset
let atmosphereOption: TravelAlbumAtmosphereEditOption?
let coverTemplate: TravelAlbumCoverTemplate?
///
var resultCountPerPhoto: Int {
atmosphereOption == nil ? 1 : 2
}
///
var selectedStyleCount: Int {
1 + (atmosphereOption == nil ? 0 : 1) + (coverTemplate == nil ? 0 : 1)
}
}

View File

@ -52,12 +52,6 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
let photos: [TravelAlbumOTGPhotoItem] let photos: [TravelAlbumOTGPhotoItem]
} }
/// 线
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
let initialIndex: Int
}
/// OTG Tab /// OTG Tab
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all case all
@ -86,16 +80,6 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
} }
} }
///
var detailText: String {
switch self {
case .liveUpload:
return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer:
return "拍摄完成后,再选择照片批量传输"
}
}
/// App /// App
var shouldAutoUploadNewImports: Bool { var shouldAutoUploadNewImports: Bool {
self == .liveUpload self == .liveUpload
@ -129,18 +113,6 @@ extension TravelAlbumOTGPhotoItem {
} }
extension Array where Element == TravelAlbumOTGPhotoItem { extension Array where Element == TravelAlbumOTGPhotoItem {
///
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
let previewablePhotos = filter { $0.thumbnailURL != nil }
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
return nil
}
return TravelAlbumOTGPhotoPreviewContext(
photos: previewablePhotos,
initialIndex: initialIndex
)
}
/// Android OTG /// Android OTG
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] { func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in

View File

@ -1,308 +0,0 @@
//
// TravelAlbumAIEditResultStore.swift
// suixinkan
//
import CoreImage
import UIKit
/// AI
@MainActor
final class TravelAlbumAIEditResultStore {
///
struct Record {
var state: TravelAlbumAIEditResultState
var editedImage: UIImage?
var atmosphereImage: UIImage?
var coverImage: UIImage?
}
static let shared = TravelAlbumAIEditResultStore()
static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange")
static let materialIDUserInfoKey = "materialID"
private var recordsByMaterialID: [Int: Record] = [:]
private init() {}
///
func state(for materialID: Int) -> TravelAlbumAIEditResultState {
recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState()
}
///
func editedImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.editedImage
}
///
func atmosphereImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.atmosphereImage
}
///
func coverImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.coverImage
}
///
func startProcessing(materialIDs: [Int]) {
materialIDs.forEach { materialID in
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.startProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
}
///
func complete(
materialID: Int,
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil,
editedImage: UIImage,
atmosphereImage: UIImage? = nil,
coverImage: UIImage? = nil
) {
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.complete(
presetID: presetID,
atmosphereOptionID: atmosphereOptionID,
coverTemplateID: coverTemplateID
)
record.editedImage = editedImage
record.atmosphereImage = atmosphereImage
record.coverImage = coverImage
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
///
func failProcessing(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.failProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
///
func restoreOriginal(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.restoreOriginal()
record.editedImage = nil
record.atmosphereImage = nil
record.coverImage = nil
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
///
func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.selectDisplayMode(mode)
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
///
func remove(materialID: Int) {
recordsByMaterialID.removeValue(forKey: materialID)
notifyChange(materialID: materialID)
}
private func notifyChange(materialID: Int) {
NotificationCenter.default.post(
name: Self.didChangeNotification,
object: self,
userInfo: [Self.materialIDUserInfoKey: materialID]
)
}
}
/// AI
@MainActor
enum TravelAlbumAIEditImageProcessor {
private static let context = CIContext()
///
static func render(
effect: TravelAlbumEditPreset.Effect,
source: UIImage
) -> UIImage {
guard effect != .original, let ciImage = CIImage(image: source) else { return source }
let output: CIImage
switch effect {
case .original:
output = ciImage
case .portrait:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.86,
kCIInputBrightnessKey: 0.07,
kCIInputContrastKey: 1.08,
])
case .vintage:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45])
case .brocade:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.22,
kCIInputBrightnessKey: 0.04,
kCIInputContrastKey: 1.1,
])
case .distantMountain:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.72,
kCIInputBrightnessKey: 0.1,
kCIInputContrastKey: 0.9,
])
case .mist:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.7,
kCIInputBrightnessKey: 0.14,
kCIInputContrastKey: 0.82,
])
case .summer:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24])
case .rich:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.38,
kCIInputBrightnessKey: -0.02,
kCIInputContrastKey: 1.2,
])
}
guard let cgImage = context.createCGImage(output, from: output.extent) else { return source }
return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation)
}
}
///
@MainActor
enum TravelAlbumCoverTemplateImageProcessor {
/// 使
static func render(
template: TravelAlbumCoverTemplate,
source: UIImage?,
targetSize: CGSize = CGSize(width: 1200, height: 900)
) -> UIImage {
UIGraphicsImageRenderer(size: targetSize).image { context in
let bounds = CGRect(origin: .zero, size: targetSize)
UIColor(hex: 0xCAD8E8).setFill()
context.cgContext.fill(bounds)
if let source {
source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds))
}
let unit = min(targetSize.width / 232, targetSize.height / 208)
switch template.previewStyle {
case .travelMemoir:
UIColor.black.withAlphaComponent(0.24).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let oval = UIBezierPath(ovalIn: CGRect(
x: bounds.midX - 50 * unit,
y: 28 * unit,
width: 100 * unit,
height: 100 * unit
))
oval.lineWidth = 9 * unit
oval.stroke()
drawCaption(
"TRAVEL\nMEMOIR",
in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .minimal:
UIColor.white.withAlphaComponent(0.9).setFill()
context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height))
drawCaption(
"LESS\nIS MORE",
in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit),
alignment: .left,
color: .black,
fontSize: 17 * unit
)
case .scenicStory:
UIColor.black.withAlphaComponent(0.2).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let inset = 20 * unit
let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset))
frame.lineWidth = 4 * unit
frame.stroke()
drawCaption(
"SCENIC STORY",
in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .film:
let stripHeight = 24 * unit
UIColor.black.withAlphaComponent(0.72).setFill()
context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight))
context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight))
UIColor.white.withAlphaComponent(0.9).setFill()
var x = 10 * unit
while x <= bounds.width - 16 * unit {
context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit))
context.cgContext.fill(CGRect(
x: x,
y: bounds.height - 16 * unit,
width: 14 * unit,
height: 10 * unit
))
x += 30 * unit
}
drawCaption(
"THE JOURNEY",
in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
}
}
}
private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
guard imageSize.width > 0, imageSize.height > 0 else { return bounds }
let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: bounds.midX - drawSize.width / 2,
y: bounds.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
}
private static func drawCaption(
_ text: String,
in rect: CGRect,
alignment: NSTextAlignment,
color: UIColor = .white,
fontSize: CGFloat
) {
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
text.draw(
in: rect,
withAttributes: [
.font: UIFont.systemFont(ofSize: fontSize, weight: .bold),
.foregroundColor: color,
.paragraphStyle: paragraph,
]
)
}
}

View File

@ -36,7 +36,6 @@ final class TravelAlbumDetailViewModel {
private(set) var sortOption: SortOption = .createdDesc private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true private(set) var isLoading = true
private(set) var isRefreshing = false private(set) var isRefreshing = false
private(set) var isUsingPurchasedDemoData = false
private(set) var isSelectionMode = false private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set<Int> = [] private(set) var selectedMaterialIds: Set<Int> = []
@ -127,7 +126,6 @@ final class TravelAlbumDetailViewModel {
if reset { if reset {
currentPage = 1 currentPage = 1
canLoadMore = false canLoadMore = false
isUsingPurchasedDemoData = false
} else { } else {
guard canLoadMore, !isLoadingMore else { return } guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true isLoadingMore = true
@ -142,15 +140,8 @@ final class TravelAlbumDetailViewModel {
orderBy: sortOption.rawValue, orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil isPurchased: selectedTab == .purchased ? 1 : nil
) )
if reset, materials = reset ? response.list : materials + response.list
selectedTab == .purchased, canLoadMore = materials.count < response.total
response.total == 0,
response.list.isEmpty {
applyPurchasedDemoMaterials()
} else {
materials = reset ? response.list : materials + response.list
canLoadMore = materials.count < response.total
}
if selectedTab == .all { if selectedTab == .all {
allPhotoCount = response.total allPhotoCount = response.total
} }
@ -163,12 +154,6 @@ final class TravelAlbumDetailViewModel {
currentPage -= 1 currentPage -= 1
} }
isLoadingMore = false isLoadingMore = false
if reset, selectedTab == .purchased {
//
applyPurchasedDemoMaterials()
notifyStateChange()
return
}
onShowMessage?(error.localizedDescription) onShowMessage?(error.localizedDescription)
} }
} }
@ -240,44 +225,6 @@ final class TravelAlbumDetailViewModel {
} }
} }
/// 使
private func applyPurchasedDemoMaterials() {
let demoItems: [(fileName: String, fileSize: Int, assetName: String, createdAt: String)] = [
("IMG_0724_0001.jpeg", 2_684_928, "purchased_alpine_lake", "2026-07-24 11:25:18"),
("IMG_0724_0002.jpeg", 3_215_360, "purchased_forest_waterfall", "2026-07-24 11:27:46"),
("IMG_0724_0003.jpeg", 2_936_832, "purchased_grassland_path", "2026-07-24 11:31:09"),
("IMG_0724_0004.jpeg", 3_481_600, "purchased_lakeside_flowers", "2026-07-24 11:34:22"),
]
var materials = demoItems.enumerated().map { index, item in
TravelAlbumMaterial(
id: -((albumId * 10) + index + 1),
userEquityTravelId: albumId,
status: 2,
fileName: item.fileName,
fileType: 1,
fileUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
fileSize: item.fileSize,
coverUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
isPurchased: true,
createdAt: item.createdAt,
updatedAt: item.createdAt
)
}
switch sortOption {
case .createdAsc:
materials.sort { $0.createdAt < $1.createdAt }
case .createdDesc:
materials.sort { $0.createdAt > $1.createdAt }
case .fileNameAsc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending }
case .fileNameDesc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedDescending }
}
self.materials = materials
canLoadMore = false
isUsingPurchasedDemoData = true
}
private func notifyStateChange() { private func notifyStateChange() {
onStateChange?() onStateChange?()
} }

View File

@ -94,7 +94,6 @@ final class WiredCameraTransferViewModel {
albumTitle: String, albumTitle: String,
headerPhone: String, headerPhone: String,
scenicSpotLabel: String? = nil, scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil, uploader: (any TravelAlbumOTGUploading)? = nil,
@ -113,11 +112,7 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore self.appStore = appStore
self.userDefaults = userDefaults self.userDefaults = userDefaults
// 沿 self.transferMode = Self.persistedTransferMode(in: userDefaults)
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
}
} }
/// ///

View File

@ -8,6 +8,7 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate { class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
func scene( func scene(
_ scene: UIScene, _ scene: UIScene,
@ -86,18 +87,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
@objc private func handleSessionDidExpire() { @objc private func handleSessionDidExpire() {
guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return }
GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin()
}
sessionExpiredDialog = dialog
guard let presenter = topViewController(from: window?.rootViewController) else {
sessionExpiredDialog = nil
return
}
presenter.present(dialog, animated: true)
}
private func transitionToLogin() {
sessionExpiredDialog = nil
PushNotificationManager.shared.handleLogout() PushNotificationManager.shared.handleLogout()
AppStore.shared.logout() AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogout() { @objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout() transitionToLogin()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogin() { @objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window) AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async { DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted() PushNotificationManager.shared.handleLoginCompleted()
@ -108,4 +127,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleAccountDidSwitch() { @objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched() PushNotificationManager.shared.handleAccountSwitched()
} }
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
guard let viewController else { return nil }
if let presented = viewController.presentedViewController {
return topViewController(from: presented)
}
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabBarController = viewController as? UITabBarController {
return topViewController(from: tabBarController.selectedViewController)
}
return viewController
}
} }

View File

@ -0,0 +1,103 @@
//
// SessionExpiredDialogViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 退
@MainActor
final class SessionExpiredDialogViewController: UIViewController {
private let onConfirm: () -> Void
private let confirmButton = AppButton(title: "确认并退出登录")
private var didConfirm = false
///
init(onConfirm: @escaping () -> Void) {
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
isModalInPresentation = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
view.accessibilityViewIsModal = true
view.accessibilityIdentifier = "sessionExpired.dialog"
let cardView = UIView()
cardView.backgroundColor = AppColor.cardBackground
cardView.layer.cornerRadius = AppRadius.lg
cardView.clipsToBounds = true
let warningImageView = UIImageView(
image: UIImage(systemName: "exclamationmark.circle.fill")
)
warningImageView.tintColor = UIColor(hex: 0xF05A5A)
warningImageView.contentMode = .scaleAspectFit
warningImageView.isAccessibilityElement = true
warningImageView.accessibilityLabel = "登录状态异常"
let titleLabel = UILabel()
titleLabel.text = "登录提醒"
titleLabel.textColor = AppColor.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = "当前用户已在其他设备登录,\n请重新登录"
messageLabel.textColor = UIColor(hex: 0x4B5563)
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
confirmButton.accessibilityIdentifier = "sessionExpired.confirmButton"
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let contentStack = UIStackView(arrangedSubviews: [
warningImageView,
titleLabel,
messageLabel,
confirmButton,
])
contentStack.axis = .vertical
contentStack.alignment = .fill
contentStack.spacing = 16
contentStack.setCustomSpacing(4, after: titleLabel)
contentStack.setCustomSpacing(20, after: messageLabel)
view.addSubview(cardView)
cardView.addSubview(contentStack)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(24)
}
warningImageView.snp.makeConstraints { make in
make.height.equalTo(64)
}
}
@objc private func confirmTapped() {
guard !didConfirm else { return }
didConfirm = true
confirmButton.isEnabled = false
onConfirm()
}
}

View File

@ -0,0 +1,284 @@
//
// CommissionRateLogViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class CommissionRateLogViewController: BaseViewController {
private let viewModel: CommissionRateLogViewModel
private let orderAPI: OrderAPI
private let navigationTitle: String
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let footerLabel = UILabel()
private var dataSource: UITableViewDiffableDataSource<Int, CommissionRateLogEntity>!
init(saleUserId: Int, acquirerName: String, orderAPI: OrderAPI? = nil) {
viewModel = CommissionRateLogViewModel(saleUserId: saleUserId)
let trimmedName = acquirerName.trimmingCharacters(in: .whitespacesAndNewlines)
navigationTitle = trimmedName.isEmpty ? "修改记录" : "\(trimmedName)的分成记录"
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: orderAPI) }
}
override func setupNavigationBar() {
title = navigationTitle
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 132
tableView.delegate = self
tableView.contentInset.top = AppSpacing.sm
tableView.register(
CommissionRateLogTimelineCell.self,
forCellReuseIdentifier: CommissionRateLogTimelineCell.reuseIdentifier
)
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
dataSource = UITableViewDiffableDataSource<Int, CommissionRateLogEntity>(
tableView: tableView
) { tableView, indexPath, item in
guard let cell = tableView.dequeueReusableCell(
withIdentifier: CommissionRateLogTimelineCell.reuseIdentifier,
for: indexPath
) as? CommissionRateLogTimelineCell else {
return UITableViewCell()
}
cell.configure(with: item, isLatest: indexPath.item == 0)
return cell
}
emptyLabel.text = "暂无修改记录"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
loadingIndicator.color = AppColor.primary
footerLabel.font = .systemFont(ofSize: 13)
footerLabel.textColor = AppColor.textTertiary
footerLabel.textAlignment = .center
view.addSubview(tableView)
view.addSubview(emptyLabel)
view.addSubview(loadingIndicator)
}
override func setupConstraints() {
tableView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
emptyLabel.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
private func applyState() {
if !viewModel.isRefreshing {
tableView.refreshControl?.endRefreshing()
}
if viewModel.initialLoading && viewModel.items.isEmpty {
loadingIndicator.startAnimating()
} else {
loadingIndicator.stopAnimating()
}
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
var snapshot = NSDiffableDataSourceSnapshot<Int, CommissionRateLogEntity>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: true)
guard !viewModel.items.isEmpty else {
tableView.tableFooterView = UIView()
return
}
footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
tableView.tableFooterView = footerLabel
}
@objc private func refreshPulled() {
Task { await viewModel.refresh(api: orderAPI) }
}
}
extension CommissionRateLogViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !viewModel.items.isEmpty,
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else {
return
}
Task { await viewModel.loadMore(api: orderAPI) }
}
}
/// Cell
final class CommissionRateLogTimelineCell: UITableViewCell {
static let reuseIdentifier = "CommissionRateLogTimelineCell"
private let railView = UIView()
private let dotView = UIView()
private let cardView = UIView()
private let metadataLabel = UILabel()
private let latestBadgeLabel = UILabel()
private let beforeRateLabel = UILabel()
private let arrowImageView = UIImageView(image: UIImage(systemName: "arrow.right"))
private let afterRateLabel = UILabel()
private let remarkLabel = UILabel()
private let contentStack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
railView.backgroundColor = AppColor.primary.withAlphaComponent(0.16)
dotView.backgroundColor = AppColor.primary
dotView.layer.cornerRadius = 4
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.md
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.04
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
cardView.layer.shadowRadius = 2
metadataLabel.font = .systemFont(ofSize: 12)
metadataLabel.textColor = AppColor.textSecondary
metadataLabel.numberOfLines = 2
latestBadgeLabel.text = "最新"
latestBadgeLabel.font = .systemFont(ofSize: 11, weight: .semibold)
latestBadgeLabel.textColor = AppColor.primary
latestBadgeLabel.textAlignment = .center
latestBadgeLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
latestBadgeLabel.layer.cornerRadius = 9
latestBadgeLabel.clipsToBounds = true
latestBadgeLabel.isHidden = true
latestBadgeLabel.setContentHuggingPriority(.required, for: .horizontal)
latestBadgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
latestBadgeLabel.snp.makeConstraints { make in
make.width.equalTo(38)
make.height.equalTo(18)
}
let headerStack = UIStackView(arrangedSubviews: [metadataLabel, latestBadgeLabel])
headerStack.axis = .horizontal
headerStack.alignment = .top
headerStack.spacing = AppSpacing.sm
[beforeRateLabel, afterRateLabel].forEach { label in
label.font = .systemFont(ofSize: 17, weight: .semibold)
label.textColor = AppColor.textPrimary
label.setContentCompressionResistancePriority(.required, for: .horizontal)
}
afterRateLabel.textColor = AppColor.primary
arrowImageView.tintColor = AppColor.textTertiary
arrowImageView.contentMode = .scaleAspectFit
let rateStack = UIStackView(arrangedSubviews: [beforeRateLabel, arrowImageView, afterRateLabel, UIView()])
rateStack.axis = .horizontal
rateStack.alignment = .center
rateStack.spacing = AppSpacing.sm
arrowImageView.snp.makeConstraints { make in
make.size.equalTo(16)
}
remarkLabel.font = .systemFont(ofSize: 13)
remarkLabel.textColor = AppColor.textSecondary
remarkLabel.numberOfLines = 0
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.sm
contentStack.addArrangedSubview(headerStack)
contentStack.addArrangedSubview(rateStack)
contentStack.addArrangedSubview(remarkLabel)
contentView.addSubview(railView)
contentView.addSubview(dotView)
contentView.addSubview(cardView)
cardView.addSubview(contentStack)
railView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(24)
make.top.bottom.equalToSuperview()
make.width.equalTo(1.5)
}
dotView.snp.makeConstraints { make in
make.centerX.equalTo(railView)
make.top.equalToSuperview().offset(20)
make.size.equalTo(8)
}
cardView.snp.makeConstraints { make in
make.leading.equalTo(railView.snp.trailing).offset(16)
make.trailing.equalToSuperview().inset(14)
make.top.equalToSuperview().offset(4)
make.bottom.equalToSuperview().inset(8)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func configure(with item: CommissionRateLogEntity, isLatest: Bool) {
metadataLabel.text = item.metadataLine
latestBadgeLabel.isHidden = !isLatest
beforeRateLabel.text = item.displayBeforeRate
afterRateLabel.text = item.displayAfterRate
if let remark = item.displayRemark {
remarkLabel.text = "备注:\(remark)"
remarkLabel.isHidden = false
} else {
remarkLabel.text = nil
remarkLabel.isHidden = true
}
accessibilityLabel = [
isLatest ? "最新记录" : nil,
item.metadataLine,
"\(item.displayBeforeRate)修改为\(item.displayAfterRate)",
item.displayRemark.map { "备注\($0)" },
].compactMap { $0 }.joined(separator: "")
}
}

View File

@ -31,12 +31,13 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF6F7FA)
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 142 tableView.estimatedRowHeight = 224
tableView.contentInset.top = AppSpacing.sm tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
tableView.dataSource = self tableView.dataSource = self
tableView.delegate = self tableView.delegate = self
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier) tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
@ -245,6 +246,17 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
cell.onEditCommission = { [weak self] in cell.onEditCommission = { [weak self] in
self?.presentCommissionDialog(for: acquirer) self?.presentCommissionDialog(for: acquirer)
} }
cell.onViewCommissionLogs = { [weak self] in
guard acquirer.saleUserId > 0 else {
self?.showToast("获客员信息无效")
return
}
let controller = CommissionRateLogViewController(
saleUserId: acquirer.saleUserId,
acquirerName: acquirer.displayName
)
self?.navigationController?.pushViewController(controller, animated: true)
}
cell.onCall = { cell.onCall = {
let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines) let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return } guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return }

View File

@ -445,22 +445,25 @@ final class CooperationAcquirerCell: UITableViewCell {
var onEditRemark: (() -> Void)? var onEditRemark: (() -> Void)?
var onEditCommission: (() -> Void)? var onEditCommission: (() -> Void)?
var onViewCommissionLogs: (() -> Void)?
var onCall: (() -> Void)? var onCall: (() -> Void)?
private let cardView = UIView() private let cardView = UIView()
private let avatarContainer = UIView() private let avatarContainer = UIView()
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill")) private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
private let nameTitleLabel = UILabel()
private let nameValueLabel = UILabel() private let nameValueLabel = UILabel()
private let editRemarkButton = UIButton(type: .system)
private let phoneTitleLabel = UILabel()
private let phoneValueLabel = UILabel() private let phoneValueLabel = UILabel()
private let callButton = UIButton(type: .system) private let callButton = UIButton(type: .system)
private let bindTimeTitleLabel = UILabel() private let commissionContainer = UIView()
private let bindTimeValueLabel = UILabel()
private let commissionTitleLabel = UILabel() private let commissionTitleLabel = UILabel()
private let commissionValueLabel = UILabel() private let commissionValueLabel = UILabel()
private let bindTimeIconView = UIImageView(image: UIImage(systemName: "calendar"))
private let bindTimeValueLabel = UILabel()
private let dividerView = UIView()
private let editRemarkButton = UIButton(type: .system)
private let editCommissionButton = UIButton(type: .system) private let editCommissionButton = UIButton(type: .system)
private let commissionLogButton = UIButton(type: .system)
private let actionStack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
@ -469,135 +472,180 @@ final class CooperationAcquirerCell: UITableViewCell {
contentView.backgroundColor = .clear contentView.backgroundColor = .clear
cardView.backgroundColor = .white cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.md cardView.layer.cornerRadius = AppRadius.xl
cardView.layer.borderWidth = 0.5
cardView.layer.borderColor = AppColor.cardOutline.cgColor
cardView.layer.shadowColor = UIColor.black.cgColor cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.04 cardView.layer.shadowOpacity = 0.04
cardView.layer.shadowOffset = CGSize(width: 0, height: 1) cardView.layer.shadowOffset = CGSize(width: 0, height: 3)
cardView.layer.shadowRadius = 2 cardView.layer.shadowRadius = 8
cardView.accessibilityIdentifier = "cooperation_acquirer_card"
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08) avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
avatarContainer.layer.cornerRadius = AppRadius.sm avatarContainer.layer.cornerRadius = 24
avatarContainer.clipsToBounds = true avatarContainer.clipsToBounds = true
avatarIconView.tintColor = AppColor.primary avatarIconView.tintColor = AppColor.primary
avatarIconView.contentMode = .scaleAspectFit avatarIconView.contentMode = .scaleAspectFit
configureTitleLabel(nameTitleLabel, text: "获客员名称") nameValueLabel.font = .systemFont(ofSize: 17, weight: .semibold)
configureTitleLabel(phoneTitleLabel, text: "手机号")
configureTitleLabel(bindTimeTitleLabel, text: "绑定时间")
configureTitleLabel(commissionTitleLabel, text: "分成比例")
nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold)
nameValueLabel.textColor = AppColor.textPrimary nameValueLabel.textColor = AppColor.textPrimary
nameValueLabel.numberOfLines = 1 nameValueLabel.numberOfLines = 1
nameValueLabel.lineBreakMode = .byTruncatingTail nameValueLabel.lineBreakMode = .byTruncatingTail
nameValueLabel.accessibilityIdentifier = "cooperation_acquirer_name"
[phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in phoneValueLabel.font = .systemFont(ofSize: 14)
label.font = .systemFont(ofSize: 13) phoneValueLabel.textColor = AppColor.textTertiary
label.textColor = AppColor.textSecondary phoneValueLabel.numberOfLines = 1
label.numberOfLines = 1 phoneValueLabel.lineBreakMode = .byTruncatingTail
label.lineBreakMode = .byTruncatingTail phoneValueLabel.accessibilityIdentifier = "cooperation_acquirer_phone"
}
commissionValueLabel.font = .systemFont(ofSize: 13, weight: .medium)
editRemarkButton.setTitle("修改备注", for: .normal)
editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13)
editRemarkButton.setTitleColor(AppColor.primary, for: .normal)
editRemarkButton.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 16, leading: 14, bottom: 16, trailing: 14)
)
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary callButton.tintColor = AppColor.primary
callButton.backgroundColor = AppColor.primaryLight callButton.backgroundColor = AppColor.primaryLight
callButton.layer.cornerRadius = 15 callButton.layer.cornerRadius = AppSpacing.minTouchTarget / 2
callButton.layer.borderWidth = 0.5
callButton.layer.borderColor = AppColor.primary.withAlphaComponent(0.12).cgColor
callButton.imageView?.contentMode = .scaleAspectFit callButton.imageView?.contentMode = .scaleAspectFit
callButton.accessibilityLabel = "拨打获客员电话"
callButton.accessibilityIdentifier = "cooperation_acquirer_call"
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
editCommissionButton.setTitle("修改比例", for: .normal) commissionContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13) commissionContainer.layer.cornerRadius = AppRadius.lg
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
editCommissionButton.setConfigurationContentInsets( commissionTitleLabel.text = "分成比例"
NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) commissionTitleLabel.font = .systemFont(ofSize: 12)
commissionTitleLabel.textColor = AppColor.textTertiary
commissionValueLabel.font = .systemFont(ofSize: 24, weight: .bold)
commissionValueLabel.textColor = AppColor.primary
commissionValueLabel.numberOfLines = 1
commissionValueLabel.adjustsFontSizeToFitWidth = true
commissionValueLabel.minimumScaleFactor = 0.75
commissionValueLabel.accessibilityIdentifier = "cooperation_acquirer_commission_rate"
bindTimeIconView.tintColor = AppColor.textTertiary
bindTimeIconView.contentMode = .scaleAspectFit
bindTimeValueLabel.font = .systemFont(ofSize: 13)
bindTimeValueLabel.textColor = AppColor.textTertiary
bindTimeValueLabel.numberOfLines = 2
bindTimeValueLabel.lineBreakMode = .byTruncatingTail
bindTimeValueLabel.accessibilityIdentifier = "cooperation_acquirer_bind_time"
dividerView.backgroundColor = AppColor.cardOutline
configureActionButton(
editRemarkButton,
title: "修改备注",
isPrimary: false,
accessibilityIdentifier: "cooperation_acquirer_edit_remark"
)
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
configureActionButton(
commissionLogButton,
title: "修改记录",
isPrimary: false,
accessibilityIdentifier: "cooperation_acquirer_commission_logs"
)
commissionLogButton.accessibilityLabel = "查看分成比例修改记录"
commissionLogButton.addTarget(self, action: #selector(commissionLogTapped), for: .touchUpInside)
configureActionButton(
editCommissionButton,
title: "修改比例",
isPrimary: true,
accessibilityIdentifier: "cooperation_acquirer_edit_commission"
) )
editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside) editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside)
actionStack.axis = .horizontal
actionStack.alignment = .fill
actionStack.distribution = .fillEqually
actionStack.spacing = AppSpacing.xs
actionStack.addArrangedSubview(editRemarkButton)
actionStack.addArrangedSubview(commissionLogButton)
actionStack.addArrangedSubview(editCommissionButton)
contentView.addSubview(cardView) contentView.addSubview(cardView)
cardView.addSubview(avatarContainer) cardView.addSubview(avatarContainer)
avatarContainer.addSubview(avatarIconView) avatarContainer.addSubview(avatarIconView)
cardView.addSubview(nameTitleLabel)
cardView.addSubview(nameValueLabel) cardView.addSubview(nameValueLabel)
cardView.addSubview(editRemarkButton)
cardView.addSubview(phoneTitleLabel)
cardView.addSubview(phoneValueLabel) cardView.addSubview(phoneValueLabel)
cardView.addSubview(callButton) cardView.addSubview(callButton)
cardView.addSubview(bindTimeTitleLabel) cardView.addSubview(commissionContainer)
commissionContainer.addSubview(commissionTitleLabel)
commissionContainer.addSubview(commissionValueLabel)
cardView.addSubview(bindTimeIconView)
cardView.addSubview(bindTimeValueLabel) cardView.addSubview(bindTimeValueLabel)
cardView.addSubview(commissionTitleLabel) cardView.addSubview(dividerView)
cardView.addSubview(commissionValueLabel) cardView.addSubview(actionStack)
cardView.addSubview(editCommissionButton)
cardView.snp.makeConstraints { make in cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 14, bottom: AppSpacing.sm, right: 14)) make.edges.equalToSuperview().inset(
UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)
)
} }
avatarContainer.snp.makeConstraints { make in avatarContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14) make.top.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview() make.size.equalTo(48)
make.size.equalTo(44)
} }
avatarIconView.snp.makeConstraints { make in avatarIconView.snp.makeConstraints { make in
make.center.equalToSuperview() make.center.equalToSuperview()
make.size.equalTo(22) make.size.equalTo(24)
}
nameTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.md)
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
} }
nameValueLabel.snp.makeConstraints { make in nameValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(nameTitleLabel) make.top.equalTo(avatarContainer).offset(2)
make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm) make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs) make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
}
editRemarkButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview()
}
phoneTitleLabel.snp.makeConstraints { make in
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.equalTo(nameTitleLabel)
} }
phoneValueLabel.snp.makeConstraints { make in phoneValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(phoneTitleLabel) make.top.equalTo(nameValueLabel.snp.bottom).offset(AppSpacing.xxs)
make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm) make.leading.equalTo(nameValueLabel)
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs) make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
} }
callButton.snp.makeConstraints { make in callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14) make.top.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalTo(phoneTitleLabel) make.size.equalTo(AppSpacing.minTouchTarget)
make.size.equalTo(30)
} }
bindTimeTitleLabel.snp.makeConstraints { make in commissionContainer.snp.makeConstraints { make in
make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalTo(avatarContainer.snp.bottom).offset(14)
make.leading.equalTo(nameTitleLabel) make.leading.equalToSuperview().inset(AppSpacing.md)
} make.width.equalTo(120)
bindTimeValueLabel.snp.makeConstraints { make in make.height.equalTo(64)
make.centerY.equalTo(bindTimeTitleLabel)
make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualToSuperview().inset(14)
} }
commissionTitleLabel.snp.makeConstraints { make in commissionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalToSuperview().offset(AppSpacing.xs)
make.leading.equalTo(nameTitleLabel) make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(AppSpacing.md)
} }
commissionValueLabel.snp.makeConstraints { make in commissionValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(commissionTitleLabel) make.top.equalTo(commissionTitleLabel.snp.bottom).offset(2)
make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm) make.centerX.equalToSuperview()
make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs) make.leading.greaterThanOrEqualToSuperview().offset(AppSpacing.xs)
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
} }
editCommissionButton.snp.makeConstraints { make in bindTimeIconView.snp.makeConstraints { make in
make.centerY.equalTo(commissionTitleLabel) make.leading.equalTo(commissionContainer.snp.trailing).offset(AppSpacing.md)
make.trailing.equalToSuperview().inset(AppSpacing.xs) make.centerY.equalTo(commissionContainer)
make.size.equalTo(18)
}
bindTimeValueLabel.snp.makeConstraints { make in
make.leading.equalTo(bindTimeIconView.snp.trailing).offset(AppSpacing.xs)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalTo(bindTimeIconView)
}
dividerView.snp.makeConstraints { make in
make.top.equalTo(commissionContainer.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(0.5)
}
actionStack.snp.makeConstraints { make in
make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(AppSpacing.minTouchTarget)
make.bottom.equalToSuperview().inset(14)
} }
} }
@ -610,26 +658,52 @@ final class CooperationAcquirerCell: UITableViewCell {
super.prepareForReuse() super.prepareForReuse()
onEditRemark = nil onEditRemark = nil
onEditCommission = nil onEditCommission = nil
onViewCommissionLogs = nil
onCall = nil onCall = nil
} }
func configure(with acquirer: CooperativeSalerEntity) { func configure(with acquirer: CooperativeSalerEntity) {
nameValueLabel.text = acquirer.displayName.isEmpty ? "" : acquirer.displayName nameValueLabel.text = acquirer.displayName.isEmpty ? "" : acquirer.displayName
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "" : CooperationOrderPhoneMask.mask(acquirer.displayPhone) phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "" : CooperationOrderPhoneMask.mask(acquirer.displayPhone)
bindTimeValueLabel.text = acquirer.displayBindTime.isEmpty ? "" : acquirer.displayBindTime let bindTime = acquirer.displayBindTime.isEmpty ? "" : Self.minutePrecisionTime(acquirer.displayBindTime)
bindTimeValueLabel.text = "绑定于 \(bindTime)"
commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "" : acquirer.displayCommissionRate commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "" : acquirer.displayCommissionRate
} }
private func configureTitleLabel(_ label: UILabel, text: String) { private func configureActionButton(
label.text = text _ button: UIButton,
label.font = .systemFont(ofSize: 13) title: String,
label.textColor = AppColor.textTertiary isPrimary: Bool,
label.setContentHuggingPriority(.required, for: .horizontal) accessibilityIdentifier: String
label.setContentCompressionResistancePriority(.required, for: .horizontal) ) {
var configuration = UIButton.Configuration.plain()
configuration.title = title
configuration.baseForegroundColor = isPrimary ? AppColor.primary : UIColor(hex: 0x5F6673)
configuration.background.backgroundColor = isPrimary ? AppColor.primaryLight : .white
configuration.background.strokeColor = isPrimary
? AppColor.primary.withAlphaComponent(0.35)
: UIColor(hex: 0xD9DEE7)
configuration.background.strokeWidth = 1
configuration.background.cornerRadius = AppRadius.md
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var updated = attributes
updated.font = .systemFont(ofSize: 12, weight: .medium)
return updated
}
button.configuration = configuration
button.accessibilityIdentifier = accessibilityIdentifier
}
private static func minutePrecisionTime(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count == 19, trimmed.dropFirst(16).first == ":" else { return trimmed }
return String(trimmed.prefix(16))
} }
@objc private func editRemarkTapped() { onEditRemark?() } @objc private func editRemarkTapped() { onEditRemark?() }
@objc private func editCommissionTapped() { onEditCommission?() } @objc private func editCommissionTapped() { onEditCommission?() }
@objc private func commissionLogTapped() { onViewCommissionLogs?() }
@objc private func callTapped() { onCall?() } @objc private func callTapped() { onCall?() }
} }

View File

@ -7,6 +7,10 @@ import CoreLocation
import SnapKit import SnapKit
import UIKit import UIKit
#if targetEnvironment(simulator)
import MapKit
#endif
/// / /// /
final class LocationReportMapControlStack: UIView { final class LocationReportMapControlStack: UIView {
@ -295,7 +299,112 @@ final class LocationReportBottomActionView: UIControl {
} }
} }
/// `MAMapView` /// 使 MapKit
#if targetEnvironment(simulator)
final class LocationReportMapView: UIView, MKMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
let mapView: MKMapView
private var markerAnnotation: MKPointAnnotation?
private var shouldCenterOnNextUserLocation = false
override init(frame: CGRect) {
mapView = MKMapView(frame: .zero)
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
tapGesture.cancelsTouchesInView = false
mapView.addGestureRecognizer(tapGesture)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func enableInitialUserLocationCentering() {
shouldCenterOnNextUserLocation = true
centerOnUserLocationIfNeeded()
}
func centerOnUserLocation() {
guard let coordinate = validUserCoordinate else { return }
setCenter(coordinate)
}
func setCenter(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
let region = MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.012)
)
mapView.setRegion(region, animated: animated)
}
func zoomIn() {
updateZoom(scale: 0.5)
}
func zoomOut() {
updateZoom(scale: 2)
}
func updateMarker(latitude: Double?, longitude: Double?) {
if let markerAnnotation {
mapView.removeAnnotation(markerAnnotation)
self.markerAnnotation = nil
}
guard let latitude, let longitude else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
markerAnnotation = annotation
mapView.addAnnotation(annotation)
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
centerOnUserLocationIfNeeded()
}
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended else { return }
let point = gesture.location(in: mapView)
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
}
private var validUserCoordinate: CLLocationCoordinate2D? {
guard let coordinate = mapView.userLocation.location?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else {
return nil
}
return coordinate
}
private func centerOnUserLocationIfNeeded() {
guard shouldCenterOnNextUserLocation, let coordinate = validUserCoordinate else { return }
setCenter(coordinate, animated: false)
shouldCenterOnNextUserLocation = false
}
private func updateZoom(scale: Double) {
let current = mapView.region
let span = MKCoordinateSpan(
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
)
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
}
}
#else
/// `MAMapView`
final class LocationReportMapView: UIView, MAMapViewDelegate { final class LocationReportMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)? var onMapTap: ((CLLocationCoordinate2D) -> Void)?
@ -381,3 +490,4 @@ final class LocationReportMapView: UIView, MAMapViewDelegate {
shouldCenterOnNextUserLocation = false shouldCenterOnNextUserLocation = false
} }
} }
#endif

View File

@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let qrContainerView = UIView() private let qrContainerView = UIView()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView() private let qrPlaceholderView = UIView()
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
private let qrErrorLabel = UILabel() private let qrErrorLabel = UILabel()
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let setAmountButton = UIButton(type: .system) private let setAmountButton = UIButton(type: .system)
@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24 qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
qrLoadingIndicator.color = AppColor.primary
qrLoadingIndicator.hidesWhenStopped = true
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.text = "未选景区或网络问题"
qrErrorLabel.font = .systemFont(ofSize: 14) qrErrorLabel.font = .systemFont(ofSize: 14)
qrErrorLabel.textColor = AppColor.danger qrErrorLabel.textColor = AppColor.danger
@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
trailing: 24 trailing: 24
) )
} }
actionRow.alpha = 0
actionRow.isUserInteractionEnabled = false
actionRow.accessibilityElementsHidden = true
configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码") configureLinkButton(saveQRButton, title: "保存二维码")
@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack) qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrLoadingIndicator)
qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton) qrPlaceholderView.addSubview(refreshButton)
@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.snp.makeConstraints { make in qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182) make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
} }
qrLoadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
qrErrorLabel.snp.makeConstraints { make in qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56) make.top.equalToSuperview().offset(56)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private func loadData() async { private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI) async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading()
await viewModel.loadPayCode(api: paymentAPI) await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
await pageConfig await pageConfig
} }
@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded() applyBrandConfigIfNeeded()
if let image = viewModel.qrImage { if viewModel.isPayCodeLoading {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
? .white
: AppColor.inputBackground
qrPlaceholderView.isHidden = false
qrErrorLabel.isHidden = true
refreshButton.isHidden = true
qrLoadingIndicator.startAnimating()
setActionRowAvailable(false)
} else if let image = viewModel.qrImage {
qrImageView.image = image qrImageView.image = image
qrImageView.isHidden = false qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
actionRow.isHidden = false qrLoadingIndicator.stopAnimating()
setActionRowAvailable(true)
} else { } else {
qrImageView.isHidden = true qrImageView.isHidden = true
qrContainerView.isHidden = true qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.isHidden = false qrPlaceholderView.isHidden = false
actionRow.isHidden = true qrErrorLabel.isHidden = false
refreshButton.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(false)
} }
if viewModel.showAmountDialog { if viewModel.showAmountDialog {
@ -408,6 +434,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
} }
} }
private func setActionRowAvailable(_ isAvailable: Bool) {
actionRow.alpha = isAvailable ? 1 : 0
actionRow.isUserInteractionEnabled = isAvailable
actionRow.accessibilityElementsHidden = !isAvailable
}
private func presentAmountDialogIfNeeded() { private func presentAmountDialogIfNeeded() {
guard amountDialog == nil else { guard amountDialog == nil else {
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark) amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func refreshTapped() { @objc private func refreshTapped() {
Task { Task {
showLoading()
await viewModel.refresh(api: paymentAPI) await viewModel.refresh(api: paymentAPI)
hideLoading()
} }
} }

View File

@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
title = "我的" title = "我的"
} }
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func setupUI() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF) view.backgroundColor = UIColor(hex: 0xF7FAFF)
@ -104,12 +109,10 @@ final class ProfileViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
let showLoading = !hasLoadedProfileOnce let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
Task { Task {
await reloadProfile(showGlobalLoading: showLoading) await reloadProfile(showGlobalLoading: showLoading)
if showLoading { hasLoadedProfileOnce = true
hasLoadedProfileOnce = true
}
} }
} }

View File

@ -3,11 +3,16 @@
// suixinkan // suixinkan
// //
import CoreLocation
import Kingfisher import Kingfisher
import Photos import Photos
import SnapKit import SnapKit
import UIKit import UIKit
#if targetEnvironment(simulator)
import MapKit
#endif
/// Android `PunchPointDetailScreen` /// Android `PunchPointDetailScreen`
final class PunchPointDetailViewController: BaseViewController { final class PunchPointDetailViewController: BaseViewController {
private let viewModel: PunchPointDetailViewModel private let viewModel: PunchPointDetailViewModel
@ -447,7 +452,192 @@ final class PunchPointDetailViewController: BaseViewController {
} }
/// ///
/// marker /// 使 MapKit marker
#if targetEnvironment(simulator)
final class PunchPointMapView: UIView, MKMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
private let mapView: MKMapView
private var punchPointAnnotation: MKPointAnnotation?
private var selectedAnnotation: MKPointAnnotation?
private var punchPointImageURL: URL?
private var fitWithUserLocation = false
override init(frame: CGRect) {
mapView = MKMapView(frame: .zero)
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.pointOfInterestFilter = .includingAll
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
tapGesture.cancelsTouchesInView = false
mapView.addGestureRecognizer(tapGesture)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使 Marker
func updatePunchPointMarker(
coordinate: CLLocationCoordinate2D?,
imageURL: String? = nil,
fitWithUserLocation: Bool = false
) {
if let punchPointAnnotation {
mapView.removeAnnotation(punchPointAnnotation)
self.punchPointAnnotation = nil
}
punchPointImageURL = imageURL.flatMap(URL.init(string:))
self.fitWithUserLocation = fitWithUserLocation
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "punch_point_original"
punchPointAnnotation = annotation
mapView.addAnnotation(annotation)
if fitWithUserLocation {
fitPunchPointAndUserLocation()
}
}
/// Marker
func updateMarker(coordinate: CLLocationCoordinate2D?) {
updatePunchPointMarker(coordinate: coordinate)
}
/// Marker
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
if let selectedAnnotation {
mapView.removeAnnotation(selectedAnnotation)
self.selectedAnnotation = nil
}
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "punch_point_selected"
selectedAnnotation = annotation
mapView.addAnnotation(annotation)
}
///
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
let delta = min(max(0.012 * pow(2, 15 - zoomLevel), 0.0005), 120)
let region = MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)
)
mapView.setRegion(region, animated: animated)
}
///
func zoomIn() {
updateZoom(scale: 0.5)
}
///
func zoomOut() {
updateZoom(scale: 2)
}
///
func centerOnUserLocation() {
guard let coordinate = mapView.userLocation.location?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
setCenter(coordinate)
}
///
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
var annotations: [any MKAnnotation] = []
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
if let selectedAnnotation { annotations.append(selectedAnnotation) }
if mapView.userLocation.location != nil { annotations.append(mapView.userLocation) }
guard !annotations.isEmpty else { return }
if annotations.count == 1, let annotation = annotations.first {
setCenter(annotation.coordinate, zoomLevel: 16)
} else {
var mapRect = MKMapRect.null
for annotation in annotations {
let point = MKMapPoint(annotation.coordinate)
mapRect = mapRect.union(MKMapRect(
origin: point,
size: MKMapSize(width: 1, height: 1)
))
}
mapView.setVisibleMapRect(mapRect, edgePadding: edgePadding, animated: true)
}
}
func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? {
if annotation is MKUserLocation { return nil }
let isOriginal = (annotation as AnyObject) === punchPointAnnotation
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
annotationView.annotation = annotation
annotationView.canShowCallout = false
annotationView.centerOffset = CGPoint(x: 0, y: -12)
if isOriginal, let punchPointImageURL {
annotationView.image = UIImage(named: "punch_point_marker")
KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in
guard case let .success(value) = result else { return }
Task { @MainActor in
guard annotationView.annotation === annotation else { return }
annotationView.image = Self.roundedMarkerImage(value.image)
}
}
} else {
annotationView.image = UIImage(named: "punch_point_marker")
}
return annotationView
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard userLocation.location != nil else { return }
fitPunchPointAndUserLocation()
}
private func fitPunchPointAndUserLocation() {
guard fitWithUserLocation else { return }
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
}
private static func roundedMarkerImage(_ source: UIImage) -> UIImage {
let size = CGSize(width: 40, height: 40)
return UIGraphicsImageRenderer(size: size).image { _ in
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip()
source.draw(in: CGRect(origin: .zero, size: size))
}
}
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended else { return }
let point = gesture.location(in: mapView)
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
}
private func updateZoom(scale: Double) {
let current = mapView.region
let span = MKCoordinateSpan(
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
)
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
}
}
#else
/// marker
final class PunchPointMapView: UIView, MAMapViewDelegate { final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)? var onMapTap: ((CLLocationCoordinate2D) -> Void)?
@ -614,6 +804,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
onMapTap?(poi.coordinate) onMapTap?(poi.coordinate)
} }
} }
#endif
/// ///
/// ///

View File

@ -11,9 +11,6 @@ import UIKit
final class TravelAlbumDetailViewController: BaseViewController { final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let aiEditResultStore = TravelAlbumAIEditResultStore.shared
private var pendingAIEditMaterialIDs: Set<Int> = []
private var failedAIEditCount = 0
private let scrollContainer = UIView() private let scrollContainer = UIView()
private let infoCard = TravelAlbumInfoCard() private let infoCard = TravelAlbumInfoCard()
@ -27,9 +24,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>! private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView() private let bottomBar = UIView()
private let bottomActionStack = UIStackView() private let bottomActionStack = UIStackView()
private let aiEditSelectedButton = UIButton(type: .system)
private let deleteSelectedButton = UIButton(type: .system) private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system)
private var isDeleteSelectedButtonVisible = false
init(albumId: Int, api: (any TravelAlbumServing)? = nil) { init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId) viewModel = TravelAlbumDetailViewModel(albumId: albumId)
@ -44,7 +41,6 @@ final class TravelAlbumDetailViewController: BaseViewController {
override func setupNavigationBar() { override func setupNavigationBar() {
title = "相册管理" title = "相册管理"
navigationItem.backButtonDisplayMode = .minimal
navigationItem.rightBarButtonItem = UIBarButtonItem( navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"), image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [ menu: UIMenu(children: [
@ -82,39 +78,21 @@ final class TravelAlbumDetailViewController: BaseViewController {
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier, withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
for: indexPath for: indexPath
) as! TravelAlbumMaterialCell ) as! TravelAlbumMaterialCell
let aiEditState = self?.aiEditResultStore.state(for: material.id)
?? TravelAlbumAIEditResultState()
let resultImage = aiEditState.isCover
? (self?.aiEditResultStore.coverImage(for: material.id)
?? self?.aiEditResultStore.editedImage(for: material.id))
: self?.aiEditResultStore.editedImage(for: material.id)
cell.apply( cell.apply(
material: material, material: material,
selectionMode: self?.viewModel.isSelectionMode == true, selectionMode: self?.viewModel.isSelectionMode == true,
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true, selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true
aiEditState: aiEditState,
editedImage: resultImage
) )
return cell return cell
} }
bottomBar.backgroundColor = AppColor.pageBackground bottomBar.backgroundColor = AppColor.pageBackground
bottomActionStack.axis = .horizontal bottomActionStack.axis = .vertical
bottomActionStack.spacing = 12 bottomActionStack.spacing = 8
bottomActionStack.distribution = .fillEqually configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
configureSelectionActionButton(
aiEditSelectedButton,
title: "AI修图",
image: UIImage(named: "travel_album_preview_ai_edit")?.withRenderingMode(.alwaysTemplate)
)
configureSelectionActionButton(
deleteSelectedButton,
title: "删除",
image: UIImage(named: "travel_album_preview_delete")?.withRenderingMode(.alwaysTemplate)
)
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary) configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
aiEditSelectedButton.isHidden = true
deleteSelectedButton.isHidden = true deleteSelectedButton.isHidden = true
deleteSelectedButton.alpha = 0
view.addSubview(scrollContainer) view.addSubview(scrollContainer)
scrollContainer.addSubview(infoCard) scrollContainer.addSubview(infoCard)
@ -127,7 +105,6 @@ final class TravelAlbumDetailViewController: BaseViewController {
manageCard.addSubview(collectionView) manageCard.addSubview(collectionView)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(bottomActionStack) bottomBar.addSubview(bottomActionStack)
bottomActionStack.addArrangedSubview(aiEditSelectedButton)
bottomActionStack.addArrangedSubview(deleteSelectedButton) bottomActionStack.addArrangedSubview(deleteSelectedButton)
bottomActionStack.addArrangedSubview(uploadButton) bottomActionStack.addArrangedSubview(uploadButton)
} }
@ -141,10 +118,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(20) make.leading.trailing.equalToSuperview().inset(20)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
} }
[aiEditSelectedButton, deleteSelectedButton, uploadButton].forEach { button in deleteSelectedButton.snp.makeConstraints { make in
button.snp.makeConstraints { make in make.height.equalTo(48).priority(.high)
make.height.equalTo(58).priority(.high) }
} uploadButton.snp.makeConstraints { make in
make.height.equalTo(48).priority(.high)
} }
scrollContainer.snp.makeConstraints { make in scrollContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
@ -185,15 +163,8 @@ final class TravelAlbumDetailViewController: BaseViewController {
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside) purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside) sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside) selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
aiEditSelectedButton.addTarget(self, action: #selector(aiEditSelectedTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
NotificationCenter.default.addObserver(
self,
selector: #selector(aiEditResultDidChange(_:)),
name: TravelAlbumAIEditResultStore.didChangeNotification,
object: aiEditResultStore
)
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() } Task { @MainActor in self?.applyViewModel() }
} }
@ -224,7 +195,8 @@ final class TravelAlbumDetailViewController: BaseViewController {
selectButton.isHidden = viewModel.selectedTab != .all selectButton.isHidden = viewModel.selectedTab != .all
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal) selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
updateBottomToolbar() deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>() var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0]) snapshot.appendSections([0])
@ -241,28 +213,6 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
} }
///
private func updateBottomToolbar() {
let toolbarState = TravelAlbumSelectionToolbarState(
isSelectionMode: viewModel.isSelectionMode,
selectedCount: viewModel.selectedMaterialIds.count
)
uploadButton.isHidden = !toolbarState.showsUpload
aiEditSelectedButton.isHidden = !toolbarState.showsSelectionActions
deleteSelectedButton.isHidden = !toolbarState.showsSelectionActions
let selectedContainsProcessingPhoto = viewModel.selectedMaterialIds.contains { materialID in
aiEditResultStore.state(for: materialID).isProcessing
}
let canStartAIEditing = toolbarState.selectionActionsEnabled
&& pendingAIEditMaterialIDs.isEmpty
&& !selectedContainsProcessingPhoto
aiEditSelectedButton.isEnabled = canStartAIEditing
deleteSelectedButton.isEnabled = toolbarState.selectionActionsEnabled
aiEditSelectedButton.alpha = canStartAIEditing ? 1 : 0.4
deleteSelectedButton.alpha = toolbarState.selectionActionsEnabled ? 1 : 0.4
bottomBar.backgroundColor = toolbarState.showsSelectionActions ? .white : AppColor.pageBackground
}
private func configurePillButton(_ button: UIButton) { private func configurePillButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16 button.layer.cornerRadius = 16
@ -292,20 +242,27 @@ final class TravelAlbumDetailViewController: BaseViewController {
button.layer.cornerRadius = 10 button.layer.cornerRadius = 10
} }
/// Iconfont private func setDeleteSelectedButtonVisible(_ visible: Bool) {
private func configureSelectionActionButton(_ button: UIButton, title: String, image: UIImage?) { guard visible != isDeleteSelectedButtonVisible else { return }
var configuration = UIButton.Configuration.plain() isDeleteSelectedButtonVisible = visible
configuration.title = title
configuration.image = image let changes = {
configuration.imagePlacement = .top self.deleteSelectedButton.isHidden = !visible
configuration.imagePadding = 5 self.deleteSelectedButton.alpha = visible ? 1 : 0
configuration.baseForegroundColor = AppColor.textSecondary self.view.layoutIfNeeded()
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 13, weight: .medium)
return attributes
} }
button.configuration = configuration guard view.window != nil else {
changes()
return
}
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: changes
)
} }
private func makeLayout() -> UICollectionViewCompositionalLayout { private func makeLayout() -> UICollectionViewCompositionalLayout {
@ -362,157 +319,6 @@ final class TravelAlbumDetailViewController: BaseViewController {
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
} }
@objc private func aiEditSelectedTapped() {
let selectedMaterials = viewModel.materials.filter { viewModel.selectedMaterialIds.contains($0.id) }
guard let firstMaterial = selectedMaterials.first else {
showToast("请选择要修图的照片")
return
}
let imageURLString = firstMaterial.coverUrl.isEmpty ? firstMaterial.fileUrl : firstMaterial.coverUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
presentAIEditSheet(sourceImage: nil, selectedMaterials: selectedMaterials)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.presentAIEditSheet(sourceImage: sourceImage, selectedMaterials: selectedMaterials)
}
}
}
/// AI
private func presentAIEditSheet(
sourceImage: UIImage?,
selectedMaterials: [TravelAlbumMaterial]
) {
let controller = TravelAlbumAIEditSheetViewController(
sourceImage: sourceImage,
selectedPhotoCount: selectedMaterials.count
)
controller.onConfirm = { [weak self] selection in
self?.startBatchAIEditing(
materials: selectedMaterials,
selection: selection
)
}
present(controller, animated: true)
}
/// AI
private func startBatchAIEditing(
materials: [TravelAlbumMaterial],
selection: TravelAlbumAIEditSelection
) {
guard pendingAIEditMaterialIDs.isEmpty, !materials.isEmpty else { return }
let materialIDs = materials.map(\.id)
let coverMaterialID = selection.coverTemplate == nil ? nil : materials.first?.id
pendingAIEditMaterialIDs = Set(materialIDs)
failedAIEditCount = 0
aiEditResultStore.startProcessing(materialIDs: materialIDs)
applyViewModel()
materials.forEach { material in
let imageURLString = material.fileUrl.isEmpty ? material.coverUrl : material.fileUrl
guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: nil
)
return
}
KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
guard let self else { return }
Task { @MainActor in
let sourceImage: UIImage?
if case let .success(value) = result {
sourceImage = value.image
} else {
sourceImage = nil
}
self.finishAIEditing(
materialID: material.id,
selection: selection,
appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
sourceImage: sourceImage
)
}
}
}
}
///
private func finishAIEditing(
materialID: Int,
selection: TravelAlbumAIEditSelection,
appliedCoverTemplate: TravelAlbumCoverTemplate?,
sourceImage: UIImage?
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self else { return }
if let sourceImage {
let refinedImage = TravelAlbumAIEditImageProcessor.render(
effect: selection.refinedPreset.effect,
source: sourceImage
)
let atmosphereImage = selection.atmosphereOption.map { option in
TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: sourceImage)
}
let coverImage = appliedCoverTemplate.map { template in
TravelAlbumCoverTemplateImageProcessor.render(
template: template,
source: refinedImage
)
}
if selection.refinedPreset.effect == .original,
selection.atmosphereOption == nil,
appliedCoverTemplate == nil {
self.aiEditResultStore.restoreOriginal(materialID: materialID)
} else {
self.aiEditResultStore.complete(
materialID: materialID,
presetID: selection.refinedPreset.id,
atmosphereOptionID: selection.atmosphereOption?.id,
coverTemplateID: appliedCoverTemplate?.id,
editedImage: refinedImage,
atmosphereImage: atmosphereImage,
coverImage: coverImage
)
}
} else {
self.aiEditResultStore.failProcessing(materialID: materialID)
self.failedAIEditCount += 1
}
self.pendingAIEditMaterialIDs.remove(materialID)
self.applyViewModel()
if self.pendingAIEditMaterialIDs.isEmpty {
let successMessage: String
if let coverTemplate = selection.coverTemplate,
selection.atmosphereOption != nil {
successMessage = "AI修图完成每张生成 2 个结果,并生成「\(coverTemplate.title)」封面"
} else if let coverTemplate = selection.coverTemplate {
successMessage = "AI修图完成已生成「\(coverTemplate.title)」封面"
} else if selection.atmosphereOption != nil {
successMessage = "AI修图完成每张已生成 2 个结果"
} else {
successMessage = "AI修图完成"
}
let message = self.failedAIEditCount == 0
? successMessage
: "\(self.failedAIEditCount) 张照片修图失败,请重试"
self.showToast(message)
}
}
}
@objc private func deleteSelectedTapped() { @objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count let count = viewModel.selectedMaterialIds.count
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert) let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
@ -525,60 +331,22 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
@objc private func uploadTapped() { @objc private func uploadTapped() {
let controller = TravelAlbumTransferModeSheetViewController()
controller.onModeSelected = { [weak self] mode in
self?.openWiredTransfer(mode: mode)
}
present(controller, animated: true)
}
///
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
let album = viewModel.album let album = viewModel.album
let controller = WiredCameraTransferViewController( let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel( viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId, albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "", albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? "", headerPhone: album?.displayPhone ?? ""
initialTransferMode: mode
) )
) )
navigationController?.pushViewController(controller, animated: true) navigationController?.pushViewController(controller, animated: true)
} }
///
@objc private func aiEditResultDidChange(_ notification: Notification) {
guard let materialID = notification.userInfo?[TravelAlbumAIEditResultStore.materialIDUserInfoKey] as? Int,
let material = dataSource.snapshot().itemIdentifiers.first(where: { $0.id == materialID }) else {
return
}
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([material])
dataSource.apply(snapshot, animatingDifferences: true)
updateBottomToolbar()
}
} }
extension TravelAlbumDetailViewController: UICollectionViewDelegate { extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return } guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
if viewModel.isSelectionMode { viewModel.toggleMaterialSelection(material)
viewModel.toggleMaterialSelection(material)
} else {
//
let initialIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) ?? 0
let controller = TravelAlbumPhotoPreviewViewController(
materials: viewModel.materials,
initialIndex: initialIndex,
api: api
)
controller.onDeleted = { [weak self] in
guard let self else { return }
Task { await self.viewModel.refreshAll(api: self.api) }
}
//
present(controller, animated: true)
}
} }
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
@ -731,70 +499,19 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func prepareForReuse() { func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
super.prepareForReuse() let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
imageView.kf.cancelDownloadTask() if let url = URL(string: urlString), !urlString.isEmpty {
imageView.image = nil imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
}
func apply(
material: TravelAlbumMaterial,
selectionMode: Bool,
selected: Bool,
aiEditState: TravelAlbumAIEditResultState,
editedImage: UIImage?
) {
imageView.backgroundColor = .clear
imageView.tintColor = nil
if aiEditState.hasEditedResult, let editedImage {
imageView.kf.cancelDownloadTask()
imageView.image = editedImage
} else if let assetName = material.bundledImageAssetName,
let bundledImage = UIImage(named: assetName) {
imageView.kf.cancelDownloadTask()
imageView.image = bundledImage
} else { } else {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl imageView.image = UIImage(systemName: "photo")
if let url = URL(string: urlString), !urlString.isEmpty { imageView.tintColor = AppColor.primary
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.primary
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
}
} }
applyAIEditStatus(aiEditState.thumbnailStatus, isPurchased: material.isPurchased)
checkImageView.isHidden = !selectionMode checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white checkImageView.tintColor = selected ? AppColor.primary : .white
nameLabel.text = material.fileName nameLabel.text = material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
} }
/// AI
private func applyAIEditStatus(
_ status: TravelAlbumAIEditResultState.ThumbnailStatus,
isPurchased: Bool
) {
let appearance: (title: String, color: UIColor, width: CGFloat)
switch status {
case .none:
appearance = isPurchased
? ("已购", AppColor.primary, 48)
: ("已上传", UIColor(hex: 0x34C759), 48)
case .processing:
appearance = ("修图中", AppColor.primary, 48)
case .edited:
appearance = ("AI已修", UIColor(hex: 0x6750A4), 48)
case .cover:
appearance = ("AI封面", UIColor(hex: 0xF59E0B), 56)
case .failed:
appearance = ("修图失败", UIColor(hex: 0xE53935), 56)
}
statusLabel.text = appearance.title
statusLabel.backgroundColor = appearance.color
statusLabel.snp.updateConstraints { make in
make.width.equalTo(appearance.width)
}
}
} }

View File

@ -1,171 +0,0 @@
//
// TravelAlbumTransferModeSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class TravelAlbumTransferModeSheetViewController: UIViewController {
///
var onModeSelected: ((TravelAlbumOTGTransferMode) -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let optionsStack = UIStackView()
private let cancelButton = UIButton(type: .system)
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
let identifier = UISheetPresentationController.Detent.Identifier("transferMode")
sheetPresentationController.detents = [
.custom(identifier: identifier) { context in
min(356, context.maximumDetentValue)
},
]
sheetPresentationController.selectedDetentIdentifier = identifier
sheetPresentationController.prefersGrabberVisible = true
sheetPresentationController.preferredCornerRadius = 22
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
}
private func setupUI() {
view.backgroundColor = .white
titleLabel.text = "选择传输模式"
titleLabel.font = .systemFont(ofSize: 19, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.text = "选择后进入对应的照片传输页面"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
optionsStack.axis = .vertical
optionsStack.spacing = 12
TravelAlbumOTGTransferMode.allCases.enumerated().forEach { index, mode in
optionsStack.addArrangedSubview(makeOptionView(mode: mode, index: index))
}
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
cancelButton.backgroundColor = UIColor(hex: 0xF4F5F7)
cancelButton.layer.cornerRadius = 12
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(optionsStack)
view.addSubview(cancelButton)
}
private func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(14)
make.leading.trailing.equalToSuperview().inset(20)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(7)
make.leading.trailing.equalToSuperview().inset(20)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(optionsStack.snp.bottom).offset(14)
make.leading.trailing.equalTo(optionsStack)
make.height.equalTo(48)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-10)
}
}
///
private func makeOptionView(mode: TravelAlbumOTGTransferMode, index: Int) -> UIView {
let container = UIView()
container.backgroundColor = UIColor(hex: 0xF7F9FC)
container.layer.cornerRadius = 12
container.layer.borderWidth = 1
container.layer.borderColor = UIColor(hex: 0xE6ECF5).cgColor
let titleLabel = UILabel()
titleLabel.text = mode.title
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
let detailLabel = UILabel()
detailLabel.text = mode.detailText
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 2
let enterLabel = UILabel()
enterLabel.text = "进入"
enterLabel.font = .systemFont(ofSize: 13, weight: .medium)
enterLabel.textColor = AppColor.primary
enterLabel.textAlignment = .right
let button = UIButton(type: .custom)
button.tag = index
button.accessibilityLabel = "\(mode.title)\(mode.detailText)"
button.addTarget(self, action: #selector(modeTapped(_:)), for: .touchUpInside)
container.addSubview(titleLabel)
container.addSubview(detailLabel)
container.addSubview(enterLabel)
container.addSubview(button)
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(13)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(enterLabel.snp.leading).offset(-12)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(5)
make.leading.equalTo(titleLabel)
make.trailing.lessThanOrEqualTo(enterLabel.snp.leading).offset(-12)
make.bottom.lessThanOrEqualToSuperview().offset(-12)
}
enterLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.width.equalTo(34)
}
button.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
container.snp.makeConstraints { make in
make.height.equalTo(78)
}
return container
}
@objc private func modeTapped(_ sender: UIButton) {
guard TravelAlbumOTGTransferMode.allCases.indices.contains(sender.tag) else { return }
let mode = TravelAlbumOTGTransferMode.allCases[sender.tag]
//
let completion = onModeSelected
dismiss(animated: true) {
completion?(mode)
}
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
}

View File

@ -788,23 +788,14 @@ final class WiredCameraTransferViewController: BaseViewController {
} }
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) { private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
// 使 guard let url = item.thumbnailURL else {
let displayedPhotos = dataSource.snapshot().itemIdentifiers
guard let context = displayedPhotos.previewContext(startingWith: item.id) else {
showToast("暂无可预览图片") showToast("暂无可预览图片")
return return
} }
let previewItems = context.photos.compactMap { photo -> MediaPreviewItem? in let previewItem: MediaPreviewItem = url.isFileURL
guard let url = photo.thumbnailURL else { return nil } ? .localImage(url: url)
return url.isFileURL : MediaPreviewItem(source: .remoteImage(url))
? .localImage(url: url) MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
: MediaPreviewItem(source: .remoteImage(url))
}
MediaPreviewViewController.present(
from: self,
items: previewItems,
startIndex: context.initialIndex
)
} }
} }

View File

@ -21,11 +21,6 @@ final class WiredTransferSettingChipButton: UIButton {
/// ///
private(set) var titleText: String = "" private(set) var titleText: String = ""
///
var displayedTitleColor: UIColor? {
titleLabelView.textColor
}
/// chip /// chip
/// - Parameter showsChevron: /// - Parameter showsChevron:
init(showsChevron: Bool = false) { init(showsChevron: Bool = false) {
@ -54,14 +49,13 @@ final class WiredTransferSettingChipButton: UIButton {
layer.borderColor = AppColor.border.cgColor layer.borderColor = AppColor.border.cgColor
titleLabelView.font = .systemFont(ofSize: 11) titleLabelView.font = .systemFont(ofSize: 11)
// 使使 titleLabelView.textColor = AppColor.textTertiary
titleLabelView.textColor = AppColor.textPrimary
titleLabelView.lineBreakMode = .byTruncatingTail titleLabelView.lineBreakMode = .byTruncatingTail
titleLabelView.isUserInteractionEnabled = false titleLabelView.isUserInteractionEnabled = false
chevronView.image = UIImage(systemName: "chevron.down")? chevronView.image = UIImage(systemName: "chevron.down")?
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) .withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
chevronView.tintColor = AppColor.textSecondary chevronView.tintColor = AppColor.textTertiary
chevronView.contentMode = .scaleAspectFit chevronView.contentMode = .scaleAspectFit
chevronView.isUserInteractionEnabled = false chevronView.isUserInteractionEnabled = false

View File

@ -4,10 +4,15 @@
// //
import Kingfisher import Kingfisher
import MAMapKit
import SnapKit import SnapKit
import UIKit import UIKit
#if targetEnvironment(simulator)
import MapKit
#else
import MAMapKit
#endif
/// ///
final class WildPhotographerReportListViewController: BaseViewController { final class WildPhotographerReportListViewController: BaseViewController {
private let viewModel: WildPhotographerReportListViewModel private let viewModel: WildPhotographerReportListViewModel
@ -1797,6 +1802,49 @@ private final class WildReportDetailMediaThumbnailView: UIView {
} }
/// ///
#if targetEnvironment(simulator)
private final class WildReportDetailMapPreviewView: UIView {
private let mapView: MKMapView
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
mapView = MKMapView(frame: .zero)
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = 10
clipsToBounds = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.isScrollEnabled = false
mapView.isZoomEnabled = false
mapView.isRotateEnabled = false
mapView.isPitchEnabled = false
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
guard let coordinate else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
annotation.title = scenicName
annotation.subtitle = detailAddress
mapView.addAnnotation(annotation)
mapView.setRegion(
MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.006, longitudeDelta: 0.006)
),
animated: false
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
#else
private final class WildReportDetailMapPreviewView: UIView { private final class WildReportDetailMapPreviewView: UIView {
private let mapView: MAMapView private let mapView: MAMapView
@ -1834,6 +1882,7 @@ private final class WildReportDetailMapPreviewView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
} }
#endif
/// 线 /// 线
private final class WildReportDetailTimelineRowView: UIView { private final class WildReportDetailTimelineRowView: UIView {

View File

@ -3,19 +3,19 @@
// suixinkan // suixinkan
// //
#if __has_include(<AMapFoundationKit/AMapFoundationKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
#import <AMapFoundationKit/AMapFoundationKit.h> #import <AMapFoundationKit/AMapFoundationKit.h>
#endif #endif
#if __has_include(<AMapLocationKit/AMapLocationKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<AMapLocationKit/AMapLocationKit.h>)
#import <AMapLocationKit/AMapLocationKit.h> #import <AMapLocationKit/AMapLocationKit.h>
#endif #endif
#if __has_include(<MAMapKit/MAMapKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<MAMapKit/MAMapKit.h>)
#import <MAMapKit/MAMapKit.h> #import <MAMapKit/MAMapKit.h>
#endif #endif
#if __has_include(<AMapSearchKit/AMapSearchKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<AMapSearchKit/AMapSearchKit.h>)
#import <AMapSearchKit/AMapSearchKit.h> #import <AMapSearchKit/AMapSearchKit.h>
#endif #endif

View File

@ -62,6 +62,7 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(response.token, "person-temp-token") XCTAssertEqual(response.token, "person-temp-token")
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"]) XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
} }
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
} }
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
let json = """
{
"token": "person-temp-token",
"scenic_users": [
{
"account_type": "scenic_user",
"ss_user_id": 101,
"username": "backend_scenic_account",
"user_name": "backend_scenic_unique_id",
"real_name": ""
}
],
"store_users": [
{
"account_type": "store_user",
"store_user_id": 201,
"username": "backend_store_account",
"user_name": "backend_store_unique_id",
"real_name": ""
}
]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(V9AuthResponse.self, from: json)
XCTAssertEqual(response.scenicUsers.first?.realName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.displayName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.toAccountSwitchAccount().title, "景区真实姓名")
XCTAssertEqual(response.storeUsers.first?.realName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.displayName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.toAccountSwitchAccount().title, "门店真实姓名")
}
} }

View File

@ -0,0 +1,277 @@
//
// CommissionRateLogTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
///
@MainActor
final class CommissionRateLogTests: XCTestCase {
func testModelDecodesCompleteRecordAndBuildsBusinessDisplayText() throws {
let data = """
{
"list": [{
"id": 1,
"binding_id": 10,
"sale_user_id": 456,
"store_user_id": 732,
"before_rate": 10,
"before_rate_label": "10%",
"after_rate": 20,
"after_rate_label": "20%",
"operator_type": "store_user",
"operator_id": 732,
"operator_name": "",
"remark": " ",
"created_at": "2026-07-23 11:00:00"
}],
"total": 1,
"page": 1,
"page_size": 10
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data)
let item = try XCTUnwrap(response.list.first)
XCTAssertEqual(response.pageSize, 10)
XCTAssertEqual(item.bindingId, 10)
XCTAssertEqual(item.saleUserId, 456)
XCTAssertEqual(item.storeUserId, 732)
XCTAssertEqual(item.operatorType, "store_user")
XCTAssertEqual(item.operatorId, 732)
XCTAssertEqual(item.displayBeforeRate, "10%(旧)")
XCTAssertEqual(item.displayAfterRate, "20%(新)")
XCTAssertEqual(item.metadataLine, "2026-07-23 11:00")
XCTAssertEqual(item.displayRemark, "调整合作比例")
}
func testDisplayFallsBackToNumericRateUnknownOperatorAndRawTime() throws {
let data = """
{
"list": [{
"id": 2,
"binding_id": 0,
"sale_user_id": 456,
"store_user_id": 0,
"before_rate": 5,
"before_rate_label": "",
"after_rate": 15,
"after_rate_label": " ",
"operator_type": "",
"operator_id": 0,
"operator_name": "",
"remark": " ",
"created_at": ""
}],
"total": 1,
"page": 1,
"page_size": 10
}
""".data(using: .utf8)!
let item = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data).list[0]
XCTAssertEqual(item.displayBeforeRate, "5%")
XCTAssertEqual(item.displayAfterRate, "15%")
XCTAssertEqual(item.metadataLine, "服务端时间")
XCTAssertNil(item.displayRemark)
}
func testAPIBuildsPathAndClampsPaginationParameters() async throws {
let session = MockURLSession(responses: [envelope(list: [], total: 0, page: 1)])
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.saleUserCommissionRateLogs(saleUserId: 456, page: 0, pageSize: 99)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate/logs")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "sale_user_id" }?.value, "456")
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "1")
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "50")
}
func testViewModelRetriesFailedPageAndDeduplicatesItems() async throws {
let page1 = envelope(
list: [
record(id: 1, before: 10, after: 20),
record(id: 1, before: 10, after: 20),
record(id: 2, before: 20, after: 30),
],
total: 3,
page: 1
)
let page2 = envelope(
list: [
record(id: 2, before: 20, after: 30),
record(id: 3, before: 30, after: 40),
],
total: 3,
page: 2
)
let response = HTTPURLResponse(
url: URL(string: "https://api-test.zhifly.cn/mock")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
let session = MockURLSession(results: [
.success((page1, response)),
.failure(URLError(.timedOut)),
.success((page2, response)),
])
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
let viewModel = CommissionRateLogViewModel(saleUserId: 456)
await viewModel.refresh(api: api)
await viewModel.loadMore(api: api)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
XCTAssertTrue(viewModel.canLoadMore)
XCTAssertFalse(viewModel.isLoadingMore)
await viewModel.loadMore(api: api)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3])
XCTAssertFalse(viewModel.canLoadMore)
let requestedPages = session.requests.compactMap { request in
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?
.first { $0.name == "page" }?
.value
}
XCTAssertEqual(requestedPages, ["1", "2", "2"])
}
func testTimelineCellOnlyMarksLatestRecord() {
let item = CommissionRateLogEntity(
id: 1,
bindingId: 10,
saleUserId: 456,
storeUserId: 732,
beforeRate: 10,
beforeRateLabel: "10%",
afterRate: 20,
afterRateLabel: "20%",
operatorType: "store_user",
operatorId: 732,
operatorName: "李师傅",
remark: "",
createdAt: "2026-07-23 11:00:00"
)
let cell = CommissionRateLogTimelineCell(style: .default, reuseIdentifier: nil)
cell.configure(with: item, isLatest: true)
XCTAssertTrue(cell.accessibilityLabel?.contains("最新记录") == true)
XCTAssertFalse(cell.accessibilityLabel?.contains("李师傅") == true)
cell.configure(with: item, isLatest: false)
XCTAssertFalse(cell.accessibilityLabel?.contains("最新记录") == true)
}
func testPageTitleUsesAcquirerNameAndFallsBackWhenEmpty() {
let namedController = CommissionRateLogViewController(
saleUserId: 456,
acquirerName: " 获客员小王 "
)
namedController.setupNavigationBar()
XCTAssertEqual(namedController.title, "给获客员小王的分成记录")
let fallbackController = CommissionRateLogViewController(
saleUserId: 456,
acquirerName: " "
)
fallbackController.setupNavigationBar()
XCTAssertEqual(fallbackController.title, "修改记录")
}
func testAcquirerCardUsesUnifiedActionsAndBusinessHierarchy() throws {
let cell = CooperationAcquirerCell(style: .default, reuseIdentifier: nil)
cell.configure(
with: CooperativeSalerEntity(
saleUserId: 456,
name: "获客员小王",
salerPhone: "13800138000",
commissionRate: 20,
commissionRateLabel: "20%",
bindTime: "2026-07-23 11:00:00"
)
)
let fittingSize = cell.contentView.systemLayoutSizeFitting(
CGSize(width: 390, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
cell.frame = CGRect(x: 0, y: 0, width: 390, height: fittingSize.height)
cell.layoutIfNeeded()
let remarkButton = try XCTUnwrap(
subview(in: cell, identifier: "cooperation_acquirer_edit_remark") as? UIButton
)
let logButton = try XCTUnwrap(
subview(in: cell, identifier: "cooperation_acquirer_commission_logs") as? UIButton
)
let commissionButton = try XCTUnwrap(
subview(in: cell, identifier: "cooperation_acquirer_edit_commission") as? UIButton
)
let bindTimeLabel = try XCTUnwrap(
subview(in: cell, identifier: "cooperation_acquirer_bind_time") as? UILabel
)
XCTAssertEqual(remarkButton.bounds.height, AppSpacing.minTouchTarget, accuracy: 0.5)
XCTAssertEqual(logButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
XCTAssertEqual(commissionButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
XCTAssertEqual(logButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
XCTAssertEqual(commissionButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
XCTAssertEqual(bindTimeLabel.text, "绑定于 2026-07-23 11:00")
}
private func envelope(list: [[String: Any]], total: Int, page: Int) -> Data {
let object: [String: Any] = [
"code": 100000,
"msg": "success",
"data": [
"list": list,
"total": total,
"page": page,
"page_size": 10,
],
]
return try! JSONSerialization.data(withJSONObject: object)
}
private func record(id: Int, before: Int, after: Int) -> [String: Any] {
[
"id": id,
"binding_id": 10,
"sale_user_id": 456,
"store_user_id": 732,
"before_rate": before,
"before_rate_label": "\(before)%",
"after_rate": after,
"after_rate_label": "\(after)%",
"operator_type": "store_user",
"operator_id": 732,
"operator_name": "李师傅",
"remark": "",
"created_at": "2026-07-23 11:00:00",
]
}
private func subview(in root: UIView, identifier: String) -> UIView? {
if root.accessibilityIdentifier == identifier {
return root
}
for child in root.subviews {
if let match = subview(in: child, identifier: identifier) {
return match
}
}
return nil
}
}

View File

@ -0,0 +1,204 @@
//
// CoreLocationProviderTests.swift
// suixinkanTests
//
import CoreLocation
import XCTest
@testable import suixinkan
/// Core Location fallback
@MainActor
final class CoreLocationProviderTests: XCTestCase {
func testPrivacyAgreementIsRequiredBeforeLocation() async {
let context = makeContext(privacyAccepted: false)
await assertLocationError(.privacyNotAccepted) {
_ = try await context.provider.requestCoordinate()
}
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
}
func testDeniedAuthorizationReturnsPermissionError() async {
let context = makeContext(authorizationStatus: .denied)
await assertLocationError(.permissionDenied) {
_ = try await context.provider.requestCoordinate()
}
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
}
func testNotDeterminedAuthorizationContinuesAfterGrant() async throws {
let coordinate = CLLocationCoordinate2D(latitude: 30.2741, longitude: 120.1551)
let context = makeContext(
authorizationStatus: .notDetermined,
requestedAuthorizationStatus: .authorizedWhenInUse,
locationResult: .success(CLLocation(
latitude: coordinate.latitude,
longitude: coordinate.longitude
))
)
let result = try await context.provider.requestCoordinate(
desiredAccuracy: kCLLocationAccuracyHundredMeters
)
XCTAssertEqual(context.manager.requestAuthorizationCallCount, 1)
XCTAssertEqual(context.manager.requestLocationCallCount, 1)
XCTAssertEqual(context.manager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
XCTAssertEqual(result.latitude, coordinate.latitude, accuracy: 0.000_001)
XCTAssertEqual(result.longitude, coordinate.longitude, accuracy: 0.000_001)
}
func testLocationFailureUsesExistingBusinessError() async {
let context = makeContext(locationResult: .failure)
await assertLocationError(.locationFailed) {
_ = try await context.provider.requestCoordinate()
}
}
func testSnapshotContainsReverseGeocodedAddress() async throws {
let context = makeContext(
locationResult: .success(CLLocation(latitude: 31.2304, longitude: 121.4737)),
geocodedAddress: "上海市黄浦区"
)
let snapshot = try await context.provider.requestSnapshot()
XCTAssertEqual(snapshot.latitude, 31.2304, accuracy: 0.000_001)
XCTAssertEqual(snapshot.longitude, 121.4737, accuracy: 0.000_001)
XCTAssertEqual(snapshot.address, "上海市黄浦区")
XCTAssertEqual(context.geocoder.requestedCoordinates.count, 1)
}
func testSnapshotFallsBackToEmptyAddressWhenGeocoderHasNoResult() async throws {
let context = makeContext(
locationResult: .success(CLLocation(latitude: 39.9042, longitude: 116.4074)),
geocodedAddress: ""
)
let snapshot = try await context.provider.requestSnapshot()
XCTAssertEqual(snapshot.address, "")
}
private func makeContext(
privacyAccepted: Bool = true,
authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
requestedAuthorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
locationResult: FakeCoreLocationManager.LocationResult = .success(
CLLocation(latitude: 39.9042, longitude: 116.4074)
),
geocodedAddress: String = "北京市东城区"
) -> TestContext {
let suiteName = "CoreLocationProviderTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
let appStore = AppStore(defaults: defaults)
appStore.session.privacyAgreementAccepted = privacyAccepted
let manager = FakeCoreLocationManager(
authorizationStatus: authorizationStatus,
requestedAuthorizationStatus: requestedAuthorizationStatus,
locationResult: locationResult
)
let geocoder = FakeLocationAddressGeocoder(address: geocodedAddress)
let provider = CoreLocationProvider(
appStore: appStore,
manager: manager,
geocoder: geocoder
)
return TestContext(provider: provider, manager: manager, geocoder: geocoder)
}
private func assertLocationError(
_ expected: LocationProviderError,
operation: () async throws -> Void
) async {
do {
try await operation()
XCTFail("Expected \(expected)")
} catch {
XCTAssertEqual(error as? LocationProviderError, expected)
}
}
}
/// Core Location fallback
@MainActor
private struct TestContext {
let provider: CoreLocationProvider
let manager: FakeCoreLocationManager
let geocoder: FakeLocationAddressGeocoder
}
/// `CLLocationManager`
@MainActor
private final class FakeCoreLocationManager: CoreLocationManaging {
///
enum LocationResult {
case success(CLLocation)
case failure
}
var authorizationStatus: CLAuthorizationStatus
var desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
weak var delegate: CLLocationManagerDelegate?
private(set) var requestAuthorizationCallCount = 0
private(set) var requestLocationCallCount = 0
private let requestedAuthorizationStatus: CLAuthorizationStatus
private let locationResult: LocationResult
init(
authorizationStatus: CLAuthorizationStatus,
requestedAuthorizationStatus: CLAuthorizationStatus,
locationResult: LocationResult
) {
self.authorizationStatus = authorizationStatus
self.requestedAuthorizationStatus = requestedAuthorizationStatus
self.locationResult = locationResult
}
func requestWhenInUseAuthorization() {
requestAuthorizationCallCount += 1
authorizationStatus = requestedAuthorizationStatus
delegate?.locationManagerDidChangeAuthorization?(CLLocationManager())
}
func requestLocation() {
requestLocationCallCount += 1
let callbackManager = CLLocationManager()
switch locationResult {
case let .success(location):
delegate?.locationManager?(callbackManager, didUpdateLocations: [location])
case .failure:
delegate?.locationManager?(
callbackManager,
didFailWithError: CLError(.locationUnknown)
)
}
}
}
///
@MainActor
private final class FakeLocationAddressGeocoder: LocationAddressGeocoding {
private(set) var requestedCoordinates: [CLLocationCoordinate2D] = []
private let address: String
init(address: String) {
self.address = address
}
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
requestedCoordinates.append(CLLocationCoordinate2D(
latitude: latitude,
longitude: longitude
))
return address
}
}

View File

@ -18,20 +18,20 @@ final class HomeMenuIconFactoryTests: XCTestCase {
XCTAssertNil(UIImage(systemName: "home_menu_space")) XCTAssertNil(UIImage(systemName: "home_menu_space"))
} }
func testSystemSymbolsUseFixedCanvas() { func testSystemSymbolsPreserveSymbolRepresentation() {
let iconNames = [ let iconNames = [
"ellipsis", "ellipsis",
"dot.radiowaves.left.and.right", "dot.radiowaves.left.and.right",
"rectangle.stack.badge.plus", "rectangle.stack.badge.plus",
"mappin.circle.fill",
] ]
for iconName in iconNames { for iconName in iconNames {
let image = HomeMenuIconFactory.image(named: iconName) let image = HomeMenuIconFactory.image(named: iconName)
XCTAssertNotNil(image) XCTAssertNotNil(image)
XCTAssertEqual(image?.size, HomeMenuIconFactory.canvasSize) XCTAssertTrue(image?.isSymbolImage ?? false)
XCTAssertEqual(image?.renderingMode, .alwaysTemplate) XCTAssertNotNil(image?.symbolConfiguration)
XCTAssertFalse(image?.isSymbolImage ?? true)
} }
} }
@ -39,6 +39,13 @@ final class HomeMenuIconFactoryTests: XCTestCase {
XCTAssertNotNil(HomeMenuIconFactory.image(named: "dot.radiowaves.left.and.right")) XCTAssertNotNil(HomeMenuIconFactory.image(named: "dot.radiowaves.left.and.right"))
XCTAssertNotNil(HomeMenuIconFactory.image(named: "rectangle.stack.badge.plus")) XCTAssertNotNil(HomeMenuIconFactory.image(named: "rectangle.stack.badge.plus"))
} }
func testLocationReportIconKeepsSystemSymbolLayers() {
let image = HomeMenuIconFactory.image(named: "mappin.circle.fill")
XCTAssertNotNil(image)
XCTAssertTrue(image?.isSymbolImage ?? false)
}
} }
/// cell /// cell
@ -50,6 +57,7 @@ final class HomeMenuCellTests: XCTestCase {
let menus = [ let menus = [
HomeMenuItem(uri: "space", title: "空间设置", iconName: "home_menu_space"), HomeMenuItem(uri: "space", title: "空间设置", iconName: "home_menu_space"),
HomeMenuItem(uri: "live", title: "直播管理", iconName: "dot.radiowaves.left.and.right"), HomeMenuItem(uri: "live", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
HomeMenuItem(uri: "location", title: "位置上报", iconName: "mappin.circle.fill"),
HomeMenuItem(uri: "more", title: "更多功能", iconName: "ellipsis"), HomeMenuItem(uri: "more", title: "更多功能", iconName: "ellipsis"),
] ]
@ -60,11 +68,19 @@ final class HomeMenuCellTests: XCTestCase {
cell.layoutIfNeeded() cell.layoutIfNeeded()
let iconView = try XCTUnwrap(findImageView(in: cell, identifier: menu.iconName)) let iconView = try XCTUnwrap(findImageView(in: cell, identifier: menu.iconName))
XCTAssertEqual(iconView.bounds.size, CGSize(width: 24, height: 24)) let alignmentSize = iconView.alignmentRect(forFrame: iconView.frame).size
XCTAssertEqual(alignmentSize.width, 24, accuracy: 0.001)
XCTAssertEqual(alignmentSize.height, 24, accuracy: 0.001)
XCTAssertEqual(iconView.superview?.bounds.size, CGSize(width: 40, height: 40)) XCTAssertEqual(iconView.superview?.bounds.size, CGSize(width: 40, height: 40))
XCTAssertEqual(iconView.image?.size, HomeMenuIconFactory.canvasSize)
XCTAssertEqual(iconView.contentMode, .center) XCTAssertEqual(iconView.contentMode, .center)
XCTAssertNil(iconView.preferredSymbolConfiguration) XCTAssertNil(iconView.preferredSymbolConfiguration)
if menu.iconName == "home_menu_space" {
XCTAssertEqual(iconView.image?.size, HomeMenuIconFactory.canvasSize)
XCTAssertFalse(iconView.image?.isSymbolImage ?? true)
} else {
XCTAssertTrue(iconView.image?.isSymbolImage ?? false)
}
} }
} }

View File

@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.showAmountDialog) XCTAssertFalse(viewModel.showAmountDialog)
} }
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
let payCodeJSON = """
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [payCodeJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
var loadingStates = [viewModel.isPayCodeLoading]
viewModel.onStateChange = {
loadingStates.append(viewModel.isPayCodeLoading)
}
await viewModel.loadPayCode(api: api)
XCTAssertEqual(loadingStates.first, true)
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
XCTAssertEqual(loadingStates.last, false)
XCTAssertTrue(viewModel.hasPayCode)
}
func testToggleReceiveVoicePersistsInAppStore() { func testToggleReceiveVoicePersistsInAppStore() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
viewModel.refreshLocalState() viewModel.refreshLocalState()
@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen) XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
} }
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() { func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
appStore.session.userName = "" appStore.session.userName = ""
appStore.session.realName = ""
appStore.permissions.saveRolePermissionList([ appStore.permissions.saveRolePermissionList([
RolePermissionResponse( RolePermissionResponse(
role: RoleInfo(id: 41, name: "", roleCode: "photographer"), role: RoleInfo(id: 41, name: "", roleCode: "photographer"),
@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "") XCTAssertEqual(viewModel.displayPhotographerName, "")
XCTAssertEqual(viewModel.displayStoreNames, "") XCTAssertEqual(viewModel.displayStoreNames, "")
} }
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
appStore.session.userName = ""
appStore.session.realName = " "
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "-")
}
func testLoadPayCodeClearsQRWhenScenicMissing() async { func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0 appStore.session.currentScenicId = 0
let session = MockURLSession(responses: []) let session = MockURLSession(responses: [])
@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
await viewModel.loadPayCode(api: api) await viewModel.loadPayCode(api: api)
XCTAssertFalse(viewModel.isPayCodeLoading)
XCTAssertFalse(viewModel.hasPayCode) XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0) XCTAssertEqual(session.requests.count, 0)
} }

View File

@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0)) XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
} }
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() { func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"), PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
"当前摄影师" "摄影师真实姓名"
) )
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]), PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
"一号店、二号店" "一号店、二号店"

View File

@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.displayNickname, "未设置昵称") XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
} }
func testColdStartBasicInfoUsesPersistedSessionCache() {
AppStore.shared.session.userName = "缓存昵称"
AppStore.shared.session.realName = "缓存真实姓名"
AppStore.shared.session.phone = "186****7230"
AppStore.shared.session.avatar = "https://cdn.example.com/cached-avatar.jpg"
let viewModel = ProfileViewModel()
XCTAssertTrue(viewModel.hasCachedBasicInfo)
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
XCTAssertEqual(viewModel.displayRealName, "缓存真实姓名")
XCTAssertEqual(viewModel.displayPhone, "186****7230")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/cached-avatar.jpg")
XCTAssertEqual(viewModel.displayUID, "profile-user")
}
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() { func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
let viewModel = ProfileViewModel() let viewModel = ProfileViewModel()

View File

@ -3,6 +3,7 @@
// suixinkanTests // suixinkanTests
// //
import CoreLocation
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan

View File

@ -0,0 +1,62 @@
//
// SessionExpiredDialogViewControllerTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// Android
@MainActor
final class SessionExpiredDialogViewControllerTests: XCTestCase {
func testDialogUsesForcedPresentationAndAndroidCopy() throws {
let dialog = SessionExpiredDialogViewController(onConfirm: {})
dialog.loadViewIfNeeded()
XCTAssertEqual(dialog.modalPresentationStyle, .overFullScreen)
XCTAssertEqual(dialog.modalTransitionStyle, .crossDissolve)
XCTAssertTrue(dialog.isModalInPresentation)
XCTAssertTrue(dialog.view.accessibilityViewIsModal)
let labels = dialog.view.allSubviews(of: UILabel.self)
XCTAssertTrue(labels.contains { $0.text == "登录提醒" })
XCTAssertTrue(labels.contains { $0.text == "当前用户已在其他设备登录,\n请重新登录" })
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
XCTAssertEqual(button.title(for: .normal), "确认并退出登录")
}
func testConfirmCallbackOnlyRunsOnce() throws {
var confirmationCount = 0
let dialog = SessionExpiredDialogViewController {
confirmationCount += 1
}
dialog.loadViewIfNeeded()
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
button.sendActions(for: .touchUpInside)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmationCount, 1)
XCTAssertFalse(button.isEnabled)
}
}
private extension UIView {
func allSubviews<View: UIView>(of type: View.Type) -> [View] {
subviews.flatMap { subview -> [View] in
let current = (subview as? View).map { [$0] } ?? []
return current + subview.allSubviews(of: type)
}
}
}

View File

@ -4,7 +4,6 @@
// //
import XCTest import XCTest
import UIKit
@testable import suixinkan @testable import suixinkan
@MainActor @MainActor
@ -76,208 +75,6 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
@MainActor @MainActor
/// ViewModel /// ViewModel
final class TravelAlbumDetailViewModelTests: XCTestCase { final class TravelAlbumDetailViewModelTests: XCTestCase {
func testAIEditResultDisablesDuringProcessingAndOverwritesPreviousResult() {
var state = TravelAlbumAIEditResultState()
XCTAssertTrue(state.canStartEditing)
XCTAssertFalse(state.hasEditedResult)
XCTAssertEqual(state.thumbnailStatus, .none)
state.startProcessing()
XCTAssertFalse(state.canStartEditing)
XCTAssertEqual(state.thumbnailStatus, .processing)
state.complete(presetID: "portrait")
XCTAssertTrue(state.canStartEditing)
XCTAssertEqual(state.appliedPresetID, "portrait")
XCTAssertEqual(state.displayMode, .edited)
XCTAssertEqual(state.thumbnailStatus, .edited)
state.selectDisplayMode(.original)
XCTAssertEqual(state.displayMode, .original)
state.startProcessing()
XCTAssertEqual(state.appliedPresetID, "portrait")
state.complete(presetID: "mist")
XCTAssertEqual(state.appliedPresetID, "mist")
XCTAssertEqual(state.displayMode, .edited)
}
func testAIEditFailureShowsFailureStatusAndKeepsPreviousResult() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait")
state.startProcessing()
state.failProcessing()
XCTAssertTrue(state.canStartEditing)
XCTAssertTrue(state.hasEditedResult)
XCTAssertEqual(state.appliedPresetID, "portrait")
XCTAssertEqual(state.thumbnailStatus, .failed)
state.startProcessing()
XCTAssertEqual(state.thumbnailStatus, .processing)
}
func testRestoreOriginalClearsEditedThumbnailStatus() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait")
state.startProcessing()
state.restoreOriginal()
XCTAssertTrue(state.canStartEditing)
XCTAssertFalse(state.hasEditedResult)
XCTAssertEqual(state.displayMode, .original)
XCTAssertEqual(state.thumbnailStatus, .none)
}
func testCoverResultUsesDistinctThumbnailStatusAndCanBeOverwritten() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait", coverTemplateID: "travel_memoir")
XCTAssertTrue(state.isCover)
XCTAssertEqual(state.appliedCoverTemplateID, "travel_memoir")
XCTAssertEqual(state.displayMode, .cover)
XCTAssertEqual(state.thumbnailStatus, .cover)
state.complete(presetID: "mist")
XCTAssertFalse(state.isCover)
XCTAssertNil(state.appliedCoverTemplateID)
XCTAssertEqual(state.thumbnailStatus, .edited)
}
func testAtmosphereResultCanBeSelectedAndIsClearedByNextSingleResult() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait", atmosphereOptionID: "atmosphere")
XCTAssertTrue(state.hasAtmosphereResult)
state.selectDisplayMode(.atmosphere)
XCTAssertEqual(state.displayMode, .atmosphere)
state.complete(presetID: "mist")
XCTAssertFalse(state.hasAtmosphereResult)
XCTAssertNil(state.appliedAtmosphereOptionID)
state.selectDisplayMode(.atmosphere)
XCTAssertEqual(state.displayMode, .edited)
}
func testAIEditResultStoreSharesAndOverwritesLatestImage() {
let materialID = 990_071
let store = TravelAlbumAIEditResultStore.shared
store.remove(materialID: materialID)
store.startProcessing(materialIDs: [materialID])
XCTAssertFalse(store.state(for: materialID).canStartEditing)
let firstImage = UIImage()
store.complete(materialID: materialID, presetID: "portrait", editedImage: firstImage)
XCTAssertTrue(store.editedImage(for: materialID) === firstImage)
let replacementImage = UIImage()
let atmosphereImage = UIImage()
let coverImage = UIImage()
store.startProcessing(materialIDs: [materialID])
store.complete(
materialID: materialID,
presetID: "mist",
atmosphereOptionID: "atmosphere",
coverTemplateID: "travel_memoir",
editedImage: replacementImage,
atmosphereImage: atmosphereImage,
coverImage: coverImage
)
XCTAssertEqual(store.state(for: materialID).appliedPresetID, "mist")
XCTAssertTrue(store.editedImage(for: materialID) === replacementImage)
XCTAssertTrue(store.atmosphereImage(for: materialID) === atmosphereImage)
XCTAssertTrue(store.coverImage(for: materialID) === coverImage)
store.remove(materialID: materialID)
}
func testSelectionToolbarReplacesUploadWithAIEditAndDelete() {
let normalState = TravelAlbumSelectionToolbarState(isSelectionMode: false, selectedCount: 0)
XCTAssertTrue(normalState.showsUpload)
XCTAssertFalse(normalState.showsSelectionActions)
let emptySelectionState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 0)
XCTAssertFalse(emptySelectionState.showsUpload)
XCTAssertTrue(emptySelectionState.showsSelectionActions)
XCTAssertFalse(emptySelectionState.selectionActionsEnabled)
let selectedState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 2)
XCTAssertTrue(selectedState.showsSelectionActions)
XCTAssertTrue(selectedState.selectionActionsEnabled)
}
func testPhotoPreviewCanSwipeAndKeepsValidIndexAfterDelete() {
let materials = [
TravelAlbumMaterial(id: 1, fileName: "A.JPG"),
TravelAlbumMaterial(id: 2, fileName: "B.JPG"),
TravelAlbumMaterial(id: 3, fileName: "C.JPG"),
]
var state = TravelAlbumPhotoPreviewState(materials: materials, initialIndex: 1)
XCTAssertEqual(state.currentMaterial?.id, 2)
XCTAssertTrue(state.moveNext())
XCTAssertEqual(state.currentMaterial?.id, 3)
XCTAssertFalse(state.moveNext())
XCTAssertTrue(state.removeCurrent())
XCTAssertEqual(state.currentMaterial?.id, 2)
XCTAssertTrue(state.movePrevious())
XCTAssertEqual(state.currentMaterial?.id, 1)
}
func testAIEditPresetsContainScrollableChoices() {
let presets = TravelAlbumEditPreset.defaultOptions
XCTAssertEqual(presets.count, 8)
XCTAssertEqual(Set(presets.map(\.id)).count, presets.count)
XCTAssertEqual(presets.first?.effect, .original)
XCTAssertTrue(presets.contains { $0.effect == .mist })
}
func testAtmosphereOptionsContainScrollableSingleChoiceStyles() {
let options = TravelAlbumAtmosphereEditOption.defaultOptions
XCTAssertGreaterThanOrEqual(options.count, 4)
XCTAssertEqual(Set(options.map(\.id)).count, options.count)
XCTAssertTrue(options.allSatisfy { $0.effect != .original })
XCTAssertEqual(TravelAlbumAtmosphereEditOption.defaultOption, options.first)
}
func testAIEditSelectionCountsOptionalAtmosphereAndCoverStyles() {
let refinedPreset = TravelAlbumEditPreset.defaultOptions[1]
let baseSelection = TravelAlbumAIEditSelection(
refinedPreset: refinedPreset,
atmosphereOption: nil,
coverTemplate: nil
)
XCTAssertEqual(baseSelection.resultCountPerPhoto, 1)
XCTAssertEqual(baseSelection.selectedStyleCount, 1)
let fullSelection = TravelAlbumAIEditSelection(
refinedPreset: refinedPreset,
atmosphereOption: .defaultOption,
coverTemplate: TravelAlbumCoverTemplate.defaultOptions[0]
)
XCTAssertEqual(fullSelection.resultCountPerPhoto, 2)
XCTAssertEqual(fullSelection.selectedStyleCount, 3)
}
func testCoverTemplatesAppearFromFourSelectedPhotos() {
XCTAssertFalse(TravelAlbumCoverTemplate.shouldShow(for: 3))
XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 4))
XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 8))
}
func testCoverTemplatesAreFreeAndReadyForFutureQuotaPricing() {
let templates = TravelAlbumCoverTemplate.defaultOptions
XCTAssertEqual(templates.count, 4)
XCTAssertEqual(Set(templates.map(\.id)).count, templates.count)
XCTAssertEqual(templates.first?.id, "travel_memoir")
XCTAssertTrue(templates.allSatisfy(\.isFreeGift))
XCTAssertTrue(templates.allSatisfy { $0.quotaCost == 0 })
}
func testRefreshLoadsInfoAndMaterials() async { func testRefreshLoadsInfoAndMaterials() async {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情") api.infoResponse = TravelAlbum(id: 2, name: "详情")
@ -309,53 +106,6 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.materialRequests.last?.orderBy, 4) XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
} }
func testPurchasedTabUsesLocalDemoMaterialsWhenServerIsEmpty() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [TravelAlbumListResponse(total: 0, list: [])]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
XCTAssertTrue(viewModel.isUsingPurchasedDemoData)
XCTAssertEqual(viewModel.materials.count, 4)
XCTAssertTrue(viewModel.materials.allSatisfy(\.isPurchased))
XCTAssertTrue(viewModel.materials.allSatisfy { $0.id < 0 })
XCTAssertTrue(viewModel.materials.allSatisfy { $0.bundledImageAssetName != nil })
}
func testPurchasedTabKeepsRealServerMaterialsWhenAvailable() async {
let api = TravelAlbumMockAPI()
let serverMaterial = TravelAlbumMaterial(
id: 88,
fileName: "SERVER_0088.JPG",
fileUrl: "https://cdn.example.com/server-88.jpg",
isPurchased: true
)
api.materialListResponses = [
TravelAlbumListResponse(total: 1, list: [serverMaterial]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
XCTAssertFalse(viewModel.isUsingPurchasedDemoData)
XCTAssertEqual(viewModel.materials, [serverMaterial])
}
func testPurchasedDemoMaterialsFollowCurrentSortOption() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
await viewModel.setSortOption(.fileNameAsc, api: api)
XCTAssertEqual(viewModel.materials.map(\.fileName), viewModel.materials.map(\.fileName).sorted())
}
func testOnlyUnpurchasedMaterialCanBeSelected() { func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
@ -420,29 +170,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输") XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
} }
func testInitialTransferModeOverridesPersistedModeAndBecomesLatestSelection() {
let defaults = makeOTGTestDefaults()
defaults.set(TravelAlbumOTGTransferMode.liveUpload.rawValue, forKey: "travelAlbum.otg.transferMode")
let firstViewModel = WiredCameraTransferViewModel(
albumId: 9,
albumTitle: "相册",
headerPhone: "13800138000",
initialTransferMode: .postTransfer,
userDefaults: defaults
)
XCTAssertEqual(firstViewModel.transferMode, .postTransfer)
XCTAssertEqual(firstViewModel.transferModeOption, "拍后传输")
let secondViewModel = WiredCameraTransferViewModel(
albumId: 18,
albumTitle: "另一个相册",
headerPhone: "13800138000",
userDefaults: defaults
)
XCTAssertEqual(secondViewModel.transferMode, .postTransfer)
}
func testConnectedStatusIncludesCameraModelName() { func testConnectedStatusIncludesCameraModelName() {
let context = makeOTGTestContext() let context = makeOTGTestContext()
let viewModel = makeWiredViewModel( let viewModel = makeWiredViewModel(
@ -483,31 +210,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(groups.first?.slots.last?.photoCount, 2) XCTAssertEqual(groups.first?.slots.last?.photoCount, 2)
} }
func testPhotoPreviewContextPreservesListOrderAndStartsAtTappedPhoto() {
let photos = [
makeOTGPhotoItem(
id: "a",
capturedAt: "2026-07-08 12:30:00",
thumbnailURL: URL(fileURLWithPath: "/tmp/a.jpg")
),
makeOTGPhotoItem(
id: "unavailable",
capturedAt: "2026-07-08 12:20:00"
),
makeOTGPhotoItem(
id: "c",
capturedAt: "2026-07-08 12:10:00",
thumbnailURL: URL(string: "https://cdn.example.com/c.jpg")
),
]
let context = photos.previewContext(startingWith: "c")
XCTAssertEqual(context?.photos.map(\.id), ["a", "c"])
XCTAssertEqual(context?.initialIndex, 1)
XCTAssertNil(photos.previewContext(startingWith: "unavailable"))
}
func testSelectedTimeSlotFallsBackWhenTabChanges() throws { func testSelectedTimeSlotFallsBackWhenTabChanges() throws {
let context = makeOTGTestContext() let context = makeOTGTestContext()
context.store.save([ context.store.save([
@ -1039,14 +741,13 @@ private extension TravelAlbumOTGPhotoRecord {
private func makeOTGPhotoItem( private func makeOTGPhotoItem(
id: String, id: String,
capturedAt: String, capturedAt: String,
status: TravelAlbumOTGUploadStatus = .pending, status: TravelAlbumOTGUploadStatus = .pending
thumbnailURL: URL? = nil
) -> TravelAlbumOTGPhotoItem { ) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem( TravelAlbumOTGPhotoItem(
id: id, id: id,
sourceId: id, sourceId: id,
fileName: "\(id).JPG", fileName: "\(id).JPG",
thumbnailURL: thumbnailURL, thumbnailURL: nil,
capturedAt: capturedAt, capturedAt: capturedAt,
fileSizeText: "1 KB", fileSizeText: "1 KB",
fileSizeBytes: 1024, fileSizeBytes: 1024,

View File

@ -15,15 +15,6 @@ final class WiredTransferSettingChipButtonTests: XCTestCase {
XCTAssertEqual(button.accessibilityLabel, "AI修图") XCTAssertEqual(button.accessibilityLabel, "AI修图")
} }
@MainActor
func testAppliedValueKeepsPrimaryTextColorWhenInteractionIsDisabled() {
let button = WiredTransferSettingChipButton()
button.apply(title: "JPG")
button.isUserInteractionEnabled = false
XCTAssertEqual(button.displayedTitleColor, AppColor.textPrimary)
}
@MainActor @MainActor
func testShowsChevronDefaultsToFalse() { func testShowsChevronDefaultsToFalse() {
let button = WiredTransferSettingChipButton() let button = WiredTransferSettingChipButton()