This commit is contained in:
2026-01-22 18:31:30 +08:00
commit d703ac3574
46 changed files with 7751 additions and 0 deletions

139
DEPLOY.md Normal file
View File

@@ -0,0 +1,139 @@
# flux-web 部署指南
本项目**无需编译**,可直接部署源码。
## 📝 部署前准备
### 1. 修改 API 地址
编辑 `src/js/config/api.config.js`
```javascript
BASE_URL: 'https://your-api-domain.com', // 改成你的API地址
```
### 2. 关闭调试模式
编辑 `src/js/config/app.config.js`
```javascript
ENABLED: false, // 改成 false
```
---
## 🚀 三种部署方式
### 方式一Nginx 部署(推荐)
**第1步上传文件**
```bash
# 上传到服务器
/var/www/flux-web/
```
**第2步创建配置文件**
```bash
# 复制示例配置
cp nginx.conf.example /etc/nginx/sites-available/flux-web
# 修改配置中的域名和路径
vim /etc/nginx/sites-available/flux-web
```
**第3步启用站点**
```bash
# 创建软链接
ln -s /etc/nginx/sites-available/flux-web /etc/nginx/sites-enabled/
# 测试配置
nginx -t
# 重载
systemctl reload nginx
```
**第4步配置 HTTPS可选**
```bash
# 安装 certbot
apt install certbot python3-certbot-nginx
# 获取证书
certbot --nginx -d your-domain.com
```
---
### 方式二Node.js 服务器
```bash
# 安装 PM2
npm install -g pm2
# 启动服务
pm2 start server.js --name flux-web
# 设置开机自启
pm2 startup
pm2 save
```
---
### 方式三:对象存储 + CDN
适合阿里云 OSS、腾讯云 COS 等:
1. 在控制台上传整个 `flux-web` 目录
2. 配置 CDN 加速
3. 绑定自定义域名
---
## ✅ 部署后检查
访问你的域名,确认:
- ✓ 页面正常显示
- ✓ 样式加载正常
- ✓ 浏览器控制台无报错
- ✓ API 请求成功F12 查看 Network
---
## 🔄 更新项目
```bash
# 备份
cp -r /var/www/flux-web /var/www/flux-web.backup
# 上传新文件覆盖即可
```
---
## ❓ 常见问题
**Q: 页面空白?**
A: 检查浏览器控制台F12查看报错信息
**Q: API 请求失败?**
A: 检查 `src/js/config/api.config.js` 中的 API 地址是否正确
**Q: 静态资源 404**
A: 确认 `static/``src/` 目录都已上传
**Q: 如何清除缓存?**
A: 修改 `index.html` 中 CSS/JS 引用,加版本号:`style.css?v=2`
---
## 📞 需要帮助?
- 查看详细配置:`nginx.conf.example`
- 项目说明:`README.md`
- 模块化文档:`src/js/README.md`

69
README.md Normal file
View File

@@ -0,0 +1,69 @@
# flux-web
薇钱包 H5 前端项目 - 纯前端,无需编译
## 🚀 快速开始
### 开发
```bash
node server.js
# 访问 http://localhost:3000
```
### 部署
**无需编译**,直接部署源码即可。查看 [DEPLOY.md](./DEPLOY.md)
## 📁 项目结构
```
flux-web/
├── index.html # 主页面
├── basic-info.html # 基本信息页面
├── style.css / basic-info.css # 样式文件
├── src/js/ # JavaScript 模块
│ ├── config/ # 配置
│ ├── core/ # 核心
│ ├── services/ # 业务服务
│ ├── ui/ # UI 组件
│ ├── utils/ # 工具
│ └── pages/ # 页面逻辑
└── static/ # 静态资源
```
## ⚙️ 配置
### 修改 API 地址
`src/js/config/api.config.js`:
```javascript
BASE_URL: 'http://localhost:8071', // 改成你的API地址
```
### 关闭调试模式
`src/js/config/app.config.js`:
```javascript
ENABLED: false, // 生产环境改为 false
```
## 📦 部署方式
- **Nginx** - 推荐,查看 `nginx.conf.example`
- **Node.js** - 使用 PM2`pm2 start server.js`
- **对象存储** - 阿里云 OSS / 腾讯云 COS
详细步骤:[DEPLOY.md](./DEPLOY.md)
## 📖 文档
- [部署指南](./DEPLOY.md)
- [模块化说明](./src/js/README.md)
- [极光配置](./docs/jverify-configuration.md)
---
北京百雅科技有限公司 © 2025

932
basic-info.css Normal file
View File

