Compare commits

...

9 Commits

17 changed files with 1932 additions and 337 deletions

View File

@@ -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;

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;
# }
# }

View File

@@ -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',
}, },
// 请求超时配置(毫秒) // 请求超时配置(毫秒)

View File

@@ -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,

View File

@@ -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 {
/**
* 拼接请求 URLBASE_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);
}); });

View 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;

View File

@@ -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);
} }
} }

View File

@@ -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);
// 静默失败,不影响用户正常使用
}
}
/** /**
* 销毁页面 * 销毁页面
*/ */

View File

@@ -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) {

View File

@@ -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);
} }
} }

View 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();

View File

@@ -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();
}
}
}
} }

View File

@@ -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

Binary file not shown.

791
功能说明.md Normal file
View 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://..." // 最终跳转URLH5直推地址
}
}
```
### 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
- **版权所有:** 北京百雅科技有限公司