.
This commit is contained in:
139
DEPLOY.md
Normal file
139
DEPLOY.md
Normal 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
69
README.md
Normal 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
932
basic-info.css
Normal 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
119
basic-info.html
Normal 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>
|
||||
250
docs/jverify-configuration.md
Normal file
250
docs/jverify-configuration.md
Normal 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
186
docs/offline-sdk-guide.md
Normal 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
217
index.html
Normal 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
55
jverification_web.js
Normal file
File diff suppressed because one or more lines are too long
50
nginx.conf.example
Normal file
50
nginx.conf.example
Normal 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
46
server.js
Normal 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 停止服务器`);
|
||||
});
|
||||
104
src/css/components/one-click-login.css
Normal file
104
src/css/components/one-click-login.css
Normal 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
351
src/js/README.md
Normal 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
|
||||
30
src/js/config/api.config.js
Normal file
30
src/js/config/api.config.js
Normal 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
176
src/js/config/app.config.js
Normal 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
49
src/js/config/index.js
Normal 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
141
src/js/core/api.js
Normal 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 || '网络错误,请稍后重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
183
src/js/core/draft-manager.js
Normal file
183
src/js/core/draft-manager.js
Normal 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
85
src/js/core/form-id.js
Normal 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
8
src/js/core/index.js
Normal 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
88
src/js/core/user-cache.js
Normal 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
79
src/js/main.js
Normal 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] 应用入口已加载');
|
||||
651
src/js/pages/basic-info.page.js
Normal file
651
src/js/pages/basic-info.page.js
Normal 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
6
src/js/pages/index.js
Normal 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
560
src/js/pages/index.page.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/js/services/auth.service.js
Normal file
90
src/js/services/auth.service.js
Normal 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();
|
||||
}
|
||||
}
|
||||
133
src/js/services/form.service.js
Normal file
133
src/js/services/form.service.js
Normal 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
9
src/js/services/index.js
Normal 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';
|
||||
354
src/js/services/jverify.service.js
Normal file
354
src/js/services/jverify.service.js
Normal 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;
|
||||
}
|
||||
109
src/js/services/loan.service.js
Normal file
109
src/js/services/loan.service.js
Normal 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;
|
||||
}
|
||||
}
|
||||
78
src/js/services/sms.service.js
Normal file
78
src/js/services/sms.service.js
Normal 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
267
src/js/ui/city-picker.js
Normal 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
9
src/js/ui/index.js
Normal 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
157
src/js/ui/modal.js
Normal 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;
|
||||
}
|
||||
}
|
||||
207
src/js/ui/one-click-login.js
Normal file
207
src/js/ui/one-click-login.js
Normal 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
281
src/js/ui/picker.js
Normal 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
174
src/js/ui/toast.js
Normal 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
114
src/js/utils/formatter.js
Normal 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
283
src/js/utils/helper.js
Normal 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
7
src/js/utils/index.js
Normal 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
149
src/js/utils/validator.js
Normal 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
BIN
static/image/base-pic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
static/image/dropdown-down.png
Normal file
BIN
static/image/dropdown-down.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/image/dropdown-right.png
Normal file
BIN
static/image/dropdown-right.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/image/personalTop.png
Normal file
BIN
static/image/personalTop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
static/image/zc-pic.png
Normal file
BIN
static/image/zc-pic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
756
style.css
Normal file
756
style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user