完善实名认证、钱包与打卡点模块 UI 与业务逻辑。

对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 16:28:10 +08:00
parent 07b2b9d459
commit 5138c1c11a
63 changed files with 3101 additions and 745 deletions

View File

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

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_delete.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.208 3.002H11.992C11.98 2.918 11.953 2.834 11.913 2.751L11.367 1.63C11.183 1.251 10.742 1.004 10.253 1.004H5.753C5.264 1.004 4.824 1.251 4.639 1.63L4.092 2.751C4.052 2.835 4.025 2.92 4.011 3.002H1.771C1.341 3.002 .989 3.354 .989 3.784V4.215C.989 4.645 1.341 4.996 1.771 4.996H2.986V14.212C2.986 14.641 3.338 14.993 3.767 14.993H12.199C12.628 14.993 12.98 14.641 12.98 14.212V4.995H14.21C14.639 4.995 14.991 4.643 14.991 4.213V3.782C14.989 3.354 14.638 3.002 14.208 3.002ZM6.031 12.487C6.031 12.766 5.803 12.995 5.524 12.995C5.244 12.995 5.016 12.766 5.016 12.487V6.504C5.016 6.224 5.244 5.996 5.524 5.996C5.803 5.996 6.031 6.224 6.031 6.504V12.487ZM8.546 12.487C8.546 12.766 8.317 12.995 8.038 12.995C7.758 12.995 7.53 12.766 7.53 12.487V6.504C7.53 6.224 7.758 5.996 8.038 5.996C8.317 5.996 8.546 6.224 8.546 6.504V12.487ZM11.049 12.487C11.049 12.766 10.821 12.995 10.541 12.995C10.261 12.995 10.033 12.766 10.033 12.487V6.504C10.033 6.224 10.261 5.996 10.541 5.996C10.821 5.996 11.049 6.224 11.049 6.504V12.487Z" fill="#EF4444"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "punch_point_dropdown.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M7 10L12 15L17 10H7Z" fill="#4B5563"/>
</svg>

After

Width:  |  Height:  |  Size: 141 B

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "punch_point_image_add.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 13H13V19H11V13H5V11H11V5H13V11H19V13Z" fill="#B6BECA"/>
</svg>

After

Width:  |  Height:  |  Size: 162 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_image_delete.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="black" fill-opacity=".5"/><path d="M8 8L16 16M16 8L8 16" stroke="white" stroke-width="1.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_location.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.625 13.695C9.094 13.135 9.667 12.38 10.344 11.431C11.021 10.498 11.628 9.531 12.167 8.53C12.688 7.529 12.965 6.647 13 5.885C12.965 4.494 12.479 3.341 11.542 2.425C10.604 1.508 9.424 1.034 8 1C6.576 1.034 5.396 1.508 4.458 2.425C3.521 3.341 3.035 4.494 3 5.885C3.035 6.647 3.312 7.529 3.833 8.53C4.372 9.531 4.979 10.498 5.656 11.431C6.333 12.38 6.906 13.135 7.375 13.695C7.548 13.898 7.757 14 8 14C8.243 14 8.452 13.898 8.625 13.695ZM8 4.256C8.625 4.273 9.102 4.544 9.432 5.07C9.745 5.613 9.745 6.156 9.432 6.699C9.102 7.225 8.625 7.496 8 7.513C7.375 7.496 6.898 7.225 6.568 6.699C6.255 6.156 6.255 5.613 6.568 5.07C6.898 4.544 7.375 4.273 8 4.256Z" fill="#4B5563"/></svg>

After

Width:  |  Height:  |  Size: 780 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_map_location.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="7" stroke="#0073FF" stroke-width="1.25"/><circle cx="12" cy="12" r="4" fill="#0073FF"/><path d="M12 3V5M12 19V21M3 12H5M19 12H21" stroke="#0073FF" stroke-width="1.25" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_marker.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 1C6.477 1 2 5.477 2 11C2 17.2 8.67 22.2 11.1 23.73C11.65 24.08 12.35 24.08 12.9 23.73C15.33 22.2 22 17.2 22 11C22 5.477 17.523 1 12 1Z" fill="#FF4D4F"/><circle cx="12" cy="10" r="3" fill="#111111"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_qr.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.692 10.692H12.846V8.538H15V11.769H12.846V12.846H10.692V11.769H8.538V8.538H10.692V10.692ZM1 8.538H7.462V15H1V8.538ZM3.154 10.692V12.846H5.308V10.692H3.154ZM1 1H7.462V7.462H1V1ZM3.154 3.154V5.308H5.308V3.154H3.154ZM8.538 1H15V7.462H8.538V1ZM10.692 3.154V5.308H12.846V3.154H10.692ZM12.846 12.846H15V15H12.846V12.846ZM8.538 12.846H10.692V15H8.538V12.846Z" fill="#4B5563"/></svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_zoom_in.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="4" y="10.5" width="16" height="3" rx="1.5" fill="#0073FF"/><rect x="10.5" y="4" width="3" height="16" rx="1.5" fill="#0073FF"/></svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "punch_point_zoom_out.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="4.5" y="10.5" width="16" height="3" rx="1.5" fill="#0073FF"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View File

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

View File

