完善实名认证、钱包与打卡点模块 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") 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 /// App H5
nonisolated static var appDownload: URL { nonisolated static var appDownload: URL {
APIEnvironment.current.baseURL.appending(path: "h5/app/download") 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 { 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 { func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
@ -303,8 +303,10 @@ enum OSSUploadPolicy {
switch moduleType { switch moduleType {
case "user_avatar": case "user_avatar":
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "real_name": case "real_name_auth":
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" 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": case "bank_card":
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "task_upload": case "task_upload":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 mapView = PunchPointMapView()
private let zoomStack = UIStackView() private let zoomStack = UIStackView()
private let zoomInButton = PunchPointMapControlButton(symbol: "plus") private let zoomInButton = PunchPointMapControlButton(assetName: "punch_point_zoom_in")
private let zoomOutButton = PunchPointMapControlButton(symbol: "minus") private let zoomOutButton = PunchPointMapControlButton(assetName: "punch_point_zoom_out")
private let locateButton = PunchPointMapControlButton(symbol: "location.fill") private let locateButton = PunchPointMapControlButton(assetName: "punch_point_map_location")
private let cardView = UIView() private let cardView = UIView()
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentStack = UIStackView() private let contentStack = UIStackView()
@ -102,11 +102,11 @@ final class PunchPointDetailViewController: BaseViewController {
} }
zoomStack.snp.makeConstraints { make in zoomStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16) 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 locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16) 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) make.size.equalTo(40)
} }
zoomInButton.snp.makeConstraints { make in zoomInButton.snp.makeConstraints { make in
@ -178,8 +178,11 @@ final class PunchPointDetailViewController: BaseViewController {
bottomBar.isHidden = false bottomBar.isHidden = false
if let region = detail.region { if let region = detail.region {
let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot) let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot)
mapView.updateMarker(coordinate: coordinate) mapView.updatePunchPointMarker(
mapView.setCenter(coordinate, zoomLevel: 16) coordinate: coordinate,
imageURL: detail.guideImages.first,
fitWithUserLocation: true
)
} }
rebuildContent(detail) rebuildContent(detail)
} }
@ -188,7 +191,11 @@ final class PunchPointDetailViewController: BaseViewController {
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name)) contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name))
let coordinateText = detail.region.map { "\($0.lat),\($0.lot)" } ?? "--" 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 ?? "--")) contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点地址", value: detail.displayAddress.nonEmptyTrimmed ?? "--"))
if let description = detail.description?.nonEmptyTrimmed { if let description = detail.description?.nonEmptyTrimmed {
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true)) contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true))
@ -241,7 +248,9 @@ final class PunchPointDetailViewController: BaseViewController {
imageView.isUserInteractionEnabled = true imageView.isUserInteractionEnabled = true
imageView.tag = index imageView.tag = index
if let url = URL(string: urlText) { 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 imageView.snp.makeConstraints { make in
make.width.equalTo(120) make.width.equalTo(120)
@ -266,7 +275,7 @@ final class PunchPointDetailViewController: BaseViewController {
label.font = .systemFont(ofSize: 14) label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746) label.textColor = UIColor(hex: 0x2E3746)
let chip = PunchPointStatusChip() 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(label)
row.addArrangedSubview(chip) row.addArrangedSubview(chip)
row.isLayoutMarginsRelativeArrangement = true row.isLayoutMarginsRelativeArrangement = true
@ -301,7 +310,9 @@ final class PunchPointDetailViewController: BaseViewController {
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.layer.cornerRadius = 16 imageView.layer.cornerRadius = 16
if let url = URL(string: qrCode) { 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 imageView.snp.makeConstraints { make in
make.size.equalTo(180) make.size.equalTo(180)
@ -331,6 +342,11 @@ final class PunchPointDetailViewController: BaseViewController {
} }
Task { Task {
do { 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) let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") } guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") }
try await PHPhotoLibrary.shared().performChanges { 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 { private func requiredTitle(_ text: String) -> NSAttributedString {
let mutable = NSMutableAttributedString(string: text, attributes: [ let mutable = NSMutableAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 14, weight: .medium), .font: UIFont.systemFont(ofSize: 14, weight: .medium),
@ -368,7 +399,35 @@ final class PunchPointDetailViewController: BaseViewController {
} }
@objc private func locateTapped() { @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() { @objc private func retryTapped() {
@ -393,7 +452,10 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)? var onMapTap: ((CLLocationCoordinate2D) -> Void)?
private let mapView: MAMapView 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) { override init(frame: CGRect) {
_ = AMapBootstrap.configureIfNeeded() _ = AMapBootstrap.configureIfNeeded()
@ -403,6 +465,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
mapView.showsUserLocation = true mapView.showsUserLocation = true
mapView.showsCompass = false mapView.showsCompass = false
mapView.showsScale = false mapView.showsScale = false
mapView.touchPOIEnabled = true
mapView.zoomLevel = 15 mapView.zoomLevel = 15
addSubview(mapView) addSubview(mapView)
mapView.snp.makeConstraints { make in mapView.snp.makeConstraints { make in
@ -415,17 +478,45 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
fatalError("init(coder:) has not been implemented") 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?) { func updateMarker(coordinate: CLLocationCoordinate2D?) {
if let markerAnnotation { updatePunchPointMarker(coordinate: coordinate)
mapView.removeAnnotation(markerAnnotation) }
self.markerAnnotation = nil
/// Marker
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
if let selectedAnnotation {
mapView.removeAnnotation(selectedAnnotation)
self.selectedAnnotation = nil
} }
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return } guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MAPointAnnotation() let annotation = MAPointAnnotation()
annotation.coordinate = coordinate annotation.coordinate = coordinate
annotation.title = "打卡点位置" annotation.title = "punch_point_selected"
markerAnnotation = annotation selectedAnnotation = annotation
mapView.addAnnotation(annotation) mapView.addAnnotation(annotation)
} }
@ -453,21 +544,92 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
setCenter(coordinate) 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) { func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
onMapTap?(coordinate) 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 { final class PunchPointMapControlButton: UIButton {
init(symbol: String) { init(assetName: String) {
super.init(frame: .zero) super.init(frame: .zero)
setImage(UIImage(systemName: symbol), for: .normal) setImage(UIImage(named: assetName), for: .normal)
tintColor = AppColor.textPrimary
backgroundColor = .white backgroundColor = .white
layer.cornerRadius = 12 layer.cornerRadius = 12
clipsToBounds = true clipsToBounds = true
imageView?.contentMode = .scaleAspectFit
accessibilityLabel = switch assetName {
case "punch_point_zoom_in": "放大地图"
case "punch_point_zoom_out": "缩小地图"
default: "定位当前位置"
}
} }
@available(*, unavailable) @available(*, unavailable)
@ -479,20 +641,46 @@ final class PunchPointMapControlButton: UIButton {
/// ///
/// ///
final class PunchPointReadOnlyField: UIView { 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) super.init(frame: .zero)
let stack = UIStackView() let stack = UIStackView()
stack.axis = .vertical stack.axis = .vertical
stack.spacing = 8 stack.spacing = 8
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.attributedText = Self.requiredTitle(title) titleLabel.attributedText = Self.requiredTitle(title)
let valueContainer = UIView()
valueContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
valueContainer.layer.cornerRadius = 8
let valueLabel = UILabel() let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--" valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium) valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary valueLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.numberOfLines = multiline ? 0 : 2 valueLabel.numberOfLines = multiline ? 0 : 2
stack.addArrangedSubview(titleLabel) 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) addSubview(stack)
stack.snp.makeConstraints { make in stack.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()

View File

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

View File

@ -15,7 +15,7 @@ final class PunchPointListViewController: BaseViewController {
private let filterContainer = UIView() private let filterContainer = UIView()
private let filterButton = UIButton(type: .system) private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel() 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 filterDropdownView = UIView()
private let filterDropdownStack = UIStackView() private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
@ -43,18 +43,22 @@ final class PunchPointListViewController: BaseViewController {
override func setupNavigationBar() { override func setupNavigationBar() {
title = "打卡点列表" title = "打卡点列表"
navigationController?.navigationBar.titleTextAttributes = [
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
.foregroundColor: UIColor(hex: 0x333333),
]
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = AppColor.pageBackground view.backgroundColor = UIColor(hex: 0xF4F4F4)
filterContainer.backgroundColor = .white filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF5F5F5) filterButton.backgroundColor = UIColor(hex: 0xF5F5F5)
filterButton.layer.cornerRadius = 8 filterButton.layer.cornerRadius = 8
filterButton.accessibilityLabel = "筛选打卡点状态"
filterTitleLabel.text = PunchPointFilterType.all.title filterTitleLabel.text = PunchPointFilterType.all.title
filterTitleLabel.font = .systemFont(ofSize: 14) filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = AppColor.textPrimary filterTitleLabel.textColor = AppColor.textPrimary
filterChevronView.tintColor = AppColor.textSecondary
filterChevronView.contentMode = .scaleAspectFit filterChevronView.contentMode = .scaleAspectFit
filterDropdownView.backgroundColor = .white filterDropdownView.backgroundColor = .white
@ -66,7 +70,7 @@ final class PunchPointListViewController: BaseViewController {
filterDropdownView.isHidden = true filterDropdownView.isHidden = true
filterDropdownStack.axis = .vertical filterDropdownStack.axis = .vertical
tableView.backgroundColor = AppColor.pageBackground tableView.backgroundColor = UIColor(hex: 0xF4F4F4)
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.delegate = self tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
@ -91,6 +95,7 @@ final class PunchPointListViewController: BaseViewController {
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
addButton.backgroundColor = AppColor.primary addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8 addButton.layer.cornerRadius = 8
addButton.accessibilityLabel = "添加打卡点"
emptyView.isHidden = true emptyView.isHidden = true
emptyView.onAction = { [weak self] in self?.addTapped() } emptyView.onAction = { [weak self] in self?.addTapped() }
@ -166,6 +171,9 @@ final class PunchPointListViewController: BaseViewController {
viewModel.onShowQRCode = { [weak self] url in viewModel.onShowQRCode = { [weak self] url in
Task { @MainActor in self?.showQRCodeDialog(url: url) } 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) tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
@ -222,35 +230,49 @@ final class PunchPointListViewController: BaseViewController {
} }
private func confirmDelete(_ item: PunchPointItem) { private func confirmDelete(_ item: PunchPointItem) {
let alert = UIAlertController( let alert = PunchPointMaterialDialogController.confirmation(
title: "删除打卡点", title: "删除打卡点",
message: "确定删除\(item.name)打卡点吗?", message: "确定删除\(item.name)打卡点吗?",
preferredStyle: .alert confirmTitle: "确定",
) onConfirm: { [weak self] in
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) self?.dismiss(animated: true)
alert.addAction(UIAlertAction(title: "确定", style: .destructive) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { await self.viewModel.delete(item: item, api: self.api) } Task { await self.viewModel.delete(item: item, api: self.api) }
}) }
) { [weak self] in
self?.dismiss(animated: true)
}
present(alert, 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) { private func showQRCodeDialog(url: String) {
let controller = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .alert) let controller = PunchPointMaterialDialogController.qrCode(url: url) { [weak self] in
let imageView = UIImageView() self?.dismiss(animated: true)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 12
if let imageURL = URL(string: url) {
imageView.kf.setImage(with: imageURL)
} }
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) present(controller, animated: true)
} }
@ -294,7 +316,7 @@ private final class PunchPointListCell: UITableViewCell {
private let operatingTag = PunchPointStatusChip() private let operatingTag = PunchPointStatusChip()
private let creatorLabel = UILabel() private let creatorLabel = UILabel()
private let createdAtLabel = 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 addressLabel = UILabel()
private let reviewTag = PunchPointStatusChip() private let reviewTag = PunchPointStatusChip()
private let auditTimeLabel = UILabel() private let auditTimeLabel = UILabel()
@ -324,14 +346,14 @@ private final class PunchPointListCell: UITableViewCell {
$0.font = .systemFont(ofSize: 12) $0.font = .systemFont(ofSize: 12)
$0.textColor = AppColor.textSecondary $0.textColor = AppColor.textSecondary
} }
locationIcon.tintColor = UIColor(hex: 0x4B5563) locationIcon.contentMode = .scaleAspectFit
addressLabel.font = .systemFont(ofSize: 12) addressLabel.font = .systemFont(ofSize: 12)
addressLabel.textColor = UIColor(hex: 0x4B5563) addressLabel.textColor = UIColor(hex: 0x4B5563)
addressLabel.numberOfLines = 2 addressLabel.numberOfLines = 2
qrButton.setImage(UIImage(systemName: "qrcode"), for: .normal) qrButton.setImage(UIImage(named: "punch_point_qr"), for: .normal)
qrButton.tintColor = AppColor.textPrimary deleteButton.setImage(UIImage(named: "punch_point_delete"), for: .normal)
deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) qrButton.accessibilityLabel = "查看二维码"
deleteButton.tintColor = AppColor.textPrimary deleteButton.accessibilityLabel = "删除打卡点"
contentView.addSubview(cardView) contentView.addSubview(cardView)
[coverImageView, nameLabel, operatingTag, creatorLabel, createdAtLabel, locationIcon, addressLabel, reviewTag, auditTimeLabel, qrButton, deleteButton].forEach(cardView.addSubview) [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) make.leading.trailing.equalToSuperview().inset(15)
} }
coverImageView.snp.makeConstraints { make in 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) make.size.equalTo(128)
} }
nameLabel.snp.makeConstraints { make in nameLabel.snp.makeConstraints { make in
@ -402,17 +425,19 @@ private final class PunchPointListCell: UITableViewCell {
} }
func apply(item: PunchPointItem) { func apply(item: PunchPointItem) {
contentView.transform = .identity
contentView.alpha = 1
nameLabel.text = item.name nameLabel.text = item.name
creatorLabel.text = "负责人:\(item.creator?.nonEmptyTrimmed ?? "--")" creatorLabel.text = "负责人:\(item.creator?.nonEmptyTrimmed ?? "--")"
createdAtLabel.text = "创建时间:\(item.createdAt)" createdAtLabel.text = "创建时间:\(item.createdAt)"
addressLabel.text = item.displayAddress.nonEmptyTrimmed ?? "--" addressLabel.text = item.displayAddress.nonEmptyTrimmed ?? "--"
auditTimeLabel.text = "审核时间:\(item.auditTime?.nonEmptyTrimmed ?? "--")" auditTimeLabel.text = "审核时间:\(item.auditTime?.nonEmptyTrimmed ?? "--")"
operatingTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status) operatingTag.apply(PunchPointDisplayFormatter.operatingStatus(item.status), compact: true)
reviewTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status) reviewTag.apply(PunchPointDisplayFormatter.auditStatus(item.status), compact: false)
if let url = item.guideImages.first.flatMap(URL.init(string:)) { 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 { } else {
coverImageView.image = UIImage(systemName: "photo") coverImageView.image = UIImage(named: "SplashLogo")
} }
} }
@ -432,14 +457,12 @@ final class PunchPointStatusChip: UIView {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
layer.cornerRadius = 10 layer.cornerRadius = 4
clipsToBounds = true clipsToBounds = true
label.font = .systemFont(ofSize: 12, weight: .medium) label.font = .systemFont(ofSize: 12)
label.textAlignment = .center label.textAlignment = .center
addSubview(label) addSubview(label)
label.snp.makeConstraints { make in label.snp.makeConstraints { make in make.edges.equalToSuperview() }
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
}
} }
@available(*, unavailable) @available(*, unavailable)
@ -448,11 +471,15 @@ final class PunchPointStatusChip: UIView {
} }
/// ///
func apply(text: String, status: Int) { func apply(_ presentation: PunchPointStatusPresentation, compact: Bool = false) {
label.text = text label.text = presentation.title
let colors = PunchPointDisplayFormatter.statusColors(status) backgroundColor = UIColor(hex: presentation.backgroundColor)
backgroundColor = UIColor(hex: colors.background) label.textColor = UIColor(hex: presentation.textColor)
label.textColor = UIColor(hex: colors.text) 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) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
titleLabel.text = "暂无打卡点" titleLabel.text = "暂无打卡点,点击下方按钮即可添加"
titleLabel.font = .systemFont(ofSize: 15) titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = AppColor.textSecondary titleLabel.textColor = AppColor.textSecondary
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
button.setTitle("添加打卡点", for: .normal) button.setTitle("立即添加", for: .normal)
button.setTitleColor(.white, for: .normal) button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
button.backgroundColor = AppColor.primary button.backgroundColor = AppColor.primary
@ -482,7 +509,7 @@ private final class PunchPointEmptyView: UIView {
button.snp.makeConstraints { make in button.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12) make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.centerX.bottom.equalToSuperview() make.centerX.bottom.equalToSuperview()
make.width.equalTo(128) make.width.greaterThanOrEqualTo(96)
make.height.equalTo(40) make.height.equalTo(40)
} }
button.addTarget(self, action: #selector(actionTapped), for: .touchUpInside) 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 { private extension String {
var nonEmptyTrimmed: String? { var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines) let text = trimmingCharacters(in: .whitespacesAndNewlines)

View File

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

View File

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

View File

@ -41,8 +41,17 @@ final class WalletWithdrawRecordCell: UITableViewCell {
configureSteps(progress: progress(for: record.settlementStatus), tint: style.progress) configureSteps(progress: progress(for: record.settlementStatus), tint: style.progress)
progressView.isHidden = record.settlementStatus == 50 progressView.isHidden = record.settlementStatus == 50
stepsStack.isHidden = record.settlementStatus == 50 stepsStack.isHidden = record.settlementStatus == 50
infoLabel.text = infoText(for: record) infoLabel.attributedText = infoText(for: record)
infoLabel.isHidden = infoLabel.text?.isEmpty ?? true 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() { private func setupUI() {
@ -51,23 +60,23 @@ final class WalletWithdrawRecordCell: UITableViewCell {
contentView.backgroundColor = .white contentView.backgroundColor = .white
cardView.backgroundColor = .white cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16 cardView.layer.cornerRadius = WalletTokens.contentRadius
cardView.layer.borderWidth = 1 cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor cardView.layer.borderColor = WalletTokens.cardBorder.cgColor
cardView.clipsToBounds = true cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .bold) amountLabel.font = WalletTokens.metricBoldFont
amountLabel.textColor = .black amountLabel.textColor = WalletTokens.textPrimary
statusLabel.font = .systemFont(ofSize: 12, weight: .medium) statusLabel.font = WalletTokens.captionMediumFont
statusLabel.textAlignment = .center statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4 statusLabel.layer.cornerRadius = WalletTokens.chipRadius
statusLabel.clipsToBounds = true statusLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14) timeLabel.font = WalletTokens.bodyFont
timeLabel.textColor = UIColor(hex: 0x7B8EAA) timeLabel.textColor = WalletTokens.textMuted
progressView.trackTintColor = UIColor(hex: 0xF2F4F8) progressView.trackTintColor = WalletTokens.progressTrack
progressView.layer.cornerRadius = 3 progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true progressView.clipsToBounds = true
@ -75,8 +84,8 @@ final class WalletWithdrawRecordCell: UITableViewCell {
stepsStack.distribution = .equalSpacing stepsStack.distribution = .equalSpacing
stepsStack.alignment = .center stepsStack.alignment = .center
infoLabel.font = .systemFont(ofSize: 14) infoLabel.font = WalletTokens.bodyFont
infoLabel.textColor = UIColor(hex: 0x4B5563) infoLabel.textColor = WalletTokens.textSecondary
infoLabel.numberOfLines = 0 infoLabel.numberOfLines = 0
contentView.addSubview(cardView) contentView.addSubview(cardView)
@ -120,14 +129,14 @@ final class WalletWithdrawRecordCell: UITableViewCell {
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in ["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel() let label = UILabel()
label.text = title label.text = title
label.font = .systemFont(ofSize: 11) label.font = WalletTokens.stepFont
let threshold = Float(index) / 3.0 let threshold = Float(index) / 3.0
label.textColor = progress >= threshold ? tint : UIColor(hex: 0xB3B8C2) label.textColor = progress >= threshold ? tint : WalletTokens.textDisabled
stepsStack.addArrangedSubview(label) stepsStack.addArrangedSubview(label)
} }
let percent = UILabel() let percent = UILabel()
percent.text = "\(Int(progress * 100))%" percent.text = "\(Int(progress * 100))%"
percent.font = .systemFont(ofSize: 12, weight: .medium) percent.font = WalletTokens.captionMediumFont
percent.textColor = tint percent.textColor = tint
stepsStack.addArrangedSubview(percent) stepsStack.addArrangedSubview(percent)
} }
@ -135,13 +144,13 @@ final class WalletWithdrawRecordCell: UITableViewCell {
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) { private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status { switch status {
case 50, 20: case 50, 20:
(UIColor(hex: 0xEBFFF5), UIColor(hex: 0x16C26D), UIColor(hex: 0x1677FF)) (WalletTokens.settlementSuccessBackground, WalletTokens.settlementSuccess, WalletTokens.summaryBackground)
case 60, 30: case 60, 30:
(UIColor(hex: 0xFFF1F0), UIColor(hex: 0xFF4D4F), UIColor(hex: 0xFF4D4F)) (WalletTokens.settlementDangerBackground, WalletTokens.progressDanger, WalletTokens.progressDanger)
case 40: case 40:
(UIColor(hex: 0xFFF3E8), UIColor(hex: 0xFF8A00), UIColor(hex: 0x1677FF)) (WalletTokens.warningBackground, WalletTokens.warning, WalletTokens.summaryBackground)
default: 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 { 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 { if (record.settlementStatus == 60 || record.settlementStatus == 30), !record.auditTime.isEmpty {
let remark = record.auditRemark.isEmpty ? "" : "\n备注信息\(record.auditRemark)" result.append(NSAttributedString(string: "审核时间\(record.auditTime)", attributes: normal))
return "审核时间:\(record.auditTime)\(remark)" }
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 { 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() { private func setupUI() {
selectionStyle = .none selectionStyle = .none
backgroundColor = .white backgroundColor = .white
dateLabel.font = .systemFont(ofSize: 14) dateLabel.font = WalletTokens.bodyFont
dateLabel.textColor = UIColor(hex: 0x9CA3AF) dateLabel.textColor = WalletTokens.textTertiary
incomeLabel.font = .systemFont(ofSize: 14) incomeLabel.font = WalletTokens.bodyFont
incomeLabel.textColor = UIColor(hex: 0x4B5563) incomeLabel.textColor = WalletTokens.textSecondary
incomeLabel.textAlignment = .right incomeLabel.textAlignment = .right
pointsLabel.font = .systemFont(ofSize: 14) pointsLabel.font = WalletTokens.bodyFont
pointsLabel.textColor = UIColor(hex: 0xFF7B00) pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right pointsLabel.textAlignment = .right
contentView.addSubview(dateLabel) contentView.addSubview(dateLabel)
@ -277,27 +302,27 @@ final class WalletTransactionRecordCell: UITableViewCell {
selectionStyle = .none selectionStyle = .none
backgroundColor = .white backgroundColor = .white
cardView.backgroundColor = .white cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12 cardView.layer.cornerRadius = WalletTokens.cardRadius
cardView.layer.borderWidth = 1 cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor cardView.layer.borderColor = WalletTokens.cardBorder.cgColor
cardView.clipsToBounds = true cardView.clipsToBounds = true
amountLabel.font = .systemFont(ofSize: 18, weight: .medium) amountLabel.font = WalletTokens.metricMediumFont
amountLabel.textColor = .black amountLabel.textColor = WalletTokens.textPrimary
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium) pointsLabel.font = WalletTokens.metricMediumFont
pointsLabel.textColor = UIColor(hex: 0xFF7B00) pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right pointsLabel.textAlignment = .right
typeLabel.font = .systemFont(ofSize: 14) typeLabel.font = WalletTokens.bodyFont
typeLabel.textColor = UIColor(hex: 0x9CA3AF) typeLabel.textColor = WalletTokens.textTertiary
orderLabel.font = .systemFont(ofSize: 12) orderLabel.font = WalletTokens.captionFont
orderLabel.textAlignment = .center orderLabel.textAlignment = .center
orderLabel.layer.cornerRadius = 4 orderLabel.layer.cornerRadius = WalletTokens.chipRadius
orderLabel.clipsToBounds = true orderLabel.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14) timeLabel.font = WalletTokens.bodyFont
timeLabel.textColor = UIColor(hex: 0x4B5563) timeLabel.textColor = WalletTokens.textSecondary
withdrawLabel.font = .systemFont(ofSize: 12) withdrawLabel.font = WalletTokens.captionFont
withdrawLabel.textAlignment = .center withdrawLabel.textAlignment = .center
withdrawLabel.layer.cornerRadius = 4 withdrawLabel.layer.cornerRadius = WalletTokens.chipRadius
withdrawLabel.clipsToBounds = true withdrawLabel.clipsToBounds = true
contentView.addSubview(cardView) 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) { private func labelStyle(for label: String) -> (background: UIColor, text: UIColor, orderBackground: UIColor, orderText: UIColor) {
switch label { switch label {
case "可提现": case "可提现":
return (UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0xEFF6FF), UIColor(hex: 0x0073FF)) return (WalletTokens.successBackground, WalletTokens.success, WalletTokens.orderBackground, WalletTokens.primary)
default: 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 withdraw(WalletWithdrawItem)
case transactionHeader(EarningDetailGroup) case transactionHeader(EarningDetailGroup)
case transaction(EarningDetailItem) case transaction(EarningDetailItem)
case loading
} }
private let viewModel: WalletViewModel private let viewModel: WalletViewModel
@ -27,6 +28,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
private let tabStack = UIStackView() private let tabStack = UIStackView()
private let withdrawTabButton = UIButton(type: .system) private let withdrawTabButton = UIButton(type: .system)
private let transactionTabButton = UIButton(type: .system) private let transactionTabButton = UIButton(type: .system)
private let tabIndicator = UIView()
private let filterPanel = WalletFilterPanelView() private let filterPanel = WalletFilterPanelView()
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl() private let refreshControl = UIRefreshControl()
@ -54,24 +56,30 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
} }
override func setupNavigationBar() { 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() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8) view.backgroundColor = WalletTokens.pageBackground
contentCard.backgroundColor = .white contentCard.backgroundColor = .white
contentCard.layer.cornerRadius = 16 contentCard.layer.cornerRadius = WalletTokens.contentRadius
contentCard.clipsToBounds = true contentCard.clipsToBounds = true
tabStack.axis = .horizontal tabStack.axis = .horizontal
tabStack.distribution = .fillEqually tabStack.distribution = .fillEqually
[withdrawTabButton, transactionTabButton].forEach { [withdrawTabButton, transactionTabButton].forEach {
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) $0.titleLabel?.font = WalletTokens.tabSelectedFont
$0.setTitleColor(UIColor(hex: 0x666666), for: .normal) $0.setTitleColor(WalletTokens.tabNormal, for: .normal)
tabStack.addArrangedSubview($0) tabStack.addArrangedSubview($0)
} }
withdrawTabButton.setTitle("提现记录", for: .normal) withdrawTabButton.setTitle("提现记录", for: .normal)
transactionTabButton.setTitle("收益明细", for: .normal) transactionTabButton.setTitle("收益明细", for: .normal)
tabIndicator.backgroundColor = WalletTokens.summaryBackground
tabIndicator.layer.cornerRadius = 1.5
tableView.backgroundColor = .white tableView.backgroundColor = .white
tableView.separatorStyle = .none tableView.separatorStyle = .none
@ -79,15 +87,19 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120 tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl 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(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier)
tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier) tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier)
tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier) tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier)
tableView.register(WalletLoadingCell.self, forCellReuseIdentifier: WalletLoadingCell.reuseIdentifier)
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
view.addSubview(summaryCard) view.addSubview(summaryCard)
view.addSubview(contentCard) view.addSubview(contentCard)
contentCard.addSubview(tabStack) contentCard.addSubview(tabStack)
contentCard.addSubview(tabIndicator)
contentCard.addSubview(filterPanel) contentCard.addSubview(filterPanel)
contentCard.addSubview(tableView) contentCard.addSubview(tableView)
view.addSubview(bottomBar) view.addSubview(bottomBar)
@ -98,32 +110,41 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
override func setupConstraints() { override func setupConstraints() {
summaryCard.snp.makeConstraints { make in summaryCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16) make.top.equalTo(view.safeAreaLayoutGuide).offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
} }
bottomBar.snp.makeConstraints { make in bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
withdrawButton.snp.makeConstraints { make in withdrawButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16) make.top.equalToSuperview().offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
} }
withdrawButton.layer.cornerRadius = WalletTokens.bottomButtonRadius
contentCard.snp.makeConstraints { make in contentCard.snp.makeConstraints { make in
make.top.equalTo(summaryCard.snp.bottom).offset(16) make.top.equalTo(summaryCard.snp.bottom).offset(WalletTokens.cardGap)
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(bottomBar.snp.top).offset(-16) make.bottom.equalTo(bottomBar.snp.top).offset(-WalletTokens.cardGap)
} }
tabStack.snp.makeConstraints { make in tabStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview() make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(50) 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 filterPanel.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom) 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 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() make.leading.trailing.bottom.equalToSuperview()
} }
} }
@ -139,13 +160,17 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
} }
withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside) withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside)
transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside) transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside)
filterPanel.onFilterTap = { [weak self] in filterPanel.onFilterSelected = { [weak self] filter in
self?.presentFilterMenu() guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
} }
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside) 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() { private func configureDataSource() {
@ -172,6 +197,11 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
) as? WalletTransactionRecordCell ) as? WalletTransactionRecordCell
cell?.apply(record) cell?.apply(record)
return cell ?? UITableViewCell() 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 points: viewModel.availablePoints
) )
applyTabAppearance() applyTabAppearance()
filterPanel.isHidden = viewModel.selectedTab != .transaction updateContentLayout()
filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel) filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel)
let isRefreshing = switch viewModel.selectedTab {
case .withdraw: viewModel.withdrawRefreshing
case .transaction: viewModel.transactionRefreshing
}
if !isRefreshing {
refreshControl.endRefreshing() refreshControl.endRefreshing()
}
applySnapshot() applySnapshot()
if let message = viewModel.errorMessage, message != lastShownMessage { if let message = viewModel.errorMessage, message != lastShownMessage {
lastShownMessage = message lastShownMessage = message
@ -195,12 +231,35 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
} }
private func applyTabAppearance() { private func applyTabAppearance() {
let selectedColor = UIColor(hex: 0x1677FF) let selectedColor = WalletTokens.summaryBackground
let normalColor = UIColor(hex: 0x666666) let normalColor = WalletTokens.tabNormal
withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal) withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal)
transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? 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) withdrawTabButton.titleLabel?.font = viewModel.selectedTab == .withdraw ? WalletTokens.tabSelectedFont : WalletTokens.tabFont
transactionTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .transaction ? .semibold : .regular) 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() { private func applySnapshot() {
@ -208,6 +267,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
snapshot.appendSections([.main]) snapshot.appendSections([.main])
if viewModel.selectedTab == .withdraw { if viewModel.selectedTab == .withdraw {
snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main) snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main)
if viewModel.withdrawLoading {
snapshot.appendItems([.loading], toSection: .main)
}
} else { } else {
let items = viewModel.transactionEntries.map { entry -> Item in let items = viewModel.transactionEntries.map { entry -> Item in
switch entry { switch entry {
@ -216,6 +278,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
} }
} }
snapshot.appendItems(items, toSection: .main) snapshot.appendItems(items, toSection: .main)
if viewModel.transactionLoading {
snapshot.appendItems([.loading], toSection: .main)
}
} }
dataSource.apply(snapshot, animatingDifferences: true) dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState() updateEmptyState()
@ -236,24 +301,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
} }
let label = UILabel() let label = UILabel()
label.text = emptyText label.text = emptyText
label.textColor = UIColor(hex: 0xB3B8C2) label.textColor = WalletTokens.textDisabled
label.font = .systemFont(ofSize: 14) label.font = WalletTokens.bodyFont
label.textAlignment = .center label.textAlignment = .center
tableView.backgroundView = label 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() { private func openPointsRedemption() {
navigationController?.pushViewController(PointsRedemptionViewController(), animated: true) navigationController?.pushViewController(PointsRedemptionViewController(), animated: true)
} }
@ -270,9 +323,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
Task { Task {
switch viewModel.selectedTab { switch viewModel.selectedTab {
case .withdraw: case .withdraw:
await viewModel.refreshSummary(api: walletAPI)
await viewModel.refreshWithdraw(api: walletAPI) await viewModel.refreshWithdraw(api: walletAPI)
await viewModel.refreshPoints(api: walletAPI)
case .transaction: case .transaction:
await viewModel.refreshTransaction(api: walletAPI) await viewModel.refreshTransaction(api: walletAPI)
} }
@ -363,17 +414,17 @@ private final class WalletSummaryCardView: UIView {
} }
private func setupUI() { private func setupUI() {
backgroundColor = UIColor(hex: 0x1677FF) backgroundColor = WalletTokens.summaryBackground
layer.cornerRadius = 24 layer.cornerRadius = WalletTokens.summaryRadius
clipsToBounds = true clipsToBounds = true
withdrawableTitle.text = "可提现金额" withdrawableTitle.text = "可提现金额"
withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8) withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8)
withdrawableTitle.font = .systemFont(ofSize: 14) withdrawableTitle.font = WalletTokens.bodyFont
withdrawableTitle.textAlignment = .center withdrawableTitle.textAlignment = .center
withdrawableLabel.textColor = .white withdrawableLabel.textColor = .white
withdrawableLabel.font = .systemFont(ofSize: 36, weight: .bold) withdrawableLabel.font = WalletTokens.displayFont
withdrawableLabel.textAlignment = .center withdrawableLabel.textAlignment = .center
withdrawableLabel.adjustsFontSizeToFitWidth = true withdrawableLabel.adjustsFontSizeToFitWidth = true
withdrawableLabel.minimumScaleFactor = 0.7 withdrawableLabel.minimumScaleFactor = 0.7
@ -388,17 +439,17 @@ private final class WalletSummaryCardView: UIView {
addSubview(metricsStack) addSubview(metricsStack)
withdrawableTitle.snp.makeConstraints { make in withdrawableTitle.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16) make.top.equalToSuperview().offset(WalletTokens.cardPadding)
make.leading.trailing.equalToSuperview().inset(24) make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
} }
withdrawableLabel.snp.makeConstraints { make in withdrawableLabel.snp.makeConstraints { make in
make.top.equalTo(withdrawableTitle.snp.bottom).offset(4) make.top.equalTo(withdrawableTitle.snp.bottom).offset(WalletTokens.tinyGap)
make.leading.trailing.equalToSuperview().inset(24) make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
} }
metricsStack.snp.makeConstraints { make in metricsStack.snp.makeConstraints { make in
make.top.equalTo(withdrawableLabel.snp.bottom).offset(18) make.top.equalTo(withdrawableLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(12) make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding)
make.bottom.equalToSuperview().inset(16) make.bottom.equalToSuperview().inset(WalletTokens.cardPadding)
} }
} }
@ -432,11 +483,11 @@ private final class WalletSummaryMetricView: UIControl {
private func setupUI() { private func setupUI() {
titleLabel.textColor = UIColor.white.withAlphaComponent(0.8) titleLabel.textColor = UIColor.white.withAlphaComponent(0.8)
titleLabel.font = .systemFont(ofSize: 14) titleLabel.font = WalletTokens.bodyFont
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
valueLabel.textColor = .white valueLabel.textColor = .white
valueLabel.font = .systemFont(ofSize: 14, weight: .medium) valueLabel.font = WalletTokens.bodyMediumFont
valueLabel.textAlignment = .center valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75 valueLabel.minimumScaleFactor = 0.75
@ -469,7 +520,7 @@ private final class WalletSummaryMetricView: UIControl {
/// ///
private final class WalletFilterPanelView: UIView { private final class WalletFilterPanelView: UIView {
var onFilterTap: (() -> Void)? var onFilterSelected: ((WalletFilter) -> Void)?
private let filterButton = UIButton(type: .system) private let filterButton = UIButton(type: .system)
private let totalTitleLabel = UILabel() private let totalTitleLabel = UILabel()
@ -487,30 +538,40 @@ private final class WalletFilterPanelView: UIView {
} }
func apply(filter: WalletFilter, totalIncome: String, totalPoints: Int) { 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 totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome
pointsLabel.text = "积分+\(totalPoints)" pointsLabel.text = "积分+\(totalPoints)"
} }
private func setupUI() { private func setupUI() {
backgroundColor = UIColor(hex: 0xF6F6F6) backgroundColor = WalletTokens.filterBackground
layer.cornerRadius = 16 layer.cornerRadius = WalletTokens.contentRadius
clipsToBounds = true clipsToBounds = true
filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal) filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal)
filterButton.titleLabel?.font = .systemFont(ofSize: 14) filterButton.titleLabel?.font = WalletTokens.bodyFont
filterButton.contentHorizontalAlignment = .left 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.text = "总收益:"
totalTitleLabel.font = .systemFont(ofSize: 14, weight: .medium) totalTitleLabel.font = WalletTokens.bodyMediumFont
totalTitleLabel.textColor = .black totalTitleLabel.textColor = .black
totalLabel.font = .systemFont(ofSize: 14, weight: .medium) totalLabel.font = WalletTokens.bodyMediumFont
totalLabel.textColor = UIColor(hex: 0x0073FF) totalLabel.textColor = WalletTokens.primary
pointsLabel.font = .systemFont(ofSize: 14, weight: .medium) pointsLabel.font = WalletTokens.bodyMediumFont
pointsLabel.textColor = UIColor(hex: 0xFF7B00) pointsLabel.textColor = WalletTokens.points
pointsLabel.textAlignment = .right pointsLabel.textAlignment = .right
addSubview(filterButton) addSubview(filterButton)
@ -519,15 +580,14 @@ private final class WalletFilterPanelView: UIView {
addSubview(pointsLabel) addSubview(pointsLabel)
filterButton.snp.makeConstraints { make in filterButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8) make.top.leading.equalToSuperview()
make.leading.equalToSuperview().offset(16) make.width.lessThanOrEqualTo(WalletTokens.filterFieldMaxWidth)
make.width.lessThanOrEqualTo(150) make.height.equalTo(WalletTokens.filterFieldHeight)
make.height.equalTo(32)
} }
totalTitleLabel.snp.makeConstraints { make in totalTitleLabel.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom).offset(4) make.top.equalTo(filterButton.snp.bottom)
make.leading.equalToSuperview().offset(16) make.leading.equalToSuperview().offset(WalletTokens.cardPadding)
make.bottom.equalToSuperview().inset(12) make.bottom.equalToSuperview().inset(WalletTokens.rowGap)
} }
totalLabel.snp.makeConstraints { make in totalLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel) make.centerY.equalTo(totalTitleLabel)
@ -540,7 +600,15 @@ private final class WalletFilterPanelView: UIView {
} }
} }
@objc private func filterTapped() { private func configureMenu(selected: WalletFilter) {
onFilterTap?() 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() { 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() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F6F8) view.backgroundColor = WalletTokens.withdrawPageBackground
contentStack.axis = .vertical 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.backgroundColor = .white
bottomBar.isHidden = true
view.addSubview(scrollView) view.addSubview(scrollView)
scrollView.addSubview(contentStack) scrollView.addSubview(contentStack)
@ -76,16 +84,16 @@ final class WithdrawViewController: BaseViewController {
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
submitButton.snp.makeConstraints { make in submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16) make.top.equalToSuperview().offset(WalletTokens.screenInset)
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(WalletTokens.screenInset)
} }
scrollView.snp.makeConstraints { make in scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview() make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top) make.bottom.equalTo(bottomBar.snp.top)
} }
contentStack.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16) make.edges.equalToSuperview().inset(WalletTokens.screenInset)
make.width.equalTo(scrollView).offset(-32) make.width.equalTo(scrollView).offset(-32)
} }
} }
@ -98,7 +106,15 @@ final class WithdrawViewController: BaseViewController {
codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged) codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged)
codeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside) codeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), 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() { private func applyState() {
@ -116,10 +132,25 @@ final class WithdrawViewController: BaseViewController {
codeButton.setTitle(viewModel.countdown > 0 ? "\(viewModel.countdown)s" : "获取验证码", for: .normal) codeButton.setTitle(viewModel.countdown > 0 ? "\(viewModel.countdown)s" : "获取验证码", for: .normal)
codeButton.isEnabled = viewModel.countdown == 0 codeButton.isEnabled = viewModel.countdown == 0
submitButton.isEnabled = viewModel.canSubmit submitButton.isEnabled = viewModel.canSubmit
updateBottomBarVisibility(hasContent: viewModel.withdrawInfo != nil && !viewModel.loading)
if viewModel.countdown > 0 { startCountdownTimerIfNeeded() } if viewModel.countdown > 0 { startCountdownTimerIfNeeded() }
showMessageIfNeeded(viewModel.statusMessage) showMessageIfNeeded(viewModel.statusMessage)
if viewModel.withdrawInfo != nil {
showMessageIfNeeded(viewModel.errorMessage) 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 { private func makeAmountCard() -> UIView {
let card = makeCard() let card = makeCard()
@ -129,19 +160,19 @@ final class WithdrawViewController: BaseViewController {
row.alignment = .center row.alignment = .center
row.spacing = 16 row.spacing = 16
let withdrawableTitle = makeBodyLabel("可提现金额", color: .black, weight: .medium) let withdrawableTitle = makeBodyLabel("可提现金额", color: .black, weight: .medium)
withdrawableLabel.font = .systemFont(ofSize: 14, weight: .medium) withdrawableLabel.font = WalletTokens.bodyMediumFont
withdrawableLabel.textColor = UIColor(hex: 0x0073FF) withdrawableLabel.textColor = WalletTokens.primary
row.addArrangedSubview(withdrawableTitle) row.addArrangedSubview(withdrawableTitle)
row.addArrangedSubview(withdrawableLabel) row.addArrangedSubview(withdrawableLabel)
let amountContainer = borderedInputContainer() let amountContainer = borderedInputContainer()
amountField.placeholder = "请输入金额" amountField.placeholder = "请输入金额"
amountField.keyboardType = .decimalPad amountField.keyboardType = .decimalPad
amountField.font = .systemFont(ofSize: 14) amountField.font = WalletTokens.bodyFont
let allButton = UIButton(type: .system) let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal) allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal) allButton.setTitleColor(WalletTokens.primary, for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14) allButton.titleLabel?.font = WalletTokens.bodyFont
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside) allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
amountContainer.addSubview(amountField) amountContainer.addSubview(amountField)
amountContainer.addSubview(allButton) amountContainer.addSubview(allButton)
@ -155,10 +186,10 @@ final class WithdrawViewController: BaseViewController {
make.centerY.equalToSuperview() make.centerY.equalToSuperview()
} }
limitLabel.font = .systemFont(ofSize: 12) limitLabel.font = WalletTokens.captionFont
limitLabel.textColor = UIColor(hex: 0x9CA3AF) limitLabel.textColor = WalletTokens.textTertiary
phoneLabel.font = .systemFont(ofSize: 12) phoneLabel.font = WalletTokens.captionFont
phoneLabel.textColor = UIColor(hex: 0x4B5563) phoneLabel.textColor = WalletTokens.textSecondary
let codeRow = UIStackView() let codeRow = UIStackView()
codeRow.axis = .horizontal codeRow.axis = .horizontal
@ -166,18 +197,19 @@ final class WithdrawViewController: BaseViewController {
let codeContainer = borderedInputContainer() let codeContainer = borderedInputContainer()
codeField.placeholder = "请输入验证码" codeField.placeholder = "请输入验证码"
codeField.keyboardType = .numberPad codeField.keyboardType = .numberPad
codeField.font = .systemFont(ofSize: 14) codeField.font = WalletTokens.bodyFont
codeContainer.addSubview(codeField) codeContainer.addSubview(codeField)
codeField.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) } codeField.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) }
codeButton.setTitle("获取验证码", for: .normal) codeButton.setTitle("获取验证码", for: .normal)
codeButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal) codeButton.setTitleColor(WalletTokens.primary, for: .normal)
codeButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) codeButton.setTitleColor(WalletTokens.textDisabled, for: .disabled)
codeButton.titleLabel?.font = WalletTokens.bodyMediumFont
codeButton.layer.borderWidth = 1 codeButton.layer.borderWidth = 1
codeButton.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor codeButton.layer.borderColor = WalletTokens.border.cgColor
codeButton.layer.cornerRadius = 4 codeButton.layer.cornerRadius = WalletTokens.chipRadius
codeRow.addArrangedSubview(codeContainer) codeRow.addArrangedSubview(codeContainer)
codeRow.addArrangedSubview(codeButton) 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) } codeButton.snp.makeConstraints { make in make.width.equalTo(100) }
let stack = UIStackView(arrangedSubviews: [title, row, amountContainer, limitLabel, phoneLabel, codeRow]) let stack = UIStackView(arrangedSubviews: [title, row, amountContainer, limitLabel, phoneLabel, codeRow])
@ -185,23 +217,26 @@ final class WithdrawViewController: BaseViewController {
stack.spacing = 12 stack.spacing = 12
card.addSubview(stack) card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } 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 return card
} }
private func makeBankCard() -> UIView { private func makeBankCard() -> UIView {
let card = makeCard() let card = makeCard()
let title = makeTitle("银行卡信息") let title = makeTitle("银行卡信息", size: 16)
let inner = makeInnerPanel() let inner = makeInnerPanel()
bankCardLabel.font = .systemFont(ofSize: 14, weight: .medium) bankCardLabel.font = WalletTokens.bodyMediumFont
bankCardLabel.textColor = UIColor(hex: 0x333333) bankCardLabel.textColor = WalletTokens.text333
bankHolderLabel.font = .systemFont(ofSize: 14) bankHolderLabel.font = WalletTokens.bodyFont
bankHolderLabel.textColor = UIColor(hex: 0x4B5563) bankHolderLabel.textColor = WalletTokens.textSecondary
let innerStack = UIStackView(arrangedSubviews: [bankCardLabel, bankHolderLabel]) let innerStack = UIStackView(arrangedSubviews: [bankCardLabel, bankHolderLabel])
innerStack.axis = .vertical innerStack.axis = .vertical
innerStack.spacing = 12 innerStack.spacing = 12
inner.addSubview(innerStack) 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]) let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical stack.axis = .vertical
stack.spacing = 8 stack.spacing = 8
@ -212,16 +247,19 @@ final class WithdrawViewController: BaseViewController {
private func makeSettlementCard() -> UIView { private func makeSettlementCard() -> UIView {
let card = makeCard() let card = makeCard()
let title = makeTitle("预计到账时间") let title = makeTitle("预计到账时间", size: 16)
let inner = makeInnerPanel() let inner = makeInnerPanel()
settlementLabel.font = .systemFont(ofSize: 14) settlementLabel.font = WalletTokens.bodyFont
settlementLabel.textColor = UIColor(hex: 0x4B5563) settlementLabel.textColor = WalletTokens.textSecondary
settlementLabel.numberOfLines = 0 settlementLabel.numberOfLines = 0
inner.addSubview(settlementLabel) 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]) let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical stack.axis = .vertical
stack.spacing = 8 stack.spacing = WalletTokens.tinyGap
card.addSubview(stack) card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card return card
@ -229,15 +267,18 @@ final class WithdrawViewController: BaseViewController {
private func makeInfoCard() -> UIView { private func makeInfoCard() -> UIView {
let card = makeCard() let card = makeCard()
let title = makeTitle("提现说明") let title = makeTitle("提现说明", size: 16)
let inner = makeInnerPanel() let inner = makeInnerPanel()
infoStack.axis = .vertical infoStack.axis = .vertical
infoStack.spacing = 4 infoStack.spacing = 4
inner.addSubview(infoStack) 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]) let stack = UIStackView(arrangedSubviews: [title, inner])
stack.axis = .vertical stack.axis = .vertical
stack.spacing = 8 stack.spacing = WalletTokens.rowGap
card.addSubview(stack) card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return card return card
@ -246,32 +287,32 @@ final class WithdrawViewController: BaseViewController {
private func makeCard() -> UIView { private func makeCard() -> UIView {
let view = UIView() let view = UIView()
view.backgroundColor = .white view.backgroundColor = .white
view.layer.cornerRadius = 12 view.layer.cornerRadius = WalletTokens.cardRadius
view.clipsToBounds = true view.clipsToBounds = true
return view return view
} }
private func makeInnerPanel() -> UIView { private func makeInnerPanel() -> UIView {
let view = UIView() let view = UIView()
view.backgroundColor = UIColor(hex: 0xF4F4F4) view.backgroundColor = WalletTokens.inputBackground
view.layer.cornerRadius = 8 view.layer.cornerRadius = WalletTokens.innerRadius
view.layer.borderWidth = 1 view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor view.layer.borderColor = WalletTokens.border.cgColor
return view return view
} }
private func borderedInputContainer() -> UIView { private func borderedInputContainer() -> UIView {
let view = UIView() let view = UIView()
view.layer.cornerRadius = 4 view.layer.cornerRadius = WalletTokens.chipRadius
view.layer.borderWidth = 1 view.layer.borderWidth = 1
view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor view.layer.borderColor = WalletTokens.border.cgColor
return view return view
} }
private func makeTitle(_ text: String) -> UILabel { private func makeTitle(_ text: String, size: CGFloat = 18) -> UILabel {
let label = UILabel() let label = UILabel()
label.text = text label.text = text
label.font = .systemFont(ofSize: 18, weight: .medium) label.font = .systemFont(ofSize: size, weight: .medium)
label.textColor = .black label.textColor = .black
return label return label
} }
@ -287,7 +328,7 @@ final class WithdrawViewController: BaseViewController {
private func applyInfoLines(_ lines: [String]) { private func applyInfoLines(_ lines: [String]) {
infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() } infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
lines.forEach { text in lines.forEach { text in
let label = makeBodyLabel(text, color: UIColor(hex: 0x4B5563)) let label = makeBodyLabel(text, color: WalletTokens.textSecondary)
label.numberOfLines = 0 label.numberOfLines = 0
infoStack.addArrangedSubview(label) infoStack.addArrangedSubview(label)
} }

View File

@ -62,4 +62,54 @@ final class ProfileModelsTests: XCTestCase {
XCTAssertTrue(info.verified) 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) XCTAssertTrue(image.isUploaded)
XCTAssertFalse(image.isUploading) 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 }) let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 })
var messages: [String] = [] var messages: [String] = []
var qrcode: String? var qrcode: String?
var removalID: Int64?
viewModel.onShowMessage = { messages.append($0) } viewModel.onShowMessage = { messages.append($0) }
viewModel.onShowQRCode = { qrcode = $0 } viewModel.onShowQRCode = { qrcode = $0 }
viewModel.onBeginDeleteAnimation = { removalID = $0 }
await viewModel.loadInitial(api: api) await viewModel.loadInitial(api: api)
viewModel.showQRCode(for: viewModel.items[1]) viewModel.showQRCode(for: viewModel.items[1])
@ -50,6 +52,9 @@ final class PunchPointViewModelTests: XCTestCase {
XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"]) XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"])
XCTAssertEqual(qrcode, "https://cdn/qr.png") 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(viewModel.items.map(\.id), [2])
XCTAssertEqual(api.deletedIds, [1]) XCTAssertEqual(api.deletedIds, [1])
} }
@ -140,6 +145,59 @@ final class PunchPointViewModelTests: XCTestCase {
XCTAssertTrue(api.addRequests.isEmpty) 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 { private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem {
PunchPointItem( PunchPointItem(
id: id, 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 { private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable {
let snapshot: HomeLocationSnapshot let snapshot: HomeLocationSnapshot
let reverseAddress: String 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 // suixinkanTests
// //
import UIKit
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan
/// ViewModel /// ViewModel
@MainActor
final class RealNameAuthViewModelTests: XCTestCase { 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() let viewModel = RealNameAuthViewModel()
viewModel.updateRealName("张三") viewModel.updateRealName("张三")
viewModel.updateIdCardNo("11010519491231002X") 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() { func testIsValidMainlandIDCardNumberChecksChecksum() {
@ -27,4 +167,67 @@ final class RealNameAuthViewModelTests: XCTestCase {
"11010519491231002X" "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") 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 XCTest
import UIKit
@testable import suixinkan @testable import suixinkan
/// API ViewModel /// 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[1])["page"], "1")
XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01") XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01")
XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9") 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[5]).contains(#""sms_code":"123456""#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true) XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), 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) 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 { func testWalletWithdrawDestinationFlow() async throws {
let viewModel = WalletViewModel() let viewModel = WalletViewModel()
@ -107,6 +150,12 @@ final class WalletFeatureTests: XCTestCase {
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .realNamePending) 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) profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: nil)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdrawalSettings) XCTAssertEqual(destination, .withdrawalSettings)
@ -115,6 +164,12 @@ final class WalletFeatureTests: XCTestCase {
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .bankCardPending) 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) profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 2)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdraw) XCTAssertEqual(destination, .withdraw)
@ -129,11 +184,21 @@ final class WalletFeatureTests: XCTestCase {
viewModel.updateVerificationCode("12a456") viewModel.updateVerificationCode("12a456")
XCTAssertEqual(viewModel.amount, "12.34") XCTAssertEqual(viewModel.amount, "12.34")
XCTAssertEqual(viewModel.verificationCode, "12456") XCTAssertEqual(viewModel.verificationCode, "12a456")
let success = await viewModel.submit(api: api) let success = await viewModel.submit(api: api)
XCTAssertTrue(success) XCTAssertTrue(success)
XCTAssertEqual(api.withdrawApplyRequests.first?.amount, "12.34") 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 { func testPointsRedemptionViewModelClampsAndRefreshes() async {
@ -141,6 +206,8 @@ final class WalletFeatureTests: XCTestCase {
let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
await viewModel.loadInitial(api: api) await viewModel.loadInitial(api: api)
viewModel.updatePointsInput("0")
XCTAssertEqual(viewModel.pointsInput, "0")
viewModel.updatePointsInput("999") viewModel.updatePointsInput("999")
XCTAssertEqual(viewModel.pointsInput, "60") XCTAssertEqual(viewModel.pointsInput, "60")
@ -151,6 +218,18 @@ final class WalletFeatureTests: XCTestCase {
XCTAssertEqual(viewModel.withdrawRecords.count, 1) 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 { private static func envelope(_ dataJSON: String) -> Data {
Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8) 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 earningQueries: [(start: String, end: String, page: Int)] = []
private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = [] private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = []
private(set) var pointApplyRequests: [Int] = [] private(set) var pointApplyRequests: [Int] = []
var shouldFailWithdrawList = false
var shouldFailEarningDetail = false
var shouldFailPointWithdrawList = false
func walletSummary(type: Int) async throws -> WalletSummaryResponse { func walletSummary(type: Int) async throws -> WalletSummaryResponse {
WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00") 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 { func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
if shouldFailWithdrawList { throw FakeWalletError.requestFailed }
WalletWithdrawListResponse( WalletWithdrawListResponse(
total: 1, total: 1,
item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")] 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 { func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse {
if shouldFailEarningDetail { throw FakeWalletError.requestFailed }
earningQueries.append((startDate, endDate, page)) earningQueries.append((startDate, endDate, page))
return EarningDetailResponse( return EarningDetailResponse(
totalAmount: "12.30", totalAmount: "12.30",
@ -242,10 +326,18 @@ private final class FakeWalletPageService: WalletPageServing {
} }
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { 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")]) 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 @MainActor
/// ///
private struct FakeWalletProfileService: WalletProfileServing { private struct FakeWalletProfileService: WalletProfileServing {
@ -270,3 +362,14 @@ private struct FakeWalletProfileService: WalletProfileServing {
return try JSONDecoder().decode(BankCardInfoResponse.self, from: data) 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)))
}
}