@@ -0,0 +1,932 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #f5f5f5;
color: #333;
font-size: 14px;
line-height: 1.5;
}
.basic-info-container {
min-height: 100vh;
padding: 16px;
padding-bottom: 100px;
}
/* 顶部卡片 */
.top-card {
width: 100%;
height: 142px;
background-image: url('./static/image/personalTop.png');
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
margin-bottom: -8px;
position: relative;
z-index: 1;
}
.top-title {
font-size: 12px;
font-weight: 600;
color: #fff;
/*margin-bottom: 4px;*/
}
.top-money {
font-size: 42px;
color: #fff;
font-weight: 700;
margin: -5px 0;
}
.top-text {
font-size: 12px;
font-weight: 400;
color: #fff;
margin-top: 2px;
}
.progress-wrapper {
display: flex;
align-items: center;
margin-top: 4px;
}
.progress-bar {
flex: 1;
height: 8px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 50px;
margin-right: 5px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #19be6b, #1dd87a, #19be6b);
background-size: 200% 100%;
border-radius: 50px;
width: 0%;
transition: width 0.5s ease;
position: relative;
overflow: hidden;
-webkit-animation: progressPulse 2s ease-in-out infinite, progressGradient 3s ease infinite;
animation: progressPulse 2s ease-in-out infinite, progressGradient 3s ease infinite;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
-webkit-animation: shimmer 2s infinite;
animation: shimmer 2s infinite;
}
@-webkit-keyframes shimmer {
0% {
-webkit-transform: translateX(-100%);
transform: translateX(-100%);
}
100% {
-webkit-transform: translateX(100%);
transform: translateX(100%);
}
}
@keyframes shimmer {
0% {
-webkit-transform: translateX(-100%);
transform: translateX(-100%);
}
100% {
-webkit-transform: translateX(100%);
transform: translateX(100%);
}
}
@-webkit-keyframes progressPulse {
0%, 100% {
-webkit-box-shadow: 0 0 0 0 rgba(25, 190, 107, 0.4);
box-shadow: 0 0 0 0 rgba(25, 190, 107, 0.4);
}
50% {
-webkit-box-shadow: 0 0 0 4px rgba(25, 190, 107, 0);
box-shadow: 0 0 0 4px rgba(25, 190, 107, 0);
}
}
@keyframes progressPulse {
0%, 100% {
-webkit-box-shadow: 0 0 0 0 rgba(25, 190, 107, 0.4);
box-shadow: 0 0 0 0 rgba(25, 190, 107, 0.4);
}
50% {
-webkit-box-shadow: 0 0 0 4px rgba(25, 190, 107, 0);
box-shadow: 0 0 0 4px rgba(25, 190, 107, 0);
}
}
@-webkit-keyframes progressGradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes progressGradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.progress-text {
color: #fff;
font-size: 14px;
font-weight: 500;
min-width: 40px;
text-align: right;
}
/* 资产信息区域 */
.asset-section {
background-color: #fff;
border-radius: 8px;
margin-top: 0;
padding: 0;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
}
.asset-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #efefef;
cursor: pointer;
}
.asset-title {
font-size: 16px;
color: #854a19;
font-weight: 500;
display: flex;
align-items: center;
}
.asset-icon {
width: 22px;
height: 22px;
margin-right: 9px;
display: inline-block;
background-color: #fff;
border-radius: 4px;
}
.asset-arrow {
width: 20px;
height: 20px;
color: #999;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.section-arrow {
width: 20px;
height: 20px;
display: inline-block;
background-color: #fff;
border-radius: 4px;
}
.asset-list {
padding: 0;
}
.asset-item {
padding: 9px 16px;
font-size: 14px;
border-bottom: 1px solid #f5f5f5;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.5s ease, transform 0.5s ease;
display: none;
}
.asset-item.show {
display: block;
opacity: 1;
transform: translateY(0);
}
.asset-item:last-child {
border-bottom: none;
}
.item-top {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.item-name {
color: #333;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
margin-right: 7px;
}
.item-value {
display: flex;
align-items: center;
color: #999;
font-size: 14px;
}
.item-value.selected {
color: #333;
}
.item-icon {
width: 20px;
height: 20px;
margin-left: 8px;
display: inline-block;
background-color: #fff;
border-radius: 4px;
flex-shrink: 0;
}
.item-options {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
max-height: 100px;
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
margin-top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease,
padding 0.4s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 1;
padding: 0;
}
.asset-item.collapsed .item-options {
max-height: 0 !important;
margin-top: 0 !important;
opacity: 0;
padding: 0 !important;
}
.option-btn {
flex: 1;
min-width: 40%;
height: 30px;
line-height: 30px;
text-align: center;
background: #f5f5f5;
border-radius: 4px;
color: #777;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.option-btn:hover {
background: #e8e8e8;
}
.option-btn.selected {
background: #3474fe;
color: #fff;
}
.option-btn:active {
transform: scale(0.98);
}
/* 基本信息区域 */
.basic-info-section {
background-color: #fff;
border-radius: 8px;
margin-top: 16px;
padding: 0;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.basic-info-section.show {
opacity: 1;
transform: translateY(0);
}
.basic-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #efefef;
cursor: pointer;
}
.basic-info-title {
font-size: 16px;
color: #854a19;
font-weight: 500;
display: flex;
align-items: center;
}
.basic-info-icon {
width: 22px;
height: 22px;
margin-right: 9px;
display: inline-block;
}
.basic-info-arrow {
width: 20px;
height: 20px;
color: #999;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.basic-info-list {
padding: 0;
}
/* 基本信息项行样式 */
.basic-info-item-row {
padding: 12px 16px;
border-bottom: 1px solid #f5f5f5;
}
.basic-info-item-row:last-child {
border-bottom: none;
}
.basic-info-item-row .item-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
}
.basic-info-item-row .item-name {
color: #333;
font-size: 14px;
font-weight: 500;
}
.basic-info-item-row .item-value {
color: #999;
font-size: 14px;
display: flex;
align-items: center;
flex: 1;
}
.basic-info-item-row .item-value-text {
flex: 1;
text-align: left;
}
.basic-info-item-row .item-icon {
margin-left: auto;
flex-shrink: 0;
}
.basic-info-item-row .item-value.selected {
color: #333;
}
.basic-info-item-row .item-icon {
width: 20px;
height: 20px;
margin-left: 8px;
display: inline-block;
background-color: #fff;
border-radius: 4px;
flex-shrink: 0;
}
/* 内联输入框样式 */
.basic-input-inline {
flex: 1;
height: 32px;
padding: 0 8px;
font-size: 14px;
color: #333;
background-color: transparent;
border: none;
outline: none;
text-align: left;
}
.basic-input-inline::placeholder {
color: #999;
text-align: left;
}
.basic-input-inline:focus {
color: #333;
}
.basic-info-item-row .item-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
min-height: 32px;
}
/* 城市字段的值居左显示 */
.basic-info-item-row#basic-info-city .item-top {
justify-content: flex-start;
gap: 8px;
}
.basic-info-item-row#basic-info-city .item-value {
flex: 1;
}
.basic-info-item-row#basic-info-city .item-value-text {
flex: 1;
text-align: left;
}
.basic-info-item-row#basic-info-city .item-icon {
margin-left: auto;
flex-shrink: 0;
}
/* 输入框样式 */
.basic-input {
width: 100%;
height: 36px;
padding: 0 12px;
font-size: 14px;
color: #333;
background-color: #f5f5f5;
border: 1px solid transparent;
border-radius: 4px;
outline: none;
transition: all 0.3s ease;
margin-bottom: 8px;
}
.basic-input:focus {
background-color: #fff;
border-color: #3474fe;
}
.confirm-btn {
width: 100%;
height: 32px;
line-height: 32px;
text-align: center;
background: #3474fe;
border-radius: 4px;
color: #fff;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.confirm-btn:hover {
background: #2563eb;
}
.confirm-btn:active {
transform: scale(0.98);
}
/* 底部按钮 */
.button-section {
position: fixed;
bottom: 20px;
left: 0;
width: 100%;
padding: 16px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
background-color: #fff;
box-shadow: 0 -2px 4px 0 rgba(0, 0, 0, 0.08);
z-index: 100;
}
/* 协议行 */
.agreement-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
margin-bottom: 10px;
line-height: 1.4;
}
.agreement-checkbox {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
cursor: pointer;
}
.agreement-checkbox input[type="checkbox"] {
width: 12px;
height: 12px;
margin: 0;
}
.agreement-links {
color: #2e6df6;
display: inline-block;
}
.submit-btn {
width: 100%;
height: 48px;
line-height: 48px;
color: #fff;
font-size: 18px;
text-align: center;
background: #3474fe;
border: none;
border-radius: 25px;
cursor: pointer;
transition: all 0.3s ease;
}
.submit-btn:disabled {
background: #c8c9cc;
cursor: not-allowed;
}
.submit-btn:not(:disabled):active {
opacity: 0.9;
transform: scale(0.98);
}
/* 动画效果 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.asset-item.show {
animation: fadeInUp 0.5s ease forwards;
}
/* 城市选择器模态框 */
.city-picker-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: none;
}
.city-picker-modal.show {
display: block;
}
.city-picker-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.city-picker-content {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background-color: #f5f5f5;
border-radius: 16px 16px 0 0;
max-height: 70vh;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.city-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e5e5;
background-color: #fff;
}
.city-picker-btn {
background: none;
border: none;
font-size: 16px;
padding: 8px 16px;
cursor: pointer;
transition: opacity 0.3s ease;
}
.city-picker-btn:active {
opacity: 0.6;
}
.city-picker-cancel {
color: #333;
}
.city-picker-confirm {
color: #3474fe;
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: #f5f5f5;
color: #333;
font-weight: 500;
}
body.modal-open {
overflow: hidden;
}
/* Toast 提示样式 */
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.3s ease;
pointer-events: none;
max-width: 80%;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.toast.show {
opacity: 1;
visibility: visible;
transform: translate(-50%, -50%) scale(1);
}
.toast-content {
line-height: 1.5;
}
/* 基本信息错误提示 */
.basic-info-error {
font-size: 12px;
color: #ff4444;
margin-top: 4px;
padding-left: 0;
text-align: right;
}
.basic-input-inline.error {
color: #ff4444;
border-bottom: 1px solid #ff4444;
background-color: #fff5f5;
}
/* 协议提示弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
}
.modal-overlay.show {
display: flex;
opacity: 1;
}
.modal-overlay.showing {
display: flex;
}
#agreementModal {
align-items: center;
justify-content: center;
}
.agreement-modal {
width: 90%;
max-width: 320px;
background-color: #fff;
border-radius: 16px;
display: flex;
flex-direction: column;
transform: scale(0.8);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1001;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
#agreementModal.show .agreement-modal {
transform: scale(1);
opacity: 1;
}
.agreement-modal-header {
padding: 20px 20px 16px;
text-align: center;
border-bottom: 1px solid #eee;
}
.agreement-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.agreement-modal-body {
padding: 20px;
text-align: center;
}
.agreement-modal-text {
font-size: 14px;
color: #333;
margin-bottom: 12px;
line-height: 1.5;
}
.agreement-modal-links {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.agreement-modal-link {
color: #3474fe;
font-size: 14px;
text-decoration: none;
line-height: 1.5;
}
.agreement-modal-link:active {
opacity: 0.7;
}
.agreement-modal-footer {
display: flex;
border-top: 1px solid #eee;
padding: 0;
}
.agreement-modal-btn {
flex: 1;
height: 48px;
line-height: 48px;
font-size: 16px;
text-align: center;
border: none;
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
}
.agreement-modal-btn-cancel {
color: #666;
border-right: 1px solid #eee;
}
.agreement-modal-btn-cancel:active {
background-color: #f5f5f5;
}
.agreement-modal-btn-confirm {
color: #fff;
background: linear-gradient(140deg, #3474fe, #3474fe);
font-weight: 500;
}
.agreement-modal-btn-confirm:active {
opacity: 0.9;
}
body.modal-open {
overflow: hidden;
position: fixed;
width: 100%;
}

119
basic-info.html Normal file
View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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>
<link rel="stylesheet" href="basic-info.css">
</head>
<body>
<div class="basic-info-container">
<!-- 顶部卡片 -->
<div class="top-card">
<div class="top-title">根据您的基础信息进行评估,请如实填写</div>
<div class="top-money" id="topMoney">35,000</div>
<div class="top-text">*最终额度以最后审批为准</div>
<div class="progress-wrapper">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">0%</div>
</div>
</div>
<!-- 资产信息区域 -->
<div class="asset-section">
<div class="asset-header" id="assetHeader">
<div class="asset-title">
<img class="asset-icon" src="./static/image/zc-pic.png" alt="资产信息">
<span>资产信息(<span id="completedCount">0</span>/6</span>
</div>
<img class="section-arrow" id="assetArrow" src="./static/image/dropdown-down.png" alt="展开">
</div>
<div class="asset-list" id="assetList">
<!-- 选项会通过JS动态添加 -->
</div>
</div>
<!-- 基本信息区域 -->
<div class="basic-info-section" id="basicInfoSection" style="display: none;">
<div class="basic-info-header">
<div class="basic-info-title">
<img class="basic-info-icon" src="./static/image/base-pic.png" alt="基本信息">
<span>基本信息</span>
</div>
<img class="section-arrow" id="basicInfoArrow" src="./static/image/dropdown-down.png" alt="收起">
</div>
<div class="basic-info-list" id="basicInfoList">
<!-- 基本信息字段会通过JS动态添加 -->
</div>
</div>
<!-- 底部按钮 -->
<div class="button-section" id="bottomSection" style="display: none;">
<div class="agreement-row">
<label class="agreement-checkbox">
<input type="checkbox" id="agreementCheckbox">
<span class="agreement-text">我已阅读并同意</span>
</label>
<span class="agreement-links">
《人脸识别验证个人信息使用授权书》
《个人信息授权书》
《个人信息共享授权书》
</span>
</div>
<button class="submit-btn" id="submitBtn" disabled>下一步</button>
</div>
</div>
<!-- 城市选择器模态框 -->
<div class="city-picker-modal" id="cityPickerModal">
<div class="city-picker-overlay"></div>
<div class="city-picker-content">
<div class="city-picker-header">
<button class="city-picker-btn city-picker-cancel" id="cityCancelBtn">取消</button>
<button class="city-picker-btn city-picker-confirm" id="cityConfirmBtn">确认</button>
</div>
<div class="city-picker-body">
<div class="city-picker-column" id="provinceColumn">
<!-- 省份列表会通过JS动态添加 -->
</div>
<div class="city-picker-column" id="cityColumn">
<!-- 城市列表会通过JS动态添加 -->
</div>
</div>
</div>
</div>
<!-- Toast 提示 -->
<div class="toast" id="toast">
<div class="toast-content" id="toastContent"></div>
</div>
<!-- 协议提示弹窗 -->
<div class="modal-overlay" id="agreementModal">
<div class="agreement-modal">
<div class="agreement-modal-header">
<h3 class="agreement-modal-title">温馨提示</h3>
</div>
<div class="agreement-modal-body">
<div class="agreement-modal-text">我已阅读并同意</div>
<div class="agreement-modal-links">
<a href="#" class="agreement-modal-link">《人脸识别验证个人信息使用授权书》</a>
<a href="#" class="agreement-modal-link">《个人信息授权书》</a>
<a href="#" class="agreement-modal-link">《个人信息共享授权书》</a>
</div>
</div>
<div class="agreement-modal-footer">
<button class="agreement-modal-btn agreement-modal-btn-cancel" id="agreementCancelBtn">取消</button>
<button class="agreement-modal-btn agreement-modal-btn-confirm" id="agreementConfirmBtn">同意并继续</button>
</div>
</div>
</div>
<!-- 新的模块化架构 -->
<script type="module" src="./src/js/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,250 @@
# 极光一键登录集成配置说明
## 🎯 功能概述
已成功集成**极光一键登录**功能到薇钱包H5应用中支持
- ✅ 极光一键登录(无需输入手机号和验证码)
- ✅ 自动降级到短信验证码登录(当一键登录不可用时)
- ✅ 智能检测运营商网络环境
---
## 📝 配置步骤
### **1. 配置极光应用ID**
编辑文件:`src/js/pages/index.page.js`
找到第30行
```javascript
// 极光一键登录配置
this.jVerifyAppId = ''; // 需要配置极光应用ID
```
修改为:
```javascript
// 极光一键登录配置
this.jVerifyAppId = '您的极光AppKey'; // 例如:'a1b2c3d4e5f6g7h8i9j0'
```
### **2. 获取极光AppKey**
1. 登录 [极光官网](https://www.jiguang.cn/)
2. 进入「JVerification」产品
3. 创建应用或使用现有应用
4. 在应用设置中获取 **AppKey**
---
## 🔄 工作流程
```
用户打开页面
检测是否配置了极光AppKey
┌────────────┬────────────┐
│ 已配置 │ 未配置 │
│ 检测SDK │ 直接使用 │
│ 可用性 │ 短信验证 │
└────────────┴────────────┘
SDK可用
┌────────────┬────────────┐
│ 是 │ 否 │
│ 显示"一键 │ 降级到 │
│ 登录"按钮 │ 短信验证 │
└────────────┴────────────┘
用户点击"一键登录"
获取Token → 验证成功?
┌────────────┬────────────┐
│ 成功 │ 失败 │
│ 自动登录 │ 降级到 │
│ 跳转页面 │ 短信验证 │
└────────────┴────────────┘
```
---
## 🎨 UI展示
### **一键登录可用时:**
```
┌─────────────────────────┐
│ 📱 一键登录 │ ← 紫色渐变按钮
├─────────────────────────┤
│ ─────────── 或 ─────── │
│ │
│ 手机号: [____________] │
│ 立即申请 │
└─────────────────────────┘
```
### **一键登录不可用时(自动降级):**
```
┌─────────────────────────┐
│ │
│ 手机号: [____________] │
│ 立即申请 │
└─────────────────────────┘
```
---
## 🔧 后端API配置
确保后端服务 `ali-sms` 正常运行:
1. **启动后端服务**
```bash
cd /e/wk-oth/go-work/ali-sms
go run main.go
```
2. **确认API端点**
- 一键登录验证:`POST /auth/jpush/login`
- 请求参数:
```json
{
"appId": "您的极光AppKey",
"loginToken": "极光SDK获取的token"
}
```
3. **配置CORS**
- 确保后端允许前端域名跨域访问
- 或在开发环境使用代理
---
## 📱 测试步骤
### **1. 配置AppKey**
编辑 `src/js/pages/index.page.js`,设置 `this.jVerifyAppId`
### **2. 启动前端服务**
```bash
# 在 IDEA 中右键 index.html → Open In Browser
```
### **3. 观察行为**
**场景A极光SDK可用**
- ✅ 显示"一键登录"按钮
- ✅ 点击后自动登录
- ✅ 成功后跳转页面
**场景B极光SDK不可用**
- ✅ 自动隐藏"一键登录"按钮
- ✅ 直接显示手机号输入框
- ✅ 使用短信验证码登录
**场景C一键登录失败**
- ✅ 显示降级提示
- ✅ 自动切换到短信验证码登录
---
## 🐛 调试方法
### **查看控制台日志**
```javascript
// 一键登录初始化
[IndexPage] 未配置极光应用ID跳过一键登录 // 需要配置AppKey
[OneClickLoginButton] 一键登录可用 // SDK加载成功
[OneClickLoginButton] 一键登录不可用 // SDK加载失败会自动降级
// 一键登录过程
[JVerifyService] 极光SDK初始化成功
[JVerifyService] 获取token成功
[JVerifyService] 一键登录成功: 138****8000
[IndexPage] 一键登录后注册成功
```
### **常见问题**
#### **问题1不显示一键登录按钮**
- ✅ 检查是否配置了 `this.jVerifyAppId`
- ✅ 打开控制台查看错误信息
- ✅ 确认用户未登录状态
#### **问题2点击后无反应**
- ✅ 检查后端服务是否运行
- ✅ 检查网络请求F12 → Network标签
- ✅ 查看控制台错误
#### **问题3登录失败但未降级**
- ✅ 检查 `onFallback` 回调是否正常
- ✅ 查看后端返回的错误信息
---
## 🔐 安全建议
1. **保护AppKey**
- 不要将AppKey硬编码在前端考虑从后端获取
- 生产环境使用环境变量
2. **手机号保护**
- 后端响应已包含 `phoneMasked`(脱敏手机号)
- 生产环境建议移除 `phone` 字段
3. **HTTPS传输**
- 生产环境强制使用HTTPS
- 防止中间人攻击
---
## 📚 相关文件清单
### **新增文件**
```
src/js/
├── services/
│ └── jverify.service.js # 极光一键登录服务
├── ui/
│ └── one-click-login.js # 一键登录按钮组件
└── pages/
└── index.page.js # 主页面(已集成)
src/css/components/
└── one-click-login.css # 一键登录样式
```
### **修改文件**
```
index.html # 添加了一键登录容器和CSS引用
```
---
## ✅ 完成检查清单
- [ ] 配置极光AppKey在 `index.page.js` 中)
- [ ] 启动后端服务 `ali-sms`
- [ ] 测试一键登录功能(手机端访问)
- [ ] 测试降级逻辑关闭WiFi使用4G/5G
- [ ] 测试登录失败场景
- [ ] 检查生产环境配置
---
## 🎉 完成效果
配置完成后,用户将享受:
1. **更快的登录体验** - 无需输入手机号和验证码
2. **无缝降级** - 一键登录不可用时自动切换
3. **更好的用户体验** - 减少操作步骤
**一键登录成功率通常达到 85%+** 📈
---
**配置完成日期:** 2025-01-22
**集成人员:** Claude Code
**版本:** 2.1.0

186
docs/offline-sdk-guide.md Normal file
View File

@@ -0,0 +1,186 @@
# 极光离线SDK配置指南
## 🎯 目标
解决 `ERR_CONNECTION_CLOSED` 错误使用离线SDK文件。
⚠️ **重要提示**极光一键登录SDK需要 **crypto-js** 作为依赖库!
---
## 📦 完整依赖2个文件
### **必需文件:**
1. **crypto-js.min.js** - 加密库依赖(必需)
2. **jverification_web.js** - 极光一键登录SDK
---
## 📁 方法1从CDN下载推荐最快
### **步骤1下载 crypto-js**
浏览器打开以下任一地址,保存网页内容:
```
https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js
https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.min.js
```
**保存为**`E:\wk-flux\微钱包_h5\web\src\assets\js\crypto-js.min.js`
### **步骤2下载极光SDK**
浏览器打开:
```
https://jverification.jiguang.cn/scripts/jverification-web.5.3.1.min.js
```
**保存为**`E:\wk-flux\微钱包_h5\web\src\assets\js\jverification_web.js`
### **步骤3完成**
刷新页面即可!
---
## 📁 方法2从极光官方下载
---
## 🔧 **方法2使用备用CDN已自动配置**
我已经为您配置了多CDN自动切换机制
```javascript
// CDN加载优先级
1. https://js.jverification.com/jverification_web.js // 官方CDN
2. https://cdn.jsdelivr.net/npm/.../jverification_web.js // jsDelivr
3. https://unpkg.com/jverification-web-sdk/.../jverification_web.js // unpkg
4. ./src/assets/js/jverification_web.js // 本地文件
```
**代码已自动尝试所有CDN源无需手动配置**
---
## 📋 **方法3从GitHub获取开发者**
如果您熟悉Git和npm
```bash
# 方案A使用npm安装推荐
npm install jverification-web-sdk --save
# 然后复制文件:
cp node_modules/jverification-web-sdk/dist/jverification_web.js src/assets/js/
# 方案B从GitHub下载
# 访问:
# https://github.com/jpush/jverification-web-sdk
```
---
## 🌐 **方法4使用代理/VPN如果网络受限**
1. 开启VPN
2. 访问:`https://js.jverification.com/jverification_web.js`
3. 保存网页内容为 `jverification_web.js`
4. 保存到:`src/assets/js/`
---
## ✅ **验证配置**
### **测试步骤:**
1. **下载并保存文件**
2. **刷新页面**Ctrl + Shift + R
3. **查看控制台**
### **成功标志:**
```
✅ [JVerifyService] CryptoJS加载成功: https://cdnjs.cloudflare.com/...
✅ [JVerifyService] JVerification SDK加载成功: https://jverification.jiguang.cn/...
✅ [JVerifyService] CryptoJS加载成功: ./src/assets/js/crypto-js.min.js
✅ [JVerifyService] JVerification SDK加载成功: ./src/assets/js/jverification_web.js
```
### **失败标志:**
```
❌ [JVerifyService] CryptoJS CDN加载失败: https://cdnjs.cloudflare.com/...
❌ [JVerifyService] JVerification CDN加载失败: https://jverification.jiguang.cn/...
❌ [JVerifyService] 所有CDN源均无法访问
```
---
## 🚀 **快速配置(推荐)**
### **最快方案使用备用CDN**
我已经为您配置了备用CDN通常能直接访问。
**刷新页面试试!** 很多时候 jsDelivr 或 unpkg 可以访问!
### **如果备用CDN也不行**
1. 访问极光资源页面下载方法1
2. 或使用VPN下载方法4
3. 保存到本地 `src/assets/js/jverification_web.js`
---
## 📱 **测试一键登录**
### **在真实手机上测试(推荐):**
1. **手机连接电脑**
- Android: 使用 Chrome DevTools 远程调试
- iPhone: 使用 Safari Web Inspector
2. **或部署到服务器**
- 使用真实域名访问
- 在手机浏览器打开
3. **使用移动网络**
- 关闭WiFi
- 使用4G/5G网络
---
## ⚠️ **重要说明**
### **当前状态:**
-**后端服务**已配置并运行端口26117
-**前端配置**AppKey已配置
-**RSA密钥**:已配置
-**Picker组件**:已修复
-**离线SDK**:待下载
### **即使没有SDK功能仍然正常**
- ✅ 短信验证码登录
- ✅ 表单验证
- ✅ 自动登录
- ✅ 页面跳转
**一键登录是锦上添花,不是必需功能!**
---
## 🎯 **推荐做法**
### **开发阶段:**
- ✅ 使用短信验证码登录(当前)
- ⏸️ 稍后配置一键登录
### **生产环境:**
- ✅ 使用离线SDK文件
- ✅ 或使用备用CDN
- ✅ 确保移动网络可用
---
**请选择一个方法配置SDK或告诉我您的选择** 😊

217
index.html Normal file
View File

@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="./src/css/components/one-click-login.css">
</head>
<body>
<div class="container">
<!-- 借款金额区域 -->
<div class="money-section money-first">
<div class="money-header">
<div class="money-label"></div>
<input type="number" class="money-input" id="loanAmount" value="50000" maxlength="6">
<div class="max-loan" id="maxLoan">全部借出200000</div>
</div>
<div class="interest-rate-text">
年化利率<span class="rate-highlight">10.8%-24%</span>(单利),实际以审核为准
</div>
</div>
<!-- 还款信息卡片 -->
<div class="money-section money-card">
<div class="info-item">
<div class="info-label">还款方式</div>
<div class="info-content">
<div class="info-main">随借随还</div>
<div class="info-desc">按日计算提前还0手续费</div>
</div>
</div>
<div class="info-item clickable" id="termTrigger">
<div class="info-label">还款期数</div>
<div class="info-content">
<div class="info-main" id="repaymentTerm">12个月</div>
<div class="info-desc">年化利率10.8%-24%(单利),以实际为准</div>
</div>
</div>
<div class="info-item no-border">
<div class="info-label">还款计划</div>
<div class="info-content">
<div class="info-main" id="repaymentPlan">首期02月05日 应还 4916.67元</div>
</div>
</div>
</div>
<div class="disclaimer-text">* 贷款试算功能,仅作为借还款信息参考</div>
<!-- 利率和用途信息 -->
<div class="money-section money-card money-compact">
<div class="info-item compact no-border">
<div class="info-label">利率</div>
<div class="info-content row">年化利率10.8%-24% (单利)</div>
</div>
<div class="info-item compact no-border clickable" id="loanPurposeTrigger">
<div class="info-label">借款用途</div>
<div class="info-content row">
<span id="loanPurpose" class="info-main">个人日常消费</span>
<span class="arrow-icon">></span>
</div>
</div>
</div>
<!-- 手机号输入区域 -->
<div class="money-section money-phone">
<div class="phone-input-wrapper">
<div class="phone-label">手机号</div>
<input type="tel" class="phone-input" id="phoneNumber" placeholder="凭手机号注册申请(已加密)" maxlength="11">
</div>
<div class="phone-error" id="phoneError" style="display: none;"></div>
<!-- 一键登录按钮容器 -->
<div class="one-click-login-wrapper" id="oneClickLoginWrapper">
<!-- 动态插入一键登录按钮 -->
</div>
<div class="button-wrapper">
<button class="apply-btn" id="applyBtn">立即申请</button>
</div>
</div>
<!-- 协议勾选 -->
<div class="agreement-section">
<label class="checkbox-wrapper">
<input type="checkbox" id="agreementCheck" class="checkbox-input">
<span class="checkbox-icon"></span>
<span class="checkbox-label">我已阅读并同意</span>
</label>
<a href="#" class="agreement-link">《个人信息共享授权书》</a>
<a href="#" class="agreement-link">《白丫融注册协议》</a>
<a href="#" class="agreement-link">《隐私政策》</a>
</div>
<!-- 底部声明 -->
<div class="footer-section">
<p class="footer-title">郑重声明</p>
<p>本平台为金融信息服务平台</p>
<p>所有贷款申请在未成功贷款前绝不收取任何费用</p>
<p>为了保障您的资金安全请不要相信任何要求您支付费用短信、邮件、电话等不实信息</p>
<p>年利率10.8%~24%具体放款金额及放款时间视个人情况而定贷款有风险借款需谨慎!</p>
<p>京ICP备2025154270号-1</p>
<p>北京百雅科技有限公司</p>
</div>
</div>
<!-- 借款用途选择器模态框 -->
<div class="modal-overlay" id="purposeModal">
<div class="modal-content">
<div class="modal-header">
<button class="modal-btn modal-btn-cancel" id="purposeCancelBtn">取消</button>
<button class="modal-btn modal-btn-confirm" id="purposeConfirmBtn">确认</button>
</div>
<div class="modal-body">
<div class="modal-option active" data-value="个人日常消费">
<span class="modal-option-text">个人日常消费</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option" data-value="装修">
<span class="modal-option-text">装修</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option" data-value="医疗">
<span class="modal-option-text">医疗</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option" data-value="教育">
<span class="modal-option-text">教育</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option" data-value="其他">
<span class="modal-option-text">其他</span>
<span class="modal-option-check"></span>
</div>
</div>
</div>
</div>
<!-- 还款期数选择器模态框 -->
<div class="modal-overlay" id="termModal">
<div class="modal-content">
<div class="modal-header">
<button class="modal-btn modal-btn-cancel" id="termCancelBtn">取消</button>
<button class="modal-btn modal-btn-confirm" id="termConfirmBtn">确认</button>
</div>
<div class="modal-body">
<div class="modal-option" data-value="3">
<span class="modal-option-text">3个月</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option" data-value="6">
<span class="modal-option-text">6个月</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option active" data-value="12">
<span class="modal-option-text">12个月</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option" data-value="24">
<span class="modal-option-text">24个月</span>
<span class="modal-option-check"></span>
</div>
<div class="modal-option" data-value="36">
<span class="modal-option-text">36个月</span>
<span class="modal-option-check"></span>
</div>
</div>
</div>
</div>
<!-- 验证码输入弹窗 -->
<div class="modal-overlay" id="verifyCodeModal">
<div class="verify-code-modal">
<div class="verify-code-header">
<h3 class="verify-code-title">输入验证码</h3>
</div>
<div class="verify-code-body">
<div class="verify-code-tip" id="verifyCodeTip">已发送至:151****7153</div>
<div class="verify-code-input-wrapper">
<div class="verify-code-input-icon">🛡️</div>
<input type="text" class="verify-code-input" id="verifyCodeInput" placeholder="请输入验证码" maxlength="6">
<div class="verify-code-countdown" id="verifyCodeCountdown">59s</div>
</div>
<div class="verify-code-error" id="verifyCodeError" style="display: none;">验证码错误</div>
</div>
<div class="verify-code-footer">
<button class="verify-code-btn" id="verifyCodeConfirmBtn">确定</button>
</div>
</div>
</div>
<!-- 协议提示弹窗 -->
<div class="modal-overlay" id="agreementModal">
<div class="agreement-modal">
<div class="agreement-modal-header">
<h3 class="agreement-modal-title">温馨提示</h3>
</div>
<div class="agreement-modal-body">
<div class="agreement-modal-text">我已阅读并同意</div>
<div class="agreement-modal-links">
<a href="#" class="agreement-modal-link">《个人信息共享授权书》</a>
<a href="#" class="agreement-modal-link">《白丫融注册协议》</a>
<a href="#" class="agreement-modal-link">《隐私政策》</a>
</div>
</div>
<div class="agreement-modal-footer">
<button class="agreement-modal-btn agreement-modal-btn-cancel" id="agreementCancelBtn">取消</button>
<button class="agreement-modal-btn agreement-modal-btn-confirm" id="agreementConfirmBtn">同意并继续</button>
</div>
</div>
</div>
<!-- 新的模块化架构 -->
<script type="module" src="./src/js/main.js"></script>
</body>
</html>

55
jverification_web.js Normal file

File diff suppressed because one or more lines are too long

50
nginx.conf.example Normal file
View File

@@ -0,0 +1,50 @@
# 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;
# }
# }

46
server.js Normal file
View File

@@ -0,0 +1,46 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const port = 3000;
const rootDir = __dirname;
const mimeTypes = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon'
};
const server = http.createServer((req, res) => {
let filePath = path.join(rootDir, req.url === '/' ? 'index.html' : req.url);
const extname = String(path.extname(filePath)).toLowerCase();
const contentType = mimeTypes[extname] || 'application/octet-stream';
fs.readFile(filePath, (error, content) => {
if (error) {
if (error.code === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>404 Not Found</h1>', 'utf-8');
} else {
res.writeHead(500);
res.end(`Server Error: ${error.code}`, 'utf-8');
}
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
});
});
server.listen(port, () => {
console.log(`服务器已启动!`);
console.log(`访问地址: http://localhost:${port}`);
console.log(`按 Ctrl+C 停止服务器`);
});

View File

@@ -0,0 +1,104 @@
/* 一键登录按钮样式 */
.one-click-login-wrapper {
padding: 0 16px;
margin-bottom: 16px;
/* 初始隐藏,避免布局闪烁 */
display: none;
}
.one-click-login-wrapper.show {
display: block;
}
.one-click-login-container {
margin-bottom: 16px;
text-align: center;
}
.one-click-login-btn {
width: 100%;
height: 48px;
border: none;
border-radius: 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.one-click-login-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.one-click-login-btn:active:not(:disabled) {
transform: translateY(0);
}
.one-click-login-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.one-click-login-btn .btn-icon {
font-size: 20px;
}
.one-click-login-btn .btn-text {
flex: 1;
}
/* 加载动画 */
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 分割线 */
.login-divider {
display: flex;
align-items: center;
margin: 20px 0;
gap: 12px;
}
.divider-line {
flex: 1;
height: 1px;
background: #e0e0e0;
}
.divider-text {
color: #999;
font-size: 14px;
padding: 0 8px;
}
/* 响应式 */
@media (max-width: 480px) {
.one-click-login-btn {
height: 44px;
font-size: 15px;
}
.one-click-login-btn .btn-icon {
font-size: 18px;
}
}

351
src/js/README.md Normal file
View File

@@ -0,0 +1,351 @@
# 薇钱包 H5 项目 - 模块化重构说明
## 📁 新的目录结构
```
web/
├── src/js/
│ ├── config/ # 配置模块
│ │ ├── index.js # 配置统一导出
│ │ ├── api.config.js # API 配置
│ │ └── app.config.js # 应用配置
│ │
│ ├── core/ # 核心模块
│ │ ├── index.js # 核心模块统一导出
│ │ ├── api.js # API 请求封装
│ │ ├── user-cache.js # 用户缓存管理
│ │ ├── form-id.js # 表单 ID 生成器
│ │ └── draft-manager.js # 草稿管理器
│ │
│ ├── utils/ # 工具函数库
│ │ ├── index.js # 工具函数统一导出
│ │ ├── validator.js # 表单验证器
│ │ ├── formatter.js # 格式化工具
│ │ └── helper.js # 通用辅助函数
│ │
│ ├── services/ # 业务服务层
│ │ ├── index.js # 服务层统一导出
│ │ ├── sms.service.js # 短信服务
│ │ ├── auth.service.js # 认证服务
│ │ ├── loan.service.js # 借款服务
│ │ └── form.service.js # 表单服务
│ │
│ ├── ui/ # UI 组件
│ │ ├── index.js # UI 组件统一导出
│ │ ├── modal.js # 模态框基类
│ │ ├── picker.js # 选择器组件
│ │ ├── toast.js # Toast 提示
│ │ └── city-picker.js # 城市选择器
│ │
│ ├── pages/ # 页面逻辑
│ │ ├── index.js # 页面逻辑统一导出
│ │ ├── index.page.js # 主页面逻辑
│ │ └── basic-info.page.js # 基本信息页面逻辑
│ │
│ └── main.js # 应用主入口
├── index.html # 主借款申请页面
├── basic-info.html # 基本信息填写页面
├── config.js # 旧配置文件(已废弃)
├── script.js # 旧主页面逻辑(已废弃)
└── basic-info.js # 旧基本信息页面逻辑(已废弃)
```
## 🚀 使用新模块化结构
### 方法 1: 使用 ES6 模块(推荐)
在 HTML 文件中使用 `<script type="module">` 引入主入口文件:
```html
<!-- index.html 和 basic-info.html -->
<script type="module" src="./src/js/main.js"></script>
```
**注意:**
- 需要删除旧的 `<script>` 标签(如 `config.js``script.js``basic-info.js`
- 确保所有模块文件都使用 `.js` 扩展名
- 现代浏览器都支持 ES6 模块
### 方法 2: 使用构建工具(可选)
如果需要更好的兼容性和性能优化,可以使用 Vite 或 Webpack
#### 使用 Vite
1. 安装 Vite
```bash
npm install -D vite
```
2. 创建 `vite.config.js`
```javascript
import { defineConfig } from 'vite';
export default defineConfig({
root: './',
build: {
outDir: 'dist',
rollupOptions: {
input: {
main: './index.html',
basicInfo: './basic-info.html'
}
}
}
});
```
3.`package.json` 中添加脚本:
```json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
```
4. 启动开发服务器:
```bash
npm run dev
```
## 📦 模块说明
### 配置模块 (config/)
所有应用配置的集中管理位置。
**使用示例:**
```javascript
import { API_CONFIG, LOAN_CONFIG, DEBUG_CONFIG } from './config/index.js';
// 使用 API 配置
const url = API_CONFIG.BASE_URL + API_CONFIG.ENDPOINTS.SEND_SMS;
// 使用借款配置
const maxAmount = LOAN_CONFIG.AMOUNT.MAX;
// 使用调试配置
if (DEBUG_CONFIG.ENABLED) {
console.log('Debug mode is on');
}
```
### 核心模块 (core/)
提供核心功能,如 API 请求、缓存管理等。
**使用示例:**
```javascript
import { ApiClient, UserCache, FormIdGenerator } from './core/index.js';
// API 请求
const response = await ApiClient.post('/api/endpoint', { data: 'value' });
// 用户缓存
UserCache.saveUserSession({ customerid: '123', mobile: '13800138000' });
const session = UserCache.getUserSession();
// 表单 ID
const formId = FormIdGenerator.getOrCreate();
```
### 工具函数模块 (utils/)
提供纯函数工具,无副作用。
**使用示例:**
```javascript
import { Validator, Formatter, debounce } from './utils/index.js';
// 验证
const phoneValidation = Validator.validatePhone('13800138000');
// 格式化
const maskedPhone = Formatter.maskPhone('13800138000'); // "138****8000"
// 防抖
const debouncedFn = debounce(() => console.log('Called'), 300);
```
### 业务服务层 (services/)
封装业务逻辑,与核心模块和工具函数协作。
**使用示例:**
```javascript
import { SMSService, AuthService, LoanService } from './services/index.js';
// 发送短信
const result = await SMSService.send('13800138000');
// 用户登录
const loginResult = await AuthService.registerOrLogin('13800138000', loanData);
// 计算还款计划
const plan = LoanService.calculateRepaymentPlan(50000, 12);
```
### UI 组件 (ui/)
可复用的 UI 组件类。
**使用示例:**
```javascript
import { Picker, Modal, CityPicker, showToast } from './ui/index.js';
// 选择器
const picker = new Picker({
triggerId: 'trigger',
modalId: 'modal',
// ... 其他配置
});
// 模态框
const modal = new Modal({
modalId: 'modal',
onConfirm: () => console.log('Confirmed')
});
// Toast 提示
showToast('操作成功', 2000);
```
### 页面逻辑 (pages/)
页面级别的逻辑封装。
**使用示例:**
```javascript
import { IndexPage, BasicInfoPage } from './pages/index.js';
// 主页面会自动初始化,无需手动调用
```
## 🔄 从旧代码迁移
### 步骤 1: 备份旧文件
```bash
mkdir old
mv config.js script.js basic-info.js old/
```
### 步骤 2: 修改 HTML 文件
**index.html 和 basic-info.html**
删除旧的 script 标签:
```html
<!-- 删除这些行 -->
<script src="config.js"></script>
<script src="script.js"></script>
<script src="basic-info.js"></script>
```
添加新的 module script
```html
<!-- 添加这一行 -->
<script type="module" src="./src/js/main.js"></script>
```
### 步骤 3: 测试功能
1. 启动开发服务器:
```bash
node server.js
```
2. 在浏览器中访问 `http://localhost:3000`
3. 测试所有功能:
- 借款申请流程
- 短信验证
- 资产信息填写
- 基本信息填写
- 表单提交
## ✨ 新架构的优势
### 1. 更好的代码组织
- **职责分离**:每个模块只负责一个功能
- **易于定位**:问题查找更快
- **代码复用**:组件和服务可在多个页面使用
### 2. 更强的可维护性
- **单一职责**:文件小,易于理解
- **依赖清晰**:通过 import 明确依赖关系
- **易于扩展**:新增功能只需添加新模块
### 3. 更好的开发体验
- **IDE 支持**:完整的代码提示和跳转
- **调试友好**:源码映射支持
- **版本控制**:小的改动更清晰
### 4. 性能优化潜力
- **按需加载**ES6 模块支持按需加载
- **Tree Shaking**:未使用的代码可被删除
- **缓存优化**:模块级别的缓存
## 🐛 常见问题
### Q1: 浏览器报错 "Cannot use import statement outside a module"
**原因:** 浏览器不支持 ES6 模块或 script 标签没有 `type="module"`
**解决:** 确保 script 标签包含 `type="module"`
```html
<script type="module" src="./src/js/main.js"></script>
```
### Q2: CORS 错误
**原因:** 直接使用 file:// 协议打开 HTML 文件。
**解决:** 使用 HTTP 服务器:
```bash
# 使用 Node.js
npx serve .
# 或使用 Python
python -m http.server 8000
# 或使用项目中的 server.js
node server.js
```
### Q3: 找不到模块
**原因:** 路径错误或文件名错误。
**解决:**
- 确保所有 import 路径以 `./``../` 开头
- 检查文件名和扩展名是否正确
- 确保导出语句export正确
## 📚 进一步阅读
- [ES6 模块入门](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules)
- [Vite 官方文档](https://cn.vitejs.dev/)
- [JavaScript 模块化最佳实践](https://www.patterns.dev/posts/modules/)
## 🤝 贡献
在添加新功能时,请遵循现有的模块化结构:
1. **配置** → 添加到 `config/` 目录
2. **工具函数** → 添加到 `utils/` 目录
3. **业务逻辑** → 添加到 `services/` 目录
4. **UI 组件** → 添加到 `ui/` 目录
5. **页面逻辑** → 添加到 `pages/` 目录
记得在相应的 `index.js` 文件中导出新模块!
---
**重构完成日期:** 2025-01-21
**重构人员:** Claude Code
**版本:** 2.0.0

View File

@@ -0,0 +1,30 @@
/**
* API 配置
* 统一管理所有 API 接口地址和配置
*/
export const API_CONFIG = {
// 基础 URL - 开发环境
BASE_URL: 'http://localhost:8071',
// 生产环境 URL如需切换取消注释并注释掉上面的
// BASE_URL: 'https://flux.1216.top',
// API 端点配置
ENDPOINTS: {
// 短信相关接口(使用 JSON 格式)
SEND_SMS: '/zcore/sms/send',
VERIFY_SMS: '/zcore/sms/verify',
// 客户相关接口(使用 x-www-form-urlencoded 格式)
CUSTOMER_REGISTER: '/partnerh5/login',
// 表单相关接口(使用 x-www-form-urlencoded 格式)
SUBMIT_FORM: '/partnerh5/submit',
SUBMIT_DRAFT_FORM: '/partnerh5/save_draft',
GET_DRAFT_FORM: '/partnerh5/get_draft',
},
// 请求超时配置(毫秒)
TIMEOUT: 30000,
};

176
src/js/config/app.config.js Normal file
View File

@@ -0,0 +1,176 @@
/**
* 应用配置
* 包含应用级别的常量、映射关系、模拟数据等
*/
// ==================== 调试配置 ====================
export const DEBUG_CONFIG = {
// 调试模式开关
ENABLED: true,
// 调试模式下的固定验证码
SMS_CODE: '123456',
// 是否启用详细日志
VERBOSE_LOGGING: true,
};
// ==================== 动画配置 ====================
export const ANIMATION_CONFIG = {
// 模态框动画时长(毫秒)
MODAL_DURATION: 300,
// 滚轮防抖延迟(毫秒)
WHEEL_DEBOUNCE_DELAY: 150,
// 滚动延迟(毫秒)
SCROLL_DELAY: 100,
// Toast 默认显示时长(毫秒)
TOAST_DURATION: 2000,
};
// ==================== 资产映射配置 ====================
export const ASSET_CONFIG = {
// 资产选项映射:中文 → 数字值
VALUE_MAPPING: {
'有房产': 1, '无房产': 2,
'有车辆': 1, '无车辆': 2,
'有公积金': 1, '无公积金': 2,
'有社保': 1, '无社保': 2,
'有信用卡': 1, '无信用卡': 2,
'有银行流水': 1, '无银行流水': 2
},
// 资产选项反向映射:数字值 → 中文
REVERSE_MAPPING: {
house: { 1: '有房产', 2: '无房产' },
car: { 1: '有车辆', 2: '无车辆' },
fund: { 1: '有公积金', 2: '无公积金' },
social: { 1: '有社保', 2: '无社保' },
credit: { 1: '有信用卡', 2: '无信用卡' },
bank: { 1: '有银行流水', 2: '无银行流水' }
},
// 资产项配置列表
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: ['有银行流水', '无银行流水'] }
],
// 进度金额配置
PROGRESS_MONEY: {
INITIAL: 35000,
FINAL: 50000,
},
};
// ==================== 基本信息配置 ====================
export const BASIC_INFO_CONFIG = {
// 基本信息字段配置
ITEMS: [
{ id: 'name', name: '真实姓名', placeholder: '请输入真实姓名', type: 'input' },
{ id: 'idCard', name: '身份证号', placeholder: '请输入身份证号', type: 'input' },
{ id: 'city', name: '所属城市', placeholder: '请选择', type: 'select' }
],
};
// ==================== 省份城市数据 ====================
export const PROVINCE_CITY_DATA = {
'江西省': ['南昌市', '九江市', '上饶市', '抚州市', '宜春市', '吉安市', '赣州市', '景德镇市', '萍乡市', '新余市', '鹰潭市'],
'山东省': ['济南市', '青岛市', '淄博市', '枣庄市', '东营市', '烟台市', '潍坊市', '济宁市', '泰安市', '威海市', '日照市', '临沂市', '德州市', '聊城市', '滨州市', '菏泽市'],
'河南省': ['郑州市', '开封市', '洛阳市', '平顶山市', '安阳市', '鹤壁市', '新乡市', '焦作市', '濮阳市', '许昌市', '漯河市', '三门峡市', '南阳市', '商丘市', '信阳市', '周口市', '驻马店市'],
'湖北省': ['武汉市', '黄石市', '十堰市', '宜昌市', '襄阳市', '鄂州市', '荆门市', '孝感市', '荆州市', '黄冈市', '咸宁市', '随州市'],
'湖南省': ['长沙市', '株洲市', '湘潭市', '衡阳市', '邵阳市', '岳阳市', '常德市', '张家界市', '益阳市', '郴州市', '永州市', '怀化市', '娄底市'],
'广东省': ['广州市', '韶关市', '深圳市', '珠海市', '汕头市', '佛山市', '江门市', '湛江市', '茂名市', '肇庆市', '惠州市', '梅州市', '汕尾市', '河源市', '阳江市', '清远市', '东莞市', '中山市', '潮州市', '揭阳市', '云浮市'],
'甘肃省': ['兰州市', '嘉峪关市', '金昌市', '白银市', '天水市', '武威市', '张掖市', '平凉市', '酒泉市', '庆阳市', '定西市', '陇南市']
};
// ==================== 借款相关配置 ====================
export const LOAN_CONFIG = {
// 借款金额范围
AMOUNT: {
DEFAULT: 50000,
MAX: 200000,
MIN: 1000,
},
// 年化利率范围
INTEREST_RATE: {
MIN: 10.8,
MAX: 24,
},
// 还款期数选项
PERIOD_OPTIONS: [3, 6, 9, 12, 18, 24, 36],
// 默认还款期数
DEFAULT_PERIOD: 12,
// 借款用途选项
PURPOSES: [
'个人日常消费',
'装修',
'旅游',
'教育',
'医疗',
'购车',
'其他'
],
// 默认借款用途
DEFAULT_PURPOSE: '个人日常消费',
};
// ==================== 缓存配置 ====================
export const CACHE_CONFIG = {
// 用户缓存时长(毫秒)
USER_SESSION_DURATION: 7 * 24 * 60 * 60 * 1000, // 7天
// 缓存键名
KEYS: {
USER_SESSION: 'flux_user_session',
FORM_ID: 'flux_form_id',
},
};
// ==================== 验证规则配置 ====================
export const VALIDATION_CONFIG = {
// 手机号验证正则
PHONE_REGEX: /^1[3-9]\d{9}$/,
// 姓名验证正则2-20个汉字支持少数民族姓名
NAME_REGEX: /^[\u4e00-\u9fa5]{2,20}(·[\u4e00-\u9fa5]+)*$/,
// 身份证号验证正则
ID_CARD_18_REGEX: /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
ID_CARD_15_REGEX: /^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$/,
// 身份证校验码权重
ID_CARD_WEIGHTS: [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2],
ID_CARD_CHECK_CODES: ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'],
};
// ==================== 借款用途选择器配置 ====================
export const PURPOSE_PICKER_CONFIG = {
triggerId: 'loanPurposeTrigger',
modalId: 'purposeModal',
cancelBtnId: 'purposeCancelBtn',
confirmBtnId: 'purposeConfirmBtn',
optionSelector: '#purposeModal .modal-option',
};
// ==================== 还款期数选择器配置 ====================
export const TERM_PICKER_CONFIG = {
triggerId: 'termTrigger',
modalId: 'termModal',
cancelBtnId: 'termCancelBtn',
confirmBtnId: 'termConfirmBtn',
optionSelector: '#termModal .modal-option',
enableWheel: true,
useDataValue: true,
};

49
src/js/config/index.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* 配置统一导出
* 提供统一的配置访问接口
*/
// 先导入所有配置
import { API_CONFIG } from './api.config.js';
import {
DEBUG_CONFIG,
ANIMATION_CONFIG,
ASSET_CONFIG,
BASIC_INFO_CONFIG,
PROVINCE_CITY_DATA,
LOAN_CONFIG,
CACHE_CONFIG,
VALIDATION_CONFIG,
PURPOSE_PICKER_CONFIG,
TERM_PICKER_CONFIG,
} from './app.config.js';
// 重新导出所有配置(命名导出)
export { API_CONFIG };
export {
DEBUG_CONFIG,
ANIMATION_CONFIG,
ASSET_CONFIG,
BASIC_INFO_CONFIG,
PROVINCE_CITY_DATA,
LOAN_CONFIG,
CACHE_CONFIG,
VALIDATION_CONFIG,
PURPOSE_PICKER_CONFIG,
TERM_PICKER_CONFIG,
};
// 默认导出所有配置(分组对象)
export default {
API: API_CONFIG,
DEBUG: DEBUG_CONFIG,
ANIMATION: ANIMATION_CONFIG,
ASSET: ASSET_CONFIG,
BASIC_INFO: BASIC_INFO_CONFIG,
PROVINCE_CITY: PROVINCE_CITY_DATA,
LOAN: LOAN_CONFIG,
CACHE: CACHE_CONFIG,
VALIDATION: VALIDATION_CONFIG,
PURPOSE_PICKER: PURPOSE_PICKER_CONFIG,
TERM_PICKER: TERM_PICKER_CONFIG,
};

141
src/js/core/api.js Normal file
View File

@@ -0,0 +1,141 @@
/**
* API 客户端
* 统一封装所有 HTTP 请求
*/
import { API_CONFIG, DEBUG_CONFIG } from '../config/index.js';
import { UserCache } from './user-cache.js';
export class ApiClient {
/**
* 构建查询参数
* @param {Object} params - 参数对象
* @returns {string} - URL 编码的参数字符串
*/
static buildParams(params) {
return Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(typeof v === 'string' ? v : JSON.stringify(v))}`)
.join('&');
}
/**
* 获取请求头
* @param {string} contentType - 内容类型
* @returns {Object} - 请求头对象
*/
static getHeaders(contentType = 'application/json') {
const headers = { 'Content-Type': contentType };
// 添加用户会话信息
const session = UserCache.getUserSession();
if (session?.sessionid) {
headers['jsessionid'] = session.sessionid;
}
return headers;
}
/**
* POST 请求 - JSON 格式
* @param {string} endpoint - API 端点
* @param {Object} data - 请求数据
* @returns {Promise<Object>} - 响应数据
*/
static async post(endpoint, data = {}) {
const url = API_CONFIG.BASE_URL + endpoint;
const headers = this.getHeaders('application/json');
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(data)
});
const result = await response.json();
if (DEBUG_CONFIG.ENABLED && DEBUG_CONFIG.VERBOSE_LOGGING) {
console.log(`[API] POST ${endpoint}`, { request: data, response: result });
}
return result;
} catch (error) {
console.error(`[API] POST ${endpoint} 请求失败:`, error);
return {
retcode: -1,
retinfo: error.message || '网络错误,请稍后重试'
};
}
}
/**
* XPOST 请求 - x-www-form-urlencoded 格式
* @param {string} endpoint - API 端点
* @param {Object} data - 请求数据(会被包装在 bean 对象中)
* @returns {Promise<Object>} - 响应数据
*/
static async xpost(endpoint, data = {}) {
const url = API_CONFIG.BASE_URL + endpoint;
const headers = this.getHeaders('application/x-www-form-urlencoded');
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: this.buildParams({ bean: data })
});
const result = await response.json();
if (DEBUG_CONFIG.ENABLED && DEBUG_CONFIG.VERBOSE_LOGGING) {
console.log(`[API] XPOST ${endpoint}`, { request: data, response: result });
}
return result;
} catch (error) {
console.error(`[API] XPOST ${endpoint} 请求失败:`, error);
return {
retcode: -1,
retinfo: error.message || '网络错误,请稍后重试'
};
}
}
/**
* GET 请求
* @param {string} endpoint - API 端点
* @param {Object} params - 查询参数
* @returns {Promise<Object>} - 响应数据
*/
static async get(endpoint, params = {}) {
const url = new URL(API_CONFIG.BASE_URL + endpoint);
// 添加查询参数
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const headers = this.getHeaders('application/json');
try {
const response = await fetch(url, {
method: 'GET',
headers
});
const result = await response.json();
if (DEBUG_CONFIG.ENABLED && DEBUG_CONFIG.VERBOSE_LOGGING) {
console.log(`[API] GET ${endpoint}`, { params, response: result });
}
return result;
} catch (error) {
console.error(`[API] GET ${endpoint} 请求失败:`, error);
return {
retcode: -1,
retinfo: error.message || '网络错误,请稍后重试'
};
}
}
}

View File

@@ -0,0 +1,183 @@
/**
* 草稿管理器
* 负责草稿数据的保存、加载和恢复
*/
import { ApiClient } from './api.js';
import { API_CONFIG, ASSET_CONFIG, CACHE_CONFIG, DEBUG_CONFIG } from '../config/index.js';
import { FormIdGenerator } from './form-id.js';
export class DraftManager {
/**
* 构建草稿数据
* @param {Object} selectedValues - 资产信息
* @param {Object} basicInfoValues - 基本信息Values - 表单数据对象
* @private
*/
static _buildDraftData(selectedValues, basicInfoValues) {
return {
assets: selectedValues,
basicInfo: basicInfoValues
};
}
/**
* 转换为服务器数据格式
* @param {Object} formData - 表单数据
* @returns {Object} - 服务器数据格式
* @private
*/
static _convertToServerFormat(formData) {
return {
house: ASSET_CONFIG.VALUE_MAPPING[formData.assets.house] || 0,
vehicle: ASSET_CONFIG.VALUE_MAPPING[formData.assets.car] || 0,
fund: ASSET_CONFIG.VALUE_MAPPING[formData.assets.fund] || 0,
social: ASSET_CONFIG.VALUE_MAPPING[formData.assets.social] || 0,
credit: ASSET_CONFIG.VALUE_MAPPING[formData.assets.credit] || 0,
bank: ASSET_CONFIG.VALUE_MAPPING[formData.assets.bank] || 0,
realname: formData.basicInfo.name || '',
idcard: formData.basicInfo.idCard || '',
city: formData.basicInfo.city || '',
draftstatus: 1, // 草稿状态
formid: FormIdGenerator.getOrCreate()
};
}
/**
* 从服务器格式转换为表单格式
* @param {Object} serverData - 服务器数据
* @returns {Object} - 表单数据
* @private
*/
static _convertFromServerFormat(serverData) {
const formData = {
assets: {},
basicInfo: {}
};
// 转换资产信息(数字值转中文选项)
if (serverData.house && ASSET_CONFIG.REVERSE_MAPPING.house[serverData.house]) {
formData.assets.house = ASSET_CONFIG.REVERSE_MAPPING.house[serverData.house];
}
if (serverData.vehicle && ASSET_CONFIG.REVERSE_MAPPING.car[serverData.vehicle]) {
formData.assets.car = ASSET_CONFIG.REVERSE_MAPPING.car[serverData.vehicle];
}
if (serverData.fund && ASSET_CONFIG.REVERSE_MAPPING.fund[serverData.fund]) {
formData.assets.fund = ASSET_CONFIG.REVERSE_MAPPING.fund[serverData.fund];
}
if (serverData.social && ASSET_CONFIG.REVERSE_MAPPING.social[serverData.social]) {
formData.assets.social = ASSET_CONFIG.REVERSE_MAPPING.social[serverData.social];
}
if (serverData.credit && ASSET_CONFIG.REVERSE_MAPPING.credit[serverData.credit]) {
formData.assets.credit = ASSET_CONFIG.REVERSE_MAPPING.credit[serverData.credit];
}
if (serverData.bank && ASSET_CONFIG.REVERSE_MAPPING.bank[serverData.bank]) {
formData.assets.bank = ASSET_CONFIG.REVERSE_MAPPING.bank[serverData.bank];
}
// 转换基本信息
if (serverData.realname) {
formData.basicInfo.name = serverData.realname;
}
if (serverData.idcard) {
formData.basicInfo.idCard = serverData.idcard;
}
if (serverData.city) {
formData.basicInfo.city = serverData.city;
}
return formData;
}
/**
* 获取 URL 中的 shortcode
* @returns {string} - shortcode
* @private
*/
static _getShortcode() {
const params = new URLSearchParams(window.location.search);
return params.get('code') || params.get('shortcode') || '';
}
/**
* 保存草稿数据到服务器
* @param {Object} selectedValues - 资产信息
* @param {Object} basicInfoValues - 基本信息Values
* @returns {Promise<Object>} - 保存结果
*/
static async saveDraft(selectedValues, basicInfoValues) {
try {
const formData = this._buildDraftData(selectedValues, basicInfoValues);
const requestData = {
...this._convertToServerFormat(formData),
shortcode: this._getShortcode()
};
const response = await ApiClient.xpost(API_CONFIG.ENDPOINTS.SUBMIT_DRAFT_FORM, requestData);
if (response.retcode === 0) {
console.log('[DraftManager] 草稿保存成功Form ID:', FormIdGenerator.getOrCreate());
return {
success: true,
message: '草稿保存成功',
data: response.result
};
} else {
return {
success: false,
message: response.retinfo || '草稿保存失败'
};
}
} catch (error) {
console.error('[DraftManager] 草稿保存出错:', error);
return {
success: false,
message: '网络错误,草稿保存失败'
};
}
}
/**
* 从服务器加载草稿数据
* @returns {Promise<Object|null>} - 草稿数据,如果不存在或不是草稿状态则返回 null
*/
static async loadDraft() {
const formId = FormIdGenerator.getCurrent();
if (!formId) {
console.log('[DraftManager] 没有表单ID无法加载草稿');
return null;
}
try {
const requestData = { formid: formId };
const response = await ApiClient.xpost(API_CONFIG.ENDPOINTS.GET_DRAFT_FORM, requestData);
if (response.retcode === 0 && response.result) {
const draftData = response.result;
// 检查是否是草稿状态
if (draftData.draftstatus === 1) {
console.log('[DraftManager] 加载草稿数据成功:', draftData);
return this._convertFromServerFormat(draftData);
} else {
console.log('[DraftManager] 表单已正式提交,不是草稿');
return null;
}
} else {
console.log('[DraftManager] 没有找到草稿数据');
return null;
}
} catch (error) {
console.error('[DraftManager] 加载草稿出错:', error);
return null;
}
}
/**
* 清除草稿数据清除表单ID下次保存时会创建新的
*/
static clearDraft() {
FormIdGenerator.clear();
console.log('[DraftManager] 已清除草稿数据');
}
}

85
src/js/core/form-id.js Normal file
View File

@@ -0,0 +1,85 @@
/**
* 表单 ID 生成器
* 负责生成和管理表单的唯一标识符
*/
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位数字
}
/**
* 获取或生成表单唯一ID
* @returns {string} - 表单ID字符串
*/
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);
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;
}
}
/**
* 清除表单ID
*/
static clear() {
localStorage.removeItem(CACHE_CONFIG.KEYS.FORM_ID);
this._lastFormId = '';
console.log('[FormIdGenerator] 已清除表单ID');
}
/**
* 获取当前表单ID不生成新的
* @returns {string|null} - 当前表单ID如果不存在则返回 null
*/
static getCurrent() {
return localStorage.getItem(CACHE_CONFIG.KEYS.FORM_ID);
}
/**
* 设置表单ID
* @param {string} formId - 表单ID
*/
static set(formId) {
if (!formId) {
console.warn('[FormIdGenerator] 尝试设置空的表单ID');
return;
}
localStorage.setItem(CACHE_CONFIG.KEYS.FORM_ID, formId);
this._lastFormId = formId;
console.log('[FormIdGenerator] 已设置表单ID:', formId);
}
}

8
src/js/core/index.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* 核心模块统一导出
*/
export { ApiClient } from './api.js';
export { UserCache } from './user-cache.js';
export { FormIdGenerator } from './form-id.js';
export { DraftManager } from './draft-manager.js';

88
src/js/core/user-cache.js Normal file
View File

@@ -0,0 +1,88 @@
/**
* 用户缓存管理
* 负责用户登录状态的本地存储和管理
*/
import { CACHE_CONFIG } from '../config/index.js';
export class UserCache {
/**
* 保存用户登录状态
* @param {Object} userData - 用户数据
* @param {string} userData.customerid - 客户ID
* @param {string} userData.mobile - 手机号
* @param {string} userData.sessionid - 会话ID
* @param {string} userData.loginPhone - 登录时使用的手机号
* @param {Object} userData.formData - 表单数据
*/
static saveUserSession(userData) {
const sessionData = {
// 兼容 customerid 和 customerId 两种字段名
customerid: userData.customerid || userData.customerId,
mobile: userData.mobile,
// 兼容 sessionid 和 sessionId 两种字段名
sessionid: userData.sessionid || userData.sessionId,
loginTime: Date.now(),
loginPhone: userData.loginPhone || '', // 保存登录时使用的手机号
formData: userData.formData || null // 保存表单数据
};
console.log('[UserCache] 保存登录状态:', sessionData);
localStorage.setItem(CACHE_CONFIG.KEYS.USER_SESSION, JSON.stringify(sessionData));
}
/**
* 获取用户登录状态
* @returns {Object|null} - 用户会话数据,如果不存在或已过期则返回 null
*/
static getUserSession() {
try {
const sessionStr = localStorage.getItem(CACHE_CONFIG.KEYS.USER_SESSION);
if (!sessionStr) return null;
const sessionData = JSON.parse(sessionStr);
const now = Date.now();
// 检查缓存是否过期
if (now - sessionData.loginTime > CACHE_CONFIG.USER_SESSION_DURATION) {
this.clearUserSession();
return null;
}
return sessionData;
} catch (error) {
console.error('[UserCache] 获取用户缓存失败:', error);
this.clearUserSession();
return null;
}
}
/**
* 清除用户登录状态
*/
static clearUserSession() {
localStorage.removeItem(CACHE_CONFIG.KEYS.USER_SESSION);
console.log('[UserCache] 已清除用户登录状态');
}
/**
* 检查用户是否已登录
* @returns {boolean} - 是否已登录
*/
static isLoggedIn() {
const session = this.getUserSession();
return session !== null && (session.sessionid || session.loginPhone);
}
/**
* 获取脱敏的手机号
* @returns {string|null} - 脱敏后的手机号
*/
static getMaskedPhone() {
const session = this.getUserSession();
if (!session || !session.loginPhone) return null;
const phone = session.loginPhone;
return phone.substring(0, 3) + '****' + phone.substring(7);
}
}

79
src/js/main.js Normal file
View File

@@ -0,0 +1,79 @@
/**
* 应用主入口文件
* 根据当前页面自动初始化相应的页面逻辑
*/
import { IndexPage, BasicInfoPage } from './pages/index.js';
/**
* 应用类
*/
class App {
constructor() {
this.currentPage = null;
}
/**
* 初始化应用
*/
init() {
// 等待 DOM 完全准备好
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setup());
} else {
// DOM 已经准备好,使用 setTimeout 确保在下一个事件循环中执行
setTimeout(() => this.setup(), 0);
}
}
/**
* 设置应用
*/
setup() {
// 检测当前页面并初始化对应的页面逻辑
if (this.isIndexPage()) {
console.log('[App] 初始化主页面');
this.currentPage = new IndexPage();
} else if (this.isBasicInfoPage()) {
console.log('[App] 初始化基本信息页面');
this.currentPage = new BasicInfoPage();
} else {
console.warn('[App] 未知的页面类型');
}
}
/**
* 判断是否为主页面
* @returns {boolean}
*/
isIndexPage() {
return document.getElementById('loanAmount') !== null;
}
/**
* 判断是否为基本信息页面
* @returns {boolean}
*/
isBasicInfoPage() {
return document.getElementById('assetList') !== null;
}
/**
* 销毁应用
*/
destroy() {
if (this.currentPage) {
this.currentPage.destroy();
this.currentPage = null;
}
}
}
// 创建应用实例并初始化
const app = new App();
app.init();
// 将应用实例暴露到全局,方便调试
window.__app = app;
console.log('[App] 应用入口已加载');

View File

@@ -0,0 +1,651 @@
/**
* 基本信息填写页面逻辑
* 负责资产信息和基本信息的填写和提交
*/
import { CityPicker, Modal } from '../ui/index.js';
import { Validator, Formatter } from '../utils/index.js';
import { DraftManager, FormIdGenerator, UserCache } from '../core/index.js';
import { ASSET_CONFIG, BASIC_INFO_CONFIG, PROVINCE_CITY_DATA } from '../config/index.js';
import { showToast } from '../ui/toast.js';
export class BasicInfoPage {
constructor() {
// 表单数据
this.selectedValues = {}; // 资产信息
this.basicInfoValues = {}; // 基本信息
// 当前步骤(渐进式显示)
this.currentStep = 0;
// 提交状态锁
this.isSubmitting = false;
this.isSavingDraft = false;
this.autoSaveTimer = null;
// 组件实例
this.cityPicker = null;
this.agreementModal = null;
this.init();
}
/**
* 初始化页面
*/
init() {
// 等待 DOM 加载完成
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setup());
} else {
this.setup();
}
}
/**
* 设置页面
*/
async setup() {
// 先检查必要的页面元素是否存在
if (!document.getElementById('assetList')) {
console.warn('[BasicInfoPage] 不是基本信息页面,跳过初始化');
return;
}
// 检查登录态
if (!UserCache.isLoggedIn()) {
showToast('请先登录');
setTimeout(() => {
window.location.href = 'index.html' + window.location.search;
}, 1500);
return;
}
this.initElements();
this.initComponents();
this.renderForm();
this.bindEvents();
this.updateProgress();
// 尝试加载草稿数据
const draftData = await DraftManager.loadDraft();
if (draftData) {
this.restoreDraftData(draftData);
showToast('已恢复草稿数据');
}
}
/**
* 初始化 DOM 元素引用
*/
initElements() {
this.elements = {
progressFill: document.getElementById('progressFill'),
progressText: document.getElementById('progressText'),
completedCount: document.getElementById('completedCount'),
topMoney: document.getElementById('topMoney'),
assetList: document.getElementById('assetList'),
submitBtn: document.getElementById('submitBtn'),
basicInfoSection: document.getElementById('basicInfoSection'),
basicInfoList: document.getElementById('basicInfoList'),
basicInfoCompletedCount: document.getElementById('basicInfoCompletedCount'),
agreementCheckbox: document.getElementById('agreementCheckbox')
};
}
/**
* 初始化组件
*/
initComponents() {
// 城市选择器
this.cityPicker = new CityPicker({
modalId: 'cityPickerModal',
provinceColumnId: 'provinceColumn',
cityColumnId: 'cityColumn',
cancelBtnId: 'cityCancelBtn',
confirmBtnId: 'cityConfirmBtn',
onConfirm: (result) => {
this.handleCityConfirm(result);
}
});
// 协议提示模态框
this.agreementModal = new Modal({
modalId: 'agreementModal',
onConfirm: () => {
if (this.elements.agreementCheckbox) {
this.elements.agreementCheckbox.checked = true;
}
this.agreementModal.hide();
this.handleSubmit();
}
});
}
/**
* 渲染表单
*/
renderForm() {
this.renderAssetItems();
this.renderBasicInfoItems();
this.startProgressiveReveal();
}
/**
* 渲染资产选项
*/
renderAssetItems() {
this.elements.assetList.innerHTML = '';
ASSET_CONFIG.ITEMS.forEach((item, index) => {
const assetItem = document.createElement('div');
assetItem.className = 'asset-item';
assetItem.id = `asset-${item.id}`;
assetItem.dataset.index = index;
// 初始状态:只有第一项显示
if (index > 0) {
assetItem.style.display = 'none';
}
const selectedValue = this.selectedValues[item.id] || '';
const isCollapsed = !!selectedValue;
const iconSrc = selectedValue ? './static/image/dropdown-right.png' : './static/image/dropdown-down.png';
assetItem.innerHTML = `
<div class="item-top">
<div class="item-name">${item.name}:</div>
<div class="item-value ${selectedValue ? 'selected' : ''}" id="value-${item.id}">
${selectedValue || '请选择'}
<img class="item-icon" src="${iconSrc}" alt="${selectedValue ? '展开' : '收起'}" id="icon-${item.id}">
</div>
</div>
<div class="item-options" id="options-${item.id}">
${item.options.map(option => `
<button class="option-btn ${selectedValue === option ? 'selected' : ''}"
data-item="${item.id}"
data-value="${option}">
${option}
</button>
`).join('')}
</div>
`;
this.elements.assetList.appendChild(assetItem);
});
// 绑定资产选项事件
this.bindAssetItemEvents();
}
/**
* 绑定资产选项事件
*/
bindAssetItemEvents() {
// 选项按钮点击
document.querySelectorAll('.option-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const itemId = btn.dataset.item;
const value = btn.dataset.value;
this.selectAssetOption(itemId, value);
});
});
// 头部点击(折叠/展开)
document.querySelectorAll('.item-top').forEach(top => {
top.addEventListener('click', () => {
const item = top.closest('.asset-item');
if (!item || item.style.display === 'none') return;
const isCollapsed = item.classList.contains('collapsed');
const itemId = item.id.replace('asset-', '');
const iconEl = document.getElementById(`icon-${itemId}`);
if (isCollapsed) {
item.classList.remove('collapsed');
if (iconEl) {
iconEl.src = './static/image/dropdown-down.png';
iconEl.alt = '收起';
}
} else {
item.classList.add('collapsed');
if (iconEl) {
iconEl.src = './static/image/dropdown-right.png';
iconEl.alt = '展开';
}
}
});
});
}
/**
* 选择资产选项
* @param {string} itemId - 资产项ID
* @param {string} value - 选项值
*/
selectAssetOption(itemId, value) {
this.selectedValues[itemId] = value;
// 更新 UI
const valueEl = document.getElementById(`value-${itemId}`);
const options = document.querySelectorAll(`#options-${itemId} .option-btn`);
const currentItem = document.getElementById(`asset-${itemId}`);
const iconEl = document.getElementById(`icon-${itemId}`);
valueEl.textContent = value;
valueEl.classList.add('selected');
if (iconEl) {
iconEl.src = './static/image/dropdown-right.png';
iconEl.alt = '展开';
}
options.forEach(btn => {
btn.classList.toggle('selected', btn.dataset.value === value);
});
// 折叠已选择的项
if (currentItem) {
currentItem.classList.add('collapsed');
}
// 更新进度
this.updateProgress();
// 检查提交按钮状态
this.checkSubmitButton();
// 自动保存草稿
this.autoSaveDraft();
// 渐进式显示下一项
const currentIndex = ASSET_CONFIG.ITEMS.findIndex(item => item.id === itemId);
if (currentIndex === this.currentStep && this.currentStep < ASSET_CONFIG.ITEMS.length - 1) {
setTimeout(() => {
this.revealNextItem();
}, 400);
} else if (currentIndex === ASSET_CONFIG.ITEMS.length - 1) {
// 资产信息全部完成,显示基本信息区域
setTimeout(() => {
this.showBasicInfoSection();
}, 400);
}
}
/**
* 渲染基本信息字段
*/
renderBasicInfoItems() {
this.elements.basicInfoList.innerHTML = '';
BASIC_INFO_CONFIG.ITEMS.forEach((item) => {
const infoItem = document.createElement('div');
infoItem.className = 'basic-info-item-row';
infoItem.id = `basic-info-${item.id}`;
const selectedValue = this.basicInfoValues[item.id] || '';
if (item.type === 'input') {
infoItem.innerHTML = `
<div class="item-top">
<div class="item-name">${item.name}:</div>
<input type="text"
class="basic-input-inline"
id="basic-input-${item.id}"
placeholder="${item.placeholder}"
value="${selectedValue}">
</div>
<div class="basic-info-error" id="error-${item.id}" style="display: none;"></div>
`;
} else if (item.id === 'city') {
const iconSrc = selectedValue ? './static/image/dropdown-right.png' : './static/image/dropdown-down.png';
infoItem.innerHTML = `
<div class="item-top item-top-clickable" id="basic-header-${item.id}">
<div class="item-name">${item.name}:</div>
<div class="item-value ${selectedValue ? 'selected' : ''}" id="basic-value-${item.id}">
<span class="item-value-text">${selectedValue || item.placeholder}</span>
<img class="item-icon" src="${iconSrc}" alt="${selectedValue ? '展开' : '收起'}" id="basic-icon-${item.id}">
</div>
</div>
`;
// 绑定城市选择
const header = infoItem.querySelector(`#basic-header-${item.id}`);
header.addEventListener('click', () => {
this.cityPicker.open(this.basicInfoValues[item.id]);
});
}
this.elements.basicInfoList.appendChild(infoItem);
// 绑定输入事件
if (item.type === 'input') {
const input = infoItem.querySelector(`#basic-input-${item.id}`);
const errorEl = infoItem.querySelector(`#error-${item.id}`);
input.addEventListener('input', () => {
this.basicInfoValues[item.id] = input.value.trim();
this.updateBasicInfoProgress();
this.checkSubmitButton();
// 清除错误提示
input.classList.remove('error');
if (errorEl) {
errorEl.style.display = 'none';
}
});
}
});
}
/**
* 绑定页面事件
*/
bindEvents() {
// 提交按钮
this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
}
/**
* 处理城市选择确认
* @param {Object} result - 选择结果
*/
handleCityConfirm(result) {
this.basicInfoValues.city = result.value;
const valueEl = document.getElementById('basic-value-city');
if (valueEl) {
valueEl.classList.add('selected');
const textEl = valueEl.querySelector('.item-value-text');
if (textEl) {
textEl.textContent = result.value;
}
const iconEl = document.getElementById('basic-icon-city');
if (iconEl) {
iconEl.src = './static/image/dropdown-right.png';
iconEl.alt = '展开';
}
}
this.updateBasicInfoProgress();
this.checkSubmitButton();
this.autoSaveDraft();
}
/**
* 渐进式显示
*/
startProgressiveReveal() {
this.revealItem(0);
this.currentStep = 0;
}
/**
* 显示下一项
*/
revealNextItem() {
if (this.currentStep < ASSET_CONFIG.ITEMS.length - 1) {
this.currentStep++;
this.revealItem(this.currentStep);
}
}
/**
* 显示指定项
* @param {number} index - 索引
*/
revealItem(index) {
const item = document.getElementById(`asset-${ASSET_CONFIG.ITEMS[index].id}`);
if (item) {
item.style.display = 'block';
item.classList.remove('collapsed');
requestAnimationFrame(() => {
item.classList.add('show');
});
}
}
/**
* 更新资产进度
*/
updateProgress() {
const completed = Object.keys(this.selectedValues).length;
const progress = (completed / ASSET_CONFIG.ITEMS.length) * 100;
this.elements.progressFill.style.width = `${progress}%`;
this.elements.progressText.textContent = `${Math.round(progress)}%`;
this.elements.completedCount.textContent = completed;
// 更新金额
const money = ASSET_CONFIG.PROGRESS_MONEY.INITIAL +
(ASSET_CONFIG.PROGRESS_MONEY.FINAL - ASSET_CONFIG.PROGRESS_MONEY.INITIAL) * (progress / 100);
this.elements.topMoney.textContent = Math.round(money).toLocaleString();
}
/**
* 更新基本信息进度
*/
updateBasicInfoProgress() {
const completed = Object.keys(this.basicInfoValues).filter(key => this.basicInfoValues[key]).length;
if (this.elements.basicInfoCompletedCount) {
this.elements.basicInfoCompletedCount.textContent = completed;
}
}
/**
* 检查提交按钮状态
*/
checkSubmitButton() {
const assetCompleted = Object.keys(this.selectedValues).length;
this.elements.submitBtn.disabled = assetCompleted < ASSET_CONFIG.ITEMS.length;
}
/**
* 显示基本信息区域
*/
showBasicInfoSection() {
if (this.elements.basicInfoSection) {
this.elements.basicInfoSection.style.display = 'block';
this.elements.basicInfoSection.classList.add('expanded');
requestAnimationFrame(() => {
this.elements.basicInfoSection.classList.add('show');
});
const bottomSection = document.getElementById('bottomSection');
if (bottomSection) {
bottomSection.style.display = 'block';
}
// 滚动到基本信息区域
setTimeout(() => {
this.elements.basicInfoSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 300);
}
}
/**
* 恢复草稿数据
* @param {Object} draftData - 草稿数据
*/
restoreDraftData(draftData) {
// 恢复资产信息
Object.keys(draftData.assets).forEach(itemId => {
if (draftData.assets[itemId]) {
this.selectedValues[itemId] = draftData.assets[itemId];
}
});
// 恢复基本信息
Object.keys(draftData.basicInfo).forEach(itemId => {
if (draftData.basicInfo[itemId]) {
this.basicInfoValues[itemId] = draftData.basicInfo[itemId];
}
});
// 重新渲染表单
this.renderForm();
// 更新进度
this.updateProgress();
this.updateBasicInfoProgress();
this.checkSubmitButton();
// 显示基本信息区域(如果资产信息已全部完成)
const assetCompleted = Object.keys(this.selectedValues).length;
if (assetCompleted >= ASSET_CONFIG.ITEMS.length) {
this.showBasicInfoSection();
}
}
/**
* 自动保存草稿
*/
autoSaveDraft() {
if (this.isSubmitting || this.isSavingDraft) {
return;
}
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer);
}
this.autoSaveTimer = setTimeout(async () => {
if (this.isSubmitting || this.isSavingDraft) {
return;
}
const assetCompleted = Object.keys(this.selectedValues).length;
const basicInfoCompleted = Object.keys(this.basicInfoValues).filter(key => this.basicInfoValues[key]).length;
if (assetCompleted > 0 || basicInfoCompleted > 0) {
this.isSavingDraft = true;
try {
const result = await DraftManager.saveDraft(this.selectedValues, this.basicInfoValues);
if (result.success) {
console.log('[BasicInfoPage] 草稿自动保存成功');
}
} catch (error) {
console.error('[BasicInfoPage] 草稿自动保存出错:', error);
} finally {
this.isSavingDraft = false;
}
}
}, 2000);
}
/**
* 处理表单提交
*/
async handleSubmit() {
// 检查是否已完成所有项
const assetCompleted = Object.keys(this.selectedValues).length;
const basicInfoCompleted = Object.keys(this.basicInfoValues).filter(key => this.basicInfoValues[key]).length;
if (assetCompleted < ASSET_CONFIG.ITEMS.length) {
showToast('请完成所有资产信息填写');
return;
}
if (basicInfoCompleted < BASIC_INFO_CONFIG.ITEMS.length) {
showToast('请完成所有基本信息填写');
return;
}
// 验证姓名
const nameValidation = Validator.validateName(this.basicInfoValues.name);
if (!nameValidation.valid) {
showToast(nameValidation.message);
this.showFieldError('name', nameValidation.message);
return;
}
// 验证身份证号
const idCardValidation = Validator.validateIdCard(this.basicInfoValues.idCard);
if (!idCardValidation.valid) {
showToast(idCardValidation.message);
this.showFieldError('idCard', idCardValidation.message);
return;
}
// 检查协议
if (!this.elements.agreementCheckbox.checked) {
this.agreementModal.show();
return;
}
// 清除错误提示
document.querySelectorAll('.basic-input-inline.error').forEach(el => {
el.classList.remove('error');
});
document.querySelectorAll('.basic-info-error').forEach(el => {
el.style.display = 'none';
});
// 构建提交数据
const submitData = {
...this.selectedValues,
...this.basicInfoValues
};
console.log('提交的数据:', submitData);
// 禁用提交按钮
this.elements.submitBtn.disabled = true;
this.elements.submitBtn.textContent = '提交中...';
this.isSubmitting = true;
try {
// 这里需要调用 FormService.submitForm() 方法
// 由于需要转换数据格式,暂时使用 console.log
showToast('表单提交功能待实现');
// 提交成功后的处理:
// FormService.clearDraft();
// showToast('信息提交成功!');
} catch (error) {
console.error('提交失败:', error);
showToast('提交失败,请稍后重试');
this.elements.submitBtn.disabled = false;
this.elements.submitBtn.textContent = '下一步';
this.isSubmitting = false;
}
}
/**
* 显示字段错误
* @param {string} fieldId - 字段ID
* @param {string} message - 错误消息
*/
showFieldError(fieldId, message) {
const input = document.getElementById(`basic-input-${fieldId}`);
const errorEl = document.getElementById(`error-${fieldId}`);
if (input) {
input.classList.add('error');
}
if (errorEl) {
errorEl.textContent = message;
errorEl.style.display = 'block';
}
}
/**
* 销毁页面
*/
destroy() {
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer);
}
if (this.cityPicker) {
this.cityPicker.destroy();
}
if (this.agreementModal) {
this.agreementModal.destroy();
}
}
}

