MiBand-TOTP/src/pages/index/index.ux
deepfal 490611335c feat(ui): 重构界面布局和样式
- 调整列表项布局,增加左右分区
- 优化底部倒计时和进度条布局
- 统一文字样式和大小
- 调整颜色和间距
- 增加返回按键处理
2025-08-18 15:10:21 +08:00

943 lines
26 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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: 55px; bottom: 48px; left: 6px; right: 6px; width: 324px;">
<list-item class="totp-item" style="height: 90px; margin-bottom: 4px; background-color: #1a1a1a; border-radius: 8px; border: 1px solid #333; flex-direction: row; align-items: center; padding: 8px 6px 8px 12px;" type="account" for="{{totpList}}" onclick="showDetail($idx)">
<!-- 左侧:服务名和用户名 -->
<div style="flex: 1; flex-direction: column; justify-content: center; margin-right: 0px;">
<text style="font-size: 30px; color: #fff; font-weight: bold; margin-bottom: 1px; text-align: left; line-height: 32px;">{{$item.name}}</text>
<text style="font-size: 24px; color: #999; text-align: left; line-height: 25px; width:200px" show="{{$item.usr}}">{{$item.usr}}</text>
</div>
<!-- 右侧:验证码 -->
<div style="width: 140px; height: 100%; align-items: center; justify-content: flex-end;">
<text style="font-size: 36px; color: #00ff88; font-family: monospace; font-weight: bold; letter-spacing: 1px; text-align: right; line-height: 38px; white-space: nowrap;">{{$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}}">
<div style="position: absolute; bottom: 8px; left: 12px; right: 12px; height: 40px; flex-direction: row; align-items: center;">
<!-- 左侧倒计时文字 -->
<div style="width: 100px; height: 100%; align-items: center; justify-content: center;">
<text class="countdown-text" style="font-size: 24px; font-weight: bold; color: {{textColor}}; transition: color 500ms ease-out; text-align: center;">{{timeLeft}}秒</text>
</div>
<!-- 右侧进度条 -->
<div style="flex: 1; height: 100%; align-items: center; justify-content: center; margin-left: 8px;">
<!-- 进度条背景 -->
<div style="position: absolute; left: 0px; right: 0px; height: 8px; background-color: rgba(255, 255, 255, 0.1); border-radius: 4px;"></div>
<!-- 进度条 -->
<progress style="position: absolute; left: 0px; right: 0px; height: 8px; background-color: transparent; color: {{progressColor}}; border-radius: 4px;" percent="{{timeProgress}}" type="horizontal"></progress>
</div>
</div>
</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>
<!-- 标准TOTP6位数字双行显示使用固定位置容器 -->
<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 vibrator from '@system.vibrator'
import storage from '@system.storage'
import interconnect from '@system.interconnect'
// Key class similar to BandTOTP
class TOTPKey {
constructor(key, name, usr = '', type = 'totp', algorithm = 'SHA1', digits = 6, period = 30) {
this.name = name
this.usr = usr
this.key = key
this.type = type // 'totp' 或 'steam'
this.algorithm = algorithm
this.digits = digits
this.period = period
this.update()
}
update() {
try {
// 根据类型选择算法
if (this.type === 'steam') {
this.otp = SteamTotp(this.key)
} else {
this.otp = TOTP(this.key, this.algorithm, this.digits, this.period)
}
} catch (error) {
this.otp = "ERROR"
}
}
}
let interconnectConn = null
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,
// interconnect状态
isConnected: false,
connectionStatus: '未连接'
},
onInit() {
console.log('TOTP应用初始化')
// 初始化interconnect连接
this.initInterconnect()
// 只从本地存储加载账户(不再从配置文件加载)
this.loadAccountsFromStorage()
// 设置interconnect消息监听
this.setupInterconnectListener()
// 设置初始颜色
this.updateColors(this.timeLeft)
this.startTimer()
},
initInterconnect() {
console.log('初始化interconnect连接')
try {
interconnectConn = interconnect.instance()
interconnectConn.getReadyState({
success: data => {
console.log('interconnect状态:', data.status)
if (data.status === 1) {
this.isConnected = true
this.connectionStatus = '已连接'
console.log('interconnect连接成功')
} else if (data.status === 2) {
this.isConnected = false
this.connectionStatus = '连接失败'
console.log('interconnect连接失败')
}
},
fail: (data, code) => {
console.log('获取interconnect状态失败, code =', code)
this.connectionStatus = '状态检查失败'
}
})
// 在initInterconnect中不设置onmessage使用单独的方法
// 监听消息接收将在setupInterconnectListener中设置
// 监听连接打开
interconnectConn.onopen = () => {
console.log('interconnect连接已打开')
this.isConnected = true
this.connectionStatus = '已连接'
// 连接成功后,请求当前账户列表给手机
this.sendCurrentAccountsToPhone()
}
// 监听连接关闭
interconnectConn.onclose = (data) => {
console.log('interconnect连接已关闭, reason =', data.data, ', code =', data.code)
this.isConnected = false
this.connectionStatus = '连接已关闭'
}
// 监听连接错误
interconnectConn.onerror = (data) => {
console.log('interconnect连接错误, errMsg =', data.data, ', errCode =', data.code)
this.isConnected = false
this.connectionStatus = '连接错误'
}
} catch (error) {
console.log('初始化interconnect失败:', error)
this.connectionStatus = '初始化失败'
}
},
setupInterconnectListener() {
if (!interconnectConn) {
console.log('interconnect连接不存在无法设置监听')
return
}
// 完全模仿BandTOTP-Band的消息处理逻辑
interconnectConn.onmessage = (data) => {
try {
console.log('接收到手机消息:', data.data)
let parsedData = JSON.parse(data.data)
if (parsedData.list && Array.isArray(parsedData.list)) {
parsedData.list.forEach((e) => {
// 查找是否已存在相同密钥
let index = this.totpList.findIndex((a) => e.key == a.key)
if (index != -1) {
// 密钥重复:只更新名字和用户名
this.totpList[index].name = e.name
this.totpList[index].usr = e.usr
} else {
// 新密钥:创建新实例并添加
this.totpList.push(new TOTPKey(
e.key,
e.name,
e.usr || '',
e.type || 'totp',
e.algorithm || 'SHA1',
e.digits || 6,
e.period || 30
))
}
})
// 关键立即保存到存储学习BandTOTP-Band
this.saveAccountsToStorage()
console.log('成功更新', this.totpList.length, '个账户')
// 震动反馈表示数据同步成功
vibrator.vibrate({
mode: 'short'
})
setTimeout(() => {
vibrator.vibrate({
mode: 'short'
})
}, 100)
}
// 处理获取账户列表请求
if (parsedData.cmd === 'getAccounts') {
console.log('手机请求获取账户列表')
this.sendCurrentAccountsToPhone()
}
// 处理删除账户请求
if (parsedData.cmd === 'deleteAccount' && parsedData.key) {
console.log('手机请求删除账户:', parsedData.key)
this.deleteAccountByKey(parsedData.key)
}
// 处理重排序账户请求
if (parsedData.cmd === 'reorderAccounts' && parsedData.accounts) {
console.log('手机请求重排序账户')
this.reorderAccounts(parsedData.accounts)
}
} catch (error) {
console.log('处理手机消息失败:', error)
}
}
},
sendCurrentAccountsToPhone() {
if (!interconnectConn || !this.isConnected) {
console.log('interconnect未连接无法发送账户列表')
return
}
try {
// 构造与Android app兼容的数据格式
const accountsData = {
list: this.totpList.map(account => ({
name: account.name,
usr: account.usr,
key: account.key,
algorithm: account.algorithm || 'SHA1',
digits: account.digits || 6,
period: account.period || 30,
type: account.type || 'totp'
}))
}
console.log('发送账户列表到手机:', accountsData)
interconnectConn.send({
data: accountsData,
success: () => {
console.log('账户列表发送成功')
},
fail: (data) => {
console.log('账户列表发送失败, errMsg =', data.data, ', errCode =', data.code)
}
})
} catch (error) {
console.log('构造发送数据失败:', error)
}
},
loadAccountsFromStorage() {
storage.get({
key: 'keys',
success: (data) => {
if (data) {
console.log('从存储加载账户:', data)
try {
const parsedData = JSON.parse(data)
if (parsedData && parsedData.list && Array.isArray(parsedData.list)) {
// 清空现有列表,避免重复加载
this.totpList = []
parsedData.list.forEach((account) => {
this.totpList.push(new TOTPKey(
account.key,
account.name,
account.usr || '',
account.type || 'totp',
account.algorithm || 'SHA1',
account.digits || 6,
account.period || 30
))
})
console.log('从存储加载了', this.totpList.length, '个账户')
}
} catch (error) {
console.log('解析存储数据失败:', error)
}
} else {
// 如果没有存储数据,确保列表为空
console.log('存储中没有账户数据')
this.totpList = []
}
},
fail: (data, code) => {
console.log('从存储加载账户失败:', code)
// 加载失败时也确保列表为空,避免使用旧数据
this.totpList = []
}
})
},
saveAccountsToStorage() {
const saveData = {
list: this.totpList.map(account => ({
name: account.name,
usr: account.usr,
key: account.key,
algorithm: account.algorithm || 'SHA1',
digits: account.digits || 6,
period: account.period || 30,
type: account.type || 'totp'
}))
}
storage.set({
key: 'keys',
value: JSON.stringify(saveData)
})
console.log('账户保存到存储,数量:', this.totpList.length)
},
deleteAccountByKey(keyToDelete) {
const initialCount = this.totpList.length
// 根据key删除账户
this.totpList = this.totpList.filter(account => account.key !== keyToDelete)
const deletedCount = initialCount - this.totpList.length
if (deletedCount > 0) {
// 保存更新后的账户列表
this.saveAccountsToStorage()
console.log('成功删除账户,删除数量:', deletedCount, '剩余数量:', this.totpList.length)
// 删除成功震动反馈
vibrator.vibrate({
mode: 'long'
})
// 如果正在查看被删除的账户详情,关闭详情页
if (this.showDetailPage && this.selectedItem && this.selectedItem.key === keyToDelete) {
this.hideDetail()
}
// 向手机发送删除成功确认
this.sendDeleteConfirmationToPhone(keyToDelete, true)
} else {
console.log('未找到要删除的账户:', keyToDelete)
// 向手机发送删除失败确认
this.sendDeleteConfirmationToPhone(keyToDelete, false)
}
},
sendDeleteConfirmationToPhone(deletedKey, success) {
if (!interconnectConn || !this.isConnected) {
console.log('interconnect未连接无法发送删除确认')
return
}
try {
const confirmationData = {
cmd: 'deleteConfirmation',
key: deletedKey,
success: success
}
console.log('发送删除确认到手机:', confirmationData)
interconnectConn.send({
data: confirmationData,
success: () => {
console.log('删除确认发送成功')
},
fail: (data) => {
console.log('删除确认发送失败, errMsg =', data.data, ', errCode =', data.code)
}
})
} catch (error) {
console.log('构造删除确认数据失败:', error)
}
},
reorderAccounts(newAccounts) {
try {
console.log('开始重排序账户,新顺序数量:', newAccounts.length)
// 根据新顺序重建账户列表
const reorderedList = []
newAccounts.forEach((accountData) => {
// 在现有列表中查找匹配的账户
const existingAccount = this.totpList.find(account => account.key === accountData.key)
if (existingAccount) {
// 保持现有的TOTPKey实例只更新顺序
reorderedList.push(existingAccount)
} else {
// 如果没找到创建新的TOTPKey实例
console.log('创建新账户实例:', accountData.name)
reorderedList.push(new TOTPKey(
accountData.key,
accountData.name,
accountData.usr || '',
accountData.type || 'totp',
accountData.algorithm || 'SHA1',
accountData.digits || 6,
accountData.period || 30
))
}
})
// 更新账户列表
this.totpList = reorderedList
// 保存到存储
this.saveAccountsToStorage()
console.log('账户重排序完成,新顺序数量:', this.totpList.length)
// 重排序成功震动反馈
vibrator.vibrate({
mode: 'short'
})
setTimeout(() => {
vibrator.vibrate({
mode: 'short'
})
}, 100)
// 向手机发送重排序成功确认
this.sendReorderConfirmationToPhone(true)
} catch (error) {
console.log('重排序账户失败:', error)
this.sendReorderConfirmationToPhone(false)
}
},
sendReorderConfirmationToPhone(success) {
if (!interconnectConn || !this.isConnected) {
console.log('interconnect未连接无法发送重排序确认')
return
}
try {
const confirmationData = {
cmd: 'reorderConfirmation',
success: success
}
console.log('发送重排序确认到手机:', confirmationData)
interconnectConn.send({
data: confirmationData,
success: () => {
console.log('重排序确认发送成功')
},
fail: (data) => {
console.log('重排序确认发送失败, errMsg =', data.data, ', errCode =', data.code)
}
})
} catch (error) {
console.log('构造重排序确认数据失败:', error)
}
},
// 已移除loadAccountsFromConfig - 只能通过手机端管理账户
onShow() {
this.isVisible = true
this.updateCodes()
this.startTimer()
// 页面显示时,如果已连接则发送当前账户列表
if (this.isConnected) {
this.sendCurrentAccountsToPhone()
}
},
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
}
// 清理interconnect连接
if (interconnectConn) {
try {
interconnectConn = null
} catch (error) {
console.log('清理interconnect连接失败:', error)
}
}
},
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'
})
},
onBackPress() {
console.log('onBackPress')
//
if (this.showDetailPage) {
this.hideDetail()
return true //
}
// 退
return false
}
}
</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: 24px;
color: #fff;
font-weight: bold;
text-align: center;
}
.header-divider {
position: absolute;
top: 45px;
left: 30px;
right: 30px;
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: 56px;
margin-bottom: 20px;
}
.empty-text {
font-size: 20px;
color: #666;
text-align: center;
}
/* 底部并排倒计时 */
.countdown-container {
position: absolute;
bottom: 0px;
left: 0px;
right: 0px;
height: 48px;
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: 30px 15px 15px 15px;
flex-direction: column;
align-items: center;
justify-content: center;
}
.detail-service {
font-size: 40px;
color: #fff;
font-weight: bold;
margin-bottom: 10px;
text-align: center;
}
.detail-account {
font-size: 30px;
color: #999;
margin-bottom: 25px;
text-align: center;
}
.detail-code-container {
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 20px;
padding: 15px;
background-color: #1a1a1a;
border-radius: 12px;
border: 2px solid #333;
height: 120px;
width: 280px;
}
.detail-code-row {
flex-direction: row;
justify-content: center;
align-items: center;
margin: 4px 0;
}
.detail-code-digit {
width: 36px;
height: 42px;
margin: 0 1px;
justify-content: center;
align-items: center;
}
.detail-code-num {
font-size: 36px;
color: #00ff88;
font-weight: bold;
font-family: monospace;
text-align: center;
line-height: 40px;
}
.detail-code-single {
font-size: 36px;
color: #00ff88;
font-weight: bold;
font-family: monospace;
letter-spacing: 3px;
text-align: center;
line-height: 40px;
}
.detail-time {
font-size: 30px;
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>