mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
feat(giffgaff): 重构eSIM工具为模块化架构
将原有的单文件实现拆分为模块化结构,提升代码可维护性和扩展性。 新增模块包括状态管理、UI控制、认证处理和eSIM服务等。 同时保留原始版本作为备份,支持通过路由切换访问不同版本。 新增MODULE_ARCHITECTURE.md详细说明模块设计与数据流向。 更新README文档,提供模块化版本使用指南和开发建议。 调整Netlify和服务器路由配置,支持新版和旧版页面共存。
This commit is contained in:
@@ -16,8 +16,14 @@
|
||||
|
||||
# 重定向和重写规则
|
||||
[[redirects]]
|
||||
# Giffgaff eSIM页面
|
||||
# Giffgaff eSIM页面 - 模块化版本
|
||||
from = "/giffgaff"
|
||||
to = "/src/giffgaff/giffgaff_modular.html"
|
||||
status = 200
|
||||
|
||||
[[redirects]]
|
||||
# Giffgaff eSIM页面 - 原始版本(备份)
|
||||
from = "/giffgaff-legacy"
|
||||
to = "/src/giffgaff/giffgaff_complete_esim.html"
|
||||
status = 200
|
||||
|
||||
|
||||
@@ -146,6 +146,11 @@ app.use('/api/simyo/*', (req, res) => {
|
||||
|
||||
// 路由配置
|
||||
app.get('/giffgaff', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'src/giffgaff/giffgaff_modular.html'));
|
||||
});
|
||||
|
||||
// 原始版本备份路由
|
||||
app.get('/giffgaff-legacy', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'src/giffgaff/giffgaff_complete_esim.html'));
|
||||
});
|
||||
|
||||
|
||||
618
src/giffgaff/MODULE_ARCHITECTURE.md
Normal file
618
src/giffgaff/MODULE_ARCHITECTURE.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# Giffgaff eSIM 工具 - 模块架构说明
|
||||
|
||||
## 🏗️ 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ giffgaff_modular.html │
|
||||
│ (HTML结构层) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CSS样式层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • giffgaff-base.css (基础样式) │
|
||||
│ • giffgaff-components.css (组件样式) │
|
||||
│ • giffgaff-service-time.css (服务时间) │
|
||||
│ • giffgaff-animations.css (动画效果) │
|
||||
│ • giffgaff-responsive.css (响应式) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ giffgaff-app.js │
|
||||
│ (应用主入口) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ 核心服务层 │ │ UI控制层 │
|
||||
├───────────────────────┤ ├───────────────────────┤
|
||||
│ • oauth-handler.js │ │ • ui-controller.js │
|
||||
│ • cookie-handler.js │ │ │
|
||||
│ • mfa-handler.js │ └───────────────────────┘
|
||||
│ • esim-service.js │ │
|
||||
└───────────────────────┘ │
|
||||
│ │
|
||||
└───────────┬───────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 基础设施层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • state-manager.js (状态管理) │
|
||||
│ • api-config.js (API配置) │
|
||||
│ • utils.js (工具函数) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📦 模块详细说明
|
||||
|
||||
### 第一层:基础设施层
|
||||
|
||||
#### 1. state-manager.js
|
||||
**职责:** 应用状态的唯一真实来源
|
||||
|
||||
**提供:**
|
||||
- 状态存储和访问
|
||||
- 状态持久化(localStorage)
|
||||
- 状态变更通知(观察者模式)
|
||||
- Cookie管理
|
||||
|
||||
**依赖:** 无
|
||||
|
||||
**被依赖:** 所有业务模块
|
||||
|
||||
```javascript
|
||||
// 状态结构
|
||||
{
|
||||
accessToken: string,
|
||||
codeVerifier: string,
|
||||
cookie: string,
|
||||
emailCodeRef: string,
|
||||
emailSignature: string,
|
||||
memberId: string,
|
||||
memberName: string,
|
||||
phoneNumber: string,
|
||||
esimSSN: string,
|
||||
esimActivationCode: string,
|
||||
esimDeliveryStatus: string,
|
||||
lpaString: string,
|
||||
isDeviceChange: boolean,
|
||||
currentStep: number
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. api-config.js
|
||||
**职责:** 集中管理API配置
|
||||
|
||||
**提供:**
|
||||
- OAuth配置
|
||||
- API端点定义
|
||||
- GraphQL查询模板
|
||||
|
||||
**依赖:** utils.js (环境检测)
|
||||
|
||||
**被依赖:** 所有服务模块
|
||||
|
||||
#### 3. utils.js
|
||||
**职责:** 通用工具函数
|
||||
|
||||
**提供:**
|
||||
- PKCE参数生成
|
||||
- 服务时间检查
|
||||
- 剪贴板操作
|
||||
- Toast通知
|
||||
- 环境检测
|
||||
|
||||
**依赖:** 无
|
||||
|
||||
**被依赖:** 所有模块
|
||||
|
||||
### 第二层:核心服务层
|
||||
|
||||
#### 4. oauth-handler.js
|
||||
**职责:** OAuth 2.0 PKCE认证流程
|
||||
|
||||
**核心方法:**
|
||||
- `startOAuthLogin()` - 启动OAuth登录
|
||||
- `processCallback()` - 处理OAuth回调
|
||||
|
||||
**依赖:**
|
||||
- state-manager.js
|
||||
- api-config.js
|
||||
- utils.js
|
||||
|
||||
**数据流:**
|
||||
```
|
||||
用户点击登录
|
||||
↓
|
||||
生成PKCE参数 (utils)
|
||||
↓
|
||||
保存到状态 (state-manager)
|
||||
↓
|
||||
打开授权页面
|
||||
↓
|
||||
用户授权并获取回调URL
|
||||
↓
|
||||
解析code和state
|
||||
↓
|
||||
交换访问令牌 (api-config)
|
||||
↓
|
||||
保存令牌 (state-manager)
|
||||
```
|
||||
|
||||
#### 5. cookie-handler.js
|
||||
**职责:** Cookie验证和生命周期管理
|
||||
|
||||
**核心方法:**
|
||||
- `verifyCookie()` - 验证Cookie有效性
|
||||
- `checkCookieValidity()` - 定期检查
|
||||
- `startValidityMonitor()` - 启动监控
|
||||
- `stopValidityMonitor()` - 停止监控
|
||||
- `handleCookieExpired()` - 处理过期
|
||||
|
||||
**依赖:**
|
||||
- state-manager.js
|
||||
- api-config.js
|
||||
|
||||
**监控机制:**
|
||||
```
|
||||
Cookie验证成功
|
||||
↓
|
||||
启动定时器(5分钟间隔)
|
||||
↓
|
||||
定期调用验证API
|
||||
↓
|
||||
如果401 → 触发过期处理
|
||||
↓
|
||||
清除状态并通知UI
|
||||
```
|
||||
|
||||
#### 6. mfa-handler.js
|
||||
**职责:** 多因素认证流程
|
||||
|
||||
**核心方法:**
|
||||
- `sendMFAChallenge()` - 发送验证码
|
||||
- `validateMFACode()` - 验证验证码
|
||||
- `sendSimSwapMFAChallenge()` - SIM交换验证码
|
||||
|
||||
**依赖:**
|
||||
- state-manager.js
|
||||
- api-config.js
|
||||
|
||||
**验证流程:**
|
||||
```
|
||||
发送验证码请求
|
||||
↓
|
||||
保存ref到状态
|
||||
↓
|
||||
用户输入验证码
|
||||
↓
|
||||
验证并获取签名
|
||||
↓
|
||||
保存签名到状态
|
||||
```
|
||||
|
||||
#### 7. esim-service.js
|
||||
**职责:** eSIM相关的所有业务操作
|
||||
|
||||
**核心方法:**
|
||||
- `getMemberInfo()` - 获取会员信息
|
||||
- `reserveESim()` - 预订eSIM
|
||||
- `swapSim()` - 交换SIM卡
|
||||
- `getESimDownloadToken()` - 获取LPA
|
||||
- `autoActivateESim()` - 自动激活
|
||||
- `smsActivateFlow()` - 完整SMS激活流程
|
||||
|
||||
**依赖:**
|
||||
- state-manager.js
|
||||
- api-config.js
|
||||
|
||||
**SMS激活完整流程:**
|
||||
```
|
||||
发送短信验证码
|
||||
↓
|
||||
验证短信验证码 → 获取签名
|
||||
↓
|
||||
预订eSIM → 获取激活码和SSN
|
||||
↓
|
||||
执行SIM交换 → 激活新eSIM
|
||||
↓
|
||||
轮询获取LPA → 生成二维码
|
||||
```
|
||||
|
||||
### 第三层:UI控制层
|
||||
|
||||
#### 8. ui-controller.js
|
||||
**职责:** UI状态管理和用户交互
|
||||
|
||||
**核心方法:**
|
||||
- `showStatus()` - 显示状态消息
|
||||
- `updateSteps()` - 更新步骤指示器
|
||||
- `showSection()` - 切换步骤
|
||||
- `updateStatusPanel()` - 更新状态面板
|
||||
- `showMemberInfo()` - 显示会员信息
|
||||
- `showESIMInfoAndGuide()` - 显示eSIM信息
|
||||
- `generateQRCode()` - 生成二维码
|
||||
- `showESimResult()` - 显示最终结果
|
||||
|
||||
**依赖:**
|
||||
- state-manager.js
|
||||
|
||||
**UI更新机制:**
|
||||
```
|
||||
状态变更 (state-manager)
|
||||
↓
|
||||
触发订阅回调
|
||||
↓
|
||||
ui-controller.updateStatusPanel()
|
||||
↓
|
||||
更新DOM显示
|
||||
```
|
||||
|
||||
### 第四层:应用入口层
|
||||
|
||||
#### 9. giffgaff-app.js
|
||||
**职责:** 应用初始化和事件协调
|
||||
|
||||
**核心功能:**
|
||||
- 初始化所有模块
|
||||
- 绑定事件监听器
|
||||
- 协调模块间交互
|
||||
- 处理用户操作
|
||||
|
||||
**初始化流程:**
|
||||
```
|
||||
页面加载
|
||||
↓
|
||||
创建GiffgaffApp实例
|
||||
↓
|
||||
订阅状态变化
|
||||
↓
|
||||
绑定所有事件监听器
|
||||
↓
|
||||
初始化服务时间检查
|
||||
↓
|
||||
恢复会话(如有)
|
||||
↓
|
||||
更新UI显示
|
||||
↓
|
||||
应用就绪
|
||||
```
|
||||
|
||||
## 🔄 数据流向
|
||||
|
||||
### 典型操作流程示例
|
||||
|
||||
#### OAuth登录流程
|
||||
```
|
||||
用户点击"开始OAuth登录"
|
||||
↓
|
||||
giffgaff-app.handleOAuthLogin()
|
||||
↓
|
||||
oauth-handler.startOAuthLogin()
|
||||
├─→ utils.generateCodeVerifier()
|
||||
├─→ utils.generateCodeChallenge()
|
||||
└─→ state-manager.set('codeVerifier', ...)
|
||||
↓
|
||||
打开授权页面
|
||||
↓
|
||||
用户输入回调URL
|
||||
↓
|
||||
giffgaff-app.handleOAuthCallback()
|
||||
↓
|
||||
oauth-handler.processCallback()
|
||||
├─→ 解析code和state
|
||||
├─→ 从state-manager恢复verifier
|
||||
├─→ 调用token交换API (api-config)
|
||||
└─→ state-manager.set('accessToken', ...)
|
||||
↓
|
||||
ui-controller.showSection(2)
|
||||
```
|
||||
|
||||
#### 短信激活流程
|
||||
```
|
||||
用户点击"短信验证码激活"
|
||||
↓
|
||||
giffgaff-app.handleSmsSend()
|
||||
↓
|
||||
mfa-handler.sendSimSwapMFAChallenge()
|
||||
├─→ 调用GraphQL API (api-config)
|
||||
└─→ state-manager.set('emailCodeRef', ...)
|
||||
↓
|
||||
用户输入验证码
|
||||
↓
|
||||
giffgaff-app.handleSmsVerify()
|
||||
↓
|
||||
esim-service.smsActivateFlow()
|
||||
├─→ mfa-handler.validateMFACode()
|
||||
├─→ esim-service.reserveESim()
|
||||
├─→ esim-service.swapSim()
|
||||
└─→ esim-service.waitAndGetLPA()
|
||||
↓
|
||||
ui-controller.showESimResult()
|
||||
```
|
||||
|
||||
## 🎯 设计模式应用
|
||||
|
||||
### 1. 单例模式 (Singleton)
|
||||
所有模块都导出单例实例,确保全局唯一:
|
||||
```javascript
|
||||
export const stateManager = new StateManager();
|
||||
export const uiController = new UIController();
|
||||
// ...
|
||||
```
|
||||
|
||||
### 2. 观察者模式 (Observer)
|
||||
状态管理使用观察者模式通知UI更新:
|
||||
```javascript
|
||||
stateManager.subscribe((state) => {
|
||||
uiController.updateStatusPanel();
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 策略模式 (Strategy)
|
||||
不同的登录方式(OAuth/Cookie)使用不同的处理器
|
||||
|
||||
### 4. 外观模式 (Facade)
|
||||
`giffgaff-app.js` 作为外观,简化模块间交互
|
||||
|
||||
## 🔍 关键技术点
|
||||
|
||||
### ES6模块系统
|
||||
```javascript
|
||||
// 导出
|
||||
export class MyClass { }
|
||||
export const myInstance = new MyClass();
|
||||
export function myFunction() { }
|
||||
|
||||
// 导入
|
||||
import { myInstance, myFunction } from './module.js';
|
||||
```
|
||||
|
||||
### 异步处理
|
||||
所有API调用使用 `async/await`:
|
||||
```javascript
|
||||
async handleOperation() {
|
||||
try {
|
||||
const result = await apiCall();
|
||||
// 处理结果
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态管理
|
||||
集中式状态 + 订阅模式:
|
||||
```javascript
|
||||
// 更新状态
|
||||
stateManager.set('key', 'value');
|
||||
|
||||
// 自动触发所有订阅者
|
||||
subscribers.forEach(fn => fn(state));
|
||||
```
|
||||
|
||||
## 📊 性能优化点
|
||||
|
||||
### 1. 资源加载
|
||||
- CSS文件可并行加载
|
||||
- JavaScript模块按需加载
|
||||
- 预连接关键域名
|
||||
|
||||
### 2. 代码分割
|
||||
- 每个模块独立文件
|
||||
- 浏览器可缓存单个模块
|
||||
- 修改一个模块不影响其他缓存
|
||||
|
||||
### 3. 运行时优化
|
||||
- 减少全局变量
|
||||
- 事件委托减少监听器
|
||||
- 防抖和节流(如需要)
|
||||
|
||||
## 🛡️ 错误处理策略
|
||||
|
||||
### 分层错误处理
|
||||
|
||||
```
|
||||
用户操作
|
||||
↓
|
||||
giffgaff-app (捕获并显示用户友好消息)
|
||||
↓
|
||||
服务模块 (捕获并转换API错误)
|
||||
↓
|
||||
API调用 (原始错误)
|
||||
```
|
||||
|
||||
### 错误类型
|
||||
|
||||
1. **网络错误** - 显示"网络连接失败"
|
||||
2. **API错误** - 显示具体错误信息
|
||||
3. **验证错误** - 显示表单验证提示
|
||||
4. **状态错误** - 引导用户回到正确步骤
|
||||
|
||||
## 🔐 安全考虑
|
||||
|
||||
### 1. 敏感数据处理
|
||||
- Access Token仅存储在内存和localStorage
|
||||
- Cookie不在代码中硬编码
|
||||
- 定期检查Cookie有效性
|
||||
|
||||
### 2. XSS防护
|
||||
- 使用 `textContent` 而非 `innerHTML`(除非必要)
|
||||
- CSP策略限制脚本来源
|
||||
- 用户输入验证
|
||||
|
||||
### 3. CSRF防护
|
||||
- 使用PKCE流程
|
||||
- State参数验证
|
||||
- Turnstile人机验证
|
||||
|
||||
## 📈 扩展性设计
|
||||
|
||||
### 添加新的认证方式
|
||||
|
||||
1. 创建新的handler模块:
|
||||
```javascript
|
||||
// new-auth-handler.js
|
||||
export class NewAuthHandler {
|
||||
async authenticate() {
|
||||
// 实现认证逻辑
|
||||
}
|
||||
}
|
||||
export const newAuthHandler = new NewAuthHandler();
|
||||
```
|
||||
|
||||
2. 在 `giffgaff-app.js` 中集成:
|
||||
```javascript
|
||||
import { newAuthHandler } from './modules/new-auth-handler.js';
|
||||
|
||||
bindNewAuthMethod() {
|
||||
element.addEventListener('click', async () => {
|
||||
const result = await newAuthHandler.authenticate();
|
||||
// 处理结果
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的API端点
|
||||
|
||||
1. 在 `api-config.js` 中添加:
|
||||
```javascript
|
||||
export function getApiEndpoints() {
|
||||
return {
|
||||
// 现有端点...
|
||||
newEndpoint: "/bff/new-endpoint"
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. 在对应服务模块中使用:
|
||||
```javascript
|
||||
async callNewEndpoint() {
|
||||
const response = await fetch(this.apiEndpoints.newEndpoint, {
|
||||
// 请求配置
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 单元测试示例
|
||||
|
||||
```javascript
|
||||
// state-manager.test.js
|
||||
import { StateManager } from './state-manager.js';
|
||||
|
||||
describe('StateManager', () => {
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new StateManager();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test('应该正确保存和恢复会话', () => {
|
||||
manager.setState({ accessToken: 'test-token' });
|
||||
manager.saveSession();
|
||||
|
||||
const newManager = new StateManager();
|
||||
const restored = newManager.loadSession();
|
||||
|
||||
expect(restored).toBe(true);
|
||||
expect(newManager.get('accessToken')).toBe('test-token');
|
||||
});
|
||||
|
||||
test('应该在超时后清除会话', () => {
|
||||
manager.setState({ accessToken: 'test-token' });
|
||||
manager.saveSession();
|
||||
|
||||
// 模拟超时
|
||||
const sessionData = JSON.parse(localStorage.getItem('giffgaff_session'));
|
||||
sessionData.timestamp = Date.now() - (3 * 60 * 60 * 1000); // 3小时前
|
||||
localStorage.setItem('giffgaff_session', JSON.stringify(sessionData));
|
||||
|
||||
const newManager = new StateManager();
|
||||
const restored = newManager.loadSession();
|
||||
|
||||
expect(restored).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 集成测试示例
|
||||
|
||||
```javascript
|
||||
// oauth-flow.test.js
|
||||
import { oauthHandler } from './oauth-handler.js';
|
||||
import { stateManager } from './state-manager.js';
|
||||
|
||||
describe('OAuth Flow', () => {
|
||||
test('完整OAuth流程', async () => {
|
||||
// 1. 启动登录
|
||||
const loginResult = await oauthHandler.startOAuthLogin();
|
||||
expect(loginResult.success).toBe(true);
|
||||
expect(stateManager.get('codeVerifier')).toBeTruthy();
|
||||
|
||||
// 2. 模拟回调
|
||||
const mockCallback = 'giffgaff://auth/callback/?code=TEST&state=STATE';
|
||||
const callbackResult = await oauthHandler.processCallback(mockCallback);
|
||||
|
||||
expect(callbackResult.success).toBe(true);
|
||||
expect(stateManager.get('accessToken')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 📝 代码规范
|
||||
|
||||
### 命名约定
|
||||
- **类名**:PascalCase (`StateManager`)
|
||||
- **函数名**:camelCase (`getMemberInfo`)
|
||||
- **常量**:UPPER_SNAKE_CASE (`SESSION_KEY`)
|
||||
- **私有方法**:前缀下划线 (`_privateMethod`)
|
||||
|
||||
### 注释规范
|
||||
```javascript
|
||||
/**
|
||||
* 函数功能描述
|
||||
* @param {string} param1 - 参数说明
|
||||
* @returns {Promise<Object>} 返回值说明
|
||||
*/
|
||||
async functionName(param1) {
|
||||
// 实现
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
```javascript
|
||||
try {
|
||||
const result = await operation();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
throw error; // 或返回错误对象
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 部署建议
|
||||
|
||||
### 生产环境
|
||||
1. 使用Webpack/Rollup打包所有模块
|
||||
2. 启用代码压缩和混淆
|
||||
3. 使用CDN加速静态资源
|
||||
4. 启用HTTP/2服务器推送
|
||||
|
||||
### 开发环境
|
||||
1. 直接使用ES6模块(无需打包)
|
||||
2. 启用Source Map调试
|
||||
3. 使用热重载提升开发效率
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** 1.0.0
|
||||
**最后更新:** 2025-10-31
|
||||
**维护者:** eSIM Tools Team
|
||||
214
src/giffgaff/README.md
Normal file
214
src/giffgaff/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Giffgaff eSIM 工具 - 模块化版本
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 使用模块化版本
|
||||
|
||||
访问重构后的页面:
|
||||
```
|
||||
https://esim.cosr.eu.org/giffgaff-modular
|
||||
```
|
||||
|
||||
或本地开发:
|
||||
```bash
|
||||
npm start
|
||||
# 访问 http://localhost:3000/src/giffgaff/giffgaff_modular.html
|
||||
```
|
||||
|
||||
## 📂 文件说明
|
||||
|
||||
### HTML文件
|
||||
- **`giffgaff_modular.html`** - 重构后的模块化版本(推荐使用)
|
||||
- **`giffgaff_complete_esim.html`** - 原始单文件版本(备份保留)
|
||||
|
||||
### 样式文件 (`styles/`)
|
||||
- **`giffgaff-base.css`** - 基础样式和CSS变量
|
||||
- **`giffgaff-components.css`** - 组件样式(按钮、卡片等)
|
||||
- **`giffgaff-service-time.css`** - 服务时间提醒专用样式
|
||||
- **`giffgaff-animations.css`** - 所有动画效果
|
||||
- **`giffgaff-responsive.css`** - 响应式设计
|
||||
|
||||
### JavaScript模块 (`js/modules/`)
|
||||
- **`state-manager.js`** - 应用状态管理
|
||||
- **`ui-controller.js`** - UI控制和更新
|
||||
- **`oauth-handler.js`** - OAuth 2.0认证
|
||||
- **`cookie-handler.js`** - Cookie验证和监控
|
||||
- **`mfa-handler.js`** - 多因素认证
|
||||
- **`esim-service.js`** - eSIM相关操作
|
||||
- **`utils.js`** - 通用工具函数
|
||||
- **`api-config.js`** - API配置和端点
|
||||
|
||||
### 主应用
|
||||
- **`giffgaff-app.js`** - 应用入口,整合所有模块
|
||||
|
||||
## 🔄 版本切换
|
||||
|
||||
### 切换到模块化版本
|
||||
|
||||
修改 `netlify.toml`:
|
||||
```toml
|
||||
[[redirects]]
|
||||
from = "/giffgaff"
|
||||
to = "/src/giffgaff/giffgaff_modular.html"
|
||||
status = 200
|
||||
```
|
||||
|
||||
或修改 `server.js`:
|
||||
```javascript
|
||||
app.get('/giffgaff', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'src/giffgaff/giffgaff_modular.html'));
|
||||
});
|
||||
```
|
||||
|
||||
### 回退到原始版本
|
||||
|
||||
如需回退,只需将路由改回:
|
||||
```toml
|
||||
[[redirects]]
|
||||
from = "/giffgaff"
|
||||
to = "/src/giffgaff/giffgaff_complete_esim.html"
|
||||
status = 200
|
||||
```
|
||||
|
||||
## 🎨 样式定制
|
||||
|
||||
所有样式变量定义在 `giffgaff-base.css` 的 `:root` 中:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary: #ffcc00; /* 主色调 */
|
||||
--secondary: #ffffff; /* 次要色 */
|
||||
--success: #4cc9f0; /* 成功色 */
|
||||
--warning: #f72585; /* 警告色 */
|
||||
/* ... 更多变量 */
|
||||
}
|
||||
```
|
||||
|
||||
修改这些变量即可快速定制主题。
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. **确定功能归属模块**
|
||||
2. **在对应模块中添加方法**
|
||||
3. **在 `giffgaff-app.js` 中绑定事件**
|
||||
4. **更新UI(如需要)**
|
||||
|
||||
示例 - 添加新的API调用:
|
||||
|
||||
```javascript
|
||||
// 1. 在 esim-service.js 中添加方法
|
||||
async newApiCall() {
|
||||
const state = stateManager.getState();
|
||||
const response = await fetch(this.apiEndpoints.newEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accessToken: state.accessToken })
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 2. 在 giffgaff-app.js 中调用
|
||||
async handleNewFeature() {
|
||||
const result = await esimService.newApiCall();
|
||||
uiController.showStatus(element, result.message, 'success');
|
||||
}
|
||||
```
|
||||
|
||||
### 调试技巧
|
||||
|
||||
1. **查看状态**:
|
||||
```javascript
|
||||
import { stateManager } from './modules/state-manager.js';
|
||||
console.log(stateManager.getState());
|
||||
```
|
||||
|
||||
2. **监控状态变化**:
|
||||
```javascript
|
||||
stateManager.subscribe((state) => {
|
||||
console.log('状态更新:', state);
|
||||
});
|
||||
```
|
||||
|
||||
3. **浏览器开发工具**:
|
||||
- Network标签:查看API请求
|
||||
- Console:查看日志输出
|
||||
- Application → Local Storage:查看持久化数据
|
||||
|
||||
## 📋 功能清单
|
||||
|
||||
所有原有功能均已保留:
|
||||
|
||||
- ✅ OAuth 2.0 PKCE登录
|
||||
- ✅ Cookie快速登录
|
||||
- ✅ 邮件/短信MFA验证
|
||||
- ✅ 会员信息获取
|
||||
- ✅ eSIM预订
|
||||
- ✅ 短信验证码激活(推荐)
|
||||
- ✅ 手动激活流程
|
||||
- ✅ LPA获取和二维码生成
|
||||
- ✅ 会话持久化和恢复
|
||||
- ✅ Cookie有效性监控
|
||||
- ✅ 服务时间检查
|
||||
- ✅ 响应式设计
|
||||
- ✅ 无障碍支持
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题:模块加载失败
|
||||
|
||||
**症状:** 控制台显示 "Failed to load module"
|
||||
|
||||
**解决方案:**
|
||||
1. 确认服务器支持ES6模块(MIME类型:`text/javascript`)
|
||||
2. 检查文件路径是否正确
|
||||
3. 确认浏览器支持ES6模块
|
||||
|
||||
### 问题:样式未生效
|
||||
|
||||
**症状:** 页面样式混乱
|
||||
|
||||
**解决方案:**
|
||||
1. 检查CSS文件路径
|
||||
2. 确认所有CSS文件都已加载
|
||||
3. 查看浏览器控制台是否有404错误
|
||||
|
||||
### 问题:功能异常
|
||||
|
||||
**症状:** 某些功能不工作
|
||||
|
||||
**解决方案:**
|
||||
1. 对比原始版本 `giffgaff_complete_esim.html`
|
||||
2. 检查浏览器控制台错误
|
||||
3. 验证API端点配置
|
||||
4. 检查状态管理是否正常
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **[重构指南](./REFACTORING_GUIDE.md)** - 详细的重构说明
|
||||
- **[重构总结](./REFACTORING_SUMMARY.md)** - 重构成果对比
|
||||
- **[用户指南](../../docs/User_Guide.md)** - 使用教程
|
||||
- **[架构文档](../../docs/ARCHITECTURE.md)** - 项目架构
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交改进建议和Pull Request!
|
||||
|
||||
### 代码规范
|
||||
- 遵循ES6+语法
|
||||
- 使用有意义的变量名
|
||||
- 添加必要的注释
|
||||
- 保持代码简洁(KISS原则)
|
||||
|
||||
### 提交前检查
|
||||
- [ ] 代码格式化
|
||||
- [ ] 功能测试通过
|
||||
- [ ] 无控制台错误
|
||||
- [ ] 响应式布局正常
|
||||
|
||||
---
|
||||
|
||||
**版本:** v2.0.0-modular
|
||||
**最后更新:** 2025-10-31
|
||||
**维护者:** eSIM Tools Team
|
||||
671
src/giffgaff/giffgaff_modular.html
Normal file
671
src/giffgaff/giffgaff_modular.html
Normal file
@@ -0,0 +1,671 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<!-- 预连接优化 -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||
<link rel="preconnect" href="https://qrcode.show" crossorigin>
|
||||
<link rel="preconnect" href="https://api.giffgaff.com" crossorigin>
|
||||
<link rel="preconnect" href="https://id.giffgaff.com" crossorigin>
|
||||
<link rel="preconnect" href="https://publicapi.giffgaff.com" crossorigin>
|
||||
|
||||
<!-- CSP策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; font-src 'self' data: https://cdnjs.cloudflare.com; connect-src 'self' https://qrcode.show https://api.qrserver.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://challenges.cloudflare.com; img-src 'self' data: https:; frame-src 'none';">
|
||||
|
||||
<title>Giffgaff eSIM 申请工具</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#ffcc00">
|
||||
<link rel="icon" href="/src/assets/favicon.ico" type="image/x-icon">
|
||||
|
||||
<!-- 外部样式 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/src/assets/fontawesome/all.min.css">
|
||||
|
||||
<!-- 共享设计系统 -->
|
||||
<link rel="preload" href="/src/styles/design-system.css" as="style" onload="this.rel='stylesheet'">
|
||||
<link rel="preload" href="/src/styles/animations.css" as="style" onload="this.rel='stylesheet'">
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/src/styles/design-system.css">
|
||||
<link rel="stylesheet" href="/src/styles/animations.css">
|
||||
</noscript>
|
||||
|
||||
<!-- Giffgaff专用样式 -->
|
||||
<link rel="stylesheet" href="./styles/giffgaff-base.css">
|
||||
<link rel="stylesheet" href="./styles/giffgaff-components.css">
|
||||
<link rel="stylesheet" href="./styles/giffgaff-service-time.css">
|
||||
<link rel="stylesheet" href="./styles/giffgaff-animations.css">
|
||||
<link rel="stylesheet" href="./styles/giffgaff-responsive.css">
|
||||
|
||||
<!-- Service Worker 注册 -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => console.log('SW registered'))
|
||||
.catch(error => console.log('SW registration failed'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container main-container">
|
||||
<!-- 应用头部 -->
|
||||
<div class="app-header">
|
||||
<div class="logo-container">
|
||||
<i class="fas fa-sim-card app-icon" aria-hidden="true"></i>
|
||||
<div class="app-title">Giffgaff eSIM工具</div>
|
||||
</div>
|
||||
|
||||
<div class="header-buttons">
|
||||
<button class="login-btn" onclick="openTutorial()" aria-label="查看教程">
|
||||
<i class="fas fa-book" aria-hidden="true"></i> 查看教程
|
||||
</button>
|
||||
<a href="https://github.com/Silentely/eSIM-Tools" target="_blank" class="github-btn" aria-label="打开GitHub仓库">
|
||||
<i class="fab fa-github" aria-hidden="true"></i> GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开卡推广 -->
|
||||
<div class="promo-banner reveal">
|
||||
<div class="promo-content">
|
||||
<i class="fas fa-gift promo-icon"></i>
|
||||
<span class="promo-text">
|
||||
如果您还没有Giffgaff账户,可以<a href="https://www.giffgaff.com/orders/affiliate/mowal44_1653194386268" target="_blank" class="promo-link">点击这里开卡</a>,享受额外5英镑话费赠送!
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title reveal">
|
||||
<h1>Giffgaff eSIM 申请工具</h1>
|
||||
<p>将您的实体SIM卡无缝转换为eSIM</p>
|
||||
<div id="modeBadge" class="mode-badge" style="display:none" aria-live="polite">
|
||||
<i class="fas fa-exchange-alt" aria-hidden="true"></i> 设备更换模式
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务时间提示 -->
|
||||
<div id="serviceTimeAlert" class="alert mb-4 service-time-alert">
|
||||
<div class="service-time-grid">
|
||||
<div class="service-time-left">
|
||||
<div class="service-time-header">
|
||||
<div id="actionMessage" class="service-time-action-badge" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="service-time-body">
|
||||
<div class="service-time-layout">
|
||||
<div class="service-time-icon-section">
|
||||
<i id="serviceTimeIcon" class="fas" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="service-time-content-section">
|
||||
<div class="service-time-title">服务时间提醒:</div>
|
||||
<div id="serviceTimeMessage" class="service-time-message">当前时间在服务时间外,Giffgaff官方在英国时间04:30至21:30之间提供SIM交换服务。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="current-time-card" aria-label="当前时间">
|
||||
<div class="time-card-icon">
|
||||
<i class="fas fa-clock" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="time-card-text">
|
||||
<div class="time-card-label">当前时间</div>
|
||||
<div id="currentTime" class="time-card-value">--:--</div>
|
||||
<div id="ukTimeHint" class="time-card-hint">英国时间 --:--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态显示面板 -->
|
||||
<div class="status-panel card-elevated reveal" role="status" aria-live="polite">
|
||||
<h3><i class="fas fa-info-circle" aria-hidden="true"></i> 当前状态</h3>
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<span class="status-label">当前模式:</span>
|
||||
<span id="statusMode" class="status-value connected">标准流程</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Access Token:</span>
|
||||
<span id="statusAccessToken" class="status-value disconnected">未登录</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">MFA 签名:</span>
|
||||
<span id="statusMfaSignature" class="status-value disconnected">未验证</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">会员ID:</span>
|
||||
<span id="statusMemberId" class="status-value disconnected">未获取</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">eSIM状态:</span>
|
||||
<span id="statusEsimStatus" class="status-value disconnected">
|
||||
<span id="esimStatusValue"></span><br>
|
||||
<span id="esimStatusPhoneSuffix"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">激活码:</span>
|
||||
<span id="statusActivationCode" class="status-value disconnected">
|
||||
<span id="activationCodeValue"></span><br>
|
||||
<span id="activationCodeSSN"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">LPA字符串:</span>
|
||||
<span id="statusLpaString" class="status-value disconnected">未生成</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clearSessionBtn" class="clear-session-btn" aria-label="清除会话并重置所有进度">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> 清除会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤指示器 -->
|
||||
<div class="step-indicator reveal" role="list" aria-label="进度步骤">
|
||||
<div class="step active" role="listitem" aria-current="step" data-step="1">
|
||||
<div class="step-number" aria-hidden="true">1</div>
|
||||
<div class="step-text">OAuth登录</div>
|
||||
</div>
|
||||
<div class="step" data-step="2">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-text">邮件验证</div>
|
||||
</div>
|
||||
<div class="step" data-step="3">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-text">会员信息</div>
|
||||
</div>
|
||||
<div class="step" data-step="4">
|
||||
<div class="step-number">4</div>
|
||||
<div class="step-text">申请eSIM</div>
|
||||
</div>
|
||||
<div class="step" data-step="5">
|
||||
<div class="step-number">5</div>
|
||||
<div class="step-text">获取二维码</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤1: 选择登录方式 -->
|
||||
<div id="step1" class="section active reveal">
|
||||
<div class="card card-elevated">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-key"></i> 第一步:选择登录方式
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4" style="font-size: 17px; color: #212529;">
|
||||
请选择您偏好的登录方式:
|
||||
</p>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
<div class="col">
|
||||
<div id="oauthCard" class="card h-100" style="cursor: pointer;" role="button" tabindex="0" aria-label="选择 OAuth 登录">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-shield-alt fa-3x mb-3" style="color: var(--primary);"></i>
|
||||
<h5>OAuth 2.0 登录</h5>
|
||||
<p class="text-muted">安全的官方认证方式</p>
|
||||
<small class="text-success">✓ 推荐使用</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div id="cookieCard" class="card h-100" style="cursor: pointer;" role="button" tabindex="0" aria-label="选择 Cookie 登录">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-cookie-bite fa-3x mb-3" style="color: var(--warning);"></i>
|
||||
<h5>Cookie 登录</h5>
|
||||
<p class="text-muted">使用已有登录Cookie</p>
|
||||
<small class="text-info">✓ 快速便捷</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loginMethodStatus" class="status"></div>
|
||||
|
||||
<!-- OAuth登录区域 -->
|
||||
<div id="oauthLoginSection" class="mt-4" style="display: none;">
|
||||
<h5 class="mb-3">OAuth 2.0 登录</h5>
|
||||
|
||||
<div class="alert alert-warning mb-4">
|
||||
<h6><i class="fas fa-exclamation-triangle me-2"></i>如何获取回调URL:</h6>
|
||||
<ol class="mb-2">
|
||||
<li><strong>打开开发者工具</strong>:按 F12 或右键选择"检查"</li>
|
||||
<li><strong>切换到"网络"标签页</strong></li>
|
||||
<li><strong>在登录页面输入邮箱验证码</strong></li>
|
||||
<li><strong>点击登录按钮</strong></li>
|
||||
<li><strong>在网络面板中查找</strong> <code>giffgaff://auth/callback/</code> 开头的请求</li>
|
||||
<li><strong>复制完整的URL</strong> 并粘贴到下方输入框</li>
|
||||
</ol>
|
||||
<p class="mb-0"><small class="text-muted">💡 提示:如果看不到网络请求,请先打开开发者工具再进行登录操作</small></p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<h6><i class="fas fa-info-circle me-2"></i>回调URL格式示例:</h6>
|
||||
<p class="mb-2"><code>giffgaff://auth/callback/?code=ABC123&state=XYZ789</code></p>
|
||||
<p class="mb-0"><small class="text-muted">确保URL包含 <code>code</code> 和 <code>state</code> 参数</small></p>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="oauthLoginBtn" class="btn btn-primary">
|
||||
<i class="fas fa-sign-in-alt me-2"></i> 开始OAuth登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="oauthStatus" class="status" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- OAuth回调处理区域 -->
|
||||
<div id="oauthCallbackSection" class="verification-section">
|
||||
<div class="mb-4">
|
||||
<label for="callbackUrl" class="form-label">回调URL:</label>
|
||||
<textarea id="callbackUrl" class="form-control" placeholder="例如:giffgaff://auth/callback/?code=EDXgE_Uq5Q96LT5s&state=7piz0IXfUIHHoxMURfaboQ" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="processCallbackBtn" class="btn btn-primary">
|
||||
<i class="fas fa-check me-2"></i> 处理回调
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="callbackStatus" class="status mt-4" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie登录区域 -->
|
||||
<div id="cookieLoginSection" class="mt-4" style="display: none;">
|
||||
<h5 class="mb-3">Cookie 登录</h5>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<h6><i class="fas fa-info-circle me-2"></i>如何获取Cookie:</h6>
|
||||
<ol class="mb-2">
|
||||
<li><strong>访问</strong> <a href="https://www.giffgaff.com" target="_blank" rel="noopener">giffgaff.com</a> 并登录您的账户</li>
|
||||
<li><strong>打开开发者工具</strong>(Chrome:F12 或 右键→检查)</li>
|
||||
<li>切换到 <strong>Application</strong>(应用)/ <strong>Storage</strong>(存储)标签页</li>
|
||||
<li>在左侧选择 <strong>Cookies</strong> → <code>https://www.giffgaff.com</code></li>
|
||||
<li>复制右侧所有条目,拼成 <code>name=value; name2=value2; ...</code> 格式</li>
|
||||
</ol>
|
||||
<div class="mt-2">
|
||||
<strong>或</strong> 在已登录的 <code>giffgaff.com</code> 页签,打开控制台(Console)粘贴以下代码并回车:
|
||||
<pre id="cookieConsoleSnippet" class="mt-2 p-2 bg-light border rounded" style="white-space:pre-wrap;word-break:break-all;">
|
||||
;(() => {
|
||||
try {
|
||||
const out = document.cookie.split('; ').filter(Boolean).join('; ');
|
||||
console.log(out);
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(out)
|
||||
.then(() => console.log('Cookie 已复制到剪贴板'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('读取 Cookie 失败:', e);
|
||||
}
|
||||
})();
|
||||
</pre>
|
||||
<div class="text-end mt-2">
|
||||
<button id="copyConsoleSnippetBtn" type="button" class="btn btn-sm btn-warning text-dark">
|
||||
<i class="fas fa-copy me-1"></i> 复制上方代码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning p-2 mt-2">
|
||||
<small>
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||
安全提示:仅在可信设备上粘贴 Cookie。本工具不会存储您的 Cookie。
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="cookieInput" class="form-label">Cookie 字符串:</label>
|
||||
<textarea id="cookieInput" class="form-control" placeholder="输入完整的Cookie字符串" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="verifyCookieBtn" class="btn btn-warning">
|
||||
<i class="fas fa-check-circle me-2"></i> 验证Cookie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="cookieStatus" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 邮件验证 -->
|
||||
<div id="step2" class="section reveal">
|
||||
<div class="card card-elevated">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-envelope"></i> 第二步:验证码验证(邮件/短信)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4" style="font-size: 17px; color: #212529;">
|
||||
我们需要发送验证码到您的邮箱或手机进行身份验证:
|
||||
</p>
|
||||
|
||||
<div class="row g-3 align-items-end mb-2">
|
||||
<div class="col-sm-6">
|
||||
<label for="mfaChannelSelect" class="form-label">验证码接收方式</label>
|
||||
<select id="mfaChannelSelect" class="form-select">
|
||||
<option value="EMAIL" selected>邮箱(EMAIL)</option>
|
||||
<option value="TEXT">短信(TEXT)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="sendEmailBtn" class="btn btn-primary">
|
||||
<i class="fas fa-paper-plane me-2"></i> 发送验证码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="emailStatus" class="status" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- 验证码输入区域 -->
|
||||
<div id="emailVerificationSection" class="verification-section">
|
||||
<p class="mb-4" style="font-size: 17px; color: #212529; margin-top: 25px;">
|
||||
请检查您的邮箱或短信并输入收到的6位验证码:
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="emailCode" class="form-label">验证码:</label>
|
||||
<input type="text" id="emailCode" class="form-control" placeholder="输入6位验证码" maxlength="6">
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="verifyEmailBtn" class="btn btn-primary">
|
||||
<i class="fas fa-check me-2"></i> 验证验证码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="emailVerifyStatus" class="status mt-4" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3: 获取会员信息 -->
|
||||
<div id="step3" class="section reveal">
|
||||
<div class="card card-elevated">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-user"></i> 第三步:获取会员信息
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4" style="font-size: 17px; color: #212529;">
|
||||
获取您的Giffgaff会员信息:
|
||||
</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="getMemberBtn" class="btn btn-primary">
|
||||
<i class="fas fa-user-circle me-2"></i> 获取会员信息
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="memberStatus" class="status" role="status" aria-live="polite"></div>
|
||||
<div id="memberInfo" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤4: 申请eSIM -->
|
||||
<div id="step4" class="section reveal">
|
||||
<div class="card card-elevated">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-sim-card"></i> 第四步:申请/激活 eSIM
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4" style="font-size: 17px; color: #212529;">
|
||||
请选择一种方式完成 eSIM 激活:
|
||||
</p>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
<div class="col">
|
||||
<div id="manualActivateCard" class="card h-100" style="cursor:not-allowed; opacity: 0.6;" role="button" tabindex="-1" aria-label="手动激活(已停用)">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-hand-point-up fa-2x mb-2"></i>
|
||||
<h5 class="fw-bold">手动激活</h5>
|
||||
<p class="small text-muted mb-0">预订空 SIM 后,前往官网手动确认激活。</p>
|
||||
<div class="text-center mt-3 badge-spacer">
|
||||
<span class="badge bg-danger badge-pill-cta" style="pointer-events: none;">暂停使用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div id="smsActivateCard" class="card h-100" style="cursor:pointer;" role="button" tabindex="0" aria-label="选择短信验证码激活">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-sms fa-2x mb-2"></i>
|
||||
<h5 class="fw-bold">短信验证码激活</h5>
|
||||
<p class="small text-muted mb-0">系统将自动完成预订与激活。</p>
|
||||
<div class="text-center mt-3 badge-spacer">
|
||||
<span class="badge bg-success text-white badge-pill-cta">✓ 推荐使用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 短信激活内联区域 -->
|
||||
<div id="smsInlineSection" style="display:none;">
|
||||
<div class="card card-body mt-3">
|
||||
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3">
|
||||
<div style="min-width:260px;">
|
||||
<label for="smsInlineChannel" class="form-label">验证码接收方式</label>
|
||||
<select id="smsInlineChannel" class="form-select">
|
||||
<option value="TEXT" selected>短信(TEXT)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button id="smsInlineSendBtn" class="btn btn-primary">
|
||||
<i class="fas fa-paper-plane me-2"></i> 发送验证码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="smsInlineStatus" class="status mt-2" role="status" aria-live="polite"></div>
|
||||
|
||||
<div id="smsCodeInputSection" class="mt-3" style="display:none;">
|
||||
<div style="max-width:420px;">
|
||||
<label for="smsInlineCode" class="form-label">短信验证码</label>
|
||||
<input id="smsInlineCode" type="text" class="form-control" placeholder="输入6位验证码" maxlength="6" />
|
||||
</div>
|
||||
<div class="btn-group mt-3">
|
||||
<button id="smsInlineVerifyBtn" class="btn btn-primary">
|
||||
<i class="fas fa-check me-2"></i> 验证并继续激活
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动激活区块 -->
|
||||
<div id="manualBlock" class="mt-3" style="display:none;">
|
||||
<div class="btn-group">
|
||||
<button id="reserveESimBtn" class="btn btn-primary">
|
||||
<i class="fas fa-bookmark me-2"></i> 预订eSIM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 已有激活码快捷入口 -->
|
||||
<div class="mt-3">
|
||||
<button id="manualEsimInputToggle" class="btn btn-link p-0" type="button">
|
||||
我已完成预定eSIM申请(已有激活码/SSN)
|
||||
</button>
|
||||
<div id="manualEsimInputSection" class="card card-body mt-2" style="display: none;">
|
||||
<a href="https://www.giffgaff.com/activate" target="_blank" class="btn btn-link p-0 ms-2 mb-3" style="font-size: 0.9rem;">
|
||||
前往激活页面
|
||||
</a>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label for="manualActivationCode" class="form-label">激活码</label>
|
||||
<input id="manualActivationCode" class="form-control" placeholder="如:Y6GKL6" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="manualSSN" class="form-label">SSN</label>
|
||||
<input id="manualSSN" class="form-control" placeholder="如:8944…" />
|
||||
</div>
|
||||
</div>
|
||||
<button id="manualSaveAndNextBtn" class="btn btn-success mt-3" type="button">
|
||||
<i class="fas fa-check me-2"></i>保存并进入下一步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="esimReserveStatus" class="status mt-2" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- eSIM信息显示区域 -->
|
||||
<div id="esimInfoDisplay" class="mt-4" style="display: none;">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>重要:eSIM已预订成功,请立即手动激活</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-warning me-2"></i>
|
||||
<strong>请保持此网页开启状态!</strong>
|
||||
</div>
|
||||
|
||||
<h5 class="text-success mb-3 text-center" id="esimStatusTitle">您的eSIM信息</h5>
|
||||
<div class="row text-center">
|
||||
<div class="col-md-6">
|
||||
<p class="mb-2"><strong>激活码:</strong></p>
|
||||
<div class="border p-3 mb-3 bg-light">
|
||||
<code id="displayActivationCode" class="text-break fs-4"></code>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" onclick="copyTextFromCode('displayActivationCode', this)">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="mb-2"><strong>SSN:</strong></p>
|
||||
<div class="border p-3 mb-3 bg-light">
|
||||
<code id="displaySSN" class="text-break fs-5"></code>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" onclick="copyTextFromCode('displaySSN', this)">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button id="confirmActivationBtn" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-check-circle me-2"></i>我已完成手动激活,继续下一步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤5: 获取eSIM -->
|
||||
<div id="step5" class="section reveal">
|
||||
<div class="card card-elevated">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-qrcode"></i> 第五步:获取eSIM二维码
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4" style="font-size: 17px; color: #212529;">
|
||||
获取您的eSIM下载代码和二维码:
|
||||
</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="getESimTokenBtn" class="btn btn-primary">
|
||||
<i class="fas fa-download me-2"></i> 获取eSIM Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tokenStatus" class="status" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- eSIM结果显示 -->
|
||||
<div id="resultContainer" class="mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-qrcode me-2"></i> 您的eSIM信息
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div id="qrcode" class="mt-4"></div>
|
||||
<div id="esimInfo" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 外部脚本 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- 入场动效 -->
|
||||
<script>
|
||||
(function(){
|
||||
const initReveal = () => {
|
||||
const nodes = document.querySelectorAll('.reveal');
|
||||
if (!nodes.length) return;
|
||||
if ('IntersectionObserver' in window) {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('show');
|
||||
io.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.1 });
|
||||
nodes.forEach(el => io.observe(el));
|
||||
} else {
|
||||
nodes.forEach(el => el.classList.add('show'));
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initReveal, { once: true });
|
||||
} else {
|
||||
initReveal();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Cloudflare Turnstile -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
<script>
|
||||
(function initInvisibleTurnstile() {
|
||||
var siteKey = (window && window.TURNSTILE_SITE_KEY) || '0x4AAAAAABqjeVscZAiqB11B';
|
||||
function boot() {
|
||||
try {
|
||||
if (!window.turnstile || !siteKey) { setTimeout(boot, 300); return; }
|
||||
var c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
var id = window.turnstile.render(c, {
|
||||
sitekey: siteKey,
|
||||
size: 'invisible',
|
||||
callback: function (token) { window.__cfTurnstileToken = token; }
|
||||
});
|
||||
var exec = function() { try { window.turnstile.execute(id); } catch (e) {} };
|
||||
exec();
|
||||
setInterval(exec, 110000);
|
||||
} catch (e) { setTimeout(boot, 500); }
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 性能优化脚本 -->
|
||||
<script src="/src/js/performance.js"></script>
|
||||
|
||||
<!-- 主应用脚本(模块化) -->
|
||||
<script type="module" src="./js/giffgaff-app.js"></script>
|
||||
|
||||
<!-- 统一版权页脚 -->
|
||||
<script type="module" src="/src/js/bootstrap-footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
841
src/giffgaff/js/giffgaff-app.js
Normal file
841
src/giffgaff/js/giffgaff-app.js
Normal file
@@ -0,0 +1,841 @@
|
||||
/**
|
||||
* Giffgaff eSIM 工具 - 主应用入口
|
||||
* 整合所有模块,处理事件绑定和应用初始化
|
||||
*/
|
||||
|
||||
import { stateManager } from './modules/state-manager.js';
|
||||
import { uiController } from './modules/ui-controller.js';
|
||||
import { oauthHandler } from './modules/oauth-handler.js';
|
||||
import { cookieHandler } from './modules/cookie-handler.js';
|
||||
import { mfaHandler } from './modules/mfa-handler.js';
|
||||
import { esimService } from './modules/esim-service.js';
|
||||
import {
|
||||
isServiceTimeAvailable,
|
||||
showServiceTimeWarning,
|
||||
copyTextFromCode,
|
||||
showToast,
|
||||
openTutorial
|
||||
} from './modules/utils.js';
|
||||
|
||||
class GiffgaffApp {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用
|
||||
*/
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
console.log('Giffgaff eSIM申请工具初始化中...');
|
||||
|
||||
// 订阅状态变化
|
||||
stateManager.subscribe((state) => {
|
||||
uiController.updateStatusPanel();
|
||||
});
|
||||
|
||||
// 绑定事件监听器
|
||||
this.bindEventListeners();
|
||||
|
||||
// 初始化服务时间检查
|
||||
this.initServiceTimeCheck();
|
||||
|
||||
// 恢复会话
|
||||
const sessionRestored = stateManager.loadSession();
|
||||
|
||||
if (sessionRestored) {
|
||||
this.handleSessionRestore();
|
||||
} else {
|
||||
uiController.showSection(1);
|
||||
}
|
||||
|
||||
// 更新状态显示
|
||||
uiController.updateStatusPanel();
|
||||
|
||||
// 启动Cookie监控(如果有)
|
||||
if (stateManager.getCookie()) {
|
||||
cookieHandler.startValidityMonitor();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.log('Giffgaff eSIM申请工具已加载');
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件监听器
|
||||
*/
|
||||
bindEventListeners() {
|
||||
const { elements } = uiController;
|
||||
|
||||
// ===== Step 1: 登录方式选择 =====
|
||||
this.bindLoginMethodSelection();
|
||||
|
||||
// OAuth登录
|
||||
elements.oauthLoginBtn?.addEventListener('click', () => this.handleOAuthLogin());
|
||||
elements.processCallbackBtn?.addEventListener('click', () => this.handleOAuthCallback());
|
||||
|
||||
// Cookie登录
|
||||
elements.verifyCookieBtn?.addEventListener('click', () => this.handleCookieVerify());
|
||||
|
||||
// ===== Step 2: MFA验证 =====
|
||||
elements.sendEmailBtn?.addEventListener('click', () => this.handleSendMFA());
|
||||
elements.verifyEmailBtn?.addEventListener('click', () => this.handleVerifyMFA());
|
||||
|
||||
// ===== Step 3: 会员信息 =====
|
||||
elements.getMemberBtn?.addEventListener('click', () => this.handleGetMember());
|
||||
|
||||
// ===== Step 4: eSIM预订和激活 =====
|
||||
this.bindStep4Actions();
|
||||
|
||||
// ===== Step 5: 获取Token =====
|
||||
elements.getESimTokenBtn?.addEventListener('click', () => this.handleGetToken());
|
||||
|
||||
// ===== 通用操作 =====
|
||||
elements.clearSessionBtn?.addEventListener('click', () => this.handleClearSession());
|
||||
|
||||
// 步骤点击跳转
|
||||
this.bindStepNavigation();
|
||||
|
||||
// Cookie过期事件
|
||||
window.addEventListener('cookieExpired', (e) => this.handleCookieExpired(e));
|
||||
|
||||
// 复制控制台代码按钮
|
||||
this.bindCopyConsoleSnippet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定登录方式选择
|
||||
*/
|
||||
bindLoginMethodSelection() {
|
||||
const oauthCard = document.getElementById('oauthCard');
|
||||
const cookieCard = document.getElementById('cookieCard');
|
||||
|
||||
const bindCard = (el, method) => {
|
||||
if (!el || el.__bound) return;
|
||||
el.__bound = true;
|
||||
const handler = () => uiController.selectLoginMethod(method);
|
||||
el.addEventListener('click', handler);
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
bindCard(oauthCard, 'oauth');
|
||||
bindCard(cookieCard, 'cookie');
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定Step4操作
|
||||
*/
|
||||
bindStep4Actions() {
|
||||
// 激活方式卡片选择
|
||||
const manualCard = document.getElementById('manualActivateCard');
|
||||
const smsCard = document.getElementById('smsActivateCard');
|
||||
|
||||
// SMS激活卡片
|
||||
if (smsCard && !smsCard.__bound) {
|
||||
smsCard.__bound = true;
|
||||
const handler = () => {
|
||||
const smsSection = document.getElementById('smsInlineSection');
|
||||
const manualBlock = document.getElementById('manualBlock');
|
||||
if (manualBlock) manualBlock.style.display = 'none';
|
||||
if (smsSection) {
|
||||
smsSection.style.display = 'block';
|
||||
smsSection.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
}
|
||||
};
|
||||
smsCard.addEventListener('click', handler);
|
||||
smsCard.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// SMS内联流程
|
||||
this.bindSmsInlineFlow();
|
||||
|
||||
// 手动输入eSIM信息
|
||||
this.bindManualEsimInput();
|
||||
|
||||
// 预订eSIM按钮
|
||||
const reserveBtn = document.getElementById('reserveESimBtn');
|
||||
reserveBtn?.addEventListener('click', () => this.handleReserveESim());
|
||||
|
||||
// 确认激活按钮(委托绑定)
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('#confirmActivationBtn');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
uiController.showSection(5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定SMS内联流程
|
||||
*/
|
||||
bindSmsInlineFlow() {
|
||||
const sendBtn = document.getElementById('smsInlineSendBtn');
|
||||
const verifyBtn = document.getElementById('smsInlineVerifyBtn');
|
||||
|
||||
if (sendBtn && !sendBtn.__bound) {
|
||||
sendBtn.__bound = true;
|
||||
sendBtn.addEventListener('click', () => this.handleSmsSend());
|
||||
}
|
||||
|
||||
if (verifyBtn && !verifyBtn.__bound) {
|
||||
verifyBtn.__bound = true;
|
||||
verifyBtn.addEventListener('click', () => this.handleSmsVerify());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手动eSIM输入
|
||||
*/
|
||||
bindManualEsimInput() {
|
||||
const toggleBtn = document.getElementById('manualEsimInputToggle');
|
||||
const section = document.getElementById('manualEsimInputSection');
|
||||
const saveBtn = document.getElementById('manualSaveAndNextBtn');
|
||||
|
||||
if (toggleBtn && !toggleBtn.__bound) {
|
||||
toggleBtn.__bound = true;
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
section.style.display = section.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
if (saveBtn && !saveBtn.__bound) {
|
||||
saveBtn.__bound = true;
|
||||
saveBtn.addEventListener('click', () => this.handleManualEsimSave());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定步骤导航
|
||||
*/
|
||||
bindStepNavigation() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const stepEl = e.target.closest('.step-indicator .step');
|
||||
if (stepEl && stepEl.dataset && stepEl.dataset.step) {
|
||||
const target = parseInt(stepEl.dataset.step, 10);
|
||||
const currentStep = stateManager.get('currentStep');
|
||||
if (!isNaN(target) && target <= currentStep) {
|
||||
e.preventDefault();
|
||||
uiController.showSection(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定复制控制台代码按钮
|
||||
*/
|
||||
bindCopyConsoleSnippet() {
|
||||
const btn = document.getElementById('copyConsoleSnippetBtn');
|
||||
const pre = document.getElementById('cookieConsoleSnippet');
|
||||
|
||||
if (btn && pre && !btn.__bound) {
|
||||
btn.__bound = true;
|
||||
btn.addEventListener('click', () => {
|
||||
const txt = (pre.innerText || pre.textContent || '').trim();
|
||||
if (!txt) return;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(txt)
|
||||
.then(() => showToast('代码已复制到剪贴板'))
|
||||
.catch(() => {
|
||||
this.fallbackCopy(txt);
|
||||
showToast('代码已复制');
|
||||
});
|
||||
} else {
|
||||
this.fallbackCopy(txt);
|
||||
showToast('代码已复制');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级复制方法
|
||||
*/
|
||||
fallbackCopy(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
// ===== 事件处理器 =====
|
||||
|
||||
/**
|
||||
* 处理OAuth登录
|
||||
*/
|
||||
async handleOAuthLogin() {
|
||||
const { elements } = uiController;
|
||||
|
||||
// 检查服务时间
|
||||
if (!isServiceTimeAvailable()) {
|
||||
const shouldContinue = await showServiceTimeWarning();
|
||||
if (!shouldContinue) return;
|
||||
}
|
||||
|
||||
try {
|
||||
elements.oauthLoginBtn.innerHTML = '<span class="loading"></span> 准备登录...';
|
||||
elements.oauthLoginBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.oauthStatus, "正在准备OAuth登录...", "success");
|
||||
|
||||
const result = await oauthHandler.startOAuthLogin();
|
||||
|
||||
elements.oauthCallbackSection.classList.add('active');
|
||||
uiController.showStatus(elements.oauthStatus, result.message, "success");
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.oauthStatus, "OAuth登录准备失败:" + error.message, "error");
|
||||
} finally {
|
||||
elements.oauthLoginBtn.innerHTML = '<i class="fas fa-sign-in-alt me-2"></i> 开始OAuth登录';
|
||||
elements.oauthLoginBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理OAuth回调
|
||||
*/
|
||||
async handleOAuthCallback() {
|
||||
const { elements } = uiController;
|
||||
const callbackUrl = elements.callbackUrl.value.trim();
|
||||
|
||||
if (!callbackUrl) {
|
||||
uiController.showStatus(elements.callbackStatus, "请输入回调URL", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
elements.processCallbackBtn.innerHTML = '<span class="loading"></span> 处理中...';
|
||||
elements.processCallbackBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.callbackStatus, "正在处理OAuth回调...", "success");
|
||||
|
||||
const result = await oauthHandler.processCallback(callbackUrl);
|
||||
|
||||
uiController.showStatus(elements.callbackStatus, "OAuth登录成功!", "success");
|
||||
|
||||
setTimeout(() => {
|
||||
uiController.showSection(2);
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.callbackStatus, "OAuth回调处理失败:" + error.message, "error");
|
||||
} finally {
|
||||
elements.processCallbackBtn.innerHTML = '<i class="fas fa-check me-2"></i> 处理回调';
|
||||
elements.processCallbackBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Cookie验证
|
||||
*/
|
||||
async handleCookieVerify() {
|
||||
const { elements } = uiController;
|
||||
|
||||
// 检查服务时间
|
||||
if (!isServiceTimeAvailable()) {
|
||||
const shouldContinue = await showServiceTimeWarning();
|
||||
if (!shouldContinue) return;
|
||||
}
|
||||
|
||||
const cookie = elements.cookieInput.value.trim();
|
||||
if (!cookie) {
|
||||
uiController.showStatus(elements.cookieStatus, "请输入Cookie字符串", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
elements.verifyCookieBtn.innerHTML = '<span class="loading"></span> 验证中...';
|
||||
elements.verifyCookieBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.cookieStatus, "正在验证Cookie...", "success");
|
||||
|
||||
const result = await cookieHandler.verifyCookie(cookie);
|
||||
|
||||
if (result.valid) {
|
||||
uiController.showStatus(elements.cookieStatus,
|
||||
"Cookie验证成功!已获取 Access Token,接下来需要进行邮件验证", "success");
|
||||
|
||||
setTimeout(() => {
|
||||
uiController.showSection(2);
|
||||
}, 2000);
|
||||
} else if (result.partialSuccess) {
|
||||
uiController.showStatus(elements.cookieStatus, result.message, "error");
|
||||
|
||||
// 提供继续按钮
|
||||
const continueBtn = document.createElement('button');
|
||||
continueBtn.className = 'btn btn-outline-primary mt-3';
|
||||
continueBtn.style.display = 'block';
|
||||
continueBtn.style.margin = '12px auto 0';
|
||||
continueBtn.innerHTML = '<i class="fas fa-arrow-right me-2"></i> 仍要继续到下一步';
|
||||
continueBtn.onclick = () => {
|
||||
stateManager.saveCookie(cookie);
|
||||
uiController.showSection(2);
|
||||
continueBtn.remove();
|
||||
};
|
||||
|
||||
const nextSibling = elements.cookieStatus.nextElementSibling;
|
||||
if (!nextSibling || nextSibling !== continueBtn) {
|
||||
elements.cookieStatus.parentNode.insertBefore(continueBtn, elements.cookieStatus.nextSibling);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || 'Cookie验证失败');
|
||||
}
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.cookieStatus, error.message, "error");
|
||||
} finally {
|
||||
elements.verifyCookieBtn.innerHTML = '<i class="fas fa-check-circle me-2"></i> 验证Cookie';
|
||||
elements.verifyCookieBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发送MFA验证码
|
||||
*/
|
||||
async handleSendMFA() {
|
||||
const { elements } = uiController;
|
||||
|
||||
// 检查服务时间
|
||||
if (!isServiceTimeAvailable()) {
|
||||
const shouldContinue = await showServiceTimeWarning();
|
||||
if (!shouldContinue) return;
|
||||
}
|
||||
|
||||
try {
|
||||
elements.sendEmailBtn.innerHTML = '<span class="loading"></span> 发送中...';
|
||||
elements.sendEmailBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.emailStatus, "正在发送验证码...", "success");
|
||||
|
||||
const channelSelect = document.getElementById('mfaChannelSelect');
|
||||
const channel = channelSelect ? channelSelect.value : 'EMAIL';
|
||||
|
||||
const result = await mfaHandler.sendMFAChallenge(channel);
|
||||
|
||||
uiController.showStatus(elements.emailStatus, result.message + "!", "success");
|
||||
elements.emailVerificationSection.classList.add('active');
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.emailStatus, "发送验证码失败:" + error.message, "error");
|
||||
} finally {
|
||||
elements.sendEmailBtn.innerHTML = '<i class="fas fa-paper-plane me-2"></i> 发送验证码';
|
||||
elements.sendEmailBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理验证MFA验证码
|
||||
*/
|
||||
async handleVerifyMFA() {
|
||||
const { elements } = uiController;
|
||||
const code = elements.emailCode.value.trim();
|
||||
|
||||
if (!code) {
|
||||
uiController.showStatus(elements.emailVerifyStatus, "请输入验证码", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
elements.verifyEmailBtn.innerHTML = '<span class="loading"></span> 验证中...';
|
||||
elements.verifyEmailBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.emailVerifyStatus, "正在验证验证码...", "success");
|
||||
|
||||
const result = await mfaHandler.validateMFACode(code);
|
||||
|
||||
uiController.showStatus(elements.emailVerifyStatus, result.message + ",已获得签名。", "success");
|
||||
|
||||
setTimeout(() => {
|
||||
uiController.showSection(3);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.emailVerifyStatus, "验证码验证失败:" + error.message, "error");
|
||||
} finally {
|
||||
elements.verifyEmailBtn.innerHTML = '<i class="fas fa-check me-2"></i> 验证验证码';
|
||||
elements.verifyEmailBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理获取会员信息
|
||||
*/
|
||||
async handleGetMember() {
|
||||
const { elements } = uiController;
|
||||
|
||||
try {
|
||||
elements.getMemberBtn.innerHTML = '<span class="loading"></span> 获取中...';
|
||||
elements.getMemberBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.memberStatus, "正在获取会员信息...", "success");
|
||||
|
||||
const result = await esimService.getMemberInfo();
|
||||
|
||||
uiController.showStatus(elements.memberStatus, result.message + "!", "success");
|
||||
uiController.showMemberInfo(result.data);
|
||||
|
||||
setTimeout(() => {
|
||||
uiController.showSection(4);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.memberStatus, "获取会员信息失败:" + error.message, "error");
|
||||
} finally {
|
||||
elements.getMemberBtn.innerHTML = '<i class="fas fa-user-circle me-2"></i> 获取会员信息';
|
||||
elements.getMemberBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预订eSIM
|
||||
*/
|
||||
async handleReserveESim() {
|
||||
const { elements } = uiController;
|
||||
|
||||
// 检查服务时间
|
||||
if (!isServiceTimeAvailable()) {
|
||||
const shouldContinue = await showServiceTimeWarning();
|
||||
if (!shouldContinue) return;
|
||||
}
|
||||
|
||||
try {
|
||||
elements.reserveESimBtn.innerHTML = '<span class="loading"></span> 预订中...';
|
||||
elements.reserveESimBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.esimReserveStatus, "正在预订eSIM...", "success");
|
||||
|
||||
const result = await esimService.reserveESim();
|
||||
|
||||
const status = result.data.esim.deliveryStatus || 'RESERVED';
|
||||
uiController.showStatus(elements.esimReserveStatus, `eSIM预订成功!状态:${status}`, "success");
|
||||
|
||||
// 显示eSIM信息和激活指导
|
||||
uiController.showESIMInfoAndGuide();
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.esimReserveStatus, "eSIM预订失败:" + error.message, "error");
|
||||
} finally {
|
||||
elements.reserveESimBtn.innerHTML = '<i class="fas fa-bookmark me-2"></i> 预订eSIM';
|
||||
elements.reserveESimBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理SMS发送
|
||||
*/
|
||||
async handleSmsSend() {
|
||||
const sendBtn = document.getElementById('smsInlineSendBtn');
|
||||
const statusEl = document.getElementById('smsInlineStatus');
|
||||
|
||||
try {
|
||||
sendBtn.innerHTML = '<span class="loading"></span> 发送中...';
|
||||
sendBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(statusEl, '正在发送短信验证码...', 'success');
|
||||
|
||||
const result = await mfaHandler.sendSimSwapMFAChallenge();
|
||||
|
||||
uiController.showStatus(statusEl, result.message + ',请查收短信。', 'success');
|
||||
|
||||
const codeSection = document.getElementById('smsCodeInputSection');
|
||||
if (codeSection) codeSection.style.display = 'block';
|
||||
} catch (error) {
|
||||
uiController.showStatus(statusEl, '发送验证码失败:' + error.message, 'error');
|
||||
} finally {
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane me-2"></i> 发送验证码';
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理SMS验证
|
||||
*/
|
||||
async handleSmsVerify() {
|
||||
const verifyBtn = document.getElementById('smsInlineVerifyBtn');
|
||||
const codeInput = document.getElementById('smsInlineCode');
|
||||
const statusEl = document.getElementById('smsInlineStatus');
|
||||
|
||||
const code = (codeInput?.value || '').trim();
|
||||
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
uiController.showStatus(statusEl, '请输入6位数字验证码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
verifyBtn.innerHTML = '<span class="loading"></span> 验证中...';
|
||||
verifyBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(statusEl, '验证码验证成功,开始自动预订与激活...', 'success');
|
||||
|
||||
// 执行完整的SMS激活流程
|
||||
await esimService.smsActivateFlow(code);
|
||||
|
||||
uiController.showSection(5);
|
||||
uiController.showESimResult();
|
||||
} catch (error) {
|
||||
uiController.showStatus(statusEl, '短信激活失败:' + error.message, 'error');
|
||||
} finally {
|
||||
verifyBtn.innerHTML = '<i class="fas fa-check me-2"></i> 验证并继续激活';
|
||||
verifyBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理手动eSIM信息保存
|
||||
*/
|
||||
handleManualEsimSave() {
|
||||
const activationInput = document.getElementById('manualActivationCode');
|
||||
const ssnInput = document.getElementById('manualSSN');
|
||||
|
||||
const activationCode = (activationInput?.value || '').trim();
|
||||
const ssn = (ssnInput?.value || '').trim();
|
||||
|
||||
if (!activationCode) {
|
||||
const statusEl = document.getElementById('esimReserveStatus');
|
||||
uiController.showStatus(statusEl, '请输入激活码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
stateManager.setState({
|
||||
esimActivationCode: activationCode,
|
||||
esimSSN: ssn || stateManager.get('esimSSN'),
|
||||
esimDeliveryStatus: 'RESERVED'
|
||||
});
|
||||
|
||||
// 更新显示
|
||||
const title = document.getElementById('esimStatusTitle');
|
||||
if (title) title.textContent = '您的eSIM信息(状态:RESERVED)';
|
||||
|
||||
const displayActivationCode = document.getElementById('displayActivationCode');
|
||||
const displaySSN = document.getElementById('displaySSN');
|
||||
if (displayActivationCode) displayActivationCode.textContent = activationCode;
|
||||
if (displaySSN && ssn) displaySSN.textContent = ssn;
|
||||
|
||||
const info = document.getElementById('esimInfoDisplay');
|
||||
if (info) info.style.display = 'block';
|
||||
|
||||
uiController.showSection(5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理获取Token
|
||||
*/
|
||||
async handleGetToken() {
|
||||
const { elements } = uiController;
|
||||
|
||||
try {
|
||||
elements.getESimTokenBtn.innerHTML = '<span class="loading"></span> 获取中...';
|
||||
elements.getESimTokenBtn.disabled = true;
|
||||
|
||||
uiController.showStatus(elements.tokenStatus, "正在获取eSIM下载代码...", "success");
|
||||
|
||||
const state = stateManager.getState();
|
||||
const result = await esimService.getESimDownloadToken(state.esimSSN);
|
||||
|
||||
uiController.showStatus(elements.tokenStatus, result.message + "!", "success");
|
||||
uiController.showESimResult();
|
||||
} catch (error) {
|
||||
uiController.showStatus(elements.tokenStatus, "获取eSIM下载代码失败:" + error.message, "error");
|
||||
} finally {
|
||||
elements.getESimTokenBtn.innerHTML = '<i class="fas fa-download me-2"></i> 获取eSIM Token';
|
||||
elements.getESimTokenBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理清除会话
|
||||
*/
|
||||
handleClearSession() {
|
||||
if (confirm('确定要清除所有会话数据吗?这将重置所有进度。')) {
|
||||
cookieHandler.stopValidityMonitor();
|
||||
stateManager.clearSession();
|
||||
uiController.resetUI();
|
||||
uiController.showStatus(uiController.elements.loginMethodStatus, "会话已清除,请重新开始", "success");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Cookie过期
|
||||
*/
|
||||
handleCookieExpired(event) {
|
||||
uiController.showSection(1);
|
||||
uiController.selectLoginMethod('cookie');
|
||||
|
||||
if (uiController.elements.loginMethodStatus) {
|
||||
uiController.showStatus(
|
||||
uiController.elements.loginMethodStatus,
|
||||
'Cookie已失效,请重新获取并验证。',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
showToast('Cookie已失效,请在第一步重新验证。');
|
||||
|
||||
const input = document.getElementById('cookieInput');
|
||||
if (input) setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理会话恢复
|
||||
*/
|
||||
handleSessionRestore() {
|
||||
const state = stateManager.getState();
|
||||
|
||||
if (state.accessToken) {
|
||||
let targetStep = 1;
|
||||
|
||||
if (state.emailSignature) {
|
||||
if (state.memberId && (state.esimActivationCode || state.esimSSN)) {
|
||||
if (state.lpaString) {
|
||||
targetStep = 5;
|
||||
// LPA已获取,提示清理
|
||||
setTimeout(() => {
|
||||
uiController.showStatus(
|
||||
uiController.elements.tokenStatus,
|
||||
'已成功获取到 eSIM 二维码/LPA(为安全起见仅显示一次)',
|
||||
'success'
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const shouldClear = confirm('已成功获取到 eSIM 二维码/LPA。是否立即清空会话并重置?');
|
||||
if (shouldClear) {
|
||||
this.handleClearSession();
|
||||
}
|
||||
}, 100);
|
||||
}, 500);
|
||||
} else {
|
||||
targetStep = 4;
|
||||
}
|
||||
} else {
|
||||
targetStep = 3;
|
||||
}
|
||||
} else if (state.accessToken) {
|
||||
targetStep = 2;
|
||||
}
|
||||
|
||||
uiController.showSection(Math.max(targetStep, state.currentStep));
|
||||
|
||||
// 恢复eSIM信息显示
|
||||
if (state.esimActivationCode || state.esimSSN) {
|
||||
const esimInfoDisplay = document.getElementById('esimInfoDisplay');
|
||||
const displayActivationCode = document.getElementById('displayActivationCode');
|
||||
const displaySSN = document.getElementById('displaySSN');
|
||||
|
||||
if (esimInfoDisplay) {
|
||||
if (displayActivationCode) displayActivationCode.textContent = state.esimActivationCode || '未获取';
|
||||
if (displaySSN) displaySSN.textContent = state.esimSSN || '未获取';
|
||||
esimInfoDisplay.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提示恢复
|
||||
if (state.esimActivationCode || state.esimSSN) {
|
||||
const resumed = sessionStorage.getItem('gg_resumed_once');
|
||||
if (resumed !== '1') {
|
||||
sessionStorage.setItem('gg_resumed_once', '1');
|
||||
const ok = confirm('检测到已有激活码/SSN,是否继续完成eSIM激活?');
|
||||
if (ok) {
|
||||
if (state.memberId) {
|
||||
uiController.showSection(4);
|
||||
} else if (state.emailSignature) {
|
||||
uiController.showSection(3);
|
||||
} else {
|
||||
uiController.showSection(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服务时间检查
|
||||
*/
|
||||
initServiceTimeCheck() {
|
||||
this.checkServiceTime();
|
||||
|
||||
// 对齐到下一分钟整点
|
||||
const msToNextMinute = 60000 - (Date.now() % 60000);
|
||||
setTimeout(() => {
|
||||
this.checkServiceTime();
|
||||
setInterval(() => this.checkServiceTime(), 60000);
|
||||
}, msToNextMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务时间
|
||||
*/
|
||||
checkServiceTime() {
|
||||
const now = new Date();
|
||||
const currentTime = now.getHours().toString().padStart(2, '0') + ':' +
|
||||
now.getMinutes().toString().padStart(2, '0');
|
||||
const ukTime = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).format(now);
|
||||
|
||||
// 更新时间显示
|
||||
const timeElement = document.getElementById('currentTime');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = currentTime;
|
||||
timeElement.style.animation = 'none';
|
||||
timeElement.offsetHeight;
|
||||
timeElement.style.animation = 'time-update 0.5s ease-out';
|
||||
}
|
||||
|
||||
const ukHint = document.getElementById('ukTimeHint');
|
||||
if (ukHint) ukHint.textContent = `英国时间 ${ukTime}`;
|
||||
|
||||
// 更新服务时间提示
|
||||
const isOutside = !isServiceTimeAvailable();
|
||||
const alertElement = document.getElementById('serviceTimeAlert');
|
||||
const iconElement = document.getElementById('serviceTimeIcon');
|
||||
const messageElement = document.getElementById('serviceTimeMessage');
|
||||
const actionMessageElement = document.getElementById('actionMessage');
|
||||
|
||||
if (isOutside) {
|
||||
alertElement.className = 'alert alert-warning mb-4';
|
||||
iconElement.className = 'fas fa-exclamation-triangle warning';
|
||||
messageElement.innerHTML = '当前时间在服务时间外,Giffgaff官方的服务窗口为<strong>英国时间 04:30–21:30</strong>';
|
||||
actionMessageElement.innerHTML = '❌ 当前时间不能申请eSIM';
|
||||
actionMessageElement.className = 'service-time-action-badge warning';
|
||||
actionMessageElement.style.display = 'block';
|
||||
} else {
|
||||
alertElement.className = 'alert alert-success mb-4';
|
||||
iconElement.className = 'fas fa-check-circle success';
|
||||
messageElement.innerHTML = '当前时间在Giffgaff官方服务时间内(<strong>英国时间 04:30–21:30</strong>)';
|
||||
actionMessageElement.innerHTML = '✅ 当前时间可以申请eSIM';
|
||||
actionMessageElement.className = 'service-time-action-badge success';
|
||||
actionMessageElement.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建应用实例
|
||||
const app = new GiffgaffApp();
|
||||
|
||||
// 页面加载完成后初始化
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => app.init());
|
||||
} else {
|
||||
app.init();
|
||||
}
|
||||
|
||||
// 导出全局函数供HTML内联事件使用
|
||||
window.copyTextFromCode = copyTextFromCode;
|
||||
window.openTutorial = openTutorial;
|
||||
|
||||
export default app;
|
||||
114
src/giffgaff/js/modules/api-config.js
Normal file
114
src/giffgaff/js/modules/api-config.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* API配置模块
|
||||
* 定义所有API端点和OAuth配置
|
||||
*/
|
||||
|
||||
import { isNetlifyEnvironment } from './utils.js';
|
||||
|
||||
/**
|
||||
* OAuth 2.0 PKCE配置
|
||||
*/
|
||||
export const oauthConfig = {
|
||||
clientId: "4a05bf219b3985647d9b9a3ba610a9ce",
|
||||
redirectUri: "giffgaff://auth/callback/",
|
||||
authUrl: "https://id.giffgaff.com/oauth/authorize",
|
||||
tokenUrl: "/bff/giffgaff-token-exchange",
|
||||
scope: "read"
|
||||
};
|
||||
|
||||
/**
|
||||
* API端点配置
|
||||
*/
|
||||
export function getApiEndpoints() {
|
||||
const isNetlify = isNetlifyEnvironment();
|
||||
|
||||
return {
|
||||
mfaChallenge: isNetlify
|
||||
? "/bff/giffgaff-mfa-challenge"
|
||||
: "https://id.giffgaff.com/v4/mfa/challenge/me",
|
||||
mfaValidation: isNetlify
|
||||
? "/bff/giffgaff-mfa-validation"
|
||||
: "https://id.giffgaff.com/v4/mfa/validation",
|
||||
graphql: isNetlify
|
||||
? "/bff/giffgaff-graphql"
|
||||
: "https://publicapi.giffgaff.com/gateway/graphql",
|
||||
cookieVerify: "/bff/verify-cookie",
|
||||
autoActivate: "/bff/auto-activate-esim",
|
||||
smsActivate: "/bff/giffgaff-sms-activate",
|
||||
qrcode: "https://qrcode.show/"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL查询定义
|
||||
*/
|
||||
export const graphqlQueries = {
|
||||
getMemberProfile: `query getMemberProfileAndSim {
|
||||
memberProfile {
|
||||
id
|
||||
memberName
|
||||
__typename
|
||||
}
|
||||
sim {
|
||||
phoneNumber
|
||||
status
|
||||
__typename
|
||||
}
|
||||
}`,
|
||||
|
||||
reserveESim: `mutation reserveESim($input: ESimReservationInput!) {
|
||||
reserveESim: reserveESim(input: $input) {
|
||||
id
|
||||
memberId
|
||||
reservationStartDate
|
||||
reservationEndDate
|
||||
status
|
||||
esim {
|
||||
ssn
|
||||
activationCode
|
||||
deliveryStatus
|
||||
associatedMemberId
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`,
|
||||
|
||||
eSimDownloadToken: `query eSimDownloadToken($ssn: String!) {
|
||||
eSimDownloadToken(ssn: $ssn) {
|
||||
id
|
||||
host
|
||||
matchingId
|
||||
lpaString
|
||||
__typename
|
||||
}
|
||||
}`,
|
||||
|
||||
simSwapMfaChallenge: `mutation simSwapMfaChallenge {
|
||||
simSwapMfaChallenge {
|
||||
ref
|
||||
methods {
|
||||
value
|
||||
channel
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`,
|
||||
|
||||
swapSim: `mutation SwapSim($activationCode: String!, $mfaSignature: String!, $mfaRef: String!) {
|
||||
swapSim(activationCode: $activationCode, mfaSignature: $mfaSignature, mfaRef: $mfaRef) {
|
||||
old {
|
||||
ssn
|
||||
activationCode
|
||||
__typename
|
||||
}
|
||||
new {
|
||||
ssn
|
||||
activationCode
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
};
|
||||
176
src/giffgaff/js/modules/cookie-handler.js
Normal file
176
src/giffgaff/js/modules/cookie-handler.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Cookie处理模块
|
||||
* 负责Cookie验证和有效性监控
|
||||
*/
|
||||
|
||||
import { stateManager } from './state-manager.js';
|
||||
import { getApiEndpoints } from './api-config.js';
|
||||
|
||||
export class CookieHandler {
|
||||
constructor() {
|
||||
this.validityTimer = null;
|
||||
this.CHECK_INTERVAL = 5 * 60 * 1000; // 5分钟
|
||||
this.apiEndpoints = getApiEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Cookie
|
||||
*/
|
||||
async verifyCookie(cookie) {
|
||||
try {
|
||||
const response = await fetch(this.apiEndpoints.cookieVerify, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cookie })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return { valid: false, message: 'Cookie已失效' };
|
||||
}
|
||||
throw new Error(`验证失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const looksLikeJwt = typeof result.accessToken === 'string' &&
|
||||
result.accessToken.includes('.') &&
|
||||
result.accessToken.length > 200;
|
||||
|
||||
if (result.success && result.valid && looksLikeJwt) {
|
||||
// 保存访问令牌和Cookie
|
||||
stateManager.set('accessToken', result.accessToken);
|
||||
stateManager.saveCookie(cookie);
|
||||
|
||||
// 启动有效性监控
|
||||
this.startValidityMonitor();
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
accessToken: result.accessToken,
|
||||
message: 'Cookie验证成功'
|
||||
};
|
||||
} else if (result.success && !result.valid) {
|
||||
return {
|
||||
valid: false,
|
||||
partialSuccess: true,
|
||||
message: result.message || 'Cookie验证通过,但未获取到可用于API的访问令牌'
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.message || 'Cookie验证失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Cookie验证错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Cookie有效性
|
||||
*/
|
||||
async checkCookieValidity() {
|
||||
try {
|
||||
const storedCookie = stateManager.getCookie();
|
||||
if (!storedCookie) {
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiEndpoints.cookieVerify, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cookie: storedCookie })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.handleCookieExpired();
|
||||
return { valid: false };
|
||||
}
|
||||
return { transientError: true };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result && result.success && result.valid) {
|
||||
// 刷新访问令牌
|
||||
if (result.accessToken) {
|
||||
stateManager.set('accessToken', result.accessToken);
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
this.handleCookieExpired();
|
||||
return { valid: false };
|
||||
} catch (err) {
|
||||
console.error('Cookie有效性检查错误:', err);
|
||||
return { transientError: true, error: err?.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动Cookie有效性监控
|
||||
*/
|
||||
startValidityMonitor() {
|
||||
const hasCookie = !!stateManager.getCookie();
|
||||
if (!hasCookie || this.validityTimer) return;
|
||||
|
||||
// 立即检查一次
|
||||
this.checkCookieValidity();
|
||||
|
||||
// 定期检查
|
||||
this.validityTimer = setInterval(() => {
|
||||
this.checkCookieValidity();
|
||||
}, this.CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止Cookie有效性监控
|
||||
*/
|
||||
stopValidityMonitor() {
|
||||
if (this.validityTimer) {
|
||||
clearInterval(this.validityTimer);
|
||||
this.validityTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Cookie过期
|
||||
*/
|
||||
handleCookieExpired() {
|
||||
this.stopValidityMonitor();
|
||||
|
||||
// 清除Cookie和令牌
|
||||
localStorage.removeItem('giffgaff_cookie');
|
||||
stateManager.setState({
|
||||
accessToken: '',
|
||||
emailSignature: ''
|
||||
});
|
||||
|
||||
// 触发过期事件
|
||||
const event = new CustomEvent('cookieExpired', {
|
||||
detail: { message: 'Cookie已失效,请重新验证' }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面可见性变化处理
|
||||
*/
|
||||
handleVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.stopValidityMonitor();
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
if (stateManager.getCookie()) {
|
||||
this.startValidityMonitor();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const cookieHandler = new CookieHandler();
|
||||
|
||||
// 监听页面可见性变化
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
cookieHandler.handleVisibilityChange();
|
||||
});
|
||||
368
src/giffgaff/js/modules/esim-service.js
Normal file
368
src/giffgaff/js/modules/esim-service.js
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* eSIM服务模块
|
||||
* 负责eSIM相关的所有操作
|
||||
*/
|
||||
|
||||
import { stateManager } from './state-manager.js';
|
||||
import { getApiEndpoints, graphqlQueries } from './api-config.js';
|
||||
|
||||
export class ESimService {
|
||||
constructor() {
|
||||
this.apiEndpoints = getApiEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员信息
|
||||
*/
|
||||
async getMemberInfo() {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
|
||||
if (!state.accessToken) {
|
||||
throw new Error("缺少访问令牌,请重新进行OAuth认证");
|
||||
}
|
||||
|
||||
if (!state.emailSignature) {
|
||||
throw new Error("缺少邮件验证签名,请完成邮件验证");
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiEndpoints.graphql, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
mfaSignature: state.emailSignature,
|
||||
query: graphqlQueries.getMemberProfile,
|
||||
operationName: 'getMemberProfileAndSim'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`请求失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(data.errors[0].message);
|
||||
}
|
||||
|
||||
// 保存会员信息
|
||||
stateManager.setState({
|
||||
memberId: data.data.memberProfile.id,
|
||||
memberName: data.data.memberProfile.memberName,
|
||||
phoneNumber: data.data.sim.phoneNumber
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
message: '会员信息获取成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取会员信息失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预订eSIM
|
||||
*/
|
||||
async reserveESim() {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
|
||||
const response = await fetch(this.apiEndpoints.graphql, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
mfaSignature: state.emailSignature,
|
||||
query: graphqlQueries.reserveESim,
|
||||
variables: {
|
||||
input: {
|
||||
memberId: state.memberId,
|
||||
userIntent: "SWITCH"
|
||||
}
|
||||
},
|
||||
operationName: 'reserveESim'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`请求失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(data.errors[0].message);
|
||||
}
|
||||
|
||||
// 保存eSIM信息
|
||||
stateManager.setState({
|
||||
esimSSN: data.data.reserveESim.esim.ssn,
|
||||
esimActivationCode: data.data.reserveESim.esim.activationCode,
|
||||
esimDeliveryStatus: data.data.reserveESim.esim.deliveryStatus
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.data.reserveESim,
|
||||
message: 'eSIM预订成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('预订eSIM失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 交换SIM卡
|
||||
*/
|
||||
async swapSim(activationCode, mfaSignature, mfaRef) {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
|
||||
const response = await fetch(this.apiEndpoints.graphql, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${state.accessToken}`,
|
||||
'X-MFA-Signature': mfaSignature,
|
||||
'X-CF-Turnstile': window.__cfTurnstileToken || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
mfaSignature: mfaSignature,
|
||||
turnstileToken: window.__cfTurnstileToken || '',
|
||||
operationName: "SwapSim",
|
||||
variables: {
|
||||
activationCode,
|
||||
mfaSignature,
|
||||
mfaRef
|
||||
},
|
||||
query: graphqlQueries.swapSim
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`SIM交换失败: ${response.status} - ${errText}`);
|
||||
}
|
||||
|
||||
const swapData = await response.json();
|
||||
|
||||
if (swapData.errors) {
|
||||
throw new Error(swapData.errors[0]?.message || 'SIM交换失败');
|
||||
}
|
||||
|
||||
const newSim = swapData?.data?.swapSim?.new;
|
||||
if (!newSim || !newSim.ssn) {
|
||||
throw new Error('SIM交换响应异常,未找到新SIM信息');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
stateManager.setState({
|
||||
esimSSN: newSim.ssn,
|
||||
esimActivationCode: newSim.activationCode || state.esimActivationCode,
|
||||
esimDeliveryStatus: 'ACTIVE'
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: swapData.data.swapSim,
|
||||
message: 'SIM交换成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('SIM交换失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取eSIM下载Token
|
||||
*/
|
||||
async getESimDownloadToken(ssn) {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
|
||||
const response = await fetch(this.apiEndpoints.graphql, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
mfaSignature: state.emailSignature,
|
||||
query: graphqlQueries.eSimDownloadToken,
|
||||
variables: { ssn },
|
||||
operationName: 'eSimDownloadToken'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`请求失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(data.errors[0].message);
|
||||
}
|
||||
|
||||
const lpaString = data.data.eSimDownloadToken.lpaString;
|
||||
stateManager.set('lpaString', lpaString);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
lpaString,
|
||||
data: data.data.eSimDownloadToken,
|
||||
message: 'eSIM下载代码获取成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取eSIM下载Token失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动激活eSIM(通过网页)
|
||||
*/
|
||||
async autoActivateESim(activationCode) {
|
||||
try {
|
||||
const webCookie = stateManager.getCookie();
|
||||
if (!webCookie) {
|
||||
throw new Error('需要 giffgaff 官网 Cookie 才能自动激活');
|
||||
}
|
||||
|
||||
const state = stateManager.getState();
|
||||
|
||||
const response = await fetch(this.apiEndpoints.autoActivate, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
activationCode,
|
||||
cookie: webCookie,
|
||||
accessToken: state.accessToken || undefined
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`激活请求失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 更新eSIM状态
|
||||
stateManager.set('esimDeliveryStatus', 'ACTIVE');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
needsManualConfirm: result.message.includes('可能需要手动确认'),
|
||||
message: result.message
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.message || '激活失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动激活eSIM失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的短信激活流程
|
||||
*/
|
||||
async smsActivateFlow(smsCode) {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
|
||||
// 1. 验证短信验证码
|
||||
const mfaResult = await this.validateMFACodeForSwap(smsCode);
|
||||
|
||||
// 2. 预订eSIM
|
||||
const reserveResult = await this.reserveESim();
|
||||
|
||||
// 3. 执行SIM交换
|
||||
const swapResult = await this.swapSim(
|
||||
reserveResult.data.esim.activationCode,
|
||||
mfaResult.signature,
|
||||
state.emailCodeRef
|
||||
);
|
||||
|
||||
// 4. 获取LPA(轮询)
|
||||
await this.waitAndGetLPA(swapResult.data.new.ssn);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '短信激活流程完成'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('短信激活流程失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待并获取LPA
|
||||
*/
|
||||
async waitAndGetLPA(ssn, maxRetries = 10) {
|
||||
// 等待系统处理
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const result = await this.getESimDownloadToken(ssn);
|
||||
if (result.lpaString) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`获取LPA尝试 ${i + 1}/${maxRetries} 失败:`, error);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
}
|
||||
|
||||
throw new Error('获取LPA超时,请稍后在第五步手动获取');
|
||||
}
|
||||
|
||||
/**
|
||||
* 为SIM交换验证MFA验证码
|
||||
*/
|
||||
async validateMFACodeForSwap(code) {
|
||||
const state = stateManager.getState();
|
||||
|
||||
const response = await fetch(this.apiEndpoints.mfaValidation, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': state.accessToken ? `Bearer ${state.accessToken}` : undefined,
|
||||
'X-CF-Turnstile': window.__cfTurnstileToken || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
cookie: stateManager.getCookie() || undefined,
|
||||
ref: state.emailCodeRef,
|
||||
code,
|
||||
turnstileToken: window.__cfTurnstileToken || ''
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`验证失败: ${response.status} - ${errText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const signature = data.signature || '';
|
||||
stateManager.set('emailSignature', signature);
|
||||
|
||||
return { success: true, signature };
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const esimService = new ESimService();
|
||||
178
src/giffgaff/js/modules/mfa-handler.js
Normal file
178
src/giffgaff/js/modules/mfa-handler.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* MFA验证处理模块
|
||||
* 负责多因素认证流程
|
||||
*/
|
||||
|
||||
import { stateManager } from './state-manager.js';
|
||||
import { getApiEndpoints } from './api-config.js';
|
||||
|
||||
export class MFAHandler {
|
||||
constructor() {
|
||||
this.apiEndpoints = getApiEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送MFA验证码
|
||||
*/
|
||||
async sendMFAChallenge(channel = 'EMAIL') {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
|
||||
const response = await fetch(this.apiEndpoints.mfaChallenge, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': state.accessToken ? `Bearer ${state.accessToken}` : undefined
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
cookie: stateManager.getCookie() || undefined,
|
||||
source: "esim",
|
||||
preferredChannels: channel === 'TEXT' ? ['TEXT'] : ['EMAIL']
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 检查令牌是否被刷新
|
||||
if (data._tokenRefreshed && data._newAccessToken) {
|
||||
console.log('令牌已刷新');
|
||||
stateManager.set('accessToken', data._newAccessToken);
|
||||
}
|
||||
|
||||
if (data && data.ref) {
|
||||
stateManager.set('emailCodeRef', data.ref);
|
||||
return {
|
||||
success: true,
|
||||
ref: data.ref,
|
||||
message: '验证码已发送'
|
||||
};
|
||||
} else {
|
||||
throw new Error('发送验证码未成功,请稍后重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送MFA验证码失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证MFA验证码
|
||||
*/
|
||||
async validateMFACode(code) {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
const ref = state.emailCodeRef;
|
||||
|
||||
if (!ref) {
|
||||
throw new Error('缺少验证码引用,请先发送验证码');
|
||||
}
|
||||
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
throw new Error('验证码格式错误,请输入6位数字');
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiEndpoints.mfaValidation, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': state.accessToken ? `Bearer ${state.accessToken}` : undefined
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
cookie: stateManager.getCookie() || undefined,
|
||||
ref,
|
||||
code
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`验证失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.signature) {
|
||||
stateManager.set('emailSignature', data.signature);
|
||||
return {
|
||||
success: true,
|
||||
signature: data.signature,
|
||||
message: '验证码验证成功'
|
||||
};
|
||||
} else {
|
||||
throw new Error('未获取到签名');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证MFA验证码失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送SIM交换MFA验证码
|
||||
*/
|
||||
async sendSimSwapMFAChallenge() {
|
||||
try {
|
||||
const state = stateManager.getState();
|
||||
|
||||
const response = await fetch(this.apiEndpoints.graphql, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': state.accessToken ? `Bearer ${state.accessToken}` : undefined
|
||||
},
|
||||
body: JSON.stringify([{
|
||||
operationName: "simSwapMfaChallenge",
|
||||
variables: {},
|
||||
query: `mutation simSwapMfaChallenge {
|
||||
simSwapMfaChallenge {
|
||||
ref
|
||||
methods {
|
||||
value
|
||||
channel
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
}])
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`发送失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('simSwapMfaChallenge 响应:', responseData);
|
||||
|
||||
if (responseData.errors) {
|
||||
throw new Error(responseData.errors[0].message || '发送验证码失败');
|
||||
}
|
||||
|
||||
const data = responseData.data;
|
||||
if (!data || !data.simSwapMfaChallenge || !data.simSwapMfaChallenge.ref) {
|
||||
throw new Error('未返回有效的验证码引用');
|
||||
}
|
||||
|
||||
stateManager.set('emailCodeRef', data.simSwapMfaChallenge.ref);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
ref: data.simSwapMfaChallenge.ref,
|
||||
message: '验证码已发送'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('发送SIM交换验证码失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const mfaHandler = new MFAHandler();
|
||||
154
src/giffgaff/js/modules/oauth-handler.js
Normal file
154
src/giffgaff/js/modules/oauth-handler.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* OAuth处理模块
|
||||
* 负责OAuth 2.0 PKCE认证流程
|
||||
*/
|
||||
|
||||
import { stateManager } from './state-manager.js';
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from './utils.js';
|
||||
import { oauthConfig } from './api-config.js';
|
||||
|
||||
export class OAuthHandler {
|
||||
/**
|
||||
* 开始OAuth登录流程
|
||||
*/
|
||||
async startOAuthLogin() {
|
||||
try {
|
||||
// 生成PKCE参数
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
// 保存code verifier
|
||||
stateManager.set('codeVerifier', codeVerifier);
|
||||
|
||||
// 临时持久化到sessionStorage
|
||||
try {
|
||||
const pkceMap = JSON.parse(sessionStorage.getItem('gg_pkce_map') || '{}');
|
||||
pkceMap[state] = codeVerifier;
|
||||
sessionStorage.setItem('gg_pkce_map', JSON.stringify(pkceMap));
|
||||
sessionStorage.setItem('gg_oauth_last_state', state);
|
||||
} catch (e) {
|
||||
console.error('保存PKCE参数失败:', e);
|
||||
}
|
||||
|
||||
// 构建授权URL
|
||||
const authParams = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: oauthConfig.clientId,
|
||||
redirect_uri: oauthConfig.redirectUri,
|
||||
scope: oauthConfig.scope,
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
const authUrl = `${oauthConfig.authUrl}?${authParams.toString()}`;
|
||||
|
||||
// 打开登录页面
|
||||
window.open(authUrl, '_blank');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '登录页面已打开,请完成登录后复制回调URL'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('OAuth登录准备失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理OAuth回调
|
||||
*/
|
||||
async processCallback(callbackUrl) {
|
||||
try {
|
||||
// 解析回调URL
|
||||
let code, state;
|
||||
|
||||
if (callbackUrl.startsWith('giffgaff://')) {
|
||||
const match = callbackUrl.match(/[?&]code=([^&]+)/);
|
||||
const stateMatch = callbackUrl.match(/[?&]state=([^&]+)/);
|
||||
code = match ? decodeURIComponent(match[1]) : null;
|
||||
state = stateMatch ? decodeURIComponent(stateMatch[1]) : null;
|
||||
} else {
|
||||
const url = new URL(callbackUrl);
|
||||
code = url.searchParams.get('code');
|
||||
state = url.searchParams.get('state');
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("回调URL中未找到授权码");
|
||||
}
|
||||
|
||||
console.log('解析到的授权码:', code);
|
||||
console.log('解析到的状态:', state);
|
||||
|
||||
// 恢复code verifier
|
||||
let codeVerifier = stateManager.get('codeVerifier');
|
||||
if (!codeVerifier) {
|
||||
try {
|
||||
const pkceMap = JSON.parse(sessionStorage.getItem('gg_pkce_map') || '{}');
|
||||
const byState = state && pkceMap[state];
|
||||
if (byState && byState.length >= 43) {
|
||||
codeVerifier = byState;
|
||||
}
|
||||
if (!codeVerifier) {
|
||||
const savedVerifier = sessionStorage.getItem('gg_code_verifier');
|
||||
if (savedVerifier && savedVerifier.length >= 43) {
|
||||
codeVerifier = savedVerifier;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('恢复code verifier失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!codeVerifier) {
|
||||
throw new Error('会话已重置或过期:缺少 code_verifier,请重新点击"开始OAuth登录"');
|
||||
}
|
||||
|
||||
// 交换访问令牌
|
||||
const tokenResponse = await fetch(oauthConfig.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: oauthConfig.redirectUri,
|
||||
client_id: oauthConfig.clientId
|
||||
})
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token交换失败: ${tokenResponse.status}`);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
// 保存访问令牌
|
||||
stateManager.set('accessToken', tokenData.access_token);
|
||||
|
||||
// 清理临时存储
|
||||
try {
|
||||
const pkceMap = JSON.parse(sessionStorage.getItem('gg_pkce_map') || '{}');
|
||||
if (state && pkceMap[state]) {
|
||||
delete pkceMap[state];
|
||||
}
|
||||
sessionStorage.setItem('gg_pkce_map', JSON.stringify(pkceMap));
|
||||
} catch (e) {
|
||||
console.error('清理PKCE参数失败:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokenData.access_token
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('OAuth回调处理失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const oauthHandler = new OAuthHandler();
|
||||
241
src/giffgaff/js/modules/state-manager.js
Normal file
241
src/giffgaff/js/modules/state-manager.js
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 状态管理模块
|
||||
* 负责应用状态的集中管理、持久化和恢复
|
||||
*/
|
||||
|
||||
export class StateManager {
|
||||
constructor() {
|
||||
this.state = {
|
||||
// OAuth相关
|
||||
accessToken: "",
|
||||
codeVerifier: "",
|
||||
|
||||
// Cookie相关
|
||||
cookie: "",
|
||||
|
||||
// MFA相关
|
||||
emailCodeRef: "",
|
||||
emailSignature: "",
|
||||
|
||||
// 会员信息
|
||||
memberId: "",
|
||||
memberName: "",
|
||||
phoneNumber: "",
|
||||
|
||||
// eSIM相关
|
||||
esimSSN: "",
|
||||
esimActivationCode: "",
|
||||
esimDeliveryStatus: "",
|
||||
lpaString: "",
|
||||
|
||||
// 模式
|
||||
isDeviceChange: true,
|
||||
|
||||
// 步骤控制
|
||||
currentStep: 1
|
||||
};
|
||||
|
||||
this.listeners = [];
|
||||
this.SESSION_KEY = 'giffgaff_session';
|
||||
this.COOKIE_KEY = 'giffgaff_cookie';
|
||||
this.SESSION_TIMEOUT = 2 * 60 * 60 * 1000; // 2小时
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态
|
||||
*/
|
||||
getState() {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态
|
||||
*/
|
||||
setState(updates) {
|
||||
this.state = { ...this.state, ...updates };
|
||||
this.notifyListeners();
|
||||
this.saveSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个状态值
|
||||
*/
|
||||
get(key) {
|
||||
return this.state[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单个状态值
|
||||
*/
|
||||
set(key, value) {
|
||||
this.state[key] = value;
|
||||
this.notifyListeners();
|
||||
this.saveSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅状态变化
|
||||
*/
|
||||
subscribe(listener) {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器
|
||||
*/
|
||||
notifyListeners() {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(this.state);
|
||||
} catch (error) {
|
||||
console.error('状态监听器执行错误:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话到localStorage
|
||||
*/
|
||||
saveSession() {
|
||||
try {
|
||||
const sessionData = {
|
||||
accessToken: this.state.accessToken,
|
||||
emailSignature: this.state.emailSignature,
|
||||
memberId: this.state.memberId,
|
||||
memberName: this.state.memberName,
|
||||
phoneNumber: this.state.phoneNumber,
|
||||
esimSSN: this.state.esimSSN,
|
||||
esimActivationCode: this.state.esimActivationCode,
|
||||
esimDeliveryStatus: this.state.esimDeliveryStatus,
|
||||
lpaString: this.state.lpaString,
|
||||
isDeviceChange: this.state.isDeviceChange,
|
||||
currentStep: this.state.currentStep,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(this.SESSION_KEY, JSON.stringify(sessionData));
|
||||
} catch (error) {
|
||||
console.error('保存会话失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从localStorage加载会话
|
||||
*/
|
||||
loadSession() {
|
||||
try {
|
||||
const sessionData = localStorage.getItem(this.SESSION_KEY);
|
||||
if (!sessionData) return false;
|
||||
|
||||
const data = JSON.parse(sessionData);
|
||||
const now = Date.now();
|
||||
|
||||
// 检查是否超时
|
||||
if (now - data.timestamp >= this.SESSION_TIMEOUT) {
|
||||
localStorage.removeItem(this.SESSION_KEY);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 恢复状态
|
||||
this.state = {
|
||||
...this.state,
|
||||
accessToken: data.accessToken || "",
|
||||
emailSignature: data.emailSignature || "",
|
||||
memberId: data.memberId || "",
|
||||
memberName: data.memberName || "",
|
||||
phoneNumber: data.phoneNumber || "",
|
||||
esimSSN: data.esimSSN || "",
|
||||
esimActivationCode: data.esimActivationCode || "",
|
||||
esimDeliveryStatus: data.esimDeliveryStatus || "",
|
||||
lpaString: data.lpaString || "",
|
||||
isDeviceChange: typeof data.isDeviceChange === 'boolean' ? data.isDeviceChange : true,
|
||||
currentStep: data.currentStep || 1
|
||||
};
|
||||
|
||||
this.notifyListeners();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('恢复会话失败:', error);
|
||||
localStorage.removeItem(this.SESSION_KEY);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存Cookie
|
||||
*/
|
||||
saveCookie(cookie) {
|
||||
if (cookie && typeof cookie === 'string') {
|
||||
localStorage.setItem(this.COOKIE_KEY, cookie);
|
||||
this.state.cookie = cookie;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Cookie
|
||||
*/
|
||||
getCookie() {
|
||||
return localStorage.getItem(this.COOKIE_KEY) || this.state.cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除会话
|
||||
*/
|
||||
clearSession() {
|
||||
// 重置状态
|
||||
this.state = {
|
||||
accessToken: "",
|
||||
codeVerifier: "",
|
||||
cookie: "",
|
||||
emailCodeRef: "",
|
||||
emailSignature: "",
|
||||
memberId: "",
|
||||
memberName: "",
|
||||
phoneNumber: "",
|
||||
esimSSN: "",
|
||||
esimActivationCode: "",
|
||||
esimDeliveryStatus: "",
|
||||
lpaString: "",
|
||||
isDeviceChange: true,
|
||||
currentStep: 1
|
||||
};
|
||||
|
||||
// 清除存储
|
||||
localStorage.removeItem(this.SESSION_KEY);
|
||||
localStorage.removeItem(this.COOKIE_KEY);
|
||||
|
||||
// 清除Cookie
|
||||
this.eraseCookie('giffgaff_access_token');
|
||||
this.eraseCookie('giffgaff_session');
|
||||
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie操作辅助函数
|
||||
*/
|
||||
setCookie(name, value, days) {
|
||||
const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : '';
|
||||
document.cookie = `${name}=${value || ''}${expires}; path=/`;
|
||||
}
|
||||
|
||||
getCookieValue(name) {
|
||||
const nameEQ = name + '=';
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
eraseCookie(name) {
|
||||
document.cookie = `${name}=; Max-Age=-99999999; path=/`;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const stateManager = new StateManager();
|
||||
516
src/giffgaff/js/modules/ui-controller.js
Normal file
516
src/giffgaff/js/modules/ui-controller.js
Normal file
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* UI控制器模块
|
||||
* 负责UI状态更新、步骤切换、状态显示等
|
||||
*/
|
||||
|
||||
import { stateManager } from './state-manager.js';
|
||||
|
||||
export class UIController {
|
||||
constructor() {
|
||||
this.elements = this.initElements();
|
||||
this.tooltips = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化DOM元素引用
|
||||
*/
|
||||
initElements() {
|
||||
return {
|
||||
steps: document.querySelectorAll('.step'),
|
||||
sections: document.querySelectorAll('.section'),
|
||||
|
||||
// 状态显示元素
|
||||
statusMode: document.getElementById('statusMode'),
|
||||
statusAccessToken: document.getElementById('statusAccessToken'),
|
||||
statusMfaSignature: document.getElementById('statusMfaSignature'),
|
||||
statusMemberId: document.getElementById('statusMemberId'),
|
||||
statusEsimStatus: document.getElementById('statusEsimStatus'),
|
||||
statusActivationCode: document.getElementById('statusActivationCode'),
|
||||
statusLpaString: document.getElementById('statusLpaString'),
|
||||
clearSessionBtn: document.getElementById('clearSessionBtn'),
|
||||
modeBadge: document.getElementById('modeBadge'),
|
||||
|
||||
// Step 1 - 登录方式选择
|
||||
loginMethodStatus: document.getElementById('loginMethodStatus'),
|
||||
oauthLoginSection: document.getElementById('oauthLoginSection'),
|
||||
cookieLoginSection: document.getElementById('cookieLoginSection'),
|
||||
|
||||
// OAuth相关
|
||||
oauthLoginBtn: document.getElementById('oauthLoginBtn'),
|
||||
oauthStatus: document.getElementById('oauthStatus'),
|
||||
oauthCallbackSection: document.getElementById('oauthCallbackSection'),
|
||||
callbackUrl: document.getElementById('callbackUrl'),
|
||||
processCallbackBtn: document.getElementById('processCallbackBtn'),
|
||||
callbackStatus: document.getElementById('callbackStatus'),
|
||||
|
||||
// Cookie相关
|
||||
cookieInput: document.getElementById('cookieInput'),
|
||||
verifyCookieBtn: document.getElementById('verifyCookieBtn'),
|
||||
cookieStatus: document.getElementById('cookieStatus'),
|
||||
|
||||
// Step 2 - Email verification
|
||||
sendEmailBtn: document.getElementById('sendEmailBtn'),
|
||||
emailStatus: document.getElementById('emailStatus'),
|
||||
emailVerificationSection: document.getElementById('emailVerificationSection'),
|
||||
emailCode: document.getElementById('emailCode'),
|
||||
verifyEmailBtn: document.getElementById('verifyEmailBtn'),
|
||||
emailVerifyStatus: document.getElementById('emailVerifyStatus'),
|
||||
|
||||
// Step 3 - Member info
|
||||
getMemberBtn: document.getElementById('getMemberBtn'),
|
||||
memberStatus: document.getElementById('memberStatus'),
|
||||
memberInfo: document.getElementById('memberInfo'),
|
||||
|
||||
// Step 4 - eSIM reservation
|
||||
reserveESimBtn: document.getElementById('reserveESimBtn'),
|
||||
esimReserveStatus: document.getElementById('esimReserveStatus'),
|
||||
|
||||
// Step 5 - Get eSIM token and QR
|
||||
getESimTokenBtn: document.getElementById('getESimTokenBtn'),
|
||||
tokenStatus: document.getElementById('tokenStatus'),
|
||||
resultContainer: document.getElementById('resultContainer'),
|
||||
qrcode: document.getElementById('qrcode'),
|
||||
esimInfo: document.getElementById('esimInfo'),
|
||||
|
||||
// Auto activation
|
||||
autoActivateBtn: document.getElementById('autoActivateBtn'),
|
||||
autoActivateStatus: document.getElementById('autoActivateStatus')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示状态消息
|
||||
*/
|
||||
showStatus(element, message, type) {
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
element.className = `status active ${type}`;
|
||||
element.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
element.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新步骤指示器
|
||||
*/
|
||||
updateSteps(currentStep) {
|
||||
this.elements.steps.forEach((step, index) => {
|
||||
const stepNo = index + 1;
|
||||
if (stepNo <= currentStep) {
|
||||
step.classList.add('active');
|
||||
step.style.cursor = 'pointer';
|
||||
step.title = stepNo < currentStep ? '点击返回此步骤' : '';
|
||||
} else {
|
||||
step.classList.remove('active');
|
||||
step.style.cursor = 'not-allowed';
|
||||
step.title = '请按顺序完成前序步骤';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示指定步骤
|
||||
*/
|
||||
showSection(stepNumber) {
|
||||
this.elements.sections.forEach((section, index) => {
|
||||
if (index === stepNumber - 1) {
|
||||
section.classList.add('active');
|
||||
} else {
|
||||
section.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 进入第2步时重置验证码UI
|
||||
if (stepNumber === 2) {
|
||||
if (this.elements.emailStatus) {
|
||||
this.elements.emailStatus.textContent = '';
|
||||
this.elements.emailStatus.className = 'status';
|
||||
}
|
||||
if (this.elements.emailVerificationSection) {
|
||||
this.elements.emailVerificationSection.classList.remove('active');
|
||||
}
|
||||
const codeInput = document.getElementById('emailCode');
|
||||
if (codeInput) codeInput.value = '';
|
||||
}
|
||||
|
||||
stateManager.set('currentStep', stepNumber);
|
||||
this.updateSteps(stepNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择登录方式
|
||||
*/
|
||||
selectLoginMethod(method) {
|
||||
if (method === 'oauth') {
|
||||
this.elements.oauthLoginSection.style.display = 'block';
|
||||
this.elements.cookieLoginSection.style.display = 'none';
|
||||
this.showStatus(this.elements.loginMethodStatus, "已选择OAuth 2.0登录方式", "success");
|
||||
} else if (method === 'cookie') {
|
||||
this.elements.oauthLoginSection.style.display = 'none';
|
||||
this.elements.cookieLoginSection.style.display = 'block';
|
||||
this.showStatus(this.elements.loginMethodStatus, "已选择Cookie登录方式", "success");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态面板显示
|
||||
*/
|
||||
updateStatusPanel() {
|
||||
const state = stateManager.getState();
|
||||
|
||||
// 模式显示
|
||||
if (this.elements.statusMode) {
|
||||
if (state.isDeviceChange) {
|
||||
this.elements.statusMode.textContent = '设备更换';
|
||||
this.elements.statusMode.className = 'status-value connected';
|
||||
} else {
|
||||
this.elements.statusMode.textContent = '标准流程';
|
||||
this.elements.statusMode.className = 'status-value connected';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.elements.modeBadge) {
|
||||
this.elements.modeBadge.style.display = state.isDeviceChange ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
// Access Token
|
||||
if (state.accessToken) {
|
||||
this.elements.statusAccessToken.textContent = state.accessToken;
|
||||
this.elements.statusAccessToken.className = 'status-value connected';
|
||||
this.addTooltip(this.elements.statusAccessToken, state.accessToken);
|
||||
} else {
|
||||
this.elements.statusAccessToken.textContent = '未登录';
|
||||
this.elements.statusAccessToken.className = 'status-value disconnected';
|
||||
this.removeTooltip(this.elements.statusAccessToken);
|
||||
}
|
||||
|
||||
// MFA签名
|
||||
if (state.emailSignature) {
|
||||
this.elements.statusMfaSignature.textContent = state.emailSignature;
|
||||
this.elements.statusMfaSignature.className = 'status-value connected';
|
||||
this.addTooltip(this.elements.statusMfaSignature, state.emailSignature, true);
|
||||
} else {
|
||||
this.elements.statusMfaSignature.textContent = '未验证';
|
||||
this.elements.statusMfaSignature.className = 'status-value disconnected';
|
||||
this.removeTooltip(this.elements.statusMfaSignature);
|
||||
}
|
||||
|
||||
// 会员ID
|
||||
if (state.memberId) {
|
||||
this.elements.statusMemberId.textContent = state.memberId;
|
||||
this.elements.statusMemberId.className = 'status-value connected';
|
||||
this.addTooltip(this.elements.statusMemberId, state.memberId);
|
||||
} else {
|
||||
this.elements.statusMemberId.textContent = '未获取';
|
||||
this.elements.statusMemberId.className = 'status-value disconnected';
|
||||
this.removeTooltip(this.elements.statusMemberId);
|
||||
}
|
||||
|
||||
// eSIM状态
|
||||
const hasPrerequisite = !!state.memberId;
|
||||
const delivery = hasPrerequisite
|
||||
? (state.esimDeliveryStatus || (state.esimActivationCode ? 'RESERVED' : '未申请'))
|
||||
: '未申请';
|
||||
const phoneSuffix = state.phoneNumber ? `/${state.phoneNumber}` : '';
|
||||
const esimStatusText = (hasPrerequisite && delivery && delivery !== '未申请')
|
||||
? `${delivery}${phoneSuffix}`
|
||||
: '未申请';
|
||||
|
||||
this.elements.statusEsimStatus.textContent = esimStatusText;
|
||||
this.elements.statusEsimStatus.className = (hasPrerequisite && delivery && delivery !== '未申请')
|
||||
? 'status-value connected'
|
||||
: 'status-value disconnected';
|
||||
|
||||
if (hasPrerequisite && delivery && delivery !== '未申请') {
|
||||
this.addTooltip(this.elements.statusEsimStatus, esimStatusText);
|
||||
} else {
|
||||
this.removeTooltip(this.elements.statusEsimStatus);
|
||||
}
|
||||
|
||||
// 激活码
|
||||
if (state.memberId && state.esimActivationCode) {
|
||||
const text = state.esimSSN
|
||||
? `${state.esimActivationCode}/${state.esimSSN}`
|
||||
: state.esimActivationCode;
|
||||
this.elements.statusActivationCode.textContent = text;
|
||||
this.elements.statusActivationCode.className = 'status-value connected';
|
||||
this.addTooltip(this.elements.statusActivationCode, text);
|
||||
} else {
|
||||
this.elements.statusActivationCode.textContent = '未获取';
|
||||
this.elements.statusActivationCode.className = 'status-value disconnected';
|
||||
this.removeTooltip(this.elements.statusActivationCode);
|
||||
}
|
||||
|
||||
// LPA字符串
|
||||
if (state.lpaString) {
|
||||
this.elements.statusLpaString.textContent = '已获取';
|
||||
this.elements.statusLpaString.className = 'status-value connected';
|
||||
this.removeTooltip(this.elements.statusLpaString);
|
||||
} else {
|
||||
this.elements.statusLpaString.textContent = '未生成';
|
||||
this.elements.statusLpaString.className = 'status-value disconnected';
|
||||
this.removeTooltip(this.elements.statusLpaString);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Tooltip
|
||||
*/
|
||||
addTooltip(element, fullText, forceShow = false) {
|
||||
if (!element || !fullText) return;
|
||||
|
||||
const isTruncated = element.scrollWidth > element.clientWidth;
|
||||
if (!forceShow && !isTruncated) return;
|
||||
|
||||
this.removeTooltip(element);
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'tooltip';
|
||||
tooltip.textContent = fullText;
|
||||
tooltip.setAttribute('role', 'tooltip');
|
||||
tooltip.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const onEnter = (e) => this.showTooltipElement(tooltip, e);
|
||||
const onLeave = () => this.hideTooltipElement(tooltip);
|
||||
|
||||
element.addEventListener('mouseenter', onEnter);
|
||||
element.addEventListener('mouseleave', onLeave);
|
||||
element.style.cursor = 'help';
|
||||
|
||||
this.tooltips.set(element, { tooltip, onEnter, onLeave });
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Tooltip
|
||||
*/
|
||||
removeTooltip(element) {
|
||||
const data = this.tooltips.get(element);
|
||||
if (data) {
|
||||
element.removeEventListener('mouseenter', data.onEnter);
|
||||
element.removeEventListener('mouseleave', data.onLeave);
|
||||
if (data.tooltip.parentNode) {
|
||||
data.tooltip.parentNode.removeChild(data.tooltip);
|
||||
}
|
||||
this.tooltips.delete(element);
|
||||
}
|
||||
element.style.cursor = 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示Tooltip元素
|
||||
*/
|
||||
showTooltipElement(tooltip, event) {
|
||||
if (!tooltip) return;
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
|
||||
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
|
||||
let top = rect.bottom + 4;
|
||||
|
||||
if (left < 10) left = 10;
|
||||
if (left + tooltipRect.width > window.innerWidth - 10) {
|
||||
left = window.innerWidth - tooltipRect.width - 10;
|
||||
}
|
||||
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.top = top + 'px';
|
||||
|
||||
setTimeout(() => {
|
||||
tooltip.classList.add('show');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏Tooltip元素
|
||||
*/
|
||||
hideTooltipElement(tooltip) {
|
||||
if (!tooltip) return;
|
||||
tooltip.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.parentNode.removeChild(tooltip);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示会员信息
|
||||
*/
|
||||
showMemberInfo(memberData) {
|
||||
const state = stateManager.getState();
|
||||
this.elements.memberInfo.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">会员信息</h5>
|
||||
<p><strong>会员ID:</strong> ${state.memberId}</p>
|
||||
<p><strong>会员姓名:</strong> ${state.memberName}</p>
|
||||
<p><strong>手机号码:</strong> ${state.phoneNumber}</p>
|
||||
<p><strong>SIM状态:</strong> ${memberData.sim.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示eSIM信息和激活指导
|
||||
*/
|
||||
showESIMInfoAndGuide() {
|
||||
const state = stateManager.getState();
|
||||
const esimInfoDisplay = document.getElementById('esimInfoDisplay');
|
||||
const displayActivationCode = document.getElementById('displayActivationCode');
|
||||
const displaySSN = document.getElementById('displaySSN');
|
||||
const esimStatusTitle = document.getElementById('esimStatusTitle');
|
||||
|
||||
if (esimInfoDisplay && displayActivationCode && displaySSN) {
|
||||
displayActivationCode.textContent = state.esimActivationCode || '未获取';
|
||||
displaySSN.textContent = state.esimSSN || '未获取';
|
||||
|
||||
if (esimStatusTitle) {
|
||||
const deliveryStatus = state.esimDeliveryStatus || 'RESERVED';
|
||||
esimStatusTitle.textContent = `您的eSIM信息(状态:${deliveryStatus})`;
|
||||
}
|
||||
|
||||
esimInfoDisplay.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码
|
||||
*/
|
||||
generateQRCode(data) {
|
||||
const size = 300;
|
||||
const vendors = [
|
||||
(s, d) => `https://qrcode.show/${encodeURIComponent(d)}?size=${s}`,
|
||||
(s, d) => `https://quickchart.io/qr?size=${s}&text=${encodeURIComponent(d)}`,
|
||||
(s, d) => `https://chart.googleapis.com/chart?cht=qr&chs=${s}x${s}&chl=${encodeURIComponent(d)}`
|
||||
];
|
||||
let vendorIdx = 0;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'qrcode-container';
|
||||
container.style.position = 'relative';
|
||||
container.style.display = 'inline-block';
|
||||
|
||||
const img = document.createElement('img');
|
||||
const setSrc = () => { img.src = vendors[vendorIdx](size, data); };
|
||||
setSrc();
|
||||
img.setAttribute('loading','lazy');
|
||||
img.alt = 'eSIM二维码';
|
||||
img.className = 'img-fluid';
|
||||
img.style.border = '5px solid white';
|
||||
img.style.borderRadius = '12px';
|
||||
img.style.maxWidth = `${size}px`;
|
||||
|
||||
img.onerror = () => {
|
||||
if (vendorIdx < vendors.length - 1) {
|
||||
vendorIdx += 1;
|
||||
setSrc();
|
||||
} else {
|
||||
this.elements.qrcode.innerHTML = '<div class="alert alert-warning">二维码生成失败,请复制下方 LPA 字符串手动安装。</div>';
|
||||
}
|
||||
};
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'tooltip';
|
||||
tooltip.style.padding = '0';
|
||||
tooltip.style.background = 'none';
|
||||
tooltip.style.boxShadow = 'none';
|
||||
tooltip.style.willChange = 'transform';
|
||||
|
||||
const largeImg = document.createElement('img');
|
||||
largeImg.style.width = '400px';
|
||||
largeImg.style.height = '400px';
|
||||
const setLargeSrc = () => { largeImg.src = vendors[vendorIdx](400, data); };
|
||||
setLargeSrc();
|
||||
tooltip.appendChild(largeImg);
|
||||
|
||||
container.addEventListener('mouseenter', (e) => this.showTooltipElement(tooltip, e));
|
||||
container.addEventListener('mouseleave', () => this.hideTooltipElement(tooltip));
|
||||
|
||||
container.appendChild(img);
|
||||
container.appendChild(tooltip);
|
||||
this.elements.qrcode.innerHTML = '';
|
||||
this.elements.qrcode.appendChild(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示eSIM结果
|
||||
*/
|
||||
showESimResult() {
|
||||
const state = stateManager.getState();
|
||||
if (!state.lpaString) return;
|
||||
|
||||
this.elements.resultContainer.classList.add('active');
|
||||
this.generateQRCode(state.lpaString);
|
||||
|
||||
this.elements.esimInfo.innerHTML = `
|
||||
<div class="mb-3">
|
||||
<h5 class="text-primary">LPA字符串</h5>
|
||||
<p class="text-break"><small>${state.lpaString}</small></p>
|
||||
</div>
|
||||
<div class="btn-group mt-3 w-100">
|
||||
<button id="copyLpaBtn" class="btn btn-outline-dark">
|
||||
<i class="fas fa-copy me-2"></i>复制 LPA 字符串
|
||||
</button>
|
||||
<button id="downloadQrBtn" class="btn btn-primary">
|
||||
<i class="fas fa-download me-2"></i>下载二维码
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-3">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
请立即保存这些信息,页面关闭后将无法再次查看!
|
||||
</div>
|
||||
`;
|
||||
|
||||
setTimeout(() => {
|
||||
this.elements.resultContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载二维码
|
||||
*/
|
||||
downloadQRCode() {
|
||||
const img = this.elements.qrcode.querySelector('img');
|
||||
if (!img) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = img.src;
|
||||
link.download = 'giffgaff_esim_qrcode.png';
|
||||
link.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置UI
|
||||
*/
|
||||
resetUI() {
|
||||
// 重置所有表单
|
||||
document.querySelectorAll('input').forEach(input => {
|
||||
if (input.type !== 'radio' && input.type !== 'checkbox') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 重置所有状态显示
|
||||
document.querySelectorAll('.status').forEach(status => {
|
||||
status.innerHTML = '';
|
||||
status.className = 'status';
|
||||
});
|
||||
|
||||
// 重置按钮状态
|
||||
document.querySelectorAll('button').forEach(btn => {
|
||||
if (!btn.id.includes('clearSession') && !btn.onclick) {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.showSection(1);
|
||||
this.updateSteps(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const uiController = new UIController();
|
||||
242
src/giffgaff/js/modules/utils.js
Normal file
242
src/giffgaff/js/modules/utils.js
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 工具函数模块
|
||||
* 提供通用的辅助函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成PKCE Code Verifier
|
||||
*/
|
||||
export function generateCodeVerifier() {
|
||||
const array = new Uint8Array(96);
|
||||
crypto.getRandomValues(array);
|
||||
const verifier = btoa(String.fromCharCode.apply(null, array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return verifier.length >= 43 ? verifier.substr(0, 128) : verifier + 'a'.repeat(43 - verifier.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成PKCE Code Challenge
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier) {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
||||
return btoa(String.fromCharCode.apply(null, new Uint8Array(hash)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机State
|
||||
*/
|
||||
export function generateState() {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode.apply(null, array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在服务时间内(英国时间 04:30-21:30)
|
||||
*/
|
||||
export function isServiceTimeAvailable() {
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/London',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).formatToParts(now);
|
||||
|
||||
const ukHour = parseInt((parts.find(p => p.type === 'hour') || {}).value || '0', 10);
|
||||
const ukMinute = parseInt((parts.find(p => p.type === 'minute') || {}).value || '0', 10);
|
||||
|
||||
const minutesSinceMidnight = ukHour * 60 + ukMinute;
|
||||
const start = 4 * 60 + 30; // 04:30
|
||||
const end = 21 * 60 + 30; // 21:30
|
||||
|
||||
return minutesSinceMidnight >= start && minutesSinceMidnight <= end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示服务时间警告对话框
|
||||
*/
|
||||
export function showServiceTimeWarning() {
|
||||
return new Promise((resolve) => {
|
||||
const warningMessage = `
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">⚠️</div>
|
||||
<h4 style="color: #dc2626; margin-bottom: 15px;">非SIM申请服务时间</h4>
|
||||
<p style="color: #374151; margin-bottom: 20px; line-height: 1.6;">
|
||||
服务窗口:<strong>英国时间 04:30–21:30</strong><br>
|
||||
建议在窗口内进行 eSIM 申请与交换。
|
||||
</p>
|
||||
<p style="color: #dc2626; font-weight: 600; margin-bottom: 20px;">
|
||||
❌ 如果继续操作将导致申请失败!
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; justify-content: center;">
|
||||
<button id="continueAnyway" style="
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
" onmouseover="this.style.background='#b91c1c'" onmouseout="this.style.background='#dc2626'">仍要继续</button>
|
||||
<button id="cancelOperation" style="
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
" onmouseover="this.style.background='#4b5563'" onmouseout="this.style.background='#6b7280'">取消操作</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.style.cssText = `
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
`;
|
||||
modalContent.innerHTML = warningMessage;
|
||||
|
||||
modal.appendChild(modalContent);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('continueAnyway').addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
document.getElementById('cancelOperation').addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制到剪贴板
|
||||
*/
|
||||
export function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示Toast通知
|
||||
*/
|
||||
export function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-notification';
|
||||
toast.innerHTML = `
|
||||
<div class="toast-content">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从code元素复制文本
|
||||
*/
|
||||
export function copyTextFromCode(codeElementId, btnEl) {
|
||||
const el = document.getElementById(codeElementId);
|
||||
if (!el) return;
|
||||
|
||||
const text = (el.textContent || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
copyToClipboard(text).then(() => {
|
||||
if (btnEl) {
|
||||
const old = btnEl.innerHTML;
|
||||
btnEl.innerHTML = '<i class="fas fa-check"></i> 已复制';
|
||||
btnEl.classList.add('btn-success');
|
||||
btnEl.classList.remove('btn-outline-primary');
|
||||
setTimeout(() => {
|
||||
btnEl.innerHTML = old;
|
||||
btnEl.classList.remove('btn-success');
|
||||
btnEl.classList.add('btn-outline-primary');
|
||||
}, 1500);
|
||||
}
|
||||
}).catch((e) => {
|
||||
console.error('复制失败:', e);
|
||||
alert('复制失败,请手动选择文本复制');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开教程
|
||||
*/
|
||||
export function openTutorial() {
|
||||
window.open('https://github.com/Silentely/eSIM-Tools/blob/main/docs/User_Guide.md', '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测环境
|
||||
*/
|
||||
export function isNetlifyEnvironment() {
|
||||
return window.location.hostname.includes('cosr.eu.org') ||
|
||||
window.location.hostname.includes('netlify');
|
||||
}
|
||||
148
src/giffgaff/styles/giffgaff-animations.css
Normal file
148
src/giffgaff/styles/giffgaff-animations.css
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Giffgaff eSIM 工具 - 动画样式
|
||||
* 包含:所有动画效果和过渡动画
|
||||
*/
|
||||
|
||||
/* ===== 基础动画 ===== */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* ===== 推广横幅动画 ===== */
|
||||
@keyframes subtle-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.02); }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-5px); }
|
||||
60% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
/* ===== 服务时间动画 ===== */
|
||||
@keyframes breathing-light {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(220, 38, 38, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(220, 38, 38, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathing-light-orange {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(245, 158, 11, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(245, 158, 11, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathing-light-green {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(22, 163, 74, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(22, 163, 74, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathing-light-purple {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(99, 102, 241, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes warning-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes success-bounce {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes time-update {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes time-breathe {
|
||||
0%, 100% { transform: translateZ(0) scale(1); }
|
||||
50% { transform: translateZ(0) scale(1.02); }
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ===== 服务时间图标过渡 ===== */
|
||||
#serviceTimeIcon {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
135
src/giffgaff/styles/giffgaff-base.css
Normal file
135
src/giffgaff/styles/giffgaff-base.css
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Giffgaff eSIM 工具 - 基础样式
|
||||
* 包含:CSS变量、基础布局、容器样式
|
||||
*/
|
||||
|
||||
:root {
|
||||
--primary: #ffcc00;
|
||||
--secondary: #ffffff;
|
||||
--success: #4cc9f0;
|
||||
--warning: #f72585;
|
||||
--light: #f8f9fa;
|
||||
--dark: #212529;
|
||||
--gray: #6c757d;
|
||||
|
||||
/* 服务时间样式变量 */
|
||||
--st-gap: 20px;
|
||||
--st-radius: 16px;
|
||||
--st-card-radius: 18px;
|
||||
--st-card-shadow: 0 8px 28px rgba(20, 20, 43, 0.12);
|
||||
--st-soft-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
--st-border: 1px solid rgba(0,0,0,0.06);
|
||||
--st-muted: #64748b;
|
||||
--st-strong: #0f172a;
|
||||
--st-accent: #6366f1;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #fff7e6 0%, #ffefcc 40%, #ffe8b3 100%);
|
||||
color: #212529;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 全局焦点样式 */
|
||||
:focus-visible {
|
||||
outline: 2px solid #4c9aff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
.btn:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(76,154,255,0.35);
|
||||
}
|
||||
|
||||
/* 表单元素 */
|
||||
textarea, input {
|
||||
width: 100%;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(255, 204, 0, 0.3);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #212529;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus, input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px rgba(255, 204, 0, 0.2);
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 204, 0, 0.3);
|
||||
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 30px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 204, 0, 0.5);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: rgba(255, 204, 0, 0.1);
|
||||
padding: 20px 25px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 204, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-header i {
|
||||
margin-right: 12px;
|
||||
font-size: 28px;
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 状态面板 */
|
||||
.status-panel {
|
||||
background: linear-gradient(135deg, #fffff0 0%, #fffbe4 90%);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.status-panel h3 {
|
||||
color: #495057;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
567
src/giffgaff/styles/giffgaff-components.css
Normal file
567
src/giffgaff/styles/giffgaff-components.css
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Giffgaff eSIM 工具 - 组件样式
|
||||
* 包含:头部、按钮、步骤指示器、状态显示等组件
|
||||
*/
|
||||
|
||||
/* ===== 应用头部 ===== */
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 10px;
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(to right, #ffcc00, #4361ee);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* ===== 按钮样式 ===== */
|
||||
.login-btn {
|
||||
background: linear-gradient(to right, #ffcc00, #ffffff);
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
color: #212529;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 30px;
|
||||
transition: all 0.3s;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(255, 204, 0, 0.3);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: linear-gradient(to right, #ffffff, #ffcc00);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
|
||||
border-color: rgba(255, 204, 0, 0.5);
|
||||
}
|
||||
|
||||
.github-btn {
|
||||
background: linear-gradient(to right, #333333, #666666);
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 30px;
|
||||
transition: all 0.3s;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(51, 51, 51, 0.3);
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-btn:hover {
|
||||
background: linear-gradient(to right, #666666, #333333);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
|
||||
border-color: rgba(51, 51, 51, 0.5);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 16px 25px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(to right, #ffcc00, #ffffff);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(to right, #ffffff, #ffcc00);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.clear-session-btn {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.clear-session-btn:hover {
|
||||
background: linear-gradient(135deg, #c82333 0%, #a71e2a 100%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ===== 页面标题 ===== */
|
||||
.page-title {
|
||||
text-align: center;
|
||||
margin: 30px 0 40px;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
letter-spacing: -0.5px;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: rgba(33, 37, 41, 0.7);
|
||||
font-size: 18px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ===== 步骤指示器 ===== */
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto 40px;
|
||||
position: relative;
|
||||
max-width: 820px;
|
||||
counter-reset: step;
|
||||
padding: 16px 24px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #f8fafc, #eef2f7);
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
width: 120px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #fffdf7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
font-size: 20px;
|
||||
color: #212529;
|
||||
border: 2px solid rgba(255, 193, 7, 0.55);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 193, 7, 0.18);
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: linear-gradient(135deg, #ffcc00, #ffd84d);
|
||||
color: white;
|
||||
box-shadow: 0 6px 16px rgba(255, 193, 7, 0.45);
|
||||
border-color: rgba(255, 193, 7, 0.8);
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
color: #212529;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== 状态显示 ===== */
|
||||
.status {
|
||||
margin-top: 25px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: none;
|
||||
font-size: 16px;
|
||||
border-left: 4px solid transparent;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
border-left: 4px solid var(--warning);
|
||||
}
|
||||
|
||||
/* ===== 状态网格 ===== */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
color: #495057;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-value:hover {
|
||||
background-color: rgba(255, 204, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-value.connected {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-value.disconnected {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== 模式徽标 ===== */
|
||||
.mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #7c2d12;
|
||||
background: linear-gradient(135deg, #fff7d6, #ffe9a6);
|
||||
border: 1px solid #ffd24d;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* ===== 推广横幅 ===== */
|
||||
.promo-banner {
|
||||
background: linear-gradient(135deg, #ffcc00, #ffd633);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 4px 15px rgba(255, 204, 0, 0.3);
|
||||
animation: subtle-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.promo-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promo-icon {
|
||||
color: #212529;
|
||||
font-size: 20px;
|
||||
animation: bounce 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.promo-text {
|
||||
color: #212529;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.promo-link {
|
||||
color: #212529;
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.promo-link:hover {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ===== 章节控制 ===== */
|
||||
.section {
|
||||
display: none;
|
||||
animation: slideIn 0.5s ease;
|
||||
}
|
||||
|
||||
.section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.verification-section {
|
||||
margin-top: 25px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.verification-section.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
/* ===== 表单标签 ===== */
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* ===== Tooltip提示框 ===== */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-width: 400px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tooltip.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.qrcode-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.qrcode-container:hover .tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ===== 加载指示器 ===== */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 50%;
|
||||
border-top-color: #000;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== Toast通知 ===== */
|
||||
.toast-notification {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 15px 25px;
|
||||
border-radius: 50px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.toast-notification.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-content i {
|
||||
color: #4cc9f0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ===== 网络状态指示器 ===== */
|
||||
.network-status {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.network-status.success {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
}
|
||||
|
||||
.network-status.warning {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
}
|
||||
|
||||
.network-status.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ===== 更新通知 ===== */
|
||||
.update-notification {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.3);
|
||||
z-index: 10000;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ===== 加载内容 ===== */
|
||||
.loading-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.loading-content p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== 徽章样式 ===== */
|
||||
.badge-pill-cta {
|
||||
font-size: 1.15rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 1px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-spacer {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* ===== 触摸反馈 ===== */
|
||||
.touch-active {
|
||||
transform: scale(0.98) !important;
|
||||
transition: transform 0.1s ease !important;
|
||||
}
|
||||
|
||||
/* ===== 结果容器 ===== */
|
||||
#resultContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#resultContainer.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
191
src/giffgaff/styles/giffgaff-responsive.css
Normal file
191
src/giffgaff/styles/giffgaff-responsive.css
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Giffgaff eSIM 工具 - 响应式样式
|
||||
* 包含:移动端适配、平板适配等响应式设计
|
||||
*/
|
||||
|
||||
/* ===== 平板设备 (max-width: 992px) ===== */
|
||||
@media (max-width: 992px) {
|
||||
.service-time-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.service-time-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service-time-body {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.service-time-title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.service-time-layout {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.service-time-icon-section {
|
||||
padding-top: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.service-time-content-section {
|
||||
padding-left: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.service-time-message {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.service-time-action-badge {
|
||||
align-self: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.current-time-card {
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.time-card-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.time-card-icon i {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.time-card-value {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 移动设备 (max-width: 768px) ===== */
|
||||
@media (max-width: 768px) {
|
||||
.app-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.step {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 15px;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
textarea, input {
|
||||
padding: 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.login-btn, .github-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 小屏移动设备 (max-width: 480px) ===== */
|
||||
@media (max-width: 480px) {
|
||||
.step-indicator {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.step {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.login-btn, .github-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.service-time-grid {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.service-time-left {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.service-time-header {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.service-time-action-badge {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.time-card-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
257
src/giffgaff/styles/giffgaff-service-time.css
Normal file
257
src/giffgaff/styles/giffgaff-service-time.css
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Giffgaff eSIM 工具 - 服务时间样式
|
||||
* 包含:服务时间提醒、时间卡片等专用样式
|
||||
*/
|
||||
|
||||
/* ===== 服务时间提醒布局 ===== */
|
||||
.service-time-alert {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 屏蔽 Bootstrap alert 默认样式 */
|
||||
#serviceTimeAlert.alert,
|
||||
#serviceTimeAlert.alert-success,
|
||||
#serviceTimeAlert.alert-warning {
|
||||
--bs-alert-bg: transparent;
|
||||
--bs-alert-border-color: transparent;
|
||||
--bs-alert-color: inherit;
|
||||
background-color: transparent !important;
|
||||
border-color: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.service-time-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 0.8fr;
|
||||
gap: var(--st-gap);
|
||||
padding: 18px;
|
||||
border-radius: var(--st-radius);
|
||||
background: linear-gradient(135deg, #fffaf0 0%, #fff8e0 100%);
|
||||
border: 1px solid rgba(255, 193, 7, 0.25);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ===== 左侧内容区 ===== */
|
||||
.service-time-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #fff3d6 60%, #fffaf0 60%);
|
||||
border: 1px solid rgba(255, 193, 7, 0.25);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.service-time-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.service-time-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.service-time-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.service-time-icon-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.service-time-content-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.service-time-title {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--st-strong);
|
||||
letter-spacing: 0.2px;
|
||||
text-align: left;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.service-time-message {
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
text-align: left;
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ===== 服务时间图标 ===== */
|
||||
#serviceTimeIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #e6f6ee;
|
||||
color: #10b981;
|
||||
font-size: 36px;
|
||||
box-shadow: inset 0 0 0 2px rgba(16,185,129,0.15);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#serviceTimeIcon.warning {
|
||||
background: #e6f6ee;
|
||||
color: #10b981;
|
||||
box-shadow: inset 0 0 0 2px rgba(16,185,129,0.15);
|
||||
animation: breathing-light-green 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#serviceTimeIcon.success {
|
||||
background: #e6f6ee;
|
||||
color: #10b981;
|
||||
box-shadow: inset 0 0 0 2px rgba(16,185,129,0.15);
|
||||
animation: breathing-light-green 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#serviceTimeIcon:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.alert-warning #serviceTimeIcon {
|
||||
animation: warning-pulse 2s ease-in-out infinite;
|
||||
background: #f8ecbb;
|
||||
color: #f59d06;
|
||||
box-shadow: inset 0 0 0 2px rgba(245,158,11,0.15);
|
||||
}
|
||||
|
||||
.alert-success #serviceTimeIcon {
|
||||
animation: breathing-light-green 2s ease-in-out infinite;
|
||||
background: #e0f6eb;
|
||||
color: #10b981;
|
||||
box-shadow: inset 0 0 0 2px rgba(16,185,129,0.15);
|
||||
}
|
||||
|
||||
/* ===== 操作徽章 ===== */
|
||||
.service-time-action-badge {
|
||||
padding: 10px 18px;
|
||||
border-radius: 20px;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.service-time-action-badge.success {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border: 2px solid #16a34a;
|
||||
animation: breathing-light-green 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.service-time-action-badge.warning {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 2px solid #dc2626;
|
||||
font-weight: 800;
|
||||
text-shadow: none;
|
||||
animation: breathing-light 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.service-time-action-badge:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ===== 时间卡片 ===== */
|
||||
.current-time-card {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--st-card-radius);
|
||||
background: linear-gradient(135deg, #fffaf0 60%, #fff3d6 60%);
|
||||
box-shadow: var(--st-card-shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.current-time-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.6), rgba(255,255,255,0));
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.time-card-icon {
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #eaeaff, #e0e7ff);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: inset 0 0 0 1px rgba(99,102,241,0.22);
|
||||
animation: breathing-light-purple 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.time-card-icon i {
|
||||
color: var(--st-accent);
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.time-card-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-card-label {
|
||||
font-size: 18px;
|
||||
color: var(--st-muted);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.time-card-value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1.2px;
|
||||
color: #0b1324;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.6);
|
||||
animation: time-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.time-card-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
Reference in New Issue
Block a user