6
src/js/pages/index.js Normal file
View File

@@ -0,0 +1,6 @@
/**
* 页面逻辑模块统一导出
*/
export { IndexPage } from './index.page.js';
export { BasicInfoPage } from './basic-info.page.js';

560
src/js/pages/index.page.js Normal file
View File

@@ -0,0 +1,560 @@
/**
* 主借款申请页面逻辑
* 负责借款申请页面的交互和业务逻辑
*/
import { Picker, Modal, OneClickLoginButton } from '../ui/index.js';
import { Validator, Formatter } from '../utils/index.js';
import { SMSService, AuthService, LoanService } from '../services/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 { showToast } from '../ui/toast.js';
export class IndexPage {
constructor() {
// 借款数据
this.loanData = {
amount: LOAN_CONFIG.AMOUNT.DEFAULT,
period: LOAN_CONFIG.DEFAULT_PERIOD,
purpose: LOAN_CONFIG.DEFAULT_PURPOSE
};
// 组件实例
this.purposePicker = null;
this.termPicker = null;
this.verifyCodeModal = null;
this.agreementModal = null;
this.oneClickLoginBtn = null; // 一键登录按钮
// 极光一键登录配置
this.jVerifyAppId = '80570da3ef331d9de547b4f1'; // 极光AppKey
// 倒计时定时器
this.countdownTimer = null;
this.init();
}
/**
* 初始化页面
*/
init() {
// 等待 DOM 加载完成
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setup());
} else {
this.setup();
}
}
/**
* 设置页面
*/
setup() {
// 先检查必要的页面元素是否存在
if (!document.getElementById('loanAmount')) {
console.warn('[IndexPage] 不是主页面,跳过初始化');
return;
}
this.initElements();
this.initPickers();
this.initModals();
this.initOneClickLogin(); // 初始化一键登录
this.bindEvents();
this.restoreUserData();
}
/**
* 初始化 DOM 元素引用
*/
initElements() {
this.elements = {
loanAmount: document.getElementById('loanAmount'),
maxLoan: document.getElementById('maxLoan'),
repaymentPlan: document.getElementById('repaymentPlan'),
loanPurpose: document.getElementById('loanPurpose'),
repaymentTerm: document.getElementById('repaymentTerm'),
phoneNumber: document.getElementById('phoneNumber'),
phoneError: document.getElementById('phoneError'),
applyBtn: document.getElementById('applyBtn'),
agreementCheck: document.getElementById('agreementCheck')
};
}
/**
* 初始化选择器
*/
initPickers() {
// 确保在下一个事件循环中初始化DOM完全加载
setTimeout(() => {
// 借款用途选择器
if (this.elements.loanPurpose) {
this.purposePicker = new Picker({
...PURPOSE_PICKER_CONFIG,
displayEl: this.elements.loanPurpose,
dataKey: 'purpose',
defaultValue: this.loanData.purpose,
onConfirm: (value) => {
this.loanData.purpose = value;
}
});
}
// 还款期数选择器
if (this.elements.repaymentTerm) {
this.termPicker = new Picker({
...TERM_PICKER_CONFIG,
displayEl: this.elements.repaymentTerm,
dataKey: 'period',
defaultValue: this.loanData.period,
formatDisplay: Formatter.formatRepaymentPeriod,
onConfirm: (value) => {
this.loanData.period = value;
this.updateRepaymentPlan();
}
});
}
}, 100);
}
/**
* 初始化模态框
*/
initModals() {
// 验证码模态框
this.verifyCodeModal = new Modal({
modalId: 'verifyCodeModal',
onConfirm: () => this.handleVerifyCodeConfirm(),
closeOnOverlay: false
});
// 协议提示模态框
this.agreementModal = new Modal({
modalId: 'agreementModal',
onConfirm: () => this.handleAgreementConfirm()
});
}
/**
* 绑定事件
*/
bindEvents() {
// 借款金额输入
this.elements.loanAmount.addEventListener('input', () => {
const amount = Math.min(
parseInt(this.elements.loanAmount.value) || 0,
LOAN_CONFIG.AMOUNT.MAX
);
this.elements.loanAmount.value = amount;
this.loanData.amount = amount;
this.updateRepaymentPlan();
});
// 全部借出按钮
this.elements.maxLoan.addEventListener('click', () => {
this.elements.loanAmount.value = LOAN_CONFIG.AMOUNT.MAX;
this.loanData.amount = LOAN_CONFIG.AMOUNT.MAX;
this.updateRepaymentPlan();
});
// 申请按钮
this.elements.applyBtn.addEventListener('click', () => this.handleApply());
// 手机号输入(清除错误提示)
this.elements.phoneNumber.addEventListener('input', () => {
if (!AuthService.isLoggedIn() && this.elements.phoneError.style.display !== 'none') {
this.elements.phoneError.style.display = 'none';
this.elements.phoneNumber.classList.remove('error');
}
});
}
/**
* 恢复用户数据
*/
restoreUserData() {
const userSession = UserCache.getUserSession();
if (userSession && userSession.loginPhone) {
// 用户已登录,显示脱敏手机号
this.elements.phoneNumber.value = UserCache.getMaskedPhone();
this.elements.phoneNumber.readOnly = true;
this.elements.phoneNumber.style.color = '#666';
// 恢复表单数据
if (userSession.formData) {
if (userSession.formData.loanamount) {
this.elements.loanAmount.value = userSession.formData.loanamount;
this.loanData.amount = userSession.formData.loanamount;
}
if (userSession.formData.repaymentperiod) {
this.termPicker.setValue(userSession.formData.repaymentperiod);
this.loanData.period = userSession.formData.repaymentperiod;
}
if (userSession.formData.loanpurpose) {
this.purposePicker.setValue(userSession.formData.loanpurpose);
this.loanData.purpose = userSession.formData.loanpurpose;
}
}
// 显示登录状态提示
this.elements.phoneError.textContent = '已登录';
this.elements.phoneError.style.display = 'block';
this.elements.phoneError.style.color = '#3474fe';
// 重新计算还款计划
this.updateRepaymentPlan();
} else {
// 未登录,设置初始值
this.elements.loanAmount.value = this.loanData.amount;
this.elements.maxLoan.textContent = `全部借出${LOAN_CONFIG.AMOUNT.MAX}`;
this.updateRepaymentPlan();
}
}
/**
* 更新还款计划
*/
updateRepaymentPlan() {
const plan = LoanService.calculateRepaymentPlan(
this.loanData.amount,
this.loanData.period
);
this.elements.repaymentPlan.textContent = LoanService.formatRepaymentPlan(plan);
}
/**
* 处理申请按钮点击
*/
handleApply() {
// 检查是否已登录
if (AuthService.isLoggedIn()) {
// 已登录,检查协议
if (!this.elements.agreementCheck.checked) {
this.agreementModal.show();
return;
}
// 直接跳转到基本信息页面
this.navigateToBasicInfo();
return;
}
// 未登录,验证手机号
const phone = this.elements.phoneNumber.value.trim();
const validation = Validator.validatePhone(phone);
if (!validation.valid) {
this.elements.phoneError.textContent = validation.message;
this.elements.phoneError.style.display = 'block';
this.elements.phoneError.style.color = '#ff4d4f';
this.elements.phoneNumber.classList.add('error');
return;
}
// 清除错误提示
this.elements.phoneError.style.display = 'none';
this.elements.phoneNumber.classList.remove('error');
// 检查协议
if (!this.elements.agreementCheck.checked) {
this.agreementModal.show();
return;
}
// 显示验证码模态框
this.showVerifyCodeModal(phone);
}
/**
* 显示验证码模态框
* @param {string} phone - 手机号
*/
async showVerifyCodeModal(phone) {
this.currentVerifyPhone = phone;
const tipEl = document.getElementById('verifyCodeTip');
const inputEl = document.getElementById('verifyCodeInput');
const countdownEl = document.getElementById('verifyCodeCountdown');
const errorEl = document.getElementById('verifyCodeError');
// 格式化手机号显示
const formattedPhone = Formatter.maskPhone(phone);
tipEl.textContent = '发送中...';
// 重置状态
inputEl.value = '';
errorEl.style.display = 'none';
inputEl.classList.remove('error');
// 显示模态框
this.verifyCodeModal.show();
// 发送短信验证码
const sendResult = await SMSService.send(phone);
if (sendResult.success) {
tipEl.textContent = `已发送至:${formattedPhone}`;
this.startCountdown(countdownEl);
} else {
tipEl.textContent = `发送失败:${formattedPhone}`;
errorEl.textContent = sendResult.message;
errorEl.style.display = 'block';
countdownEl.textContent = '重新发送';
countdownEl.style.color = '#3474fe';
countdownEl.style.cursor = 'pointer';
countdownEl.onclick = () => this.resendSMS(countdownEl);
}
}
/**
* 启动倒计时
* @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) {
countdownEl.textContent = `${time}s`;
} else {
countdownEl.textContent = '重新发送';
countdownEl.style.color = '#3474fe';
countdownEl.style.cursor = 'pointer';
countdownEl.onclick = () => this.resendSMS(countdownEl);
clearInterval(this.countdownTimer);
}
}, 1000);
}
/**
* 重新发送短信
* @param {HTMLElement} countdownEl - 倒计时元素
*/
async resendSMS(countdownEl) {
const tipEl = document.getElementById('verifyCodeTip');
const errorEl = document.getElementById('verifyCodeError');
countdownEl.textContent = '发送中...';
countdownEl.style.color = '#999';
countdownEl.style.cursor = 'default';
countdownEl.onclick = null;
errorEl.style.display = 'none';
const sendResult = await SMSService.send(this.currentVerifyPhone);
if (sendResult.success) {
this.startCountdown(countdownEl);
} else {
errorEl.textContent = sendResult.message;
errorEl.style.display = 'block';
countdownEl.textContent = '重新发送';
countdownEl.style.color = '#3474fe';
countdownEl.style.cursor = 'pointer';
countdownEl.onclick = () => this.resendSMS(countdownEl);
}
}
/**
* 处理验证码确认
*/
async handleVerifyCodeConfirm() {
const inputEl = document.getElementById('verifyCodeInput');
const errorEl = document.getElementById('verifyCodeError');
const code = inputEl.value.trim();
// 验证码验证
const validation = Validator.validateSmsCode(code);
if (!validation.valid) {
errorEl.textContent = validation.message;
errorEl.style.display = 'block';
inputEl.classList.add('error');
return;
}
// 显示验证中状态
this.verifyCodeModal.setConfirmButton(true, '验证中...');
errorEl.style.display = 'none';
// 验证短信验证码
const verifyResult = await SMSService.verify(this.currentVerifyPhone, code);
if (!verifyResult.success) {
errorEl.textContent = verifyResult.message;
errorEl.style.display = 'block';
inputEl.classList.add('error');
this.verifyCodeModal.setConfirmButton(false, '确定');
return;
}
// 验证成功,进行用户注册/登录
this.verifyCodeModal.setConfirmButton(true, '登录中...');
const registerResult = await AuthService.registerOrLogin(
this.currentVerifyPhone,
this.loanData
);
if (registerResult.success) {
// 登录成功,跳转到基本信息页面
this.verifyCodeModal.hide();
this.navigateToBasicInfo();
} else {
errorEl.textContent = registerResult.message;
errorEl.style.display = 'block';
this.verifyCodeModal.setConfirmButton(false, '确定');
}
}
/**
* 处理协议确认
*/
handleAgreementConfirm() {
this.elements.agreementCheck.checked = true;
this.agreementModal.hide();
if (AuthService.isLoggedIn()) {
this.navigateToBasicInfo();
} else {
const phone = this.elements.phoneNumber.value.trim();
const validation = Validator.validatePhone(phone);
if (validation.valid) {
this.showVerifyCodeModal(phone);
}
}
}
/**
* 跳转到基本信息页面
*/
navigateToBasicInfo() {
window.location.href = 'basic-info.html' + window.location.search;
}
/**
* 初始化一键登录按钮
*/
initOneClickLogin() {
// 如果没有配置极光应用ID跳过初始化
if (!this.jVerifyAppId) {
console.log('[IndexPage] 未配置极光应用ID跳过一键登录');
return;
}
// 检查用户是否已登录
const userSession = UserCache.getUserSession();
if (userSession && userSession.loginPhone) {
console.log('[IndexPage] 用户已登录,不显示一键登录');
return;
}
try {
this.oneClickLoginBtn = new OneClickLoginButton({
containerId: 'oneClickLoginWrapper',
appId: this.jVerifyAppId,
buttonText: '一键登录',
onSuccess: (phone) => this.handleOneClickLoginSuccess(phone),
onFallback: () => this.handleOneClickLoginFallback()
});
} catch (error) {
console.error('[IndexPage] 初始化一键登录失败:', error);
}
}
/**
* 处理一键登录成功
* @param {string} phone - 手机号
*/
async handleOneClickLoginSuccess(phone) {
showToast('一键登录成功!');
try {
// 使用一键登录的手机号进行用户注册/登录
const registerResult = await AuthService.registerOrLogin(phone, this.loanData);
if (registerResult.success) {
// 登录成功,跳转到基本信息页面
showToast('登录成功!');
setTimeout(() => {
this.navigateToBasicInfo();
}, 500);
} else {
// 注册/登录失败,降级到短信验证
showToast(registerResult.message);
this.showPhoneNumberInput(phone); // 显示手机号输入框
}
} catch (error) {
console.error('[IndexPage] 一键登录后注册失败:', error);
showToast('登录失败,请使用短信验证码登录');
this.handleOneClickLoginFallback();
}
}
/**
* 处理一键登录降级(切换到短信验证码)
*/
handleOneClickLoginFallback() {
console.log('[IndexPage] 一键登录降级到短信验证码');
// 隐藏一键登录按钮(已由组件自动处理)
// 显示手机号输入框和立即申请按钮
this.showPhoneNumberInput();
}
/**
* 显示手机号输入区域
* @param {string} defaultPhone - 默认手机号(可选)
*/
showPhoneNumberInput(defaultPhone = '') {
const phoneInput = this.elements.phoneNumber;
const phoneWrapper = phoneInput?.closest('.phone-input-wrapper');
const buttonWrapper = document.querySelector('.button-wrapper');
// 只在元素被隐藏时才设置 display避免布局闪烁
if (phoneWrapper && phoneWrapper.style.display === 'none') {
phoneWrapper.style.display = 'block';
}
if (buttonWrapper && buttonWrapper.style.display === 'none') {
buttonWrapper.style.display = 'block';
}
// 如果提供了默认手机号,自动填充
if (defaultPhone && phoneInput) {
phoneInput.value = defaultPhone;
}
}
/**
* 销毁页面
*/
destroy() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
}
if (this.purposePicker) {
this.purposePicker.destroy();
}
if (this.termPicker) {
this.termPicker.destroy();
}
if (this.verifyCodeModal) {
this.verifyCodeModal.destroy();
}
if (this.agreementModal) {
this.agreementModal.destroy();
}
if (this.oneClickLoginBtn) {
this.oneClickLoginBtn.destroy();
}
}
}