@ -0,0 +1,7 @@
{
"images" : [
{ "filename" : "ic_accept_orders.svg", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "template" }
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#0073FF" d="M8 15c1.276-.018 2.443-.328 3.5-.93 1.057-.619 1.914-1.475 2.57-2.57.62-1.112.93-2.279.93-3.5s-.31-2.388-.93-3.5c-.656-1.094-1.513-1.951-2.57-2.57C10.443 1.328 9.276 1.018 8 1 6.724 1.018 5.557 1.328 4.5 1.93 3.443 2.549 2.586 3.406 1.93 4.5 1.31 5.612 1 6.779 1 8s.31 2.388.93 3.5c.656 1.095 1.513 1.951 2.57 2.57 1.057.602 2.224.912 3.5.93Zm3.09-8.285-3.5 3.5c-.31.255-.62.255-.93 0l-1.75-1.75c-.255-.31-.255-.62 0-.93.31-.255.62-.255.93 0l1.285 1.285 3.035-3.035c.31-.255.62-.255.93 0 .255.31.255.62 0 .93Z"/>
</svg>

After

Width:  |  Height:  |  Size: 630 B

View File

@ -0,0 +1,7 @@
{
"images" : [
{ "filename" : "ic_area_authority.svg", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "template" }
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#0073FF" d="M11.333 4.33c-.018.518-.203 1.119-.555 1.804-.352.685-.759 1.351-1.222 1.998-.445.629-.825 1.138-1.139 1.527a.536.536 0 0 1-.417.194.536.536 0 0 1-.417-.194 28.23 28.23 0 0 1-1.139-1.527c-.463-.647-.87-1.313-1.222-1.998-.352-.685-.537-1.286-.555-1.804.018-.943.333-1.729.972-2.359C6.278 1.342 7.065 1.019 8 1c.935.019 1.722.342 2.361.971.639.63.954 1.416.972 2.359Zm.223 2.221.277-.556c0-.018 0-.036.028-.055l3.222-1.332c.241-.093.444-.075.611.055.185.13.287.315.306.555v7.521c-.019.296-.157.5-.417.611l-4.027 1.61V6.551ZM3.833 4.83c.074.407.185.795.334 1.165l.277.556v6.993L.917 14.96c-.241.074-.444.046-.611-.084C.121 14.766.019 14.581 0 14.321V6.828c.019-.296.157-.509.417-.638L3.833 4.83Zm5.278 5.384c.426-.518.945-1.23 1.556-2.137v6.91l-5.334-1.526V8.077c.611.907 1.13 1.619 1.556 2.137.315.351.685.527 1.111.527.426 0 .796-.176 1.111-.527ZM8 5.218c.426-.018.75-.203.972-.555.185-.37.185-.74 0-1.11C8.75 3.201 8.426 3.017 8 2.998c-.426.019-.75.203-.972.555-.185.37-.185.74 0 1.11.222.352.546.537.972.555Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,7 @@
{
"images" : [
{ "filename" : "ic_checked.svg", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<path fill="#0073FF" d="M9 1a8 8 0 1 0 0 16A8 8 0 0 0 9 1Zm-.603 11.016L4.778 8.698l.603-.301 2.812 1.969 4.124-4.382h.905l-4.825 6.032Z"/>
</svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@ -0,0 +1,7 @@
{
"images" : [
{ "filename" : "ic_marketing.svg", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "template" }
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#0073FF" d="M3.6 2c.75.017 1.325.356 1.725 1.016.367.677.367 1.354 0 2.031-.4.66-.975.99-1.725 1.016-.75-.026-1.325-.356-1.725-1.016-.367-.677-.367-1.354 0-2.031C2.275 2.356 2.85 2.017 3.6 2Zm9.2 0c.75.017 1.325.356 1.725 1.016.367.677.367 1.354 0 2.031-.4.66-.975.99-1.725 1.016-.75-.026-1.325-.356-1.725-1.016-.367-.677-.367-1.354 0-2.031C11.475 2.356 12.05 2.017 12.8 2ZM0 9.592c.017-.779.275-1.422.775-1.93.5-.508 1.133-.77 1.9-.787h1.05c.4 0 .775.085 1.125.254a3.96 3.96 0 0 0-.05.559c.033.998.392 1.81 1.075 2.437H.525C.208 10.091.033 9.913 0 9.592Zm10.125.533c.683-.627 1.042-1.439 1.075-2.437 0-.187-.017-.373-.05-.559.35-.169.725-.254 1.125-.254h1.05c.767.017 1.4.279 1.9.787.5.508.758 1.151.775 1.93-.033.321-.208.499-.525.533h-5.35ZM5.6 7.688c0-.44.108-.847.325-1.219.217-.373.508-.669.875-.889A2.36 2.36 0 0 1 8 5.25c.417 0 .817.11 1.2.33.367.22.658.516.875.889.217.372.325.779.325 1.219 0 .44-.108.847-.325 1.218-.217.373-.508.669-.875.889-.383.22-.783.33-1.2.33-.417 0-.817-.11-1.2-.33a2.445 2.445 0 0 1-.875-.889A2.38 2.38 0 0 1 5.6 7.688ZM3.2 14.314c.017-.947.342-1.742.975-2.386.633-.643 1.417-.965 2.35-.99h2.95c.933.025 1.717.347 2.35.99.633.644.958 1.439.975 2.386-.033.423-.258.652-.675.686h-8.25c-.417-.034-.642-.263-.675-.686Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,7 @@
{
"images" : [
{ "filename" : "ic_unchecked.svg", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<circle cx="9" cy="9" r="7.5" fill="none" stroke="#0073FF" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 171 B

View File

@ -0,0 +1,5 @@
{
"images" : [{ "filename" : "home_menu_punch_point.svg", "idiom" : "universal" }],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "preserves-vector-representation" : true }
}

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.477 2 2 6.477 2 12C2 17.523 6.477 22 12 22C17.523 22 22 17.523 22 12C22 6.477 17.523 2 12 2ZM16.91 9.79L11.593 15.107C11.318 15.4 10.848 15.397 10.578 15.127L7.597 12.146C7.324 11.873 7.324 11.426 7.597 11.152C7.871 10.879 8.318 10.879 8.592 11.152L11.076 13.636L15.916 8.795C16.189 8.522 16.637 8.522 16.91 8.795C17.184 9.069 17.184 9.516 16.91 9.79Z" fill="#0073FF"/></svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "home_menu_wallet.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22,19.924 Q22,20.357 21.857,20.734 Q21.714,21.11 21.449,21.395 Q21.185,21.681 20.833,21.84 Q20.48,22 20.062,22 L4.07,22 Q3.652,22 3.278,21.84 Q2.903,21.681 2.617,21.395 Q2.33,21.11 2.165,20.734 Q2,20.357 2,19.924 L2,8.539 Q2,7.672 2.584,7.067 Q3.167,6.463 4.004,6.463 L19.996,6.463 Q20.833,6.463 21.416,7.067 Q22,7.672 22,8.539 L22,11.642 L17,11.642 Q16.163,11.642 15.579,12.235 Q14.996,12.828 14.996,13.695 Q15.018,14.288 15.238,14.745 Q15.414,15.133 15.822,15.452 Q16.229,15.771 17,15.771 L22,15.771 L22,19.924 Z M19.004,5.413 L9.004,5.413 Q10.194,4.775 11.251,4.181 Q12.176,3.679 13.079,3.177 Q13.982,2.676 14.489,2.402 Q15.26,1.968 15.866,2.003 Q16.471,2.037 16.89,2.219 Q17.374,2.47 17.727,2.881 L19.004,5.413 Z M16.009,13.695 Q16.009,13.262 16.295,12.965 Q16.581,12.669 17,12.669 Q17.419,12.669 17.705,12.965 Q17.991,13.262 17.991,13.695 Q17.991,14.129 17.705,14.437 Q17.419,14.745 17,14.745 Q16.581,14.745 16.295,14.437 Q16.009,14.129 16.009,13.695 Z" fill="#0073FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -22,6 +22,16 @@ enum AppAgreementURLs {
APIEnvironment.current.baseURL.appending(path: "h5/app/user-agreement")
}
/// 使 H5
nonisolated static var walletUserNotice: URL {
APIEnvironment.current.baseURL.appending(path: "h5/app/wallet-user-notice")
}
/// 使 H5
nonisolated static var walletPrivacyPolicy: URL {
APIEnvironment.current.baseURL.appending(path: "h5/app/wallet-privacy")
}
/// App H5
nonisolated static var appDownload: URL {
APIEnvironment.current.baseURL.appending(path: "h5/app/download")

View File

@ -32,7 +32,7 @@ final class OSSUploadService {
}
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress)
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name_auth", onProgress: onProgress)
}
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
@ -303,8 +303,10 @@ enum OSSUploadPolicy {
switch moduleType {
case "user_avatar":
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "real_name":
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "real_name_auth":
let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerId = userId.isEmpty ? String(scenicId) : userId
return "real_name_auth/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)"
case "bank_card":
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "task_upload":

View File

@ -11,7 +11,7 @@ enum HomeMenuCatalog {
/// Android `Constants.menuList`
static let allItems: [HomeMenuItem] = [
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"),
HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "creditcard.fill"),
HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "home_menu_wallet"),
HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud.fill"),
HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "home_menu_material"),
HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"),
@ -19,7 +19,7 @@ enum HomeMenuCatalog {
HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"),
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "gearshape.fill"),
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell.fill"),
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"),
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "home_menu_punch_point"),
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"),
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"),

View File

@ -139,8 +139,7 @@ final class ProfileAPI {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/real-name/sms-verify-code",
body: EmptyPayload()
path: "/api/yf-handset-app/photog/real-name/sms-verify-code"
)
)
}

View File

@ -5,59 +5,147 @@
import Foundation
/// ViewModel
///
enum RealNameAuthMode: Equatable {
case newApplication
case pending
case approved
case rejected
///
var isReadOnly: Bool {
self == .pending || self == .approved
}
///
var isEditable: Bool {
!isReadOnly
}
}
///
enum RealNameAuthStep: Int, Equatable {
case identity = 1
case verification = 2
}
/// ViewModel
final class RealNameAuthViewModel {
private(set) var info: RealNameInfo?
private(set) var mode: RealNameAuthMode
private(set) var currentStep: RealNameAuthStep = .identity
private(set) var realName = ""
private(set) var idCardNo = ""
private(set) var smsCode = ""
private(set) var startDate = Date()
private(set) var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
private(set) var startDate: Date?
private(set) var endDate: Date?
private(set) var isLongValid = false
private(set) var isAgreed = false
private(set) var frontUrl = ""
private(set) var backUrl = ""
private(set) var pendingFrontImageData: Data?
private(set) var pendingBackImageData: Data?
private(set) var frontPreviewImageData: Data?
private(set) var backPreviewImageData: Data?
private(set) var frontUploadProgress = 0
private(set) var backUploadProgress = 0
private(set) var isFrontUploading = false
private(set) var isBackUploading = false
private(set) var loading = false
private(set) var sendingCode = false
private(set) var submitting = false
private(set) var otpCountdown = 0
private(set) var statusMessage: String?
private var pendingFrontFileName: String?
private var pendingBackFileName: String?
private var countdownTask: Task<Void, Never>?
/// View
var onStateChange: (() -> Void)?
/// 使
init(info: RealNameInfo? = nil) {
self.info = info
mode = Self.mode(for: info)
apply(info)
}
deinit {
countdownTask?.cancel()
}
///
var isUploading: Bool {
isFrontUploading || isBackUploading
}
///
var canGoNextStep: Bool {
guard mode.isEditable, currentStep == .identity, !isUploading else { return false }
return identityValidationMessage == nil && isAgreed
}
///
var canSubmit: Bool {
guard mode.isEditable, currentStep == .verification, !submitting, !isUploading else { return false }
return validationMessage == nil
}
///
var canRequestCode: Bool {
mode.isEditable && currentStep == .verification && otpCountdown == 0 && !sendingCode
}
///
func updateRealName(_ value: String) {
guard mode.isEditable else { return }
realName = value
notifyStateChange()
}
/// X
func updateIdCardNo(_ value: String) {
idCardNo = value
guard mode.isEditable else { return }
idCardNo = String(value.filter { $0.isNumber || $0 == "X" || $0 == "x" }.prefix(18)).uppercased()
notifyStateChange()
}
/// 11
func updateSmsCode(_ value: String) {
smsCode = value
guard mode.isEditable else { return }
smsCode = String(value.filter(\.isNumber).prefix(11))
notifyStateChange()
}
///
func toggleAgreement() {
guard mode.isEditable else { return }
isAgreed.toggle()
notifyStateChange()
}
///
func updateLongValid(_ value: Bool) {
guard mode.isEditable else { return }
isLongValid = value
if value {
endDate = nil
}
notifyStateChange()
}
///
func updateStartDate(_ value: Date) {
startDate = value
guard mode.isEditable else { return }
startDate = Calendar.current.startOfDay(for: value)
notifyStateChange()
}
///
func updateEndDate(_ value: Date) {
endDate = value
guard mode.isEditable, !isLongValid else { return }
endDate = Calendar.current.startOfDay(for: value)
notifyStateChange()
}
///
func load(api: ProfileAPI) async throws {
guard !loading else { return }
loading = true
@ -67,11 +155,37 @@ final class RealNameAuthViewModel {
notifyStateChange()
}
let response = try await api.realNameInfo()
info = response.realNameInfo
mode = Self.mode(for: response.realNameInfo)
apply(response.realNameInfo)
}
///
@discardableResult
func goToVerificationStep() -> String? {
guard mode.isEditable else {
currentStep = .verification
notifyStateChange()
return nil
}
if let identityValidationMessage {
return identityValidationMessage
}
guard isAgreed else { return "请阅读并同意用户协议与隐私政策" }
currentStep = .verification
notifyStateChange()
return nil
}
///
func backToIdentityStep() {
currentStep = .identity
notifyStateChange()
}
/// 60
func sendCode(api: ProfileAPI) async throws {
guard !sendingCode else { return }
guard canRequestCode else { return }
sendingCode = true
notifyStateChange()
defer {
@ -79,31 +193,56 @@ final class RealNameAuthViewModel {
notifyStateChange()
}
try await api.realNameSmsVerifyCode()
statusMessage = "验证码发送"
statusMessage = "验证码发送成功"
startCountdown()
notifyStateChange()
}
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
///
func uploadIdentityImage(
data: Data,
side: RealNameImageSide,
uploader: any OSSUploadServing,
scenicId: Int,
timestamp: TimeInterval = Date().timeIntervalSince1970
) async throws {
guard mode.isEditable else { return }
guard !uploading(side) else { return }
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
switch side {
case .front:
pendingFrontImageData = processed.data
pendingFrontFileName = processed.fileName
frontUrl = ""
case .back:
pendingBackImageData = processed.data
pendingBackFileName = processed.fileName
backUrl = ""
setPreview(processed.data, side: side)
setUploading(true, progress: 0, side: side)
defer { setUploading(false, progress: progress(for: side), side: side) }
do {
let url = try await uploader.uploadRealNameImage(
data: processed.data,
fileName: processed.fileName,
scenicId: scenicId
) { [weak self] progress in
self?.setProgress(progress, side: side)
}
setURL(url, side: side)
setProgress(100, side: side)
} catch {
setPreview(nil, side: side)
setURL("", side: side)
throw error
}
}
///
func deleteIdentityImage(side: RealNameImageSide) {
guard mode.isEditable, !uploading(side) else { return }
setPreview(nil, side: side)
setURL("", side: side)
setProgress(0, side: side)
notifyStateChange()
}
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
if info?.verified == true {
statusMessage = "已完成实名认证"
notifyStateChange()
return
}
///
func submit(api: ProfileAPI) async throws {
guard mode.isEditable, !submitting else { return }
if let validationMessage {
throw RealNameValidationError.message(validationMessage)
}
@ -114,30 +253,45 @@ final class RealNameAuthViewModel {
submitting = false
notifyStateChange()
}
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
try await api.realNameSubmit(makeRequest())
statusMessage = "已提交审核"
try await api.realNameSubmit(try makeRequest())
statusMessage = "认证成功"
notifyStateChange()
try await load(api: api)
}
///
var validationMessage: String? {
if realName.trimmed.isEmpty { return "请输入真实姓名" }
if idCardNo.trimmed.isEmpty { return "输入身份证号" }
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
if info?.verified != true {
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
}
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
if let identityValidationMessage { return identityValidationMessage }
if !isAgreed { return "阅读并同意用户协议与隐私政策" }
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
return nil
}
/// Android DTO
func makeRequest() throws -> RealNameAuthRequest {
if let validationMessage {
throw RealNameValidationError.message(validationMessage)
}
guard let startDate else {
throw RealNameValidationError.message("请选择证件起始日期")
}
return RealNameAuthRequest(
realName: realName.trimmed,
idCardNo: Self.normalizedIDCardNumber(idCardNo),
smsVerifyCode: smsCode.trimmed,
startDate: Self.dateFormatter.string(from: startDate),
endDate: isLongValid ? "" : endDate.map(Self.dateFormatter.string(from:)) ?? "",
isLongValid: isLongValid ? 1 : 0,
frontUrl: frontUrl.trimmed,
backUrl: backUrl.trimmed
)
}
///
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
/// 18
nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool {
let number = normalizedIDCardNumber(value)
guard number.count == 18 else { return false }
@ -152,38 +306,112 @@ final class RealNameAuthViewModel {
return chars[17] == checkCodes[sum % 11]
}
private var identityValidationMessage: String? {
if realName.trimmed.isEmpty { return "请输入真实姓名" }
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
if backUrl.trimmed.isEmpty { return "请选择身份证国徽面图片" }
if frontUrl.trimmed.isEmpty { return "请选择身份证人像面图片" }
guard let startDate else { return "请选择证件起始日期" }
if !isLongValid {
guard let endDate else { return "请选择证件截止日期" }
if endDate < startDate { return "证件结束日期不能早于起始日期" }
}
return nil
}
private func apply(_ info: RealNameInfo?) {
self.info = info
guard let info else {
notifyStateChange()
isAgreed = false
return
}
realName = info.realName
idCardNo = info.idCardNo
frontUrl = info.frontUrl ?? ""
backUrl = info.backUrl ?? ""
pendingFrontImageData = nil
pendingBackImageData = nil
frontPreviewImageData = nil
backPreviewImageData = nil
isLongValid = info.isLongValid
startDate = Self.date(from: info.startDate) ?? startDate
endDate = Self.date(from: info.endDate) ?? endDate
startDate = Self.date(from: info.startDate)
endDate = info.isLongValid ? nil : Self.date(from: info.endDate)
isAgreed = true
}
private static func mode(for info: RealNameInfo?) -> RealNameAuthMode {
guard let info else { return .newApplication }
switch info.auditStatus {
case 2: return .approved
case 3: return .rejected
default: return .pending
}
}
private func uploading(_ side: RealNameImageSide) -> Bool {
side == .front ? isFrontUploading : isBackUploading
}
private func progress(for side: RealNameImageSide) -> Int {
side == .front ? frontUploadProgress : backUploadProgress
}
private func setPreview(_ data: Data?, side: RealNameImageSide) {
if side == .front {
frontPreviewImageData = data
} else {
backPreviewImageData = data
}
notifyStateChange()
}
private func makeRequest() -> RealNameAuthRequest {
RealNameAuthRequest(
realName: realName.trimmed,
idCardNo: Self.normalizedIDCardNumber(idCardNo),
smsVerifyCode: smsCode.trimmed,
startDate: Self.dateFormatter.string(from: startDate),
endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate),
isLongValid: isLongValid ? 1 : 0,
frontUrl: frontUrl.trimmed,
backUrl: backUrl.trimmed
)
private func setURL(_ url: String, side: RealNameImageSide) {
if side == .front {
frontUrl = url
} else {
backUrl = url
}
notifyStateChange()
}
private static let dateFormatter: DateFormatter = {
private func setUploading(_ uploading: Bool, progress: Int, side: RealNameImageSide) {
if side == .front {
isFrontUploading = uploading
frontUploadProgress = progress
} else {
isBackUploading = uploading
backUploadProgress = progress
}
notifyStateChange()
}
private func setProgress(_ progress: Int, side: RealNameImageSide) {
let normalized = max(0, min(100, progress))
if side == .front {
frontUploadProgress = normalized
} else {
backUploadProgress = normalized
}
notifyStateChange()
}
private func startCountdown(seconds: Int = 60) {
countdownTask?.cancel()
otpCountdown = seconds
countdownTask = Task { [weak self] in
guard seconds > 0 else { return }
for remaining in stride(from: seconds - 1, through: 0, by: -1) {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
guard let self else { return }
self.otpCountdown = remaining
self.notifyStateChange()
}
}
}
nonisolated private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
@ -196,33 +424,16 @@ final class RealNameAuthViewModel {
return dateFormatter.date(from: value)
}
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
if let pendingFrontImageData {
frontUrl = try await uploader.uploadRealNameImage(
data: pendingFrontImageData,
fileName: pendingFrontFileName ?? "real_name_front.jpg",
scenicId: scenicId
) { _ in }
self.pendingFrontImageData = nil
}
if let pendingBackImageData {
backUrl = try await uploader.uploadRealNameImage(
data: pendingBackImageData,
fileName: pendingBackFileName ?? "real_name_back.jpg",
scenicId: scenicId
) { _ in }
self.pendingBackImageData = nil
}
}
private func notifyStateChange() {
onStateChange?()
}
}
///
enum RealNameValidationError: LocalizedError, Equatable {
case message(String)
///
var errorDescription: String? {
switch self {
case .message(let message): message

View File

@ -390,6 +390,13 @@ struct PunchPointUploadDialogState: Sendable, Equatable {
let progress: Int
}
///
struct PunchPointStatusPresentation: Sendable, Equatable {
let title: String
let backgroundColor: UInt
let textColor: UInt
}
/// UI
enum PunchPointDisplayFormatter {
///
@ -401,32 +408,40 @@ enum PunchPointDisplayFormatter {
return "\(prefix)****\(suffix)"
}
/// /
static func statusColors(_ status: Int) -> (background: UInt, text: UInt) {
/// Android `OperatingTag`
static func operatingStatus(_ status: Int) -> PunchPointStatusPresentation {
switch status {
case 1:
return (0xF0FDF4, 0x09BE4F)
return PunchPointStatusPresentation(title: "营运中", backgroundColor: 0xF0FDF4, textColor: 0x22C55E)
case 2:
return (0xFFF0E2, 0xFF7B00)
case 3:
return (0xEFF6FF, 0x0073FF)
case 4:
return (0xFFE7E7, 0xEF4444)
return PunchPointStatusPresentation(title: "已暂停", backgroundColor: 0xF4F4F4, textColor: 0x4B5563)
default:
return (0xF4F4F4, 0x666666)
return PunchPointStatusPresentation(title: "未上线", backgroundColor: 0xF4F4F4, textColor: 0x4B5563)
}
}
/// Android `StatusLabelChip`
static func auditStatus(_ status: Int) -> PunchPointStatusPresentation {
switch status {
case 1, 2:
return PunchPointStatusPresentation(title: "已通过", backgroundColor: 0xF0FDF4, textColor: 0x22C55E)
case 4:
return PunchPointStatusPresentation(title: "已驳回", backgroundColor: 0xFFE7E7, textColor: 0xEF4444)
default:
return PunchPointStatusPresentation(title: "待审核", backgroundColor: 0xFFF0E2, textColor: 0xFF7B00)
}
}
}
private extension KeyedDecodingContainer {
func decodeFlexibleInt64(forKey key: Key) throws -> Int64? {
if let value = try decodeIfPresent(Int64.self, forKey: key) {
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return Int64(value)
}
if let value = try decodeIfPresent(String.self, forKey: key) {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return Int64(value)
}
return nil

View File

@ -29,11 +29,13 @@ final class PunchPointListViewModel {
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onShowQRCode: ((String) -> Void)?
var onBeginDeleteAnimation: ((Int64) -> Void)?
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
private let currentScenicIdProvider: () -> Int
private var pendingDeleteIDs = Set<Int64>()
/// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) {
@ -81,12 +83,11 @@ final class PunchPointListViewModel {
///
func delete(item: PunchPointItem, api: any PunchPointAPIProtocol) async {
guard !pendingDeleteIDs.contains(item.id) else { return }
do {
try await api.delete(id: item.id)
items.removeAll { $0.id == item.id }
totalCount = max(0, totalCount - 1)
canLoadMore = items.count < totalCount
notifyStateChange()
pendingDeleteIDs.insert(item.id)
onBeginDeleteAnimation?(item.id)
} catch is CancellationError {
return
} catch {
@ -94,6 +95,15 @@ final class PunchPointListViewModel {
}
}
///
func completeDeleteAnimation(id: Int64) {
guard pendingDeleteIDs.remove(id) != nil else { return }
items.removeAll { $0.id == id }
totalCount = max(0, totalCount - 1)
canLoadMore = items.count < totalCount
notifyStateChange()
}
private func load(reset: Bool, showLoading: Bool, api: any PunchPointAPIProtocol) async {
if reset {
currentPage = 1

View File

@ -145,8 +145,7 @@ final class WalletAPI: WalletPageServing {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms",
body: EmptyPayload()
path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms"
)
)
}
@ -169,8 +168,7 @@ final class WalletAPI: WalletPageServing {
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/point/overview",
queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))],
body: EmptyPayload()
queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))]
)
)
}

View File

@ -89,6 +89,7 @@ final class WalletViewModel {
private let staffIdProvider: () -> Int?
private let calendar: Calendar
private let nowProvider: () -> Date
private var withdrawPage = 1
private var withdrawCanLoadMore = true
private var transactionPage = 1
@ -101,10 +102,12 @@ final class WalletViewModel {
staffIdProvider: @escaping () -> Int? = {
Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines))
},
calendar: Calendar = Calendar(identifier: .gregorian)
calendar: Calendar = Calendar(identifier: .gregorian),
nowProvider: @escaping () -> Date = Date.init
) {
self.staffIdProvider = staffIdProvider
self.calendar = calendar
self.nowProvider = nowProvider
}
///
@ -223,8 +226,11 @@ final class WalletViewModel {
private func loadWithdraw(api: any WalletPageServing, refresh: Bool) async {
if withdrawLoading && !refresh { return }
let targetPage = refresh ? 1 : withdrawPage
let previousPage = withdrawPage
let previousCanLoadMore = withdrawCanLoadMore
withdrawLoading = true
withdrawRefreshing = refresh
errorMessage = nil
if refresh {
withdrawCanLoadMore = true
withdrawPage = 1
@ -238,8 +244,8 @@ final class WalletViewModel {
withdrawPage = response.item.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
withdrawRecords = []
withdrawCanLoadMore = false
withdrawPage = previousPage
withdrawCanLoadMore = previousCanLoadMore
}
errorMessage = error.localizedDescription
}
@ -252,9 +258,12 @@ final class WalletViewModel {
private func loadTransaction(api: any WalletPageServing, refresh: Bool) async {
if transactionLoading && !refresh { return }
let targetPage = refresh ? 1 : transactionPage
let previousPage = transactionPage
let previousCanLoadMore = transactionCanLoadMore
let range = dateRange(for: filter)
transactionLoading = true
transactionRefreshing = refresh
errorMessage = nil
if refresh {
transactionCanLoadMore = true
transactionPage = 1
@ -276,8 +285,8 @@ final class WalletViewModel {
transactionPage = response.list.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
transactionGroups = []
transactionCanLoadMore = false
transactionPage = previousPage
transactionCanLoadMore = previousCanLoadMore
}
errorMessage = error.localizedDescription
}
@ -287,7 +296,8 @@ final class WalletViewModel {
notifyStateChange()
}
private func dateRange(for filter: WalletFilter, now: Date = Date()) -> (start: String, end: String) {
private func dateRange(for filter: WalletFilter) -> (start: String, end: String) {
let now = nowProvider()
let end = calendar.startOfDay(for: now)
let start = calendar.date(byAdding: .day, value: -filter.dayOffset, to: end) ?? end
return (Self.dayFormatter.string(from: start), Self.dayFormatter.string(from: end))
@ -367,7 +377,7 @@ final class WithdrawViewModel {
///
func updateVerificationCode(_ value: String) {
verificationCode = value.filter(\.isNumber)
verificationCode = value
notifyStateChange()
}
@ -416,14 +426,17 @@ final class WithdrawViewModel {
///
var canSubmit: Bool {
validationMessage == nil && !submitting
withdrawInfo != nil &&
!amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!submitting
}
///
var validationMessage: String? {
guard let withdrawInfo else { return "提现信息加载中" }
guard !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入提现金额" }
guard let amountDecimal = Decimal(string: amount), amountDecimal > 0 else { return "请输入有效的金额" }
guard let amountDecimal = Decimal(string: amount) else { return "请输入有效的金额" }
let withdrawable = Decimal(string: withdrawInfo.amountWithdrawable) ?? 0
if amountDecimal > withdrawable { return "金额不能大于可提现金额" }
guard !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入验证码" }
@ -495,6 +508,7 @@ final class PointsRedemptionViewModel {
func refreshOverview(api: any WalletPageServing) async {
guard let staffId = staffIdProvider(), staffId > 0 else { return }
overviewLoading = true
errorMessage = nil
notifyStateChange()
do {
let response = try await api.pointOverview(staffId: staffId)
@ -527,7 +541,7 @@ final class PointsRedemptionViewModel {
return
}
let points = min(Int(digits) ?? 0, withdrawnPoints)
pointsInput = points > 0 ? String(points) : ""
pointsInput = String(points)
notifyStateChange()
}
@ -566,8 +580,11 @@ final class PointsRedemptionViewModel {
private func loadWithdrawList(api: any WalletPageServing, refresh: Bool) async {
if withdrawLoading && !refresh { return }
let targetPage = refresh ? 1 : withdrawPage
let previousPage = withdrawPage
let previousCanLoadMore = withdrawCanLoadMore
withdrawLoading = true
withdrawRefreshing = refresh
errorMessage = nil
if refresh {
withdrawCanLoadMore = true
withdrawPage = 1
@ -581,8 +598,8 @@ final class PointsRedemptionViewModel {
withdrawPage = response.list.isEmpty ? targetPage : targetPage + 1
} catch {
if refresh {
withdrawRecords = []
withdrawCanLoadMore = false
withdrawPage = previousPage
withdrawCanLoadMore = previousCanLoadMore
}
errorMessage = error.localizedDescription
}

View File

@ -0,0 +1,82 @@
//
// WalletTokens.swift
// suixinkan
//
import CoreGraphics
import UIKit
/// Android
enum WalletTokens {
// MARK: - Colors
static let pageBackground = UIColor(hex: 0xF5F6F8)
static let withdrawPageBackground = UIColor(hex: 0xF9FAFB)
static let summaryBackground = UIColor(hex: 0x1677FF)
static let primary = UIColor(hex: 0x0073FF)
static let contentBackground = UIColor.white
static let filterBackground = UIColor(hex: 0xF6F6F6)
static let inputBackground = UIColor(hex: 0xF4F4F4)
static let inputFocusedBackground = UIColor(hex: 0xF4F5F9)
static let border = UIColor(hex: 0xEEEEEE)
static let cardBorder = UIColor(hex: 0xF3F4F6)
static let progressTrack = UIColor(hex: 0xF2F4F8)
static let textPrimary = UIColor.black
static let text333 = UIColor(hex: 0x333333)
static let textSecondary = UIColor(hex: 0x4B5563)
static let textMuted = UIColor(hex: 0x7B8EAA)
static let textTertiary = UIColor(hex: 0x9CA3AF)
static let textDisabled = UIColor(hex: 0xB3B8C2)
static let tabNormal = UIColor(hex: 0x666666)
static let points = UIColor(hex: 0xFF7B00)
static let danger = UIColor(hex: 0xEF4444)
static let progressDanger = UIColor(hex: 0xFF4D4F)
static let success = UIColor(hex: 0x22C55E)
static let settlementSuccess = UIColor(hex: 0x16C26D)
static let warning = UIColor(hex: 0xFF8A00)
static let successBackground = UIColor(hex: 0xF0FDF4)
static let settlementSuccessBackground = UIColor(hex: 0xEBFFF5)
static let warningBackground = UIColor(hex: 0xFFF3E8)
static let dangerBackground = UIColor(hex: 0xFFE7E7)
static let settlementDangerBackground = UIColor(hex: 0xFFF1F0)
static let infoBackground = UIColor(hex: 0xE6F1FF)
static let pointsPendingBackground = UIColor(hex: 0xFFF0E2)
static let orderBackground = UIColor(hex: 0xEFF6FF)
static let neutralChipBackground = UIColor(hex: 0xF4F4F4)
// MARK: - Layout
static let screenInset: CGFloat = 16
static let cardPadding: CGFloat = 16
static let cardGap: CGFloat = 16
static let rowGap: CGFloat = 12
static let compactGap: CGFloat = 8
static let tinyGap: CGFloat = 4
static let summaryHorizontalPadding: CGFloat = 24
static let summaryRadius: CGFloat = 24
static let contentRadius: CGFloat = 16
static let cardRadius: CGFloat = 12
static let innerRadius: CGFloat = 8
static let chipRadius: CGFloat = 4
static let inputHeight: CGFloat = 46
static let filterFieldHeight: CGFloat = 56
static let filterFieldMaxWidth: CGFloat = 148
static let progressHeight: CGFloat = 6
static let bottomButtonRadius: CGFloat = 12
static let bottomButtonHeight: CGFloat = 46
// MARK: - Typography
static let displayFont = UIFont.systemFont(ofSize: 36, weight: .bold)
static let metricBoldFont = UIFont.systemFont(ofSize: 18, weight: .bold)
static let metricMediumFont = UIFont.systemFont(ofSize: 18, weight: .medium)
static let titleFont = UIFont.systemFont(ofSize: 18, weight: .medium)
static let subtitleFont = UIFont.systemFont(ofSize: 16, weight: .medium)
static let tabFont = UIFont.systemFont(ofSize: 16)
static let tabSelectedFont = UIFont.systemFont(ofSize: 16, weight: .semibold)
static let bodyFont = UIFont.systemFont(ofSize: 14)
static let bodyMediumFont = UIFont.systemFont(ofSize: 14, weight: .medium)
static let captionFont = UIFont.systemFont(ofSize: 12)
static let captionMediumFont = UIFont.systemFont(ofSize: 12, weight: .medium)
static let stepFont = UIFont.systemFont(ofSize: 11)
}

View File

@ -3,111 +3,5 @@
// suixinkan
//
import SnapKit
import UIKit
///
final class RealNameAuthAuditViewController: BaseViewController {
private let info: RealNameInfo
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let statusBadge = ProfileStatusBadgeView()
private let rejectLabel = UILabel()
private let frontImageView = UIImageView()
private let backImageView = UIImageView()
init(info: RealNameInfo) {
self.info = info
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "认证状态"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
rejectLabel.numberOfLines = 0
rejectLabel.font = .systemFont(ofSize: 14)
rejectLabel.textColor = UIColor(hex: 0xEF4444)
[frontImageView, backImageView].forEach {
$0.contentMode = .scaleAspectFit
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
$0.layer.cornerRadius = 8
$0.clipsToBounds = true
$0.snp.makeConstraints { make in make.height.equalTo(160) }
}
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(makeTitle("审核状态"))
contentStack.addArrangedSubview(statusBadge)
if let reason = info.rejectReason, !reason.isEmpty, info.auditStatus == 3 {
rejectLabel.text = "驳回原因:\(reason)"
contentStack.addArrangedSubview(rejectLabel)
}
contentStack.addArrangedSubview(makeInfoRow("姓名", info.realName))
contentStack.addArrangedSubview(makeInfoRow("身份证号", maskID(info.idCardNo)))
contentStack.addArrangedSubview(makeTitle("身份证国徽面"))
contentStack.addArrangedSubview(backImageView)
contentStack.addArrangedSubview(makeTitle("身份证人像面"))
contentStack.addArrangedSubview(frontImageView)
applyStatus()
frontImageView.loadRemoteImage(urlString: info.frontUrl)
backImageView.loadRemoteImage(urlString: info.backUrl)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
}
private func applyStatus() {
switch info.auditStatus {
case 2:
statusBadge.apply(style: .auditApproved, text: "已实名认证")
case 3:
statusBadge.apply(style: .auditRejected, text: "审核不通过")
default:
statusBadge.apply(style: .auditPending, text: info.auditStatusText ?? "审核中")
}
}
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 15, weight: .semibold)
return label
}
private func makeInfoRow(_ title: String, _ value: String) -> UILabel {
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.text666
label.text = "\(title)\(value)"
return label
}
private func maskID(_ value: String) -> String {
guard value.count > 8 else { return value }
let prefix = value.prefix(4)
let suffix = value.suffix(4)
return "\(prefix)**********\(suffix)"
}
}
///
typealias RealNameAuthAuditViewController = RealNameAuthViewController

File diff suppressed because it is too large Load Diff

View File

@ -15,9 +15,9 @@ final class PunchPointDetailViewController: BaseViewController {
private let mapView = PunchPointMapView()
private let zoomStack = UIStackView()
private let zoomInButton = PunchPointMapControlButton(symbol: "plus")
private let zoomOutButton = PunchPointMapControlButton(symbol: "minus")
private let locateButton = PunchPointMapControlButton(symbol: "location.fill")
private let zoomInButton = PunchPointMapControlButton(assetName: "punch_point_zoom_in")
private let zoomOutButton = PunchPointMapControlButton(assetName: "punch_point_zoom_out")
private let locateButton = PunchPointMapControlButton(assetName: "punch_point_map_location")
private let cardView = UIView()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
@ -102,11 +102,11 @@ final class PunchPointDetailViewController: BaseViewController {
}
zoomStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(cardView.snp.top).offset(-16)
make.bottom.equalTo(locateButton.snp.top).offset(-12)
}
locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(zoomStack.snp.top).offset(-12)
make.bottom.equalTo(cardView.snp.top).offset(-16)
make.size.equalTo(40)
}
zoomInButton.snp.makeConstraints { make in
@ -178,8 +178,11 @@ final class PunchPointDetailViewController: BaseViewController {
bottomBar.isHidden = false
if let region = detail.region {
let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot)
mapView.updateMarker(coordinate: coordinate)
mapView.setCenter(coordinate, zoomLevel: 16)
mapView.updatePunchPointMarker(
coordinate: coordinate,
imageURL: detail.guideImages.first,
fitWithUserLocation: true
)
}
rebuildContent(detail)
}
@ -188,7 +191,11 @@ final class PunchPointDetailViewController: BaseViewController {
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name))
let coordinateText = detail.region.map { "\($0.lat),\($0.lot)" } ?? "--"
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点坐标", value: coordinateText))
contentStack.addArrangedSubview(PunchPointReadOnlyField(
title: "*打卡点坐标",
value: coordinateText,
showsLocationIcon: true
))
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点地址", value: detail.displayAddress.nonEmptyTrimmed ?? "--"))
if let description = detail.description?.nonEmptyTrimmed {
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true))
@ -241,7 +248,9 @@ final class PunchPointDetailViewController: BaseViewController {
imageView.isUserInteractionEnabled = true
imageView.tag = index
if let url = URL(string: urlText) {
imageView.kf.setImage(with: url)
imageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo"))
} else {
imageView.image = UIImage(named: "SplashLogo")
}
imageView.snp.makeConstraints { make in
make.width.equalTo(120)
@ -266,7 +275,7 @@ final class PunchPointDetailViewController: BaseViewController {
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746)
let chip = PunchPointStatusChip()
chip.apply(text: detail.statusLabel.nonEmptyTrimmed ?? "--", status: detail.status)
chip.apply(PunchPointDisplayFormatter.auditStatus(detail.status), compact: false)
row.addArrangedSubview(label)
row.addArrangedSubview(chip)
row.isLayoutMarginsRelativeArrangement = true
@ -301,7 +310,9 @@ final class PunchPointDetailViewController: BaseViewController {
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 16
if let url = URL(string: qrCode) {
imageView.kf.setImage(with: url)
imageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo"))
} else {
imageView.image = UIImage(named: "SplashLogo")
}
imageView.snp.makeConstraints { make in
make.size.equalTo(180)
@ -331,6 +342,11 @@ final class PunchPointDetailViewController: BaseViewController {
}
Task {
do {
let authorization = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard authorization == .authorized || authorization == .limited else {
await MainActor.run { self.presentPhotoPermissionAlert() }
return
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") }
try await PHPhotoLibrary.shared().performChanges {
@ -343,6 +359,21 @@ final class PunchPointDetailViewController: BaseViewController {
}
}
@MainActor
private func presentPhotoPermissionAlert() {
let alert = UIAlertController(
title: "无法保存二维码",
message: "相册权限未授予,请在设置中开启",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
})
present(alert, animated: true)
}
private func requiredTitle(_ text: String) -> NSAttributedString {
let mutable = NSMutableAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
@ -368,7 +399,35 @@ final class PunchPointDetailViewController: BaseViewController {
}
@objc private func locateTapped() {
mapView.centerOnUserLocation()
Task {
do {
let snapshot = try await LocationProvider.shared.requestSnapshot()
let coordinate = CLLocationCoordinate2D(
latitude: snapshot.latitude,
longitude: snapshot.longitude
)
await MainActor.run { self.mapView.setCenter(coordinate, zoomLevel: 16) }
} catch LocationProviderError.permissionDenied {
await MainActor.run { self.presentLocationPermissionAlert() }
} catch {
await MainActor.run { self.showToast("定位失败") }
}
}
}
@MainActor
private func presentLocationPermissionAlert() {
let alert = UIAlertController(
title: "无法定位",
message: "定位权限未授予,请在设置中开启",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
})
present(alert, animated: true)
}
@objc private func retryTapped() {
@ -393,7 +452,10 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
private let mapView: MAMapView
private var markerAnnotation: MAPointAnnotation?
private var punchPointAnnotation: MAPointAnnotation?
private var selectedAnnotation: MAPointAnnotation?
private var punchPointImageURL: URL?
private var fitWithUserLocation = false
override init(frame: CGRect) {
_ = AMapBootstrap.configureIfNeeded()
@ -403,6 +465,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
mapView.showsUserLocation = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.touchPOIEnabled = true
mapView.zoomLevel = 15
addSubview(mapView)
mapView.snp.makeConstraints { make in
@ -415,17 +478,45 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
fatalError("init(coder:) has not been implemented")
}
///
/// 使 Android 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 = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "punch_point_original"
punchPointAnnotation = annotation
mapView.addAnnotation(annotation)
if fitWithUserLocation {
fitPunchPointAndUserLocation()
}
}
/// Marker
func updateMarker(coordinate: CLLocationCoordinate2D?) {
if let markerAnnotation {
mapView.removeAnnotation(markerAnnotation)
self.markerAnnotation = nil
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 = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "打卡点位置"
markerAnnotation = annotation
annotation.title = "punch_point_selected"
selectedAnnotation = annotation
mapView.addAnnotation(annotation)
}
@ -453,21 +544,92 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
setCenter(coordinate)
}
/// Android
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
var annotations: [MAAnnotation] = []
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
if let selectedAnnotation { annotations.append(selectedAnnotation) }
if let userLocation = mapView.userLocation,
let location = userLocation.location,
CLLocationCoordinate2DIsValid(location.coordinate) {
annotations.append(userLocation)
}
guard !annotations.isEmpty else { return }
if annotations.count == 1, let annotation = annotations.first {
setCenter(annotation.coordinate, zoomLevel: 16)
} else {
mapView.showAnnotations(annotations, edgePadding: edgePadding, animated: true)
}
}
private func fitPunchPointAndUserLocation() {
guard fitWithUserLocation else { return }
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
}
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
if annotation is MAUserLocation { return nil }
let isOriginal = annotation === punchPointAnnotation
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
?? MAAnnotationView(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: MAMapView!, didUpdate userLocation: MAUserLocation!, updatingLocation: Bool) {
guard updatingLocation, userLocation.location != nil else { return }
fitPunchPointAndUserLocation()
}
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))
}
}
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
onMapTap?(coordinate)
}
func mapView(_ mapView: MAMapView!, didTouchPois pois: [Any]!) {
guard let poi = pois.first as? MATouchPoi else { return }
onMapTap?(poi.coordinate)
}
}
///
///
final class PunchPointMapControlButton: UIButton {
init(symbol: String) {
init(assetName: String) {
super.init(frame: .zero)
setImage(UIImage(systemName: symbol), for: .normal)
tintColor = AppColor.textPrimary
setImage(UIImage(named: assetName), for: .normal)
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
imageView?.contentMode = .scaleAspectFit
accessibilityLabel = switch assetName {
case "punch_point_zoom_in": "放大地图"
case "punch_point_zoom_out": "缩小地图"
default: "定位当前位置"
}
}
@available(*, unavailable)
@ -479,20 +641,46 @@ final class PunchPointMapControlButton: UIButton {
///
///
final class PunchPointReadOnlyField: UIView {
init(title: String, value: String, multiline: Bool = false) {
init(title: String, value: String, multiline: Bool = false, showsLocationIcon: Bool = false) {
super.init(frame: .zero)
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
let titleLabel = UILabel()
titleLabel.attributedText = Self.requiredTitle(title)
let valueContainer = UIView()
valueContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
valueContainer.layer.cornerRadius = 8
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textColor = AppColor.textPrimary
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.numberOfLines = multiline ? 0 : 2
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(valueLabel)
stack.addArrangedSubview(valueContainer)
valueContainer.addSubview(valueLabel)
if showsLocationIcon {
let icon = UIImageView(image: UIImage(named: "punch_point_location"))
icon.contentMode = .scaleAspectFit
valueContainer.addSubview(icon)
icon.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
valueLabel.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(12)
make.leading.equalToSuperview().offset(12)
make.trailing.lessThanOrEqualTo(icon.snp.leading).offset(-8)
}
} else {
valueLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12))
}
}
valueContainer.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(multiline ? 72 : 46)
}
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()

View File

@ -17,9 +17,9 @@ final class PunchPointFormViewController: BaseViewController {
private let mapView = PunchPointMapView()
private let zoomStack = UIStackView()
private let zoomInButton = PunchPointMapControlButton(symbol: "plus")
private let zoomOutButton = PunchPointMapControlButton(symbol: "minus")
private let locateButton = PunchPointMapControlButton(symbol: "location.fill")
private let zoomInButton = PunchPointMapControlButton(assetName: "punch_point_zoom_in")
private let zoomOutButton = PunchPointMapControlButton(assetName: "punch_point_zoom_out")
private let locateButton = PunchPointMapControlButton(assetName: "punch_point_map_location")
private let cardView = UIView()
private let scrollView = UIScrollView()
private let formStack = UIStackView()
@ -105,11 +105,11 @@ final class PunchPointFormViewController: BaseViewController {
}
zoomStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(cardView.snp.top).offset(-16)
make.bottom.equalTo(locateButton.snp.top).offset(-12)
}
locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(zoomStack.snp.top).offset(-12)
make.bottom.equalTo(cardView.snp.top).offset(-16)
make.size.equalTo(40)
}
zoomInButton.snp.makeConstraints { make in
@ -148,12 +148,11 @@ final class PunchPointFormViewController: BaseViewController {
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
Task { @MainActor in self?.handleMessage(message) }
}
viewModel.onCoordinateChange = { [weak self] coordinate in
Task { @MainActor in
self?.mapView.updateMarker(coordinate: coordinate)
self?.mapView.setCenter(coordinate, zoomLevel: 17)
self?.applyMapCoordinate(coordinate)
}
}
viewModel.onSubmitSuccess = { [weak self] in
@ -232,6 +231,39 @@ final class PunchPointFormViewController: BaseViewController {
return PunchPointImageState(data: data, fileName: fileName)
}
@MainActor
private func handleMessage(_ message: String) {
guard message.contains("定位权限未授予") else {
showToast(message)
return
}
let alert = UIAlertController(title: "无法定位", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
})
present(alert, animated: true)
}
@MainActor
private func applyMapCoordinate(_ coordinate: CLLocationCoordinate2D) {
if let initialDetail, let original = initialDetail.coordinate {
mapView.updatePunchPointMarker(
coordinate: original,
imageURL: initialDetail.guideImages.first
)
let isOriginal = abs(original.latitude - coordinate.latitude) < 0.000_001
&& abs(original.longitude - coordinate.longitude) < 0.000_001
mapView.updateSelectedMarker(coordinate: isOriginal ? nil : coordinate)
mapView.fitAllMarkers()
} else {
mapView.updatePunchPointMarker(coordinate: nil)
mapView.updateSelectedMarker(coordinate: coordinate)
mapView.setCenter(coordinate, zoomLevel: 17)
}
}
private func updateUploadDialog(_ state: PunchPointUploadDialogState?) {
guard let state else {
progressAlert?.dismiss(animated: true)
@ -329,13 +361,13 @@ private final class PunchPointTextFieldView: UIView {
super.init(frame: .zero)
titleLabel.attributedText = Self.requiredTitle(title)
textField.placeholder = placeholder
textField.font = .systemFont(ofSize: 14)
textField.font = .systemFont(ofSize: 15)
textField.textColor = AppColor.textPrimary
textField.backgroundColor = .white
textField.layer.cornerRadius = 8
textField.layer.borderColor = AppColor.border.cgColor
textField.layer.borderWidth = 1
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 46))
textField.leftViewMode = .always
addSubview(titleLabel)
addSubview(textField)
@ -345,7 +377,7 @@ private final class PunchPointTextFieldView: UIView {
textField.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(44)
make.height.equalTo(46)
}
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
}
@ -395,10 +427,11 @@ private final class PunchPointCoordinateFieldView: UIView {
container.layer.borderWidth = 1
container.layer.borderColor = AppColor.border.cgColor
valueLabel.text = placeholder
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.font = .systemFont(ofSize: 15)
valueLabel.textColor = UIColor(hex: 0x999999)
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
locateButton.tintColor = AppColor.textPrimary
locateButton.setImage(UIImage(named: "punch_point_location"), for: .normal)
locateButton.imageView?.contentMode = .scaleAspectFit
locateButton.accessibilityLabel = "定位打卡点坐标"
addSubview(titleLabel)
addSubview(container)
container.addSubview(valueLabel)
@ -409,7 +442,7 @@ private final class PunchPointCoordinateFieldView: UIView {
container.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(44)
make.height.equalTo(46)
}
valueLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
@ -457,7 +490,7 @@ private final class PunchPointTextViewFieldView: UIView, UITextViewDelegate {
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = UIColor(hex: 0x333333)
textView.font = .systemFont(ofSize: 14)
textView.font = .systemFont(ofSize: 15)
textView.textColor = AppColor.textPrimary
textView.layer.cornerRadius = 8
textView.layer.borderWidth = 1
@ -476,7 +509,7 @@ private final class PunchPointTextViewFieldView: UIView, UITextViewDelegate {
textView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(92)
make.height.equalTo(100)
}
placeholderLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
@ -575,7 +608,8 @@ private final class PunchPointImageGridView: UIView {
///
private final class PunchPointImageThumbView: UIControl {
private let imageView = UIImageView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let addIcon = UIImageView(image: UIImage(named: "punch_point_image_add"))
private let addLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let overlayLabel = UILabel()
var onAdd: (() -> Void)?
@ -586,26 +620,35 @@ private final class PunchPointImageThumbView: UIControl {
super.init(frame: frame)
layer.cornerRadius = 8
clipsToBounds = true
backgroundColor = .white
backgroundColor = UIColor(hex: 0xF4F4F4)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addIcon.tintColor = AppColor.primary
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
deleteButton.tintColor = .white
addLabel.text = "点击上传"
addLabel.font = .systemFont(ofSize: 12)
addLabel.textColor = UIColor(hex: 0xB6BECA)
addLabel.textAlignment = .center
deleteButton.setImage(UIImage(named: "punch_point_image_delete"), for: .normal)
deleteButton.accessibilityLabel = "删除图片"
overlayLabel.font = .systemFont(ofSize: 14, weight: .medium)
overlayLabel.textColor = .white
overlayLabel.textAlignment = .center
overlayLabel.backgroundColor = UIColor.black.withAlphaComponent(0.45)
addSubview(imageView)
addSubview(addIcon)
addSubview(addLabel)
addSubview(deleteButton)
addSubview(overlayLabel)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-8)
make.size.equalTo(24)
}
addLabel.snp.makeConstraints { make in
make.top.equalTo(addIcon.snp.bottom).offset(2)
make.centerX.equalToSuperview()
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
@ -624,18 +667,22 @@ private final class PunchPointImageThumbView: UIControl {
}
func applyAdd() {
accessibilityLabel = "点击上传图片"
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
imageView.image = nil
imageView.kf.cancelDownloadTask()
addIcon.isHidden = false
addLabel.isHidden = false
deleteButton.isHidden = true
overlayLabel.isHidden = true
}
func apply(item: PunchPointImageState) {
accessibilityLabel = item.errorMessage == nil ? "打卡点图片" : "图片上传失败,点击重试"
layer.borderWidth = 0
addIcon.isHidden = true
addLabel.isHidden = true
deleteButton.isHidden = item.isUploading
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)

View File

@ -15,7 +15,7 @@ final class PunchPointListViewController: BaseViewController {
private let filterContainer = UIView()
private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let filterChevronView = UIImageView(image: UIImage(named: "punch_point_dropdown"))
private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain)
@ -43,18 +43,22 @@ final class PunchPointListViewController: BaseViewController {
override func setupNavigationBar() {
title = "打卡点列表"
navigationController?.navigationBar.titleTextAttributes = [
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
.foregroundColor: UIColor(hex: 0x333333),
]
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.backgroundColor = UIColor(hex: 0xF4F4F4)
filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF5F5F5)
filterButton.layer.cornerRadius = 8
filterButton.accessibilityLabel = "筛选打卡点状态"
filterTitleLabel.text = PunchPointFilterType.all.title
filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = AppColor.textPrimary
filterChevronView.tintColor = AppColor.textSecondary
filterChevronView.contentMode = .scaleAspectFit
filterDropdownView.backgroundColor = .white
@ -66,7 +70,7 @@ final class PunchPointListViewController: BaseViewController {
filterDropdownView.isHidden = true
filterDropdownStack.axis = .vertical
tableView.backgroundColor = AppColor.pageBackground
tableView.backgroundColor = UIColor(hex: 0xF4F4F4)
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
@ -91,6 +95,7 @@ final class PunchPointListViewController: BaseViewController {
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
addButton.accessibilityLabel = "添加打卡点"
emptyView.isHidden = true
emptyView.onAction = { [weak self] in self?.addTapped() }
@ -166,6 +171,9 @@ final class PunchPointListViewController: BaseViewController {
viewModel.onShowQRCode = { [weak self] url in
Task { @MainActor in self?.showQRCodeDialog(url: url) }
}
viewModel.onBeginDeleteAnimation = { [weak self] id in
Task { @MainActor in self?.animateRemoval(id: id) }
}
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
@ -222,35 +230,49 @@ final class PunchPointListViewController: BaseViewController {
}
private func confirmDelete(_ item: PunchPointItem) {
let alert = UIAlertController(
let alert = PunchPointMaterialDialogController.confirmation(
title: "删除打卡点",
message: "确定删除\(item.name)打卡点吗?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.delete(item: item, api: self.api) }
})
confirmTitle: "确定",
onConfirm: { [weak self] in
self?.dismiss(animated: true)
guard let self else { return }
Task { await self.viewModel.delete(item: item, api: self.api) }
}
) { [weak self] in
self?.dismiss(animated: true)
}
present(alert, animated: true)
}
@MainActor
private func animateRemoval(id: Int64) {
guard let index = viewModel.items.firstIndex(where: { $0.id == id }) else {
viewModel.completeDeleteAnimation(id: id)
return
}
let indexPath = IndexPath(row: index, section: 0)
guard let cell = tableView.cellForRow(at: indexPath) else {
viewModel.completeDeleteAnimation(id: id)
return
}
UIView.animate(
withDuration: 0.3,
delay: 0,
options: [.curveEaseIn, .allowUserInteraction]
) {
cell.contentView.transform = CGAffineTransform(translationX: self.tableView.bounds.width, y: 0)
cell.contentView.alpha = 0
} completion: { [weak self] _ in
guard let self else { return }
self.viewModel.completeDeleteAnimation(id: id)
}
}
private func showQRCodeDialog(url: String) {
let controller = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .alert)
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 12
if let imageURL = URL(string: url) {
imageView.kf.setImage(with: imageURL)
let controller = PunchPointMaterialDialogController.qrCode(url: url) { [weak self] in
self?.dismiss(animated: true)
}
controller.view.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(52)
make.size.equalTo(180)
}
controller.addAction(UIAlertAction(title: "确定", style: .default))
present(controller, animated: true)
}
@ -294,7 +316,7 @@ private final class PunchPointListCell: UITableViewCell {
private let operatingTag = PunchPointStatusChip()
private let creatorLabel = UILabel()
private let createdAtLabel = UILabel()
private let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
private let locationIcon = UIImageView(image: UIImage(named: "punch_point_location"))
private let addressLabel = UILabel()
private let reviewTag = PunchPointStatusChip()
private let auditTimeLabel = UILabel()
@ -324,14 +346,14 @@ private final class PunchPointListCell: UITableViewCell {
$0.font = .systemFont(ofSize: 12)
$0.textColor = AppColor.textSecondary
}
locationIcon.tintColor = UIColor(hex: 0x4B5563)
locationIcon.contentMode = .scaleAspectFit
addressLabel.font = .systemFont(ofSize: 12)
addressLabel.textColor = UIColor(hex: 0x4B5563)
addressLabel.numberOfLines = 2
qrButton.setImage(UIImage(systemName: "qrcode"), for: .normal)
qrButton.tintColor = AppColor.textPrimary
deleteButton.setImage(UIImage(systemName: "trash"), for: .normal)
deleteButton.tintColor = AppColor.textPrimary
qrButton.setImage(UIImage(named: "punch_point_qr"), for: .normal)
deleteButton.setImage(UIImage(named: "punch_point_delete"), for: .normal)
qrButton.accessibilityLabel = "查看二维码"
deleteButton.accessibilityLabel = "删除打卡点"
contentView.addSubview(cardView)
[coverImageView, nameLabel, operatingTag, creatorLabel, createdAtLabel, locationIcon, addressLabel, reviewTag, auditTimeLabel, qrButton, deleteButton].forEach(cardView.addSubview)
@ -341,7 +363,8 @@ private final class PunchPointListCell: UITableViewCell {
make.leading.trailing.equalToSuperview().inset(15)
}
coverImageView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.top.equalToSuperview().offset(14)
make.leading.equalToSuperview().offset(16)
make.size.equalTo(128)
}
nameLabel.snp.makeConstraints { make in
@ -402,17 +425,19 @@ private final class PunchPointListCell: UITableViewCell {
}
func apply(item: PunchPointItem) {
contentView.transform = .identity
contentView.alpha = 1
nameLabel.text = item.name
creatorLabel.text = "负责人:\(item.creator?.nonEmptyTrimmed ?? "--")"
createdAtLabel.text = "创建时间:\(item.createdAt)"
addressLabel.text = item.displayAddress.nonEmptyTrimmed ?? "--"
auditTimeLabel.text = "审核时间:\(item.auditTime?.nonEmptyTrimmed ?? "--")"
operatingTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status)
reviewTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status)
operatingTag.apply(PunchPointDisplayFormatter.operatingStatus(item.status), compact: true)
reviewTag.apply(PunchPointDisplayFormatter.auditStatus(item.status), compact: false)
if let url = item.guideImages.first.flatMap(URL.init(string:)) {
coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
coverImageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo"))
} else {
coverImageView.image = UIImage(systemName: "photo")
coverImageView.image = UIImage(named: "SplashLogo")
}
}
@ -432,14 +457,12 @@ final class PunchPointStatusChip: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
layer.cornerRadius = 4
clipsToBounds = true
label.font = .systemFont(ofSize: 12, weight: .medium)
label.font = .systemFont(ofSize: 12)
label.textAlignment = .center
addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
}
label.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
@available(*, unavailable)
@ -448,11 +471,15 @@ final class PunchPointStatusChip: UIView {
}
///
func apply(text: String, status: Int) {
label.text = text
let colors = PunchPointDisplayFormatter.statusColors(status)
backgroundColor = UIColor(hex: colors.background)
label.textColor = UIColor(hex: colors.text)
func apply(_ presentation: PunchPointStatusPresentation, compact: Bool = false) {
label.text = presentation.title
backgroundColor = UIColor(hex: presentation.backgroundColor)
label.textColor = UIColor(hex: presentation.textColor)
label.snp.remakeConstraints { make in
make.edges.equalToSuperview().inset(
UIEdgeInsets(top: compact ? 2 : 4, left: compact ? 8 : 10, bottom: compact ? 2 : 4, right: compact ? 8 : 10)
)
}
}
}
@ -465,11 +492,11 @@ private final class PunchPointEmptyView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.text = "暂无打卡点"
titleLabel.font = .systemFont(ofSize: 15)
titleLabel.text = "暂无打卡点,点击下方按钮即可添加"
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = AppColor.textSecondary
titleLabel.textAlignment = .center
button.setTitle("添加打卡点", for: .normal)
button.setTitle("立即添加", for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
button.backgroundColor = AppColor.primary
@ -482,7 +509,7 @@ private final class PunchPointEmptyView: UIView {
button.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.centerX.bottom.equalToSuperview()
make.width.equalTo(128)
make.width.greaterThanOrEqualTo(96)
make.height.equalTo(40)
}
button.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
@ -498,6 +525,151 @@ private final class PunchPointEmptyView: UIView {
}
}
/// Android Material3
private final class PunchPointMaterialDialogController: UIViewController {
private enum Content {
case confirmation(title: String, message: String, confirmTitle: String)
case qrCode(url: String)
}
private let content: Content
private let onConfirm: () -> Void
private let onCancel: (() -> Void)?
private init(content: Content, onConfirm: @escaping () -> Void, onCancel: (() -> Void)? = nil) {
self.content = content
self.onConfirm = onConfirm
self.onCancel = onCancel
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
static func confirmation(
title: String,
message: String,
confirmTitle: String,
onConfirm: @escaping () -> Void,
onCancel: @escaping () -> Void
) -> PunchPointMaterialDialogController {
PunchPointMaterialDialogController(
content: .confirmation(title: title, message: message, confirmTitle: confirmTitle),
onConfirm: onConfirm,
onCancel: onCancel
)
}
static func qrCode(url: String, onConfirm: @escaping () -> Void) -> PunchPointMaterialDialogController {
PunchPointMaterialDialogController(content: .qrCode(url: url), onConfirm: onConfirm)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
view.accessibilityViewIsModal = true
let dialog = UIView()
dialog.backgroundColor = .white
dialog.layer.cornerRadius = 28
dialog.clipsToBounds = true
view.addSubview(dialog)
dialog.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.greaterThanOrEqualToSuperview().offset(32)
make.trailing.lessThanOrEqualToSuperview().offset(-32)
make.width.lessThanOrEqualTo(320)
}
switch content {
case let .confirmation(title, message, confirmTitle):
configureConfirmation(dialog: dialog, title: title, message: message, confirmTitle: confirmTitle)
case let .qrCode(url):
configureQRCode(dialog: dialog, url: url)
}
}
private func configureConfirmation(dialog: UIView, title: String, message: String, confirmTitle: String) {
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = UIColor(hex: 0x333333)
let messageLabel = UILabel()
messageLabel.text = message
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textColor = UIColor(hex: 0x666666)
messageLabel.numberOfLines = 0
let cancel = textButton(title: "取消", color: UIColor(hex: 0x666666), action: #selector(cancelTapped))
let confirm = textButton(title: confirmTitle, color: AppColor.primary, action: #selector(confirmTapped))
let actions = UIStackView(arrangedSubviews: [UIView(), cancel, confirm])
actions.axis = .horizontal
actions.spacing = 8
[titleLabel, messageLabel, actions].forEach(dialog.addSubview)
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(24)
make.leading.trailing.equalToSuperview().inset(24)
}
messageLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(24)
}
actions.snp.makeConstraints { make in
make.top.equalTo(messageLabel.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(12)
make.height.equalTo(44)
}
}
private func configureQRCode(dialog: UIView, url: String) {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 12
if let imageURL = URL(string: url) {
imageView.kf.setImage(with: imageURL)
}
let confirm = textButton(title: "确定", color: AppColor.primary, action: #selector(confirmTapped))
dialog.addSubview(imageView)
dialog.addSubview(confirm)
imageView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(32)
make.centerX.equalToSuperview()
make.size.equalTo(180)
make.leading.greaterThanOrEqualToSuperview().offset(32)
}
confirm.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(12)
make.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(12)
make.width.equalTo(64)
make.height.equalTo(44)
}
}
private func textButton(title: String, color: UIColor, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(color, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
@objc private func confirmTapped() {
onConfirm()
}
@objc private func cancelTapped() {
onCancel?()
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -330,7 +330,7 @@ private final class CloudPickCell: UICollectionViewCell {
private let imageView = UIImageView()
private let nameLabel = UILabel()
private let badgeView = UIView()
private let selectionIconView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill"))
private let folderIconView = UIImageView()
override init(frame: CGRect) {
@ -351,14 +351,14 @@ private final class CloudPickCell: UICollectionViewCell {
nameLabel.numberOfLines = 2
nameLabel.textAlignment = .center
badgeView.backgroundColor = AppColor.primary
badgeView.layer.cornerRadius = 10
badgeView.isHidden = true
selectionIconView.tintColor = AppColor.primary
selectionIconView.contentMode = .scaleAspectFit
selectionIconView.isHidden = true
contentView.addSubview(imageView)
contentView.addSubview(folderIconView)
contentView.addSubview(nameLabel)
contentView.addSubview(badgeView)
contentView.addSubview(selectionIconView)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
@ -372,7 +372,7 @@ private final class CloudPickCell: UICollectionViewCell {
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
}
badgeView.snp.makeConstraints { make in
selectionIconView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.width.height.equalTo(20)
}
@ -388,7 +388,7 @@ private final class CloudPickCell: UICollectionViewCell {
imageView.kf.cancelDownloadTask()
imageView.image = nil
folderIconView.isHidden = true
badgeView.isHidden = true
selectionIconView.isHidden = true
contentView.alpha = 1
}
@ -396,7 +396,7 @@ private final class CloudPickCell: UICollectionViewCell {
nameLabel.text = file.name
contentView.layer.borderWidth = isSelected ? 2 : 0
contentView.layer.borderColor = AppColor.primary.cgColor
badgeView.isHidden = !isSelected
selectionIconView.isHidden = !isSelected
if isGridMode {
imageView.snp.remakeConstraints { make in

View File

@ -19,6 +19,7 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
private let contentStack = UIStackView()
private let pointsCard = UIView()
private let pointsValueLabel = UILabel()
private let summaryLoadingIndicator = UIActivityIndicatorView(style: .large)
private let inputCard = UIView()
private let pointsInputField = UITextField()
private let withdrawablePointsLabel = UILabel()
@ -29,6 +30,7 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
private let submitButton = AppButton(title: "立即兑换")
private var dataSource: UITableViewDiffableDataSource<Section, PointWithdrawItem>!
private var tableHeightConstraint: Constraint?
private var lastShownMessage: String?
///
@ -47,13 +49,17 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
}
override func setupNavigationBar() {
title = "积分兑现"
let titleLabel = UILabel()
titleLabel.text = "积分兑现"
titleLabel.textColor = WalletTokens.text333
titleLabel.font = UIFont.systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
view.backgroundColor = WalletTokens.pageBackground
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.spacing = WalletTokens.cardGap
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
@ -72,21 +78,21 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
make.leading.trailing.bottom.equalToSuperview()
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.top.equalToSuperview().offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
submitButton.layer.cornerRadius = WalletTokens.bottomButtonRadius
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.edges.equalToSuperview().inset(WalletTokens.screenInset)
make.width.equalTo(scrollView).offset(-32)
}
tableView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(260)
make.height.lessThanOrEqualTo(420)
tableHeightConstraint = make.height.equalTo(200).constraint
}
}
@ -97,7 +103,8 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
pointsInputField.addTarget(self, action: #selector(pointsInputChanged), for: .editingChanged)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
Task { await viewModel.refreshOverview(api: walletAPI) }
Task { await viewModel.refreshWithdrawList(api: walletAPI) }
}
private func configureTable() {
@ -106,7 +113,7 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
scrollView.refreshControl = refreshControl
tableView.register(PointWithdrawRecordCell.self, forCellReuseIdentifier: PointWithdrawRecordCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Section, PointWithdrawItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
@ -121,47 +128,82 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
private func applyState() {
pointsValueLabel.text = "\(viewModel.withdrawnPoints)"
withdrawablePointsLabel.text = "\(viewModel.withdrawnPoints)"
pointsValueLabel.isHidden = viewModel.overviewLoading
if viewModel.overviewLoading {
summaryLoadingIndicator.startAnimating()
} else {
summaryLoadingIndicator.stopAnimating()
}
pointsInputField.text = viewModel.pointsInput
submitButton.isEnabled = (Int(viewModel.pointsInput) ?? 0) > 0
refreshControl.endRefreshing()
if !viewModel.withdrawRefreshing, !viewModel.overviewLoading {
refreshControl.endRefreshing()
}
var snapshot = NSDiffableDataSourceSnapshot<Section, PointWithdrawItem>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.withdrawRecords, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
self?.updateTablePresentation()
}
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
}
private func updateEmptyState() {
guard viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading else {
private func updateTablePresentation() {
tableView.layoutIfNeeded()
let isEmpty = viewModel.withdrawRecords.isEmpty
updateLoadingFooter()
guard isEmpty, !viewModel.withdrawLoading else {
tableView.backgroundView = nil
let minimumHeight: CGFloat = viewModel.withdrawLoading ? 56 : 44
let height = max(minimumHeight, min(tableView.contentSize.height, 400))
tableHeightConstraint?.update(offset: height)
tableView.isScrollEnabled = tableView.contentSize.height > 400
return
}
let label = UILabel()
label.text = "暂无提现记录"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0xB3B8C2)
label.font = WalletTokens.bodyFont
label.textColor = WalletTokens.textDisabled
label.textAlignment = .center
tableView.backgroundView = label
tableHeightConstraint?.update(offset: 200)
tableView.isScrollEnabled = false
}
private func updateLoadingFooter() {
guard viewModel.withdrawLoading else {
tableView.tableFooterView = UIView(frame: .zero)
return
}
let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56))
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = WalletTokens.primary
indicator.startAnimating()
footer.addSubview(indicator)
indicator.snp.makeConstraints { make in make.center.equalToSuperview() }
tableView.tableFooterView = footer
}
private func makeSummaryCard() -> UIView {
pointsCard.backgroundColor = UIColor(hex: 0x1677FF)
pointsCard.layer.cornerRadius = 16
pointsCard.backgroundColor = WalletTokens.summaryBackground
pointsCard.layer.cornerRadius = WalletTokens.contentRadius
pointsCard.clipsToBounds = true
let title = UILabel()
title.text = "可提现积分"
title.font = .systemFont(ofSize: 14)
title.font = WalletTokens.bodyFont
title.textColor = UIColor.white.withAlphaComponent(0.8)
title.textAlignment = .center
pointsValueLabel.font = .systemFont(ofSize: 36, weight: .bold)
pointsValueLabel.font = WalletTokens.displayFont
pointsValueLabel.textColor = .white
pointsValueLabel.textAlignment = .center
pointsCard.addSubview(title)
pointsCard.addSubview(pointsValueLabel)
summaryLoadingIndicator.color = .white
summaryLoadingIndicator.hidesWhenStopped = true
pointsCard.addSubview(summaryLoadingIndicator)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
@ -171,35 +213,40 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
make.leading.trailing.equalToSuperview().inset(24)
make.bottom.equalToSuperview().inset(16)
}
summaryLoadingIndicator.snp.makeConstraints { make in
make.center.equalTo(pointsValueLabel)
}
return pointsCard
}
private func makeInputCard() -> UIView {
inputCard.backgroundColor = .white
inputCard.layer.cornerRadius = 12
inputCard.layer.cornerRadius = WalletTokens.cardRadius
inputCard.clipsToBounds = true
let title = makeTitle("积分提现", size: 18)
let titleSpacer = UIView()
titleSpacer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.compactGap) }
let row = UIStackView()
row.axis = .horizontal
row.spacing = 16
let label = makeBody("可提现积分", color: .black, weight: .medium)
withdrawablePointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
withdrawablePointsLabel.textColor = UIColor(hex: 0xFF7B00)
withdrawablePointsLabel.font = WalletTokens.bodyMediumFont
withdrawablePointsLabel.textColor = WalletTokens.points
row.addArrangedSubview(label)
row.addArrangedSubview(withdrawablePointsLabel)
let inputContainer = UIView()
inputContainer.layer.cornerRadius = 4
inputContainer.layer.cornerRadius = WalletTokens.chipRadius
inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
inputContainer.layer.borderColor = WalletTokens.border.cgColor
pointsInputField.placeholder = "请输入积分"
pointsInputField.keyboardType = .numberPad
pointsInputField.font = .systemFont(ofSize: 14)
pointsInputField.font = WalletTokens.bodyFont
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14)
allButton.setTitleColor(WalletTokens.primary, for: .normal)
allButton.titleLabel?.font = WalletTokens.bodyFont
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
inputContainer.addSubview(pointsInputField)
inputContainer.addSubview(allButton)
@ -212,14 +259,14 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
inputContainer.snp.makeConstraints { make in make.height.equalTo(46) }
inputContainer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.inputHeight) }
let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: UIColor(hex: 0xEF4444))
ratio.font = .systemFont(ofSize: 12)
let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: WalletTokens.danger)
ratio.font = WalletTokens.captionFont
let stack = UIStackView(arrangedSubviews: [title, row, inputContainer, ratio])
let stack = UIStackView(arrangedSubviews: [title, titleSpacer, row, inputContainer, ratio])
stack.axis = .vertical
stack.spacing = 12
stack.spacing = WalletTokens.compactGap
inputCard.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return inputCard
@ -227,20 +274,21 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
private func makeRecordCard() -> UIView {
recordCard.backgroundColor = .white
recordCard.layer.cornerRadius = 12
recordCard.layer.cornerRadius = WalletTokens.cardRadius
recordCard.clipsToBounds = true
let title = makeTitle("积分提现记录", size: 14)
title.textColor = UIColor(hex: 0x0073FF)
title.textColor = WalletTokens.primary
title.font = UIFont.systemFont(ofSize: 14, weight: .bold)
title.textAlignment = .center
recordCard.addSubview(title)
recordCard.addSubview(tableView)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.top.equalToSuperview().offset(WalletTokens.cardPadding)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
make.top.equalTo(title.snp.bottom).offset(WalletTokens.rowGap)
make.leading.trailing.bottom.equalToSuperview().inset(WalletTokens.cardPadding)
}
return recordCard
}
@ -276,7 +324,8 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
}
@objc private func refreshPulled() {
Task { await viewModel.loadInitial(api: walletAPI) }
Task { await viewModel.refreshOverview(api: walletAPI) }
Task { await viewModel.refreshWithdrawList(api: walletAPI) }
}
@objc private func submitTapped() {
@ -301,9 +350,11 @@ private final class PointWithdrawRecordCell: UITableViewCell {
private let amountLabel = UILabel()
private let timeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .bar)
private let percentLabel = UILabel()
private let stepsStack = UIStackView()
private let infoLabel = UILabel()
private let statusLabel = UILabel()
private let remarkLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
@ -329,41 +380,52 @@ private final class PointWithdrawRecordCell: UITableViewCell {
progressView.progress = Float(progress) / 100.0
progressView.progressTintColor = style.progress
progressView.isHidden = progress == 0
percentLabel.isHidden = progress == 0
stepsStack.isHidden = progress == 0
percentLabel.text = "\(progress)%"
configureSteps(progress: progress, tint: style.progress)
infoLabel.text = infoText(for: record)
remarkLabel.text = record.rejectReason.isEmpty ? "" : "备注信息:\(record.rejectReason)"
remarkLabel.isHidden = record.rejectReason.isEmpty
updateDynamicConstraints(progress: progress, showsRemark: !record.rejectReason.isEmpty)
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.layer.cornerRadius = WalletTokens.contentRadius
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.layer.borderColor = WalletTokens.cardBorder.cgColor
cardView.clipsToBounds = true
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium)
pointsLabel.textColor = .black
amountLabel.font = .systemFont(ofSize: 18, weight: .medium)
amountLabel.textColor = UIColor(hex: 0xEF4444)
pointsLabel.font = WalletTokens.metricMediumFont
pointsLabel.textColor = WalletTokens.textPrimary
amountLabel.font = WalletTokens.metricMediumFont
amountLabel.textColor = WalletTokens.danger
amountLabel.textAlignment = .right
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
progressView.trackTintColor = UIColor(hex: 0xF2F4F8)
timeLabel.font = WalletTokens.bodyFont
timeLabel.textColor = WalletTokens.textMuted
progressView.trackTintColor = WalletTokens.progressTrack
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
percentLabel.font = WalletTokens.bodyMediumFont
percentLabel.textColor = WalletTokens.textTertiary
percentLabel.textAlignment = .right
stepsStack.axis = .horizontal
stepsStack.distribution = .equalSpacing
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.font = WalletTokens.captionFont
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.layer.cornerRadius = WalletTokens.chipRadius
statusLabel.clipsToBounds = true
infoLabel.font = .systemFont(ofSize: 14)
infoLabel.textColor = UIColor(hex: 0x4B5563)
infoLabel.font = WalletTokens.bodyFont
infoLabel.textColor = WalletTokens.textSecondary
infoLabel.numberOfLines = 0
remarkLabel.font = WalletTokens.bodyFont
remarkLabel.textColor = WalletTokens.danger
remarkLabel.numberOfLines = 0
contentView.addSubview(cardView)
[pointsLabel, amountLabel, timeLabel, progressView, stepsStack, infoLabel, statusLabel].forEach(cardView.addSubview)
[pointsLabel, amountLabel, timeLabel, progressView, percentLabel, stepsStack, infoLabel, statusLabel, remarkLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0))
@ -382,25 +444,47 @@ private final class PointWithdrawRecordCell: UITableViewCell {
}
progressView.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(6)
make.leading.equalToSuperview().offset(WalletTokens.cardPadding)
make.trailing.equalTo(percentLabel.snp.leading).offset(-WalletTokens.rowGap)
make.height.equalTo(WalletTokens.progressHeight)
}
percentLabel.snp.makeConstraints { make in
make.centerY.equalTo(progressView)
make.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
stepsStack.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(stepsStack.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
make.bottom.equalToSuperview().inset(14)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(infoLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(70)
}
updateDynamicConstraints(progress: 0, showsRemark: false)
}
private func updateDynamicConstraints(progress: Int, showsRemark: Bool) {
infoLabel.snp.remakeConstraints { make in
if progress > 0 {
make.top.equalTo(stepsStack.snp.bottom).offset(WalletTokens.compactGap)
} else {
make.top.equalTo(timeLabel.snp.bottom).offset(WalletTokens.compactGap)
}
make.leading.equalToSuperview().offset(WalletTokens.cardPadding)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-WalletTokens.compactGap)
if !showsRemark {
make.bottom.equalToSuperview().inset(14)
}
}
remarkLabel.snp.remakeConstraints { make in
make.top.equalTo(infoLabel.snp.bottom).offset(WalletTokens.tinyGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
if showsRemark {
make.bottom.equalToSuperview().inset(14)
}
}
}
private func configureSteps(progress: Int, tint: UIColor) {
@ -408,15 +492,10 @@ private final class PointWithdrawRecordCell: UITableViewCell {
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 11)
label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : UIColor(hex: 0x7B8EAA)
label.font = WalletTokens.stepFont
label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : WalletTokens.textMuted
stepsStack.addArrangedSubview(label)
}
let percent = UILabel()
percent.text = "\(progress)%"
percent.font = .systemFont(ofSize: 12, weight: .medium)
percent.textColor = UIColor(hex: 0x9CA3AF)
stepsStack.addArrangedSubview(percent)
}
private func statusText(for status: Int) -> String {
@ -431,13 +510,13 @@ private final class PointWithdrawRecordCell: UITableViewCell {
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status {
case 2:
(UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0x1677FF))
(WalletTokens.successBackground, WalletTokens.success, WalletTokens.summaryBackground)
case 1:
(UIColor(hex: 0xFFF0E2), UIColor(hex: 0xFF7B00), UIColor(hex: 0x1677FF))
(WalletTokens.pointsPendingBackground, WalletTokens.points, WalletTokens.summaryBackground)
case 3:
(UIColor(hex: 0xFFE7E7), UIColor(hex: 0xEF4444), UIColor(hex: 0xFF4D4F))
(WalletTokens.dangerBackground, WalletTokens.danger, WalletTokens.progressDanger)
default:
(UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF))
(WalletTokens.infoBackground, WalletTokens.summaryBackground, WalletTokens.summaryBackground)
}
}
@ -452,8 +531,7 @@ private final class PointWithdrawRecordCell: UITableViewCell {
private func infoText(for record: PointWithdrawItem) -> String {
switch record.status {
case 3:
let remark = record.rejectReason.isEmpty ? "" : "\n备注信息:\(record.rejectReason)"
return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)\(remark)"
return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)"
case 2:
return "到账时间:\(record.paymentTime.isEmpty ? "暂无" : record.paymentTime)"
default:

View File

@ -41,8 +41,17 @@ final class WalletWithdrawRecordCell: UITableViewCell {
configureSteps(progress: progress(for: record.settlementStatus), tint: style.progress)
progressView.isHidden = record.settlementStatus == 50
stepsStack.isHidden = record.settlementStatus == 50
infoLabel.text = infoText(for: record)
infoLabel.isHidden = infoLabel.text?.isEmpty ?? true
infoLabel.attributedText = infoText(for: record)
infoLabel.isHidden = infoLabel.attributedText?.length == 0
infoLabel.snp.remakeConstraints { make in
if record.settlementStatus == 50 {
make.top.equalTo(timeLabel.snp.bottom).offset(WalletTokens.compactGap)
} else {
make.top.equalTo(stepsStack.snp.bottom).offset(10)
}
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
make.bottom.equalToSuperview().inset(14)
}
}
private func setupUI() {
@ -51,23 +60,23 @@ final class WalletWithdrawRecordCell: UITableViewCell {
contentView.backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.layer.cornerRadius = WalletTokens.contentRadius
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.layer.borderColor = WalletTokens.cardBorder.cgColor
cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .bold)
amountLabel.textColor = .black
amountLabel.font = WalletTokens.metricBoldFont
amountLabel.textColor = WalletTokens.textPrimary
statusLabel.font = .systemFont(ofSize: 12, weight: .medium)
statusLabel.font = WalletTokens.captionMediumFont
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.layer.cornerRadius = WalletTokens.chipRadius
statusLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
timeLabel.font = WalletTokens.bodyFont
timeLabel.textColor = WalletTokens.textMuted
progressView.trackTintColor = UIColor(hex: 0xF2F4F8)
progressView.trackTintColor = WalletTokens.progressTrack
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
@ -75,8 +84,8 @@ final class WalletWithdrawRecordCell: UITableViewCell {
stepsStack.distribution = .equalSpacing
stepsStack.alignment = .center
infoLabel.font = .systemFont(ofSize: 14)
infoLabel.textColor = UIColor(hex: 0x4B5563)
infoLabel.font = WalletTokens.bodyFont
infoLabel.textColor = WalletTokens.textSecondary
infoLabel.numberOfLines = 0
contentView.addSubview(cardView)
@ -120,14 +129,14 @@ final class WalletWithdrawRecordCell: UITableViewCell {
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 11)
label.font = WalletTokens.stepFont
let threshold = Float(index) / 3.0
label.textColor = progress >= threshold ? tint : UIColor(hex: 0xB3B8C2)
label.textColor = progress >= threshold ? tint : WalletTokens.textDisabled
stepsStack.addArrangedSubview(label)
}
let percent = UILabel()
percent.text = "\(Int(progress * 100))%"
percent.font = .systemFont(ofSize: 12, weight: .medium)
percent.font = WalletTokens.captionMediumFont
percent.textColor = tint
stepsStack.addArrangedSubview(percent)
}
@ -135,13 +144,13 @@ final class WalletWithdrawRecordCell: UITableViewCell {
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status {
case 50, 20:
(UIColor(hex: 0xEBFFF5), UIColor(hex: 0x16C26D), UIColor(hex: 0x1677FF))
(WalletTokens.settlementSuccessBackground, WalletTokens.settlementSuccess, WalletTokens.summaryBackground)
case 60, 30:
(UIColor(hex: 0xFFF1F0), UIColor(hex: 0xFF4D4F), UIColor(hex: 0xFF4D4F))
(WalletTokens.settlementDangerBackground, WalletTokens.progressDanger, WalletTokens.progressDanger)
case 40:
(UIColor(hex: 0xFFF3E8), UIColor(hex: 0xFF8A00), UIColor(hex: 0x1677FF))
(WalletTokens.warningBackground, WalletTokens.warning, WalletTokens.summaryBackground)
default:
(UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF))
(WalletTokens.infoBackground, WalletTokens.summaryBackground, WalletTokens.summaryBackground)
}
}
@ -156,18 +165,34 @@ final class WalletWithdrawRecordCell: UITableViewCell {
}
}
private func infoText(for record: WalletWithdrawItem) -> String {
private func infoText(for record: WalletWithdrawItem) -> NSAttributedString {
let result = NSMutableAttributedString()
let normal: [NSAttributedString.Key: Any] = [
.font: WalletTokens.bodyFont,
.foregroundColor: WalletTokens.textSecondary,
]
let success: [NSAttributedString.Key: Any] = [
.font: WalletTokens.bodyFont,
.foregroundColor: WalletTokens.settlementSuccess,
]
let danger: [NSAttributedString.Key: Any] = [
.font: WalletTokens.bodyFont,
.foregroundColor: WalletTokens.progressDanger,
]
if (record.settlementStatus == 10 || record.settlementStatus == 40), !record.expectedAt.isEmpty {
return "预计到账时间:\(record.expectedAt)"
result.append(NSAttributedString(string: "预计到账时间:\(record.expectedAt)", attributes: normal))
}
if (record.settlementStatus == 60 || record.settlementStatus == 30), !record.auditTime.isEmpty {
let remark = record.auditRemark.isEmpty ? "" : "\n备注信息\(record.auditRemark)"
return "审核时间:\(record.auditTime)\(remark)"
result.append(NSAttributedString(string: "审核时间\(record.auditTime)", attributes: normal))
}
if (record.settlementStatus == 60 || record.settlementStatus == 30), !record.auditRemark.isEmpty {
if result.length > 0 { result.append(NSAttributedString(string: "\n")) }
result.append(NSAttributedString(string: "备注信息:\(record.auditRemark)", attributes: danger))
}
if record.settlementStatus == 50, !record.expectedAt.isEmpty {
return "到账时间:\(record.expectedAt)"
result.append(NSAttributedString(string: "到账时间:\(record.expectedAt)", attributes: success))
}
return ""
return result
}
}
@ -199,13 +224,13 @@ final class WalletTransactionHeaderCell: UITableViewCell {
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
dateLabel.font = .systemFont(ofSize: 14)
dateLabel.textColor = UIColor(hex: 0x9CA3AF)
incomeLabel.font = .systemFont(ofSize: 14)
incomeLabel.textColor = UIColor(hex: 0x4B5563)
dateLabel.font = WalletTokens.bodyFont
dateLabel.textColor = WalletTokens.textTertiary
incomeLabel.font = WalletTokens.bodyFont
incomeLabel.textColor = WalletTokens.textSecondary
incomeLabel.textAlignment = .right
pointsLabel.font = .systemFont(ofSize: 14)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.font = WalletTokens.bodyFont
pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right
contentView.addSubview(dateLabel)
@ -277,27 +302,27 @@ final class WalletTransactionRecordCell: UITableViewCell {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.layer.cornerRadius = WalletTokens.cardRadius
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.layer.borderColor = WalletTokens.cardBorder.cgColor
cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .medium)
amountLabel.textColor = .black
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
amountLabel.font = WalletTokens.metricMediumFont
amountLabel.textColor = WalletTokens.textPrimary
pointsLabel.font = WalletTokens.metricMediumFont
pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right
typeLabel.font = .systemFont(ofSize: 14)
typeLabel.textColor = UIColor(hex: 0x9CA3AF)
orderLabel.font = .systemFont(ofSize: 12)
typeLabel.font = WalletTokens.bodyFont
typeLabel.textColor = WalletTokens.textTertiary
orderLabel.font = WalletTokens.captionFont
orderLabel.textAlignment = .center
orderLabel.layer.cornerRadius = 4
orderLabel.layer.cornerRadius = WalletTokens.chipRadius
orderLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x4B5563)
withdrawLabel.font = .systemFont(ofSize: 12)
timeLabel.font = WalletTokens.bodyFont
timeLabel.textColor = WalletTokens.textSecondary
withdrawLabel.font = WalletTokens.captionFont
withdrawLabel.textAlignment = .center
withdrawLabel.layer.cornerRadius = 4
withdrawLabel.layer.cornerRadius = WalletTokens.chipRadius
withdrawLabel.clipsToBounds = true
contentView.addSubview(cardView)
@ -340,9 +365,34 @@ final class WalletTransactionRecordCell: UITableViewCell {
private func labelStyle(for label: String) -> (background: UIColor, text: UIColor, orderBackground: UIColor, orderText: UIColor) {
switch label {
case "可提现":
return (UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0xEFF6FF), UIColor(hex: 0x0073FF))
return (WalletTokens.successBackground, WalletTokens.success, WalletTokens.orderBackground, WalletTokens.primary)
default:
return (UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA), UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA))
return (WalletTokens.neutralChipBackground, WalletTokens.textMuted, WalletTokens.neutralChipBackground, WalletTokens.textMuted)
}
}
}
/// Cell Android
final class WalletLoadingCell: UITableViewCell {
static let reuseIdentifier = "WalletLoadingCell"
private let indicator = UIActivityIndicatorView(style: .medium)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .white
indicator.color = WalletTokens.summaryBackground
indicator.startAnimating()
contentView.addSubview(indicator)
indicator.snp.makeConstraints { make in
make.center.equalToSuperview()
make.top.bottom.equalToSuperview().inset(WalletTokens.cardPadding)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -16,6 +16,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
case withdraw(WalletWithdrawItem)
case transactionHeader(EarningDetailGroup)
case transaction(EarningDetailItem)
case loading
}
private let viewModel: WalletViewModel
@ -27,6 +28,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
private let tabStack = UIStackView()
private let withdrawTabButton = UIButton(type: .system)
private let transactionTabButton = UIButton(type: .system)
private let tabIndicator = UIView()
private let filterPanel = WalletFilterPanelView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
@ -54,24 +56,30 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
override func setupNavigationBar() {
title = "我的钱包"
let titleLabel = UILabel()
titleLabel.text = "我的钱包"
titleLabel.textColor = WalletTokens.text333
titleLabel.font = UIFont.systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
view.backgroundColor = WalletTokens.pageBackground
contentCard.backgroundColor = .white
contentCard.layer.cornerRadius = 16
contentCard.layer.cornerRadius = WalletTokens.contentRadius
contentCard.clipsToBounds = true
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
[withdrawTabButton, transactionTabButton].forEach {
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
$0.setTitleColor(UIColor(hex: 0x666666), for: .normal)
$0.titleLabel?.font = WalletTokens.tabSelectedFont
$0.setTitleColor(WalletTokens.tabNormal, for: .normal)
tabStack.addArrangedSubview($0)
}
withdrawTabButton.setTitle("提现记录", for: .normal)
transactionTabButton.setTitle("收益明细", for: .normal)
tabIndicator.backgroundColor = WalletTokens.summaryBackground
tabIndicator.layer.cornerRadius = 1.5
tableView.backgroundColor = .white
tableView.separatorStyle = .none
@ -79,15 +87,19 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.contentInset = UIEdgeInsets(top: WalletTokens.rowGap, left: 0, bottom: WalletTokens.rowGap, right: 0)
tableView.verticalScrollIndicatorInsets = tableView.contentInset
tableView.register(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier)
tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier)
tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier)
tableView.register(WalletLoadingCell.self, forCellReuseIdentifier: WalletLoadingCell.reuseIdentifier)
bottomBar.backgroundColor = .white
view.addSubview(summaryCard)
view.addSubview(contentCard)
contentCard.addSubview(tabStack)
contentCard.addSubview(tabIndicator)
contentCard.addSubview(filterPanel)
contentCard.addSubview(tableView)
view.addSubview(bottomBar)
@ -98,32 +110,41 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
override func setupConstraints() {
summaryCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.top.equalTo(view.safeAreaLayoutGuide).offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
withdrawButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.top.equalToSuperview().offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
withdrawButton.layer.cornerRadius = WalletTokens.bottomButtonRadius
contentCard.snp.makeConstraints { make in
make.top.equalTo(summaryCard.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-16)
make.top.equalTo(summaryCard.snp.bottom).offset(WalletTokens.cardGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(bottomBar.snp.top).offset(-WalletTokens.cardGap)
}
tabStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(50)
}
tabIndicator.snp.makeConstraints { make in
make.width.equalTo(24)
make.height.equalTo(3)
make.bottom.equalTo(tabStack)
make.centerX.equalTo(withdrawTabButton)
}
filterPanel.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.height.equalTo(0)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterPanel.snp.bottom).offset(4)
make.top.equalTo(filterPanel.snp.bottom).offset(WalletTokens.rowGap)
make.leading.trailing.bottom.equalToSuperview()
}
}
@ -139,13 +160,17 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside)
transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside)
filterPanel.onFilterTap = { [weak self] in
self?.presentFilterMenu()
filterPanel.onFilterSelected = { [weak self] filter in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
Task { await viewModel.refreshSummary(api: walletAPI) }
Task { await viewModel.refreshWithdraw(api: walletAPI) }
Task { await viewModel.refreshTransaction(api: walletAPI) }
Task { await viewModel.refreshPoints(api: walletAPI) }
}
private func configureDataSource() {
@ -172,6 +197,11 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
) as? WalletTransactionRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
case .loading:
return tableView.dequeueReusableCell(
withIdentifier: WalletLoadingCell.reuseIdentifier,
for: indexPath
)
}
}
}
@ -184,9 +214,15 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
points: viewModel.availablePoints
)
applyTabAppearance()
filterPanel.isHidden = viewModel.selectedTab != .transaction
updateContentLayout()
filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel)
refreshControl.endRefreshing()
let isRefreshing = switch viewModel.selectedTab {
case .withdraw: viewModel.withdrawRefreshing
case .transaction: viewModel.transactionRefreshing
}
if !isRefreshing {
refreshControl.endRefreshing()
}
applySnapshot()
if let message = viewModel.errorMessage, message != lastShownMessage {
lastShownMessage = message
@ -195,12 +231,35 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
private func applyTabAppearance() {
let selectedColor = UIColor(hex: 0x1677FF)
let normalColor = UIColor(hex: 0x666666)
let selectedColor = WalletTokens.summaryBackground
let normalColor = WalletTokens.tabNormal
withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal)
transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? selectedColor : normalColor, for: .normal)
withdrawTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .withdraw ? .semibold : .regular)
transactionTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .transaction ? .semibold : .regular)
withdrawTabButton.titleLabel?.font = viewModel.selectedTab == .withdraw ? WalletTokens.tabSelectedFont : WalletTokens.tabFont
transactionTabButton.titleLabel?.font = viewModel.selectedTab == .transaction ? WalletTokens.tabSelectedFont : WalletTokens.tabFont
tabIndicator.snp.remakeConstraints { make in
make.width.equalTo(24)
make.height.equalTo(3)
make.bottom.equalTo(tabStack)
make.centerX.equalTo(viewModel.selectedTab == .withdraw ? withdrawTabButton : transactionTabButton)
}
UIView.animate(withDuration: 0.2) {
self.contentCard.layoutIfNeeded()
}
}
private func updateContentLayout() {
let showsFilter = viewModel.selectedTab == .transaction
filterPanel.isHidden = !showsFilter
filterPanel.snp.remakeConstraints { make in
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
if showsFilter {
make.top.equalTo(tabStack.snp.bottom).offset(WalletTokens.rowGap)
} else {
make.top.equalTo(tabStack.snp.bottom)
make.height.equalTo(0)
}
}
}
private func applySnapshot() {
@ -208,6 +267,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
snapshot.appendSections([.main])
if viewModel.selectedTab == .withdraw {
snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main)
if viewModel.withdrawLoading {
snapshot.appendItems([.loading], toSection: .main)
}
} else {
let items = viewModel.transactionEntries.map { entry -> Item in
switch entry {
@ -216,6 +278,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
}
snapshot.appendItems(items, toSection: .main)
if viewModel.transactionLoading {
snapshot.appendItems([.loading], toSection: .main)
}
}
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
@ -236,24 +301,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
}
let label = UILabel()
label.text = emptyText
label.textColor = UIColor(hex: 0xB3B8C2)
label.font = .systemFont(ofSize: 14)
label.textColor = WalletTokens.textDisabled
label.font = WalletTokens.bodyFont
label.textAlignment = .center
tableView.backgroundView = label
}
private func presentFilterMenu() {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
WalletFilter.allCases.forEach { filter in
alert.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func openPointsRedemption() {
navigationController?.pushViewController(PointsRedemptionViewController(), animated: true)
}
@ -270,9 +323,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
Task {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.refreshSummary(api: walletAPI)
await viewModel.refreshWithdraw(api: walletAPI)
await viewModel.refreshPoints(api: walletAPI)
case .transaction:
await viewModel.refreshTransaction(api: walletAPI)
}
@ -363,17 +414,17 @@ private final class WalletSummaryCardView: UIView {
}
private func setupUI() {
backgroundColor = UIColor(hex: 0x1677FF)
layer.cornerRadius = 24
backgroundColor = WalletTokens.summaryBackground
layer.cornerRadius = WalletTokens.summaryRadius
clipsToBounds = true
withdrawableTitle.text = "可提现金额"
withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8)
withdrawableTitle.font = .systemFont(ofSize: 14)
withdrawableTitle.font = WalletTokens.bodyFont
withdrawableTitle.textAlignment = .center
withdrawableLabel.textColor = .white
withdrawableLabel.font = .systemFont(ofSize: 36, weight: .bold)
withdrawableLabel.font = WalletTokens.displayFont
withdrawableLabel.textAlignment = .center
withdrawableLabel.adjustsFontSizeToFitWidth = true
withdrawableLabel.minimumScaleFactor = 0.7
@ -388,17 +439,17 @@ private final class WalletSummaryCardView: UIView {
addSubview(metricsStack)
withdrawableTitle.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
make.top.equalToSuperview().offset(WalletTokens.cardPadding)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
}
withdrawableLabel.snp.makeConstraints { make in
make.top.equalTo(withdrawableTitle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
make.top.equalTo(withdrawableTitle.snp.bottom).offset(WalletTokens.tinyGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
}
metricsStack.snp.makeConstraints { make in
make.top.equalTo(withdrawableLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(12)
make.bottom.equalToSuperview().inset(16)
make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
make.bottom.equalToSuperview().inset(WalletTokens.cardPadding)
}
}
@ -432,11 +483,11 @@ private final class WalletSummaryMetricView: UIControl {
private func setupUI() {
titleLabel.textColor = UIColor.white.withAlphaComponent(0.8)
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.font = WalletTokens.bodyFont
titleLabel.textAlignment = .center
valueLabel.textColor = .white
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.font = WalletTokens.bodyMediumFont
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75
@ -469,7 +520,7 @@ private final class WalletSummaryMetricView: UIControl {
///
private final class WalletFilterPanelView: UIView {
var onFilterTap: (() -> Void)?
var onFilterSelected: ((WalletFilter) -> Void)?
private let filterButton = UIButton(type: .system)
private let totalTitleLabel = UILabel()
@ -487,30 +538,40 @@ private final class WalletFilterPanelView: UIView {
}
func apply(filter: WalletFilter, totalIncome: String, totalPoints: Int) {
filterButton.setTitle("\(filter.title)", for: .normal)
filterButton.configuration?.title = filter.title
configureMenu(selected: filter)
totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome
pointsLabel.text = "积分+\(totalPoints)"
}
private func setupUI() {
backgroundColor = UIColor(hex: 0xF6F6F6)
layer.cornerRadius = 16
backgroundColor = WalletTokens.filterBackground
layer.cornerRadius = WalletTokens.contentRadius
clipsToBounds = true
filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal)
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
filterButton.titleLabel?.font = WalletTokens.bodyFont
filterButton.contentHorizontalAlignment = .left
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
filterButton.backgroundColor = WalletTokens.inputFocusedBackground
filterButton.layer.cornerRadius = WalletTokens.contentRadius
filterButton.showsMenuAsPrimaryAction = true
filterButton.changesSelectionAsPrimaryAction = false
filterButton.configuration = .plain()
filterButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 12)
filterButton.configuration?.image = UIImage(systemName: "chevron.down")
filterButton.configuration?.imagePlacement = .trailing
filterButton.configuration?.imagePadding = 8
filterButton.tintColor = WalletTokens.text333
totalTitleLabel.text = "总收益:"
totalTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalTitleLabel.font = WalletTokens.bodyMediumFont
totalTitleLabel.textColor = .black
totalLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalLabel.textColor = UIColor(hex: 0x0073FF)
totalLabel.font = WalletTokens.bodyMediumFont
totalLabel.textColor = WalletTokens.primary
pointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.font = WalletTokens.bodyMediumFont
pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right
addSubview(filterButton)
@ -519,15 +580,14 @@ private final class WalletFilterPanelView: UIView {
addSubview(pointsLabel)
filterButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.equalToSuperview().offset(16)
make.width.lessThanOrEqualTo(150)
make.height.equalTo(32)
make.top.leading.equalToSuperview()
make.width.lessThanOrEqualTo(WalletTokens.filterFieldMaxWidth)
make.height.equalTo(WalletTokens.filterFieldHeight)
}
totalTitleLabel.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom).offset(4)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(12)
make.top.equalTo(filterButton.snp.bottom)
make.leading.equalToSuperview().offset(WalletTokens.cardPadding)
make.bottom.equalToSuperview().inset(WalletTokens.rowGap)
}
totalLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
@ -540,7 +600,15 @@ private final class WalletFilterPanelView: UIView {
}
}
@objc private func filterTapped() {
onFilterTap?()
private func configureMenu(selected: WalletFilter) {
let actions = WalletFilter.allCases.map { [weak self] filter in
UIAction(
title: filter.title,
state: filter == selected ? .on : .off
) { _ in
self?.onFilterSelected?(filter)
}
}
filterButton.menu = UIMenu(children: actions)
}
}

View File

@ -51,14 +51,22 @@ final class WithdrawViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "提现设置"
let titleLabel = UILabel()
titleLabel.text = "提现设置"
titleLabel.textColor = WalletTokens.text333
titleLabel.font = UIFont.systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8)
view.backgroundColor = WalletTokens.withdrawPageBackground
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.spacing = WalletTokens.cardGap
contentStack.backgroundColor = .white
contentStack.layer.cornerRadius = WalletTokens.cardRadius
contentStack.clipsToBounds = true
bottomBar.backgroundColor = .white
bottomBar.isHidden = true
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
@ -76,16 +84,16 @@ final class WithdrawViewController: BaseViewController {
make.leading.trailing.bottom.equalToSuperview()
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
make.top.equalToSuperview().offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(WalletTokens.screenInset)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.edges.equalToSuperview().inset(WalletTokens.screenInset)
make.width.equalTo(scrollView).offset(-32)
}
}
@ -98,7 +106,15 @@ final class WithdrawViewController: BaseViewController {
codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged)
codeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
Task { await viewModel.load(api: walletAPI) }
Task {
showLoading()
await viewModel.load(api: walletAPI)
hideLoading()
if viewModel.withdrawInfo == nil, let message = viewModel.errorMessage {
navigationController?.popViewController(animated: true)
(navigationController?.topViewController as? BaseViewController)?.showToast(message)
}
}
}
private func applyState() {
@ -116,9 +132,24 @@ final class WithdrawViewController: BaseViewController {
codeButton.setTitle(viewModel.countdown > 0 ? "\(viewModel.countdown)s" : "获取验证码", for: .normal)
codeButton.isEnabled = viewModel.countdown == 0
submitButton.isEnabled = viewModel.canSubmit
updateBottomBarVisibility(hasContent: viewModel.withdrawInfo != nil && !viewModel.loading)
if viewModel.countdown > 0 { startCountdownTimerIfNeeded() }
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
if viewModel.withdrawInfo != nil {
showMessageIfNeeded(viewModel.errorMessage)
}
}
private func updateBottomBarVisibility(hasContent: Bool) {
bottomBar.isHidden = !hasContent
scrollView.snp.remakeConstraints { make in
make.top.leading.trailing.equalToSuperview()
if hasContent {
make.bottom.equalTo(bottomBar.snp.top)
} else {
make.bottom.equalToSuperview()
}
}
}
private func makeAmountCard() -> UIView {
@ -129,19 +160,19 @@ final class WithdrawViewController: BaseViewController {
row.alignment = .center
row.spacing = 16
let withdrawableTitle = makeBodyLabel("可提现金额", color: .black, weight: .medium)
withdrawableLabel.font = .systemFont(ofSize: 14, weight: .medium)
withdrawableLabel.textColor = UIColor(hex: 0x0073FF)
withdrawableLabel.font = WalletTokens.bodyMediumFont
withdrawableLabel.textColor = WalletTokens.primary
row.addArrangedSubview(withdrawableTitle)
row.addArrangedSubview(withdrawableLabel)
let amountContainer = borderedInputContainer()
amountField.placeholder = "请输入金额"
amountField.keyboardType = .decimalPad
amountField.font = .systemFont(ofSize: 14)
amountField.font = WalletTokens.bodyFont
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14)
allButton.setTitleColor(WalletTokens.primary, for: .normal)
allButton.titleLabel?.font = WalletTokens.bodyFont
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
amountContainer.addSubview(amountField)
amountContainer.addSubview(allButton)
@ -155,10 +186,10 @@ final class WithdrawViewController: BaseViewController {
make.centerY.equalToSuperview()
}
limitLabel.font = .systemFont(ofSize: 12)
limitLabel.textColor = UIColor(hex: 0x9CA3AF)
phoneLabel.font = .systemFont(ofSize: 12)
phoneLabel.textColor = UIColor(hex: 0x4B5563)
limitLabel.font = WalletTokens.captionFont
limitLabel.textColor = WalletTokens.textTertiary
phoneLabel.font = WalletTokens.captionFont
phoneLabel.textColor = WalletTokens.textSecondary
let codeRow = UIStackView()
codeRow.axis = .horizontal
@ -166,18 +197,19 @@ final class WithdrawViewController: BaseViewController {
let codeContainer = borderedInputContainer()
codeField.placeholder = "请输入验证码"
codeField.keyboardType = .numberPad
codeField.font = .systemFont(ofSize: 14)
codeField.font = WalletTokens.bodyFont
codeContainer.addSubview(codeField)
codeField.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) }
codeButton.setTitle("获取验证码", for: .normal)
codeButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
codeButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
codeButton.setTitleColor(WalletTokens.primary, for: .normal)
codeButton.setTitleColor(WalletTokens.textDisabled, for: .disabled)
codeButton.titleLabel?.font = WalletTokens.bodyMediumFont
codeButton.layer.borderWidth = 1
codeButton.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
codeButton.layer.cornerRadius = 4
codeButton.layer.borderColor = WalletTokens.border.cgColor
codeButton.layer.cornerRadius = WalletTokens.chipRadius
codeRow.addArrangedSubview(codeContainer)
codeRow.addArrangedSubview(codeButton)
codeContainer.snp.makeConstraints { make in make.height.equalTo(46) }
codeContainer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.inputHeight) }
codeButton.snp.makeConstraints { make in make.width.equalTo(100) }
let stack = UIStackView(arrangedSubviews: [title, row, amountContainer, limitLabel, phoneLabel, codeRow])
@ -185,23 +217,26 @@ final class WithdrawViewController: BaseViewController {
stack.spacing = 12
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
amountContainer.snp.makeConstraints { make in make.height.equalTo(46) }
amountContainer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.inputHeight) }
return card
}
private func makeBankCard() -> UIView {
let card = makeCard()
let title = makeTitle("银行卡信息")
let title = makeTitle("银行卡信息", size: 16)
let inner = makeInnerPanel()
bankCardLabel.font = .systemFont(ofSize: 14, weight: .medium)
bankCardLabel.textColor = UIColor(hex: 0x333333)
bankHolderLabel.font = .systemFont(ofSize: 14)
bankHolderLabel.textColor = UIColor(hex: 0x4B5563)
bankCardLabel.font = WalletTokens.bodyMediumFont
bankCardLabel.textColor = WalletTokens.text333
bankHolderLabel.font = WalletTokens.bodyFont
bankHolderLabel.textColor = WalletTokens.textSecondary
let innerStack = UIStackView(arrangedSubviews: [bankCardLabel, bankHolderLabel])
innerStack.axis = .vertical
innerStack.spacing = 12
inner.addSubview(innerStack)
innerStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
innerStack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
@ -212,16 +247,19 @@ final class WithdrawViewController: BaseViewController {
private func makeSettlementCard() -> UIView {
let card = makeCard()
let title = makeTitle("预计到账时间")
let title = makeTitle("预计到账时间", size: 16)
let inner = makeInnerPanel()
settlementLabel.font = .systemFont(ofSize: 14)
settlementLabel.textColor = UIColor(hex: 0x4B5563)
settlementLabel.font = WalletTokens.bodyFont
settlementLabel.textColor = WalletTokens.textSecondary
settlementLabel.numberOfLines = 0
inner.addSubview(settlementLabel)
settlementLabel.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
settlementLabel.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
stack.spacing = WalletTokens.tinyGap
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
@ -229,15 +267,18 @@ final class WithdrawViewController: BaseViewController {
private func makeInfoCard() -> UIView {
let card = makeCard()
let title = makeTitle("提现说明")
let title = makeTitle("提现说明", size: 16)
let inner = makeInnerPanel()
infoStack.axis = .vertical
infoStack.spacing = 4
inner.addSubview(infoStack)
infoStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
infoStack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap)
make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding)
}
let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical
stack.spacing = 8
stack.spacing = WalletTokens.rowGap
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card
@ -246,32 +287,32 @@ final class WithdrawViewController: BaseViewController {
private func makeCard() -> UIView {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 12
view.layer.cornerRadius = WalletTokens.cardRadius
view.clipsToBounds = true
return view
}
private func makeInnerPanel() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0xF4F4F4)
view.layer.cornerRadius = 8
view.backgroundColor = WalletTokens.inputBackground
view.layer.cornerRadius = WalletTokens.innerRadius
view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
view.layer.borderColor = WalletTokens.border.cgColor
return view
}
private func borderedInputContainer() -> UIView {
let view = UIView()
view.layer.cornerRadius = 4
view.layer.cornerRadius = WalletTokens.chipRadius
view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
view.layer.borderColor = WalletTokens.border.cgColor
return view
}
private func makeTitle(_ text: String) -> UILabel {
private func makeTitle(_ text: String, size: CGFloat = 18) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 18, weight: .medium)
label.font = .systemFont(ofSize: size, weight: .medium)
label.textColor = .black
return label
}
@ -287,7 +328,7 @@ final class WithdrawViewController: BaseViewController {
private func applyInfoLines(_ lines: [String]) {
infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
lines.forEach { text in
let label = makeBodyLabel(text, color: UIColor(hex: 0x4B5563))
let label = makeBodyLabel(text, color: WalletTokens.textSecondary)
label.numberOfLines = 0
infoStack.addArrangedSubview(label)
}

View File

@ -62,4 +62,54 @@ final class ProfileModelsTests: XCTestCase {
XCTAssertTrue(info.verified)
}
func testRealNameInfoDecodesCompleteAuditDetails() throws {
let json = """
{
"real_name": "张三",
"id_card_no": "11010519491231002X",
"audit_status": "3",
"audit_status_text": "审核驳回",
"reject_reason": "国徽面照片模糊",
"start_date": "2020-01-01",
"end_date": "2030-01-01",
"is_long_valid": "0",
"front_url": "https://cdn.example.com/front.jpg",
"back_url": "https://cdn.example.com/back.jpg",
"auditor_id": "8",
"auditor": { "id": "8", "name": "审核员" },
"audit_at": "2026-07-13 12:00:00"
}
""".data(using: .utf8)!
let info = try JSONDecoder().decode(RealNameInfo.self, from: json)
XCTAssertEqual(info.auditStatus, 3)
XCTAssertEqual(info.rejectReason, "")
XCTAssertEqual(info.frontUrl, "https://cdn.example.com/front.jpg")
XCTAssertEqual(info.backUrl, "https://cdn.example.com/back.jpg")
XCTAssertEqual(info.auditorId, 8)
XCTAssertEqual(info.auditor?.name, "审核员")
XCTAssertEqual(info.auditAt, "2026-07-13 12:00:00")
}
func testRealNameInfoRemainsCompatibleWhenOptionalAuditFieldsAreMissing() throws {
let json = """
{
"real_name": "",
"id_card_no": "11010519491231002X",
"audit_status": 1,
"is_long_valid": 1
}
""".data(using: .utf8)!
let info = try JSONDecoder().decode(RealNameInfo.self, from: json)
XCTAssertEqual(info.auditStatus, 1)
XCTAssertTrue(info.isLongValid)
XCTAssertNil(info.rejectReason)
XCTAssertNil(info.frontUrl)
XCTAssertNil(info.auditor)
XCTAssertNil(info.auditAt)
}
}

View File

@ -82,4 +82,22 @@ final class PunchPointModelsTests: XCTestCase {
XCTAssertTrue(image.isUploaded)
XCTAssertFalse(image.isUploading)
}
func testOperatingAndAuditStatusPresentationMatchAndroid() {
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(1).title, "营运中")
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(2).title, "已暂停")
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(4).title, "未上线")
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(1).title, "已通过")
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(2).textColor, 0x22C55E)
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(3).title, "待审核")
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(4).title, "已驳回")
}
func testAllAndroidFiltersKeepExactStatusValuesAndTitles() {
XCTAssertEqual(PunchPointFilterType.allCases.map(\.apiStatus), [0, 1, 2, 3, 4])
XCTAssertEqual(
PunchPointFilterType.allCases.map(\.title),
["全部", "运营中", "已暂停", "未审核", "审核未通过"]
)
}
}

