chore: 整理项目结构,清理归档文件,更新部署配置
This commit is contained in:
@@ -37,6 +37,8 @@ export interface Agent {
|
||||
export interface LoginData extends Agent {
|
||||
/** 认证 token(存入 localStorage,后续请求自动携带) */
|
||||
token: string
|
||||
/** 是否需要 OTP 验证 */
|
||||
require_otp?: boolean
|
||||
}
|
||||
|
||||
/** 坐席列表响应 */
|
||||
@@ -51,19 +53,18 @@ export interface AgentListData {
|
||||
|
||||
/**
|
||||
* 坐席登录
|
||||
* 第一步使用简单的用户名登录(无密码验证)
|
||||
* admin 角色需要 OTP 二次验证
|
||||
* 使用账号密码+OTP认证
|
||||
* 登录成功后 token 存入 localStorage,后续请求自动携带
|
||||
*
|
||||
* @param userId - 企微用户ID
|
||||
* @param name - 坐席姓名
|
||||
* @param otpCode - OTP 动态码(admin 角色必填)
|
||||
* @param userId - 账号/用户名
|
||||
* @param password - 密码
|
||||
* @param otpCode - OTP 动态码(可选,首次登录不需要,需要时必填)
|
||||
* @returns 坐席信息和 token
|
||||
*/
|
||||
export async function login(userId: string, name: string, otpCode?: string): Promise<LoginData> {
|
||||
export async function login(userId: string, password: string, otpCode?: string): Promise<LoginData> {
|
||||
const response: AxiosResponse = await apiClient.post('/agents/login', {
|
||||
user_id: userId,
|
||||
name: name,
|
||||
password: password,
|
||||
otp_code: otpCode || undefined,
|
||||
})
|
||||
return response.data.data
|
||||
|
||||
@@ -205,7 +205,8 @@ export async function uploadImage(file: File): Promise<{
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
'/messages/image',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
{ headers: { 'Content-Type': undefined } }
|
||||
)
|
||||
return response.data.data
|
||||
}
|
||||
@@ -226,7 +227,8 @@ export async function uploadMessageFile(file: File): Promise<{
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
'/messages/file',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
{ headers: { 'Content-Type': undefined } }
|
||||
)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
@@ -56,14 +56,42 @@ export async function uploadFile(file: File | Blob): Promise<UploadResponse> {
|
||||
formData.append('file', file, fileName)
|
||||
}
|
||||
|
||||
const response: AxiosResponse = await apiClient.post('/upload', formData, {
|
||||
// 必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
|
||||
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
|
||||
headers: {
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
timeout: 60000,
|
||||
})
|
||||
return response.data.data
|
||||
// ISS-B3 修复:上传带自动重试(3次,指数退避 1s→2s→4s)
|
||||
return await uploadWithRetry(formData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传重试包装函数
|
||||
*
|
||||
* 做什么:对文件上传请求进行自动重试,最多 3 次
|
||||
* 为什么:网络波动时避免用户手动重试,提升上传成功率
|
||||
* 重试策略:指数退避(1秒 → 2秒 → 4秒),最多 3 次重试(共 4 次尝试)
|
||||
*
|
||||
* @param formData - 已构建好的 FormData 对象
|
||||
* @param maxRetries - 最大重试次数(默认 3)
|
||||
* @returns 上传响应数据
|
||||
*/
|
||||
async function uploadWithRetry(formData: FormData, maxRetries: number = 3): Promise<UploadResponse> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response: AxiosResponse = await apiClient.post('/upload', formData, {
|
||||
// 必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
|
||||
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
|
||||
headers: {
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
timeout: 60000,
|
||||
})
|
||||
return response.data.data
|
||||
} catch (err) {
|
||||
if (attempt === maxRetries) throw err
|
||||
// 指数退避:1s, 2s, 4s
|
||||
const delay = 1000 * Math.pow(2, attempt)
|
||||
console.warn(`[Upload] 上传失败,${delay / 1000}秒后重试(第 ${attempt + 1}/${maxRetries} 次)`)
|
||||
await new Promise(r => setTimeout(r, delay))
|
||||
}
|
||||
}
|
||||
// 不应到达此处,但 TypeScript 需要返回值
|
||||
throw new Error('上传失败:已达最大重试次数')
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
</div>
|
||||
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
|
||||
</div>
|
||||
<!-- 图片/截图功能已移除 -->
|
||||
<!--
|
||||
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
|
||||
🖼
|
||||
<span class="tb-tip">图片</span>
|
||||
@@ -52,6 +54,7 @@
|
||||
✂
|
||||
<span class="tb-tip">截图</span>
|
||||
</button>
|
||||
-->
|
||||
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
|
||||
📎
|
||||
<span class="tb-tip">文件</span>
|
||||
|
||||
@@ -99,8 +99,9 @@ router.beforeEach((to, _from, next) => {
|
||||
}
|
||||
|
||||
if (requiresAuth && !token) {
|
||||
// 需要认证但没有 token,跳转到 Portal 统一入口
|
||||
window.location.href = '/itportal/'
|
||||
// 需要认证但没有 token,跳转到当前端登录页(不再跳转到 Portal)
|
||||
// v1.1: 坐席/管理端直接登录,无需经过 Portal
|
||||
next({ path: '/login' })
|
||||
} else if (to.path === '/login' && token) {
|
||||
// 已登录用户访问登录页,跳转到工作台
|
||||
next({ path: '/workspace' })
|
||||
|
||||
@@ -69,24 +69,24 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
/**
|
||||
* 坐席登录
|
||||
* 调用后端登录 API,获取坐席信息和 token
|
||||
* admin 角色需要 OTP 二次验证
|
||||
* 支持账号密码+OTP认证
|
||||
* 登录成功后自动跳转到工作台页面
|
||||
*
|
||||
* @param inputUserId - 企微用户ID
|
||||
* @param inputName - 坐席姓名
|
||||
* @param otpCode - OTP 动态码(可选)
|
||||
* @param inputUserId - 账号/用户名
|
||||
* @param password - 密码
|
||||
* @param otpCode - OTP 动态码(可选,首次登录不需要)
|
||||
* @returns 登录数据(包含 require_otp 标记)
|
||||
*/
|
||||
async function login(inputUserId: string, inputName: string, otpCode?: string): Promise<any> {
|
||||
async function login(inputUserId: string, password: string, otpCode?: string): Promise<any> {
|
||||
try {
|
||||
logging.value = true
|
||||
const data = await apiLogin(inputUserId, inputName, otpCode)
|
||||
const data = await apiLogin(inputUserId, password, otpCode)
|
||||
|
||||
// 检查是否需要 OTP 验证
|
||||
if ('require_otp' in data && data.require_otp) {
|
||||
// 返回 data,让 Login.vue 处理 require_otp
|
||||
logging.value = false
|
||||
return data
|
||||
throw new Error('require_otp')
|
||||
}
|
||||
|
||||
// 保存登录信息
|
||||
@@ -106,6 +106,12 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
router.push('/workspace')
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
|
||||
// 如果是 require_otp 错误,直接抛出让 UI 处理
|
||||
if (error instanceof Error && error.message === 'require_otp') {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 使用 mock 数据作为 fallback(开发/演示用)
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Mock] 使用模拟登录数据')
|
||||
|
||||
+142
-245
@@ -1,18 +1,14 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席扫码登录页 (Phase 1.2, task #15)
|
||||
// IT智能服务台 — 坐席登录页 (v1.1, 2026-07-04)
|
||||
// =============================================================================
|
||||
// 说明:重写自原"用户名 + 姓名表单"登录,改为"企微扫码登录"
|
||||
// 说明: 从扫码登录改为账号密码+OTP表单登录
|
||||
//
|
||||
// 流程:
|
||||
// 1. 进入页面 → useQrcodeLogin.startLogin() 调后端 /auth_qrcode/create
|
||||
// 2. 展示二维码 + 倒计时(120s)
|
||||
// 3. 员工用企微扫 → 后端状态 → scanned → UI 切换"已扫码,请在手机上确认"
|
||||
// 4. 员工在手机上点确认 → 后端 confirm → 拿到 token → 写 localStorage → 跳 /workspace
|
||||
// 5. 倒计时归 0 → 自动停止轮询,UI 显示"已过期",用户点"刷新二维码"
|
||||
//
|
||||
// 管理员场景(扫码确认后 require_otp=true)→ 当前版本暂未集成 OTP 输入框
|
||||
// OTP 二次认证由 task #17 (Phase 2.1 后端 MFA) + task #20 (Phase 2.4 前端 MFA UI) 负责
|
||||
// 短期方案:管理员走 /itportal/ 入口(那边有 OTP UI)
|
||||
// 1. 用户输入账号 + 密码
|
||||
// 2. 点击登录 → 调用后端 /api/agents/login
|
||||
// 3. 若 require_otp=true,显示 OTP 输入框
|
||||
// 4. 用户输入 OTP 后再次提交
|
||||
// 5. 登录成功 → 存 token → 跳 /workspace
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
@@ -21,101 +17,79 @@
|
||||
<!-- 标题区 -->
|
||||
<div class="login-title">
|
||||
<h1>🛠️ IT智能服务台</h1>
|
||||
<p>坐席工作台 · 扫码登录</p>
|
||||
<p>坐席工作台 · 账号登录</p>
|
||||
</div>
|
||||
|
||||
<!-- 二维码区 -->
|
||||
<div class="qrcode-section">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading && !qrcodePngBase64" class="qrcode-placeholder">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>正在生成二维码…</p>
|
||||
</div>
|
||||
|
||||
<!-- 二维码图片(base64 PNG) -->
|
||||
<img
|
||||
v-else-if="qrcodePngBase64"
|
||||
:src="`data:image/png;base64,${qrcodePngBase64}`"
|
||||
alt="登录二维码"
|
||||
class="qrcode-image"
|
||||
:class="{ 'qrcode-expired': status === 'expired' }"
|
||||
/>
|
||||
|
||||
<!-- 降级:后端没返回 base64,显示 qrcode_url 提示用户手动复制 -->
|
||||
<div v-else-if="qrcodeUrl" class="qrcode-fallback">
|
||||
<p class="qrcode-fallback-hint">请复制以下链接到企业微信打开:</p>
|
||||
<!-- 登录表单 -->
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="loginForm"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="账号" prop="userId">
|
||||
<el-input
|
||||
:model-value="qrcodeUrl"
|
||||
readonly
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
class="qrcode-fallback-url"
|
||||
v-model="loginForm.userId"
|
||||
placeholder="请输入账号(如 sxn)"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
<p class="qrcode-fallback-tip">
|
||||
(前端暂未集成二维码渲染库,后端应返回 base64 PNG)
|
||||
</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="errorMessage" class="qrcode-error">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 状态文字 -->
|
||||
<div class="status-section">
|
||||
<!-- 等待扫码 -->
|
||||
<div v-if="status === 'waiting' && countdown > 0" class="status-waiting">
|
||||
<p class="status-main">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
请用<span class="highlight">企业微信</span>扫描二维码
|
||||
</p>
|
||||
<p class="status-sub">
|
||||
二维码 <span class="countdown">{{ countdown }}</span> 秒后过期
|
||||
</p>
|
||||
</div>
|
||||
<!-- OTP 输入区: 仅在 require_otp 时显示 -->
|
||||
<el-form-item v-if="requireOtp" label="OTP 验证码" prop="otpCode">
|
||||
<el-input
|
||||
v-model="loginForm.otpCode"
|
||||
placeholder="请输入 Google Authenticator 验证码"
|
||||
size="large"
|
||||
:prefix-icon="Key"
|
||||
maxlength="6"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 已扫码,等待员工在手机上确认 -->
|
||||
<div v-else-if="status === 'scanned'" class="status-scanned">
|
||||
<p class="status-main">
|
||||
<el-icon color="#67c23a"><Check /></el-icon>
|
||||
扫码成功
|
||||
</p>
|
||||
<p class="status-sub" v-if="scannedBy">
|
||||
{{ scannedBy }},请在手机上点<span class="highlight">"确认登录"</span>
|
||||
</p>
|
||||
<p class="status-sub" v-else>
|
||||
请在手机上点<span class="highlight">"确认登录"</span>
|
||||
</p>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
v-if="otpRequired"
|
||||
type="primary"
|
||||
class="otp-button"
|
||||
@click="handleOtpConfirm"
|
||||
size="large"
|
||||
:loading="logging"
|
||||
:disabled="logging"
|
||||
@click="handleLogin"
|
||||
class="login-btn"
|
||||
>
|
||||
输入 OTP 动态码
|
||||
{{ logging ? '登录中...' : (requireOtp ? '验证 OTP' : '登录') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 已过期 -->
|
||||
<div v-else-if="status === 'expired'" class="status-expired">
|
||||
<p class="status-main">
|
||||
<el-icon color="#e6a23c"><Warning /></el-icon>
|
||||
二维码已过期
|
||||
</p>
|
||||
<el-button type="primary" class="refresh-button" @click="refreshQrcode">
|
||||
刷新二维码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="errorMsg = ''"
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<!-- 提示信息 -->
|
||||
<div class="login-hint">
|
||||
<p>登录即表示同意《IT智能服务台使用规范》</p>
|
||||
<p class="login-hint-sub">
|
||||
首次使用请确保已在企业微信中完成认证
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,65 +99,93 @@
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useQrcodeLogin } from '@/composables/useQrcodeLogin'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { User, Lock, Key } from '@element-plus/icons-vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
/**
|
||||
* 扫码登录成功回调
|
||||
* 1. 存 token 到 localStorage(双 key: agent_token + portal_token,跨端共享)
|
||||
* 2. 跳转到 /workspace
|
||||
*/
|
||||
function handleLoginSuccess(token: string, _employeeId: string, _roles: string[]): void {
|
||||
localStorage.setItem('agent_token', token)
|
||||
localStorage.setItem('portal_token', token)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/workspace')
|
||||
}
|
||||
/** 表单引用 */
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const {
|
||||
qrcodePngBase64,
|
||||
qrcodeUrl,
|
||||
countdown,
|
||||
status,
|
||||
otpRequired,
|
||||
scannedBy,
|
||||
loading,
|
||||
errorMessage,
|
||||
startLogin,
|
||||
refreshQrcode,
|
||||
stopPolling,
|
||||
} = useQrcodeLogin({
|
||||
onSuccess: handleLoginSuccess,
|
||||
onError: (msg) => ElMessage.error(msg),
|
||||
/** 登录表单数据 */
|
||||
const loginForm = reactive({
|
||||
userId: '',
|
||||
password: '',
|
||||
otpCode: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 管理员 OTP 输入按钮(暂未实现完整流程,提示用户去 /itportal/)
|
||||
* Phase 2.4 完成后这里跳到 OTP 输入弹窗
|
||||
*/
|
||||
function handleOtpConfirm(): void {
|
||||
ElMessage.info('管理员 OTP 二次认证:请前往 /itportal/ 完成(Phase 2.4 即将上线)')
|
||||
/** 是否需要 OTP 验证 */
|
||||
const requireOtp = ref(false)
|
||||
|
||||
/** 登录中状态 */
|
||||
const logging = ref(false)
|
||||
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref<string>('')
|
||||
|
||||
/** 表单校验规则 */
|
||||
const rules: FormRules = {
|
||||
userId: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
],
|
||||
otpCode: [
|
||||
{ required: true, message: '请输入 OTP 验证码', trigger: 'blur' },
|
||||
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// 方法
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
// 进入页面立即生成二维码
|
||||
startLogin()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 离开页面停止轮询(防止内存泄漏)
|
||||
stopPolling()
|
||||
})
|
||||
/**
|
||||
* 处理登录
|
||||
*/
|
||||
async function handleLogin(): Promise<void> {
|
||||
// 表单校验
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
logging.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const result = await agentStore.login(
|
||||
loginForm.userId.trim(),
|
||||
loginForm.password,
|
||||
loginForm.otpCode.trim() || undefined
|
||||
)
|
||||
|
||||
// 检查是否需要 OTP 验证
|
||||
if (result && result.require_otp) {
|
||||
requireOtp.value = true
|
||||
loginForm.otpCode = ''
|
||||
ElMessage.info('请输入 OTP 验证码')
|
||||
logging.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/workspace')
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
|
||||
errorMsg.value = errMsg
|
||||
} finally {
|
||||
logging.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -200,7 +202,7 @@ onUnmounted(() => {
|
||||
/* 登录卡片 */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
max-width: 400px;
|
||||
background: var(--bg-secondary, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px;
|
||||
@@ -226,111 +228,10 @@ onUnmounted(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 二维码区 */
|
||||
.qrcode-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.qrcode-expired {
|
||||
opacity: 0.3;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.qrcode-placeholder,
|
||||
.qrcode-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
}
|
||||
|
||||
.qrcode-placeholder .el-icon,
|
||||
.qrcode-error .el-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.qrcode-fallback {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qrcode-fallback-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-regular, #606266);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qrcode-fallback-url {
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.qrcode-fallback-tip {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder, #c0c4cc);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 状态区 */
|
||||
.status-section {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.status-main {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-waiting .status-main .el-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refresh-button,
|
||||
.otp-button {
|
||||
margin-top: 16px;
|
||||
/* 登录按钮 */
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 底部提示 */
|
||||
@@ -341,14 +242,10 @@ onUnmounted(() => {
|
||||
line-height: 1.6;
|
||||
border-top: 1px solid var(--border-color-lighter, #ebeef5);
|
||||
padding-top: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-hint p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.login-hint-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder, #c0c4cc);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user