View File

@@ -0,0 +1,90 @@
/**
* 认证服务
* 负责用户注册、登录等认证相关功能
*/
import { ApiClient } from '../core/api.js';
import { UserCache } from '../core/user-cache.js';
import { API_CONFIG } from '../config/index.js';
export class AuthService {
/**
* 客户注册或登录
* @param {string} phone - 手机号
* @param {Object} loanData - 借款数据
* @param {number} loanData.loanamount - 借款金额
* @param {number} loanData.repaymentperiod - 还款期数
* @param {string} loanData.loanpurpose - 借款用途
* @returns {Promise<{success: boolean, message: string, data: Object}>} - 注册/登录结果
*/
static async registerOrLogin(phone, loanData) {
try {
const response = await ApiClient.xpost(API_CONFIG.ENDPOINTS.CUSTOMER_REGISTER, {
mobile: phone,
loanamount: loanData.loanamount,
repaymentperiod: loanData.repaymentperiod,
loanpurpose: loanData.loanpurpose
});
if (response.retcode === 0) {
console.log('[AuthService] 后端返回数据:', response.result);
// 保存用户登录状态到缓存
const userData = {
...response.result,
customerid: response.result.customerid || response.result.customerId,
loginPhone: phone,
sessionid: response.result.sessionid || response.result.sessionId,
mobile: response.result.mobile,
formData: loanData
};
console.log('[AuthService] 保存用户数据:', userData);
UserCache.saveUserSession(userData);
return {
success: true,
message: response.retinfo || '登录成功',
data: response.result
};
} else {
return { success: false, message: response.retinfo };
}
} catch (error) {
console.error('[AuthService] 注册/登录失败:', error);
return { success: false, message: error.message || '网络错误,请稍后重试' };
}
}
/**
* 检查用户登录状态
* @returns {boolean} - 是否已登录
*/
static isLoggedIn() {
return UserCache.isLoggedIn();
}
/**
* 获取当前用户信息
* @returns {Object|null} - 用户信息
*/
static getCurrentUser() {
return UserCache.getUserSession();
}
/**
* 退出登录
*/
static logout() {
UserCache.clearUserSession();
console.log('[AuthService] 用户已退出登录');
}
/**
* 获取脱敏手机号
* @returns {string|null} - 脱敏后的手机号
*/
static getMaskedPhone() {
return UserCache.getMaskedPhone();
}
}

