This commit is contained in:
“xHuPo” 2025-05-23 18:57:11 +08:00
parent a45ddf13d5
commit bcd986e3f7
46 changed files with 6166 additions and 454 deletions

View file

@ -0,0 +1,169 @@
// otp-add/index.js
import { createOTP } from '../../services/otp';
Page({
data: {
form: {
name: '',
issuer: '',
secret: '',
algorithm: 'SHA1',
digits: 6,
period: 30
},
algorithms: ['SHA1', 'SHA256', 'SHA512'],
digitOptions: [6, 8],
periodOptions: [30, 60],
submitting: false,
scanMode: false
},
// 处理输入变化
handleInputChange(e) {
const { field } = e.currentTarget.dataset;
const { value } = e.detail;
this.setData({
[`form.${field}`]: value
});
},
// 处理选择器变化
handlePickerChange(e) {
const { field } = e.currentTarget.dataset;
const { value } = e.detail;
const options = this.data[`${field}Options`] || this.data[field];
const selectedValue = options[value];
this.setData({
[`form.${field}`]: selectedValue
});
},
// 扫描二维码
handleScanQRCode() {
this.setData({ scanMode: true });
wx.scanCode({
scanType: ['qrCode'],
success: (res) => {
try {
// 解析otpauth://协议的URL
const url = res.result;
if (url.startsWith('otpauth://totp/')) {
const parsedUrl = new URL(url);
const path = parsedUrl.pathname.substring(1); // 移除开头的斜杠
// 解析路径中的issuer和name
let issuer = '';
let name = path;
if (path.includes(':')) {
const parts = path.split(':');
issuer = parts[0];
name = parts[1];
}
// 从查询参数中获取其他信息
const secret = parsedUrl.searchParams.get('secret') || '';
const algorithm = parsedUrl.searchParams.get('algorithm') || 'SHA1';
const digits = parseInt(parsedUrl.searchParams.get('digits') || '6');
const period = parseInt(parsedUrl.searchParams.get('period') || '30');
// 如果查询参数中有issuer优先使用
if (parsedUrl.searchParams.get('issuer')) {
issuer = parsedUrl.searchParams.get('issuer');
}
this.setData({
form: {
name,
issuer,
secret,
algorithm,
digits,
period
}
});
wx.showToast({
title: '二维码解析成功',
icon: 'success'
});
} else {
wx.showToast({
title: '不支持的二维码格式',
icon: 'none'
});
}
} catch (err) {
wx.showToast({
title: '二维码解析失败',
icon: 'none'
});
}
},
fail: () => {
wx.showToast({
title: '扫描取消',
icon: 'none'
});
},
complete: () => {
this.setData({ scanMode: false });
}
});
},
// 提交表单
handleSubmit() {
const { form } = this.data;
// 表单验证
if (!form.name) {
wx.showToast({
title: '请输入名称',
icon: 'none'
});
return;
}
if (!form.secret) {
wx.showToast({
title: '请输入密钥',
icon: 'none'
});
return;
}
this.setData({ submitting: true });
createOTP(form)
.then(() => {
wx.showToast({
title: '添加成功',
icon: 'success'
});
// 返回上一页
setTimeout(() => {
wx.navigateBack();
}, 1500);
})
.catch(err => {
wx.showToast({
title: err.message || '添加失败',
icon: 'none'
});
})
.finally(() => {
this.setData({ submitting: false });
});
},
// 取消
handleCancel() {
wx.navigateBack();
}
});

View file

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View file