View File

@ -39,8 +39,10 @@ final class PunchPointViewModelTests: XCTestCase {
let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 })
var messages: [String] = []
var qrcode: String?
var removalID: Int64?
viewModel.onShowMessage = { messages.append($0) }
viewModel.onShowQRCode = { qrcode = $0 }
viewModel.onBeginDeleteAnimation = { removalID = $0 }
await viewModel.loadInitial(api: api)
viewModel.showQRCode(for: viewModel.items[1])
@ -50,6 +52,9 @@ final class PunchPointViewModelTests: XCTestCase {
XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"])
XCTAssertEqual(qrcode, "https://cdn/qr.png")
XCTAssertEqual(removalID, 1)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
viewModel.completeDeleteAnimation(id: 1)
XCTAssertEqual(viewModel.items.map(\.id), [2])
XCTAssertEqual(api.deletedIds, [1])
}
@ -140,6 +145,59 @@ final class PunchPointViewModelTests: XCTestCase {
XCTAssertTrue(api.addRequests.isEmpty)
}
func testImageLimitAcceptsNineAndRejectsFurtherSelection() async {
let uploader = MockPunchPointUploader()
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 })
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
let images = (0 ..< 10).map {
PunchPointImageState(data: Data([UInt8($0)]), fileName: "\($0).jpg")
}
await viewModel.addLocalImages(images, uploader: uploader)
await viewModel.addLocalImages(
[PunchPointImageState(data: Data([99]), fileName: "extra.jpg")],
uploader: uploader
)
XCTAssertEqual(viewModel.images.count, 9)
XCTAssertEqual(uploader.uploads.count, 9)
XCTAssertEqual(messages.last, "最多只能选择9张图片")
}
func testFailedImageCanRetryAndBecomeUploaded() async {
let uploader = SequencedPunchPointUploader()
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 })
await viewModel.addLocalImages(
[PunchPointImageState(data: Data([1]), fileName: "retry.jpg")],
uploader: uploader
)
XCTAssertNotNil(viewModel.images.first?.errorMessage)
await viewModel.retryUpload(at: 0, uploader: uploader)
XCTAssertNil(viewModel.images.first?.errorMessage)
XCTAssertEqual(viewModel.images.first?.uploadedURL, "https://cdn/retry.jpg")
XCTAssertEqual(uploader.callCount, 2)
}
func testAutoLocationFillsCoordinateAndAddress() async {
let location = MockPunchPointLocationProvider(
snapshot: HomeLocationSnapshot(latitude: 31.2, longitude: 121.5, address: "当前位置地址")
)
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location)
var changedCoordinate: CLLocationCoordinate2D?
viewModel.onCoordinateChange = { changedCoordinate = $0 }
await viewModel.autoLocation()
XCTAssertEqual(viewModel.coordinate?.latitude, 31.2)
XCTAssertEqual(viewModel.coordinate?.longitude, 121.5)
XCTAssertEqual(viewModel.address, "当前位置地址")
XCTAssertEqual(changedCoordinate?.latitude, 31.2)
}
private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem {
PunchPointItem(
id: id,
@ -233,6 +291,25 @@ private final class SlowPunchPointUploader: PunchPointImageUploading {
}
}
@MainActor
private final class SequencedPunchPointUploader: PunchPointImageUploading {
var callCount = 0
func uploadPunchPointImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
callCount += 1
if callCount == 1 {
throw URLError(.cannotConnectToHost)
}
onProgress(100)
return "https://cdn/retry.jpg"
}
}
private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable {
let snapshot: HomeLocationSnapshot
let reverseAddress: String

View File

@ -0,0 +1,66 @@
//
// RealNameAuthAPITests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// API
@MainActor
final class RealNameAuthAPITests: XCTestCase {
func testInfoUsesAndroidGETPath() async throws {
let response = Data(#"{"code":100000,"msg":"success","data":{"real_name_info":null}}"#.utf8)
let session = MockURLSession(responses: [response])
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.realNameInfo()
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/info")
}
func testSMSCodeUsesPOSTWithoutBody() async throws {
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
let session = MockURLSession(responses: [response])
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
try await api.realNameSmsVerifyCode()
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/sms-verify-code")
XCTAssertNil(request.httpBody)
}
func testSubmitUsesAndroidJSONFields() async throws {
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
let session = MockURLSession(responses: [response])
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
let payload = RealNameAuthRequest(
realName: "张三",
idCardNo: "11010519491231002X",
smsVerifyCode: "123456",
startDate: "2026-07-13",
endDate: "",
isLongValid: 1,
frontUrl: "front",
backUrl: "back"
)
try await api.realNameSubmit(payload)
let request = try XCTUnwrap(session.requests.first)
let body = try XCTUnwrap(request.httpBody)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/submit")
XCTAssertEqual(object["real_name"] as? String, "张三")
XCTAssertEqual(object["id_card_no"] as? String, "11010519491231002X")
XCTAssertEqual(object["sms_verify_code"] as? String, "123456")
XCTAssertEqual(object["is_long_valid"] as? Int, 1)
XCTAssertEqual(object["front_url"] as? String, "front")
XCTAssertEqual(object["back_url"] as? String, "back")
}
}

View File

@ -3,17 +3,157 @@
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class RealNameAuthViewModelTests: XCTestCase {
func testValidationRequiresSmsCodeWhenNotVerified() {
func testNewApplicationStartsAtIdentityStepAndRequiresName() {
let viewModel = RealNameAuthViewModel()
XCTAssertEqual(viewModel.mode, .newApplication)
XCTAssertEqual(viewModel.currentStep, .identity)
XCTAssertFalse(viewModel.isAgreed)
XCTAssertFalse(viewModel.canGoNextStep)
XCTAssertEqual(viewModel.validationMessage, "请输入真实姓名")
}
func testInputFilteringMatchesFormRules() {
let viewModel = RealNameAuthViewModel()
viewModel.updateIdCardNo("11010519491231002xABC")
viewModel.updateSmsCode("1a2b345678901234")
XCTAssertEqual(viewModel.idCardNo, "11010519491231002X")
XCTAssertEqual(viewModel.smsCode, "12345678901")
}
func testModesFollowAuditStatus() throws {
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 1)).mode, .pending)
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 2)).mode, .approved)
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 3)).mode, .rejected)
}
func testRejectedInfoIsEditableAndCanAdvance() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
XCTAssertTrue(viewModel.isAgreed)
XCTAssertTrue(viewModel.canGoNextStep)
XCTAssertNil(viewModel.goToVerificationStep())
XCTAssertEqual(viewModel.currentStep, .verification)
XCTAssertFalse(viewModel.canSubmit)
}
func testReadOnlyInfoCanBrowseSecondStepButCannotSubmit() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 2))
XCTAssertNil(viewModel.goToVerificationStep())
XCTAssertEqual(viewModel.currentStep, .verification)
XCTAssertFalse(viewModel.canSubmit)
viewModel.updateRealName("不能修改")
XCTAssertEqual(viewModel.realName, "张三")
}
func testLongValidityClearsEndDate() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
XCTAssertNotNil(viewModel.endDate)
viewModel.updateLongValid(true)
XCTAssertTrue(viewModel.isLongValid)
XCTAssertNil(viewModel.endDate)
XCTAssertTrue(viewModel.canGoNextStep)
}
func testEndDateCannotBeEarlierThanStartDate() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
viewModel.updateEndDate(Self.date("2026-07-12"))
XCTAssertFalse(viewModel.canGoNextStep)
XCTAssertEqual(viewModel.validationMessage, "证件结束日期不能早于起始日期")
}
func testAgreementControlsStepTransition() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
XCTAssertTrue(viewModel.canGoNextStep)
viewModel.toggleAgreement()
XCTAssertFalse(viewModel.canGoNextStep)
XCTAssertEqual(viewModel.goToVerificationStep(), "请阅读并同意用户协议与隐私政策")
XCTAssertEqual(viewModel.currentStep, .identity)
}
func testImmediateUploadsProduceURLsAndEnableNextStep() async throws {
let viewModel = RealNameAuthViewModel()
viewModel.updateRealName("张三")
viewModel.updateIdCardNo("11010519491231002X")
viewModel.updateStartDate(Self.date("2026-07-13"))
viewModel.updateEndDate(Self.date("2036-07-13"))
viewModel.toggleAgreement()
let imageData = try XCTUnwrap(Self.imageData())
let uploader = MockRealNameOSSUploadService()
XCTAssertEqual(viewModel.validationMessage, "请输入短信验证码")
try await viewModel.uploadIdentityImage(data: imageData, side: .back, uploader: uploader, scenicId: 9, timestamp: 1)
try await viewModel.uploadIdentityImage(data: imageData, side: .front, uploader: uploader, scenicId: 9, timestamp: 2)
XCTAssertEqual(viewModel.backUrl, "https://cdn.example.com/real_name_back_1.jpg")
XCTAssertEqual(viewModel.frontUrl, "https://cdn.example.com/real_name_front_2.jpg")
XCTAssertEqual(viewModel.backUploadProgress, 100)
XCTAssertTrue(viewModel.canGoNextStep)
viewModel.deleteIdentityImage(side: .front)
XCTAssertTrue(viewModel.frontUrl.isEmpty)
XCTAssertFalse(viewModel.canGoNextStep)
}
func testUploadFailureClearsPendingPreviewAndURL() async throws {
let viewModel = RealNameAuthViewModel()
let imageData = try XCTUnwrap(Self.imageData())
do {
try await viewModel.uploadIdentityImage(
data: imageData,
side: .front,
uploader: MockRealNameOSSUploadService(shouldFail: true),
scenicId: 9
)
XCTFail("Expected upload failure")
} catch {
XCTAssertTrue(viewModel.frontUrl.isEmpty)
XCTAssertNil(viewModel.frontPreviewImageData)
XCTAssertFalse(viewModel.isFrontUploading)
}
}
func testRequestUsesNormalizedFieldsAndLongTermDate() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
viewModel.updateLongValid(true)
viewModel.updateSmsCode("12a3456")
_ = viewModel.goToVerificationStep()
let request = try viewModel.makeRequest()
XCTAssertEqual(request.idCardNo, "11010519491231002X")
XCTAssertEqual(request.smsVerifyCode, "123456")
XCTAssertEqual(request.isLongValid, 1)
XCTAssertEqual(request.endDate, "")
}
func testSendCodeStartsSixtySecondCountdown() async throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
_ = viewModel.goToVerificationStep()
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
let api = ProfileAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [response])))
try await viewModel.sendCode(api: api)
XCTAssertEqual(viewModel.otpCountdown, 60)
XCTAssertFalse(viewModel.canRequestCode)
XCTAssertEqual(viewModel.statusMessage, "验证码发送成功")
}
func testIsValidMainlandIDCardNumberChecksChecksum() {
@ -27,4 +167,67 @@ final class RealNameAuthViewModelTests: XCTestCase {
"11010519491231002X"
)
}
private func makeInfo(status: Int) throws -> RealNameInfo {
let json = Data(
"""
{
"real_name":"",
"id_card_no":"11010519491231002X",
"audit_status":\(status),
"audit_status_text":"",
"start_date":"2026-07-13",
"end_date":"2036-07-13",
"is_long_valid":0,
"front_url":"https://cdn.example.com/front.jpg",
"back_url":"https://cdn.example.com/back.jpg",
"auditor":{"id":1,"name":"审核员"},
"audit_at":"2026-07-13 12:00:00",
"reject_reason":"照片模糊"
}
""".utf8
)
return try JSONDecoder().decode(RealNameInfo.self, from: json)
}
private static func date(_ value: String) -> Date {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: value)!
}
private static func imageData() -> Data? {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 24))
let image = renderer.image { context in
UIColor.white.setFill()
context.fill(CGRect(x: 0, y: 0, width: 40, height: 24))
}
return image.jpegData(compressionQuality: 0.9)
}
}
/// 实名认证上传测试替身。
@MainActor
private struct MockRealNameOSSUploadService: OSSUploadServing {
let shouldFail: Bool
init(shouldFail: Bool = false) {
self.shouldFail = shouldFail
}
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
"https://cdn.example.com/avatar.jpg"
}
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if shouldFail { throw URLError(.cannotWriteToFile) }
onProgress(100)
return "https://cdn.example.com/\(fileName)"
}
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
"https://cdn.example.com/bank.jpg"
}
}

