feat(giffgaff): 重构eSIM工具为模块化架构

将原有的单文件实现拆分为模块化结构,提升代码可维护性和扩展性。
新增模块包括状态管理、UI控制、认证处理和eSIM服务等。
同时保留原始版本作为备份,支持通过路由切换访问不同版本。

新增MODULE_ARCHITECTURE.md详细说明模块设计与数据流向。
更新README文档,提供模块化版本使用指南和开发建议。
调整Netlify和服务器路由配置,支持新版和旧版页面共存。
This commit is contained in:
Abner
2025-10-31 23:30:45 +08:00
parent 1114719664
commit 0de9083f4b
19 changed files with 5643 additions and 1 deletions

View File

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

View File

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

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

View 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>ChromeF12 或 右键→检查)</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>

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

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

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

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

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

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

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

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

View 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:3021: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');
}

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

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

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

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

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