View File

@@ -0,0 +1,133 @@
/**
* 表单服务
* 负责表单提交和数据管理
*/
import { ApiClient } from '../core/api.js';
import { FormIdGenerator } from '../core/form-id.js';
import { API_CONFIG } from '../config/index.js';
export class FormService {
/**
* 获取 URL 中的 shortcode
* @returns {string} - shortcode
* @private
*/
static _getShortcode() {
const params = new URLSearchParams(window.location.search);
return params.get('code') || params.get('shortcode') || '';
}
/**
* 提交表单数据到服务器
* @param {Object} formData - 表单数据
* @param {Object} formData.assets - 资产信息
* @param {Object} formData.basicInfo - 基本信息
* @returns {Promise<{success: boolean, message: string, data: Object}>} - 提交结果
*/
static async submitForm(formData) {
try {
const requestData = {
shortcode: this._getShortcode(),
...formData,
draftstatus: 0, // 正式提交状态
formid: FormIdGenerator.getOrCreate()
};
const response = await ApiClient.xpost(API_CONFIG.ENDPOINTS.SUBMIT_FORM, requestData);
if (response.retcode === 0) {
// 提交成功后清除本地存储的formId
FormIdGenerator.clear();
console.log('[FormService] 表单提交成功');
return {
success: true,
message: '表单提交成功',
data: response.result
};
} else {
return { success: false, message: response.retinfo || '提交失败' };
}
} catch (error) {
console.error('[FormService] 表单提交失败:', error);
return { success: false, message: error.message || '网络错误,请稍后重试' };
}
}
/**
* 保存草稿数据到服务器
* @param {Object} formData - 表单数据
* @returns {Promise<{success: boolean, message: string, data: Object}>} - 保存结果
*/
static async saveDraft(formData) {
try {
const requestData = {
shortcode: this._getShortcode(),
...formData,
draftstatus: 1, // 草稿状态
formid: FormIdGenerator.getOrCreate()
};
const response = await ApiClient.xpost(API_CONFIG.ENDPOINTS.SUBMIT_DRAFT_FORM, requestData);
if (response.retcode === 0) {
console.log('[FormService] 草稿保存成功Form ID:', FormIdGenerator.getOrCreate());
return {
success: true,
message: '草稿保存成功',
data: response.result
};
} else {
return { success: false, message: response.retinfo || '草稿保存失败' };
}
} catch (error) {
console.error('[FormService] 草稿保存失败:', error);
return { success: false, message: error.message || '网络错误,请稍后重试' };
}
}
/**
* 从服务器加载草稿数据
* @returns {Promise<Object|null>} - 草稿数据,如果不存在则返回 null
*/
static async loadDraft() {
const formId = FormIdGenerator.getCurrent();
if (!formId) {
console.log('[FormService] 没有表单ID无法加载草稿');
return null;
}
try {
const requestData = { formid: formId };
const response = await ApiClient.xpost(API_CONFIG.ENDPOINTS.GET_DRAFT_FORM, requestData);
if (response.retcode === 0 && response.result) {
const draftData = response.result;
// 检查是否是草稿状态
if (draftData.draftstatus === 1) {
console.log('[FormService] 加载草稿数据成功:', draftData);
return draftData;
} else {
console.log('[FormService] 表单已正式提交,不是草稿');
return null;
}
} else {
console.log('[FormService] 没有找到草稿数据');
return null;
}
} catch (error) {
console.error('[FormService] 加载草稿失败:', error);
return null;
}
}
/**
* 清除草稿数据
*/
static clearDraft() {
FormIdGenerator.clear();
console.log('[FormService] 已清除草稿数据');
}
}

