🐛 调整两个版本邮件格式
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useLoadingBar } from '@/composables/useLoadingBar'
|
||||||
|
|
||||||
|
const { progress, visible, status } = useLoadingBar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="loading-bar">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed top-0 left-0 right-0 z-[9999] h-[2px] pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full transition-all duration-200 ease-out"
|
||||||
|
:class="[
|
||||||
|
status === 'error' ? 'bg-destructive' : 'bg-primary',
|
||||||
|
]"
|
||||||
|
:style="{ width: progress + '%' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="status === 'loading'"
|
||||||
|
class="absolute right-0 top-0 h-full w-24 opacity-60 blur-sm bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-bar-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.loading-bar-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const progress = ref(0)
|
||||||
|
const visible = ref(false)
|
||||||
|
const status = ref<'loading' | 'done' | 'error'>('loading')
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function clearTimer() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
clearTimer()
|
||||||
|
progress.value = 0
|
||||||
|
status.value = 'loading'
|
||||||
|
visible.value = true
|
||||||
|
// 模拟进度:快速到 80%,然后减速
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (progress.value < 80) {
|
||||||
|
progress.value += (80 - progress.value) * 0.1
|
||||||
|
} else if (progress.value < 95) {
|
||||||
|
progress.value += 0.5
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
clearTimer()
|
||||||
|
status.value = 'done'
|
||||||
|
progress.value = 100
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
progress.value = 0
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function error() {
|
||||||
|
clearTimer()
|
||||||
|
status.value = 'error'
|
||||||
|
progress.value = 100
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
progress.value = 0
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoadingBar() {
|
||||||
|
return { progress, visible, status, start, finish, error }
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { cpUserApi } from '@/api/cpdaily'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const uuid = route.params.uuid as string
|
||||||
|
|
||||||
|
type PageState = 'loading' | 'invalid' | 'not-cpdaily' | 'fetching' | 'success' | 'error'
|
||||||
|
const state = ref<PageState>('loading')
|
||||||
|
const deviceCode = ref('')
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
function isCpdaily(): boolean {
|
||||||
|
const ua = navigator.userAgent.toLowerCase()
|
||||||
|
return ua.includes('cpdaily') || ua.includes('wisedu') || ua.includes('campushoy')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCode() {
|
||||||
|
if (!deviceCode.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(deviceCode.value)
|
||||||
|
} catch {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = deviceCode.value
|
||||||
|
el.style.cssText = 'position:fixed;opacity:0'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
}
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCordova(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const isAndroid = /Android|Linux/.test(navigator.userAgent)
|
||||||
|
const src = 'https://static.campushoy.com/innerjs/'
|
||||||
|
+ (isAndroid ? 'cordova_android.js' : 'cordova_ios.js')
|
||||||
|
const s = document.createElement('script')
|
||||||
|
s.src = src
|
||||||
|
s.onload = () => resolve()
|
||||||
|
s.onerror = () => reject(new Error('脚本加载失败'))
|
||||||
|
document.head.appendChild(s)
|
||||||
|
if (!(window as any).mamp) (window as any).mamp = {}
|
||||||
|
;(window as any).mamp.initEnv = (cb: () => void) =>
|
||||||
|
document.addEventListener('deviceready', cb, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitCordova(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let n = 0
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if ((window as any).cordova?.exec) {
|
||||||
|
clearInterval(t)
|
||||||
|
resolve()
|
||||||
|
} else if (++n > 600) {
|
||||||
|
clearInterval(t)
|
||||||
|
reject(new Error('Cordova 加载超时'))
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceUuid(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
;(window as any).cordova.exec(
|
||||||
|
(res: any) => resolve(res.uuid),
|
||||||
|
(err: any) => reject(new Error(err?.message || String(err))),
|
||||||
|
'MAMPDevice', 'getDeviceInfo', []
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 1. 验证UUID有效性
|
||||||
|
try {
|
||||||
|
await cpUserApi.checkQr(uuid)
|
||||||
|
} catch {
|
||||||
|
state.value = 'invalid'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检测是否在今日校园App内
|
||||||
|
if (!isCpdaily()) {
|
||||||
|
state.value = 'not-cpdaily'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 加载cordova获取设备码
|
||||||
|
state.value = 'fetching'
|
||||||
|
try {
|
||||||
|
await loadCordova()
|
||||||
|
await waitCordova()
|
||||||
|
deviceCode.value = await getDeviceUuid()
|
||||||
|
state.value = 'success'
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = e.message || '未知错误'
|
||||||
|
state.value = 'error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="authorize-page">
|
||||||
|
<div class="aurora-orb aurora-orb-1" />
|
||||||
|
<div class="aurora-orb aurora-orb-2" />
|
||||||
|
<div class="container">
|
||||||
|
<div class="glass-card">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<template v-if="state === 'loading'">
|
||||||
|
<div class="icon-wrap icon-loading">📱</div>
|
||||||
|
<h1>正在验证...</h1>
|
||||||
|
<p class="desc">请稍候</p>
|
||||||
|
<div class="spinner" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 二维码无效 -->
|
||||||
|
<template v-else-if="state === 'invalid'">
|
||||||
|
<div class="icon-wrap icon-error">✕</div>
|
||||||
|
<h1>二维码无效</h1>
|
||||||
|
<p class="err-text">二维码无效或已过期,请重新获取</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 非今日校园App -->
|
||||||
|
<template v-else-if="state === 'not-cpdaily'">
|
||||||
|
<div class="icon-wrap icon-warn">📵</div>
|
||||||
|
<h1>请用今日校园扫码</h1>
|
||||||
|
<p class="desc">此页面需要在<strong>今日校园 App</strong>内打开才能获取设备码</p>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div class="step-text">打开今日校园 App<span>在手机上启动今日校园</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div class="step-text">点击右上角扫一扫<span>使用 App 内置扫码功能</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div class="step-text">扫描电脑上的二维码<span>扫码后设备码将自动显示</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 正在获取设备码 -->
|
||||||
|
<template v-else-if="state === 'fetching'">
|
||||||
|
<div class="icon-wrap icon-loading">📱</div>
|
||||||
|
<h1>正在获取设备码</h1>
|
||||||
|
<p class="desc">请稍候,正在读取设备信息...</p>
|
||||||
|
<div class="spinner" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 获取成功 -->
|
||||||
|
<template v-else-if="state === 'success'">
|
||||||
|
<div class="icon-wrap icon-success">✓</div>
|
||||||
|
<h1>获取成功</h1>
|
||||||
|
<p class="desc">已成功获取设备码,请复制后转发给系统管理员</p>
|
||||||
|
<div class="result-section">
|
||||||
|
<div class="result-label">设备码</div>
|
||||||
|
<div class="code-box">{{ deviceCode }}</div>
|
||||||
|
<button class="btn-primary" @click="copyCode">
|
||||||
|
<span>📋</span>
|
||||||
|
<span>{{ copied ? '已复制 ✓' : '复制设备码' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="footer-note">请及时复制,关闭页面后需重新扫码获取</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 获取失败 -->
|
||||||
|
<template v-else-if="state === 'error'">
|
||||||
|
<div class="icon-wrap icon-error">✕</div>
|
||||||
|
<h1>获取失败</h1>
|
||||||
|
<p class="err-text">{{ errorMsg }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.authorize-page {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: #F5F5F7;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.aurora-orb {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
animation: float 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.aurora-orb-1 {
|
||||||
|
width: 60vw; height: 60vw;
|
||||||
|
background: radial-gradient(circle, rgba(162,210,255,0.4), transparent);
|
||||||
|
top: -20%; left: -10%;
|
||||||
|
}
|
||||||
|
.aurora-orb-2 {
|
||||||
|
width: 55vw; height: 55vw;
|
||||||
|
background: radial-gradient(circle, rgba(200,180,255,0.3), transparent);
|
||||||
|
bottom: -20%; right: -10%;
|
||||||
|
animation-delay: -10s;
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%,100% { transform: translate(0,0) scale(1); }
|
||||||
|
50% { transform: translate(30px,-30px) scale(1.1); }
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.6);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
|
||||||
|
padding: 36px 28px 32px;
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeInUp 0.6s cubic-bezier(0.25,1,0.5,1) 0.05s backwards;
|
||||||
|
}
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.icon-wrap {
|
||||||
|
width: 72px; height: 72px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
.icon-loading { background: linear-gradient(135deg,#0071e3,#40a9ff); box-shadow: 0 4px 16px rgba(0,113,227,0.3); }
|
||||||
|
.icon-warn { background: linear-gradient(135deg,#ff9f0a,#ffcc02); box-shadow: 0 4px 16px rgba(255,159,10,0.3); }
|
||||||
|
.icon-success { background: linear-gradient(135deg,#34c759,#30d158); box-shadow: 0 4px 16px rgba(52,199,89,0.3); }
|
||||||
|
.icon-error { background: linear-gradient(135deg,#ff3b30,#ff6961); box-shadow: 0 4px 16px rgba(255,59,48,0.3); }
|
||||||
|
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; color: #1d1d1f; margin-bottom: 10px; }
|
||||||
|
.desc { font-size: 15px; color: #86868b; line-height: 1.6; }
|
||||||
|
.desc strong { color: #1d1d1f; font-weight: 600; }
|
||||||
|
.spinner {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border: 3px solid rgba(0,113,227,0.15);
|
||||||
|
border-top-color: #0071e3;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 20px auto 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.steps { margin: 20px 0 0; display: flex; flex-direction: column; gap: 10px; text-align: left; }
|
||||||
|
.step {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
background: rgba(0,0,0,0.03); border-radius: 12px;
|
||||||
|
padding: 12px 14px; border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.step-num {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
background: #0071e3; color: white;
|
||||||
|
font-size: 12px; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; margin-top: 1px;
|
||||||
|
}
|
||||||
|
.step-text { font-size: 14px; color: #1d1d1f; line-height: 1.5; }
|
||||||
|
.step-text span { color: #86868b; font-size: 13px; display: block; margin-top: 2px; }
|
||||||
|
.err-text { font-size: 15px; color: #d70015; margin-top: 16px; line-height: 1.5; }
|
||||||
|
.result-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
.result-label {
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
letter-spacing: 0.07em; text-transform: uppercase;
|
||||||
|
color: #86868b; text-align: left;
|
||||||
|
}
|
||||||
|
.code-box {
|
||||||
|
background: #F2F2F7; border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: "SF Mono", "Fira Code", monospace;
|
||||||
|
font-size: 13px; color: #1d1d1f;
|
||||||
|
word-break: break-all; line-height: 1.7;
|
||||||
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
|
user-select: all; text-align: left;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #0071e3; color: white;
|
||||||
|
padding: 13px 24px; border-radius: 980px; border: none;
|
||||||
|
font-size: 15px; font-weight: 500; cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.25,1,0.5,1);
|
||||||
|
width: 100%;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { transform: scale(1.02); box-shadow: 0 8px 24px rgba(0,113,227,0.3); filter: brightness(1.08); }
|
||||||
|
.btn-primary:active { transform: scale(0.98); }
|
||||||
|
.footer-note {
|
||||||
|
text-align: center; font-size: 13px; color: #86868b;
|
||||||
|
margin-top: 16px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user