完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
6
suixinkan/Assets.xcassets/PunchPoint/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_delete.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/punch_point_delete.svg
vendored
Normal 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 |
15
suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "punch_point_dropdown.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/punch_point_dropdown.svg
vendored
Normal 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 |
15
suixinkan/Assets.xcassets/PunchPoint/punch_point_image_add.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "punch_point_image_add.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 |
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_image_delete.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_image_delete.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
@ -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 |
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_location.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/punch_point_location.svg
vendored
Normal 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 |
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_map_location.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_map_location.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
@ -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 |
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_marker.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/punch_point_marker.svg
vendored
Normal 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 |
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_qr.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/punch_point_qr.svg
vendored
Normal 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 |
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_zoom_in.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/punch_point_zoom_in.svg
vendored
Normal 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 |
5
suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "punch_point_zoom_out.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/punch_point_zoom_out.svg
vendored
Normal 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 |
6
suixinkan/Assets.xcassets/RealName/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
7
suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/Contents.json
vendored
Normal 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" }
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/ic_accept_orders.svg
vendored
Normal 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 |
7
suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/Contents.json
vendored
Normal 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" }
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/ic_area_authority.svg
vendored
Normal 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 |
7
suixinkan/Assets.xcassets/RealName/ic_checked.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "ic_checked.svg", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/RealName/ic_checked.imageset/ic_checked.svg
vendored
Normal 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 |
7
suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/Contents.json
vendored
Normal 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" }
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/ic_marketing.svg
vendored
Normal 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 |
7
suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "ic_unchecked.svg", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/ic_unchecked.svg
vendored
Normal 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 |
5
suixinkan/Assets.xcassets/home_menu_punch_point.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"images" : [{ "filename" : "home_menu_punch_point.svg", "idiom" : "universal" }],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "preserves-vector-representation" : true }
|
||||||
|
}
|
||||||
1
suixinkan/Assets.xcassets/home_menu_punch_point.imageset/home_menu_punch_point.svg
vendored
Normal 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 |
15
suixinkan/Assets.xcassets/home_menu_wallet.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "home_menu_wallet.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/home_menu_wallet.imageset/home_menu_wallet.svg
vendored
Normal 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 |
@ -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")
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
82
suixinkan/Theme/Modules/WalletTokens.swift
Normal 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)
|
||||||
|
}
|
||||||
@ -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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
["全部", "运营中", "已暂停", "未审核", "审核未通过"]
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
66
suixinkanTests/RealNameAuthAPITests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||