9
src/js/services/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* 业务服务层统一导出
*/
export { SMSService } from './sms.service.js';
export { AuthService } from './auth.service.js';
export { LoanService } from './loan.service.js';
export { FormService } from './form.service.js';
export { JVerifyService, getJVerifyService } from './jverify.service.js';

View File

@@ -0,0 +1,354 @@
/**
* 极光一键登录服务
* 提供极光一键登录功能,失败时自动降级到短信验证码登录
*/
import { ApiClient } from '../core/api.js';
import { DEBUG_CONFIG } from '../config/index.js';
export class JVerifyService {
constructor() {
this.isLoaded = false;
this.isAvailable = false;
this.sdk = null;
// 极光配置(需要从后端获取或配置)
this.config = {
appId: '', // 极光应用ID
// 后端 API 地址
apiUrl: '/auth/jpush/login'
};
}
/**
* 初始化极光SDK
* @param {string} appId - 极光应用ID
* @returns {Promise<boolean>} - 是否初始化成功
*/
async init(appId) {
if (this.isLoaded) {
return this.isAvailable;
}
this.config.appId = appId;
console.log('[JVerifyService] 初始化SDK, appId:', appId);
try {
// 动态加载极光SDK脚本
await this.loadSDK();
// 初始化极光SDK
if (window.JVerificationInterface && window.JVerificationInterface.init) {
// 调试模式配置
const debugMode = DEBUG_CONFIG.ENABLED;
console.log('[JVerifyService] 准备调用SDK.init, appkey:', appId);
// 初始化SDK
window.JVerificationInterface.init({
appkey: appId, // 注意:官方文档中是 appkey全小写不是 appKey
debugMode: debugMode,
success: () => {
console.log('[JVerifyService] 极光SDK初始化成功');
this.isAvailable = true;
this.isLoaded = true;
},
fail: (error) => {
console.warn('[JVerifyService] 极光SDK初始化失败:', error);
this.isAvailable = false;
this.isLoaded = true;
}
});
return true;
} else {
console.warn('[JVerifyService] 极光SDK未找到');
this.isLoaded = true;
this.isAvailable = false;
return false;
}
} catch (error) {
console.error('[JVerifyService] 加载极光SDK失败:', error);
this.isLoaded = true;
this.isAvailable = false;
return false;
}
}
/**
* 动态加载crypto-js依赖极光SDK必需
* @returns {Promise<void>}
* @private
*/
loadCryptoJS() {
return new Promise((resolve, reject) => {
// 检查是否已加载
if (window.CryptoJS) {
console.log('[JVerifyService] CryptoJS已加载');
resolve();
return;
}
const cdnUrls = [
'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js',
'https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.min.js',
'./src/assets/js/crypto-js.min.js'
];
let currentIndex = 0;
const tryLoadScript = () => {
if (currentIndex >= cdnUrls.length) {
reject(new Error('CryptoJS加载失败所有CDN源均无法访问'));
return;
}
const script = document.createElement('script');
script.charset = 'UTF-8';
script.src = cdnUrls[currentIndex];
script.onload = () => {
console.log(`[JVerifyService] CryptoJS加载成功: ${cdnUrls[currentIndex]}`);
resolve();
};
script.onerror = () => {
console.warn(`[JVerifyService] CryptoJS CDN加载失败: ${cdnUrls[currentIndex]}`);
currentIndex++;
tryLoadScript();
};
document.head.appendChild(script);
};
tryLoadScript();
});
}
/**
* 动态加载极光SDK脚本支持离线和多CDN
* @returns {Promise<void>}
* @private
*/
loadSDK() {
return new Promise((resolve, reject) => {
// 检查是否已加载
if (window.JVerificationInterface) {
console.log('[JVerifyService] 极光SDK已加载');
resolve();
return;
}
// 先加载crypto-js依赖
this.loadCryptoJS().then(() => {
// CryptoJS加载成功后继续加载JVerification SDK
this.loadJVerificationScript().then(resolve).catch(reject);
}).catch((error) => {
console.error('[JVerifyService] 加载依赖失败:', error);
reject(error);
});
});
}
/**
* 加载极光JVerification脚本
* @returns {Promise<void>}
* @private
*/
loadJVerificationScript() {
return new Promise((resolve, reject) => {
// 检查是否已加载
if (window.JVerificationInterface) {
console.log('[JVerifyService] 极光SDK已加载');
resolve();
return;
}
// CDN列表按优先级排序
// 注意:广告拦截器可能会阻止 jiguang.cn 域名,建议使用离线文件
const cdnUrls = [
'./jverification_web.js', // 本地文件(根目录,推荐)
'https://unpkg.com/jg-jverification-cordova-plugin@latest/dist/jverification_web.js', // unpkg通常不被拦截
'https://cdn.jsdelivr.net/npm/jg-jverification-cordova-plugin/dist/jverification_web.js', // jsDelivr
];
let currentIndex = 0;
const tryLoadScript = () => {
if (currentIndex >= cdnUrls.length) {
reject(new Error('所有CDN源均无法访问'));
return;
}
const script = document.createElement('script');
script.charset = 'UTF-8';
script.src = cdnUrls[currentIndex];
script.onload = () => {
console.log(`[JVerifyService] JVerification SDK加载成功: ${cdnUrls[currentIndex]}`);
resolve();
};
script.onerror = () => {
console.warn(`[JVerifyService] JVerification CDN加载失败: ${cdnUrls[currentIndex]}`);
currentIndex++;
tryLoadScript(); // 尝试下一个CDN
};
document.head.appendChild(script);
};
tryLoadScript();
});
}
/**
* 检查一键登录是否可用
* @returns {boolean}
*/
checkAvailable() {
return this.isLoaded && this.isAvailable;
}
/**
* 获取登录Token
* @returns {Promise<string>} - 登录Token
*/
async getToken() {
return new Promise((resolve, reject) => {
if (!this.isAvailable) {
reject(new Error('极光一键登录不可用'));
return;
}
try {
// 调用极光SDK获取token
window.JVerificationInterface.getToken({
success: (result) => {
console.log('[JVerifyService] 获取token成功:', result);
resolve(result.token); // 返回登录token
},
fail: (error) => {
console.error('[JVerifyService] 获取token失败:', error);
reject(new Error(error.code + ': ' + error.content));
}
});
} catch (error) {
reject(error);
}
});
}
/**
* 使用极光一键登录
* @param {string} appId - 极光应用ID
* @returns {Promise<{success: boolean, phone: string, message: string}>}
*/
async login(appId) {
try {
// 1. 检查SDK是否可用
if (!this.checkAvailable()) {
return {
success: false,
phone: null,
message: '一键登录不可用,请使用短信验证码登录'
};
}
// 2. 获取登录token
const token = await this.getToken();
// 3. 调用后端API验证
const response = await ApiClient.post(this.config.apiUrl, {
appId: appId,
loginToken: token
});
if (response.retcode === 0 && response.result) {
console.log('[JVerifyService] 一键登录成功:', response.result.phoneMasked);
return {
success: true,
phone: response.result.phone,
message: '登录成功'
};
} else {
console.error('[JVerifyService] 后端验证失败:', response.retinfo);
return {
success: false,
phone: null,
message: response.retinfo || '登录验证失败'
};
}
} catch (error) {
console.error('[JVerifyService] 一键登录异常:', error);
// 判断错误类型,返回友好的提示
if (error.message.includes('不可用')) {
return {
success: false,
phone: null,
message: '一键登录不可用'
};
}
return {
success: false,
phone: null,
message: '登录失败,请使用短信验证码登录'
};
}
}
/**
* 预登录(检查运营商网络)
* @returns {Promise<boolean>}
*/
async preLogin() {
return new Promise((resolve) => {
if (!this.isAvailable) {
resolve(false);
return;
}
try {
window.JVerificationInterface.checkLogin({
success: () => {
console.log('[JVerifyService] 运营商网络检查通过');
resolve(true);
},
fail: (error) => {
console.warn('[JVerifyService] 运营商网络检查失败:', error);
resolve(false);
}
});
} catch (error) {
console.error('[JVerifyService] 预登录检查异常:', error);
resolve(false);
}
});
}
/**
* 重置SDK状态
*/
reset() {
if (window.JVerificationInterface && window.JVerificationInterface.clear) {
window.JVerificationInterface.clear();
}
this.isLoaded = false;
this.isAvailable = false;
}
}
// 创建全局单例
let jVerifyService = null;
/**
* 获取极光一键登录服务实例
* @returns {JVerifyService}
*/
export function getJVerifyService() {
if (!jVerifyService) {
jVerifyService = new JVerifyService();
}
return jVerifyService;
}

View File