View File

@ -106,4 +106,52 @@ final class TravelAlbumOTGCoreTests: XCTestCase {
XCTAssertEqual(key, "punch_point/20260709/u900/aaaaaaaabbbbccccddddeeeeeeeeeeee_guide_photo.jpg")
}
func testOSSRealNameObjectKeyUsesAndroidUserPath() {
let previousUserId = AppStore.shared.session.userId
AppStore.shared.session.userId = "u901"
defer { AppStore.shared.session.userId = previousUserId }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13))!
let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
let key = OSSUploadPolicy.objectKey(
fileName: "id/front.jpg",
scenicId: 9,
moduleType: "real_name_auth",
date: date,
uuid: uuid,
timeZone: TimeZone(secondsFromGMT: 0)!
)
XCTAssertEqual(key, "real_name_auth/20260713/u901/11111111222233334444555555555555_id_front.jpg")
}
func testOSSRealNamePathDoesNotChangeAvatarOrBankCardModules() {
let date = Date(timeIntervalSince1970: 0)
let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
let timeZone = TimeZone(secondsFromGMT: 0)!
let avatar = OSSUploadPolicy.objectKey(
fileName: "avatar.jpg",
scenicId: 9,
moduleType: "user_avatar",
date: date,
uuid: uuid,
timeZone: timeZone
)
let bankCard = OSSUploadPolicy.objectKey(
fileName: "bank.jpg",
scenicId: 9,
moduleType: "bank_card",
date: date,
uuid: uuid,
timeZone: timeZone
)
XCTAssertTrue(avatar.hasPrefix("avatar/19700101/9/"))
XCTAssertTrue(bankCard.hasPrefix("bank_card/19700101/9/"))
}
}