@ -0,0 +1,119 @@
<!-- otp-add/index.wxml -->
<view class="container">
<view class="header">
<text class="title">添加OTP</text>
</view>
<view class="form-container">
<view class="form-group">
<text class="form-label">名称 <text class="required">*</text></text>
<input
class="form-input"
placeholder="请输入OTP名称"
value="{{form.name}}"
bindinput="handleInputChange"
data-field="name"
/>
</view>
<view class="form-group">
<text class="form-label">发行方</text>
<input
class="form-input"
placeholder="请输入发行方名称"
value="{{form.issuer}}"
bindinput="handleInputChange"
data-field="issuer"
/>
</view>
<view class="form-group">
<text class="form-label">密钥 <text class="required">*</text></text>
<view class="secret-input-container">
<input
class="form-input"
placeholder="请输入密钥或扫描二维码"
value="{{form.secret}}"
bindinput="handleInputChange"
data-field="secret"
/>
<view class="scan-button" bindtap="handleScanQRCode" wx:if="{{!scanMode}}">
<text class="scan-icon">🔍</text>
</view>
<view class="scanning-indicator" wx:else>
<view class="scanning-spinner"></view>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label">算法</text>
<picker
mode="selector"
range="{{algorithms}}"
value="{{algorithms.indexOf(form.algorithm)}}"
bindchange="handlePickerChange"
data-field="algorithm"
>
<view class="picker-view">
<text>{{form.algorithm}}</text>
<text class="picker-arrow">▼</text>
</view>
</picker>
</view>
<view class="form-row">
<view class="form-group half">
<text class="form-label">位数</text>
<picker
mode="selector"
range="{{digitOptions}}"
value="{{digitOptions.indexOf(form.digits)}}"
bindchange="handlePickerChange"
data-field="digits"
>
<view class="picker-view">
<text>{{form.digits}}</text>
<text class="picker-arrow">▼</text>
</view>
</picker>
</view>
<view class="form-group half">
<text class="form-label">周期(秒)</text>
<picker
mode="selector"
range="{{periodOptions}}"
value="{{periodOptions.indexOf(form.period)}}"
bindchange="handlePickerChange"
data-field="period"
>
<view class="picker-view">
<text>{{form.period}}</text>
<text class="picker-arrow">▼</text>
</view>
</picker>
</view>
</view>
</view>
<view class="button-group">
<button
class="cancel-button"
bindtap="handleCancel"
disabled="{{submitting}}"
>取消</button>
<button
class="submit-button {{submitting ? 'loading' : ''}}"
bindtap="handleSubmit"
disabled="{{submitting}}"
>
<text wx:if="{{!submitting}}">保存</text>
<view wx:else class="loading-container">
<view class="loading-icon"></view>
<text>保存中...</text>
</view>
</button>
</view>
</view>

View file

@ -0,0 +1,176 @@
/* otp-add/index.wxss */
.container {
min-height: 100vh;
background-color: #f8f8f8;
padding: 0 0 40rpx 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 40rpx 32rpx;
background-color: #ffffff;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333333;
}
.form-container {
background-color: #ffffff;
padding: 32rpx;
margin: 32rpx;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.form-group {
margin-bottom: 32rpx;
}
.form-row {
display: flex;
justify-content: space-between;
}
.form-group.half {
width: 48%;
}
.form-label {
display: block;
font-size: 28rpx;
color: #666666;
margin-bottom: 12rpx;
}
.required {
color: #ff4d4f;
}
.form-input {
width: 100%;
height: 80rpx;
background-color: #f5f5f5;
border-radius: 8rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333333;
box-sizing: border-box;
}
.secret-input-container {
position: relative;
}
.scan-button {
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.scan-icon {
font-size: 40rpx;
color: #1890ff;
}
.scanning-indicator {
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
width: 40rpx;
height: 40rpx;
}
.scanning-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.picker-view {
width: 100%;
height: 80rpx;
background-color: #f5f5f5;
border-radius: 8rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333333;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.picker-arrow {
font-size: 24rpx;
color: #999999;
}
.button-group {
display: flex;
justify-content: space-between;
padding: 32rpx;
}
.cancel-button, .submit-button {
width: 48%;
height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.cancel-button {
background-color: #f5f5f5;
color: #666666;
}
.submit-button {
background-color: #1890ff;
color: #ffffff;
}
.submit-button.loading {
background-color: #8cc4ff;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
}
.loading-icon {
width: 36rpx;
height: 36rpx;
margin-right: 10rpx;
border: 4rpx solid #ffffff;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s linear infinite;
}