Initial commit - TOTP Authenticator for Xiaomi Band 10
This commit is contained in:
commit
b7ef931edf
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl:*)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git add:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
7
.eslintignore
Normal file
7
.eslintignore
Normal file
@ -0,0 +1,7 @@
|
||||
/.nyc_output
|
||||
/coverage
|
||||
/node_modules
|
||||
/tests/fixtures
|
||||
/sign
|
||||
/dist
|
||||
/build
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/.nyc_output
|
||||
/coverage
|
||||
/node_modules
|
||||
/sign
|
||||
/dist
|
||||
/build
|
||||
.husky/
|
||||
16
.npmignore
Normal file
16
.npmignore
Normal file
@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
*.log
|
||||
*.iml
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
.nyc_output
|
||||
/coverage
|
||||
|
||||
/logs
|
||||
22
.prettierrc.js
Normal file
22
.prettierrc.js
Normal file
@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
printWidth: 100, // 指定代码长度,超出换行
|
||||
tabWidth: 2, // tab 键的宽度
|
||||
useTabs: false, // 使用空格替代tab
|
||||
semi: false, // 结尾加上分号
|
||||
singleQuote: false, // 使用单引号
|
||||
quoteProps: "consistent", // 要求对象字面量属性是否使用引号包裹,(‘as-needed’: 没有特殊要求,禁止使用,'consistent': 保持一致 , preserve: 不限制,想用就用)
|
||||
trailingComma: "none", // 不添加对象和数组最后一个元素的逗号
|
||||
bracketSpacing: false, // 对象中对空格和空行进行处理
|
||||
jsxBracketSameLine: false, // 在多行JSX元素的最后一行追加 >
|
||||
requirePragma: false, // 是否严格按照文件顶部的特殊注释格式化代码
|
||||
insertPragma: false, // 是否在格式化的文件顶部插入Pragma标记,以表明该文件被prettier格式化过了
|
||||
proseWrap: "preserve", // 按照文件原样折行
|
||||
htmlWhitespaceSensitivity: "ignore", // html文件的空格敏感度,控制空格是否影响布局
|
||||
endOfLine: "auto", // 结尾是 \n \r \n\r auto
|
||||
overrides: [
|
||||
{
|
||||
files: "*.ux",
|
||||
options: {parser: "vue"}
|
||||
}
|
||||
]
|
||||
}
|
||||
44
.stylelintrc.js
Normal file
44
.stylelintrc.js
Normal file
@ -0,0 +1,44 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-recess-order"
|
||||
// "stylelint-selector-bem-pattern"
|
||||
],
|
||||
ignoreFiles: ["node_modules", "test", "dist", "**/*.js"],
|
||||
rules: {
|
||||
"no-descending-specificity": null,
|
||||
"color-hex-case": "lower",
|
||||
"color-hex-length": "short",
|
||||
"at-rule-no-unknown": null,
|
||||
"block-no-empty": null,
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ["blur"]
|
||||
}
|
||||
],
|
||||
"property-no-unknown": [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: [
|
||||
"placeholder-color",
|
||||
"gradient-start",
|
||||
"gradient-center",
|
||||
"gradient-end",
|
||||
"caret-color",
|
||||
"selected-color",
|
||||
"block-color"
|
||||
]
|
||||
}
|
||||
],
|
||||
"max-line-length": null,
|
||||
// "indentation": 2,
|
||||
// "no-empty-source": null,
|
||||
"selector-type-no-unknown": [
|
||||
true,
|
||||
{
|
||||
ignoreTypes: ["selected-color", "block-color"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
202
README.md
Normal file
202
README.md
Normal file
@ -0,0 +1,202 @@
|
||||
# TOTP验证器 - 小米手环10版
|
||||
|
||||
一个专为小米手环10设计的TOTP(时间一次性密码)验证器应用,支持生成双因素认证码。
|
||||
|
||||
## 🎯 项目简介
|
||||
|
||||
本项目是一个运行在小米手环10上的验证器应用,支持生成TOTP和Steam验证码。通过简洁的界面和触感反馈,为用户提供便捷的二步验证体验。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 🔐 **多种验证码支持**:支持标准TOTP和Steam验证码算法
|
||||
- ⏰ **实时倒计时**:弧形进度条显示验证码剩余有效时间
|
||||
- 📱 **响应式界面**:针对手环屏幕尺寸优化的用户界面
|
||||
- 📳 **触感反馈**:验证码更新、页面切换时的震动提醒
|
||||
- 🔄 **自动刷新**:验证码自动更新,无需手动操作
|
||||
- 📊 **详情查看**:大字体显示验证码,方便查看
|
||||
- ⚡ **性能优化**:针对手环设备的电量和性能优化
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **开发框架**:快应用 (QuickApp)
|
||||
- **UI技术**:UX模板语言
|
||||
- **加密算法**:HMAC-SHA1, Base32编码
|
||||
- **依赖库**:crypto-js
|
||||
- **构建工具**:aiot-toolkit
|
||||
|
||||
## 📦 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.ux # 应用入口文件
|
||||
├── manifest.json # 应用配置清单
|
||||
├── common/
|
||||
│ ├── accounts.json # 账户配置文件
|
||||
│ └── logo.png # 应用图标
|
||||
├── pages/
|
||||
│ └── index/
|
||||
│ └── index.ux # 主页面
|
||||
└── utils/
|
||||
└── totp.js # TOTP算法实现
|
||||
```
|
||||
|
||||
## 🚀 安装使用
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 8.10
|
||||
- aiot-toolkit >= 2.0.4
|
||||
- 小米手环10设备
|
||||
|
||||
### 开发环境搭建
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd totp-authenticator
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **开发调试**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. **构建发布**
|
||||
```bash
|
||||
npm run build
|
||||
npm run release
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 账户配置
|
||||
|
||||
在 `src/common/accounts.json` 中配置验证器账户:
|
||||
|
||||
```json
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"name": "服务名称",
|
||||
"username": "用户名",
|
||||
"secret": "Base32密钥",
|
||||
"enabled": true,
|
||||
"type": "totp"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"title": "TOTP验证器",
|
||||
"refreshInterval": 20,
|
||||
"maxDisplayAccounts": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 支持的验证码类型
|
||||
|
||||
- `totp`: 标准TOTP验证码(6位数字)
|
||||
- `steam`: Steam验证码(5位字符)
|
||||
|
||||
### 密钥格式
|
||||
|
||||
- 支持Base32编码的密钥
|
||||
- 支持40位十六进制密钥
|
||||
- 密钥示例:`JBSWY3DPEHPK3PXP`
|
||||
|
||||
## 🎨 界面说明
|
||||
|
||||
### 主界面
|
||||
- 显示所有启用的验证器账户
|
||||
- 实时显示验证码和剩余时间
|
||||
- 弧形进度条指示时间进度
|
||||
|
||||
### 详情页面
|
||||
- 大字体显示验证码(分行显示更清晰)
|
||||
- Steam验证码单行显示
|
||||
- 返回按钮和时间提醒
|
||||
|
||||
### 颜色系统
|
||||
- 🟢 绿色:正常状态(时间充足)
|
||||
- 🟡 橙色:警告状态(时间不足10秒)
|
||||
- 🔴 红色:紧急状态(时间不足5秒)
|
||||
|
||||
## 📱 交互说明
|
||||
|
||||
- **点击账户卡片**:进入验证码详情页面
|
||||
- **点击返回按钮**:返回主界面
|
||||
- **验证码更新**:自动震动提醒
|
||||
- **页面切换**:轻微震动反馈
|
||||
|
||||
## 🔐 安全须知
|
||||
|
||||
- 请妥善保管TOTP密钥,避免泄露
|
||||
- 建议定期备份账户配置
|
||||
- 删除不再使用的验证器账户
|
||||
- 注意设备安全,避免他人访问
|
||||
|
||||
## 🛠️ 开发说明
|
||||
|
||||
### 代码规范
|
||||
|
||||
```bash
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 代码格式化
|
||||
prettier --write "src/**/*.{ux,js}"
|
||||
```
|
||||
|
||||
### 性能优化
|
||||
|
||||
- 页面隐藏时停止定时器以节省电量
|
||||
- 使用节能模式减少不必要的计算
|
||||
- 优化动画性能,避免卡顿
|
||||
|
||||
### 调试技巧
|
||||
|
||||
- 使用 `console.log` 进行调试
|
||||
- 通过 `aiot start --watch` 实现热重载
|
||||
- 检查手环设备日志排查问题
|
||||
|
||||
## 📋 待办事项
|
||||
|
||||
- [ ] 添加账户管理功能
|
||||
- [ ] 支持二维码扫描添加账户
|
||||
- [ ] 添加数据同步功能
|
||||
- [ ] 支持更多验证码算法
|
||||
- [ ] 优化电量消耗
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 本项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- 感谢小米开发者社区提供的技术支持
|
||||
- 感谢快应用框架团队
|
||||
- 感谢所有贡献者和测试用户
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 提交 Issue
|
||||
- 发起 Pull Request
|
||||
- 联系项目维护者
|
||||
|
||||
---
|
||||
|
||||
⌚ **让验证更简单,让手环更智能!**
|
||||
21
commitlint.config.js
Normal file
21
commitlint.config.js
Normal file
@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"bug", // 此项特别针对bug号,用于向测试反馈bug列表的bug修改情况
|
||||
"feat", // 新功能(feature)
|
||||
"fix", // 修补bug
|
||||
"docs", // 文档(documentation)
|
||||
"style", // 格式(不影响代码运行的变动)
|
||||
"refactor", // 重构(即不是新增功能,也不是修改bug的代码变动)
|
||||
"test", // 增加测试
|
||||
"chore", // 构建过程或辅助工具的变动
|
||||
"revert", // feat(pencil): add ‘graphiteWidth’ option (撤销之前的commit)
|
||||
"merge" // 合并分支, 例如: merge(前端页面): feature-xxxx修改线程地址
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
3
husky.sh
Normal file
3
husky.sh
Normal file
@ -0,0 +1,3 @@
|
||||
npx husky install
|
||||
npx husky add .husky/pre-commit 'npx lint-staged'
|
||||
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit ${1}'
|
||||
0
jsconfig.json
Normal file
0
jsconfig.json
Normal file
10071
package-lock.json
generated
Normal file
10071
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "totp",
|
||||
"version": "1.0.0",
|
||||
"description": "TOTP Authenticator for Xiaomi Watch",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "aiot start --watch",
|
||||
"build": "aiot build",
|
||||
"release": "aiot release",
|
||||
"lint": "eslint --format codeframe --fix --ext .ux,.js src/"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ux,js}": [
|
||||
"prettier --write",
|
||||
"eslint --format codeframe --fix",
|
||||
"git add"
|
||||
],
|
||||
"*.{less,css}": [
|
||||
"prettier --write",
|
||||
"stylelint --fix --custom-syntax postcss-less",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aiot-toolkit/jsc": "^1.0.3",
|
||||
"aiot-toolkit": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0"
|
||||
}
|
||||
}
|
||||
8
src/app.ux
Normal file
8
src/app.ux
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
export default {
|
||||
onCreate() {
|
||||
},
|
||||
onDestroy() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
44
src/common/accounts.json
Normal file
44
src/common/accounts.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"name": "Example Service 1",
|
||||
"username": "user1",
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
"enabled": true,
|
||||
"type": "totp"
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"username": "myusername",
|
||||
"secret": "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
|
||||
"enabled": true,
|
||||
"type": "totp"
|
||||
},
|
||||
{
|
||||
"name": "Steam",
|
||||
"username": "steamuser",
|
||||
"secret": "NBSWY3DPEHPK3PXPNBSWY3DPEHPK3PXP",
|
||||
"enabled": true,
|
||||
"type": "steam"
|
||||
},
|
||||
{
|
||||
"name": "Work Server",
|
||||
"username": "employee",
|
||||
"secret": "MFRGG2LTEBYGCZLSMUFA====",
|
||||
"enabled": true,
|
||||
"type": "totp"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft",
|
||||
"username": "user@example.com",
|
||||
"secret": "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
|
||||
"enabled": false,
|
||||
"type": "totp"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"title": "TOTP验证器",
|
||||
"refreshInterval": 20,
|
||||
"maxDisplayAccounts": 10
|
||||
}
|
||||
}
|
||||
BIN
src/common/logo.png
Normal file
BIN
src/common/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
1
src/config-watch.json
Normal file
1
src/config-watch.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
33
src/manifest.json
Normal file
33
src/manifest.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"package": "com.example.totp.authenticator",
|
||||
"name": "TOTP",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": 1,
|
||||
"minPlatformVersion": 1000,
|
||||
"icon": "/common/logo.png",
|
||||
"deviceTypeList": [
|
||||
"watch"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"name": "system.router"
|
||||
},
|
||||
{
|
||||
"name": "system.vibrator"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"logLevel": "log",
|
||||
"designWidth": "device-width",
|
||||
"disableAnimation": true,
|
||||
"launchAnimation": false
|
||||
},
|
||||
"router": {
|
||||
"entry": "pages/index",
|
||||
"pages": {
|
||||
"pages/index": {
|
||||
"component": "index"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
559
src/pages/index/index.ux
Normal file
559
src/pages/index/index.ux
Normal file
@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<div class="totp-app">
|
||||
<!-- 顶部标题区域 -->
|
||||
<div class="header">
|
||||
<text class="header-title" style="font-size: 22px; top:5px">{{appTitle}}</text>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="header-divider"></div>
|
||||
|
||||
<!-- 账户列表 -->
|
||||
<list class="totp-list" style="position: absolute; top: 70px; bottom: 70px; left: 2px; right: 2px; width: 208px;">
|
||||
<list-item class="totp-item" style="height: 115px; margin-bottom: 10px; background-color: #1a1a1a; border-radius: 15px; border: 1px solid #333; flex-direction: column; justify-content: space-between; padding: 8px;" type="account" for="{{totpList}}" onclick="showDetail($idx)">
|
||||
<div style="flex-direction: column; width: 100%; margin-bottom: 8px;">
|
||||
<text style="font-size: 24px; color: #fff; font-weight: bold; margin-bottom: 3px; text-align: left; width: 100%; line-height: 26px;">{{$item.name}}</text>
|
||||
<text style="font-size: 18px; color: #999; text-align: left; width: 100%; line-height: 20px;" show="{{$item.usr}}">{{$item.usr}}</text>
|
||||
</div>
|
||||
<div style="align-items: center; justify-content: center; width: 100%; height: 45px;">
|
||||
<text style="font-size: 32px; color: #00ff88; font-family: monospace; font-weight: bold; letter-spacing: 4px; text-align: center; width: 100%; line-height: 36px;">{{$item.otp}}</text>
|
||||
</div>
|
||||
</list-item>
|
||||
</list>
|
||||
|
||||
<!-- 无账户时的占位符 -->
|
||||
<div class="empty-placeholder" show="{{totpList.length == 0}}">
|
||||
<text class="empty-icon">🔑</text>
|
||||
<text class="empty-text">暂无验证器</text>
|
||||
</div>
|
||||
|
||||
<!-- 底部弧形倒计时进度条 -->
|
||||
<div class="countdown-container" show="{{totpList.length > 0}}">
|
||||
<!-- 弧形进度条 -->
|
||||
<progress style="position: absolute; left: 0px; bottom: 10px; center-x: 106px; center-y: 0px; width: 212px; height: 100px; radius: 100px; start-angle: 240deg; total-angle: -120deg; stroke-width: 8px; background-color: rgba(255, 255, 255, 0.2); color: {{progressColor}};" percent="{{timeProgress}}" type="arc"></progress>
|
||||
<!-- 倒计时文字 -->
|
||||
<text class="countdown-text" style="position: absolute; bottom: 35px; left: 0px; right: 0px; text-align: center; font-size: 20px; font-weight: bold; color: {{textColor}}; transition: color 500ms ease-out;">{{timeLeft}}秒</text>
|
||||
</div>
|
||||
|
||||
<!-- 详情页面 -->
|
||||
<div class="detail-page" show="{{showDetailPage}}">
|
||||
<div class="detail-content">
|
||||
<text class="detail-service">{{selectedItem.name}}</text>
|
||||
<text class="detail-account" show="{{selectedItem.usr}}">{{selectedItem.usr}}</text>
|
||||
<div class="detail-code-container">
|
||||
<!-- Steam验证码:5位字符,一行显示 -->
|
||||
<text class="detail-code-single" if="{{selectedItem.type === 'steam'}}">{{selectedItem.otp}}</text>
|
||||
<!-- 标准TOTP:6位数字,双行显示,使用固定位置容器 -->
|
||||
<block if="{{selectedItem.type !== 'steam'}}">
|
||||
<!-- 第一行:3个数字 -->
|
||||
<div class="detail-code-row">
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(0, 1)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(1, 2)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(2, 3)}}</text>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:3个数字 -->
|
||||
<div class="detail-code-row">
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(3, 4)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(4, 5)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(5, 6)}}</text>
|
||||
</div>
|
||||
</div>
|
||||
</block>
|
||||
</div>
|
||||
<text class="detail-time">剩余时间 {{timeLeft}} 秒</text>
|
||||
</div>
|
||||
|
||||
<!-- 底部返回按钮容器 -->
|
||||
<div class="detail-footer">
|
||||
<div class="detail-back-btn" onclick="hideDetail">
|
||||
<text class="detail-back-icon">←</text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { TOTP, SteamTotp } from '../../utils/totp'
|
||||
import accountsConfig from '../../common/accounts.json'
|
||||
import vibrator from '@system.vibrator'
|
||||
|
||||
// Key class similar to BandTOTP
|
||||
class TOTPKey {
|
||||
constructor(key, name, usr = '', type = 'totp') {
|
||||
this.name = name
|
||||
this.usr = usr
|
||||
this.key = key
|
||||
this.type = type // 新增类型字段:'totp' 或 'steam'
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
try {
|
||||
// 根据类型选择算法
|
||||
if (this.type === 'steam') {
|
||||
this.otp = SteamTotp(this.key)
|
||||
} else {
|
||||
this.otp = TOTP(this.key)
|
||||
}
|
||||
} catch (error) {
|
||||
this.otp = "ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
private: {
|
||||
totpList: [],
|
||||
timeLeft: 30,
|
||||
timeProgress: 100,
|
||||
progressColor: '#00ff88',
|
||||
textColor: '#fff',
|
||||
showDetailPage: false,
|
||||
selectedItem: null,
|
||||
selectedIndex: 0,
|
||||
updateInterval: null,
|
||||
// Vela电池优化:性能模式控制
|
||||
isVisible: true,
|
||||
performanceMode: 'normal', // normal, power-save, high-performance
|
||||
lastUpdateTime: 0,
|
||||
// 配置相关
|
||||
appTitle: "TOTP验证器",
|
||||
refreshInterval: 100
|
||||
},
|
||||
|
||||
onInit() {
|
||||
|
||||
// 加载配置文件
|
||||
this.loadAccountsFromConfig()
|
||||
|
||||
// 设置初始颜色
|
||||
this.updateColors(this.timeLeft)
|
||||
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
loadAccountsFromConfig() {
|
||||
try {
|
||||
|
||||
// 加载设置
|
||||
if (accountsConfig.settings) {
|
||||
this.appTitle = accountsConfig.settings.title || "TOTP验证器"
|
||||
this.refreshInterval = accountsConfig.settings.refreshInterval || 100
|
||||
}
|
||||
|
||||
// 加载启用的账户
|
||||
const enabledAccounts = accountsConfig.accounts.filter(account => account.enabled)
|
||||
|
||||
// 转换为TOTPKey实例
|
||||
this.totpList = enabledAccounts.map(account => {
|
||||
return new TOTPKey(
|
||||
account.secret,
|
||||
account.name,
|
||||
account.username,
|
||||
account.type || 'totp' // 如果没有指定type,默认为totp
|
||||
)
|
||||
})
|
||||
|
||||
this.totpList.forEach(() => {
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load accounts config:', error)
|
||||
// 如果加载配置失败,使用默认账户
|
||||
this.loadDefaultAccounts()
|
||||
}
|
||||
},
|
||||
|
||||
loadDefaultAccounts() {
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.isVisible = true
|
||||
this.updateCodes()
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
onHide() {
|
||||
this.isVisible = false
|
||||
// Vela优化:页面隐藏时切换到节能模式
|
||||
this.performanceMode = 'power-save'
|
||||
|
||||
// Vela优化:页面隐藏时停止动画以节省电量
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval)
|
||||
this.updateInterval = null
|
||||
}
|
||||
// 清除所有动画状态
|
||||
this.removeExpiringAnimation()
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
// Vela生命周期:确保清理所有资源
|
||||
this.isVisible = false
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval)
|
||||
this.updateInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
updateCodes() {
|
||||
|
||||
// Vela优化:添加更新动画效果
|
||||
this.totpList.forEach((item, index) => {
|
||||
try {
|
||||
const oldOtp = item.otp
|
||||
item.update()
|
||||
|
||||
// 如果代码发生变化,触发更新动画
|
||||
if (oldOtp !== item.otp && oldOtp !== "ERROR") {
|
||||
this.triggerCodeUpdateAnimation()
|
||||
}
|
||||
} catch (error) {
|
||||
item.otp = "ERROR"
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
triggerCodeUpdateAnimation() {
|
||||
// 简化实现,避免DOM操作错误
|
||||
},
|
||||
|
||||
updateColors(timeLeft) {
|
||||
let newProgressColor, newTextColor
|
||||
|
||||
if (timeLeft <= 5) {
|
||||
newProgressColor = '#ff6b6b'
|
||||
newTextColor = '#ff6b6b'
|
||||
} else if (timeLeft <= 10) {
|
||||
newProgressColor = '#ffa502'
|
||||
newTextColor = '#ffa502'
|
||||
} else {
|
||||
newProgressColor = '#00ff88'
|
||||
newTextColor = '#fff'
|
||||
}
|
||||
|
||||
// 只在颜色真正变化时才更新
|
||||
if (this.progressColor !== newProgressColor) {
|
||||
this.progressColor = newProgressColor
|
||||
}
|
||||
if (this.textColor !== newTextColor) {
|
||||
this.textColor = newTextColor
|
||||
}
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval)
|
||||
}
|
||||
if (this.progressInterval) {
|
||||
clearInterval(this.progressInterval)
|
||||
}
|
||||
|
||||
// 使用统一的更新函数,确保时间和进度条完全同步
|
||||
const updateAll = () => {
|
||||
const now = Date.now()
|
||||
const millisecondsInCycle = now % 30000
|
||||
const secondsInCycle = Math.floor(millisecondsInCycle / 1000)
|
||||
const newTimeLeft = 30 - secondsInCycle
|
||||
|
||||
// 使用同一个时间源计算进度条和倒计时
|
||||
this.timeProgress = (30000 - millisecondsInCycle) / 300
|
||||
|
||||
// 更新倒计时文字
|
||||
if (this.timeLeft !== newTimeLeft) {
|
||||
this.timeLeft = newTimeLeft
|
||||
|
||||
// 只在时间变化时更新颜色
|
||||
this.updateColors(newTimeLeft)
|
||||
|
||||
// 添加紧急状态动画
|
||||
if (newTimeLeft <= 5) {
|
||||
this.addExpiringAnimation()
|
||||
} else {
|
||||
this.removeExpiringAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
// 在新周期开始时更新验证码
|
||||
if (millisecondsInCycle < 200 && secondsInCycle === 0) {
|
||||
this.updateCodes()
|
||||
|
||||
// 验证码刷新时短震动+长震动提醒
|
||||
vibrator.vibrate({
|
||||
mode: 'short'
|
||||
})
|
||||
setTimeout(() => {
|
||||
vibrator.vibrate({
|
||||
mode: 'long'
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
updateAll()
|
||||
|
||||
// 统一使用10ms更新,确保进度条和时间完全同步
|
||||
this.updateInterval = setInterval(updateAll, 10)
|
||||
},
|
||||
|
||||
restartTimer() {
|
||||
// 重新启动定时器以应用新的性能设置
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
addExpiringAnimation() {
|
||||
// 简化实现,避免DOM操作错误
|
||||
},
|
||||
|
||||
removeExpiringAnimation() {
|
||||
// 简化实现,避免DOM操作错误
|
||||
},
|
||||
|
||||
showDetail(index) {
|
||||
if (this.totpList.length > 0 && index >= 0 && index < this.totpList.length) {
|
||||
this.selectedIndex = index
|
||||
this.selectedItem = this.totpList[index]
|
||||
this.showDetailPage = true
|
||||
|
||||
// 进入详情页震动反馈
|
||||
vibrator.vibrate({
|
||||
mode: 'short'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
hideDetail() {
|
||||
this.showDetailPage = false
|
||||
this.selectedItem = null
|
||||
|
||||
// 返回时震动反馈
|
||||
vibrator.vibrate({
|
||||
mode: 'short'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.totp-app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
position: fixed;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000;
|
||||
flex-shrink: 0;
|
||||
/* 移除多余的边框 */
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
position: absolute;
|
||||
top: 55px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #333, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 列表区域 - 简化版本 */
|
||||
.totp-list {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.totp-item {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 空状态占位符 */
|
||||
.empty-placeholder {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部弧形倒计时 */
|
||||
.countdown-container {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
height: 100px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.countdown-text {
|
||||
color: #fff;
|
||||
transition: color 500ms ease-out;
|
||||
}
|
||||
|
||||
/* 详情页面 */
|
||||
.detail-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
padding: 40px 20px 20px 20px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail-service {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-account {
|
||||
font-size: 22px;
|
||||
color: #999;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-code-container {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #333;
|
||||
height: 120px;
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.detail-code-row {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.detail-code-digit {
|
||||
width: 30px;
|
||||
height: 50px;
|
||||
/* background-color: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 8px; */
|
||||
margin: 0 1px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-code-num {
|
||||
font-size: 32px;
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.detail-code-line {
|
||||
font-size: 36px;
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
line-height: 44px;
|
||||
margin: 0px 0;
|
||||
}
|
||||
|
||||
.detail-code-single {
|
||||
font-size: 32px;
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
letter-spacing: 3px;
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
font-size: 22px;
|
||||
color: #AAA;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部按钮容器 */
|
||||
.detail-footer {
|
||||
height: 100px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-back-btn {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 35px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.detail-back-icon {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
134
src/utils/totp.js
Normal file
134
src/utils/totp.js
Normal file
@ -0,0 +1,134 @@
|
||||
var CryptoJS = require("crypto-js");
|
||||
let timeOffset=0
|
||||
function time(){
|
||||
return Math.floor(Date.now() / 1000) + (timeOffset || 0);
|
||||
}
|
||||
function TOTP(key) {
|
||||
|
||||
const timeStep = 30; // 默认时间步长为 30 秒
|
||||
const currentTime = time()
|
||||
const timeCounter = Math.floor(currentTime / timeStep).toString(16).padStart(16, '0'); // 时间计数器,16位
|
||||
|
||||
try {
|
||||
// 使用 Base32 解码并返回 WordArray
|
||||
const decodedKey = base32Decode(key);
|
||||
|
||||
// 使用 HMAC-SHA1 计算哈希值
|
||||
const hmacHash = CryptoJS.HmacSHA1(CryptoJS.enc.Hex.parse(timeCounter),decodedKey);
|
||||
|
||||
// 动态截取
|
||||
const hmacBytes = hexToBytes(hmacHash.toString());
|
||||
const offset = hmacBytes[hmacBytes.length - 1] & 0xf;
|
||||
const otp =
|
||||
((hmacBytes[offset] & 0x7f) << 24) |
|
||||
(hmacBytes[offset + 1] << 16) |
|
||||
(hmacBytes[offset + 2] << 8) |
|
||||
hmacBytes[offset + 3];
|
||||
|
||||
// 生成 6 位 OTP
|
||||
const result = (otp % 1000000).toString().padStart(6, '0');
|
||||
return result
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Steam验证码实现
|
||||
function SteamTotp(secret) {
|
||||
secret = bufferizeSecret(secret); // 确保密钥的处理函数兼容
|
||||
// 创建模拟的 8 字节 buffer
|
||||
let bytes = new Uint8Array(8).fill(0);
|
||||
let timeDiv30 = Math.floor(time() / 30);
|
||||
// 将高位写入前 4 字节(为 0)
|
||||
bytes[4] = (timeDiv30 >> 24) & 0xff;
|
||||
bytes[5] = (timeDiv30 >> 16) & 0xff;
|
||||
bytes[6] = (timeDiv30 >> 8) & 0xff;
|
||||
bytes[7] = timeDiv30 & 0xff;
|
||||
// 使用 CryptoJS 计算 HMAC
|
||||
let hmac = CryptoJS.HmacSHA1(CryptoJS.lib.WordArray.create(bytes), secret);
|
||||
|
||||
// 转换 HMAC 为字节数组
|
||||
let hmacBytes = wordArrayToUint8Array(hmac);
|
||||
|
||||
// 获取偏移量
|
||||
const start = hmacBytes[19] & 0x0f;
|
||||
|
||||
// 截取 4 字节
|
||||
hmacBytes = hmacBytes.slice(start, start + 4);
|
||||
|
||||
// 转换为无符号 32 位整数并取 31 位
|
||||
let fullcode =
|
||||
((hmacBytes[0] << 24) | (hmacBytes[1] << 16) | (hmacBytes[2] << 8) | hmacBytes[3]) & 0x7fffffff;
|
||||
|
||||
// 生成验证码
|
||||
const chars = '23456789BCDFGHJKMNPQRTVWXY';
|
||||
let code = '';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
code += chars.charAt(fullcode % chars.length);
|
||||
fullcode /= chars.length;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
function bufferizeSecret(secret) {
|
||||
if (typeof secret === 'string') {
|
||||
// 检查是否为 hex 编码
|
||||
if (secret.match(/^[0-9a-f]{40}$/i)) {
|
||||
return CryptoJS.enc.Hex.parse(secret); // 解析为 WordArray
|
||||
} else {
|
||||
// 假设是 base32 编码
|
||||
return base32Decode(secret); // 解析为 WordArray
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是字符串,假设已经是 WordArray 或其他支持的格式
|
||||
return secret;
|
||||
}
|
||||
|
||||
// 将 WordArray 转换为字节数组(Uint8Array)
|
||||
function wordArrayToUint8Array(wordArray) {
|
||||
const words = wordArray.words;
|
||||
const sigBytes = wordArray.sigBytes;
|
||||
const bytes = new Uint8Array(sigBytes);
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < sigBytes) {
|
||||
bytes[i++] = (words[j] >>> 24) & 0xff;
|
||||
bytes[i++] = (words[j] >>> 16) & 0xff;
|
||||
bytes[i++] = (words[j] >>> 8) & 0xff;
|
||||
bytes[i++] = words[j++] & 0xff;
|
||||
}
|
||||
return bytes.slice(0, sigBytes);
|
||||
}
|
||||
|
||||
// Base32 解码函数
|
||||
function base32Decode(input) {
|
||||
const base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
input = input.toUpperCase().replace(/=+$/, ''); // 去掉填充符
|
||||
|
||||
let bits = '';
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const val = base32chars.indexOf(input[i]);
|
||||
if (val === -1) throw new Error('Invalid Base32 character');
|
||||
bits += val.toString(2).padStart(5, '0');
|
||||
}
|
||||
|
||||
let hex="";
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
hex+=parseInt(bits.substring(i, i + 8), 2).toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
return CryptoJS.enc.Hex.parse(hex);
|
||||
}
|
||||
|
||||
// 将十六进制字符串转换为字节数组
|
||||
function hexToBytes(hex) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes.push(parseInt(hex.substr(i, 2), 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export { TOTP, SteamTotp, timeOffset }
|
||||
Loading…
Reference in New Issue
Block a user