@@ -0,0 +1,109 @@
/**
* 借款服务
* 负责借款相关计算和业务逻辑
*/
import { LOAN_CONFIG } from '../config/index.js';
export class LoanService {
/**
* 计算还款计划
* @param {number} amount - 借款金额
* @param {number} period - 还款期数(月)
* @param {number} interestRate - 年化利率(%
* @returns {Object} - 还款计划
*/
static calculateRepaymentPlan(amount, period, interestRate = LOAN_CONFIG.INTEREST_RATE.MIN) {
const monthlyPrincipal = amount / period;
const monthlyInterest = amount * (interestRate / 100) / 12;
const firstAmount = monthlyPrincipal + monthlyInterest;
// 计算首期还款日期
const firstDate = this.calculateFirstRepaymentDate();
return {
firstDate,
firstAmount: firstAmount.toFixed(2),
totalInterest: (monthlyInterest * period).toFixed(2),
totalAmount: (amount + monthlyInterest * period).toFixed(2)
};
}
/**
* 计算首期还款日期
* @param {number} daysToAdd - 增加的天数默认30天
* @returns {string} - 格式化的日期(如:"02月05日"
*/
static calculateFirstRepaymentDate(daysToAdd = 30) {
const date = new Date();
date.setDate(date.getDate() + daysToAdd);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}${day}`;
}
/**
* 计算预期额度(根据完成度)
* @param {number} progress - 进度0-100
* @returns {number} - 预期额度
*/
static calculateExpectedAmount(progress) {
const { INITIAL, FINAL } = LOAN_CONFIG.AMOUNT;
// 根据完成度线性增加
return INITIAL + (FINAL - INITIAL) * (progress / 100);
}
/**
* 验证借款金额
* @param {number} amount - 借款金额
* @returns {{valid: boolean, message: string}} - 验证结果
*/
static validateAmount(amount) {
if (isNaN(amount)) {
return { valid: false, message: '请输入有效的借款金额' };
}
if (amount < LOAN_CONFIG.AMOUNT.MIN) {
return {
valid: false,
message: `借款金额不能低于${LOAN_CONFIG.AMOUNT.MIN}`
};
}
if (amount > LOAN_CONFIG.AMOUNT.MAX) {
return {
valid: false,
message: `借款金额不能超过${LOAN_CONFIG.AMOUNT.MAX}`
};
}
return { valid: true };
}
/**
* 格式化还款计划显示
* @param {Object} plan - 还款计划
* @returns {string} - 格式化后的显示文本
*/
static formatRepaymentPlan(plan) {
return `首期${plan.firstDate} 应还 ${plan.firstAmount}`;
}
/**
* 获取还款期数选项列表
* @returns {Array<number>} - 期数选项数组
*/
static getPeriodOptions() {
return LOAN_CONFIG.PERIOD_OPTIONS;
}
/**
* 获取借款用途选项列表
* @returns {Array<string>} - 用途选项数组
*/
static getPurposeOptions() {
return LOAN_CONFIG.PURPOSES;
}
}

View File

@@ -0,0 +1,78 @@
/**
* 短信服务
* 负责短信验证码的发送和验证
*/
import { ApiClient } from '../core/api.js';
import { API_CONFIG, DEBUG_CONFIG } from '../config/index.js';
export class SMSService {
/**
* 发送短信验证码
* @param {string} phone - 手机号
* @returns {Promise<{success: boolean, message: string}>} - 发送结果
*/
static async send(phone) {
try {
// 调试模式:不发送真实短信
if (DEBUG_CONFIG.ENABLED) {
console.log(`[SMSService] 调试模式,跳过发送短信,请使用验证码: ${DEBUG_CONFIG.SMS_CODE}`);
return {
success: true,
message: `调试模式:请输入验证码 ${DEBUG_CONFIG.SMS_CODE}`
};
}
const response = await ApiClient.post(API_CONFIG.ENDPOINTS.SEND_SMS, {
phone
});
if (response.retcode === 0) {
return { success: true, message: response.retinfo };
} else {
return { success: false, message: response.retinfo };
}
} catch (error) {
console.error('[SMSService] 发送短信失败:', error);
return { success: false, message: error.message || '网络错误,请稍后重试' };
}
}
/**
* 验证短信验证码
* @param {string} phone - 手机号
* @param {string} code - 验证码
* @returns {Promise<{success: boolean, message: string}>} - 验证结果
*/
static async verify(phone, code) {
try {
// 调试模式:固定验证码验证
if (DEBUG_CONFIG.ENABLED) {
if (code === DEBUG_CONFIG.SMS_CODE) {
console.log(`[SMSService] 调试模式,验证码验证成功: ${code}`);
return { success: true, message: '验证码验证成功(调试模式)' };
} else {
console.log(`[SMSService] 调试模式,验证码错误: ${code},正确验证码: ${DEBUG_CONFIG.SMS_CODE}`);
return {
success: false,
message: `验证码错误,调试模式请使用: ${DEBUG_CONFIG.SMS_CODE}`
};
}
}
const response = await ApiClient.post(API_CONFIG.ENDPOINTS.VERIFY_SMS, {
phone,
code
});
if (response.retcode === 0) {
return { success: true, message: response.retinfo };
} else {
return { success: false, message: response.retinfo };
}
} catch (error) {
console.error('[SMSService] 验证短信失败:', error);
return { success: false, message: error.message || '网络错误,请稍后重试' };
}
}
}

267
src/js/ui/city-picker.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* 城市选择器组件
* 提供省份和城市的联动选择功能
*/
import { PROVINCE_CITY_DATA } from '../config/index.js';
export class CityPicker {
/**
* 构造函数
* @param {Object} options - 配置选项
* @param {string} options.modalId - 模态框元素ID
* @param {string} options.provinceColumnId - 省份列元素ID
* @param {string} options.cityColumnId - 城市列元素ID
* @param {string} options.cancelBtnId - 取消按钮ID
* @param {string} options.confirmBtnId - 确认按钮ID
* @param {Function} options.onConfirm - 确认回调
*/
constructor(options) {
this.modal = document.getElementById(options.modalId);
this.provinceColumn = document.getElementById(options.provinceColumnId);
this.cityColumn = document.getElementById(options.cityColumnId);
this.cancelBtn = document.getElementById(options.cancelBtnId);
this.confirmBtn = document.getElementById(options.confirmBtnId);
this.onConfirm = options.onConfirm || null;
// 选中状态
this.selectedProvince = '';
this.selectedCity = '';
this.tempSelectedProvince = '';
this.tempSelectedCity = '';
if (!this.modal) {
console.error(`[CityPicker] 找不到模态框元素: ${options.modalId}`);
return;
}
this.init();
}
/**
* 初始化
*/
init() {
// 绑定取消按钮
if (this.cancelBtn) {
this.cancelBtn.addEventListener('click', () => this.close());
}
// 绑定确认按钮
if (this.confirmBtn) {
this.confirmBtn.addEventListener('click', () => this.confirmSelection());
}
// 绑定遮罩层点击
const overlay = this.modal.querySelector('.city-picker-overlay');
if (overlay) {
overlay.addEventListener('click', () => this.close());
}
// 渲染省份列表
this.renderProvinceList();
}
/**
* 渲染省份列表
*/
renderProvinceList() {
if (!this.provinceColumn) return;
this.provinceColumn.innerHTML = '';
const provinces = Object.keys(PROVINCE_CITY_DATA);
provinces.forEach(province => {
const provinceItem = document.createElement('div');
provinceItem.className = 'city-picker-item';
provinceItem.textContent = province;
provinceItem.dataset.province = province;
provinceItem.addEventListener('click', () => {
this.selectProvince(province);
});
this.provinceColumn.appendChild(provinceItem);
});
}
/**
* 选择省份
* @param {string} province - 省份
*/
selectProvince(province) {
this.tempSelectedProvince = province;
// 更新省份选中状态
const items = this.provinceColumn.querySelectorAll('.city-picker-item');
items.forEach(item => {
item.classList.toggle('active', item.dataset.province === province);
});
// 渲染城市列表
this.renderCityList(province);
}
/**
* 渲染城市列表
* @param {string} province - 省份
*/
renderCityList(province) {
if (!this.cityColumn) return;
this.cityColumn.innerHTML = '';
const cities = PROVINCE_CITY_DATA[province] || [];
cities.forEach(city => {
const cityItem = document.createElement('div');
cityItem.className = 'city-picker-item';
cityItem.textContent = city;
cityItem.dataset.city = city;
cityItem.addEventListener('click', () => {
this.selectCity(city);
});
this.cityColumn.appendChild(cityItem);
});
// 如果之前选择了这个省份的城市,自动选中
if (this.tempSelectedCity && cities.includes(this.tempSelectedCity)) {
this.selectCity(this.tempSelectedCity);
} else if (cities.length > 0) {
// 默认选择第一个城市
this.selectCity(cities[0]);
}
}
/**
* 选择城市
* @param {string} city - 城市
*/
selectCity(city) {
this.tempSelectedCity = city;
// 更新城市选中状态
const items = this.cityColumn.querySelectorAll('.city-picker-item');
items.forEach(item => {
item.classList.toggle('active', item.dataset.city === city);
});
}
/**
* 打开选择器
* @param {string} currentValue - 当前值(格式:"省/市"
*/
open(currentValue = '') {
// 解析当前值
if (currentValue) {
const parts = currentValue.split('/');
if (parts.length === 2) {
this.tempSelectedProvince = parts[0];
this.tempSelectedCity = parts[1];
}
}
// 如果没有选中,默认选择第一个省份
if (!this.tempSelectedProvince) {
const provinces = Object.keys(PROVINCE_CITY_DATA);
if (provinces.length > 0) {
this.tempSelectedProvince = provinces[0];
}
}
// 渲染列表
this.renderProvinceList();
if (this.tempSelectedProvince) {
this.selectProvince(this.tempSelectedProvince);
}
// 显示模态框
document.body.classList.add('modal-open');
this.modal.classList.add('show');
}
/**
* 关闭选择器
*/
close() {
if (!this.modal) return;
this.modal.classList.remove('show');
document.body.classList.remove('modal-open');
// 恢复选中状态
this.tempSelectedProvince = this.selectedProvince;
this.tempSelectedCity = this.selectedCity;
}
/**
* 确认选择
*/
confirmSelection() {
if (this.tempSelectedProvince && this.tempSelectedCity) {
this.selectedProvince = this.tempSelectedProvince;
this.selectedCity = this.tempSelectedCity;
const cityValue = `${this.tempSelectedProvince}/${this.tempSelectedCity}`;
if (this.onConfirm) {
this.onConfirm({
province: this.selectedProvince,
city: this.selectedCity,
value: cityValue
});
}
this.close();
}
}
/**
* 设置选中值
* @param {string} value - 值(格式:"省/市"
*/
setValue(value) {
if (!value) return;
const parts = value.split('/');
if (parts.length === 2) {
this.selectedProvince = parts[0];
this.selectedCity = parts[1];
this.tempSelectedProvince = parts[0];
this.tempSelectedCity = parts[1];
}
}
/**
* 获取选中值
* @returns {{province: string, city: string, value: string}} - 选中值
*/
getValue() {
return {
province: this.selectedProvince,
city: this.selectedCity,
value: `${this.selectedProvince}/${this.selectedCity}`
};
}
/**
* 重置选择器
*/
reset() {
this.selectedProvince = '';
this.selectedCity = '';
this.tempSelectedProvince = '';
this.tempSelectedCity = '';
}
/**
* 销毁选择器
*/
destroy() {
this.close();
this.modal = null;
this.provinceColumn = null;
this.cityColumn = null;
}
}

9
src/js/ui/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* UI 组件统一导出
*/
export { Modal } from './modal.js';
export { Picker } from './picker.js';
export { Toast, getToast, showToast, showSuccess, showError } from './toast.js';
export { CityPicker } from './city-picker.js';
export { OneClickLoginButton } from './one-click-login.js';

157
src/js/ui/modal.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* 模态框基类
* 提供通用的模态框功能
*/
import { ANIMATION_CONFIG } from '../config/index.js';
export class Modal {
/**
* 构造函数
* @param {Object} options - 配置选项
* @param {string} options.modalId - 模态框元素ID
* @param {Function} options.onConfirm - 确认回调
* @param {Function} options.onCancel - 取消回调
* @param {boolean} options.closeOnOverlay - 点击遮罩层是否关闭默认true
*/
constructor(options) {
this.modal = document.getElementById(options.modalId);
this.onConfirm = options.onConfirm || null;
this.onCancel = options.onCancel || null;
this.closeOnOverlay = options.closeOnOverlay !== false;
this.isOpen = false;
if (!this.modal) {
console.error(`[Modal] 找不到模态框元素: ${options.modalId}`);
return;
}
this.init();
}
/**
* 初始化
*/
init() {
// 绑定关闭按钮
const cancelBtn = this.modal.querySelector('.modal-cancel-btn, [id$="CancelBtn"]');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this.onCancelClick());
}
// 绑定确认按钮
const confirmBtn = this.modal.querySelector('.modal-confirm-btn, [id$="ConfirmBtn"]');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => this.onConfirmClick());
}
// 绑定遮罩层点击
if (this.closeOnOverlay) {
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) {
this.hide();
}
});
}
// 绑定ESC键关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) {
this.hide();
}
});
}
/**
* 显示模态框
*/
show() {
if (!this.modal) return;
document.body.classList.add('modal-open');
this.modal.classList.add('showing');
this.isOpen = true;
requestAnimationFrame(() => {
this.modal.classList.add('show');
});
}
/**
* 隐藏模态框
*/
hide() {
if (!this.modal) return;
this.modal.classList.remove('show');
this.isOpen = false;
setTimeout(() => {
this.modal.classList.remove('showing');
document.body.classList.remove('modal-open');
}, ANIMATION_CONFIG.MODAL_DURATION);
}
/**
* 确认按钮点击处理
*/
onConfirmClick() {
if (this.onConfirm) {
this.onConfirm();
} else {
this.hide();
}
}
/**
* 取消按钮点击处理
*/
onCancelClick() {
if (this.onCancel) {
this.onCancel();
}
this.hide();
}
/**
* 设置确认按钮状态
* @param {boolean} disabled - 是否禁用
* @param {string} text - 按钮文本
*/
setConfirmButton(disabled, text) {
const confirmBtn = this.modal.querySelector('.modal-confirm-btn, [id$="ConfirmBtn"]');
if (confirmBtn) {
confirmBtn.disabled = disabled;
if (text) {
confirmBtn.textContent = text;
}
}
}
/**
* 重置模态框状态
*/
reset() {
// 清除所有输入框的值
const inputs = this.modal.querySelectorAll('input[type="text"], input[type="number"], textarea');
inputs.forEach(input => {
input.value = '';
input.classList.remove('error');
});
// 隐藏所有错误提示
const errors = this.modal.querySelectorAll('.error-message, .basic-info-error');
errors.forEach(error => {
error.style.display = 'none';
});
}
/**
* 销毁模态框
*/
destroy() {
this.hide();
// 移除事件监听器等清理工作
this.modal = null;
}
}

View File

@@ -0,0 +1,207 @@
/**
* 一键登录按钮组件
* 提供极光一键登录按钮和降级提示
*/
import { getJVerifyService } from '../services/index.js';
export class OneClickLoginButton {
/**
* 构造函数
* @param {Object} options - 配置选项
* @param {string} options.containerId - 容器元素ID
* @param {string} options.appId - 极光应用ID
* @param {Function} options.onSuccess - 登录成功回调 (phone) => void
* @param {Function} options.onFallback - 降级回调 () => void
* @param {string} options buttonText - 按钮文本(默认"一键登录"
*/
constructor(options) {
this.container = document.getElementById(options.containerId);
this.appId = options.appId || '';
this.onSuccess = options.onSuccess || null;
this.onFallback = options.onFallback || null;
this.buttonText = options.buttonText || '一键登录';
this.isLoading = false;
if (!this.container) {
console.error('[OneClickLoginButton] 找不到容器元素');
return;
}
this.render();
this.init();
}
/**
* 初始化
*/
async init() {
try {
// 初始化极光SDK
const jVerifyService = getJVerifyService();
const isReady = await jVerifyService.init(this.appId);
if (isReady && jVerifyService.checkAvailable()) {
// 检查运营商网络
const canLogin = await jVerifyService.preLogin();
if (canLogin) {
// 显示一键登录按钮
this.showButton();
console.log('[OneClickLoginButton] 一键登录可用');
} else {
// 运营商网络不支持,隐藏一键登录
this.hide();
console.log('[OneClickLoginButton] 运营商网络不支持');
if (this.onFallback) {
this.onFallback();
}
}
} else {
// SDK不可用隐藏一键登录
this.hide();
console.log('[OneClickLoginButton] 一键登录不可用');
if (this.onFallback) {
this.onFallback();
}
}
} catch (error) {
console.error('[OneClickLoginButton] 初始化失败:', error);
this.hide();
if (this.onFallback) {
this.onFallback();
}
}
}
/**
* 渲染按钮
*/
render() {
this.container.innerHTML = `
<div class="one-click-login-container" id="oneClickLoginContainer" style="display: none;">
<button class="one-click-login-btn" id="oneClickLoginBtn">
<span class="btn-icon">📱</span>
<span class="btn-text">${this.buttonText}</span>
</button>
<div class="login-divider">
<span class="divider-line"></span>
<span class="divider-text">或</span>
<span class="divider-line"></span>
</div>
</div>
`;
// 绑定事件
const btn = this.container.querySelector('#oneClickLoginBtn');
if (btn) {
btn.addEventListener('click', () => this.handleClick());
}
}
/**
* 显示按钮
*/
showButton() {
const container = document.getElementById('oneClickLoginContainer');
if (container) {
container.style.display = 'block';
}
// 显示 wrapper 容器(避免布局闪烁)
if (this.container) {
this.container.classList.add('show');
}
}
/**
* 隐藏组件
*/
hide() {
if (this.container) {
this.container.style.display = 'none';
this.container.classList.remove('show');
}
}
/**
* 处理点击事件
*/
async handleClick() {
if (this.isLoading) {
return; // 防止重复点击
}
this.isLoading = true;
this.setLoading(true);
try {
const jVerifyService = getJVerifyService();
const result = await jVerifyService.login(this.appId);
if (result.success) {
// 登录成功
console.log('[OneClickLoginButton] 登录成功:', result.phone);
if (this.onSuccess) {
this.onSuccess(result.phone);
}
} else {
// 登录失败,降级到短信验证
console.log('[OneClickLoginButton] 登录失败:', result.message);
this.showFallbackMessage(result.message);
if (this.onFallback) {
this.onFallback();
}
}
} catch (error) {
console.error('[OneClickLoginButton] 登录异常:', error);
this.showFallbackMessage('登录失败,请使用短信验证码登录');
if (this.onFallback) {
this.onFallback();
}
} finally {
this.isLoading = false;
this.setLoading(false);
}
}
/**
* 设置加载状态
* @param {boolean} loading
*/
setLoading(loading) {
const btn = this.container.querySelector('#oneClickLoginBtn');
if (btn) {
if (loading) {
btn.disabled = true;
btn.innerHTML = `
<span class="btn-spinner"></span>
<span class="btn-text">登录中...</span>
`;
} else {
btn.disabled = false;
btn.innerHTML = `
<span class="btn-icon">📱</span>
<span class="btn-text">${this.buttonText}</span>
`;
}
}
}
/**
* 显示降级消息
* @param {string} message
*/
showFallbackMessage(message) {
// 可以在这里显示一个Toast提示
console.log('[OneClickLoginButton] 降级提示:', message);
}
/**
* 销毁组件
*/
destroy() {
if (this.container) {
this.container.innerHTML = '';
}
}
}

281
src/js/ui/picker.js Normal file
View File

@@ -0,0 +1,281 @@
/**
* 选择器组件
* 提供滚轮选择和点击选择功能
*/
import { ANIMATION_CONFIG } from '../config/index.js';
export class Picker {
/**
* 构造函数
* @param {Object} options - 配置选项
* @param {string} options.triggerId - 触发器元素ID
* @param {string} options.modalId - 模态框元素ID
* @param {string} options.cancelBtnId - 取消按钮ID
* @param {string} options.confirmBtnId - 确认按钮ID
* @param {string} options.optionSelector - 选项选择器
* @param {HTMLElement} options.displayEl - 显示元素
* @param {string} options.dataKey - 数据键名
* @param {Function} options.onConfirm - 确认回调
* @param {boolean} options.enableWheel - 是否启用滚轮切换默认false
* @param {boolean} options.useDataValue - 是否使用data-value默认false
* @param {Function} options.formatDisplay - 格式化显示函数
* @param {any} options.defaultValue - 默认值
*/
constructor(options) {
this.trigger = document.getElementById(options.triggerId);
this.modal = document.getElementById(options.modalId);
this.cancelBtn = document.getElementById(options.cancelBtnId);
this.confirmBtn = document.getElementById(options.confirmBtnId);
this.optionSelector = options.optionSelector;
this.displayEl = options.displayEl;
this.dataKey = options.dataKey;
this.onConfirm = options.onConfirm || null;
this.enableWheel = options.enableWheel || false;
this.useDataValue = options.useDataValue || false;
this.formatDisplay = options.formatDisplay || null;
this.defaultValue = options.defaultValue || null;
this.selectedValue = this.defaultValue;
this.tempSelectedValue = this.defaultValue;
this.options = [];
this.modalBody = null;
if (!this.trigger || !this.modal) {
console.error(`[Picker] 找不到必要的元素`);
return;
}
this.init();
}
/**
* 初始化
*/
init() {
// 获取所有选项元素
this.options = Array.from(document.querySelectorAll(this.optionSelector));
this.modalBody = this.modal.querySelector('.modal-body');
// 绑定触发器点击
this.trigger.addEventListener('click', () => this.open());
// 绑定取消按钮
if (this.cancelBtn) {
this.cancelBtn.addEventListener('click', () => this.close(true));
}
// 绑定确认按钮
if (this.confirmBtn) {
this.confirmBtn.addEventListener('click', () => this.handleConfirm());
}
// 绑定选项点击
this.options.forEach(option => {
option.addEventListener('click', () => this.selectOption(option));
});
// 绑定遮罩层点击
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) {
this.close(true);
}
});
// 绑定键盘事件
document.addEventListener('keydown', (e) => {
if (!this.modal.classList.contains('show')) return;
if (e.key === 'Escape') {
this.close(true);
} else if (e.key === 'Enter') {
this.handleConfirm();
}
});
// 初始化滚轮导航
if (this.enableWheel) {
this.initWheelNavigation();
}
// 更新初始显示
this.updateDisplay();
}
/**
* 获取选项的值
* @param {HTMLElement} option - 选项元素
* @returns {any} - 选项值
*/
getOptionValue(option) {
if (this.useDataValue) {
const val = option.getAttribute('data-value');
return isNaN(val) ? val : parseInt(val);
}
return this.getOptionText(option);
}
/**
* 获取选项的文本内容
* @param {HTMLElement} option - 选项元素
* @returns {string} - 选项文本
*/
getOptionText(option) {
const textEl = option.querySelector('.modal-option-text');
return textEl ? textEl.textContent : option.textContent.trim();
}
/**
* 更新选中状态
* @param {any} value - 值
*/
updateSelection(value = this.selectedValue) {
this.options.forEach(option => {
const optionValue = this.getOptionValue(option);
const isActive = optionValue == value; // 使用==而不是===以支持数字比较
option.classList.toggle('active', isActive);
if (isActive) {
setTimeout(() => {
option.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, ANIMATION_CONFIG.SCROLL_DELAY);
}
});
}
/**
* 更新显示
*/
updateDisplay() {
if (this.displayEl) {
const displayValue = this.formatDisplay
? this.formatDisplay(this.selectedValue)
: this.selectedValue;
this.displayEl.textContent = displayValue;
}
}
/**
* 打开选择器
*/
open() {
this.tempSelectedValue = this.selectedValue;
document.body.classList.add('modal-open');
this.modal.classList.add('showing');
requestAnimationFrame(() => {
this.modal.classList.add('show');
this.updateSelection(this.tempSelectedValue);
});
}
/**
* 关闭选择器
* @param {boolean} restore - 是否恢复选中值
*/
close(restore = false) {
if (restore) {
this.tempSelectedValue = this.selectedValue;
this.updateSelection(this.tempSelectedValue);
}
this.modal.classList.remove('show');
setTimeout(() => {
this.modal.classList.remove('showing');
document.body.classList.remove('modal-open');
}, ANIMATION_CONFIG.MODAL_DURATION);
}
/**
* 选择选项
* @param {HTMLElement} option - 选项元素
*/
selectOption(option) {
this.options.forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
this.tempSelectedValue = this.getOptionValue(option);
}
/**
* 确认选择
*/
handleConfirm() {
this.selectedValue = this.tempSelectedValue;
this.updateDisplay();
if (this.onConfirm) {
this.onConfirm(this.selectedValue);
}
this.close();
}
/**
* 设置选中值
* @param {any} value - 值
*/
setValue(value) {
this.selectedValue = value;
this.tempSelectedValue = value;
this.updateDisplay();
}
/**
* 获取选中值
* @returns {any} - 选中值
*/
getValue() {
return this.selectedValue;
}
/**
* 初始化滚轮导航
*/
initWheelNavigation() {
if (!this.modalBody) return;
let isScrolling = false;
let lastWheelTime = 0;
this.modalBody.addEventListener('wheel', (e) => {
if (!this.modal.classList.contains('show')) return;
e.preventDefault();
e.stopPropagation();
// 节流处理
const now = Date.now();
if (isScrolling || now - lastWheelTime < ANIMATION_CONFIG.WHEEL_DEBOUNCE_DELAY) return;
lastWheelTime = now;
isScrolling = true;
setTimeout(() => { isScrolling = false; }, ANIMATION_CONFIG.WHEEL_DEBOUNCE_DELAY);
const currentIndex = this.options.findIndex(opt => opt.classList.contains('active'));
let nextIndex = currentIndex;
if (e.deltaY > 0) {
nextIndex = Math.min(currentIndex + 1, this.options.length - 1);
} else if (e.deltaY < 0) {
nextIndex = Math.max(currentIndex - 1, 0);
}
if (nextIndex !== currentIndex) {
this.selectOption(this.options[nextIndex]);
this.options[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, { passive: false });
}
/**
* 销毁选择器
*/
destroy() {
this.close();
// 移除事件监听器等清理工作
this.trigger = null;
this.modal = null;
this.options = [];
}
}

174
src/js/ui/toast.js Normal file
View File

@@ -0,0 +1,174 @@
/**
* Toast 提示组件
* 提供轻量级的消息提示功能
*/
import { ANIMATION_CONFIG } from '../config/index.js';
export class Toast {
/**
* 构造函数
* @param {Object} options - 配置选项
* @param {string} options.toastId - Toast 元素ID默认 "toast"
* @param {string} options.contentId - 内容元素ID默认 "toastContent"
* @param {number} options.duration - 显示时长(毫秒)
* @param {string} options.type - 类型success | error | warning | info
*/
constructor(options = {}) {
this.toast = document.getElementById(options.toastId || 'toast');
this.contentEl = document.getElementById(options.contentId || 'toastContent');
this.defaultDuration = options.duration || ANIMATION_CONFIG.TOAST_DURATION;
this.defaultType = options.type || 'info';
if (!this.toast) {
console.warn('[Toast] 找不到 Toast 元素,请确保 DOM 中存在对应元素');
}
}
/**
* 显示 Toast
* @param {string} message - 消息内容
* @param {number} duration - 显示时长毫秒0 表示不自动关闭
* @param {string} type - 类型
*/
show(message, duration = this.defaultDuration, type = this.defaultType) {
if (!this.toast || !this.contentEl) {
console.warn('[Toast] Toast 元素不存在');
return;
}
// 清除之前的定时器
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
// 设置消息内容和类型
this.contentEl.textContent = message;
this.setType(type);
// 显示 Toast
this.toast.classList.add('show');
// 自动关闭
if (duration > 0) {
this.timer = setTimeout(() => {
this.hide();
}, duration);
}
}
/**
* 隐藏 Toast
*/
hide() {
if (!this.toast) return;
this.toast.classList.remove('show');
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
/**
* 设置 Toast 类型
* @param {string} type - 类型
*/
setType(type) {
if (!this.toast) return;
// 移除所有类型类
this.toast.classList.remove('toast-success', 'toast-error', 'toast-warning', 'toast-info');
// 添加类型类
this.toast.classList.add(`toast-${type}`);
}
/**
* 显示成功消息
* @param {string} message - 消息内容
* @param {number} duration - 显示时长
*/
success(message, duration) {
this.show(message, duration, 'success');
}
/**
* 显示错误消息
* @param {string} message - 消息内容
* @param {number} duration - 显示时长
*/
error(message, duration) {
this.show(message, duration, 'error');
}
/**
* 显示警告消息
* @param {string} message - 消息内容
* @param {number} duration - 显示时长
*/
warning(message, duration) {
this.show(message, duration, 'warning');
}
/**
* 显示信息消息
* @param {string} message - 消息内容
* @param {number} duration - 显示时长
*/
info(message, duration) {
this.show(message, duration, 'info');
}
/**
* 销毁 Toast
*/
destroy() {
this.hide();
this.toast = null;
this.contentEl = null;
}
}
// 创建全局 Toast 实例
let globalToast = null;
/**
* 获取全局 Toast 实例
* @returns {Toast} - Toast 实例
*/
export function getToast() {
if (!globalToast) {
globalToast = new Toast();
}
return globalToast;
}
/**
* 快捷方法:显示 Toast
* @param {string} message - 消息内容
* @param {number} duration - 显示时长
*/
export function showToast(message, duration) {
return getToast().show(message, duration);
}
/**
* 快捷方法:显示成功消息
* @param {string} message - 消息内容
* @param {number} duration - 显示时长
*/
export function showSuccess(message, duration) {
return getToast().success(message, duration);
}
/**
* 快捷方法:显示错误消息
* @param {string} message - 消息内容
* @param {number} duration - 显示时长
*/
export function showError(message, duration) {
return getToast().error(message, duration);
}

114
src/js/utils/formatter.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* 格式化工具
* 提供各种数据格式化功能
*/
export class Formatter {
/**
* 手机号脱敏显示中间4位用星号
* @param {string} phone - 手机号
* @returns {string} - 脱敏后的手机号
*/
static maskPhone(phone) {
if (!phone || phone.length !== 11) {
return phone;
}
return phone.substring(0, 3) + '****' + phone.substring(7);
}
/**
* 格式化还款期数显示
* @param {number} period - 还款期数
* @returns {string} - 格式化后的期数(如:"12个月"
*/
static formatRepaymentPeriod(period) {
return `${period}个月`;
}
/**
* 格式化金额显示(添加千分位)
* @param {number} amount - 金额
* @returns {string} - 格式化后的金额
*/
static formatMoney(amount) {
if (isNaN(amount)) return '0';
return amount.toLocaleString();
}
/**
* 格式化日期
* @param {Date|string|number} date - 日期
* @param {string} format - 格式(如:"YYYY-MM-DD"
* @returns {string} - 格式化后的日期
*/
static formatDate(date, format = 'YYYY-MM-DD') {
const d = new Date(date);
if (isNaN(d.getTime())) {
return '';
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds);
}
/**
* 格式化利率显示
* @param {number} rate - 利率
* @returns {string} - 格式化后的利率(如:"10.8%"
*/
static formatInterestRate(rate) {
if (isNaN(rate)) return '0%';
return `${rate}%`;
}
/**
* 格式化百分比
* @param {number} value - 数值0-1
* @param {number} decimals - 小数位数
* @returns {string} - 格式化后的百分比
*/
static formatPercent(value, decimals = 0) {
if (isNaN(value)) return '0%';
return `${(value * 100).toFixed(decimals)}%`;
}
/**
* 格式化城市显示(省/市格式)
* @param {string} province - 省份
* @param {string} city - 城市
* @returns {string} - 格式化后的城市(如:"江西省/南昌市"
*/
static formatCity(province, city) {
if (!province || !city) return '';
return `${province}/${city}`;
}
/**
* 解析城市字符串
* @param {string} cityStr - 城市字符串(如:"江西省/南昌市"
* @returns {{province: string, city: string}} - 省份和城市对象
*/
static parseCity(cityStr) {
if (!cityStr) return { province: '', city: '' };
const parts = cityStr.split('/');
if (parts.length === 2) {
return { province: parts[0], city: parts[1] };
}
return { province: '', city: cityStr };
}
}

283
src/js/utils/helper.js Normal file
View File

@@ -0,0 +1,283 @@
/**
* 通用辅助函数
* 提供常用的工具函数
*/
/**
* 防抖函数
* @param {Function} func - 要执行的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function} - 防抖后的函数
*/
export function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* 节流函数
* @param {Function} func - 要执行的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function} - 节流后的函数
*/
export function throttle(func, delay) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= delay) {
lastTime = now;
return func.apply(this, args);
}
};
}
/**
* 深拷贝对象
* @param {any} obj - 要拷贝的对象
* @returns {any} - 拷贝后的对象
*/
export function deepClone(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => deepClone(item));
}
const cloned = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key]);
}
}
return cloned;
}
/**
* 获取对象的深层属性值
* @param {Object} obj - 对象
* @param {string} path - 属性路径(如:"a.b.c"
* @param {any} defaultValue - 默认值
* @returns {any} - 属性值
*/
export function getDeepValue(obj, path, defaultValue = null) {
const keys = path.split('.');
let result = obj;
for (const key of keys) {
if (result === null || result === undefined) {
return defaultValue;
}
result = result[key];
}
return result !== undefined ? result : defaultValue;
}
/**
* 检查对象是否为空
* @param {Object} obj - 对象
* @returns {boolean} - 是否为空
*/
export function isEmpty(obj) {
if (obj === null || obj === undefined) return true;
if (typeof obj === 'string') return obj.trim().length === 0;
if (Array.isArray(obj)) return obj.length === 0;
if (typeof obj === 'object') return Object.keys(obj).length === 0;
return false;
}
/**
* 延迟执行
* @param {number} ms - 延迟时间(毫秒)
* @returns {Promise} - Promise 对象
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 生成随机字符串
* @param {number} length - 字符串长度
* @returns {string} - 随机字符串
*/
export function randomString(length = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* 获取URL参数
* @param {string} name - 参数名
* @param {string} url - URL可选默认为当前页面URL
* @returns {string|null} - 参数值
*/
export function getUrlParam(name, url = window.location.href) {
const params = new URLSearchParams(new URL(url).search);
return params.get(name);
}
/**
* 设置URL参数
* @param {string} name - 参数名
* @param {string} value - 参数值
* @param {boolean} replace - 是否替换历史记录
*/
export function setUrlParam(name, value, replace = false) {
const url = new URL(window.location.href);
url.searchParams.set(name, value);
if (replace) {
window.history.replaceState(null, '', url.toString());
} else {
window.history.pushState(null, '', url.toString());
}
}
/**
* 移除URL参数
* @param {string} name - 参数名
* @param {boolean} replace - 是否替换历史记录
*/
export function removeUrlParam(name, replace = false) {
const url = new URL(window.location.href);
url.searchParams.delete(name);
if (replace) {
window.history.replaceState(null, '', url.toString());
} else {
window.history.pushState(null, '', url.toString());
}
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>} - 是否成功
*/
export async function copyToClipboard(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
} else {
// 降级方案
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
return success;
}
} catch (error) {
console.error('[Helper] 复制到剪贴板失败:', error);
return false;
}
}
/**
* 检查是否为移动设备
* @returns {boolean} - 是否为移动设备
*/
export function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
/**
* 获取设备类型
* @returns {string} - 设备类型("mobile" | "tablet" | "desktop"
*/
export function getDeviceType() {
const userAgent = navigator.userAgent;
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent)) {
if (/iPad/i.test(userAgent) || (/Android/i.test(userAgent) && window.innerWidth >= 768)) {
return 'tablet';
}
return 'mobile';
}
return 'desktop';
}
/**
* 平滑滚动到元素
* @param {HTMLElement|string} element - 元素或元素ID
* @param {Object} options - 选项
*/
export function scrollToElement(element, options = {}) {
const target = typeof element === 'string' ? document.getElementById(element) : element;
if (!target) return;
const defaultOptions = {
behavior: 'smooth',
block: 'start',
inline: 'nearest',
...options
};
target.scrollIntoView(defaultOptions);
}
/**
* 下载文件
* @param {string} url - 文件URL
* @param {string} filename - 文件名
*/
export function downloadFile(url, filename) {
const link = document.createElement('a');
link.href = url;
link.download = filename || '';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} - 格式化后的文件大小
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 获取浏览器信息
* @returns {Object} - 浏览器信息
*/
export function getBrowserInfo() {
const userAgent = navigator.userAgent;
return {
userAgent,
language: navigator.language,
platform: navigator.platform,
cookieEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
screenWidth: window.screen.width,
screenHeight: window.screen.height,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
};
}

7
src/js/utils/index.js Normal file
View File

@@ -0,0 +1,7 @@
/**
* 工具函数模块统一导出
*/
export { Validator } from './validator.js';
export { Formatter } from './formatter.js';
export * from './helper.js';

149
src/js/utils/validator.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* 表单验证器
* 提供各种表单字段的验证功能
*/
import { VALIDATION_CONFIG } from '../config/index.js';
export class Validator {
/**
* 验证手机号
* @param {string} phone - 手机号
* @returns {{valid: boolean, message: string}} - 验证结果
*/
static validatePhone(phone) {
if (!phone || !phone.trim()) {
return { valid: false, message: '请输入手机号' };
}
if (!VALIDATION_CONFIG.PHONE_REGEX.test(phone)) {
return { valid: false, message: '请输入正确的手机号' };
}
return { valid: true };
}
/**
* 验证姓名2-20个汉字支持少数民族姓名
* @param {string} name - 姓名
* @returns {{valid: boolean, message: string}} - 验证结果
*/
static validateName(name) {
if (!name || !name.trim()) {
return { valid: false, message: '请输入真实姓名' };
}
const nameStr = name.trim();
if (!VALIDATION_CONFIG.NAME_REGEX.test(nameStr)) {
return { valid: false, message: '请输入2-20个汉字支持少数民族姓名' };
}
return { valid: true };
}
/**
* 验证身份证号
* @param {string} idCard - 身份证号
* @returns {{valid: boolean, message: string}} - 验证结果
*/
static validateIdCard(idCard) {
if (!idCard || !idCard.trim()) {
return { valid: false, message: '请输入身份证号' };
}
const idCardStr = idCard.trim();
// 验证18位身份证号
if (idCardStr.length === 18) {
if (!VALIDATION_CONFIG.ID_CARD_18_REGEX.test(idCardStr)) {
return { valid: false, message: '请输入正确的身份证号' };
}
// 验证校验码
if (!this._validateIdCardCheckCode(idCardStr)) {
return { valid: false, message: '身份证号校验码错误' };
}
return { valid: true };
}
// 验证15位身份证号旧版
if (idCardStr.length === 15) {
if (!VALIDATION_CONFIG.ID_CARD_15_REGEX.test(idCardStr)) {
return { valid: false, message: '请输入正确的身份证号' };
}
return { valid: true };
}
return { valid: false, message: '请输入正确的身份证号' };
}
/**
* 验证18位身份证号的校验码
* @param {string} idCard - 18位身份证号
* @returns {boolean} - 校验码是否正确
* @private
*/
static _validateIdCardCheckCode(idCard) {
let sum = 0;
for (let i = 0; i < 17; i++) {
sum += parseInt(idCard[i]) * VALIDATION_CONFIG.ID_CARD_WEIGHTS[i];
}
const checkCode = VALIDATION_CONFIG.ID_CARD_CHECK_CODES[sum % 11];
return idCard[17].toUpperCase() === checkCode;
}
/**
* 验证短信验证码
* @param {string} code - 验证码
* @returns {{valid: boolean, message: string}} - 验证结果
*/
static validateSmsCode(code) {
if (!code || !code.trim()) {
return { valid: false, message: '请输入验证码' };
}
if (code.length !== 6) {
return { valid: false, message: '请输入6位验证码' };
}
return { valid: true };
}
/**
* 验证必填项
* @param {any} value - 值
* @param {string} fieldName - 字段名称
* @returns {{valid: boolean, message: string}} - 验证结果
*/
static validateRequired(value, fieldName = '此项') {
if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) {
return { valid: false, message: `请输入${fieldName}` };
}
return { valid: true };
}
/**
* 验证数字范围
* @param {number} value - 数值
* @param {number} min - 最小值
* @param {number} max - 最大值
* @param {string} fieldName - 字段名称
* @returns {{valid: boolean, message: string}} - 验证结果
*/
static validateRange(value, min, max, fieldName = '数值') {
if (isNaN(value)) {
return { valid: false, message: `${fieldName}必须是数字` };
}
if (value < min || value > max) {
return { valid: false, message: `${fieldName}必须在${min}-${max}之间` };
}
return { valid: true };
}
}

BIN
static/image/base-pic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
static/image/zc-pic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

756
style.css Normal file
View File

@@ -0,0 +1,756 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #f5f5f5;
color: #333;
font-size: 14px;
line-height: 1.5;
}
.container {
min-height: 100vh;
padding: 10px 16px;
padding-bottom: 20px;
}
.money-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
border-bottom: 1px solid #ddd;
padding-bottom: 12px;
position: relative;
}
.money-label {
font-weight: 600;
margin-right: 8px;
font-size: 14px;
}
.money-input {
flex: 1;
font-size: 38px;
font-weight: 600;
border: none;
outline: none;
color: #333;
background: transparent;
width: 265px;
text-align: left;
}
.max-loan {
position: absolute;
bottom: 4px;
right: 0;
color: #3474fe;
font-size: 12px;
cursor: pointer;
}
.interest-rate-text {
margin-top: 12px;
font-size: 12px;
color: #777;
}
.rate-highlight {
color: #3474fe;
}
/* 借款金额区域 - 统一使用money-section */
.money-section {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
font-size: 14px;
margin-top: 8px;
}
.money-first {
margin-top: 5px;
padding: 20px;
box-shadow: none;
position: relative;
}
.money-card {
padding: 0 20px;
}
.money-compact {
padding-bottom: 1px;
}
.money-phone {
padding-top: 16px;
padding-bottom: 16px;
padding-left: 0;
padding-right: 0;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
height: 52px;
border-bottom: 1px solid #efefef;
}
.info-item.clickable {
cursor: pointer;
}
.info-item.no-border {
border: none;
}
.info-item.compact {
height: 49px;
color: #777;
font-size: 12px;
}
.info-label {
font-weight: 600;
color: #333;
font-size: 14px;
min-width: 56px;
}
.info-content.row {
flex-direction: row;
align-items: center;
justify-content: flex-end;
}
.info-content {
text-align: right;
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
}
.info-main {
font-size: 12px;
font-weight: 400;
color: #333;
}
.info-desc {
color: #aaa;
font-size: 11px;
margin-top: 2px;
}
.arrow-icon {
color: #999;
margin-left: 4px;
font-size: 14px;
}
.disclaimer-text {
font-size: 10px;
color: #777;
margin-top: 8px;
padding: 0 20px;
}
.phone-input-wrapper {
height: 52px;
padding: 0 16px;
margin: 0 16px 16px 16px;
background-color: #f8f8f8;
border-radius: 25px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
.phone-label {
font-size: 14px;
font-weight: 600;
color: #333;
}
.phone-input {
font-size: 12px;
font-weight: 400;
border: none;
outline: none;
background: transparent;
text-align: right;
flex: 1;
margin-left: 12px;
color: #333;
width: 175px;
}
.phone-input::placeholder {
color: #999;
}
.phone-input.error {
color: #ff4444;
}
.phone-error {
font-size: 12px;
color: #ff4444;
text-align: center;
margin-top: 8px;
padding: 0 16px;
min-height: 20px;
}
.button-wrapper {
width: 100%;
padding: 24px 16px;
padding-top: 20px;
padding-bottom: 0;
background-color: #fff;
}
.apply-btn {
width: 100%;
height: 48px;
line-height: 48px;
color: #fff;
font-size: 18px;
text-align: center;
background: linear-gradient(140deg, #3474fe, #3474fe);
border: none;
border-radius: 25px;
cursor: pointer;
transition: all 0.2s ease;
}
.apply-btn:active {
opacity: 0.9;
}
/* 协议勾选 */
.agreement-section {
margin: 24px 16px 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: 11px;
line-height: 1.5;
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
margin-right: 4px;
}
.checkbox-input {
display: none;
}
.checkbox-icon {
width: 12px;
height: 12px;
border: 1px solid #c8c9cc;
border-radius: 50%;
margin-right: 5px;
position: relative;
flex-shrink: 0;
}
.checkbox-input:checked + .checkbox-icon {
background-color: #2979ff;
border-color: #2979ff;
}
.checkbox-input:checked + .checkbox-icon::after {
content: '';
position: absolute;
left: 3px;
top: 1px;
width: 4px;
height: 7px;
border: solid #fff;
border-width: 0 1px 1px 0;
transform: rotate(45deg);
}
.checkbox-label {
font-size: 11px;
color: #606266;
}
.agreement-link {
color: #2979ff;
font-size: 12px;
text-decoration: none;
margin: 0 2px;
white-space: nowrap;
}
.agreement-link:active {
opacity: 0.7;
}
/* 底部声明 */
.footer-section {
text-align: center;
color: #aaa;
margin: 16px 0;
font-size: 12px;
padding: 0 16px;
}
.footer-title {
font-weight: 600;
margin-bottom: 8px;
}
.footer-section p {
line-height: 22px;
font-size: 11px;
margin: 4px 0;
}
/* 借款用途选择器模态框 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: none;
align-items: flex-end;
justify-content: center;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
}
.modal-overlay.show {
display: flex;
opacity: 1;
}
.modal-overlay.showing {
display: flex;
}
.modal-content {
width: 100%;
max-width: 100%;
background-color: #fff;
border-radius: 16px 16px 0 0;
max-height: 70vh;
display: flex;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-overlay.show .modal-content {
transform: translateY(0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
flex-shrink: 0;
position: relative;
}
.modal-header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, #eee 20%, #eee 80%, transparent);
}
.modal-btn {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 8px 16px;
transition: all 0.2s ease;
border-radius: 4px;
min-width: 60px;
text-align: center;
user-select: none;
}
.modal-btn-cancel {
color: #666;
}
.modal-btn-cancel:active {
background-color: #f5f5f5;
opacity: 0.8;
}
.modal-btn-confirm {
color: #3474fe;
font-weight: 500;
}
.modal-btn-confirm:active {
background-color: rgba(52, 116, 254, 0.1);
opacity: 0.8;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 0;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.modal-body::-webkit-scrollbar {
width: 4px;
}
.modal-body::-webkit-scrollbar-track {
background: transparent;
}
.modal-body::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 2px;
}
.modal-body::-webkit-scrollbar-thumb:hover {
background: #bbb;
}
/* 模态框选项通用样式 */
.modal-option {
padding: 16px 20px;
font-size: 16px;
color: #333;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
}
.modal-option:last-child {
border-bottom: none;
}
.modal-option-text {
text-align: center;
}
.modal-option-check {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #ddd;
transition: all 0.2s ease;
flex-shrink: 0;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
}
.modal-option:active {
background-color: #f5f5f5;
}
.modal-option.active {
background-color: #e8e8e8;
}
.modal-option.active .modal-option-text {
color: #333;
font-weight: 600;
}
.modal-option.active .modal-option-check {
border-color: #3474fe;
background-color: #3474fe;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23fff' d='M10 3L4.5 8.5 2 6' stroke='none' stroke-width='2' fill-rule='evenodd'/%3E%3C/svg%3E");
background-size: 10px 10px;
background-position: center;
background-repeat: no-repeat;
}
/* 防止背景滚动 */
body.modal-open {
overflow: hidden;
position: fixed;
width: 100%;
}
/* 验证码弹窗样式 */
#verifyCodeModal {
align-items: center;
justify-content: center;
}
.verify-code-modal {
width: 90%;
max-width: 400px;
background-color: #fff;
border-radius: 16px;
display: flex;
flex-direction: column;
transform: scale(0.8);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1001;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
#verifyCodeModal.show .verify-code-modal {
transform: scale(1);
opacity: 1;
}
.verify-code-header {
padding: 20px 20px 16px;
text-align: center;
border-bottom: 1px solid #eee;
}
.verify-code-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.verify-code-body {
padding: 24px 20px;
}
.verify-code-tip {
font-size: 14px;
color: #666;
text-align: center;
margin-bottom: 20px;
}
.verify-code-input-wrapper {
display: flex;
align-items: center;
background-color: #f8f8f8;
border-radius: 8px;
padding: 0 16px;
height: 48px;
margin-bottom: 12px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.verify-code-input-wrapper:focus-within {
border-color: #3474fe;
background-color: #fff;
}
.verify-code-input-wrapper.error {
border-color: #ff4444;
background-color: #fff5f5;
}
.verify-code-input-icon {
font-size: 18px;
margin-right: 12px;
flex-shrink: 0;
}
.verify-code-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 16px;
color: #333;
height: 100%;
}
.verify-code-input::placeholder {
color: #999;
}
.verify-code-countdown {
font-size: 14px;
color: #999;
margin-left: 12px;
flex-shrink: 0;
cursor: pointer;
user-select: none;
}
.verify-code-error {
font-size: 12px;
color: #ff4444;
text-align: center;
margin-top: 8px;
}
.verify-code-footer {
padding: 0 20px 24px;
}
.verify-code-btn {
width: 100%;
height: 48px;
line-height: 48px;
color: #fff;
font-size: 16px;
text-align: center;
background: linear-gradient(140deg, #3474fe, #3474fe);
border: none;
border-radius: 24px;
cursor: pointer;
transition: all 0.2s ease;
}
.verify-code-btn:active {
opacity: 0.9;
}
/* 协议提示弹窗样式 */
#agreementModal {
align-items: center;
justify-content: center;
}
.agreement-modal {
width: 90%;
max-width: 320px;
background-color: #fff;
border-radius: 16px;
display: flex;
flex-direction: column;
transform: scale(0.8);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1001;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
#agreementModal.show .agreement-modal {
transform: scale(1);
opacity: 1;
}
.agreement-modal-header {
padding: 20px 20px 16px;
text-align: center;
border-bottom: 1px solid #eee;
}
.agreement-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.agreement-modal-body {
padding: 20px;
text-align: center;
}
.agreement-modal-text {
font-size: 14px;
color: #333;
margin-bottom: 12px;
line-height: 1.5;
}
.agreement-modal-links {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.agreement-modal-link {
color: #3474fe;
font-size: 14px;
text-decoration: none;
line-height: 1.5;
}
.agreement-modal-link:active {
opacity: 0.7;
}
.agreement-modal-footer {
display: flex;
border-top: 1px solid #eee;
padding: 0;
}
.agreement-modal-btn {
flex: 1;
height: 48px;
line-height: 48px;
font-size: 16px;
text-align: center;
border: none;
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
}
.agreement-modal-btn-cancel {
color: #666;
border-right: 1px solid #eee;
}
.agreement-modal-btn-cancel:active {
background-color: #f5f5f5;
}
.agreement-modal-btn-confirm {
color: #fff;
background: linear-gradient(140deg, #3474fe, #3474fe);
font-weight: 500;
}
.agreement-modal-btn-confirm:active {
opacity: 0.9;
}
/* 响应式适配 */
@media (max-width: 375px) {
.money-input {
font-size: 32px;
}
.info-item {
height: 49px;
}
}