feat: 增加深浅Theme切换和一些实用工具
This commit is contained in:
1 parent
cbe6695df7
commit
10c524bc67
24 files changed
+1724
-434
No files matched your search
@@ -77,6 +77,11 @@ accounts.get('/', async (c) => {
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
// 获取服务器当前时间 (用于时间校准)
|
||||
accounts.get('/server-time', (c) => {
|
||||
return c.json({ success: true, time: Date.now() });
|
||||
});
|
||||
|
||||
// 添加新账号
|
||||
accounts.post('/', async (c) => {
|
||||
const { service, category, account, secret, digits = 6, period = 30 } = await c.req.json();
|
||||
|
||||
@@ -32,25 +32,3 @@ onBeforeUnmount(() => {
|
||||
<TheFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh; /* 改为固定高度,以便内部滚动 */
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: hidden; /* 防止 main 自身滚动,让子元素决定 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
/>
|
||||
|
||||
<div class="batch-actions" v-if="selectedIds.length > 0" style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="color: #606266; font-size: 14px;">已选择 {{ selectedIds.length }} 项</span>
|
||||
<span class="batch-text">已选择 {{ selectedIds.length }} 项</span>
|
||||
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
|
||||
<el-icon><Delete /></el-icon> 批量删除
|
||||
</el-button>
|
||||
@@ -31,9 +31,9 @@
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="account in accounts" :key="account.id" style="margin-bottom: 20px;">
|
||||
<el-card class="account-card" :class="{ 'is-selected': selectedIds.includes(account.id) }" shadow="hover" @click="copyCode(account)">
|
||||
<div class="card-header">
|
||||
<div class="service-info" style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="service-info">
|
||||
<el-checkbox :model-value="selectedIds.includes(account.id)" @change="toggleSelection(account.id)" @click.stop />
|
||||
<h3 class="service-name">{{ account.service }}</h3>
|
||||
<h3 class="service-name" :title="account.service">{{ account.service }}</h3>
|
||||
<el-tag size="small" v-if="account.category" effect="light">{{ account.category }}</el-tag>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<p class="account-name" style="margin-left: 24px;">{{ account.account }}</p>
|
||||
<p class="account-name">{{ account.account }}</p>
|
||||
|
||||
<div class="code-display-area">
|
||||
<div class="code-left">
|
||||
@@ -73,7 +73,7 @@
|
||||
:color="account.color || '#67C23A'"
|
||||
>
|
||||
<template #default>
|
||||
<span class="timer-text-small">{{ account.remaining || 30 }}</span>
|
||||
<span class="timer-text">{{ account.remaining || 30 }}</span>
|
||||
</template>
|
||||
</el-progress>
|
||||
</div>
|
||||
@@ -443,48 +443,4 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
if (globalTimer) clearInterval(globalTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-card { border-radius: 12px; transition: all 0.3s ease; border: none; cursor: pointer; }
|
||||
.account-card:hover { transform: translateY(-5px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
|
||||
.service-name { margin: 0 0 5px 0; font-size: 18px; color: #303133; }
|
||||
.account-name { color: #909399; font-size: 14px; margin: 0 0 10px 0; word-break: break-all; }
|
||||
.more-icon { cursor: pointer; color: #909399; padding: 5px; }
|
||||
.more-icon:hover { color: #409EFF; }
|
||||
.account-card.is-selected { border: 1px solid #409EFF; background-color: #f4f9ff; }
|
||||
|
||||
.code-display-area { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; padding-left: 24px; }
|
||||
.code-left { display: flex; flex-direction: column; justify-content: center; }
|
||||
.current-code { font-size: 24px; font-weight: bold; color: #409EFF; letter-spacing: 2px; line-height: 1; font-family: monospace; }
|
||||
.next-code { font-size: 16px; color: #909399; margin-top: 4px; font-family: monospace; }
|
||||
.timer-text-small { font-size: 12px; color: #606266; font-weight: bold; display: block; text-align: center; }
|
||||
:deep(.el-progress__text) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: none; /* 移除默认的偏移,由 Flex 接管 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width:30px;
|
||||
}
|
||||
|
||||
.qr-container { display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.qr-info { margin-bottom: 15px; }
|
||||
.qr-service { margin: 0 0 5px 0; font-size: 20px; color: #303133; }
|
||||
.qr-account { margin: 0; color: #909399; font-size: 14px; }
|
||||
.qr-image-wrapper { margin: 10px 0; padding: 10px; background: #fff; border-radius: 8px; border: 1px solid #EBEEF5; }
|
||||
.qr-code-img { display: block; width: 200px; height: 200px; }
|
||||
.qr-tip { font-size: 12px; color: #606266; margin: 10px 0 20px 0; }
|
||||
|
||||
.secret-section { width: 100%; margin-bottom: 15px; }
|
||||
.secret-box { display: flex; align-items: center; justify-content: space-between; background: #f5f7fa; padding: 10px 15px; border-radius: 6px; border: 1px solid #e4e7ed; }
|
||||
.secret-text { font-family: monospace; font-size: 14px; color: #606266; letter-spacing: 1px; }
|
||||
.secret-actions { display: flex; gap: 15px; }
|
||||
.action-icon { cursor: pointer; font-size: 16px; color: #909399; transition: color 0.2s; }
|
||||
.action-icon:hover { color: #409EFF; }
|
||||
.uri-link-wrapper { margin-top: 5px; }
|
||||
</style>
|
||||
</script>
|
||||
@@ -87,14 +87,4 @@ const submitAddAccount = async () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">📷 扫码添加</h2>
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
|
||||
<p style="color: #909399;">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
|
||||
<p style="color: var(--el-text-color-secondary);">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
|
||||
</div>
|
||||
<QrScanner @scan-success="handleScanSuccess" />
|
||||
</div>
|
||||
@@ -57,14 +57,4 @@ const handleScanSuccess = async (uri) => {
|
||||
if (err !== 'cancel') console.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
@@ -117,9 +117,9 @@
|
||||
</div>
|
||||
<div v-if="!(isEditing && hasExistingAutoPwd && configUseExistingAutoPwd)" style="width: 100%;">
|
||||
<el-input v-model="form.autoBackupPassword" type="password" show-password placeholder="输入加密密码" />
|
||||
<div class="form-tip" style="font-size: 12px; color: #909399; margin-top: 5px;"><span style="color: #F56C6C;">*</span> 必填项,长度必须 ≥ 12 位。</div>
|
||||
<div class="form-tip"><span style="color: #F56C6C;">*</span> 必填项,长度必须 ≥ 12 位。</div>
|
||||
</div>
|
||||
<div v-else style="padding: 10px; background-color: #f0f9eb; border-radius: 4px; color: #67C23A; font-size: 13px; display: flex; align-items: center; gap: 8px; width: 100%;">
|
||||
<div v-else class="success-tip">
|
||||
<el-icon><CircleCheck /></el-icon><span>系统将继续使用原有的自动备份密码。</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -145,7 +145,7 @@
|
||||
<div v-if="!useAutoPassword">
|
||||
<el-input v-model="backupPassword" type="password" show-password placeholder="输入加密密码 (至少12位)" />
|
||||
</div>
|
||||
<div v-else style="padding: 10px; background-color: #f0f9eb; border-radius: 4px; color: #67C23A; font-size: 13px; display: flex; align-items: center; gap: 8px;">
|
||||
<div v-else class="success-tip">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<span>系统将使用您预设的自动备份密码进行加密。</span>
|
||||
</div>
|
||||
@@ -398,14 +398,4 @@ const handleRestore = async () => {
|
||||
}
|
||||
|
||||
onMounted(fetchProviders)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.provider-card { height: 100%; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.provider-info { display: flex; align-items: center; }
|
||||
.provider-title { display: flex; align-items: center; gap: 8px; font-weight: bold; flex-wrap: wrap; }
|
||||
.status-text { font-size: 13px; color: #666; margin-bottom: 15px; }
|
||||
.action-buttons { display: flex; gap: 10px; }
|
||||
</style>
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>云端备份中心</h2>
|
||||
<p style="color: #909399;">配置多个备份源(WebDAV, S3等),确保数据万无一失。</p>
|
||||
<p style="color: var(--el-text-color-secondary);">配置多个备份源(WebDAV, S3等),确保数据万无一失。</p>
|
||||
</div>
|
||||
<BackupSettings @restore-success="$emit('success')" />
|
||||
</div>
|
||||
@@ -14,14 +14,4 @@
|
||||
import BackupSettings from './BackupSettings.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>数据导出</h2>
|
||||
<p style="color: #909399;">选择您需要的导出格式。请注意,明文导出存在安全风险。</p>
|
||||
<p style="color: var(--el-text-color-secondary);">选择您需要的导出格式。请注意,明文导出存在安全风险。</p>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
@@ -125,18 +125,4 @@ const executeExport = async () => {
|
||||
showWarningDialog.value = false
|
||||
} catch (error) {} finally { isExporting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.export-options { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
|
||||
.export-options .el-button { margin: 0; justify-content: flex-start; padding-left: 20px; height: auto; padding-top: 15px; padding-bottom: 15px; }
|
||||
@media (max-width: 768px) { .export-options { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="tab-card-wrapper">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h2>数据导入</h2>
|
||||
<p style="color: #909399;">支持从各类 2FA 软件或本系统的备份文件中恢复数据。</p>
|
||||
<p style="color: var(--el-text-color-secondary);">支持从各类 2FA 软件或本系统的备份文件中恢复数据。</p>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;" v-loading="isImporting && !showDecryptDialog" :element-loading-text="loadingText">
|
||||
@@ -101,17 +101,4 @@ const submitImportData = async () => {
|
||||
}
|
||||
} catch (error) {} finally { isImporting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-card-wrapper {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
.import-options { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
|
||||
.import-options .el-button { margin: 0; justify-content: flex-start; padding-left: 20px; height: auto; padding-top: 15px; padding-bottom: 15px; }
|
||||
@media (max-width: 768px) { .import-options { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</script>
|
||||
@@ -12,35 +12,4 @@
|
||||
|
||||
<script setup>
|
||||
// 这里可以添加页脚需要的逻辑,例如版本号显示等
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-footer {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 0.5rem;
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<el-button v-if="layoutState.isMobile && route.path !== '/login'" link @click="layoutState.showMobileMenu = true" style="margin-right: 5px; padding: 0;">
|
||||
<el-icon :size="24" color="#606266"><Menu /></el-icon>
|
||||
<el-icon :size="24" class="menu-icon"><Menu /></el-icon>
|
||||
</el-button>
|
||||
<el-icon :size="24" color="#409EFC" style="margin-right: 10px;"><Lock /></el-icon>
|
||||
<h2>2FAuth</h2>
|
||||
@@ -12,54 +12,16 @@
|
||||
<el-avatar :size="32" :src="userState.userInfo?.avatar_template" />
|
||||
<span class="username">{{ userState.userInfo?.username }}</span>
|
||||
</div>
|
||||
<el-button type="danger" circle @click="handleLogout" title="退出登录">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Lock, SwitchButton, Menu } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Lock, Menu } from '@element-plus/icons-vue'
|
||||
import { layoutState } from '../states/layout'
|
||||
import { userState } from '../states/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const handleLogout = () => {
|
||||
userState.clearUserInfo()
|
||||
router.push('/login')
|
||||
ElMessage.success('已安全退出')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo, .user-actions, .user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo h2 { margin: 0; color: #303133; font-size: 20px; }
|
||||
.user-actions { gap: 20px; }
|
||||
.user-profile { gap: 10px; background: #f0f2f5; padding: 4px 15px 4px 4px; border-radius: 20px; }
|
||||
.username { font-weight: 600; color: #606266; font-size: 14px; }
|
||||
|
||||
@media (max-width: 768px) { .header { padding: 0 15px; } .logo h2 { font-size: 18px; } }
|
||||
</style>
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="tool-pane">
|
||||
<div class="result-box">
|
||||
<div class="password-display">{{ password }}</div>
|
||||
<el-button type="primary" link @click="copyPassword" title="复制">
|
||||
<el-icon size="20"><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-row">
|
||||
<span class="label">长度: {{ length }}</span>
|
||||
<el-slider v-model="length" :min="6" :max="64" @input="generate" style="flex: 1; margin: 0 15px;" />
|
||||
</div>
|
||||
|
||||
<div class="control-row checkboxes">
|
||||
<el-checkbox v-model="useUpper" label="A-Z" @change="generate" />
|
||||
<el-checkbox v-model="useLower" label="a-z" @change="generate" />
|
||||
<el-checkbox v-model="useNumber" label="0-9" @change="generate" />
|
||||
<el-checkbox v-model="useSymbol" label="#$@" @change="generate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" size="large" @click="generate" style="width: 100%; margin-top: 20px;">
|
||||
<el-icon><Refresh /></el-icon> 重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CopyDocument, Refresh } from '@element-plus/icons-vue'
|
||||
|
||||
const password = ref('')
|
||||
const length = ref(16)
|
||||
const useUpper = ref(true)
|
||||
const useLower = ref(true)
|
||||
const useNumber = ref(true)
|
||||
const useSymbol = ref(true)
|
||||
|
||||
const generate = () => {
|
||||
let chars = ''
|
||||
if (useLower.value) chars += 'abcdefghijklmnopqrstuvwxyz'
|
||||
if (useUpper.value) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
if (useNumber.value) chars += '0123456789'
|
||||
if (useSymbol.value) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
|
||||
if (!chars) return (password.value = '')
|
||||
|
||||
let res = ''
|
||||
const array = new Uint32Array(length.value)
|
||||
window.crypto.getRandomValues(array)
|
||||
for (let i = 0; i < length.value; i++) {
|
||||
res += chars[array[i] % chars.length]
|
||||
}
|
||||
password.value = res
|
||||
}
|
||||
|
||||
const copyPassword = async () => {
|
||||
if (!password.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(password.value)
|
||||
ElMessage.success('密码已复制')
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(generate)
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="tool-pane">
|
||||
<div class="qr-parser-container">
|
||||
<el-radio-group v-model="parserMode" style="margin-bottom: 20px; width: 100%; display: flex; justify-content: center;">
|
||||
<el-radio-button label="camera">摄像头扫描</el-radio-button>
|
||||
<el-radio-button label="image">图片识别</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<!-- Camera Mode -->
|
||||
<div v-if="parserMode === 'camera'" class="scan-area">
|
||||
<div class="video-wrapper" v-show="isScanning">
|
||||
<video ref="videoEl" playsinline muted style="width: 100%; border-radius: 8px; background: #000;"></video>
|
||||
</div>
|
||||
<div v-if="!isScanning" class="camera-placeholder" @click="toggleCamera">
|
||||
<el-icon :size="48" color="#909399"><Camera /></el-icon>
|
||||
<p>点击开启摄像头</p>
|
||||
</div>
|
||||
<el-button :type="isScanning ? 'danger' : 'primary'" @click="toggleCamera" style="margin-top: 15px; width: 100%;">
|
||||
{{ isScanning ? '停止扫描' : '开启摄像头' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Image Mode -->
|
||||
<div v-else class="upload-area">
|
||||
<div class="image-preview" @click="triggerFileUpload">
|
||||
<img v-if="previewImg" :src="previewImg" class="preview-img" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon :size="48"><Upload /></el-icon>
|
||||
<p>点击选择或拖拽二维码图片</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref="fileInput" accept="image/*" style="display: none" @change="handleFileChange" />
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-if="scanResult" class="result-section" style="margin-top: 20px;">
|
||||
<el-divider content-position="left">解析结果</el-divider>
|
||||
<el-input v-model="scanResult" type="textarea" :rows="3" readonly resize="none" />
|
||||
<el-button type="success" plain style="width: 100%; margin-top: 10px;" @click="copyResult">
|
||||
<el-icon><CopyDocument /></el-icon> 复制内容
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Camera, Upload, CopyDocument } from '@element-plus/icons-vue'
|
||||
import jsQR from 'jsqr'
|
||||
|
||||
const parserMode = ref('camera')
|
||||
const scanResult = ref('')
|
||||
const isScanning = ref(false)
|
||||
const videoEl = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const previewImg = ref('')
|
||||
let stream = null
|
||||
let rafId = null
|
||||
|
||||
const toggleCamera = async () => {
|
||||
if (isScanning.value) stopCamera()
|
||||
else await startCamera()
|
||||
}
|
||||
|
||||
const startCamera = async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
|
||||
isScanning.value = true
|
||||
setTimeout(() => {
|
||||
if (videoEl.value) {
|
||||
videoEl.value.srcObject = stream
|
||||
videoEl.value.play()
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
}, 100)
|
||||
} catch (err) { ElMessage.error('无法访问摄像头,请检查权限') }
|
||||
}
|
||||
|
||||
const stopCamera = () => {
|
||||
isScanning.value = false
|
||||
if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null }
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!isScanning.value || !videoEl.value) return
|
||||
if (videoEl.value.readyState === videoEl.value.HAVE_ENOUGH_DATA) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = videoEl.value.videoWidth
|
||||
canvas.height = videoEl.value.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(videoEl.value, 0, 0, canvas.width, canvas.height)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })
|
||||
if (code) {
|
||||
scanResult.value = code.data
|
||||
ElMessage.success('二维码识别成功')
|
||||
stopCamera()
|
||||
return
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => fileInput.value.click()
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0]; if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
previewImg.value = ev.target.result
|
||||
const img = new Image(); img.onload = () => {
|
||||
const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height
|
||||
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height)
|
||||
if (code) { scanResult.value = code.data; ElMessage.success('图片识别成功') } else { ElMessage.warning('未识别到二维码') }
|
||||
}; img.src = ev.target.result
|
||||
}; reader.readAsDataURL(file); e.target.value = ''
|
||||
}
|
||||
|
||||
const copyResult = async () => {
|
||||
if (!scanResult.value) return
|
||||
try { await navigator.clipboard.writeText(scanResult.value); ElMessage.success('内容已复制') } catch (e) {}
|
||||
}
|
||||
|
||||
onUnmounted(stopCamera)
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="tool-pane">
|
||||
<div class="time-sync-container">
|
||||
<!-- 状态横幅 -->
|
||||
<el-alert
|
||||
v-if="syncStatus"
|
||||
:title="syncStatus.title"
|
||||
:type="syncStatus.type"
|
||||
:description="syncStatus.desc"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
/>
|
||||
|
||||
<!-- 时钟仪表盘 -->
|
||||
<div class="clocks-wrapper">
|
||||
<div class="clock-card local">
|
||||
<div class="clock-label">📱 本地设备时间</div>
|
||||
<div class="clock-time">{{ formatTime(localTime) }}</div>
|
||||
</div>
|
||||
<div class="clock-card server">
|
||||
<div class="clock-label">☁️ 服务器时间 (估算)</div>
|
||||
<div class="clock-time">{{ formatTime(serverTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细数据 -->
|
||||
<div class="sync-details">
|
||||
<p>时间偏差: <strong>{{ offset !== null ? `${offset > 0 ? '+' : ''}${offset} ms` : '--' }}</strong></p>
|
||||
<p>网络延迟: <span>{{ rtt !== null ? `${rtt} ms` : '--' }}</span></p>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" size="large" :loading="isSyncing" @click="syncTime" style="width: 100%; margin-top: 20px;">
|
||||
<el-icon><Refresh /></el-icon> 立即检测
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
const localTime = ref(Date.now())
|
||||
const offset = ref(null)
|
||||
const rtt = ref(null)
|
||||
const isSyncing = ref(false)
|
||||
let clockTimer = null
|
||||
|
||||
const serverTime = computed(() => localTime.value + (offset.value || 0))
|
||||
const formatTime = (ts) => new Date(ts).toLocaleTimeString()
|
||||
|
||||
const syncStatus = computed(() => {
|
||||
if (offset.value === null) return null
|
||||
const abs = Math.abs(offset.value)
|
||||
if (abs < 2000) return { title: '时间同步正常', type: 'success', desc: '本地时间与服务器误差极小,不影响 2FA 验证。' }
|
||||
if (abs < 30000) return { title: '存在微小偏差', type: 'warning', desc: '建议校准本地时间,但通常仍可使用。' }
|
||||
return { title: '时间偏差过大', type: 'error', desc: '严重偏差!2FA 验证码将失效,请务必校准设备时间。' }
|
||||
})
|
||||
|
||||
const syncTime = async () => {
|
||||
isSyncing.value = true
|
||||
const start = Date.now()
|
||||
try {
|
||||
const res = await request('/api/accounts/server-time')
|
||||
const end = Date.now()
|
||||
if (res.success) {
|
||||
const serverTs = res.time
|
||||
rtt.value = end - start
|
||||
const estimatedServerTime = serverTs + (rtt.value / 2)
|
||||
offset.value = Math.round(estimatedServerTime - end)
|
||||
ElMessage.success(`校准完成,偏差 ${offset.value}ms`)
|
||||
}
|
||||
} catch (e) { ElMessage.error('无法连接服务器') } finally { isSyncing.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { clockTimer = setInterval(() => { localTime.value = Date.now() }, 1000); syncTime() })
|
||||
onUnmounted(() => { if (clockTimer) clearInterval(clockTimer) })
|
||||
</script>
|
||||
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div class="tool-pane">
|
||||
<div class="totp-layout">
|
||||
<!-- Bottom: Configuration -->
|
||||
<div class="config-side">
|
||||
<!-- 1. Secret Input (Tabs) -->
|
||||
<div class="config-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">密钥配置</h3>
|
||||
<el-button link type="primary" @click="triggerImportQr">
|
||||
<el-icon><Camera /></el-icon> 识别二维码导入
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" type="border-card" class="secret-tabs">
|
||||
<el-tab-pane label="Base32" name="base32">
|
||||
<el-input
|
||||
v-model="secretBase32"
|
||||
@input="handleBase32Input"
|
||||
placeholder="JBSWY3DP..."
|
||||
clearable
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
<div class="tab-actions">
|
||||
<el-button size="small" @click="refreshBase32"><el-icon><Refresh /></el-icon> 随机生成</el-button>
|
||||
<el-button size="small" @click="copy(secretBase32)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Hex (十六进制)" name="hex">
|
||||
<el-input
|
||||
v-model="secretHex"
|
||||
@input="handleHexInput"
|
||||
placeholder="48656c6c6f..."
|
||||
clearable
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
<div class="tab-actions">
|
||||
<el-button size="small" @click="refreshHex"><el-icon><Refresh /></el-icon> 随机生成</el-button>
|
||||
<el-button size="small" @click="copy(secretHex)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="ASCII" name="ascii">
|
||||
<el-input
|
||||
v-model="secretAscii"
|
||||
@input="handleAsciiInput"
|
||||
placeholder="Hello..."
|
||||
clearable
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
<div class="tab-actions">
|
||||
<el-button size="small" @click="refreshAscii"><el-icon><Refresh /></el-icon> 随机生成</el-button>
|
||||
<el-button size="small" @click="copy(secretAscii)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 2. Metadata -->
|
||||
<div class="config-section">
|
||||
<h3 class="section-title">基础信息</h3>
|
||||
<div class="meta-row">
|
||||
<el-input v-model="issuer" @input="updateUri">
|
||||
<template #prefix><span class="input-label">服务商</span></template>
|
||||
</el-input>
|
||||
<el-input v-model="account" @input="updateUri">
|
||||
<template #prefix><span class="input-label">账号标识</span></template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Result Preview -->
|
||||
<div class="config-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">结果预览</h3>
|
||||
<el-button link type="primary" @click="downloadQrCode" :disabled="!qrCodeUrl">
|
||||
<el-icon><Download /></el-icon> 保存二维码
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="preview-top-section">
|
||||
<div class="qr-wrapper" v-loading="!qrCodeUrl">
|
||||
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="QR Code" class="qr-img" />
|
||||
<el-empty v-else description="配置参数以生成预览" :image-size="100" />
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<div class="totp-code" :class="{ 'blur': !currentCode }">{{ currentCode || '------' }}</div>
|
||||
<div class="totp-timer" :class="{ 'urgent': remaining < 5 }">
|
||||
<el-icon><Timer /></el-icon> {{ remaining }}s 后刷新
|
||||
</div>
|
||||
<el-button type="primary" plain size="small" @click="copy(currentCode)" :disabled="!currentCode" style="margin-top: 10px;">
|
||||
<el-icon><CopyDocument /></el-icon> 复制验证码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uri-box" v-if="qrCodeUrl">
|
||||
<div class="uri-text">{{ currentUri }}</div>
|
||||
<el-button link type="primary" @click="copy(currentUri)"><el-icon><CopyDocument /></el-icon></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Advanced Settings -->
|
||||
<div class="config-section">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="高级设置 (算法/位数/步长)" name="1">
|
||||
<div class="advanced-row">
|
||||
<el-select v-model="algorithm" @change="updateAll('settings')" placeholder="算法" style="flex: 1">
|
||||
<el-option label="SHA-1 (默认)" value="SHA-1" />
|
||||
<el-option label="SHA-256" value="SHA-256" />
|
||||
<el-option label="SHA-512" value="SHA-512" />
|
||||
</el-select>
|
||||
<el-select v-model="digits" @change="updateAll('settings')" placeholder="位数" style="width: 100px">
|
||||
<el-option label="6 位" :value="6" />
|
||||
<el-option label="8 位" :value="8" />
|
||||
</el-select>
|
||||
<el-select v-model="period" @change="updateAll('settings')" placeholder="周期" style="width: 100px">
|
||||
<el-option label="30 秒" :value="30" />
|
||||
<el-option label="60 秒" :value="60" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
|
||||
<!-- 5. Time Offset -->
|
||||
<div class="config-section">
|
||||
<div class="label-row">
|
||||
<span class="label-text">时间偏移 (Time Travel): {{ timeOffset > 0 ? '+' : '' }}{{ timeOffset }}s</span>
|
||||
<el-button link type="primary" @click="adjustTime(0, true)" size="small">重置</el-button>
|
||||
</div>
|
||||
<el-button-group style="width: 100%; display: flex;">
|
||||
<el-button @click="adjustTime(-period)" style="flex:1" size="small">上个周期</el-button>
|
||||
<el-button @click="adjustTime(period)" style="flex:1" size="small">下个周期</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="config-section" style="margin-top: 20px;">
|
||||
<el-button type="success" size="large" @click="saveToAccount" style="width: 100%;" :loading="isSaving">
|
||||
<el-icon><CircleCheck /></el-icon> 保存到我的账户
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref="qrFileInput" accept="image/*" style="display: none" @change="handleQrFileChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CopyDocument, Refresh, Timer, Camera, CircleCheck, Download } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import jsQR from 'jsqr'
|
||||
import { request } from '../utils/request'
|
||||
|
||||
// --- State ---
|
||||
const activeTab = ref('base32')
|
||||
const secretBase32 = ref('')
|
||||
const secretHex = ref('')
|
||||
const secretAscii = ref('')
|
||||
|
||||
// Metadata & Settings
|
||||
const issuer = ref('2FAuth-Tool')
|
||||
const account = ref('TestUser')
|
||||
const algorithm = ref('SHA-1')
|
||||
const digits = ref(6)
|
||||
const period = ref(30)
|
||||
const timeOffset = ref(0)
|
||||
|
||||
const qrCodeUrl = ref('')
|
||||
const currentUri = ref('')
|
||||
const currentCode = ref('')
|
||||
const remaining = ref(30)
|
||||
const isSaving = ref(false)
|
||||
const qrFileInput = ref(null)
|
||||
let timer = null
|
||||
|
||||
// --- Base32 Logic ---
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
|
||||
const base32Encode = (buffer) => {
|
||||
let bits = 0, value = 0, output = ''
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
value = (value << 8) | buffer[i]
|
||||
bits += 8
|
||||
while (bits >= 5) {
|
||||
output += alphabet[(value >>> (bits - 5)) & 31]
|
||||
bits -= 5
|
||||
}
|
||||
}
|
||||
if (bits > 0) output += alphabet[(value << (5 - bits)) & 31]
|
||||
return output
|
||||
}
|
||||
|
||||
const base32Decode = (input) => {
|
||||
let bits = 0, value = 0
|
||||
const buffer = []
|
||||
const cleaned = input.toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '')
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
const val = alphabet.indexOf(cleaned[i])
|
||||
value = (value << 5) | val
|
||||
bits += 5
|
||||
while (bits >= 8) {
|
||||
buffer.push((value >>> (bits - 8)) & 255)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
return new Uint8Array(buffer)
|
||||
}
|
||||
|
||||
// --- Hex Logic ---
|
||||
const toHex = (buffer) => {
|
||||
return Array.from(buffer).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
const fromHex = (hexString) => {
|
||||
const hex = hexString.replace(/[^0-9a-fA-F]/g, '')
|
||||
if (hex.length % 2 !== 0) return new Uint8Array(0)
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// --- ASCII Logic ---
|
||||
const toAscii = (buffer) => {
|
||||
return String.fromCharCode.apply(null, buffer)
|
||||
}
|
||||
|
||||
const fromAscii = (str) => {
|
||||
const buffer = new Uint8Array(str.length)
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
buffer[i] = str.charCodeAt(i)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
const updateAll = async (sourceType) => {
|
||||
try {
|
||||
// 1. Sync Inputs
|
||||
if (sourceType === 'base32') {
|
||||
const bytes = base32Decode(secretBase32.value)
|
||||
secretHex.value = toHex(bytes)
|
||||
secretAscii.value = toAscii(bytes)
|
||||
} else if (sourceType === 'hex') {
|
||||
const bytes = fromHex(secretHex.value)
|
||||
if (bytes.length > 0) secretBase32.value = base32Encode(bytes)
|
||||
secretAscii.value = toAscii(bytes)
|
||||
} else if (sourceType === 'ascii') {
|
||||
const bytes = fromAscii(secretAscii.value)
|
||||
secretBase32.value = base32Encode(bytes)
|
||||
secretHex.value = toHex(bytes)
|
||||
}
|
||||
|
||||
// 2. Generate QR
|
||||
if (secretBase32.value) {
|
||||
const label = encodeURIComponent(`${issuer.value}:${account.value}`)
|
||||
const algoParam = algorithm.value.replace('-', '') // SHA-1 -> SHA1
|
||||
const uri = `otpauth://totp/${label}?secret=${secretBase32.value}&issuer=${encodeURIComponent(issuer.value)}&algorithm=${algoParam}&period=${period.value}&digits=${digits.value}`
|
||||
currentUri.value = uri
|
||||
qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 200, margin: 1 })
|
||||
} else {
|
||||
qrCodeUrl.value = ''
|
||||
currentUri.value = ''
|
||||
currentCode.value = ''
|
||||
}
|
||||
|
||||
// 3. Update TOTP immediately
|
||||
calcTotp()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// Ignore decoding errors during typing
|
||||
}
|
||||
}
|
||||
|
||||
const handleBase32Input = () => updateAll('base32')
|
||||
const handleHexInput = () => updateAll('hex')
|
||||
const handleAsciiInput = () => updateAll('ascii')
|
||||
const updateUri = () => updateAll('settings')
|
||||
|
||||
const refreshBase32 = () => {
|
||||
// Generate 20 random bytes (160 bits) - Standard high security
|
||||
const array = new Uint8Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
secretBase32.value = base32Encode(array)
|
||||
updateAll('base32')
|
||||
ElMessage.success('已生成新的随机密钥')
|
||||
}
|
||||
|
||||
const refreshHex = () => {
|
||||
// Generate 20 random bytes
|
||||
const array = new Uint8Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
secretHex.value = toHex(array)
|
||||
updateAll('hex')
|
||||
ElMessage.success('已生成新的 Hex 密钥')
|
||||
}
|
||||
|
||||
const refreshAscii = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()'
|
||||
let result = ''
|
||||
const array = new Uint32Array(20)
|
||||
window.crypto.getRandomValues(array)
|
||||
for (let i = 0; i < 20; i++) result += chars[array[i] % chars.length]
|
||||
secretAscii.value = result
|
||||
updateAll('ascii')
|
||||
ElMessage.success('已生成随机 ASCII 密钥')
|
||||
}
|
||||
|
||||
// --- TOTP Calculation ---
|
||||
const calcTotp = async () => {
|
||||
if (!secretBase32.value) return
|
||||
|
||||
const p = period.value
|
||||
const now = (Date.now() / 1000) + timeOffset.value
|
||||
const epoch = Math.floor(now / p)
|
||||
remaining.value = Math.ceil(p - (now % p))
|
||||
|
||||
try {
|
||||
const keyBytes = base32Decode(secretBase32.value)
|
||||
if (keyBytes.length === 0) return
|
||||
|
||||
const timeBuffer = new ArrayBuffer(8)
|
||||
new DataView(timeBuffer).setBigUint64(0, BigInt(epoch), false)
|
||||
|
||||
const key = await window.crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: algorithm.value }, false, ['sign'])
|
||||
const signature = await window.crypto.subtle.sign('HMAC', key, timeBuffer)
|
||||
|
||||
const view = new DataView(signature)
|
||||
const offset = view.getUint8(19) & 0xf
|
||||
const binary = ((view.getUint8(offset) & 0x7f) << 24) |
|
||||
((view.getUint8(offset + 1) & 0xff) << 16) |
|
||||
((view.getUint8(offset + 2) & 0xff) << 8) |
|
||||
(view.getUint8(offset + 3) & 0xff)
|
||||
|
||||
const mod = Math.pow(10, digits.value)
|
||||
currentCode.value = (binary % mod).toString().padStart(digits.value, '0')
|
||||
} catch (e) {
|
||||
currentCode.value = 'ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Time Travel ---
|
||||
const adjustTime = (delta, reset = false) => {
|
||||
if (reset) timeOffset.value = 0
|
||||
else timeOffset.value += delta
|
||||
calcTotp()
|
||||
}
|
||||
|
||||
// --- Import QR ---
|
||||
const triggerImportQr = () => qrFileInput.value.click()
|
||||
|
||||
const handleQrFileChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height)
|
||||
|
||||
if (code) {
|
||||
parseOtpUri(code.data)
|
||||
ElMessage.success('二维码解析成功')
|
||||
} else {
|
||||
ElMessage.warning('未识别到二维码')
|
||||
}
|
||||
}
|
||||
img.src = ev.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const parseOtpUri = (uri) => {
|
||||
try {
|
||||
if (!uri.startsWith('otpauth://')) return
|
||||
const url = new URL(uri)
|
||||
const params = url.searchParams
|
||||
|
||||
if (params.has('secret')) {
|
||||
secretBase32.value = params.get('secret')
|
||||
updateAll('base32')
|
||||
}
|
||||
|
||||
if (params.has('issuer')) issuer.value = params.get('issuer')
|
||||
if (params.has('digits')) digits.value = parseInt(params.get('digits'))
|
||||
if (params.has('period')) period.value = parseInt(params.get('period'))
|
||||
if (params.has('algorithm')) {
|
||||
const algo = params.get('algorithm').toUpperCase()
|
||||
if (algo === 'SHA1') algorithm.value = 'SHA-1'
|
||||
else if (algo === 'SHA256') algorithm.value = 'SHA-256'
|
||||
else if (algo === 'SHA512') algorithm.value = 'SHA-512'
|
||||
}
|
||||
|
||||
// Parse Label
|
||||
const label = decodeURIComponent(url.pathname.replace(/^\//, ''))
|
||||
if (label.includes(':')) {
|
||||
const parts = label.split(':')
|
||||
if (!params.has('issuer')) issuer.value = parts[0]
|
||||
account.value = parts[1]
|
||||
} else {
|
||||
account.value = label
|
||||
}
|
||||
|
||||
updateAll('settings')
|
||||
} catch (e) {
|
||||
console.error('URI Parse Error', e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Save to Account ---
|
||||
const saveToAccount = async () => {
|
||||
if (!secretBase32.value) return ElMessage.warning('密钥不能为空')
|
||||
if (!issuer.value || !account.value) return ElMessage.warning('请填写服务商和账号')
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const res = await request('/api/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
service: issuer.value,
|
||||
account: account.value,
|
||||
secret: secretBase32.value,
|
||||
digits: digits.value,
|
||||
period: period.value,
|
||||
category: '工具箱添加'
|
||||
})
|
||||
})
|
||||
if (res.success) {
|
||||
ElMessage.success('已保存到我的账户')
|
||||
}
|
||||
} catch (e) {
|
||||
// Error handled by request
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadQrCode = () => {
|
||||
if (!qrCodeUrl.value) return
|
||||
const a = document.createElement('a')
|
||||
a.href = qrCodeUrl.value
|
||||
a.download = `2fa-qr-${account.value || 'code'}.png`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const copy = async (text) => {
|
||||
if (!text) return
|
||||
try { await navigator.clipboard.writeText(text); ElMessage.success('复制成功') } catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshBase32() // Init with a random key
|
||||
timer = setInterval(calcTotp, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="tools-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<div class="tools-header">
|
||||
<el-button v-if="currentTool" link @click="currentTool = null" class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回
|
||||
</el-button>
|
||||
<h2>{{ currentTool ? getToolTitle(currentTool) : '🛠️ 实用工具箱' }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- 工具列表 (卡片视图) -->
|
||||
<div v-if="!currentTool" class="tools-grid">
|
||||
<el-card
|
||||
v-for="tool in tools"
|
||||
:key="tool.id"
|
||||
shadow="hover"
|
||||
class="tool-card"
|
||||
@click="currentTool = tool.id"
|
||||
>
|
||||
<div class="tool-card-content">
|
||||
<div class="icon-wrapper" :style="{ backgroundColor: tool.bgColor }">
|
||||
<el-icon :size="32" :color="tool.iconColor"><component :is="tool.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="text-info">
|
||||
<h3>{{ tool.title }}</h3>
|
||||
<p>{{ tool.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 具体工具内容 -->
|
||||
<div v-else class="tool-container">
|
||||
|
||||
<!-- 动态组件加载 -->
|
||||
<component :is="activeComponent" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Key, ArrowLeft, Timer, Lock, Camera } from '@element-plus/icons-vue'
|
||||
import ToolPassword from './ToolPassword.vue'
|
||||
import ToolTimeSync from './ToolTimeSync.vue'
|
||||
import ToolQrParser from './ToolQrParser.vue'
|
||||
import ToolTotpSecret from './ToolTotpSecret.vue'
|
||||
|
||||
const currentTool = ref(null)
|
||||
|
||||
const tools = [
|
||||
{
|
||||
id: 'totp-secret',
|
||||
title: 'TOTP 密钥工具',
|
||||
desc: '随机生成密钥,支持 Base32 格式转换与校验',
|
||||
icon: Lock,
|
||||
iconColor: '#67C23A',
|
||||
bgColor: 'var(--el-color-success-light-9)'
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
title: '密码生成器',
|
||||
desc: '生成高强度随机密码,支持自定义长度和字符类型',
|
||||
icon: Key,
|
||||
iconColor: '#409EFF',
|
||||
bgColor: 'var(--el-color-primary-light-9)'
|
||||
},
|
||||
{
|
||||
id: 'time-sync',
|
||||
title: '时间校准器',
|
||||
desc: '精准检测本地时间与服务器时间的偏差',
|
||||
icon: Timer,
|
||||
iconColor: '#E6A23C',
|
||||
bgColor: 'var(--el-color-warning-light-9)'
|
||||
},
|
||||
{
|
||||
id: 'qr-parser',
|
||||
title: '二维码解析器',
|
||||
desc: '识别图片中的二维码内容,提取 OTP 链接',
|
||||
icon: Camera,
|
||||
iconColor: '#F56C6C',
|
||||
bgColor: 'var(--el-color-danger-light-9)'
|
||||
}
|
||||
]
|
||||
|
||||
const getToolTitle = (id) => {
|
||||
const t = tools.find(t => t.id === id)
|
||||
return t ? t.title : '工具'
|
||||
}
|
||||
|
||||
const activeComponent = computed(() => {
|
||||
switch (currentTool.value) {
|
||||
case 'password': return ToolPassword
|
||||
case 'time-sync': return ToolTimeSync
|
||||
case 'qr-parser': return ToolQrParser
|
||||
case 'totp-secret': return ToolTotpSecret
|
||||
default: return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =========================================
|
||||
深色模式变量配置 (Dark Mode Configuration)
|
||||
========================================= */
|
||||
|
||||
html.dark {
|
||||
/* 玻璃拟态卡片 */
|
||||
--glass-card-bg: rgba(30, 30, 30, 0.6);
|
||||
--glass-card-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* GitHub 按钮 (深色背景下使用纯黑+边框) */
|
||||
--github-btn-bg: #000000;
|
||||
--github-btn-border: var(--el-border-color-light);
|
||||
|
||||
/* 状态色块 */
|
||||
--success-tip-bg: var(--el-fill-color-light);
|
||||
--account-selected-bg: rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
/* =========================================
|
||||
0. 强制同步修复 (Force Sync)
|
||||
========================================= */
|
||||
/* 核心修复:强制禁用所有元素的过渡动画,确保深浅色切换瞬间完成,消除不同步感 */
|
||||
*, *::before, *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
0.1 全局语义化变量 (Design Tokens)
|
||||
========================================= */
|
||||
:root {
|
||||
/* 玻璃拟态卡片 (登录/加载) */
|
||||
--glass-card-bg: rgba(255, 255, 255, 0.95);
|
||||
--glass-card-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* GitHub 按钮 */
|
||||
--github-btn-bg: #24292e;
|
||||
--github-btn-border: #24292e;
|
||||
|
||||
/* 状态色块 */
|
||||
--success-tip-bg: var(--el-color-success-light-9);
|
||||
--account-selected-bg: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
1. 全局基础样式 (Global Reset & Base)
|
||||
========================================= */
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--el-bg-color-page);
|
||||
color: var(--el-text-color-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
/* 滚动条优化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* 通用组件样式 */
|
||||
.tab-card-wrapper {
|
||||
background: var(--el-bg-color);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
2. 布局组件 (Layout)
|
||||
========================================= */
|
||||
/* App.vue */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* TheHeader.vue */
|
||||
.header {
|
||||
background: var(--el-bg-color);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.logo, .user-actions, .user-profile { display: flex; align-items: center; }
|
||||
.logo h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; }
|
||||
.user-actions { gap: 20px; }
|
||||
.user-profile { gap: 10px; background: var(--el-fill-color-light); padding: 4px 15px 4px 4px; border-radius: 20px; }
|
||||
.username { font-weight: 600; color: var(--el-text-color-regular); font-size: 14px; }
|
||||
.menu-icon { color: var(--el-text-color-regular); }
|
||||
@media (max-width: 768px) { .header { padding: 0 15px; } .logo h2 { font-size: 18px; } }
|
||||
|
||||
/* TheFooter.vue */
|
||||
.global-footer {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
background-color: var(--el-bg-color);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.footer-content { color: var(--el-text-color-secondary); font-size: 0.875rem; }
|
||||
.divider { margin: 0 0.5rem; color: var(--el-border-color); }
|
||||
|
||||
/* Home.vue */
|
||||
.home-container { height: 100%; background-color: var(--el-bg-color-page); }
|
||||
.main-body { height: 100%; display: flex; overflow: hidden; }
|
||||
.left-aside { background: var(--el-bg-color); border-right: 1px solid var(--el-border-color-light); display: flex; flex-direction: column; }
|
||||
.side-menu { border-right: none; padding-top: 10px; flex: 1; }
|
||||
.main-content { padding: 30px 20px; width: 100%; background-color: var(--el-bg-color-page); overflow-y: auto; }
|
||||
.sidebar-footer { padding: 20px; text-align: center; display: flex; justify-content: center; gap: 10px; }
|
||||
.drawer-content { height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
/* =========================================
|
||||
3. 页面/功能组件 (Pages & Features)
|
||||
========================================= */
|
||||
|
||||
/* Login.vue */
|
||||
.login-container {
|
||||
display: flex; justify-content: center; align-items: center; height: 100%;
|
||||
background: transparent; position: relative;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%; max-width: 420px; border-radius: 16px;
|
||||
box-shadow: var(--glass-card-shadow);
|
||||
background: var(--glass-card-bg);
|
||||
backdrop-filter: blur(10px); border: none;
|
||||
}
|
||||
.logo-container { text-align: center; margin-bottom: 30px; padding-top: 10px; }
|
||||
.logo-container h2 { margin: 16px 0 8px; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; }
|
||||
.subtitle { margin: 0; color: var(--el-text-color-secondary); font-size: 14px; }
|
||||
.action-container { display: flex; justify-content: center; margin-bottom: 30px; }
|
||||
.github-btn {
|
||||
width: 100%; height: 50px; font-size: 16px; border-radius: 8px;
|
||||
background-color: var(--github-btn-bg); border-color: var(--github-btn-border);
|
||||
}
|
||||
.github-btn:hover, .github-btn:focus {
|
||||
background-color: #4a4e53; border-color: #4a4e53;
|
||||
transform: translateY(-2px); box-shadow: 0 4px 12px rgba(36, 41, 46, 0.3);
|
||||
}
|
||||
.footer-tips { margin-top: 10px; }
|
||||
|
||||
/* OAuthCallback.vue */
|
||||
.callback-container { display: flex; justify-content: center; align-items: center; height: 100%; background: transparent; }
|
||||
.loading-card {
|
||||
width: 100%; max-width: 420px; border-radius: 16px;
|
||||
background: var(--glass-card-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* AccountList.vue */
|
||||
.account-card {
|
||||
border-radius: 12px;
|
||||
/* 全局规则已覆盖 transition,此处只需定义样式 */
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
cursor: pointer; background-color: var(--el-bg-color);
|
||||
height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.account-card:hover { transform: translateY(-5px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important; }
|
||||
.account-list-wrapper .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
|
||||
/* Flex 容器:限制宽度以支持内部文本截断 */
|
||||
.service-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
min-width: 0; /* 关键:允许 Flex 子项收缩 */
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* 标题截断逻辑 */
|
||||
.service-name { margin: 0; font-size: 18px; color: var(--el-text-color-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 让卡片内容撑开,底部对齐 */
|
||||
.account-card .el-card__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.account-name { color: var(--el-text-color-secondary); font-size: 14px; margin: 0 0 10px 24px; word-break: break-all; }
|
||||
.more-icon { cursor: pointer; color: var(--el-text-color-secondary); padding: 5px; }
|
||||
.more-icon:hover { color: #409EFF; }
|
||||
.account-card.is-selected { border: 1px solid #409EFF; background-color: var(--account-selected-bg); }
|
||||
|
||||
.batch-text { color: var(--el-text-color-regular); font-size: 14px; }
|
||||
.code-display-area { display: flex; justify-content: space-between; align-items: center; margin-top: auto; padding-left: 24px; padding-top: 15px; }
|
||||
.code-left { display: flex; flex-direction: column; justify-content: center; }
|
||||
.current-code { font-size: 24px; font-weight: bold; color: #409EFF; letter-spacing: 2px; line-height: 1; font-family: monospace; }
|
||||
.next-code { font-size: 16px; color: var(--el-text-color-secondary); margin-top: 4px; font-family: monospace; }
|
||||
.timer-text { font-size: 12px; color: var(--el-text-color-regular); font-weight: bold; display: block; text-align: center; }
|
||||
.el-progress__text {
|
||||
top: 0; left: 0; width: 100%; height: 100%; transform: none;
|
||||
display: flex; align-items: center; justify-content: center; min-width:30px;
|
||||
}
|
||||
|
||||
/* AccountList - QR Dialog */
|
||||
.qr-container { display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.qr-info { margin-bottom: 15px; }
|
||||
.qr-service { margin: 0 0 5px 0; font-size: 20px; color: var(--el-text-color-primary); }
|
||||
.qr-account { margin: 0; color: var(--el-text-color-secondary); font-size: 14px; }
|
||||
.qr-image-wrapper { margin: 10px 0; padding: 10px; background: #fff; border-radius: 8px; border: 1px solid var(--el-border-color-lighter); }
|
||||
.qr-code-img { display: block; width: 200px; height: 200px; }
|
||||
.qr-tip { font-size: 12px; color: var(--el-text-color-regular); margin: 10px 0 20px 0; }
|
||||
.secret-section { width: 100%; margin-bottom: 15px; }
|
||||
.secret-box { display: flex; align-items: center; justify-content: space-between; background: var(--el-fill-color-light); padding: 10px 15px; border-radius: 6px; border: 1px solid var(--el-border-color-light); }
|
||||
.secret-text { font-family: monospace; font-size: 14px; color: var(--el-text-color-regular); letter-spacing: 1px; }
|
||||
.secret-actions { display: flex; gap: 15px; }
|
||||
.action-icon { cursor: pointer; font-size: 16px; color: var(--el-text-color-secondary); transition: color 0.2s; }
|
||||
.action-icon:hover { color: #409EFF; }
|
||||
.uri-link-wrapper { margin-top: 5px; }
|
||||
|
||||
/* BackupSettings.vue */
|
||||
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.provider-card { height: 100%; }
|
||||
.backup-container .card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.provider-info { display: flex; align-items: center; }
|
||||
.provider-title { display: flex; align-items: center; gap: 8px; font-weight: bold; flex-wrap: wrap; }
|
||||
.status-text { font-size: 13px; color: var(--el-text-color-regular); margin-bottom: 15px; }
|
||||
.action-buttons { display: flex; gap: 10px; }
|
||||
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 5px; }
|
||||
.success-tip { padding: 10px; background-color: var(--success-tip-bg); border-radius: 4px; color: var(--el-color-success); font-size: 13px; display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
|
||||
/* DataImport.vue & DataExport.vue */
|
||||
.import-options, .export-options { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
|
||||
.import-options .el-button, .export-options .el-button { margin: 0; justify-content: flex-start; padding-left: 20px; height: auto; padding-top: 15px; padding-bottom: 15px; }
|
||||
@media (max-width: 768px) {
|
||||
.import-options, .export-options { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
4. UtilityTools 组件 (工具箱)
|
||||
========================================= */
|
||||
.tools-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tools-header .back-btn {
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.tools-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.tool-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.text-info h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.text-info p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
5. UtilityTools 子组件详情页样式
|
||||
========================================= */
|
||||
|
||||
/* 通用容器 */
|
||||
.tool-pane {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* --- ToolTimeSync (时间校准) --- */
|
||||
.time-sync-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clocks-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.clock-card {
|
||||
flex: 1;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.clock-card.local { border-top: 4px solid #409EFF; }
|
||||
.clock-card.server { border-top: 4px solid #E6A23C; }
|
||||
|
||||
.clock-label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.clock-time {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.sync-details {
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sync-details p {
|
||||
margin: 5px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
/* --- ToolQrParser (二维码解析) --- */
|
||||
.qr-parser-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scan-area, .upload-area {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.camera-placeholder, .image-preview {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
border: 2px dashed var(--el-border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: var(--el-fill-color-lighter);
|
||||
transition: border-color 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-placeholder:hover, .image-preview:hover {
|
||||
border-color: #409EFF;
|
||||
}
|
||||
|
||||
.camera-placeholder p, .upload-placeholder p {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- ToolPassword (密码生成) --- */
|
||||
.result-box {
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.password-display {
|
||||
font-family: monospace;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: var(--el-bg-color);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-row.checkboxes {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-row .label {
|
||||
min-width: 60px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clocks-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
.control-row.checkboxes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- ToolTotpSecret (TOTP 密钥工具) --- */
|
||||
.totp-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.preview-top-section {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.preview-top-section {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.uri-box {
|
||||
margin-top: 15px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.uri-text {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-row .el-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.totp-code {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
letter-spacing: 6px;
|
||||
font-family: monospace;
|
||||
margin-bottom: 5px;
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
|
||||
.totp-code.blur {
|
||||
filter: blur(4px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.totp-timer {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.totp-timer.urgent {
|
||||
color: #F56C6C;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
margin-right: 12px;
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import './main.css'
|
||||
import './dark.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initTheme } from './states/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 初始化主题状态 (从 localStorage 恢复)
|
||||
initTheme()
|
||||
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const isDark = ref(false)
|
||||
|
||||
export const initTheme = () => {
|
||||
// 1. 检查本地存储
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved) {
|
||||
isDark.value = saved === 'dark'
|
||||
} else {
|
||||
// 2. 跟随系统
|
||||
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
export const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
const applyTheme = () => {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听系统变化 (仅在用户未手动设置过时生效)
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
isDark.value = e.matches
|
||||
applyTheme()
|
||||
}
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
+124
-41
@@ -2,18 +2,16 @@
|
||||
<el-container class="home-container">
|
||||
<el-container class="main-body">
|
||||
<!-- 侧边栏 (Desktop) -->
|
||||
<el-aside width="240px" class="left-aside" v-if="!layoutState.isMobile">
|
||||
<el-aside :width="isCollapse ? '64px' : '240px'" class="left-aside" v-if="!layoutState.isMobile">
|
||||
<el-menu
|
||||
:default-active="activeTab"
|
||||
class="side-menu"
|
||||
@select="handleMenuSelect"
|
||||
background-color="#fff"
|
||||
text-color="#606266"
|
||||
active-text-color="#409EFF"
|
||||
:collapse="isCollapse"
|
||||
>
|
||||
<el-menu-item index="accounts">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>我的账户</span>
|
||||
<template #title><span>我的账户</span></template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="add-account">
|
||||
@@ -37,9 +35,22 @@
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="backups">
|
||||
<el-icon><Cloudy /></el-icon><span>云端备份</span>
|
||||
<el-icon><Cloudy /></el-icon><template #title><span>云端备份</span></template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="tools">
|
||||
<el-icon><Tools /></el-icon><template #title><span>实用工具</span></template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 底部切换按钮 -->
|
||||
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapse }">
|
||||
<el-button circle :icon="isCollapse ? Expand : Fold" @click="toggleCollapse" :title="isCollapse ? '展开' : '折叠'" />
|
||||
<el-button circle :icon="isDark ? Sunny : Moon" @click="toggleTheme" />
|
||||
<el-button circle @click="handleLogout" title="退出登录">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<!-- 抽屉菜单 (Mobile) -->
|
||||
@@ -49,38 +60,50 @@
|
||||
size="240px"
|
||||
:with-header="false"
|
||||
>
|
||||
<el-menu
|
||||
:default-active="activeTab"
|
||||
class="side-menu"
|
||||
@select="handleMenuSelect"
|
||||
style="border-right: none;"
|
||||
>
|
||||
<el-menu-item index="accounts">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>我的账户</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="add-account">
|
||||
<template #title>
|
||||
<el-icon><Plus /></el-icon><span>添加账号</span>
|
||||
</template>
|
||||
<el-menu-item index="add-account-scan">
|
||||
<el-icon><Camera /></el-icon><span>扫码添加</span>
|
||||
<div class="drawer-content">
|
||||
<el-menu
|
||||
:default-active="activeTab"
|
||||
class="side-menu"
|
||||
@select="handleMenuSelect"
|
||||
style="border-right: none;"
|
||||
>
|
||||
<el-menu-item index="accounts">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>我的账户</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="add-account-manual">
|
||||
<el-icon><Edit /></el-icon><span>手动输入</span>
|
||||
<el-sub-menu index="add-account">
|
||||
<template #title>
|
||||
<el-icon><Plus /></el-icon><span>添加账号</span>
|
||||
</template>
|
||||
<el-menu-item index="add-account-scan">
|
||||
<el-icon><Camera /></el-icon><span>扫码添加</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="add-account-manual">
|
||||
<el-icon><Edit /></el-icon><span>手动输入</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="migration">
|
||||
<template #title>
|
||||
<el-icon><Sort /></el-icon><span>数据迁移</span>
|
||||
</template>
|
||||
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
|
||||
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="backups">
|
||||
<el-icon><Cloudy /></el-icon><span>云端备份</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="migration">
|
||||
<template #title>
|
||||
<el-icon><Sort /></el-icon><span>数据迁移</span>
|
||||
</template>
|
||||
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
|
||||
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="backups">
|
||||
<el-icon><Cloudy /></el-icon><span>云端备份</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<el-menu-item index="tools">
|
||||
<el-icon><Tools /></el-icon><span>实用工具</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<el-button circle :icon="isDark ? Sunny : Moon" @click="toggleTheme" />
|
||||
<el-button circle @click="handleLogout" title="退出登录">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-main class="main-content">
|
||||
@@ -112,6 +135,11 @@
|
||||
<DataBackup @success="handleSuccess" />
|
||||
</div>
|
||||
|
||||
<!-- 视图:实用工具 -->
|
||||
<div v-if="activeTab === 'tools'" class="view-container">
|
||||
<UtilityTools />
|
||||
</div>
|
||||
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
@@ -119,8 +147,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Iphone, Plus, Camera, Edit, Sort, Upload, Download, Cloudy } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Iphone, Plus, Camera, Edit, Sort, Upload, Download, Cloudy, Sunny, Moon, SwitchButton, Fold, Expand, Tools } from '@element-plus/icons-vue'
|
||||
import { layoutState } from '../states/layout'
|
||||
import { isDark, toggleTheme } from '../states/theme'
|
||||
import { userState } from '../states/user'
|
||||
|
||||
import AccountList from '../components/AccountList.vue'
|
||||
import AddAccountScan from '../components/AddAccountScan.vue'
|
||||
@@ -128,9 +160,12 @@ import AddAccountManual from '../components/AddAccountManual.vue'
|
||||
import DataExport from '../components/DataExport.vue'
|
||||
import DataImport from '../components/DataImport.vue'
|
||||
import DataBackup from '../components/DataBackup.vue'
|
||||
import UtilityTools from '../components/UtilityTools.vue'
|
||||
|
||||
const activeTab = ref('accounts')
|
||||
const accountListRef = ref(null)
|
||||
const router = useRouter()
|
||||
const isCollapse = ref(localStorage.getItem('sidebar_collapse') === 'true')
|
||||
|
||||
// 移动端菜单处理
|
||||
const handleMenuSelect = (index) => {
|
||||
@@ -149,12 +184,23 @@ const handleSuccess = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
userState.clearUserInfo()
|
||||
router.push('/login')
|
||||
ElMessage.success('已安全退出')
|
||||
}
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapse.value = !isCollapse.value
|
||||
localStorage.setItem('sidebar_collapse', isCollapse.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
height: 100%; /* 占满父容器 (App.vue 的 main) */
|
||||
background-color: #f5f7fa;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.main-body {
|
||||
@@ -164,19 +210,56 @@ const handleSuccess = () => {
|
||||
}
|
||||
|
||||
.left-aside {
|
||||
background: white;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
background: var(--el-bg-color);
|
||||
border-right: 1px solid var(--el-border-color-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 视觉优化:隐藏侧边栏滚动条,保持界面清爽 */
|
||||
.left-aside::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
border-right: none;
|
||||
padding-top: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 30px 20px;
|
||||
width: 100%;
|
||||
background-color: #f5f7fa;
|
||||
background-color: var(--el-bg-color-page);
|
||||
overflow-y: auto; /* 内容区独立滚动 */
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer.is-collapsed {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto; /* 确保移动端菜单过长时可以滚动 */
|
||||
}
|
||||
</style>
|
||||
@@ -63,72 +63,4 @@ const handleGitHubLogin = async () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
/* 这里的背景色会覆盖 App.vue 的全局背景 */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.logo-container h2 {
|
||||
margin: 16px 0 8px;
|
||||
color: #303133;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.github-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: #24292e;
|
||||
border-color: #24292e;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.github-btn:hover,
|
||||
.github-btn:focus {
|
||||
background-color: #4a4e53;
|
||||
border-color: #4a4e53;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(36, 41, 46, 0.3);
|
||||
}
|
||||
|
||||
.footer-tips {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
@@ -88,22 +88,4 @@ onMounted(async () => {
|
||||
const goBackToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callback-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
Reference in new issue
Block a user