Compare commits

..

5 Commits

Author SHA1 Message Date
5c2bfdef8f . 2026-02-03 20:29:21 +08:00
033aa5dfec 修改:调试用短链码 2026-01-30 16:17:50 +08:00
4f025ce788 重构:统一缓存管理和消除重复代码 2026-01-30 16:06:25 +08:00
cdda12afaa 重构:移除身份证自动填充地区功能 2026-01-30 13:48:53 +08:00
027db84a25 优化:API配置支持同站部署和安全区域适配 2026-01-30 13:47:57 +08:00
13 changed files with 996 additions and 286 deletions

View File

@@ -15,7 +15,7 @@ body {
.basic-info-container {
min-height: 100vh;
padding: 16px;
padding-bottom: 100px;
padding-bottom: calc(130px + env(safe-area-inset-bottom, 0px));
}
/* 顶部卡片 */
@@ -547,14 +547,13 @@ body {
transform: scale(0.98);
}
/* 底部按钮 */
/* 底部按钮:贴底并预留安全区,避免被系统栏/手势区遮挡 */
.button-section {
position: fixed;
bottom: 20px;
bottom: 0;
left: 0;
width: 100%;
padding: 16px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
background-color: #fff;
box-shadow: 0 -2px 4px 0 rgba(0, 0, 0, 0.08);
z-index: 100;

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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="./src/css/components/one-click-login.css">
</head>

View File

@@ -8,7 +8,9 @@ export const API_CONFIG = {
// BASE_URL: 'http://localhost:8071',
// 生产环境 URL如需切换取消注释并注释掉上面的
BASE_URL: 'https://flux.1216.top',
// BASE_URL: 'https://flux.1216.top',
BASE_URL: '',
// API 端点配置
ENDPOINTS: {

View File

@@ -12,7 +12,7 @@ export const DEBUG_CONFIG = {
SMS_CODE: '123456',
// 调试模式下默认的短链代码
DEFAULT_SHORTCODE: 'sRh907',
DEFAULT_SHORTCODE: 'I3fMzX',
// 是否启用详细日志
VERBOSE_LOGGING: true,

View File

@@ -7,6 +7,17 @@ import { API_CONFIG, DEBUG_CONFIG } from '../config/index.js';
import { UserCache } from './user-cache.js';
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 - 参数对象
@@ -42,7 +53,7 @@ export class ApiClient {
* @returns {Promise<Object>} - 响应数据
*/
static async post(endpoint, data = {}) {
const url = API_CONFIG.BASE_URL + endpoint;
const url = this.getRequestUrl(endpoint);
const headers = this.getHeaders('application/json');
try {
@@ -75,7 +86,7 @@ export class ApiClient {
* @returns {Promise<Object>} - 响应数据
*/
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');
try {
@@ -108,9 +119,11 @@ export class ApiClient {
* @returns {Promise<Object>} - 响应数据
*/
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]) => {
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';
export class FormIdGenerator {
// 防止递归调用检测
static _gettingFormId = false;
static _lastFormId = '';
/**
* 生成随机9位数字的表单唯一标识符
* @returns {number} - 9位随机数字
* @private
*/
static _generateRandomId() {
return Math.floor(Math.random() * 900000000) + 100000000; // 生成100000000-999999999之间的9位数字
return Math.floor(Math.random() * 900000000) + 100000000;
}
/**
@@ -24,32 +20,15 @@ export class FormIdGenerator {
* @returns {string} - 表单ID字符串
*/
static getOrCreate() {
// 防止递归调用检测
if (this._gettingFormId) {
console.error('[FormIdGenerator] 检测到递归调用 getOrCreateFormId', new Error().stack);
return this._lastFormId || '';
let formId = localStorage.getItem(CACHE_CONFIG.KEYS.FORM_ID);
if (!formId) {
formId = this._generateRandomId().toString();
localStorage.setItem(CACHE_CONFIG.KEYS.FORM_ID, formId);
console.log('[FormIdGenerator] 生成新的表单ID:', formId);
}
this._gettingFormId = true;
try {
console.log('[FormIdGenerator] getOrCreate 被调用');
let formId = localStorage.getItem(CACHE_CONFIG.KEYS.FORM_ID);
console.log('[FormIdGenerator] 从 localStorage 获取的 formId:', formId);
if (!formId) {
formId = this._generateRandomId().toString();
localStorage.setItem(CACHE_CONFIG.KEYS.FORM_ID, formId);
console.log('[FormIdGenerator] 生成新的表单ID:', formId);
}
this._lastFormId = formId;
return formId;
} catch (error) {
console.error('[FormIdGenerator] getOrCreate 出错:', error);
return '';
} finally {
this._gettingFormId = false;
}
return formId;
}
/**
@@ -57,7 +36,6 @@ export class FormIdGenerator {
*/
static clear() {
localStorage.removeItem(CACHE_CONFIG.KEYS.FORM_ID);
this._lastFormId = '';
console.log('[FormIdGenerator] 已清除表单ID');
}
@@ -79,7 +57,6 @@ export class FormIdGenerator {
return;
}
localStorage.setItem(CACHE_CONFIG.KEYS.FORM_ID, formId);
this._lastFormId = formId;
console.log('[FormIdGenerator] 已设置表单ID:', formId);
}
}

View File

@@ -27,9 +27,6 @@ export class BasicInfoPage {
this.isSavingDraft = false;
this.autoSaveTimer = null;
// 身份证自动填充标记
this.lastFilledAreaCode = null;
// 组件实例
this.cityPicker = null;
this.agreementModal = null;
@@ -336,34 +333,15 @@ export class BasicInfoPage {
const input = infoItem.querySelector(`#basic-input-${item.id}`);
const errorEl = infoItem.querySelector(`#error-${item.id}`);
input.addEventListener('input', async () => {
input.addEventListener('input', () => {
this.basicInfoValues[item.id] = input.value.trim();
this.updateBasicInfoProgress();
this.checkSubmitButton();
// 清除错误提示
input.classList.remove('error');
if (errorEl) {
input.classList.remove('error');
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] 地区自动填充成功');
}
}
}
});
}
});
@@ -636,18 +614,12 @@ export class BasicInfoPage {
// 清除草稿
DraftManager.clearDraft();
// 提交成功,显示结果
const h5Urls = response.data?.h5Urls || response.data?.h5urls;
if (response.data && h5Urls && h5Urls.length > 0) {
// 如果有返回的 H5 URL显示跳转选项
// 提交成功:有返回数据则进入成功页(含无 H5 时的成功+5秒倒计时/回首页)
if (response.data) {
this.showSubmitSuccessDialog(response.data);
} else {
// 普通成功提示
showToast('信息提交成功!');
// 延迟跳转或刷新
setTimeout(() => {
window.location.reload();
}, 2000);
setTimeout(() => window.location.reload(), 2000);
}
} else {
throw new Error(response.message || '提交失败');
@@ -850,29 +822,25 @@ export class BasicInfoPage {
iframeSection.style.display = 'none';
}
// 创建完成提示
// 有 redirectUrl 跳转直推页,否则 5 秒后回首页(与无 h5Urls 分支一致)
const finalUrl = redirectUrl || window.location.href.split('?')[0];
const btnText = redirectUrl ? '立即跳转' : '返回首页';
const completeSection = document.createElement('div');
completeSection.className = 'auth-complete-section';
completeSection.innerHTML = `
<div class="auth-complete-icon">✓</div>
<div class="auth-complete-title">全部授权完成</div>
<div class="auth-complete-desc">您的申请已全部提交成功!</div>
${redirectUrl ? `
<div class="auth-countdown">
<span id="countdownSeconds">5</span> 秒后自动跳转...
</div>
<button class="auth-redirect-btn" id="redirectNowBtn">立即跳转</button>
` : `
<button class="auth-redirect-btn" onclick="window.location.reload()">返回首页</button>
`}
<div class="auth-countdown">
<span id="countdownSeconds">5</span> 秒后自动跳转...
</div>
<button class="auth-redirect-btn" id="redirectNowBtn">${btnText}</button>
`;
container.appendChild(completeSection);
// 启动倒计时
if (redirectUrl) {
this.startFinalCountdown(redirectUrl);
}
this.startFinalCountdown(finalUrl);
}
/**
@@ -942,7 +910,7 @@ export class BasicInfoPage {
console.log('[BasicInfoPage] IP定位成功:', province, city);
// 查找城市代码
const cityCode = await this.findCityCode(province, city);
const cityCode = await AreaService.findCityCode(province, city);
if (cityCode) {
// 自动填充城市
@@ -967,37 +935,6 @@ export class BasicInfoPage {
}
}
/**
* 查找城市代码
*/
async findCityCode(provinceName, cityName) {
try {
// 获取省份数据
const provinces = await AreaService.getProvinces();
const province = provinces.find(p => p.name === provinceName);
if (!province) {
return null;
}
// 获取城市数据
const cities = await AreaService.getCities(province.code);
const city = cities.find(c => c.name === cityName && c.code.length === 4);
if (city) {
return {
provinceCode: province.code,
cityCode: city.code
};
}
return null;
} catch (error) {
console.error('[BasicInfoPage] 查找城市代码失败:', error);
return null;
}
}
/**
* 销毁页面
*/

View File

@@ -6,6 +6,7 @@
import { Picker, Modal, OneClickLoginButton } from '../ui/index.js';
import { Validator, Formatter } from '../utils/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 { UserCache } from '../core/user-cache.js';
import { showToast } from '../ui/toast.js';
@@ -30,8 +31,8 @@ export class IndexPage {
// 注意:使用一键登录需要在极光控制台配置域名白名单,否则会出现跨域错误
this.jVerifyAppId = '80570da3ef331d9de547b4f1';
// 倒计时定时器
this.countdownTimer = null;
// 倒计时取消函数
this.countdownCancel = null;
this.init();
}
@@ -314,24 +315,27 @@ export class IndexPage {
* @param {HTMLElement} countdownEl - 倒计时元素
*/
startCountdown(countdownEl) {
let time = 59;
countdownEl.textContent = `${time}s`;
countdownEl.style.color = '#999';
countdownEl.style.cursor = 'default';
countdownEl.onclick = null;
this.countdownTimer = setInterval(() => {
time--;
if (time > 0) {
// 清除之前的倒计时
if (this.countdownCancel) {
this.countdownCancel();
}
this.countdownCancel = AuthFlowService.startCountdown(
59,
(time) => {
countdownEl.textContent = `${time}s`;
} else {
},
() => {
countdownEl.textContent = '重新发送';
countdownEl.style.color = '#3474fe';
countdownEl.style.cursor = 'pointer';
countdownEl.onclick = () => this.resendSMS(countdownEl);
clearInterval(this.countdownTimer);
}
}, 1000);
);
}
/**
@@ -534,8 +538,8 @@ export class IndexPage {
* 销毁页面
*/
destroy() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
if (this.countdownCancel) {
this.countdownCancel();
}
if (this.purposePicker) {

View File

@@ -5,6 +5,7 @@
import { API_CONFIG } from '../config/api.config.js';
import { ApiClient } from '../core/api.js';
import { CacheManager } from '../core/cache-manager.js';
const CACHE_KEY_PREFIX = 'area_cache_';
const CACHE_DURATION = 10 * 60 * 1000; // 10分钟
@@ -13,9 +14,6 @@ const CACHE_DURATION = 10 * 60 * 1000; // 10分钟
* 区域服务
*/
export class AreaService {
// 正在进行的请求缓存
static pendingRequests = new Map();
/**
* 获取区域列表
* @param {string} provincecode - 省份代码(可选)
@@ -24,68 +22,18 @@ export class AreaService {
static async getAreaList(provincecode) {
const cacheKey = `${CACHE_KEY_PREFIX}${provincecode || 'all'}`;
// 检查 localStorage 缓存
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_DURATION) {
console.log(`[AreaService] 使用缓存 ${provincecode || 'all'}`);
return data;
}
}
// 检查是否有正在进行的请求
if (this.pendingRequests.has(cacheKey)) {
console.log(`[AreaService] 等待正在进行的请求 ${provincecode || 'all'}`);
return this.pendingRequests.get(cacheKey);
}
// 创建新请求
const requestPromise = this.doGetAreaList(provincecode, cacheKey);
this.pendingRequests.set(cacheKey, requestPromise);
try {
return await requestPromise;
} finally {
// 请求完成后清除缓存
this.pendingRequests.delete(cacheKey);
}
}
/**
* 执行实际的区域列表请求
* @private
*/
static async doGetAreaList(provincecode, cacheKey) {
try {
// 构建请求参数
const params = {};
if (provincecode) {
params.provincecode = provincecode;
}
return CacheManager.getCachedOrFetch(cacheKey, async () => {
const params = provincecode ? { provincecode } : {};
console.log(`[AreaService] 请求区域数据 ${provincecode || 'all'}`);
// 使用 ApiClient 发送请求
const result = await ApiClient.get(API_CONFIG.ENDPOINTS.AREA_LIST, params);
if (result.retcode !== 0) {
throw new Error(result.retmsg || '获取区域数据失败');
}
const areaList = result.result || [];
// 保存到 localStorage 缓存
localStorage.setItem(cacheKey, JSON.stringify({
data: areaList,
timestamp: Date.now()
}));
return areaList;
} catch (error) {
console.error('[AreaService] 获取区域数据失败:', error);
throw error;
}
return result.result || [];
}, CACHE_DURATION);
}
/**
@@ -109,57 +57,49 @@ export class AreaService {
}
/**
* 清除缓存
* 根据省市名称查找代码
* @param {string} provinceName - 省份名称
* @param {string} cityName - 城市名称
* @returns {Promise<Object|null>} - {provinceCode, cityCode},如果未找到则返回 null
*/
static clearCache() {
Object.keys(localStorage)
.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) {
static async findCityCode(provinceName, cityName) {
if (!provinceName || !cityName) {
return null;
}
try {
const provincecode = areaCode.substring(0, 2);
const provinces = await this.getProvinces();
const province = provinces.find(p => p.name === provinceName);
// 并行查询省列表和该省的市区列表
const [provinces, areas] = await Promise.all([
this.getProvinces(),
this.getAreaList(provincecode)
]);
// 查找省、市、区
const province = provinces.find(p => p.code === provincecode);
const city = areas.find(a => a.code === areaCode.substring(0, 4));
const district = areas.find(a => a.code === areaCode);
// 至少要有省份才返回
if (province) {
return {
province: province.name,
city: city ? city.name : '',
district: district ? district.name : '',
provinceCode: province.code,
cityCode: city ? city.code : '',
districtCode: district ? district.code : '',
value: city ? city.name : province.name // 只返回市名或省名
};
if (!province) {
console.warn(`[AreaService] 未找到省份: ${provinceName}`);
return null;
}
return null;
const areas = await this.getCities(province.code);
const city = areas.find(c => c.name === cityName && c.code.length === 4);
if (!city) {
console.warn(`[AreaService] 未找到城市: ${provinceName}-${cityName}`);
return null;
}
return {
provinceCode: province.code,
cityCode: city.code
};
} catch (error) {
console.error('[AreaService] 根据代码查询地区失败:', error);
console.error('[AreaService] 查找城市代码失败:', error);
return null;
}
}
/**
* 清除缓存
*/
static clearCache() {
CacheManager.clearCacheByPrefix(CACHE_KEY_PREFIX);
}
}
export default AreaService;

View File

@@ -671,7 +671,7 @@ export class CityPicker {
console.log('[CityPicker] IP定位成功:', province, city);
// 查找城市代码
const cityCode = await this.findCityCode(province, city);
const cityCode = await AreaService.findCityCode(province, city);
if (cityCode) {
this.currentLocationCity = {
@@ -703,40 +703,4 @@ export class CityPicker {
}
}
}
/**
* 查找城市代码
* @param {string} provinceName - 省份名称
* @param {string} cityName - 城市名称
* @returns {Promise<Object|null>} - 包含省代码和市代码的对象
*/
async findCityCode(provinceName, cityName) {
try {
// 先在省份数据中查找省份代码
if (this.provinces.length === 0) {
await this.loadProvinces();
}
const province = this.provinces.find(p => p.name === provinceName);
if (!province) {
return null;
}
// 加载该省的城市数据
const cities = await AreaService.getCities(province.code);
const city = cities.find(c => c.name === cityName && c.code.length === 4);
if (city) {
return {
provinceCode: province.code,
cityCode: city.code
};
}
return null;
} catch (error) {
console.error('[CityPicker] 查找城市代码失败:', error);
return null;
}
}
}

View File

@@ -96,26 +96,6 @@ export class Validator {
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 - 验证码

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
- **版权所有:** 北京百雅科技有限公司