Compare commits
9 Commits
d492c1c42e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2bfdef8f | |||
| 033aa5dfec | |||
| 4f025ce788 | |||
| cdda12afaa | |||
| 027db84a25 | |||
| 520b9e93bd | |||
| 01d1ce5ba8 | |||
| 8eb09ce6f0 | |||
| 7f87126c53 |
373
basic-info.css
373
basic-info.css
@@ -15,7 +15,7 @@ body {
|
|||||||
.basic-info-container {
|
.basic-info-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
padding-bottom: 100px;
|
padding-bottom: calc(130px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部卡片 */
|
/* 顶部卡片 */
|
||||||
@@ -547,14 +547,13 @@ body {
|
|||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部按钮 */
|
/* 底部按钮:贴底并预留安全区,避免被系统栏/手势区遮挡 */
|
||||||
.button-section {
|
.button-section {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px;
|
padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
box-shadow: 0 -2px 4px 0 rgba(0, 0, 0, 0.08);
|
box-shadow: 0 -2px 4px 0 rgba(0, 0, 0, 0.08);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -669,9 +668,9 @@ body {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #f5f5f5;
|
background-color: #fff;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 20px 20px 0 0;
|
||||||
max-height: 70vh;
|
max-height: 80vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
animation: slideUp 0.3s ease;
|
animation: slideUp 0.3s ease;
|
||||||
@@ -690,11 +689,319 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px;
|
padding: 6px 20px;
|
||||||
border-bottom: 1px solid #e5e5e5;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.city-picker-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-picker-body::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-picker-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 定位和热门城市区域 ==================== */
|
||||||
|
.city-picker-hot-section {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位区域 */
|
||||||
|
.location-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位标签 */
|
||||||
|
.location-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid #e5e5e5;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-badge:hover {
|
||||||
|
border-color: #3474fe;
|
||||||
|
color: #3474fe;
|
||||||
|
background: #E8F0FF;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 116, 254, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-badge:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-badge.locating {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-badge .location-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
display: inline-block;
|
||||||
|
filter: hue-rotate(0deg) saturate(1.2);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-badge .location-text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 热门城市区域 */
|
||||||
|
.hot-cities-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: #3474fe;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-cities-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 热门城市标签 */
|
||||||
|
.city-tag {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid #e5e5e5;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag:hover {
|
||||||
|
border-color: #3474fe;
|
||||||
|
color: #3474fe;
|
||||||
|
background: #E8F0FF;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 116, 254, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag.active {
|
||||||
|
background: #3474fe;
|
||||||
|
border-color: #3474fe;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 116, 254, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 省份城市联动区域 ==================== */
|
||||||
|
.province-city-section {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-city-section > .section-title {
|
||||||
|
padding: 12px 16px 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-city-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 省份列表 */
|
||||||
|
.province-list-wrapper {
|
||||||
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-item {
|
||||||
|
padding: 12px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-item.active {
|
||||||
|
background: #3474fe;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 城市网格 */
|
||||||
|
.city-grid-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-grid::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-grid::-webkit-scrollbar-thumb {
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-item {
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.3;
|
||||||
|
min-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-item:hover {
|
||||||
|
background: #E8F0FF;
|
||||||
|
border-color: #3474fe;
|
||||||
|
color: #3474fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-item.active {
|
||||||
|
background: #3474fe;
|
||||||
|
border-color: #3474fe;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 淡入动画 */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.province-list-wrapper {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-cities-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.city-picker-btn {
|
.city-picker-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -713,50 +1020,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.city-picker-confirm {
|
.city-picker-confirm {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 400;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-picker-confirm.has-selection {
|
||||||
color: #3474fe;
|
color: #3474fe;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-picker-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.city-picker-column {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.city-picker-column:first-child {
|
|
||||||
border-right: 1px solid #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.city-picker-item {
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
border-bottom: 1px solid #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.city-picker-item:active {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.city-picker-item.active {
|
|
||||||
background-color: #e8f0ff;
|
|
||||||
color: #3474fe;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.modal-open {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast 提示样式 */
|
/* Toast 提示样式 */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -74,16 +74,46 @@
|
|||||||
<button class="city-picker-btn city-picker-cancel" id="cityCancelBtn">取消</button>
|
<button class="city-picker-btn city-picker-cancel" id="cityCancelBtn">取消</button>
|
||||||
<button class="city-picker-btn city-picker-confirm" id="cityConfirmBtn">确认</button>
|
<button class="city-picker-btn city-picker-confirm" id="cityConfirmBtn">确认</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="city-picker-body">
|
<div class="city-picker-body">
|
||||||
<div class="city-picker-column" id="provinceColumn">
|
<!-- 定位和热门城市区域 -->
|
||||||
|
<div class="city-picker-hot-section">
|
||||||
|
<!-- 定位标签 -->
|
||||||
|
<div class="location-section" id="locationSection">
|
||||||
|
<!-- 定位标签会通过JS动态添加 -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 热门城市 -->
|
||||||
|
<div class="hot-cities-section">
|
||||||
|
<div class="section-title">热门城市</div>
|
||||||
|
<div class="hot-cities-grid" id="hotCitiesList">
|
||||||
|
<!-- 热门城市标签会通过JS动态添加 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 省份城市联动区域 -->
|
||||||
|
<div class="province-city-section">
|
||||||
|
<div class="section-title">选择地区</div>
|
||||||
|
<div class="province-city-container">
|
||||||
|
<!-- 省份列表 -->
|
||||||
|
<div class="province-list-wrapper">
|
||||||
|
<div class="province-list" id="provinceColumn">
|
||||||
<!-- 省份列表会通过JS动态添加 -->
|
<!-- 省份列表会通过JS动态添加 -->
|
||||||
</div>
|
</div>
|
||||||
<div class="city-picker-column" id="cityColumn">
|
</div>
|
||||||
|
|
||||||
|
<!-- 城市网格 -->
|
||||||
|
<div class="city-grid-wrapper">
|
||||||
|
<div class="city-grid" id="cityColumn">
|
||||||
<!-- 城市列表会通过JS动态添加 -->
|
<!-- 城市列表会通过JS动态添加 -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast 提示 -->
|
<!-- Toast 提示 -->
|
||||||
<div class="toast" id="toast">
|
<div class="toast" id="toast">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>薇钱包</title>
|
<title>百雅融</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
<link rel="stylesheet" href="./src/css/components/one-click-login.css">
|
<link rel="stylesheet" href="./src/css/components/one-click-login.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# flux-web Nginx 配置示例
|
|
||||||
#
|
|
||||||
# 使用方法:
|
|
||||||
# 1. 将下面配置复制到 /etc/nginx/sites-available/flux-web
|
|
||||||
# 2. 修改 server_name(你的域名)和 root(项目路径)
|
|
||||||
# 3. 执行:ln -s /etc/nginx/sites-available/flux-web /etc/nginx/sites-enabled/
|
|
||||||
# 4. 测试:nginx -t
|
|
||||||
# 5. 重载:systemctl reload nginx
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name your-domain.com; # ← 改成你的域名
|
|
||||||
|
|
||||||
root /var/www/flux-web; # ← 改成项目路径
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# 静态资源缓存
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
|
||||||
expires 30d;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 主路由
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTPS 配置(可选,使用 Let's Encrypt 免费证书)
|
|
||||||
# 先执行:apt install certbot python3-certbot-nginx
|
|
||||||
# 然后执行:certbot --nginx -d your-domain.com
|
|
||||||
|
|
||||||
# 完整 HTTPS 配置示例:
|
|
||||||
#
|
|
||||||
# server {
|
|
||||||
# listen 443 ssl;
|
|
||||||
# server_name your-domain.com;
|
|
||||||
# root /var/www/flux-web;
|
|
||||||
# index index.html;
|
|
||||||
#
|
|
||||||
# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
|
||||||
# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
|
||||||
#
|
|
||||||
# location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
|
||||||
# expires 30d;
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# location / {
|
|
||||||
# try_files $uri $uri/ /index.html;
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
@@ -8,7 +8,9 @@ export const API_CONFIG = {
|
|||||||
// BASE_URL: 'http://localhost:8071',
|
// BASE_URL: 'http://localhost:8071',
|
||||||
|
|
||||||
// 生产环境 URL(如需切换,取消注释并注释掉上面的)
|
// 生产环境 URL(如需切换,取消注释并注释掉上面的)
|
||||||
BASE_URL: 'https://flux.1216.top',
|
// BASE_URL: 'https://flux.1216.top',
|
||||||
|
|
||||||
|
BASE_URL: '',
|
||||||
|
|
||||||
// API 端点配置
|
// API 端点配置
|
||||||
ENDPOINTS: {
|
ENDPOINTS: {
|
||||||
@@ -32,6 +34,9 @@ export const API_CONFIG = {
|
|||||||
|
|
||||||
// 区域数据接口
|
// 区域数据接口
|
||||||
AREA_LIST: '/api/partnerh5/area_list',
|
AREA_LIST: '/api/partnerh5/area_list',
|
||||||
|
|
||||||
|
// IP定位接口
|
||||||
|
IP_LOCATION: '/api/partnerh5/ip_location',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 请求超时配置(毫秒)
|
// 请求超时配置(毫秒)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const DEBUG_CONFIG = {
|
|||||||
SMS_CODE: '123456',
|
SMS_CODE: '123456',
|
||||||
|
|
||||||
// 调试模式下默认的短链代码
|
// 调试模式下默认的短链代码
|
||||||
DEFAULT_SHORTCODE: 'sRh907',
|
DEFAULT_SHORTCODE: 'I3fMzX',
|
||||||
|
|
||||||
// 是否启用详细日志
|
// 是否启用详细日志
|
||||||
VERBOSE_LOGGING: true,
|
VERBOSE_LOGGING: true,
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ import { API_CONFIG, DEBUG_CONFIG } from '../config/index.js';
|
|||||||
import { UserCache } from './user-cache.js';
|
import { UserCache } from './user-cache.js';
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
|
/**
|
||||||
|
* 拼接请求 URL(BASE_URL 为空时使用当前页同源,便于同站部署)
|
||||||
|
* @param {string} endpoint - 路径,如 /api/partnerh5/area_list
|
||||||
|
* @returns {string} - 完整 URL 或相对路径
|
||||||
|
*/
|
||||||
|
static getRequestUrl(endpoint) {
|
||||||
|
const base = API_CONFIG.BASE_URL;
|
||||||
|
if (base) return base.replace(/\/$/, '') + endpoint;
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建查询参数
|
* 构建查询参数
|
||||||
* @param {Object} params - 参数对象
|
* @param {Object} params - 参数对象
|
||||||
@@ -42,7 +53,7 @@ export class ApiClient {
|
|||||||
* @returns {Promise<Object>} - 响应数据
|
* @returns {Promise<Object>} - 响应数据
|
||||||
*/
|
*/
|
||||||
static async post(endpoint, data = {}) {
|
static async post(endpoint, data = {}) {
|
||||||
const url = API_CONFIG.BASE_URL + endpoint;
|
const url = this.getRequestUrl(endpoint);
|
||||||
const headers = this.getHeaders('application/json');
|
const headers = this.getHeaders('application/json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -75,7 +86,7 @@ export class ApiClient {
|
|||||||
* @returns {Promise<Object>} - 响应数据
|
* @returns {Promise<Object>} - 响应数据
|
||||||
*/
|
*/
|
||||||
static async xpost(endpoint, data = {}) {
|
static async xpost(endpoint, data = {}) {
|
||||||
const url = API_CONFIG.BASE_URL + endpoint;
|
const url = this.getRequestUrl(endpoint);
|
||||||
const headers = this.getHeaders('application/x-www-form-urlencoded');
|
const headers = this.getHeaders('application/x-www-form-urlencoded');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -108,9 +119,11 @@ export class ApiClient {
|
|||||||
* @returns {Promise<Object>} - 响应数据
|
* @returns {Promise<Object>} - 响应数据
|
||||||
*/
|
*/
|
||||||
static async get(endpoint, params = {}) {
|
static async get(endpoint, params = {}) {
|
||||||
const url = new URL(API_CONFIG.BASE_URL + endpoint);
|
const baseUrl = this.getRequestUrl(endpoint);
|
||||||
|
const url = baseUrl.startsWith('http')
|
||||||
|
? new URL(baseUrl)
|
||||||
|
: new URL(baseUrl, window.location.origin);
|
||||||
|
|
||||||
// 添加查询参数
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
url.searchParams.append(key, value);
|
url.searchParams.append(key, value);
|
||||||
});
|
});
|
||||||
|
|||||||
103
src/js/core/cache-manager.js
Normal file
103
src/js/core/cache-manager.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 通用缓存管理器
|
||||||
|
* 提供 localStorage 缓存、请求去重和过期时间管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CacheManager {
|
||||||
|
// 正在进行的请求缓存(静态)
|
||||||
|
static pendingRequests = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存或执行请求
|
||||||
|
* @param {string} cacheKey - 缓存键
|
||||||
|
* @param {Function} fetchFn - 获取数据的异步函数
|
||||||
|
* @param {number} duration - 缓存有效期(毫秒),默认 10 分钟
|
||||||
|
* @returns {Promise<any>} - 缓存的数据或新获取的数据
|
||||||
|
*/
|
||||||
|
static async getCachedOrFetch(cacheKey, fetchFn, duration = 10 * 60 * 1000) {
|
||||||
|
// 检查 localStorage 缓存
|
||||||
|
const cached = this._getFromCache(cacheKey, duration);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有正在进行的请求
|
||||||
|
if (this.pendingRequests.has(cacheKey)) {
|
||||||
|
console.log(`[CacheManager] 等待正在进行的请求: ${cacheKey}`);
|
||||||
|
return this.pendingRequests.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新请求
|
||||||
|
const requestPromise = this._fetchAndCache(cacheKey, fetchFn, duration);
|
||||||
|
this.pendingRequests.set(cacheKey, requestPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await requestPromise;
|
||||||
|
} finally {
|
||||||
|
// 请求完成后清除缓存
|
||||||
|
this.pendingRequests.delete(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 获取缓存
|
||||||
|
* @private
|
||||||
|
* @param {string} key - 缓存键
|
||||||
|
* @param {number} duration - 有效期(毫秒)
|
||||||
|
* @returns {any|null} - 缓存的数据,如果不存在或已过期则返回 null
|
||||||
|
*/
|
||||||
|
static _getFromCache(key, duration) {
|
||||||
|
const cached = localStorage.getItem(key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
if (Date.now() - timestamp < duration) {
|
||||||
|
console.log(`[CacheManager] 使用缓存: ${key}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[CacheManager] 缓存解析失败: ${key}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据并缓存
|
||||||
|
* @private
|
||||||
|
* @param {string} key - 缓存键
|
||||||
|
* @param {Function} fetchFn - 获取数据的异步函数
|
||||||
|
* @param {number} duration - 有效期(毫秒)
|
||||||
|
* @returns {Promise<any>} - 获取的数据
|
||||||
|
*/
|
||||||
|
static async _fetchAndCache(key, fetchFn, duration) {
|
||||||
|
const data = await fetchFn();
|
||||||
|
localStorage.setItem(key, JSON.stringify({
|
||||||
|
data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定前缀的所有缓存
|
||||||
|
* @param {string} prefix - 缓存键前缀
|
||||||
|
*/
|
||||||
|
static clearCacheByPrefix(prefix) {
|
||||||
|
Object.keys(localStorage)
|
||||||
|
.filter(key => key.startsWith(prefix))
|
||||||
|
.forEach(key => localStorage.removeItem(key));
|
||||||
|
console.log(`[CacheManager] 清除缓存前缀: ${prefix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除单个缓存
|
||||||
|
* @param {string} key - 缓存键
|
||||||
|
*/
|
||||||
|
static clearCache(key) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
console.log(`[CacheManager] 清除缓存: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CacheManager;
|
||||||
@@ -6,17 +6,13 @@
|
|||||||
import { CACHE_CONFIG } from '../config/index.js';
|
import { CACHE_CONFIG } from '../config/index.js';
|
||||||
|
|
||||||
export class FormIdGenerator {
|
export class FormIdGenerator {
|
||||||
// 防止递归调用检测
|
|
||||||
static _gettingFormId = false;
|
|
||||||
static _lastFormId = '';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机9位数字的表单唯一标识符
|
* 生成随机9位数字的表单唯一标识符
|
||||||
* @returns {number} - 9位随机数字
|
* @returns {number} - 9位随机数字
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
static _generateRandomId() {
|
static _generateRandomId() {
|
||||||
return Math.floor(Math.random() * 900000000) + 100000000; // 生成100000000-999999999之间的9位数字
|
return Math.floor(Math.random() * 900000000) + 100000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,17 +20,7 @@ export class FormIdGenerator {
|
|||||||
* @returns {string} - 表单ID字符串
|
* @returns {string} - 表单ID字符串
|
||||||
*/
|
*/
|
||||||
static getOrCreate() {
|
static getOrCreate() {
|
||||||
// 防止递归调用检测
|
|
||||||
if (this._gettingFormId) {
|
|
||||||
console.error('[FormIdGenerator] 检测到递归调用 getOrCreateFormId', new Error().stack);
|
|
||||||
return this._lastFormId || '';
|
|
||||||
}
|
|
||||||
this._gettingFormId = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('[FormIdGenerator] getOrCreate 被调用');
|
|
||||||
let formId = localStorage.getItem(CACHE_CONFIG.KEYS.FORM_ID);
|
let formId = localStorage.getItem(CACHE_CONFIG.KEYS.FORM_ID);
|
||||||
console.log('[FormIdGenerator] 从 localStorage 获取的 formId:', formId);
|
|
||||||
|
|
||||||
if (!formId) {
|
if (!formId) {
|
||||||
formId = this._generateRandomId().toString();
|
formId = this._generateRandomId().toString();
|
||||||
@@ -42,14 +28,7 @@ export class FormIdGenerator {
|
|||||||
console.log('[FormIdGenerator] 生成新的表单ID:', formId);
|
console.log('[FormIdGenerator] 生成新的表单ID:', formId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._lastFormId = formId;
|
|
||||||
return formId;
|
return formId;
|
||||||
} catch (error) {
|
|
||||||
console.error('[FormIdGenerator] getOrCreate 出错:', error);
|
|
||||||
return '';
|
|
||||||
} finally {
|
|
||||||
this._gettingFormId = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +36,6 @@ export class FormIdGenerator {
|
|||||||
*/
|
*/
|
||||||
static clear() {
|
static clear() {
|
||||||
localStorage.removeItem(CACHE_CONFIG.KEYS.FORM_ID);
|
localStorage.removeItem(CACHE_CONFIG.KEYS.FORM_ID);
|
||||||
this._lastFormId = '';
|
|
||||||
console.log('[FormIdGenerator] 已清除表单ID');
|
console.log('[FormIdGenerator] 已清除表单ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +57,6 @@ export class FormIdGenerator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localStorage.setItem(CACHE_CONFIG.KEYS.FORM_ID, formId);
|
localStorage.setItem(CACHE_CONFIG.KEYS.FORM_ID, formId);
|
||||||
this._lastFormId = formId;
|
|
||||||
console.log('[FormIdGenerator] 已设置表单ID:', formId);
|
console.log('[FormIdGenerator] 已设置表单ID:', formId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
import { CityPicker, Modal } from '../ui/index.js';
|
import { CityPicker, Modal } from '../ui/index.js';
|
||||||
import { Validator, Formatter } from '../utils/index.js';
|
import { Validator, Formatter } from '../utils/index.js';
|
||||||
import { DraftManager, FormIdGenerator, UserCache } from '../core/index.js';
|
import { DraftManager, FormIdGenerator, UserCache } from '../core/index.js';
|
||||||
import { ASSET_CONFIG, BASIC_INFO_CONFIG, PROVINCE_CITY_DATA, CACHE_CONFIG } from '../config/index.js';
|
import { ASSET_CONFIG, BASIC_INFO_CONFIG, PROVINCE_CITY_DATA, CACHE_CONFIG, API_CONFIG } from '../config/index.js';
|
||||||
import { showToast } from '../ui/toast.js';
|
import { showToast } from '../ui/toast.js';
|
||||||
import { AreaService } from '../services/area.service.js';
|
import { AreaService } from '../services/area.service.js';
|
||||||
|
import { getLocationService } from '../services/location.service.js';
|
||||||
import { AuthFlowService, AUTH_STATUS } from '../services/index.js';
|
import { AuthFlowService, AUTH_STATUS } from '../services/index.js';
|
||||||
|
import { ApiClient } from '../core/api.js';
|
||||||
|
|
||||||
export class BasicInfoPage {
|
export class BasicInfoPage {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -25,9 +27,6 @@ export class BasicInfoPage {
|
|||||||
this.isSavingDraft = false;
|
this.isSavingDraft = false;
|
||||||
this.autoSaveTimer = null;
|
this.autoSaveTimer = null;
|
||||||
|
|
||||||
// 身份证自动填充标记
|
|
||||||
this.lastFilledAreaCode = null;
|
|
||||||
|
|
||||||
// 组件实例
|
// 组件实例
|
||||||
this.cityPicker = null;
|
this.cityPicker = null;
|
||||||
this.agreementModal = null;
|
this.agreementModal = null;
|
||||||
@@ -71,6 +70,9 @@ export class BasicInfoPage {
|
|||||||
this.renderForm();
|
this.renderForm();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.updateProgress();
|
this.updateProgress();
|
||||||
|
|
||||||
|
// IP定位自动填充城市
|
||||||
|
this.autoFillCityByLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +102,7 @@ export class BasicInfoPage {
|
|||||||
modalId: 'cityPickerModal',
|
modalId: 'cityPickerModal',
|
||||||
provinceColumnId: 'provinceColumn',
|
provinceColumnId: 'provinceColumn',
|
||||||
cityColumnId: 'cityColumn',
|
cityColumnId: 'cityColumn',
|
||||||
|
hotCitiesId: 'hotCitiesList',
|
||||||
cancelBtnId: 'cityCancelBtn',
|
cancelBtnId: 'cityCancelBtn',
|
||||||
confirmBtnId: 'cityConfirmBtn',
|
confirmBtnId: 'cityConfirmBtn',
|
||||||
onConfirm: (result) => {
|
onConfirm: (result) => {
|
||||||
@@ -330,34 +333,15 @@ export class BasicInfoPage {
|
|||||||
const input = infoItem.querySelector(`#basic-input-${item.id}`);
|
const input = infoItem.querySelector(`#basic-input-${item.id}`);
|
||||||
const errorEl = infoItem.querySelector(`#error-${item.id}`);
|
const errorEl = infoItem.querySelector(`#error-${item.id}`);
|
||||||
|
|
||||||
input.addEventListener('input', async () => {
|
input.addEventListener('input', () => {
|
||||||
this.basicInfoValues[item.id] = input.value.trim();
|
this.basicInfoValues[item.id] = input.value.trim();
|
||||||
this.updateBasicInfoProgress();
|
this.updateBasicInfoProgress();
|
||||||
this.checkSubmitButton();
|
this.checkSubmitButton();
|
||||||
|
|
||||||
// 清除错误提示
|
|
||||||
input.classList.remove('error');
|
|
||||||
if (errorEl) {
|
if (errorEl) {
|
||||||
|
input.classList.remove('error');
|
||||||
errorEl.style.display = 'none';
|
errorEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 身份证输入到6位时,自动填充地区
|
|
||||||
if (item.id === 'idCard' && input.value.trim().length >= 6) {
|
|
||||||
const areaCode = Validator.extractAreaCode(input.value);
|
|
||||||
console.log('[BasicInfoPage] 身份证输入,提取地区代码:', areaCode);
|
|
||||||
|
|
||||||
if (areaCode && areaCode !== this.lastFilledAreaCode) {
|
|
||||||
console.log('[BasicInfoPage] 查询地区信息...');
|
|
||||||
const areaInfo = await AreaService.getAreaByCode(areaCode);
|
|
||||||
console.log('[BasicInfoPage] 查询结果:', areaInfo);
|
|
||||||
|
|
||||||
if (areaInfo) {
|
|
||||||
this.handleCityConfirm(areaInfo);
|
|
||||||
this.lastFilledAreaCode = areaCode;
|
|
||||||
console.log('[BasicInfoPage] 地区自动填充成功');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -630,18 +614,12 @@ export class BasicInfoPage {
|
|||||||
// 清除草稿
|
// 清除草稿
|
||||||
DraftManager.clearDraft();
|
DraftManager.clearDraft();
|
||||||
|
|
||||||
// 提交成功,显示结果
|
// 提交成功:有返回数据则进入成功页(含无 H5 时的成功+5秒倒计时/回首页)
|
||||||
const h5Urls = response.data?.h5Urls || response.data?.h5urls;
|
if (response.data) {
|
||||||
if (response.data && h5Urls && h5Urls.length > 0) {
|
|
||||||
// 如果有返回的 H5 URL,显示跳转选项
|
|
||||||
this.showSubmitSuccessDialog(response.data);
|
this.showSubmitSuccessDialog(response.data);
|
||||||
} else {
|
} else {
|
||||||
// 普通成功提示
|
|
||||||
showToast('信息提交成功!');
|
showToast('信息提交成功!');
|
||||||
// 延迟跳转或刷新
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || '提交失败');
|
throw new Error(response.message || '提交失败');
|
||||||
@@ -686,29 +664,27 @@ export class BasicInfoPage {
|
|||||||
|
|
||||||
// 如果没有 h5Urls,显示成功提示
|
// 如果没有 h5Urls,显示成功提示
|
||||||
if (!h5Urls || h5Urls.length === 0) {
|
if (!h5Urls || h5Urls.length === 0) {
|
||||||
|
// 有 redirectUrl 跳转到指定地址,否则返回首页
|
||||||
|
const finalUrl = redirectUrl || window.location.href.split('?')[0];
|
||||||
|
const btnText = redirectUrl ? '立即跳转' : '返回首页';
|
||||||
|
|
||||||
authContainer.innerHTML = `
|
authContainer.innerHTML = `
|
||||||
<div class="auth-complete-section">
|
<div class="auth-complete-section">
|
||||||
<div class="auth-complete-icon">✓</div>
|
<div class="auth-complete-icon">✓</div>
|
||||||
<div class="auth-complete-title">信息提交成功</div>
|
<div class="auth-complete-title">信息提交成功</div>
|
||||||
<div class="auth-complete-desc">您的申请已提交成功!</div>
|
<div class="auth-complete-desc">您的申请已提交成功!</div>
|
||||||
${redirectUrl ? `
|
|
||||||
<div class="auth-countdown">
|
<div class="auth-countdown">
|
||||||
<span id="countdownSeconds">5</span> 秒后自动跳转...
|
<span id="countdownSeconds">5</span> 秒后自动跳转...
|
||||||
</div>
|
</div>
|
||||||
<button class="auth-redirect-btn" id="redirectNowBtn">立即跳转</button>
|
<button class="auth-redirect-btn" id="redirectNowBtn">${btnText}</button>
|
||||||
` : `
|
|
||||||
<button class="auth-redirect-btn" onclick="window.location.reload()">返回首页</button>
|
|
||||||
`}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const mainContainer = document.querySelector('.container') || document.body;
|
const mainContainer = document.querySelector('.container') || document.body;
|
||||||
mainContainer.insertBefore(authContainer, mainContainer.firstChild);
|
mainContainer.insertBefore(authContainer, mainContainer.firstChild);
|
||||||
|
|
||||||
// 如果有 redirectUrl,启动倒计时
|
// 启动 5 秒倒计时
|
||||||
if (redirectUrl) {
|
this.startFinalCountdown(finalUrl);
|
||||||
this.startFinalCountdown(redirectUrl);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,29 +822,25 @@ export class BasicInfoPage {
|
|||||||
iframeSection.style.display = 'none';
|
iframeSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建完成提示
|
// 有 redirectUrl 跳转直推页,否则 5 秒后回首页(与无 h5Urls 分支一致)
|
||||||
|
const finalUrl = redirectUrl || window.location.href.split('?')[0];
|
||||||
|
const btnText = redirectUrl ? '立即跳转' : '返回首页';
|
||||||
|
|
||||||
const completeSection = document.createElement('div');
|
const completeSection = document.createElement('div');
|
||||||
completeSection.className = 'auth-complete-section';
|
completeSection.className = 'auth-complete-section';
|
||||||
completeSection.innerHTML = `
|
completeSection.innerHTML = `
|
||||||
<div class="auth-complete-icon">✓</div>
|
<div class="auth-complete-icon">✓</div>
|
||||||
<div class="auth-complete-title">全部授权完成</div>
|
<div class="auth-complete-title">全部授权完成</div>
|
||||||
<div class="auth-complete-desc">您的申请已全部提交成功!</div>
|
<div class="auth-complete-desc">您的申请已全部提交成功!</div>
|
||||||
${redirectUrl ? `
|
|
||||||
<div class="auth-countdown">
|
<div class="auth-countdown">
|
||||||
<span id="countdownSeconds">5</span> 秒后自动跳转...
|
<span id="countdownSeconds">5</span> 秒后自动跳转...
|
||||||
</div>
|
</div>
|
||||||
<button class="auth-redirect-btn" id="redirectNowBtn">立即跳转</button>
|
<button class="auth-redirect-btn" id="redirectNowBtn">${btnText}</button>
|
||||||
` : `
|
|
||||||
<button class="auth-redirect-btn" onclick="window.location.reload()">返回首页</button>
|
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.appendChild(completeSection);
|
container.appendChild(completeSection);
|
||||||
|
|
||||||
// 启动倒计时
|
this.startFinalCountdown(finalUrl);
|
||||||
if (redirectUrl) {
|
|
||||||
this.startFinalCountdown(redirectUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -918,6 +890,51 @@ export class BasicInfoPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过IP定位自动填充城市
|
||||||
|
*/
|
||||||
|
async autoFillCityByLocation() {
|
||||||
|
try {
|
||||||
|
// 如果已经有城市值,跳过
|
||||||
|
if (this.basicInfoValues.city) {
|
||||||
|
console.log('[BasicInfoPage] 城市已存在,跳过IP定位填充');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BasicInfoPage] 开始IP定位...');
|
||||||
|
const locationService = getLocationService();
|
||||||
|
const location = await locationService.getLocation();
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
const { province, city } = location;
|
||||||
|
console.log('[BasicInfoPage] IP定位成功:', province, city);
|
||||||
|
|
||||||
|
// 查找城市代码
|
||||||
|
const cityCode = await AreaService.findCityCode(province, city);
|
||||||
|
|
||||||
|
if (cityCode) {
|
||||||
|
// 自动填充城市
|
||||||
|
this.handleCityConfirm({
|
||||||
|
value: city,
|
||||||
|
province: province,
|
||||||
|
city: city,
|
||||||
|
provinceCode: cityCode.provinceCode,
|
||||||
|
cityCode: cityCode.cityCode
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[BasicInfoPage] 城市自动填充成功:', city);
|
||||||
|
} else {
|
||||||
|
console.warn('[BasicInfoPage] 未找到城市代码:', province, city);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[BasicInfoPage] IP定位失败: 未获取到位置信息');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BasicInfoPage] IP定位异常:', error);
|
||||||
|
// 静默失败,不影响用户正常使用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 销毁页面
|
* 销毁页面
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Picker, Modal, OneClickLoginButton } from '../ui/index.js';
|
import { Picker, Modal, OneClickLoginButton } from '../ui/index.js';
|
||||||
import { Validator, Formatter } from '../utils/index.js';
|
import { Validator, Formatter } from '../utils/index.js';
|
||||||
import { SMSService, AuthService, LoanService } from '../services/index.js';
|
import { SMSService, AuthService, LoanService } from '../services/index.js';
|
||||||
|
import { AuthFlowService } from '../services/auth-flow.service.js';
|
||||||
import { LOAN_CONFIG, PURPOSE_PICKER_CONFIG, TERM_PICKER_CONFIG, ANIMATION_CONFIG } from '../config/index.js';
|
import { LOAN_CONFIG, PURPOSE_PICKER_CONFIG, TERM_PICKER_CONFIG, ANIMATION_CONFIG } from '../config/index.js';
|
||||||
import { UserCache } from '../core/user-cache.js';
|
import { UserCache } from '../core/user-cache.js';
|
||||||
import { showToast } from '../ui/toast.js';
|
import { showToast } from '../ui/toast.js';
|
||||||
@@ -30,8 +31,8 @@ export class IndexPage {
|
|||||||
// 注意:使用一键登录需要在极光控制台配置域名白名单,否则会出现跨域错误
|
// 注意:使用一键登录需要在极光控制台配置域名白名单,否则会出现跨域错误
|
||||||
this.jVerifyAppId = '80570da3ef331d9de547b4f1';
|
this.jVerifyAppId = '80570da3ef331d9de547b4f1';
|
||||||
|
|
||||||
// 倒计时定时器
|
// 倒计时取消函数
|
||||||
this.countdownTimer = null;
|
this.countdownCancel = null;
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
@@ -314,24 +315,27 @@ export class IndexPage {
|
|||||||
* @param {HTMLElement} countdownEl - 倒计时元素
|
* @param {HTMLElement} countdownEl - 倒计时元素
|
||||||
*/
|
*/
|
||||||
startCountdown(countdownEl) {
|
startCountdown(countdownEl) {
|
||||||
let time = 59;
|
|
||||||
countdownEl.textContent = `${time}s`;
|
|
||||||
countdownEl.style.color = '#999';
|
countdownEl.style.color = '#999';
|
||||||
countdownEl.style.cursor = 'default';
|
countdownEl.style.cursor = 'default';
|
||||||
countdownEl.onclick = null;
|
countdownEl.onclick = null;
|
||||||
|
|
||||||
this.countdownTimer = setInterval(() => {
|
// 清除之前的倒计时
|
||||||
time--;
|
if (this.countdownCancel) {
|
||||||
if (time > 0) {
|
this.countdownCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.countdownCancel = AuthFlowService.startCountdown(
|
||||||
|
59,
|
||||||
|
(time) => {
|
||||||
countdownEl.textContent = `${time}s`;
|
countdownEl.textContent = `${time}s`;
|
||||||
} else {
|
},
|
||||||
|
() => {
|
||||||
countdownEl.textContent = '重新发送';
|
countdownEl.textContent = '重新发送';
|
||||||
countdownEl.style.color = '#3474fe';
|
countdownEl.style.color = '#3474fe';
|
||||||
countdownEl.style.cursor = 'pointer';
|
countdownEl.style.cursor = 'pointer';
|
||||||
countdownEl.onclick = () => this.resendSMS(countdownEl);
|
countdownEl.onclick = () => this.resendSMS(countdownEl);
|
||||||
clearInterval(this.countdownTimer);
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -534,8 +538,8 @@ export class IndexPage {
|
|||||||
* 销毁页面
|
* 销毁页面
|
||||||
*/
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.countdownTimer) {
|
if (this.countdownCancel) {
|
||||||
clearInterval(this.countdownTimer);
|
this.countdownCancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.purposePicker) {
|
if (this.purposePicker) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { API_CONFIG } from '../config/api.config.js';
|
import { API_CONFIG } from '../config/api.config.js';
|
||||||
import { ApiClient } from '../core/api.js';
|
import { ApiClient } from '../core/api.js';
|
||||||
|
import { CacheManager } from '../core/cache-manager.js';
|
||||||
|
|
||||||
const CACHE_KEY_PREFIX = 'area_cache_';
|
const CACHE_KEY_PREFIX = 'area_cache_';
|
||||||
const CACHE_DURATION = 10 * 60 * 1000; // 10分钟
|
const CACHE_DURATION = 10 * 60 * 1000; // 10分钟
|
||||||
@@ -19,44 +20,20 @@ export class AreaService {
|
|||||||
* @returns {Promise<Array>} 区域列表 [{code, name}]
|
* @returns {Promise<Array>} 区域列表 [{code, name}]
|
||||||
*/
|
*/
|
||||||
static async getAreaList(provincecode) {
|
static async getAreaList(provincecode) {
|
||||||
// 检查缓存
|
|
||||||
const cacheKey = `${CACHE_KEY_PREFIX}${provincecode || 'all'}`;
|
const cacheKey = `${CACHE_KEY_PREFIX}${provincecode || 'all'}`;
|
||||||
const cached = localStorage.getItem(cacheKey);
|
|
||||||
|
|
||||||
if (cached) {
|
return CacheManager.getCachedOrFetch(cacheKey, async () => {
|
||||||
const { data, timestamp } = JSON.parse(cached);
|
const params = provincecode ? { provincecode } : {};
|
||||||
if (Date.now() - timestamp < CACHE_DURATION) {
|
console.log(`[AreaService] 请求区域数据 ${provincecode || 'all'}`);
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 构建请求参数
|
|
||||||
const params = {};
|
|
||||||
if (provincecode) {
|
|
||||||
params.provincecode = provincecode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 ApiClient 发送请求
|
|
||||||
const result = await ApiClient.get(API_CONFIG.ENDPOINTS.AREA_LIST, params);
|
const result = await ApiClient.get(API_CONFIG.ENDPOINTS.AREA_LIST, params);
|
||||||
|
|
||||||
if (result.retcode !== 0) {
|
if (result.retcode !== 0) {
|
||||||
throw new Error(result.retmsg || '获取区域数据失败');
|
throw new Error(result.retmsg || '获取区域数据失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
const areaList = result.result || [];
|
return result.result || [];
|
||||||
|
}, CACHE_DURATION);
|
||||||
// 保存到缓存
|
|
||||||
localStorage.setItem(cacheKey, JSON.stringify({
|
|
||||||
data: areaList,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}));
|
|
||||||
|
|
||||||
return areaList;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[AreaService] 获取区域数据失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,56 +57,48 @@ export class AreaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除缓存
|
* 根据省市名称查找代码
|
||||||
|
* @param {string} provinceName - 省份名称
|
||||||
|
* @param {string} cityName - 城市名称
|
||||||
|
* @returns {Promise<Object|null>} - {provinceCode, cityCode},如果未找到则返回 null
|
||||||
*/
|
*/
|
||||||
static clearCache() {
|
static async findCityCode(provinceName, cityName) {
|
||||||
Object.keys(localStorage)
|
if (!provinceName || !cityName) {
|
||||||
.filter(key => key.startsWith(CACHE_KEY_PREFIX))
|
|
||||||
.forEach(key => localStorage.removeItem(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据地区代码(身份证前6位)查询地区信息
|
|
||||||
* @param {string} areaCode - 地区代码(6位)
|
|
||||||
* @returns {Promise<Object|null>} - 地区信息 {province, city, district, value},如果未找到则返回 null
|
|
||||||
*/
|
|
||||||
static async getAreaByCode(areaCode) {
|
|
||||||
if (!areaCode || areaCode.length < 6) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const provincecode = areaCode.substring(0, 2);
|
const provinces = await this.getProvinces();
|
||||||
|
const province = provinces.find(p => p.name === provinceName);
|
||||||
|
|
||||||
// 并行查询省列表和该省的市区列表
|
if (!province) {
|
||||||
const [provinces, areas] = await Promise.all([
|
console.warn(`[AreaService] 未找到省份: ${provinceName}`);
|
||||||
this.getProvinces(),
|
return null;
|
||||||
this.getAreaList(provincecode)
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
// 查找省、市、区
|
const areas = await this.getCities(province.code);
|
||||||
const province = provinces.find(p => p.code === provincecode);
|
const city = areas.find(c => c.name === cityName && c.code.length === 4);
|
||||||
const city = areas.find(a => a.code === areaCode.substring(0, 4));
|
|
||||||
const district = areas.find(a => a.code === areaCode);
|
if (!city) {
|
||||||
|
console.warn(`[AreaService] 未找到城市: ${provinceName}-${cityName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 至少要有省份才返回
|
|
||||||
if (province) {
|
|
||||||
return {
|
return {
|
||||||
province: province.name,
|
|
||||||
city: city ? city.name : '',
|
|
||||||
district: district ? district.name : '',
|
|
||||||
provinceCode: province.code,
|
provinceCode: province.code,
|
||||||
cityCode: city ? city.code : '',
|
cityCode: city.code
|
||||||
districtCode: district ? district.code : '',
|
|
||||||
value: city ? `${province.name}/${city.name}` : province.name
|
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AreaService] 查找城市代码失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
/**
|
||||||
} catch (error) {
|
* 清除缓存
|
||||||
console.error('[AreaService] 根据代码查询地区失败:', error);
|
*/
|
||||||
return null;
|
static clearCache() {
|
||||||
}
|
CacheManager.clearCacheByPrefix(CACHE_KEY_PREFIX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
124
src/js/services/location.service.js
Normal file
124
src/js/services/location.service.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 定位服务
|
||||||
|
* 提供全局 IP 定位功能,避免重复请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_CONFIG } from '../config/api.config.js';
|
||||||
|
import { ApiClient } from '../core/api.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定位服务(单例模式)
|
||||||
|
*/
|
||||||
|
export class LocationService {
|
||||||
|
static instance = null;
|
||||||
|
|
||||||
|
// 定位缓存
|
||||||
|
locationCache = null;
|
||||||
|
|
||||||
|
// 正在定位的 Promise
|
||||||
|
locatingPromise = null;
|
||||||
|
|
||||||
|
// 缓存有效期(10分钟)
|
||||||
|
CACHE_DURATION = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单例实例
|
||||||
|
*/
|
||||||
|
static getInstance() {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new LocationService();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前定位(带缓存)
|
||||||
|
* @returns {Promise<Object>} 定位结果 {province, city, code}
|
||||||
|
*/
|
||||||
|
async getLocation() {
|
||||||
|
// 如果有缓存且未过期,直接返回
|
||||||
|
if (this.locationCache) {
|
||||||
|
const { data, timestamp } = this.locationCache;
|
||||||
|
if (Date.now() - timestamp < this.CACHE_DURATION) {
|
||||||
|
console.log('[LocationService] 使用缓存定位:', data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在定位,返回同一个 Promise
|
||||||
|
if (this.locatingPromise) {
|
||||||
|
console.log('[LocationService] 定位进行中,等待结果...');
|
||||||
|
return this.locatingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始定位
|
||||||
|
this.locatingPromise = this.doLocation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.locatingPromise;
|
||||||
|
|
||||||
|
// 缓存结果
|
||||||
|
this.locationCache = {
|
||||||
|
data: result,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
// 清除定位 Promise
|
||||||
|
this.locatingPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 IP 定位
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async doLocation() {
|
||||||
|
try {
|
||||||
|
console.log('[LocationService] 开始 IP 定位...');
|
||||||
|
const result = await ApiClient.get(API_CONFIG.ENDPOINTS.IP_LOCATION);
|
||||||
|
|
||||||
|
if (result.retcode === 0 && result.result) {
|
||||||
|
const { province, city } = result.result;
|
||||||
|
|
||||||
|
if (province && city) {
|
||||||
|
console.log('[LocationService] IP 定位成功:', province, city);
|
||||||
|
return {
|
||||||
|
province,
|
||||||
|
city,
|
||||||
|
code: null // 稍后可以添加城市代码
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('定位失败:未获取到省市信息');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LocationService] IP 定位失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除缓存
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.locationCache = null;
|
||||||
|
console.log('[LocationService] 缓存已清除');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置定位结果(手动设置)
|
||||||
|
* @param {Object} location 定位结果 {province, city, code}
|
||||||
|
*/
|
||||||
|
setLocation(location) {
|
||||||
|
this.locationCache = {
|
||||||
|
data: location,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
console.log('[LocationService] 手动设置定位:', location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例获取函数
|
||||||
|
export const getLocationService = () => LocationService.getInstance();
|
||||||
@@ -1,9 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* 城市选择器组件
|
* 城市选择器组件
|
||||||
* 提供省份和城市的联动选择功能
|
* 提供省份和城市的联动选择功能,支持热门城市快速选择和定位
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AreaService } from '../services/area.service.js';
|
import {AreaService} from '../services/area.service.js';
|
||||||
|
import {getLocationService} from '../services/location.service.js';
|
||||||
|
import {API_CONFIG} from '../config/api.config.js';
|
||||||
|
import {ApiClient} from '../core/api.js';
|
||||||
|
|
||||||
|
// 热门城市列表(12个核心城市)
|
||||||
|
const HOT_CITIES = [
|
||||||
|
{name: '北京市', province: '北京市', code: '1100'},
|
||||||
|
{name: '上海市', province: '上海市', code: '3100'},
|
||||||
|
{name: '广州市', province: '广东省', code: '4401'},
|
||||||
|
{name: '深圳市', province: '广东省', code: '4403'},
|
||||||
|
{name: '成都市', province: '四川省', code: '5101'},
|
||||||
|
{name: '杭州市', province: '浙江省', code: '3301'},
|
||||||
|
{name: '重庆市', province: '重庆市', code: '5000'},
|
||||||
|
{name: '武汉市', province: '湖北省', code: '4201'},
|
||||||
|
{name: '西安市', province: '陕西省', code: '6101'},
|
||||||
|
{name: '南京市', province: '江苏省', code: '3201'},
|
||||||
|
{name: '苏州市', province: '江苏省', code: '3205'},
|
||||||
|
{name: '天津市', province: '天津市', code: '1200'}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 直辖市列表(省和市同名)
|
||||||
|
const MUNICIPALITIES = ['北京市', '上海市', '天津市', '重庆市'];
|
||||||
|
|
||||||
export class CityPicker {
|
export class CityPicker {
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +34,7 @@ export class CityPicker {
|
|||||||
* @param {string} options.modalId - 模态框元素ID
|
* @param {string} options.modalId - 模态框元素ID
|
||||||
* @param {string} options.provinceColumnId - 省份列元素ID
|
* @param {string} options.provinceColumnId - 省份列元素ID
|
||||||
* @param {string} options.cityColumnId - 城市列元素ID
|
* @param {string} options.cityColumnId - 城市列元素ID
|
||||||
|
* @param {string} options.hotCitiesId - 热门城市区域元素ID
|
||||||
* @param {string} options.cancelBtnId - 取消按钮ID
|
* @param {string} options.cancelBtnId - 取消按钮ID
|
||||||
* @param {string} options.confirmBtnId - 确认按钮ID
|
* @param {string} options.confirmBtnId - 确认按钮ID
|
||||||
* @param {Function} options.onConfirm - 确认回调
|
* @param {Function} options.onConfirm - 确认回调
|
||||||
@@ -20,6 +43,7 @@ export class CityPicker {
|
|||||||
this.modal = document.getElementById(options.modalId);
|
this.modal = document.getElementById(options.modalId);
|
||||||
this.provinceColumn = document.getElementById(options.provinceColumnId);
|
this.provinceColumn = document.getElementById(options.provinceColumnId);
|
||||||
this.cityColumn = document.getElementById(options.cityColumnId);
|
this.cityColumn = document.getElementById(options.cityColumnId);
|
||||||
|
this.hotCitiesArea = document.getElementById(options.hotCitiesId);
|
||||||
this.cancelBtn = document.getElementById(options.cancelBtnId);
|
this.cancelBtn = document.getElementById(options.cancelBtnId);
|
||||||
this.confirmBtn = document.getElementById(options.confirmBtnId);
|
this.confirmBtn = document.getElementById(options.confirmBtnId);
|
||||||
this.onConfirm = options.onConfirm || null;
|
this.onConfirm = options.onConfirm || null;
|
||||||
@@ -37,6 +61,11 @@ export class CityPicker {
|
|||||||
// 数据缓存
|
// 数据缓存
|
||||||
this.provinces = [];
|
this.provinces = [];
|
||||||
this.cities = [];
|
this.cities = [];
|
||||||
|
this.hotCities = HOT_CITIES;
|
||||||
|
|
||||||
|
// 定位相关
|
||||||
|
this.currentLocationCity = null; // 当前定位城市
|
||||||
|
this.isLocating = false; // 是否正在定位
|
||||||
|
|
||||||
if (!this.modal) {
|
if (!this.modal) {
|
||||||
console.error(`[CityPicker] 找不到模态框元素: ${options.modalId}`);
|
console.error(`[CityPicker] 找不到模态框元素: ${options.modalId}`);
|
||||||
@@ -68,6 +97,154 @@ export class CityPicker {
|
|||||||
|
|
||||||
// 预加载省份数据
|
// 预加载省份数据
|
||||||
this.loadProvinces();
|
this.loadProvinces();
|
||||||
|
|
||||||
|
// 获取定位
|
||||||
|
this.getCurrentLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染定位标签
|
||||||
|
*/
|
||||||
|
renderLocationBadge() {
|
||||||
|
const locationSection = document.getElementById('locationSection');
|
||||||
|
if (!locationSection) return;
|
||||||
|
|
||||||
|
locationSection.innerHTML = '';
|
||||||
|
|
||||||
|
if (this.currentLocationCity) {
|
||||||
|
// 已定位,显示当前城市
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'location-badge';
|
||||||
|
badge.innerHTML = `
|
||||||
|
<span class="location-icon">📍</span>
|
||||||
|
<span class="location-text">${this.currentLocationCity.name}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
badge.addEventListener('click', async () => {
|
||||||
|
await this.selectHotCity(this.currentLocationCity);
|
||||||
|
});
|
||||||
|
|
||||||
|
locationSection.appendChild(badge);
|
||||||
|
} else if (this.isLocating) {
|
||||||
|
// 正在定位
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'location-badge locating';
|
||||||
|
badge.innerHTML = `
|
||||||
|
<span class="location-icon">⌛</span>
|
||||||
|
<span class="location-text">定位中...</span>
|
||||||
|
`;
|
||||||
|
locationSection.appendChild(badge);
|
||||||
|
} else {
|
||||||
|
// 未定位,显示定位按钮
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'location-badge';
|
||||||
|
badge.innerHTML = `
|
||||||
|
<span class="location-icon">📍</span>
|
||||||
|
<span class="location-text">获取定位</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
badge.addEventListener('click', () => {
|
||||||
|
this.getCurrentLocation();
|
||||||
|
});
|
||||||
|
|
||||||
|
locationSection.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染热门城市区域
|
||||||
|
*/
|
||||||
|
renderHotCities() {
|
||||||
|
// 渲染定位标签
|
||||||
|
this.renderLocationBadge();
|
||||||
|
|
||||||
|
if (!this.hotCitiesArea) return;
|
||||||
|
|
||||||
|
this.hotCitiesArea.innerHTML = '';
|
||||||
|
|
||||||
|
// 渲染热门城市列表
|
||||||
|
this.hotCities.forEach(city => {
|
||||||
|
const cityTag = document.createElement('div');
|
||||||
|
cityTag.className = 'city-tag';
|
||||||
|
cityTag.textContent = city.name;
|
||||||
|
cityTag.dataset.cityName = city.name;
|
||||||
|
cityTag.dataset.provinceName = city.province;
|
||||||
|
cityTag.dataset.cityCode = city.code;
|
||||||
|
cityTag.dataset.provinceCode = city.code.substring(0, 2) + '00';
|
||||||
|
|
||||||
|
// 添加淡入动画
|
||||||
|
cityTag.style.animationDelay = `${Math.floor(Math.random() * 100)}ms`;
|
||||||
|
|
||||||
|
// 单击选中热门城市
|
||||||
|
cityTag.addEventListener('click', async () => {
|
||||||
|
await this.selectHotCity(city);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 双击直接确认选择
|
||||||
|
cityTag.addEventListener('dblclick', async () => {
|
||||||
|
await this.selectHotCity(city);
|
||||||
|
this.confirmSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hotCitiesArea.appendChild(cityTag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择热门城市
|
||||||
|
* @param {Object} city - 热门城市对象
|
||||||
|
*/
|
||||||
|
async selectHotCity(city) {
|
||||||
|
const isMunicipality = MUNICIPALITIES.includes(city.province);
|
||||||
|
|
||||||
|
this.tempSelectedProvince = city.province;
|
||||||
|
this.tempSelectedCity = city.name;
|
||||||
|
this.tempSelectedProvinceCode = city.provinceCode || city.code.substring(0, 2) + '00';
|
||||||
|
this.tempSelectedCityCode = city.code;
|
||||||
|
|
||||||
|
// 更新热门城市选中状态
|
||||||
|
const tags = this.hotCitiesArea.querySelectorAll('.city-tag');
|
||||||
|
tags.forEach(tag => {
|
||||||
|
tag.classList.toggle('active', tag.dataset.cityCode === city.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步更新省份和城市的选中状态
|
||||||
|
if (this.provinces.length === 0) {
|
||||||
|
await this.loadProvinces();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中对应的省份
|
||||||
|
const province = this.provinces.find(p => p.name === city.province || p.code === this.tempSelectedProvinceCode);
|
||||||
|
if (province) {
|
||||||
|
this.tempSelectedProvince = province.name;
|
||||||
|
this.tempSelectedProvinceCode = province.code;
|
||||||
|
|
||||||
|
// 更新省份列表选中状态
|
||||||
|
const provinceItems = this.provinceColumn.querySelectorAll('.province-item');
|
||||||
|
provinceItems.forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.provinceCode === province.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载并渲染城市列表
|
||||||
|
await this.loadCities(province.code);
|
||||||
|
|
||||||
|
// 选中对应的城市
|
||||||
|
const cityItems = this.cityColumn.querySelectorAll('.city-item');
|
||||||
|
if (isMunicipality) {
|
||||||
|
// 直辖市:自动选中唯一选项(省份名称)
|
||||||
|
if (cityItems.length > 0) {
|
||||||
|
cityItems[0].classList.add('active');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 普通城市:选中对应的城市
|
||||||
|
cityItems.forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.cityCode === city.code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新确认按钮状态
|
||||||
|
this.updateConfirmButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,13 +267,16 @@ export class CityPicker {
|
|||||||
|
|
||||||
this.provinceColumn.innerHTML = '';
|
this.provinceColumn.innerHTML = '';
|
||||||
|
|
||||||
this.provinces.forEach(province => {
|
this.provinces.forEach((province, index) => {
|
||||||
const provinceItem = document.createElement('div');
|
const provinceItem = document.createElement('div');
|
||||||
provinceItem.className = 'city-picker-item';
|
provinceItem.className = 'province-item';
|
||||||
provinceItem.textContent = province.name;
|
provinceItem.textContent = province.name;
|
||||||
provinceItem.dataset.province = province.name;
|
provinceItem.dataset.province = province.name;
|
||||||
provinceItem.dataset.provinceCode = province.code;
|
provinceItem.dataset.provinceCode = province.code;
|
||||||
|
|
||||||
|
// 添加淡入动画
|
||||||
|
provinceItem.style.animation = `fadeIn 0.3s ease ${index * 20}ms backwards`;
|
||||||
|
|
||||||
provinceItem.addEventListener('click', () => {
|
provinceItem.addEventListener('click', () => {
|
||||||
this.selectProvince(province.name, province.code);
|
this.selectProvince(province.name, province.code);
|
||||||
});
|
});
|
||||||
@@ -115,13 +295,20 @@ export class CityPicker {
|
|||||||
this.tempSelectedProvinceCode = provinceCode;
|
this.tempSelectedProvinceCode = provinceCode;
|
||||||
|
|
||||||
// 更新省份选中状态
|
// 更新省份选中状态
|
||||||
const items = this.provinceColumn.querySelectorAll('.city-picker-item');
|
const items = this.provinceColumn.querySelectorAll('.province-item');
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
item.classList.toggle('active', item.dataset.provinceCode === provinceCode);
|
item.classList.toggle('active', item.dataset.provinceCode === provinceCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 清除城市选中状态(切换省份时)
|
||||||
|
this.tempSelectedCity = '';
|
||||||
|
this.tempSelectedCityCode = '';
|
||||||
|
|
||||||
// 加载并渲染城市列表
|
// 加载并渲染城市列表
|
||||||
await this.loadCities(provinceCode);
|
await this.loadCities(provinceCode);
|
||||||
|
|
||||||
|
// 更新确认按钮状态
|
||||||
|
this.updateConfirmButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,20 +333,48 @@ export class CityPicker {
|
|||||||
|
|
||||||
this.cityColumn.innerHTML = '';
|
this.cityColumn.innerHTML = '';
|
||||||
|
|
||||||
// 只显示市(4位),不显示区(6位)
|
// 判断是否为直辖市
|
||||||
const cities = this.cities.filter(area => area.code.length === 4);
|
const isMunicipality = MUNICIPALITIES.includes(this.tempSelectedProvince);
|
||||||
|
|
||||||
cities.forEach(city => {
|
let cities;
|
||||||
|
if (isMunicipality) {
|
||||||
|
// 直辖市:直接显示省份名称作为唯一的城市选项
|
||||||
|
// 这样用户选择后返回的就是"北京市"、"上海市"等
|
||||||
|
cities = [{
|
||||||
|
name: this.tempSelectedProvince,
|
||||||
|
code: this.tempSelectedProvinceCode
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
// 普通省份:显示市级数据(4位代码),排除"市辖区"
|
||||||
|
cities = this.cities.filter(area =>
|
||||||
|
area.code.length === 4 &&
|
||||||
|
area.name !== '市辖区'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cities.forEach((city, index) => {
|
||||||
const cityItem = document.createElement('div');
|
const cityItem = document.createElement('div');
|
||||||
cityItem.className = 'city-picker-item';
|
cityItem.className = 'city-item';
|
||||||
cityItem.textContent = city.name;
|
cityItem.textContent = city.name;
|
||||||
cityItem.dataset.city = city.name;
|
cityItem.dataset.city = city.name;
|
||||||
cityItem.dataset.cityCode = city.code;
|
cityItem.dataset.cityCode = city.code;
|
||||||
|
// 添加 title 属性,鼠标悬停时显示完整城市名称
|
||||||
|
cityItem.title = city.name;
|
||||||
|
|
||||||
|
// 添加淡入动画
|
||||||
|
cityItem.style.animation = `fadeIn 0.3s ease ${index * 15}ms backwards`;
|
||||||
|
|
||||||
|
// 单击选中城市
|
||||||
cityItem.addEventListener('click', () => {
|
cityItem.addEventListener('click', () => {
|
||||||
this.selectCity(city.name, city.code);
|
this.selectCity(city.name, city.code);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 双击直接确认选择
|
||||||
|
cityItem.addEventListener('dblclick', () => {
|
||||||
|
this.selectCity(city.name, city.code);
|
||||||
|
this.confirmSelection();
|
||||||
|
});
|
||||||
|
|
||||||
this.cityColumn.appendChild(cityItem);
|
this.cityColumn.appendChild(cityItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,12 +383,11 @@ export class CityPicker {
|
|||||||
const existingCity = cities.find(c => c.code === this.tempSelectedCityCode);
|
const existingCity = cities.find(c => c.code === this.tempSelectedCityCode);
|
||||||
if (existingCity) {
|
if (existingCity) {
|
||||||
this.selectCity(existingCity.name, existingCity.code);
|
this.selectCity(existingCity.name, existingCity.code);
|
||||||
} else if (cities.length > 0) {
|
|
||||||
// 默认选择第一个
|
|
||||||
this.selectCity(cities[0].name, cities[0].code);
|
|
||||||
}
|
}
|
||||||
} else if (cities.length > 0) {
|
}
|
||||||
// 默认选择第一个
|
|
||||||
|
// 对于直辖市,自动选中唯一的选项
|
||||||
|
if (isMunicipality && cities.length === 1 && !this.tempSelectedCityCode) {
|
||||||
this.selectCity(cities[0].name, cities[0].code);
|
this.selectCity(cities[0].name, cities[0].code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,35 +402,59 @@ export class CityPicker {
|
|||||||
this.tempSelectedCityCode = cityCode;
|
this.tempSelectedCityCode = cityCode;
|
||||||
|
|
||||||
// 更新城市选中状态
|
// 更新城市选中状态
|
||||||
const items = this.cityColumn.querySelectorAll('.city-picker-item');
|
const items = this.cityColumn.querySelectorAll('.city-item');
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
item.classList.toggle('active', item.dataset.cityCode === cityCode);
|
item.classList.toggle('active', item.dataset.cityCode === cityCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新热门城市选中状态
|
||||||
|
this.updateHotCitiesSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开选择器
|
* 打开选择器
|
||||||
* @param {string} currentValue - 当前值(格式:"省/市")
|
* @param {string} currentValue - 当前值(格式:"市名" 或 "省/市")
|
||||||
*/
|
*/
|
||||||
async open(currentValue = '') {
|
async open(currentValue = '') {
|
||||||
// 解析当前值,并查找对应的代码
|
// 解析当前值,并查找对应的代码
|
||||||
if (currentValue) {
|
if (currentValue) {
|
||||||
const parts = currentValue.split('/');
|
let cityName = '';
|
||||||
if (parts.length === 2) {
|
let provinceName = '';
|
||||||
const provinceName = parts[0];
|
|
||||||
const cityName = parts[1];
|
|
||||||
|
|
||||||
|
// 支持两种格式:纯市名 "北京市" 或 省市格式 "北京市/北京市"
|
||||||
|
if (currentValue.includes('/')) {
|
||||||
|
const parts = currentValue.split('/');
|
||||||
|
provinceName = parts[0];
|
||||||
|
cityName = parts[1];
|
||||||
|
} else {
|
||||||
|
cityName = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先在热门城市中查找
|
||||||
|
const hotCity = this.hotCities.find(c => c.name === cityName);
|
||||||
|
if (hotCity) {
|
||||||
|
this.tempSelectedProvince = hotCity.province;
|
||||||
|
this.tempSelectedCity = hotCity.name;
|
||||||
|
this.tempSelectedProvinceCode = hotCity.code.substring(0, 2) + '00';
|
||||||
|
this.tempSelectedCityCode = hotCity.code;
|
||||||
|
} else {
|
||||||
// 查找省份代码
|
// 查找省份代码
|
||||||
if (this.provinces.length === 0) {
|
if (this.provinces.length === 0) {
|
||||||
await this.loadProvinces();
|
await this.loadProvinces();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果有省份名,直接查找
|
||||||
|
if (provinceName) {
|
||||||
const province = this.provinces.find(p => p.name === provinceName);
|
const province = this.provinces.find(p => p.name === provinceName);
|
||||||
if (province) {
|
if (province) {
|
||||||
this.tempSelectedProvince = provinceName;
|
this.tempSelectedProvince = provinceName;
|
||||||
this.tempSelectedProvinceCode = province.code;
|
this.tempSelectedProvinceCode = province.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载城市数据并查找城市代码
|
// 如果有省代码,加载城市数据并查找城市代码
|
||||||
await this.loadCities(province.code);
|
if (this.tempSelectedProvinceCode) {
|
||||||
|
await this.loadCities(this.tempSelectedProvinceCode);
|
||||||
const city = this.cities.find(c => c.name === cityName && c.code.length === 4);
|
const city = this.cities.find(c => c.name === cityName && c.code.length === 4);
|
||||||
if (city) {
|
if (city) {
|
||||||
this.tempSelectedCity = cityName;
|
this.tempSelectedCity = cityName;
|
||||||
@@ -238,16 +476,51 @@ export class CityPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 渲染列表
|
// 渲染列表
|
||||||
|
this.renderLocationBadge(); // 渲染定位标签
|
||||||
|
this.renderHotCities();
|
||||||
this.renderProvinceList();
|
this.renderProvinceList();
|
||||||
|
|
||||||
|
// 更新热门城市选中状态
|
||||||
|
this.updateHotCitiesSelection();
|
||||||
|
|
||||||
if (this.tempSelectedProvinceCode) {
|
if (this.tempSelectedProvinceCode) {
|
||||||
await this.selectProvince(this.tempSelectedProvince, this.tempSelectedProvinceCode);
|
await this.selectProvince(this.tempSelectedProvince, this.tempSelectedProvinceCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新确认按钮状态
|
||||||
|
this.updateConfirmButtonState();
|
||||||
|
|
||||||
// 显示模态框
|
// 显示模态框
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
this.modal.classList.add('show');
|
this.modal.classList.add('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新热门城市选中状态
|
||||||
|
*/
|
||||||
|
updateHotCitiesSelection() {
|
||||||
|
if (!this.hotCitiesArea) return;
|
||||||
|
|
||||||
|
const tags = this.hotCitiesArea.querySelectorAll('.city-tag');
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const isSelected = this.tempSelectedCityCode && tag.dataset.cityCode === this.tempSelectedCityCode;
|
||||||
|
tag.classList.toggle('active', isSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新确认按钮状态
|
||||||
|
this.updateConfirmButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新确认按钮状态
|
||||||
|
*/
|
||||||
|
updateConfirmButtonState() {
|
||||||
|
if (this.confirmBtn) {
|
||||||
|
const hasSelection = this.tempSelectedProvince && this.tempSelectedCity;
|
||||||
|
this.confirmBtn.classList.toggle('has-selection', hasSelection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭选择器
|
* 关闭选择器
|
||||||
*/
|
*/
|
||||||
@@ -274,7 +547,8 @@ export class CityPicker {
|
|||||||
this.selectedProvinceCode = this.tempSelectedProvinceCode;
|
this.selectedProvinceCode = this.tempSelectedProvinceCode;
|
||||||
this.selectedCityCode = this.tempSelectedCityCode;
|
this.selectedCityCode = this.tempSelectedCityCode;
|
||||||
|
|
||||||
const cityValue = `${this.tempSelectedProvince}/${this.tempSelectedCity}`;
|
// 只返回市名,如:北京市、武汉市、曲靖市
|
||||||
|
const cityValue = this.tempSelectedCity;
|
||||||
|
|
||||||
if (this.onConfirm) {
|
if (this.onConfirm) {
|
||||||
this.onConfirm({
|
this.onConfirm({
|
||||||
@@ -292,18 +566,38 @@ export class CityPicker {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置选中值
|
* 设置选中值
|
||||||
* @param {string} value - 值(格式:"省/市")
|
* @param {string} value - 值(格式:"市名" 或 "省/市")
|
||||||
*/
|
*/
|
||||||
setValue(value) {
|
setValue(value) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
|
// 支持两种格式:纯市名或省/市格式
|
||||||
|
if (value.includes('/')) {
|
||||||
const parts = value.split('/');
|
const parts = value.split('/');
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
this.selectedProvince = parts[0];
|
this.selectedProvince = parts[0];
|
||||||
this.selectedCity = parts[1];
|
this.selectedCity = parts[1];
|
||||||
this.tempSelectedProvince = parts[0];
|
this.tempSelectedProvince = parts[0];
|
||||||
this.tempSelectedCity = parts[1];
|
this.tempSelectedCity = parts[1];
|
||||||
// 代码需要通过查找获取,这里暂时留空
|
}
|
||||||
|
} else {
|
||||||
|
// 纯市名,从热门城市中查找省份信息
|
||||||
|
const hotCity = this.hotCities.find(c => c.name === value);
|
||||||
|
if (hotCity) {
|
||||||
|
const provinceCode = hotCity.code.substring(0, 2) + '00';
|
||||||
|
this.selectedProvince = hotCity.province;
|
||||||
|
this.selectedCity = hotCity.name;
|
||||||
|
this.selectedProvinceCode = provinceCode;
|
||||||
|
this.selectedCityCode = hotCity.code;
|
||||||
|
this.tempSelectedProvince = hotCity.province;
|
||||||
|
this.tempSelectedCity = hotCity.name;
|
||||||
|
this.tempSelectedProvinceCode = provinceCode;
|
||||||
|
this.tempSelectedCityCode = hotCity.code;
|
||||||
|
} else {
|
||||||
|
// 如果不在热门城市中,只记录城市名
|
||||||
|
this.selectedCity = value;
|
||||||
|
this.tempSelectedCity = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +611,7 @@ export class CityPicker {
|
|||||||
city: this.selectedCity,
|
city: this.selectedCity,
|
||||||
provinceCode: this.selectedProvinceCode,
|
provinceCode: this.selectedProvinceCode,
|
||||||
cityCode: this.selectedCityCode,
|
cityCode: this.selectedCityCode,
|
||||||
value: `${this.selectedProvince}/${this.selectedCity}`
|
value: this.selectedCity // 只返回市名
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,4 +638,69 @@ export class CityPicker {
|
|||||||
this.provinceColumn = null;
|
this.provinceColumn = null;
|
||||||
this.cityColumn = null;
|
this.cityColumn = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前定位(使用IP定位)
|
||||||
|
*/
|
||||||
|
async getCurrentLocation() {
|
||||||
|
// 如果已经定位过,不再重复定位
|
||||||
|
if (this.currentLocationCity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在定位,避免重复请求
|
||||||
|
if (this.isLocating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLocating = true;
|
||||||
|
|
||||||
|
// 如果定位区域已渲染,更新状态显示"定位中"
|
||||||
|
const locationSection = document.getElementById('locationSection');
|
||||||
|
if (locationSection) {
|
||||||
|
this.renderLocationBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用全局定位服务
|
||||||
|
const locationService = getLocationService();
|
||||||
|
const location = await locationService.getLocation();
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
const {province, city} = location;
|
||||||
|
console.log('[CityPicker] IP定位成功:', province, city);
|
||||||
|
|
||||||
|
// 查找城市代码
|
||||||
|
const cityCode = await AreaService.findCityCode(province, city);
|
||||||
|
|
||||||
|
if (cityCode) {
|
||||||
|
this.currentLocationCity = {
|
||||||
|
name: city,
|
||||||
|
province: province,
|
||||||
|
code: cityCode.cityCode,
|
||||||
|
provinceCode: cityCode.provinceCode
|
||||||
|
};
|
||||||
|
console.log('[CityPicker] 定位成功:', this.currentLocationCity.name);
|
||||||
|
} else {
|
||||||
|
// 如果找不到代码,返回基本信息(代码为空)
|
||||||
|
this.currentLocationCity = {
|
||||||
|
name: city,
|
||||||
|
province: province,
|
||||||
|
code: '',
|
||||||
|
provinceCode: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CityPicker] IP定位失败:', error);
|
||||||
|
// 定位失败时不显示错误,静默处理
|
||||||
|
} finally {
|
||||||
|
this.isLocating = false;
|
||||||
|
// 更新UI显示
|
||||||
|
const locationSection = document.getElementById('locationSection');
|
||||||
|
if (locationSection) {
|
||||||
|
this.renderLocationBadge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,26 +96,6 @@ export class Validator {
|
|||||||
return idCard[17].toUpperCase() === checkCode;
|
return idCard[17].toUpperCase() === checkCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从身份证号提取地区代码
|
|
||||||
* @param {string} idCard - 身份证号
|
|
||||||
* @returns {string|null} - 地区代码(前6位),如果无效则返回 null
|
|
||||||
*/
|
|
||||||
static extractAreaCode(idCard) {
|
|
||||||
if (!idCard) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idCardStr = idCard.trim();
|
|
||||||
|
|
||||||
// 身份证号前6位是地区代码
|
|
||||||
if (idCardStr.length >= 6) {
|
|
||||||
return idCardStr.substring(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证短信验证码
|
* 验证短信验证码
|
||||||
* @param {string} code - 验证码
|
* @param {string} code - 验证码
|
||||||
|
|||||||
BIN
static/image.zip
Normal file
BIN
static/image.zip
Normal file
Binary file not shown.
791
功能说明.md
Normal file
791
功能说明.md
Normal file
@@ -0,0 +1,791 @@
|
|||||||
|
# flux-web 功能说明文档
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
flux-web 是薇钱包 H5 前端项目,提供用户借款申请、信息填写和授权流程的完整业务功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、业务流程总览
|
||||||
|
|
||||||
|
### 1.1 完整用户旅程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ index.html │───>│ basic-info.html │───>│ 授权流程 │
|
||||||
|
│ 借款申请页面 │ │ 基本信息填写 │ │ (可选) │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 核心业务流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
用户进入页面
|
||||||
|
│
|
||||||
|
├─> 输入/修改借款金额
|
||||||
|
├─> 选择还款期数
|
||||||
|
├─> 选择借款用途
|
||||||
|
│
|
||||||
|
├─> [已有登录态?] ──是──> 显示脱敏手机号
|
||||||
|
│ │
|
||||||
|
│ └─> 勾选协议 → 跳转基本信息页
|
||||||
|
│
|
||||||
|
└─> 否 ──> 选择登录方式
|
||||||
|
│
|
||||||
|
├─> 方式A: 极光一键登录
|
||||||
|
│ └─> 成功 → 注册/登录 → 跳转
|
||||||
|
│ └─> 失败 → 降级到方式B
|
||||||
|
│
|
||||||
|
└─> 方式B: 短信验证码登录
|
||||||
|
├─> 输入手机号
|
||||||
|
├─> 发送验证码 (60s倒计时)
|
||||||
|
├─> 验证码校验
|
||||||
|
└─> 注册/登录 → 跳转
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、index.html - 借款申请页面
|
||||||
|
|
||||||
|
### 2.1 页面元素与交互
|
||||||
|
|
||||||
|
| 元素 | DOM ID | 交互行为 | 数据流向 |
|
||||||
|
|------|--------|----------|----------|
|
||||||
|
| 借款金额输入 | `loanAmount` | input 事件触发 | `loanData.amount` |
|
||||||
|
| 全部借出按钮 | `maxLoan` | 点击设为最大值 | `loanData.amount = 200000` |
|
||||||
|
| 还款期数 | `repaymentTerm` | 点击打开选择器 | `loanData.period` |
|
||||||
|
| 还款计划 | `repaymentPlan` | 自动计算更新 | `calculateRepaymentPlan()` |
|
||||||
|
| 借款用途 | `loanPurpose` | 点击打开选择器 | `loanData.purpose` |
|
||||||
|
| 手机号输入 | `phoneNumber` | 输入 + 验证 | → 验证码弹窗 |
|
||||||
|
| 一键登录 | `oneClickLoginWrapper` | 极光SDK | → 成功后自动填手机号 |
|
||||||
|
| 立即申请按钮 | `applyBtn` | 点击触发申请 | → 验证码/跳转 |
|
||||||
|
| 协议勾选 | `agreementCheck` | 必选 | 未勾选弹窗提示 |
|
||||||
|
|
||||||
|
### 2.2 借款数据结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
loanData = {
|
||||||
|
amount: 50000, // 借款金额 (1000-200000)
|
||||||
|
period: 12, // 还款期数 (3,6,9,12,18,24,36)
|
||||||
|
purpose: '个人日常消费' // 借款用途
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 还款计划计算逻辑
|
||||||
|
|
||||||
|
**接口:** `LoanService.calculateRepaymentPlan(amount, period, interestRate)`
|
||||||
|
|
||||||
|
**计算公式:**
|
||||||
|
```
|
||||||
|
月均本金 = 借款金额 / 期数
|
||||||
|
月利息 = 借款金额 × (年化利率 / 100) / 12
|
||||||
|
首期还款 = 月均本金 + 月利息
|
||||||
|
首期日期 = 当前日期 + 30天
|
||||||
|
```
|
||||||
|
|
||||||
|
**年化利率范围:** 10.8% - 24%(单利)
|
||||||
|
|
||||||
|
**显示格式:** `首期02月05日 应还 4916.67元`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、用户认证流程
|
||||||
|
|
||||||
|
### 3.1 极光一键登录
|
||||||
|
|
||||||
|
**初始化条件:**
|
||||||
|
- 配置了 `jVerifyAppId`
|
||||||
|
- 用户未登录
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
```
|
||||||
|
加载极光SDK
|
||||||
|
│
|
||||||
|
├─> 成功获取手机号
|
||||||
|
│ └─> AuthService.registerOrLogin(phone, loanData)
|
||||||
|
│ └─> 成功 → 跳转基本信息页
|
||||||
|
│ └─> 失败 → 显示手机号输入框
|
||||||
|
│
|
||||||
|
└─> 失败/不支持
|
||||||
|
└─> 自动降级到短信验证码登录
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键接口:** `AuthService.registerOrLogin(phone, loanData)`
|
||||||
|
|
||||||
|
### 3.2 短信验证码登录
|
||||||
|
|
||||||
|
**流程图:**
|
||||||
|
```
|
||||||
|
输入手机号
|
||||||
|
│
|
||||||
|
├─> 验证手机号格式
|
||||||
|
│ └─> 无效 → 显示错误提示
|
||||||
|
│
|
||||||
|
├─> 有效 → 点击"立即申请"
|
||||||
|
│ │
|
||||||
|
│ ├─> 勾选协议?
|
||||||
|
│ │ └─> 否 → 弹协议确认窗
|
||||||
|
│ │
|
||||||
|
│ └─> 是 → 打开验证码弹窗
|
||||||
|
│ │
|
||||||
|
│ ├─> 发送验证码
|
||||||
|
│ │ ├─> SMSService.send(phone)
|
||||||
|
│ │ ├─> 成功 → 启动60s倒计时
|
||||||
|
│ │ └─> 失败 → 显示错误,可重新发送
|
||||||
|
│ │
|
||||||
|
│ ├─> 输入验证码
|
||||||
|
│ │ └─> SMSService.verify(phone, code)
|
||||||
|
│ │ └─> 成功 → 注册/登录
|
||||||
|
│ │ └─> 失败 → 显示错误
|
||||||
|
│ │
|
||||||
|
│ └─> 验证通过
|
||||||
|
│ └─> 跳转基本信息页
|
||||||
|
```
|
||||||
|
|
||||||
|
**倒计时逻辑:**
|
||||||
|
- 60秒倒计时
|
||||||
|
- 倒计时结束显示"重新发送"
|
||||||
|
- 可点击重新发送
|
||||||
|
|
||||||
|
### 3.3 接口数据说明
|
||||||
|
|
||||||
|
#### 发送短信验证码
|
||||||
|
|
||||||
|
**接口:** `POST /zcore/sms/send`
|
||||||
|
|
||||||
|
**请求格式:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phone": "13800138000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retcode": 0,
|
||||||
|
"retinfo": "发送成功"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 验证短信验证码
|
||||||
|
|
||||||
|
**接口:** `POST /zcore/sms/verify`
|
||||||
|
|
||||||
|
**请求格式:** `application/json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phone": "13800138000",
|
||||||
|
"code": "123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retcode": 0,
|
||||||
|
"retinfo": "验证成功"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 用户注册/登录
|
||||||
|
|
||||||
|
**接口:** `POST /api/partnerh5/login`
|
||||||
|
|
||||||
|
**请求格式:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
```
|
||||||
|
bean={"phone":"13800138000","loanamount":50000,"repaymentperiod":12,"loanpurpose":"个人日常消费"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retcode": 0,
|
||||||
|
"result": {
|
||||||
|
"customerid": "12345",
|
||||||
|
"sessionid": "abc123xyz",
|
||||||
|
"loginPhone": "13800138000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**本地存储:**
|
||||||
|
```javascript
|
||||||
|
UserCache.saveUserSession({
|
||||||
|
customerid: "12345",
|
||||||
|
sessionid: "abc123xyz",
|
||||||
|
loginPhone: "13800138000",
|
||||||
|
formData: { loanamount: 50000, ... }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**后续请求自动添加请求头:**
|
||||||
|
```
|
||||||
|
jsessionid: abc123xyz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、basic-info.html - 基本信息填写页面
|
||||||
|
|
||||||
|
### 4.1 页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 顶部进度卡片 │
|
||||||
|
│ - 预期额度: 35000 → 50000 │
|
||||||
|
│ - 进度条: 0% → 100% │
|
||||||
|
│ - 已完成: 0/8 │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─> 选择一项,进度+12.5%
|
||||||
|
│ 预期额度增加1875元
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 资产信息区域 (渐进式显示) │
|
||||||
|
│ 1. 房产: 有房产/无房产 │
|
||||||
|
│ 2. 车辆: 有车辆/无车辆 │
|
||||||
|
│ 3. 公积金: 有/无 │
|
||||||
|
│ 4. 社保: 有/无 │
|
||||||
|
│ 5. 信用卡: 有/无 │
|
||||||
|
│ 6. 银行流水: 有/无 │
|
||||||
|
│ 7. 职业: 4个选项 │
|
||||||
|
│ 8. 芝麻分: 4个选项 │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
└─> 8项全部完成后
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 基本信息区域 (自动展开) │
|
||||||
|
│ 1. 真实姓名 (输入) │
|
||||||
|
│ 2. 身份证号 (输入) │
|
||||||
|
│ 3. 所属城市 (选择器) │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 提交按钮 (基本信息完成时可用) │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 资产选项配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
ASSET_CONFIG.ITEMS = [
|
||||||
|
{ id: 'house', name: '房产', options: ['有房产', '无房产'] },
|
||||||
|
{ id: 'car', name: '车辆', options: ['有车辆', '无车辆'] },
|
||||||
|
{ id: 'fund', name: '公积金', options: ['有公积金', '无公积金'] },
|
||||||
|
{ id: 'social', name: '社保', options: ['有社保', '无社保'] },
|
||||||
|
{ id: 'credit', name: '信用卡', options: ['有信用卡', '无信用卡'] },
|
||||||
|
{ id: 'bank', name: '银行流水', options: ['有银行流水', '无银行流水'] },
|
||||||
|
{ id: 'job', name: '职业', options: ['上班族', '自由职业', '企业主', '公务员/国企'] },
|
||||||
|
{ id: 'zhima', name: '芝麻分', options: ['700以上', '650-700', '600-650', '无'] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 数据映射关系
|
||||||
|
|
||||||
|
**前端中文 → 后端数字:**
|
||||||
|
```javascript
|
||||||
|
VALUE_MAPPING = {
|
||||||
|
'有房产': 1, '无房产': 2,
|
||||||
|
'有车辆': 1, '无车辆': 2,
|
||||||
|
// ... 其他类似
|
||||||
|
'上班族': 1, '自由职业': 2, '企业主': 3, '公务员/国企': 4,
|
||||||
|
'700以上': 1, '650-700': 2, '600-650': 3, '无': 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 渐进式显示逻辑
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
selectAssetOption(itemId, value) {
|
||||||
|
// 1. 保存选择
|
||||||
|
this.selectedValues[itemId] = value;
|
||||||
|
|
||||||
|
// 2. 更新进度
|
||||||
|
const completed = Object.keys(this.selectedValues).length;
|
||||||
|
const progress = (completed / 8) * 100;
|
||||||
|
const money = 35000 + (50000 - 35000) * (progress / 100);
|
||||||
|
|
||||||
|
// 3. 检查是否是当前步骤
|
||||||
|
const currentIndex = ASSET_CONFIG.ITEMS.findIndex(item => item.id === itemId);
|
||||||
|
if (currentIndex === this.currentStep && this.currentStep < 7) {
|
||||||
|
// 延迟400ms后显示下一项
|
||||||
|
setTimeout(() => this.revealNextItem(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 自动保存草稿(2秒后)
|
||||||
|
this.autoSaveDraft();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 城市选择逻辑
|
||||||
|
|
||||||
|
**IP定位自动填充:**
|
||||||
|
```
|
||||||
|
页面加载
|
||||||
|
│
|
||||||
|
├─> 调用 IP定位接口
|
||||||
|
│ └─> /api/partnerh5/ip_location
|
||||||
|
│ └─> 返回 { province: "广东省", city: "深圳市" }
|
||||||
|
│
|
||||||
|
├─> 查询城市代码
|
||||||
|
│ └─> AreaService.findCityCode(province, city)
|
||||||
|
│ └─> 返回 { provinceCode: "440000", cityCode: "440300" }
|
||||||
|
│
|
||||||
|
└─> 自动填充城市字段
|
||||||
|
```
|
||||||
|
|
||||||
|
**省市区数据结构:**
|
||||||
|
```javascript
|
||||||
|
PROVINCE_CITY_DATA = {
|
||||||
|
'广东省': ['广州市', '深圳市', '珠海市', ...],
|
||||||
|
'江西省': ['南昌市', '九江市', ...],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、表单提交与草稿管理
|
||||||
|
|
||||||
|
### 5.1 草稿自动保存
|
||||||
|
|
||||||
|
**触发条件:**
|
||||||
|
- 选择任一资产选项
|
||||||
|
- 修改任一基本信息
|
||||||
|
|
||||||
|
**延迟机制:** 2秒防抖
|
||||||
|
|
||||||
|
**保存接口:** `POST /api/partnerh5/save_draft`
|
||||||
|
|
||||||
|
**请求数据:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
shortcode: "I3fMzX", // URL参数或默认值
|
||||||
|
formid: "uuid-xxx", // 唯一表单ID
|
||||||
|
draftstatus: 1, // 1=草稿,0=正式提交
|
||||||
|
|
||||||
|
// 资产信息(映射为数字)
|
||||||
|
house: 1, car: 2, fund: 1, ...,
|
||||||
|
|
||||||
|
// 基本信息
|
||||||
|
name: "张三",
|
||||||
|
idCard: "110101199001011234",
|
||||||
|
city: "深圳市"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 表单正式提交
|
||||||
|
|
||||||
|
**提交接口:** `POST /api/partnerh5/submit`
|
||||||
|
|
||||||
|
**请求数据结构:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
shortcode: "I3fMzX",
|
||||||
|
formid: "uuid-xxx",
|
||||||
|
draftstatus: 0, // 0=正式提交
|
||||||
|
|
||||||
|
// 资产信息
|
||||||
|
house: 1, car: 2, fund: 1, social: 1,
|
||||||
|
credit: 1, bank: 1, job: 1, zhima: 2,
|
||||||
|
|
||||||
|
// 基本信息
|
||||||
|
name: "张三",
|
||||||
|
idCard: "110101199001011234",
|
||||||
|
city: "深圳市"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retcode": 0,
|
||||||
|
"retinfo": "提交成功",
|
||||||
|
"result": {
|
||||||
|
"formdataid": 12345, // 表单数据ID(用于授权流程)
|
||||||
|
"h5Urls": [ // 需要授权的H5列表(可能为空)
|
||||||
|
{
|
||||||
|
"apicode": "TAOBAA",
|
||||||
|
"apiname": "淘宝授权",
|
||||||
|
"h5url": "https://..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apicode": "JD",
|
||||||
|
"apiname": "京东授权",
|
||||||
|
"h5url": "https://..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"redirectUrl": "https://..." // 最终跳转URL(H5直推地址)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 表单验证规则
|
||||||
|
|
||||||
|
| 字段 | 验证规则 | 错误提示 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 手机号 | `/^1[3-9]\d{9}$/` | "请输入有效的11位手机号" |
|
||||||
|
| 姓名 | `/^[\u4e00-\u9fa5]{2,20}/` | "请输入2-20位中文姓名" |
|
||||||
|
| 身份证 | 18位身份证校验 | "请输入有效的身份证号码" |
|
||||||
|
| 验证码 | 6位数字 | "请输入6位验证码" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、授权流程(AuthFlow)
|
||||||
|
|
||||||
|
### 6.1 流程触发条件
|
||||||
|
|
||||||
|
表单提交成功且 `h5Urls` 不为空时触发。
|
||||||
|
|
||||||
|
### 6.2 授权状态枚举
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
AUTH_STATUS = {
|
||||||
|
WAITING: 1, // 等待授权
|
||||||
|
CALLBACK: 2, // 已回调
|
||||||
|
APPLY_OK: 3, // 进件成功
|
||||||
|
APPLY_FAIL: 4 // 进件失败
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 授权流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
表单提交成功,返回 h5Urls
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 显示授权进度列表 │
|
||||||
|
│ - 当前产品: 1/3 │
|
||||||
|
│ - 状态: ● 淘宝授权 授权中... │
|
||||||
|
│ - iframe 加载 h5url │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
开始轮询授权状态
|
||||||
|
│
|
||||||
|
├─> 每3秒查询一次
|
||||||
|
│ └─> POST /api/partnerh5/check_auth_status
|
||||||
|
│ └─> { formdataid: 12345, apicode: "TAOBAO" }
|
||||||
|
│
|
||||||
|
├─> 状态判断
|
||||||
|
│ ├─> status = 1 (WAITING) → 继续轮询
|
||||||
|
│ ├─> status = 2 (CALLBACK) → 成功,下一个
|
||||||
|
│ ├─> status = 3 (APPLY_OK) → 成功,下一个
|
||||||
|
│ └─> status = 4 (APPLY_FAIL) → 失败,下一个
|
||||||
|
│
|
||||||
|
├─> 超时判断
|
||||||
|
│ └─> 超过10分钟 → 标记超时,下一个
|
||||||
|
│
|
||||||
|
└─> 全部完成
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 显示完成页面 │
|
||||||
|
│ - 全部授权完成 ✓ │
|
||||||
|
│ - 5秒后自动跳转... │
|
||||||
|
│ - [立即跳转] 按钮 │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─> 有 redirectUrl → 跳转到指定页面
|
||||||
|
└─> 无 redirectUrl → 返回首页
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 授权状态查询接口
|
||||||
|
|
||||||
|
**接口:** `POST /api/partnerh5/check_auth_status`
|
||||||
|
|
||||||
|
**请求格式:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
```
|
||||||
|
bean={"formdataid":12345,"apicode":"TAOBAO"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retcode": 0,
|
||||||
|
"result": {
|
||||||
|
"apicode": "TAOBAO",
|
||||||
|
"apiname": "淘宝授权",
|
||||||
|
"status": 2,
|
||||||
|
"h5url": "https://..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 轮询配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
POLL_INTERVAL = 3000 // 轮询间隔:3秒
|
||||||
|
POLL_TIMEOUT = 10 * 60 * 1000 // 超时时间:10分钟
|
||||||
|
COUNTDOWN_SECONDS = 5 // 完成后倒计时:5秒
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Mixed Content 处理
|
||||||
|
|
||||||
|
当 HTTPS 页面需要加载 HTTP 的 iframe 时:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (window.location.protocol === 'https:' && h5url.startsWith('http://')) {
|
||||||
|
// 检测到 Mixed Content,使用新窗口打开
|
||||||
|
window.open(h5url, '_blank');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、页面间数据传递
|
||||||
|
|
||||||
|
### 7.1 URL 参数传递
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 从 index.html 跳转到 basic-info.html,保留当前页的查询串
|
||||||
|
window.location.href = 'basic-info.html' + window.location.search;
|
||||||
|
|
||||||
|
// 跳转后 URL 参数保持不变,例如:
|
||||||
|
// basic-info.html?shortcode=I3fMzX&channel=toutiao
|
||||||
|
// 其中 shortcode 为短链编码(与后端 shortcode 对应),;channel 等其它参数一并保留。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 本地存储传递
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 保存用户会话
|
||||||
|
UserCache.saveUserSession({
|
||||||
|
customerid: "12345",
|
||||||
|
sessionid: "abc123",
|
||||||
|
loginPhone: "13800138000",
|
||||||
|
formData: {
|
||||||
|
loanamount: 50000,
|
||||||
|
repaymentperiod: 12,
|
||||||
|
loanpurpose: "个人日常消费"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 读取用户会话
|
||||||
|
const session = UserCache.getUserSession();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 表单ID管理
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 生成或获取表单ID
|
||||||
|
const formId = FormIdGenerator.getOrCreate(); // uuid
|
||||||
|
|
||||||
|
// 提交成功后清除
|
||||||
|
FormIdGenerator.clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、API 接口汇总
|
||||||
|
|
||||||
|
### 8.1 接口列表
|
||||||
|
|
||||||
|
| 接口 | 方法 | 用途 | Content-Type |
|
||||||
|
|------|------|------|--------------|
|
||||||
|
| /zcore/sms/send | POST | 发送短信验证码 | application/json |
|
||||||
|
| /zcore/sms/verify | POST | 验证短信验证码 | application/json |
|
||||||
|
| /zcore/jpush/login | POST | 极光一键登录 | application/json |
|
||||||
|
| /api/partnerh5/login | POST | 用户注册/登录 | x-www-form-urlencoded |
|
||||||
|
| /api/partnerh5/submit | POST | 提交表单 | x-www-form-urlencoded |
|
||||||
|
| /api/partnerh5/save_draft | POST | 保存草稿 | x-www-form-urlencoded |
|
||||||
|
| /api/partnerh5/get_draft | POST | 获取草稿 | x-www-form-urlencoded |
|
||||||
|
| /api/partnerh5/check_auth_status | POST | 检查授权状态 | x-www-form-urlencoded |
|
||||||
|
| /api/partnerh5/area_list | GET | 获取区域列表 | - |
|
||||||
|
| /api/partnerh5/ip_location | GET | IP定位 | - |
|
||||||
|
|
||||||
|
### 8.2 请求头规范
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 所有请求自动添加
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json' 或 'application/x-www-form-urlencoded',
|
||||||
|
'jsessionid': '从 UserCache 获取' // 用户登录后自动添加
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 响应格式规范
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retcode": 0, // 0=成功,其他=失败
|
||||||
|
"retinfo": "操作成功", // 提示信息
|
||||||
|
"result": { ... } // 返回数据(可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、配置说明
|
||||||
|
|
||||||
|
### 9.1 API 配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/js/config/api.config.js
|
||||||
|
BASE_URL: '' // 空字符串表示同源部署
|
||||||
|
TIMEOUT: 30000 // 请求超时30秒
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 应用配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/js/config/app.config.js
|
||||||
|
DEBUG_CONFIG: {
|
||||||
|
ENABLED: false, // 生产环境设为 false
|
||||||
|
SMS_CODE: '123456', // 调试模式固定验证码
|
||||||
|
DEFAULT_SHORTCODE: 'I3fMzX', // 默认短链代码
|
||||||
|
VERBOSE_LOGGING: true // 详细日志
|
||||||
|
}
|
||||||
|
|
||||||
|
LOAN_CONFIG: {
|
||||||
|
AMOUNT: { MIN: 1000, MAX: 200000, DEFAULT: 50000 },
|
||||||
|
INTEREST_RATE: { MIN: 10.8, MAX: 24 },
|
||||||
|
PERIOD_OPTIONS: [3, 6, 9, 12, 18, 24, 36]
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSET_CONFIG: {
|
||||||
|
PROGRESS_MONEY: {
|
||||||
|
INITIAL: 35000, // 初始预期额度
|
||||||
|
FINAL: 50000 // 最终预期额度
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、关键业务逻辑
|
||||||
|
|
||||||
|
### 10.1 一键登录降级机制
|
||||||
|
|
||||||
|
```
|
||||||
|
极光一键登录初始化
|
||||||
|
│
|
||||||
|
├─> 成功获取手机号
|
||||||
|
│ └─> 调用注册接口
|
||||||
|
│ ├─> 成功 → 跳转
|
||||||
|
│ └─> 失败 → 显示手机号输入框
|
||||||
|
│
|
||||||
|
└─> 失败/不支持
|
||||||
|
└─> 隐藏一键登录按钮
|
||||||
|
└─> 显示手机号输入框
|
||||||
|
└─> 显示"立即申请"按钮
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 已登录用户处理
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 页面加载时检查登录态
|
||||||
|
restoreUserData() {
|
||||||
|
const session = UserCache.getUserSession();
|
||||||
|
|
||||||
|
if (session && session.loginPhone) {
|
||||||
|
// 显示脱敏手机号
|
||||||
|
this.elements.phoneNumber.value = Formatter.maskPhone(session.loginPhone);
|
||||||
|
this.elements.phoneNumber.readOnly = true;
|
||||||
|
|
||||||
|
// 恢复表单数据
|
||||||
|
if (session.formData) {
|
||||||
|
this.elements.loanAmount.value = session.formData.loanamount;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 协议确认逻辑
|
||||||
|
|
||||||
|
```
|
||||||
|
点击"立即申请"
|
||||||
|
│
|
||||||
|
├─> 已登录
|
||||||
|
│ └─> 勾选协议? ──否──> 弹协议确认窗
|
||||||
|
│ └─> 是 → 跳转基本信息页
|
||||||
|
│
|
||||||
|
└─> 未登录
|
||||||
|
└─> 验证手机号
|
||||||
|
└─> 勾选协议? ──否──> 弹协议确认窗
|
||||||
|
└─> 是 → 显示验证码弹窗
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 表单提交锁机制
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 防止重复提交
|
||||||
|
this.isSubmitting = false;
|
||||||
|
|
||||||
|
async handleSubmit() {
|
||||||
|
if (this.isSubmitting) return; // 已在提交中
|
||||||
|
|
||||||
|
this.elements.submitBtn.disabled = true;
|
||||||
|
this.elements.submitBtn.textContent = '提交中...';
|
||||||
|
this.isSubmitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await DraftManager.submitForm(...);
|
||||||
|
if (response.success) {
|
||||||
|
// 处理成功
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 恢复按钮状态
|
||||||
|
this.elements.submitBtn.disabled = false;
|
||||||
|
this.elements.submitBtn.textContent = '下一步';
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、错误处理
|
||||||
|
|
||||||
|
### 11.1 短信发送失败
|
||||||
|
|
||||||
|
```
|
||||||
|
发送短信
|
||||||
|
│
|
||||||
|
└─> 失败
|
||||||
|
├─> 显示错误信息
|
||||||
|
├─> 倒计时显示"重新发送"
|
||||||
|
└─> 可点击重新发送
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 验证码错误
|
||||||
|
|
||||||
|
```
|
||||||
|
输入验证码点击确定
|
||||||
|
│
|
||||||
|
└─> 验证失败
|
||||||
|
├─> 显示错误提示
|
||||||
|
├─> 输入框标红
|
||||||
|
├─> 保持弹窗打开
|
||||||
|
└─> 可重新输入
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 表单提交失败
|
||||||
|
|
||||||
|
```
|
||||||
|
提交表单
|
||||||
|
│
|
||||||
|
└─> 失败
|
||||||
|
├─> Toast 提示错误信息
|
||||||
|
├─> 恢复提交按钮
|
||||||
|
├─> 不清除表单数据
|
||||||
|
└─> 可重新提交
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、版本信息
|
||||||
|
|
||||||
|
- **当前版本:** 2.0.0
|
||||||
|
- **最后更新:** 2025-01-21
|
||||||
|
- **版权所有:** 北京百雅科技有限公司
|
||||||
Reference in New Issue
Block a user