commit 2b8870a40efff2ad3cdc5acc77d0ab94dd1fb4a0
Author: “xHuPo” <7513325+vrocwang@users.noreply.github.com>
Date: Mon Jun 9 13:35:15 2025 +0800
init
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..569f0b0
--- /dev/null
+++ b/app.js
@@ -0,0 +1,89 @@
+//app.js
+const { syncTokens, showToast } = require('./utils/util');
+const config = require('./utils/config');
+const cloud = require('./utils/cloud');
+
+App({
+ onLaunch: function (options) {
+ // 获取本地OTP信息
+ this.getLocalOtpInfo();
+ // 不再自动登录,只在用户点击登录按钮时才登录
+ },
+
+ getLocalOtpInfo() {
+ try {
+ const otpList = wx.getStorageSync('tokens');
+ if (otpList) {
+ this.globalData.otpList = otpList;
+ }
+ } catch (e) {
+ console.error("获取本地OTP信息失败:", e);
+ }
+ },
+
+
+ async userLogin(options) {
+ try {
+ // 获取微信登录凭证
+ const { code } = await new Promise((resolve, reject) => {
+ wx.login({
+ success: resolve,
+ fail: reject
+ });
+ });
+
+ // 调用后端登录接口
+ const loginResult = await cloud.request({
+ url: config.API_ENDPOINTS.AUTH.LOGIN,
+ method: 'POST',
+ data: { code },
+ needToken: false
+ });
+
+ // 保存登录信息到storage
+ wx.setStorageSync(config.JWT_CONFIG.storage.access, loginResult.accessToken);
+ wx.setStorageSync(config.JWT_CONFIG.storage.refresh, loginResult.refreshToken);
+
+ // 初始化基本用户信息
+ const userInfo = {
+ avatarUrl: wx.getStorageSync('userAvatar') || '/images/default-avatar.png',
+ nickName: wx.getStorageSync('userNickName') || '微信用户'
+ };
+ this.globalData.userInfo = userInfo;
+
+ // 同步OTP数据
+ await this.syncOtpData();
+
+ return userInfo;
+ } catch (error) {
+ console.error('登录失败:', error);
+ showToast('登录失败,请重试', 'none');
+ throw error;
+ }
+ },
+
+ async syncOtpData() {
+ try {
+ // 获取本地数据
+ const localTokens = this.globalData.otpList;
+
+ // 使用util.js中的syncTokens函数进行同步
+ // 注意:syncTokens函数已经被更新为使用cloud模块
+ const syncedTokens = await syncTokens(localTokens);
+
+ // 更新本地存储和全局数据
+ wx.setStorageSync('tokens', syncedTokens);
+ this.globalData.otpList = syncedTokens;
+
+ } catch (error) {
+ console.error('同步OTP数据失败:', error);
+ // 同步失败不显示错误提示,因为这是自动同步过程
+ }
+ },
+
+ globalData: {
+ version: 103,
+ otpList: [],
+ userInfo: null
+ }
+})
\ No newline at end of file
diff --git a/app.json b/app.json
new file mode 100644
index 0000000..501e440
--- /dev/null
+++ b/app.json
@@ -0,0 +1,38 @@
+{
+ "pages":[
+ "pages/index/index",
+ "pages/mine/mine",
+ "pages/edit/edit",
+ "pages/form/form",
+ "pages/info/info"
+ ],
+ "window": {
+ "backgroundColor": "#f8f9fa",
+ "backgroundTextStyle": "dark",
+ "navigationBarBackgroundColor": "#ffffff",
+ "navigationBarTitleText": "动态验证码",
+ "navigationBarTextStyle":"black"
+ },
+ "tabBar": {
+ "borderStyle":"black",
+ "backgroundColor":"#ffffff",
+ "color":"#999999",
+ "selectedColor": "#007AFF",
+ "list": [
+ {
+ "pagePath": "pages/index/index",
+ "text": "验证码",
+ "iconPath": "images/indexoff.png",
+ "selectedIconPath": "images/index.png"
+ },
+ {
+ "pagePath": "pages/mine/mine",
+ "text": "我的",
+ "iconPath": "images/mineoff.png",
+ "selectedIconPath": "images/mine.png"
+ }
+ ]
+ },
+ "sitemapLocation": "sitemap.json",
+ "style":"v2"
+}
\ No newline at end of file
diff --git a/app.wxss b/app.wxss
new file mode 100644
index 0000000..7d5a0a4
--- /dev/null
+++ b/app.wxss
@@ -0,0 +1,4 @@
+/**app.wxss**/
+page {
+ background: #f8f9fa;
+}
\ No newline at end of file
diff --git a/images/default-avatar.png b/images/default-avatar.png
new file mode 100644
index 0000000..bf59892
Binary files /dev/null and b/images/default-avatar.png differ
diff --git a/images/index.png b/images/index.png
new file mode 100644
index 0000000..a0d2d8d
Binary files /dev/null and b/images/index.png differ
diff --git a/images/indexoff.png b/images/indexoff.png
new file mode 100644
index 0000000..d4bebc2
Binary files /dev/null and b/images/indexoff.png differ
diff --git a/images/mine.png b/images/mine.png
new file mode 100644
index 0000000..dad8fbe
Binary files /dev/null and b/images/mine.png differ
diff --git a/images/mineoff.png b/images/mineoff.png
new file mode 100644
index 0000000..aed579a
Binary files /dev/null and b/images/mineoff.png differ
diff --git a/images/share.png b/images/share.png
new file mode 100644
index 0000000..f13c3da
Binary files /dev/null and b/images/share.png differ
diff --git a/pages/edit/edit.js b/pages/edit/edit.js
new file mode 100644
index 0000000..a9824af
--- /dev/null
+++ b/pages/edit/edit.js
@@ -0,0 +1,233 @@
+// pages/edit/edit.js
+let util = require('../../utils/util')
+
+Page({
+ /**
+ * 页面的初始数据
+ */
+ data: {
+ issuer: '',
+ remark: '',
+ secret: '',
+ type: '',
+ counter: 0,
+ isHotp: false,
+ isSubmitting: false,
+ token: [],
+ token_id: null,
+ hasLoaded: false
+ },
+
+ /**
+ * 生命周期函数--监听页面加载
+ */
+ onLoad: function (options) {
+ if (!options.token_id) {
+ wx.showToast({
+ title: '参数错误',
+ icon: 'error',
+ duration: 2000
+ })
+ setTimeout(() => {
+ wx.navigateBack({ delta: 1 })
+ }, 2000)
+ return
+ }
+
+ this.setData({
+ token_id: options.token_id
+ })
+
+ this.loadTokenData()
+ },
+
+ /**
+ * 加载令牌数据
+ */
+ loadTokenData: function() {
+ const self = this
+ wx.getStorage({
+ key: 'tokens',
+ success: function(res) {
+ const tokens = res.data
+ const targetToken = tokens.find(token => String(token.id) === String(self.data.token_id))
+
+ if (!targetToken) {
+ wx.showToast({
+ title: '令牌不存在',
+ icon: 'error',
+ duration: 2000
+ })
+ setTimeout(() => {
+ wx.navigateBack({ delta: 1 })
+ }, 2000)
+ return
+ }
+
+ self.setData({
+ tokens: tokens,
+ issuer: targetToken.issuer || '',
+ remark: targetToken.remark || '',
+ secret: targetToken.secret || '',
+ type: targetToken.type || 'totp',
+ counter: targetToken.counter || 0,
+ isHotp: targetToken.type === 'hotp',
+ hasLoaded: true
+ })
+ },
+ fail: function(err) {
+ wx.showToast({
+ title: '加载失败',
+ icon: 'error',
+ duration: 2000
+ })
+ setTimeout(() => {
+ wx.navigateBack({ delta: 1 })
+ }, 2000)
+ }
+ })
+ },
+
+ // 验证计数器值
+ validateCounter: function(counter) {
+ const num = parseInt(counter)
+ if (isNaN(num) || num < 0) {
+ wx.showToast({
+ title: '计数器值必须是非负整数',
+ icon: 'none',
+ duration: 2000
+ })
+ return false
+ }
+ return true
+ },
+
+ // 修改并提交数据
+ keySubmit: function (e) {
+ const self = this
+ const values = e.detail.value
+
+ // 防止重复提交
+ if (this.data.isSubmitting) {
+ return
+ }
+
+ // 检查数据是否已加载
+ if (!this.data.hasLoaded) {
+ wx.showToast({
+ title: '数据未加载完成',
+ icon: 'error',
+ duration: 2000
+ })
+ return
+ }
+
+ // 检查必填字段
+ if (!values.issuer || !values.issuer.trim()) {
+ wx.showToast({
+ title: '发行方不能为空',
+ icon: 'none',
+ duration: 2000
+ })
+ return
+ }
+
+ // 如果是HOTP类型,验证计数器值
+ if (this.data.isHotp && values.counter !== undefined) {
+ if (!this.validateCounter(values.counter)) {
+ return
+ }
+ }
+
+ this.setData({
+ isSubmitting: true
+ })
+
+ // 创建tokens的副本并更新数据
+ const updatedTokens = [...this.data.tokens]
+ const tokenIndex = updatedTokens.findIndex(token => String(token.id) === String(this.data.token_id))
+
+ if (tokenIndex === -1) {
+ wx.showToast({
+ title: '令牌不存在',
+ icon: 'error',
+ duration: 2000
+ })
+ this.setData({
+ isSubmitting: false
+ })
+ return
+ }
+
+ // 更新令牌数据
+ updatedTokens[tokenIndex] = {
+ ...updatedTokens[tokenIndex],
+ issuer: values.issuer.trim(),
+ remark: (values.remark || '').trim(),
+ secret: this.data.secret, // 使用已加载的secret,而不是从表单获取
+ type: this.data.type
+ }
+
+ // 如果是HOTP类型,更新计数器值
+ if (this.data.isHotp && values.counter !== undefined) {
+ updatedTokens[tokenIndex].counter = parseInt(values.counter)
+ }
+
+ wx.setStorage({
+ key: 'tokens',
+ data: updatedTokens,
+ success: function(res) {
+ // 更新本地数据
+ self.setData({
+ tokens: updatedTokens
+ })
+
+ // 保存到本地存储
+ wx.setStorageSync('tokens', updatedTokens)
+
+ wx.showToast({
+ title: '更新成功',
+ icon: 'success',
+ duration: 1500
+ })
+
+ setTimeout(() => {
+ wx.navigateBack({
+ delta: 1,
+ })
+ }, 1500)
+ },
+ fail: function(err) {
+ wx.showToast({
+ title: '保存失败',
+ icon: 'error',
+ duration: 2000
+ })
+ },
+ complete: function() {
+ self.setData({
+ isSubmitting: false
+ })
+ }
+ })
+ },
+
+ /**
+ * 生命周期函数--监听页面卸载
+ */
+ onUnload: function () {
+ // 清理数据,防止下次加载时出现数据混淆
+ this.setData({
+ token: [],
+ token_id: null,
+ hasLoaded: false,
+ isSubmitting: false,
+ issuer: '',
+ remark: '',
+ secret: '',
+ type: '',
+ counter: 0,
+ isHotp: false
+ })
+ }
+})
\ No newline at end of file
diff --git a/pages/edit/edit.json b/pages/edit/edit.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/pages/edit/edit.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/pages/edit/edit.wxml b/pages/edit/edit.wxml
new file mode 100644
index 0000000..6d9be71
--- /dev/null
+++ b/pages/edit/edit.wxml
@@ -0,0 +1,33 @@
+
+
\ No newline at end of file
diff --git a/pages/edit/edit.wxss b/pages/edit/edit.wxss
new file mode 100644
index 0000000..bd1b8de
--- /dev/null
+++ b/pages/edit/edit.wxss
@@ -0,0 +1,104 @@
+/* pages/edit/edit.wxss */
+page {
+ background: #f8f9fa;
+}
+
+.container {
+ padding: 20rpx;
+ box-sizing: border-box;
+ min-height: 100vh;
+}
+
+.section {
+ margin-bottom: 30rpx;
+ background: #ffffff;
+ border-radius: 12rpx;
+ padding: 24rpx;
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
+}
+
+.section-title {
+ color: #333333;
+ font-size: 32rpx;
+ font-weight: 500;
+ margin-bottom: 24rpx;
+}
+
+.input-box {
+ margin-top: 20rpx;
+ background: transparent;
+ border: none;
+ padding: 0;
+}
+
+.input-box text {
+ color: #333333;
+ font-size: 28rpx;
+ display: block;
+ margin-bottom: 12rpx;
+}
+
+.input-box input {
+ width: 100%;
+ height: 80rpx;
+ padding: 0 20rpx;
+ background: #ffffff;
+ border: 1rpx solid #e0e0e0;
+ border-radius: 8rpx;
+ font-size: 28rpx;
+ color: #333333;
+ box-sizing: border-box;
+}
+
+.input-box input:focus {
+ border-color: #007AFF;
+}
+
+.tip-text {
+ color: #ff9c10;
+ font-size: 24rpx;
+ margin-top: 16rpx;
+ opacity: 0.8;
+}
+
+.button-group {
+ padding: 0;
+ margin-top: 40rpx;
+ display: flex;
+ justify-content: space-between;
+}
+
+.add-btn {
+ margin-top: 0;
+ flex: 1;
+}
+
+.add-btn button {
+ width: 100%;
+ height: 80rpx;
+ border-radius: 8rpx;
+ background: #007AFF;
+ color: white;
+ font-size: 28rpx;
+ border: none;
+}
+
+.add-btn button[disabled] {
+ background: rgba(0, 122, 255, 0.5);
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.edit-ad {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ padding: 20rpx;
+ box-sizing: border-box;
+ background: #f8f9fa;
+}
+
+.edit-ad ad {
+ margin: 0 auto;
+ border-radius: 8rpx;
+ overflow: hidden;
+}
\ No newline at end of file
diff --git a/pages/form/form.js b/pages/form/form.js
new file mode 100644
index 0000000..be7e276
--- /dev/null
+++ b/pages/form/form.js
@@ -0,0 +1,196 @@
+// pages/form/form.js
+const {
+ addToken,
+ parseURL,
+ validateToken,
+ showToast
+} = require('../../utils/util');
+
+Page({
+ /**
+ * 页面的初始数据
+ */
+ data: {
+ type: 'totp', // 默认选择TOTP
+ isSubmitting: false // 防止重复提交
+ },
+
+ /**
+ * 生命周期函数--监听页面加载
+ */
+ onLoad: function (options) {
+ // 准备初始数据
+ const initialData = {
+ type: options.type || 'totp',
+ formData: {
+ issuer: '',
+ remark: '',
+ secret: '',
+ algo: 'SHA1',
+ digits: '6',
+ period: '30',
+ counter: '0'
+ },
+ pageReady: true // 直接设置为ready状态
+ };
+
+ // 如果有扫描结果,预处理数据
+ if (options.scan) {
+ try {
+ const scanData = decodeURIComponent(options.scan);
+ const parsedToken = parseURL(scanData);
+
+ if (parsedToken) {
+ initialData.type = parsedToken.type;
+ initialData.formData = {
+ issuer: parsedToken.issuer || '',
+ remark: parsedToken.remark || '',
+ secret: parsedToken.secret || '',
+ algo: parsedToken.algo || 'SHA1',
+ digits: parsedToken.digits || '6',
+ period: parsedToken.period || '30',
+ counter: parsedToken.counter || '0'
+ };
+
+ // 立即显示成功提示
+ wx.showToast({
+ title: '二维码解析成功',
+ icon: 'success',
+ duration: 1500
+ });
+ }
+ } catch (error) {
+ console.error('解析扫描数据失败:', error);
+ wx.showToast({
+ title: '无效的二维码数据',
+ icon: 'none',
+ duration: 1500
+ });
+ }
+ }
+
+ // 一次性设置所有数据
+ this.setData(initialData);
+ },
+
+ /**
+ * 处理类型切换
+ */
+ onTypeChange: function(e) {
+ this.setData({
+ type: e.detail.value
+ });
+ },
+
+ /**
+ * 验证并格式化表单数据
+ */
+ validateAndFormatForm: function(values) {
+ try {
+ // 基本字段验证
+ if (!values.issuer || !values.issuer.trim()) {
+ throw new Error('请输入服务名称');
+ }
+ if (!values.remark || !values.remark.trim()) {
+ throw new Error('请输入账号备注');
+ }
+ if (!values.secret || !values.secret.trim()) {
+ throw new Error('请输入密钥');
+ }
+
+ // 格式化数据
+ const tokenData = {
+ type: values.type,
+ issuer: values.issuer.trim(),
+ remark: values.remark.trim(),
+ secret: values.secret.trim().toUpperCase(),
+ algo: values.algo,
+ digits: parseInt(values.digits, 10)
+ };
+
+ // 类型特定字段
+ if (values.type === 'totp') {
+ const period = parseInt(values.period, 10);
+ if (isNaN(period) || period < 15 || period > 300) {
+ throw new Error('更新周期必须在15-300秒之间');
+ }
+ tokenData.period = period;
+ } else {
+ const counter = parseInt(values.counter, 10);
+ if (isNaN(counter) || counter < 0) {
+ throw new Error('计数器初始值必须大于等于0');
+ }
+ tokenData.counter = counter;
+ }
+
+ // 使用工具函数验证完整的令牌数据
+ const errors = validateToken(tokenData);
+ if (errors) {
+ throw new Error(errors.join('\n'));
+ }
+
+ return tokenData;
+ } catch (error) {
+ showToast(error.message, 'none');
+ return null;
+ }
+ },
+
+ /**
+ * 提交数据
+ */
+ keySubmit: async function (e) {
+ // 防止重复提交
+ if (this.data.isSubmitting) {
+ return;
+ }
+
+ this.setData({ isSubmitting: true });
+
+ try {
+ const values = e.detail.value;
+ const tokenData = this.validateAndFormatForm(values);
+
+ if (!tokenData) {
+ this.setData({ isSubmitting: false });
+ return;
+ }
+
+ // 添加令牌
+ await addToken(tokenData);
+
+ // 显示成功提示
+ wx.showToast({
+ title: '添加成功',
+ icon: 'success',
+ mask: true,
+ duration: 800
+ });
+
+ // 获取页面事件通道
+ const eventChannel = this.getOpenerEventChannel();
+
+ // 通知上一页面刷新数据
+ if (eventChannel && typeof eventChannel.emit === 'function') {
+ eventChannel.emit('tokenAdded');
+ }
+
+ // 立即跳转到首页
+ wx.switchTab({
+ url: '/pages/index/index'
+ });
+
+ } catch (error) {
+ console.error('添加令牌失败:', error);
+ wx.showToast({
+ title: error.message || '添加失败',
+ icon: 'none',
+ duration: 2000
+ });
+ } finally {
+ this.setData({ isSubmitting: false });
+ }
+ },
+
+ // 扫描功能已移至主页面
+})
\ No newline at end of file
diff --git a/pages/form/form.json b/pages/form/form.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/pages/form/form.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/pages/form/form.wxml b/pages/form/form.wxml
new file mode 100644
index 0000000..f13face
--- /dev/null
+++ b/pages/form/form.wxml
@@ -0,0 +1,82 @@
+
+
\ No newline at end of file
diff --git a/pages/form/form.wxss b/pages/form/form.wxss
new file mode 100644
index 0000000..0606dd4
--- /dev/null
+++ b/pages/form/form.wxss
@@ -0,0 +1,142 @@
+/* pages/form/form.wxss */
+page {
+ background: #f8f9fa;
+ padding-bottom: 40rpx;
+ transition: opacity 0.2s ease;
+}
+
+.container {
+ padding: 20rpx 0;
+ opacity: 1;
+}
+
+/* 页面加载动画 - 优化性能 */
+@keyframes quickFadeIn {
+ from { opacity: 0.8; }
+ to { opacity: 1; }
+}
+
+.page-loaded {
+ animation: quickFadeIn 0.15s ease-out;
+}
+
+/* 区块样式 */
+.section {
+ margin-bottom: 30rpx;
+ background: #ffffff;
+ border-radius: 12rpx;
+ padding: 24rpx;
+ margin: 0 20rpx 30rpx;
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
+}
+
+.section-title {
+ color: #007AFF;
+ font-size: 32rpx;
+ font-weight: 500;
+ margin-bottom: 24rpx;
+}
+
+.input-box {
+ margin-top: 20rpx;
+ background: transparent;
+ border: none;
+ padding: 0;
+}
+
+.input-box text {
+ color: #333333;
+ font-size: 28rpx;
+ display: block;
+ margin-bottom: 12rpx;
+}
+
+.input-box input {
+ width: 100%;
+ height: 80rpx;
+ padding: 0 20rpx;
+ background: #ffffff;
+ border: 1rpx solid #e0e0e0;
+ border-radius: 8rpx;
+ font-size: 28rpx;
+ color: #333333;
+ box-sizing: border-box;
+}
+
+.input-box input:focus {
+ border-color: #007AFF;
+}
+
+/* 单选框组样式 */
+.input-box radio-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20rpx;
+ margin-top: 20rpx;
+}
+
+.input-box radio {
+ margin-right: 20rpx;
+}
+
+.input-box radio .wx-radio-input {
+ background: #ffffff !important;
+ border-color: #e0e0e0 !important;
+ width: 36rpx !important;
+ height: 36rpx !important;
+}
+
+.input-box radio .wx-radio-input.wx-radio-input-checked {
+ background: #007AFF !important;
+ border-color: #007AFF !important;
+}
+
+/* 按钮组样式 */
+.button-group {
+ padding: 30rpx 20rpx;
+ display: flex;
+ justify-content: center;
+}
+
+.submit-button {
+ width: 100% !important;
+ height: 80rpx !important;
+ border-radius: 8rpx !important;
+ background: #007AFF !important;
+ color: #ffffff !important;
+ font-weight: 500 !important;
+ font-size: 28rpx !important;
+ border: none !important;
+ box-shadow: 0 4rpx 16rpx rgba(0, 122, 255, 0.3) !important;
+}
+
+.submit-button[disabled] {
+ background: #e0e0e0 !important;
+ color: #999999 !important;
+ opacity: 0.7 !important;
+ box-shadow: none !important;
+}
+
+/* 广告样式 */
+.form-ad {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ background: #f8f9fa;
+ padding: 20rpx;
+ box-sizing: border-box;
+}
+
+.form-ad ad {
+ margin: 0 auto;
+ border-radius: 8rpx;
+ overflow: hidden;
+}
+
+/* 错误提示样式 */
+.error-text {
+ color: #ff4444;
+ font-size: 24rpx;
+ margin-top: 16rpx;
+ padding: 0 20rpx;
+}
\ No newline at end of file
diff --git a/pages/index/index.js b/pages/index/index.js
new file mode 100644
index 0000000..7de06f9
--- /dev/null
+++ b/pages/index/index.js
@@ -0,0 +1,721 @@
+// index.js
+const {
+ generateCode,
+ getRemainingSeconds,
+ formatTime,
+ syncTokens
+} = require('../../utils/util');
+const storage = require('../../utils/storage');
+const eventManager = require('../../utils/eventManager');
+
+Page({
+ data: {
+ tokens: [],
+ remainingSeconds: {}, // 存储每个令牌的剩余时间
+ loading: true,
+ syncing: false,
+ error: null
+ },
+
+ onLoad: function() {
+ // 初始化页面数据
+ this.loadTokens();
+
+ // 监听令牌更新事件
+ this.tokensUpdatedListener = (tokens) => {
+ this.loadTokens();
+ };
+ eventManager.on('tokensUpdated', this.tokensUpdatedListener);
+
+ // 初始化页面路由
+ this.pageRoutes = {
+ form: '/pages/form/form',
+ edit: '/pages/edit/edit'
+ };
+ },
+
+ onShow: function() {
+ // 先加载令牌列表
+ this.loadTokens();
+ // 然后延迟100ms刷新验证码,确保tokens已经加载
+ setTimeout(() => {
+ this.refreshTokens();
+ }, 100);
+ },
+
+ onPullDownRefresh: function() {
+ this.refreshTokens().then(() => {
+ wx.stopPullDownRefresh();
+ });
+ },
+
+ async loadTokens() {
+ try {
+ this.setData({ loading: true, error: null });
+
+ // 从本地存储加载令牌
+ const tokens = await wx.getStorageSync('tokens') || [];
+ // console.log('[DEBUG] Loaded tokens count:', tokens.length);
+
+ // 统计令牌类型
+ const typeCounts = tokens.reduce((acc, token) => {
+ acc[token.type] = (acc[token.type] || 0) + 1;
+ return acc;
+ }, {});
+ // console.log('[DEBUG] Token types:', typeCounts);
+
+ // 初始化剩余时间
+ const remainingSeconds = {};
+ tokens.forEach(token => {
+ if (token.type === 'totp') {
+ const period = token.period || 30;
+ const remaining = getRemainingSeconds(period);
+ // console.log(`[DEBUG] Token ${token.id} period=${period}, remaining=${remaining}`);
+ remainingSeconds[token.id] = remaining;
+ }
+ });
+
+ // 获取当前时间戳
+ const timestamp = Math.floor(Date.now() / 1000);
+
+ // 先更新验证码
+ let tokensToSet = [...tokens]; // 创建tokens的副本
+ if (tokensToSet.length > 0) {
+ const updatedTokens = await this.updateTokenCodes(tokensToSet);
+ if (Array.isArray(updatedTokens)) {
+ tokensToSet = updatedTokens;
+ }
+ }
+
+ // 设置状态
+ this.setData({
+ tokens: tokensToSet,
+ remainingSeconds,
+ loading: false
+ });
+
+ // 设置定时器,定期更新TOTP令牌
+ this.setupRefreshTimer();
+ } catch (error) {
+ console.error('Failed to load tokens:', error);
+ this.setData({
+ error: '加载令牌失败: ' + error.message,
+ loading: false
+ });
+ }
+ },
+
+ async refreshTokens() {
+ try {
+ const currentTokens = [...this.data.tokens]; // 创建tokens的副本
+ const updatedTokens = await this.updateTokenCodes(currentTokens);
+
+ // 更新剩余时间
+ const remainingSeconds = {};
+ updatedTokens.forEach(token => {
+ if (token.type === 'totp') {
+ remainingSeconds[token.id] = getRemainingSeconds(token.period || 30);
+ }
+ });
+
+ this.setData({
+ tokens: updatedTokens,
+ remainingSeconds
+ });
+ } catch (error) {
+ console.error('Failed to refresh tokens:', error);
+ this.setData({
+ error: '刷新令牌失败: ' + error.message
+ });
+ }
+ },
+
+ setupRefreshTimer() {
+ // 清除现有的定时器
+ if (this.refreshTimer) {
+ clearInterval(this.refreshTimer);
+ }
+
+ // console.log('[DEBUG] Setting up refresh timer...');
+
+ // 设置新的定时器,每秒检查一次
+ this.refreshTimer = setInterval(() => {
+ // console.log('[DEBUG] Timer tick - checking tokens...');
+
+ const currentTokens = [...this.data.tokens]; // 创建tokens的副本
+ const currentRemainingSeconds = this.data.remainingSeconds || {};
+ const remainingSeconds = {};
+ const tokensToUpdate = [];
+
+ currentTokens.forEach(token => {
+ // 处理所有支持的OTP类型
+ const supportedTypes = ['totp', 'hotp', 'otpauth'];
+ if (!supportedTypes.includes(token.type)) {
+ // console.log(`[DEBUG] Skipping unsupported token type: ${token.type}`);
+ return;
+ }
+
+ // console.log(`[DEBUG] Processing ${token.type.toUpperCase()} token ${token.id || token.name}`);
+
+ // 针对不同类型做特殊处理
+ switch(token.type) {
+ case 'totp':
+ // 标准TOTP处理逻辑
+ break;
+ case 'hotp':
+ // HOTP特定处理
+ // console.log(`[DEBUG] HOTP token detected, counter: ${token.counter}`);
+ break;
+ case 'otpauth':
+ // 扫描二维码生成的令牌
+ // console.log(`[DEBUG] OTPAUTH URI token detected`);
+ break;
+ }
+
+ const period = token.period || 30;
+ const remaining = getRemainingSeconds(period);
+ const prevRemaining = currentRemainingSeconds[token.id];
+
+ // console.log(`[DEBUG] Token ${token.id}: prev=${prevRemaining}, curr=${remaining}`);
+
+ // 更新剩余时间
+ remainingSeconds[token.id] = remaining;
+
+ // 在以下情况下更新token:
+ // 1. 剩余时间为0(新的时间窗口开始)
+ // 2. 之前没有剩余时间记录(首次加载)
+ // 3. 时间窗口发生变化(prevRemaining > remaining,说明跨越了时间窗口)
+ if (remaining === 0 || prevRemaining === undefined || (prevRemaining > 0 && remaining < prevRemaining)) {
+ // console.log(`[DEBUG] Token ${token.id} needs update`);
+ tokensToUpdate.push({...token}); // 创建token的副本
+ }
+ });
+
+ // 只在有变化时更新剩余时间
+ if (Object.keys(remainingSeconds).length > 0) {
+ // console.log('[DEBUG] Updating remainingSeconds:', remainingSeconds);
+ this.setData({
+ 'remainingSeconds': {
+ ...currentRemainingSeconds,
+ ...remainingSeconds
+ }
+ }, () => {
+ // console.log('[DEBUG] remainingSeconds updated');
+ });
+ }
+
+ // 只更新需要更新的token,并传入当前时间戳
+ if (tokensToUpdate.length > 0) {
+ // console.log('[DEBUG] Updating tokens:', tokensToUpdate.length);
+ const timestamp = Math.floor(Date.now() / 1000);
+ this.updateSelectedTokens(tokensToUpdate, timestamp);
+ }
+ }, 1000);
+ },
+
+ // 只更新选定的token
+ async updateSelectedTokens(tokensToUpdate, timestamp) {
+ try {
+ const currentTokens = this.data.tokens;
+ // 获取当前时间戳,如果没有传入
+ const currentTimestamp = timestamp || Math.floor(Date.now() / 1000);
+
+ // 为每个token计算正确的时间戳
+ const updatePromises = tokensToUpdate.map(token => {
+ if (token.type === 'totp') {
+ // 计算时间窗口的开始时间
+ const period = token.period || 30;
+ const windowStart = Math.floor(currentTimestamp / period) * period;
+ return this.updateTokenCode(token, windowStart);
+ } else {
+ // 对于HOTP类型,直接使用当前时间戳
+ return this.updateTokenCode(token, currentTimestamp);
+ }
+ });
+
+ const updatedTokens = await Promise.all(updatePromises);
+
+ // 创建token ID到更新后token的映射
+ const updatedTokenMap = {};
+ updatedTokens.forEach(token => {
+ updatedTokenMap[token.id] = token;
+ });
+
+ // 更新tokens数组中的特定token
+ const newTokens = currentTokens.map(token =>
+ updatedTokenMap[token.id] ? updatedTokenMap[token.id] : token
+ );
+
+ this.setData({ tokens: newTokens });
+ } catch (error) {
+ console.error('更新选定token失败:', error);
+ }
+ },
+
+ async updateTokenCodes(tokens) {
+ if (!Array.isArray(tokens) || tokens.length === 0) {
+ return [];
+ }
+
+ try {
+ // 获取当前时间戳
+ const currentTimestamp = Math.floor(Date.now() / 1000);
+
+ // 并行更新所有令牌的验证码,为每个令牌计算其时间窗口的开始时间
+ const updatePromises = tokens.map(token => {
+ if (token.type === 'totp') {
+ // 计算时间窗口的开始时间
+ const period = token.period || 30;
+ const windowStart = Math.floor(currentTimestamp / period) * period;
+ return this.updateTokenCode(token, windowStart);
+ } else {
+ // 对于HOTP类型,直接使用当前时间戳
+ return this.updateTokenCode(token, currentTimestamp);
+ }
+ });
+
+ const updatedTokens = await Promise.all(updatePromises);
+ return updatedTokens;
+ } catch (error) {
+ console.error('更新验证码失败:', error);
+ // 出错时返回原始tokens,避免undefined
+ return tokens;
+ }
+ },
+
+ async updateTokenCode(token, timestamp) {
+ try {
+ // 生成新的验证码,使用传入的统一时间戳
+ const code = await generateCode({
+ type: token.type,
+ secret: token.secret,
+ algorithm: token.algorithm || 'SHA1',
+ digits: token.digits || 6,
+ period: token.period || 30,
+ counter: token.counter,
+ timestamp: timestamp // 使用传入的统一时间戳
+ });
+
+ // 更新令牌数据
+ token.code = code;
+ token.lastUpdate = formatTime(new Date());
+
+ return token;
+ } catch (error) {
+ console.error(`更新令牌 ${token.id} 失败:`, error);
+ token.error = error.message;
+ return token;
+ }
+ },
+
+ async refreshHotpToken(event) {
+ const tokenId = event.currentTarget.dataset.tokenId;
+ const token = this.data.tokens.find(t => t.id === tokenId);
+
+ if (!token) return;
+
+ try {
+ // 增加计数器值
+ const newCounter = (token.counter || 0) + 1;
+
+ // 生成新的验证码
+ const code = await generateCode({
+ type: 'hotp',
+ secret: token.secret,
+ algorithm: token.algorithm || 'SHA1',
+ counter: newCounter,
+ digits: token.digits || 6
+ });
+
+ // 更新令牌数据
+ token.code = code;
+ token.counter = newCounter;
+ token.lastUpdate = formatTime(new Date());
+
+ // 更新存储和状态
+ const tokens = this.data.tokens.map(t =>
+ t.id === token.id ? token : t
+ );
+
+ await wx.setStorageSync('tokens', tokens);
+ this.setData({ tokens });
+
+ // 同步到云端
+ if (this.data.syncing) {
+ await syncTokens(tokens);
+ }
+ } catch (error) {
+ console.error(`刷新HOTP令牌 ${token.id} 失败:`, error);
+ this.setData({
+ error: '刷新HOTP令牌失败: ' + error.message
+ });
+ }
+ },
+
+ async syncWithCloud() {
+ try {
+ this.setData({ syncing: true, error: null });
+
+ // 获取本地令牌
+ const localTokens = this.data.tokens;
+
+ // 同步令牌
+ const syncedTokens = await syncTokens(localTokens);
+
+ // 更新所有令牌的验证码
+ await this.updateTokenCodes(syncedTokens);
+
+ // 更新存储和状态
+ await wx.setStorageSync('tokens', syncedTokens);
+ this.setData({
+ tokens: syncedTokens,
+ syncing: false
+ });
+ } catch (error) {
+ console.error('同步失败:', error);
+ this.setData({
+ error: '同步失败: ' + error.message,
+ syncing: false
+ });
+ }
+ },
+
+ onUnload: function() {
+ // 清除定时器
+ if (this.refreshTimer) {
+ clearInterval(this.refreshTimer);
+ }
+
+ // 移除令牌更新事件监听
+ if (this.tokensUpdatedListener) {
+ eventManager.off('tokensUpdated', this.tokensUpdatedListener);
+ }
+ },
+
+ // 处理令牌点击事件
+ handleTokenTap(event) {
+ const tokenId = event.currentTarget.dataset.tokenId;
+ const token = this.data.tokens.find(t => t.id === tokenId);
+
+ if (!token) return;
+
+ // 复制验证码到剪贴板
+ wx.setClipboardData({
+ data: token.code,
+ success: () => {
+ wx.showToast({
+ title: '验证码已复制',
+ icon: 'success',
+ duration: 1500
+ });
+ }
+ });
+ },
+
+ // 显示添加令牌菜单
+ showAddTokenMenu() {
+ wx.showActionSheet({
+ itemList: ['扫描二维码', '手动添加'],
+ success: (res) => {
+ switch (res.tapIndex) {
+ case 0: // 扫描二维码
+ wx.scanCode({
+ onlyFromCamera: false,
+ scanType: ['qrCode'],
+ success: (res) => {
+ try {
+ // 解析二维码内容
+ const qrContent = res.result;
+ // 如果是otpauth://格式的URL
+ if (qrContent.startsWith('otpauth://')) {
+ // 小程序兼容的URL解析
+ const [protocolAndPath, search] = qrContent.split('?');
+ const [protocol, path] = protocolAndPath.split('://');
+ const type = protocol.replace('otpauth:', '');
+
+ // 解析路径部分
+ const decodedPath = decodeURIComponent(path.substring(1)); // 移除开头的/
+ let [issuer, remark] = decodedPath.split(':');
+ if (!remark) {
+ remark = issuer;
+ issuer = '';
+ }
+
+ // 解析查询参数
+ const params = {};
+ if (search) {
+ search.split('&').forEach(pair => {
+ const [key, value] = pair.split('=');
+ if (key && value) {
+ params[key] = decodeURIComponent(value);
+ }
+ });
+ }
+
+ // 从参数中获取issuer(如果存在)
+ if (params.issuer) {
+ issuer = params.issuer;
+ }
+
+ // 验证secret参数
+ if (!params.secret) {
+ wx.showToast({
+ title: '无效的二维码:缺少secret参数',
+ icon: 'none',
+ duration: 2000
+ });
+ return;
+ }
+
+ // 将otpauth类型转换为实际类型
+ let validType = type.toLowerCase();
+ if (validType === 'otpauth') {
+ // 从URI路径中提取实际类型
+ validType = path.split('/')[0].toLowerCase();
+ }
+
+ // 构建表单数据,确保数字类型参数正确转换
+ const formData = {
+ type: validType,
+ issuer,
+ remark,
+ secret: params.secret,
+ algorithm: params.algorithm || 'SHA1',
+ digits: params.digits ? parseInt(params.digits, 10) : 6,
+ period: validType === 'totp' ? (params.period ? parseInt(params.period, 10) : 30) : undefined,
+ counter: validType === 'hotp' ? (params.counter ? parseInt(params.counter, 10) : 0) : undefined
+ };
+
+ // 验证必要参数
+ if (formData.digits < 6 || formData.digits > 8) {
+ formData.digits = 6;
+ console.warn('验证码位数无效,已设置为默认值6');
+ }
+
+ if (validType === 'totp' && (formData.period < 15 || formData.period > 60)) {
+ formData.period = 30;
+ console.warn('TOTP周期无效,已设置为默认值30秒');
+ }
+
+ // 检查必要参数是否完整
+ if (formData.secret && formData.type) {
+ // 直接添加令牌
+ this.addTokenDirectly(formData).then(() => {
+ wx.showToast({
+ title: '令牌已添加',
+ icon: 'success'
+ });
+ }).catch(error => {
+ console.error('添加令牌失败:', error);
+ // 失败后跳转到表单页面
+ const queryString = Object.entries(formData)
+ .filter(([_, value]) => value !== undefined)
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
+ .join('&');
+
+ wx.navigateTo({
+ url: `${this.pageRoutes.form}?${queryString}`
+ });
+ });
+ } else {
+ // 参数不完整,跳转到表单页面
+ const queryString = Object.entries(formData)
+ .filter(([_, value]) => value !== undefined)
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
+ .join('&');
+
+ wx.navigateTo({
+ url: `${this.pageRoutes.form}?${queryString}`
+ });
+ }
+ } else {
+ wx.showToast({
+ title: '无效的二维码格式',
+ icon: 'error'
+ });
+ }
+ } catch (error) {
+ console.error('解析二维码失败:', error);
+ wx.showToast({
+ title: '解析二维码失败',
+ icon: 'error'
+ });
+ }
+ },
+ fail: (error) => {
+ console.error('扫描二维码失败:', error);
+ if (error.errMsg.includes('cancel')) {
+ // 用户取消扫码,不显示错误提示
+ return;
+ }
+ wx.showToast({
+ title: '扫描失败',
+ icon: 'error'
+ });
+ }
+ });
+ break;
+ case 1: // 手动添加
+ wx.navigateTo({
+ url: '/pages/form/form',
+ events: {
+ // 监听form页面的事件
+ tokenAdded: () => {
+ this.loadTokens();
+ }
+ },
+ success: () => {
+ // 跳转成功后的回调
+ wx.showToast({
+ title: '请填写令牌信息',
+ icon: 'none',
+ duration: 1000
+ });
+ }
+ });
+ break;
+ }
+ }
+ });
+ },
+
+ // 编辑令牌
+ editToken(event) {
+ const id = event.currentTarget.dataset.tokenId;
+ wx.navigateTo({
+ url: `${this.pageRoutes.edit}?token_id=${id}`
+ });
+ },
+
+ // 删除令牌
+ deleteToken(event) {
+ const id = event.currentTarget.dataset.id;
+ const that = this;
+
+ wx.showModal({
+ title: '确认删除',
+ content: '确定要删除这个令牌吗?',
+ success(res) {
+ if (res.confirm) {
+ // 从本地存储中删除
+ wx.getStorage({
+ key: 'tokens',
+ success(res) {
+ const tokens = res.data.filter(token => token.id !== id);
+ wx.setStorage({
+ key: 'tokens',
+ data: tokens,
+ success() {
+ // 更新UI
+ that.setData({ tokens });
+ wx.showToast({
+ title: '删除成功',
+ icon: 'success'
+ });
+
+ // 如果正在同步,更新云端
+ if (that.data.syncing) {
+ syncTokens(tokens).catch(err => {
+ console.error('同步删除失败:', err);
+ });
+ }
+ },
+ fail() {
+ wx.showToast({
+ title: '删除失败',
+ icon: 'none'
+ });
+ }
+ });
+ }
+ });
+ }
+ }
+ });
+ },
+
+ // 直接添加令牌
+ async addTokenDirectly(tokenData) {
+ try {
+ // 获取当前令牌列表
+ const tokens = await wx.getStorageSync('tokens') || [];
+
+ // 生成唯一ID和时间戳
+ const newToken = {
+ ...tokenData,
+ id: Date.now().toString(),
+ createdAt: new Date().toISOString(),
+ lastUpdate: new Date().toISOString()
+ };
+
+ // 如果是TOTP类型,先初始化剩余时间
+ if ((newToken.type || 'totp').toLowerCase() === 'totp') {
+ const period = parseInt(newToken.period || '30', 10);
+ const currentRemainingSeconds = this.data.remainingSeconds || {};
+
+ // 先更新剩余时间,避免闪烁
+ this.setData({
+ remainingSeconds: {
+ ...currentRemainingSeconds,
+ [newToken.id]: getRemainingSeconds(period)
+ }
+ });
+ }
+
+ // 生成验证码
+ try {
+ // 确保类型是字符串且为小写
+ const type = (newToken.type || 'totp').toLowerCase();
+ // 确保digits是数字
+ const digits = parseInt(newToken.digits || '6', 10);
+ // 确保period是数字
+ const period = parseInt(newToken.period || '30', 10);
+ // 确保counter是数字(如果存在)
+ const counter = newToken.counter ? parseInt(newToken.counter, 10) : undefined;
+
+ const code = await generateCode({
+ type,
+ secret: newToken.secret,
+ algorithm: newToken.algorithm || 'SHA1',
+ digits,
+ period,
+ counter
+ });
+ newToken.code = code;
+ } catch (error) {
+ console.error('生成验证码失败:', error);
+ wx.showToast({
+ title: '生成验证码失败: ' + error.message,
+ icon: 'none',
+ duration: 2000
+ });
+ throw error;
+ }
+
+ // 添加到列表并保存
+ const updatedTokens = [...tokens, newToken];
+ await wx.setStorageSync('tokens', updatedTokens);
+
+ // 更新UI,使用setData的回调确保UI更新完成
+ this.setData({ tokens: updatedTokens }, () => {
+ // 触发更新事件
+ eventManager.emit('tokensUpdated', updatedTokens);
+
+ // 如果正在同步,更新云端
+ if (this.data.syncing) {
+ syncTokens(updatedTokens).catch(err => {
+ console.error('同步新令牌失败:', err);
+ });
+ }
+ });
+ } catch (error) {
+ console.error('直接添加令牌失败:', error);
+ throw error;
+ }
+ },
+
+ // 阻止触摸移动
+ catchTouchMove() {
+ return false;
+ }
+});
\ No newline at end of file
diff --git a/pages/index/index.json b/pages/index/index.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/pages/index/index.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/pages/index/index.wxml b/pages/index/index.wxml
new file mode 100644
index 0000000..b114535
--- /dev/null
+++ b/pages/index/index.wxml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+ {{error}}
+
+
+
+
+
+ 🔐
+ 暂无验证器
+ 点击下方按钮添加新的验证器
+
+
+
+
+
+
+
+
+
+
+
+
+ {{token.code || '------'}}
+
+
+
+ 🔄
+
+
+
+ ✏️
+
+
+
+ 🗑️
+
+
+
+
+
+
+
+
+ 剩余 {{remainingSeconds[token.id]}} 秒
+
+
+
+
+
+
+ 计数器: {{token.counter || 0}}
+ 点击刷新按钮生成新的验证码
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ 添加验证器
+
+
\ No newline at end of file
diff --git a/pages/index/index.wxss b/pages/index/index.wxss
new file mode 100644
index 0000000..ff28617
--- /dev/null
+++ b/pages/index/index.wxss
@@ -0,0 +1,301 @@
+/**index.wxss**/
+.container {
+ padding: 20rpx;
+ box-sizing: border-box;
+ min-height: 100vh;
+ background: #f8f9fa;
+}
+
+.container.is-loading {
+ overflow: hidden;
+}
+
+/* 加载状态 */
+.loading-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(255, 255, 255, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 999;
+}
+
+.loading {
+ width: 100%;
+ text-align: center;
+ padding: 40rpx 0;
+ color: #666;
+}
+
+/* 错误提示 */
+.error-message {
+ width: 100%;
+ padding: 20rpx;
+ background-color: #ffebee;
+ color: #c62828;
+ text-align: center;
+ margin-bottom: 20rpx;
+ border-radius: 8rpx;
+ font-size: 28rpx;
+}
+
+/* 滚动视图 */
+.scroll-view {
+ width: 100%;
+ height: calc(100vh - 40rpx);
+}
+
+/* 空状态 */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 120rpx 40rpx;
+ text-align: center;
+}
+
+.empty-icon {
+ font-size: 80rpx;
+ margin-bottom: 20rpx;
+}
+
+.empty-title {
+ font-size: 36rpx;
+ color: #333;
+ margin-bottom: 12rpx;
+ font-weight: 500;
+}
+
+.empty-desc {
+ font-size: 28rpx;
+ color: #999;
+}
+
+/* 令牌列表 */
+.token-list {
+ width: 100%;
+ padding-bottom: 180rpx;
+}
+
+.token-item {
+ background: #fff;
+ border-radius: 12rpx;
+ padding: 24rpx;
+ margin-bottom: 20rpx;
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
+ position: relative;
+ transition: all 0.3s ease;
+}
+
+.token-item.warn {
+ background: #fff8f8;
+}
+
+.token-content {
+ width: 100%;
+}
+
+/* 令牌头部 */
+.token-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 16rpx;
+}
+
+.token-type {
+ font-size: 24rpx;
+ padding: 4rpx 12rpx;
+ border-radius: 4rpx;
+ margin-right: 12rpx;
+}
+
+.token-type.totp {
+ background: #e3f2fd;
+ color: #1976d2;
+}
+
+.token-type.hotp {
+ background: #f3e5f5;
+ color: #7b1fa2;
+}
+
+.token-issuer {
+ font-size: 32rpx;
+ color: #333;
+ font-weight: 500;
+}
+
+/* 令牌主体 */
+.token-body {
+ width: 100%;
+}
+
+.code-container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 20rpx 0;
+}
+
+.code {
+ font-size: 48rpx;
+ font-family: monospace;
+ color: #007AFF;
+ font-weight: bold;
+ letter-spacing: 2rpx;
+}
+
+.code-actions {
+ display: flex;
+ align-items: center;
+}
+
+/* HOTP刷新按钮 */
+.refresh-btn {
+ width: 64rpx;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 12rpx;
+ opacity: 0.6;
+ transition: all 0.3s ease;
+}
+
+.refresh-btn.refreshing {
+ opacity: 0.3;
+ pointer-events: none;
+}
+
+.refresh-icon {
+ font-size: 36rpx;
+}
+
+.refresh-icon.spin {
+ animation: spin 1s linear infinite;
+}
+
+/* 编辑按钮 */
+.edit-btn {
+ width: 64rpx;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0.6;
+}
+
+.edit-icon {
+ font-size: 32rpx;
+}
+
+/* 删除按钮 */
+.delete-btn {
+ width: 64rpx;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0.6;
+ margin-left: 12rpx;
+}
+
+.delete-icon {
+ font-size: 32rpx;
+ color: #ff4d4f;
+}
+
+.delete-btn.button-hover {
+ opacity: 0.8;
+}
+
+/* TOTP进度条 */
+.totp-info {
+ margin-top: 16rpx;
+}
+
+.remaining-time {
+ font-size: 24rpx;
+ color: #666;
+ margin-bottom: 8rpx;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 4rpx;
+ background: #e0e0e0;
+ border-radius: 2rpx;
+ overflow: hidden;
+}
+
+.progress-bar .progress {
+ height: 100%;
+ background: #4caf50;
+ transition: width 1s linear;
+}
+
+.progress-bar.warn .progress {
+ background: #f44336;
+}
+
+/* HOTP计数器信息 */
+.counter-info {
+ margin-top: 16rpx;
+ font-size: 24rpx;
+ color: #666;
+}
+
+.counter-info .hint {
+ margin-left: 12rpx;
+ color: #999;
+}
+
+/* 添加按钮 */
+.add-button {
+ position: fixed;
+ bottom: 40rpx;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #007AFF;
+ border-radius: 40rpx;
+ padding: 20rpx 32rpx;
+ display: flex;
+ align-items: center;
+ box-shadow: 0 4rpx 12rpx rgba(0, 122, 255, 0.3);
+ transition: all 0.3s ease;
+}
+
+.add-icon {
+ color: white;
+ font-size: 40rpx;
+ margin-right: 8rpx;
+}
+
+.add-text {
+ color: white;
+ font-size: 28rpx;
+}
+
+/* 广告容器 */
+.ad-container {
+ width: 100%;
+ margin-top: 20rpx;
+ margin-bottom: 20rpx;
+}
+
+/* 动画 */
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+/* 按钮点击效果 */
+.button-hover {
+ opacity: 0.7;
+ transform: scale(0.98);
+}
\ No newline at end of file
diff --git a/pages/info/info.js b/pages/info/info.js
new file mode 100644
index 0000000..81027cd
--- /dev/null
+++ b/pages/info/info.js
@@ -0,0 +1,66 @@
+// pages/info/info.js
+Page({
+
+ /**
+ * 页面的初始数据
+ */
+ data: {
+
+ },
+
+ /**
+ * 生命周期函数--监听页面加载
+ */
+ onLoad: function (options) {
+
+ },
+
+ /**
+ * 生命周期函数--监听页面初次渲染完成
+ */
+ onReady: function () {
+
+ },
+
+ /**
+ * 生命周期函数--监听页面显示
+ */
+ onShow: function () {
+
+ },
+
+ /**
+ * 生命周期函数--监听页面隐藏
+ */
+ onHide: function () {
+
+ },
+
+ /**
+ * 生命周期函数--监听页面卸载
+ */
+ onUnload: function () {
+
+ },
+
+ /**
+ * 页面相关事件处理函数--监听用户下拉动作
+ */
+ onPullDownRefresh: function () {
+
+ },
+
+ /**
+ * 页面上拉触底事件的处理函数
+ */
+ onReachBottom: function () {
+
+ },
+
+ /**
+ * 用户点击右上角分享
+ */
+ onShareAppMessage: function () {
+
+ }
+})
\ No newline at end of file
diff --git a/pages/info/info.json b/pages/info/info.json
new file mode 100644
index 0000000..a05af9b
--- /dev/null
+++ b/pages/info/info.json
@@ -0,0 +1,3 @@
+{
+ "navigationBarTitleText": "介绍"
+ }
\ No newline at end of file
diff --git a/pages/info/info.wxml b/pages/info/info.wxml
new file mode 100644
index 0000000..d27dc6f
--- /dev/null
+++ b/pages/info/info.wxml
@@ -0,0 +1,20 @@
+
+
+
+ 个人主页:
+ xhupo.com
+
+
+ 评论博客:
+ memos.zeroc.net
+
+
+ 开发工具:
+ VSCode + 腾讯云CodeBuddy
+
+
+ 项目源码:
+ git.xhupo.com/otp
+
+
+
\ No newline at end of file
diff --git a/pages/info/info.wxss b/pages/info/info.wxss
new file mode 100644
index 0000000..e6983a9
--- /dev/null
+++ b/pages/info/info.wxss
@@ -0,0 +1,31 @@
+.container {
+ padding: 20px;
+ display: flex;
+ justify-content: center;
+}
+
+.info-card {
+ width: 100%;
+ max-width: 500px;
+ background: #fff;
+ border-radius: 10px;
+ padding: 20px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+}
+
+.info-item {
+ margin-bottom: 15px;
+ display: flex;
+ align-items: center;
+}
+
+.label {
+ font-weight: bold;
+ width: 80px;
+ color: #333;
+}
+
+.link {
+ color: #07C160;
+ text-decoration: none;
+}
\ No newline at end of file
diff --git a/pages/mine/mine.js b/pages/mine/mine.js
new file mode 100644
index 0000000..7f1b5b8
--- /dev/null
+++ b/pages/mine/mine.js
@@ -0,0 +1,217 @@
+// pages/mine/mine.js
+const {
+ syncTokens,
+ getCloudTokens,
+ showToast,
+ showLoading,
+ hideLoading
+} = require('../../utils/util');
+
+Page({
+ /**
+ * 页面的初始数据
+ */
+ data: {
+ loading: false,
+ uploading: false,
+ isLoggedIn: false,
+ userInfo: null,
+ currentYear: new Date().getFullYear()
+ },
+
+ onShow: function() {
+ // 每次显示页面时检查登录状态
+ const app = getApp();
+ this.setData({
+ isLoggedIn: !!app.globalData.token,
+ userInfo: app.globalData.userInfo
+ });
+ },
+
+ /**
+ * 数据备份
+ */
+ uploadData: async function() {
+ // 检查登录状态
+ if (!this.data.isLoggedIn) {
+ showToast('请先登录');
+ return;
+ }
+
+ try {
+ const tokens = wx.getStorageSync('tokens') || [];
+
+ if (!tokens.length) {
+ showToast('未发现本地数据');
+ return;
+ }
+
+ // 显示确认对话框
+ const confirmed = await new Promise(resolve => {
+ wx.showModal({
+ title: '数据备份',
+ content: '确定备份本地数据到云端?',
+ confirmText: '确定',
+ confirmColor: '#ff9c10',
+ success: res => resolve(res.confirm)
+ });
+ });
+
+ if (!confirmed) return;
+
+ this.setData({ uploading: true });
+ showLoading('正在备份...');
+
+ // 同步到云端
+ await syncTokens(tokens);
+
+ showToast('数据备份成功', 'success');
+ } catch (error) {
+ console.error('数据备份失败:', error);
+ showToast('数据备份失败,请重试');
+ } finally {
+ this.setData({ uploading: false });
+ hideLoading();
+ }
+ },
+
+ /**
+ * 数据恢复
+ */
+ restoreData: async function() {
+ // 检查登录状态
+ if (!this.data.isLoggedIn) {
+ showToast('请先登录');
+ return;
+ }
+
+ try {
+ this.setData({ loading: true });
+ showLoading('正在获取云端数据...');
+
+ // 获取云端数据
+ const cloudTokens = await getCloudTokens();
+
+ if (!cloudTokens || !cloudTokens.length) {
+ showToast('未发现备份数据');
+ return;
+ }
+
+ // 显示确认对话框
+ const confirmed = await new Promise(resolve => {
+ wx.showModal({
+ title: '云端数据恢复',
+ content: `发现${cloudTokens.length}条数据,确定使用云端数据覆盖本地记录?`,
+ confirmColor: '#ff9c10',
+ success: res => resolve(res.confirm)
+ });
+ });
+
+ if (!confirmed) return;
+
+ // 保存到本地
+ await wx.setStorageSync('tokens', cloudTokens);
+ showToast('数据恢复成功', 'success');
+
+ } catch (error) {
+ console.error('数据恢复失败:', error);
+ showToast('数据恢复失败,请重试');
+ } finally {
+ this.setData({ loading: false });
+ hideLoading();
+ }
+ },
+
+ /**
+ * 用户授权登录
+ */
+ goAuth: async function() {
+ if (this.data.isLoggedIn) return;
+
+ try {
+ showLoading('登录中...');
+ const app = getApp();
+ await app.userLogin();
+
+ // 更新登录状态,使用默认值
+ this.setData({
+ isLoggedIn: !!app.globalData.token,
+ userInfo: {
+ avatarUrl: wx.getStorageSync('userAvatar') || '/images/default-avatar.png',
+ nickName: wx.getStorageSync('userNickName') || '微信用户'
+ }
+ });
+
+ showToast('登录成功', 'success');
+ } catch (error) {
+ console.error('登录失败:', error);
+ showToast('登录失败,请重试');
+ } finally {
+ hideLoading();
+ }
+ },
+
+ /**
+ * 处理头像选择
+ */
+ onChooseAvatar: async function(e) {
+ const { avatarUrl } = e.detail;
+ try {
+ showLoading('更新头像中...');
+
+ // 更新头像
+ this.setData({
+ 'userInfo.avatarUrl': avatarUrl
+ });
+
+ // 保存到本地存储
+ wx.setStorageSync('userAvatar', avatarUrl);
+
+ showToast('头像更新成功', 'success');
+ } catch (error) {
+ console.error('头像更新失败:', error);
+ showToast('头像更新失败,请重试');
+ } finally {
+ hideLoading();
+ }
+ },
+
+ /**
+ * 处理昵称修改
+ */
+ onNicknameChange: function(e) {
+ const nickName = e.detail.value;
+ if (!nickName) return;
+
+ try {
+ // 更新昵称
+ this.setData({
+ 'userInfo.nickName': nickName
+ });
+
+ // 保存到本地存储
+ wx.setStorageSync('userNickName', nickName);
+ } catch (error) {
+ console.error('昵称更新失败:', error);
+ showToast('昵称更新失败,请重试');
+ }
+ },
+
+ /**
+ * 页面相关事件处理函数--监听用户下拉动作
+ */
+ onPullDownRefresh: function() {
+ wx.stopPullDownRefresh();
+ },
+
+ /**
+ * 转发
+ */
+ onShareAppMessage: function() {
+ return {
+ title: '支持云端备份的动态验证码',
+ path: '/pages/index/index',
+ imageUrl: '/images/share.png'
+ };
+ }
+});
\ No newline at end of file
diff --git a/pages/mine/mine.json b/pages/mine/mine.json
new file mode 100644
index 0000000..ca06959
--- /dev/null
+++ b/pages/mine/mine.json
@@ -0,0 +1,5 @@
+{
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": false,
+ "component": true
+}
\ No newline at end of file
diff --git a/pages/mine/mine.wxml b/pages/mine/mine.wxml
new file mode 100644
index 0000000..fa0df1c
--- /dev/null
+++ b/pages/mine/mine.wxml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/mine/mine.wxss b/pages/mine/mine.wxss
new file mode 100644
index 0000000..d8e327a
--- /dev/null
+++ b/pages/mine/mine.wxss
@@ -0,0 +1,121 @@
+/* pages/info/info.wxss */
+page {
+ background: #f5f5f5;
+}
+.mine{
+ z-index: 0;
+}
+.top-bg{
+ z-index: -1;
+ position: fixed;
+ top:0;
+ width: 100%;
+ height: 550rpx;
+ background: #f8f9fa;
+ background-image: -webkit-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
+ background-image: -moz-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
+ background-image: -o-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
+ background-image: radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
+}
+.user-bg {
+ height: 550rpx;
+ display: flex;
+ justify-content: center;
+ padding-top: 40rpx;
+ overflow: hidden;
+ position: relative;
+ flex-direction: column;
+ align-items: center;
+ color: #333;
+ font-weight: 400;
+ text-shadow: 0 0 3px rgba(255, 255, 255, 0.3);
+}
+.userinfo {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 20rpx;
+}
+.userinfo-avatar {
+ width: 150rpx;
+ height: 150rpx;
+ border-radius: 50%;
+ overflow: hidden;
+ margin: 20rpx auto;
+ justify-content: center;
+}
+
+.userinfo-nickname {
+ color: #666;
+ text-align: center;
+ background-color: transparent;
+}
+
+.avatar-wrapper {
+ padding: 0;
+ width: 150rpx !important;
+ height: 150rpx !important;
+ border-radius: 50%;
+ background-color: transparent;
+ margin: 20rpx auto;
+}
+
+.avatar-wrapper::after {
+ border: none;
+}
+
+.login-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ background-color: transparent;
+ padding: 0;
+ margin: 0;
+ line-height: normal;
+}
+
+.login-btn::after {
+ border: none;
+}
+
+input.userinfo-nickname {
+ width: 200rpx;
+ border-bottom: 1px solid rgba(102, 102, 102, 0.5);
+ padding: 5rpx 10rpx;
+ margin-top: 10rpx;
+}
+
+.user-bg text {
+ opacity: 0.9;
+}
+
+.user-bg image {
+ width: 400rpx;
+ height: 200rpx;
+}
+.btns{
+ z-index: 10;
+ position: relative;
+}
+.btn{
+ z-index: 10;
+ margin: 1rem;
+ border-radius: 100px;
+ background: #ffffff;
+ color: #ff9c10;
+ border: 2px solid #e8e8e8;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+}
+.btn[disabled]:not([type]){
+ background: #ffffff;
+ color: #ff9c10;
+}
+.footer{
+ position: absolute;
+ bottom: 20px;
+ margin-top: 10rem;
+ font-size: 12px;
+ text-align: center;
+ width: 100%;
+ color: #999;
+}
\ No newline at end of file
diff --git a/project.config.json b/project.config.json
new file mode 100644
index 0000000..37a0e35
--- /dev/null
+++ b/project.config.json
@@ -0,0 +1,25 @@
+{
+ "setting": {
+ "es6": true,
+ "postcss": true,
+ "minified": true,
+ "uglifyFileName": false,
+ "enhance": true,
+ "packNpmRelationList": [],
+ "babelSetting": {
+ "ignore": [],
+ "disablePlugins": [],
+ "outputPath": ""
+ },
+ "useCompilerPlugins": false,
+ "minifyWXML": true
+ },
+ "compileType": "miniprogram",
+ "simulatorPluginLibVersion": {},
+ "packOptions": {
+ "ignore": [],
+ "include": []
+ },
+ "appid": "wxb6599459668b6b55",
+ "editorSetting": {}
+}
\ No newline at end of file
diff --git a/project.private.config.json b/project.private.config.json
new file mode 100644
index 0000000..493b9cf
--- /dev/null
+++ b/project.private.config.json
@@ -0,0 +1,14 @@
+{
+ "libVersion": "3.8.6",
+ "projectname": "%25E5%258A%25A8%25E6%2580%2581%25E4%25BB%25A4%25E7%2589%258C",
+ "setting": {
+ "urlCheck": true,
+ "coverView": true,
+ "lazyloadPlaceholderEnable": false,
+ "skylineRenderEnable": false,
+ "preloadBackgroundData": false,
+ "autoAudits": false,
+ "showShadowRootInWxmlPanel": true,
+ "compileHotReLoad": true
+ }
+}
\ No newline at end of file
diff --git a/sitemap.json b/sitemap.json
new file mode 100644
index 0000000..27b2b26
--- /dev/null
+++ b/sitemap.json
@@ -0,0 +1,7 @@
+{
+ "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
+ "rules": [{
+ "action": "allow",
+ "page": "*"
+ }]
+}
\ No newline at end of file
diff --git a/utils/.gitignore b/utils/.gitignore
new file mode 100644
index 0000000..c912533
--- /dev/null
+++ b/utils/.gitignore
@@ -0,0 +1 @@
+__tests__
diff --git a/utils/auth.js b/utils/auth.js
new file mode 100644
index 0000000..2c6e6f8
--- /dev/null
+++ b/utils/auth.js
@@ -0,0 +1,311 @@
+/**
+ * 认证相关工具函数
+ * 包含JWT token管理、自动刷新、请求拦截等功能
+ */
+
+const config = require('./config');
+const eventManager = require('./eventManager');
+
+// Token刷新状态
+let isRefreshing = false;
+// 等待token刷新的请求队列
+let refreshSubscribers = [];
+
+/**
+ * 订阅Token刷新
+ * @param {Function} callback - Token刷新后的回调函数
+ */
+const subscribeTokenRefresh = (callback) => {
+ refreshSubscribers.push(callback);
+};
+
+/**
+ * 执行Token刷新后的回调
+ * @param {string} token - 新的access token
+ */
+const onTokenRefreshed = (token) => {
+ refreshSubscribers.forEach(callback => callback(token));
+ refreshSubscribers = [];
+};
+
+/**
+ * 解析JWT Token
+ * @param {string} token - JWT token
+ * @returns {Object|null} 解析后的payload或null
+ */
+const parseToken = (token) => {
+ try {
+ const [, payload] = token.split('.');
+ return JSON.parse(atob(payload));
+ } catch (error) {
+ console.error('Token解析失败:', error);
+ return null;
+ }
+};
+
+/**
+ * 检查Token是否需要刷新
+ * @param {string} token - JWT token
+ * @returns {boolean} 是否需要刷新
+ */
+const shouldRefreshToken = (token) => {
+ const decoded = parseToken(token);
+ if (!decoded || !decoded.exp) return true;
+
+ const expiresIn = decoded.exp * 1000 - Date.now();
+ return expiresIn < config.JWT_CONFIG.refreshThreshold;
+};
+
+/**
+ * 刷新Token
+ * @returns {Promise} 新的access token或null
+ */
+const refreshToken = async () => {
+ const refreshToken = wx.getStorageSync(config.JWT_CONFIG.storage.refresh);
+ if (!refreshToken) return null;
+
+ try {
+ const response = await wx.request({
+ url: `${config.API_BASE_URL}${config.API_ENDPOINTS.AUTH.REFRESH}`,
+ method: 'POST',
+ header: {
+ [config.JWT_CONFIG.headerKey]: `${config.JWT_CONFIG.tokenPrefix}${refreshToken}`
+ }
+ });
+
+ if (response.statusCode === 200 && response.data.access_token) {
+ const { access_token, refresh_token } = response.data;
+ wx.setStorageSync(config.JWT_CONFIG.storage.access, access_token);
+ if (refresh_token) {
+ wx.setStorageSync(config.JWT_CONFIG.storage.refresh, refresh_token);
+ }
+ return access_token;
+ }
+
+ throw new Error(response.data?.message || '刷新Token失败');
+ } catch (error) {
+ console.error('刷新Token失败:', error);
+ logout(); // 刷新失败时登出
+ return null;
+ }
+};
+
+/**
+ * 用户登录
+ * @param {string} username - 用户名
+ * @param {string} password - 密码
+ * @returns {Promise