View File

@ -4,6 +4,7 @@
//
import XCTest
import UIKit
@testable import suixinkan
/// API ViewModel
@ -71,6 +72,8 @@ final class WalletFeatureTests: XCTestCase {
XCTAssertEqual(Self.queryItems(from: session.requests[1])["page"], "1")
XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01")
XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9")
XCTAssertNil(session.requests[4].httpBody)
XCTAssertNil(session.requests[6].httpBody)
XCTAssertEqual(Self.bodyString(from: session.requests[5]).contains(#""sms_code":"123456""#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), true)
@ -96,6 +99,46 @@ final class WalletFeatureTests: XCTestCase {
XCTAssertGreaterThanOrEqual(api.earningQueries.count, 2)
}
func testWalletFiltersUseAndroidDateOffsets() async {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13, hour: 12))!
let api = FakeWalletPageService()
let viewModel = WalletViewModel(
staffIdProvider: { 9 },
calendar: calendar,
nowProvider: { now }
)
await viewModel.refreshTransaction(api: api)
await viewModel.selectFilter(.last30Days, api: api)
await viewModel.selectFilter(.last180Days, api: api)
XCTAssertEqual(api.earningQueries.map(\.start), ["2026-07-06", "2026-06-13", "2026-01-14"])
XCTAssertEqual(api.earningQueries.map(\.end), ["2026-07-13", "2026-07-13", "2026-07-13"])
}
func testRefreshFailureKeepsWalletAndPointRecords() async {
let api = FakeWalletPageService()
let walletViewModel = WalletViewModel(staffIdProvider: { 9 })
let pointsViewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
await walletViewModel.refreshWithdraw(api: api)
await walletViewModel.refreshTransaction(api: api)
await pointsViewModel.refreshWithdrawList(api: api)
api.shouldFailWithdrawList = true
api.shouldFailEarningDetail = true
api.shouldFailPointWithdrawList = true
await walletViewModel.refreshWithdraw(api: api)
await walletViewModel.refreshTransaction(api: api)
await pointsViewModel.refreshWithdrawList(api: api)
XCTAssertEqual(walletViewModel.withdrawRecords.count, 1)
XCTAssertEqual(walletViewModel.transactionGroups.count, 1)
XCTAssertEqual(pointsViewModel.withdrawRecords.count, 1)
}
func testWalletWithdrawDestinationFlow() async throws {
let viewModel = WalletViewModel()
@ -107,6 +150,12 @@ final class WalletFeatureTests: XCTestCase {
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .realNamePending)
profile = FakeWalletProfileService(realNameStatus: 3, bankCardStatus: nil)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
guard case .realNameAudit = destination else {
return XCTFail("实名认证驳回应进入审核页")
}
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: nil)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdrawalSettings)
@ -115,6 +164,12 @@ final class WalletFeatureTests: XCTestCase {
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .bankCardPending)
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 3)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
guard case .withdrawalAudit = destination else {
return XCTFail("银行卡驳回应进入审核页")
}
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 2)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdraw)
@ -129,11 +184,21 @@ final class WalletFeatureTests: XCTestCase {
viewModel.updateVerificationCode("12a456")
XCTAssertEqual(viewModel.amount, "12.34")
XCTAssertEqual(viewModel.verificationCode, "12456")
XCTAssertEqual(viewModel.verificationCode, "12a456")
let success = await viewModel.submit(api: api)
XCTAssertTrue(success)
XCTAssertEqual(api.withdrawApplyRequests.first?.amount, "12.34")
viewModel.updateAmount("0")
XCTAssertNil(viewModel.validationMessage)
viewModel.updateAmount("99")
XCTAssertEqual(viewModel.validationMessage, "金额不能大于可提现金额")
await viewModel.requestVerificationCode(api: api)
XCTAssertEqual(viewModel.countdown, 60)
viewModel.decrementCountdown()
XCTAssertEqual(viewModel.countdown, 59)
}
func testPointsRedemptionViewModelClampsAndRefreshes() async {
@ -141,6 +206,8 @@ final class WalletFeatureTests: XCTestCase {
let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
await viewModel.loadInitial(api: api)
viewModel.updatePointsInput("0")
XCTAssertEqual(viewModel.pointsInput, "0")
viewModel.updatePointsInput("999")
XCTAssertEqual(viewModel.pointsInput, "60")
@ -151,6 +218,18 @@ final class WalletFeatureTests: XCTestCase {
XCTAssertEqual(viewModel.withdrawRecords.count, 1)
}
func testWalletTokensAndHomeIconMatchAndroid() {
XCTAssertEqual(WalletTokens.pageBackground.walletHexRGB, 0xF5F6F8)
XCTAssertEqual(WalletTokens.withdrawPageBackground.walletHexRGB, 0xF9FAFB)
XCTAssertEqual(WalletTokens.summaryBackground.walletHexRGB, 0x1677FF)
XCTAssertEqual(WalletTokens.primary.walletHexRGB, 0x0073FF)
XCTAssertEqual(WalletTokens.summaryRadius, 24)
XCTAssertEqual(WalletTokens.contentRadius, 16)
XCTAssertEqual(WalletTokens.cardRadius, 12)
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "home_menu_wallet")
XCTAssertNotNil(UIImage(named: "home_menu_wallet"))
}
private static func envelope(_ dataJSON: String) -> Data {
Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8)
}
@ -175,6 +254,9 @@ private final class FakeWalletPageService: WalletPageServing {
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = []
private(set) var pointApplyRequests: [Int] = []
var shouldFailWithdrawList = false
var shouldFailEarningDetail = false
var shouldFailPointWithdrawList = false
func walletSummary(type: Int) async throws -> WalletSummaryResponse {
WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00")
@ -191,6 +273,7 @@ private final class FakeWalletPageService: WalletPageServing {
}
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
if shouldFailWithdrawList { throw FakeWalletError.requestFailed }
WalletWithdrawListResponse(
total: 1,
item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")]
@ -198,6 +281,7 @@ private final class FakeWalletPageService: WalletPageServing {
}
func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse {
if shouldFailEarningDetail { throw FakeWalletError.requestFailed }
earningQueries.append((startDate, endDate, page))
return EarningDetailResponse(
totalAmount: "12.30",
@ -242,10 +326,18 @@ private final class FakeWalletPageService: WalletPageServing {
}
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
if shouldFailPointWithdrawList { throw FakeWalletError.requestFailed }
PointWithdrawListResponse(total: 1, list: [PointWithdrawItem(id: page, points: 24, amount: 2, status: 1, createdAt: "2026-07-01")])
}
}
///
private enum FakeWalletError: LocalizedError {
case requestFailed
var errorDescription: String? { "请求失败" }
}
@MainActor
///
private struct FakeWalletProfileService: WalletProfileServing {
@ -270,3 +362,14 @@ private struct FakeWalletProfileService: WalletProfileServing {
return try JSONDecoder().decode(BankCardInfoResponse.self, from: data)
}
}
private extension UIColor {
var walletHexRGB: UInt {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return UInt(Int(round(red * 255)) << 16 | Int(round(green * 255)) << 8 | Int(round(blue * 255)))
}
}