master
lion 2 months ago
parent cf7abe4599
commit 3fa8ae1d4b

@ -1,68 +1,190 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import 'bootstrap/dist/css/bootstrap.min.css'
import '../../styles/prototype-styles.css'
import { adminLogin } from '../../api/admin/auth' import { adminLogin } from '../../api/admin/auth'
import { getApiBase } from '../../config/api' import { getApiBase } from '../../config/api'
import { useAdminAuthStore } from '../../stores/adminAuth' import { useAdminAuthStore } from '../../stores/adminAuth'
import { import {
brandingFormFromApi, brandingFormFromApi,
emptyBrandingForm, emptyBrandingForm,
hasVisibleText,
type BrandingForm, type BrandingForm,
} from '../../utils/competitionBranding' } from '../../utils/competitionBranding'
import { applyParticipantBodyTheme, clearParticipantBodyTheme } from '../../utils/participantTheme' import '../../styles/prototype-styles.css'
import '../../styles/login-page-overrides.css'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const adminAuth = useAdminAuthStore() const adminAuth = useAdminAuthStore()
const slug = computed(() => String(route.params.slug ?? '').trim())
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const usernameErr = ref(false)
const passwordErr = ref(false)
const submitting = ref(false) const submitting = ref(false)
const errorMsg = ref('') const apiHint = ref('')
const apiHintTone = ref<'neutral' | 'error' | 'success'>('neutral')
const competitionSlug = computed(() => String(route.params.slug ?? '').trim())
const competitionLoadError = ref('')
const brand = ref<BrandingForm>(emptyBrandingForm()) const brand = ref<BrandingForm>(emptyBrandingForm())
const competitionName = ref('') const competitionName = ref('')
const headline = computed(() => { const apiHintClass = computed(() => {
if (apiHintTone.value === 'error') return 'login-page-wls__hint login-page-wls__hint--error'
if (apiHintTone.value === 'success') return 'login-page-wls__hint login-page-wls__hint--success'
return 'login-page-wls__hint'
})
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const markLineInnerHtml = computed(() => {
const t = brand.value.login.markLine
return hasVisibleText(t) ? escapeHtml(t) : ''
})
const headlineInnerHtml = computed(() => {
const t = brand.value.login.headline
if (hasVisibleText(t)) return escapeHtml(t)
const n = competitionName.value.trim() const n = competitionName.value.trim()
return n ? `${n} · 管理登录` : '赛事管理登录' return n ? escapeHtml(n) : ''
})
const sloganText = computed(() => {
if (competitionLoadError.value) return competitionLoadError.value
const s = brand.value.login.slogan
if (hasVisibleText(s)) return s
return ''
}) })
const slogan = computed(() => brand.value.login.slogan || '请使用后台管理员账号登录') const sloganIsWarning = computed(() => Boolean(competitionLoadError.value))
async function loadCompetition() { const cardTitleDisplay = computed(() => {
const s = slug.value if (hasVisibleText(brand.value.login.cardWelcome)) {
if (!s) return return brand.value.login.cardWelcome.replace(/登陆/g, '登录')
}
return '欢迎登录'
})
const footerCopyrightText = computed(() =>
hasVisibleText(brand.value.login.footerCopyright) ? brand.value.login.footerCopyright : '',
)
const logoUrl = computed(() =>
hasVisibleText(brand.value.login.logoUrl) ? brand.value.login.logoUrl : '',
)
let bodyThemePrimaryBackup: string | null = null
let faviconHrefBackup: string | null = null
let loginHtmlClassApplied = false
function setHint(message: string, tone: 'neutral' | 'error' | 'success' = 'neutral') {
apiHint.value = message
apiHintTone.value = tone
}
function clearHint() {
apiHint.value = ''
apiHintTone.value = 'neutral'
}
function normalizeTitle(s: string): string {
return s.replace(/[\u200B-\u200D\uFEFF]/gu, '').trim()
}
async function loadCompetitionBrand() {
competitionLoadError.value = ''
brand.value = emptyBrandingForm()
competitionName.value = ''
const slug = competitionSlug.value
const titleSnapshot = typeof document !== 'undefined' ? document.title : ''
if (!slug) {
competitionLoadError.value = '缺少赛事访问地址,请从正确入口进入'
return
}
try { try {
const r = await fetch(`${getApiBase()}/api/v1/public/competitions/by-slug/${encodeURIComponent(s)}`, { const r = await fetch(`${getApiBase()}/api/v1/public/competitions/by-slug/${encodeURIComponent(slug)}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}) })
if (!r.ok) return if (!r.ok) {
const raw = (await r.json()) as Record<string, unknown> competitionLoadError.value =
const data = r.status === 404 ? '赛事不存在或未发布,请核对访问地址' : '无法加载赛事信息'
raw.data != null && typeof raw.data === 'object' && !Array.isArray(raw.data) return
? (raw.data as Record<string, unknown>) }
const raw = await r.json()
const payload =
raw != null && typeof raw === 'object' && 'data' in (raw as object)
? (raw as { data?: unknown }).data
: raw : raw
competitionName.value = String(data.name ?? '') const d = payload as Record<string, unknown>
brand.value = brandingFormFromApi(data.branding_json ?? null)
competitionName.value = String(d?.name ?? '')
brand.value = brandingFormFromApi(d?.branding_json ?? d?.branding ?? null)
const docTitle = brand.value.documentTitle
if (typeof document !== 'undefined') {
document.title = hasVisibleText(docTitle)
? `${normalizeTitle(docTitle)} · 管理登录`
: titleSnapshot || '管理登录'
}
const theme = brand.value.login.themePrimary
if (hasVisibleText(theme) && typeof document !== 'undefined') {
if (bodyThemePrimaryBackup === null) {
bodyThemePrimaryBackup = document.body.style.getPropertyValue('--primary') || ''
}
document.body.style.setProperty('--primary', theme)
document.body.style.setProperty('--primary-soft', `${theme}14`)
}
const fav = brand.value.login.faviconUrl
if (hasVisibleText(fav) && typeof document !== 'undefined') {
let link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
if (faviconHrefBackup === null) {
faviconHrefBackup = link.getAttribute('href') || ''
}
link.href = fav
}
} catch { } catch {
brand.value = emptyBrandingForm() competitionLoadError.value = '网络错误,无法校验赛事链接'
} }
} }
async function onSubmit() { async function onSubmit() {
errorMsg.value = '' const u = username.value.trim()
if (!username.value.trim() || !password.value.trim()) { const p = password.value
errorMsg.value = '请输入账号和密码' usernameErr.value = !u
passwordErr.value = !p
if (!u) {
setHint('请填写账号', 'error')
return
}
if (!p) {
setHint('请填写密码', 'error')
return
}
if (!competitionSlug.value) {
setHint('缺少赛事访问地址', 'error')
return return
} }
clearHint()
submitting.value = true submitting.value = true
setHint('登录中…', 'neutral')
try { try {
const res = await adminLogin({ const res = await adminLogin({
username: username.value.trim(), username: u,
password: password.value.trim(), password: p,
}) })
adminAuth.setSession( adminAuth.setSession(
res.token, res.token,
@ -70,70 +192,154 @@ async function onSubmit() {
? { id: res.user.id, username: res.user.username, name: res.user.name } ? { id: res.user.id, username: res.user.username, name: res.user.name }
: null, : null,
) )
clearHint()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '' const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect && redirect.includes('/manage') && !redirect.includes('/login')) { if (redirect && redirect.includes('/manage') && !redirect.includes('/login')) {
await router.replace(redirect) await router.replace(redirect)
} else { } else {
await router.replace({ name: 'manage-dashboard', params: { slug: slug.value } }) await router.replace({ name: 'manage-dashboard', params: { slug: competitionSlug.value } })
} }
} catch (e) { } catch (e) {
errorMsg.value = e instanceof Error ? e.message : '登录失败' setHint(e instanceof Error ? e.message : '登录失败', 'error')
} finally { } finally {
submitting.value = false submitting.value = false
} }
} }
onMounted(() => { onMounted(() => {
document.body.classList.add('prototype-page', 'participant-portal-theme', 'admin-desktop-page') if (typeof document !== 'undefined') {
void loadCompetition() document.documentElement.classList.add('cxxfds-login-page')
loginHtmlClassApplied = true
}
document.body.classList.add('login-page-wls')
void loadCompetitionBrand()
}) })
watch(brand, () => applyParticipantBodyTheme(brand.value.login.themePrimary), { deep: true, immediate: true })
onUnmounted(() => { onUnmounted(() => {
document.body.classList.remove('prototype-page', 'participant-portal-theme', 'admin-desktop-page') if (typeof document !== 'undefined' && loginHtmlClassApplied) {
clearParticipantBodyTheme() document.documentElement.classList.remove('cxxfds-login-page')
loginHtmlClassApplied = false
}
document.body.classList.remove('login-page-wls')
if (typeof document !== 'undefined') {
if (bodyThemePrimaryBackup !== null) {
if (bodyThemePrimaryBackup === '') {
document.body.style.removeProperty('--primary')
document.body.style.removeProperty('--primary-soft')
} else {
document.body.style.setProperty('--primary', bodyThemePrimaryBackup)
}
bodyThemePrimaryBackup = null
}
if (faviconHrefBackup !== null) {
const link = document.querySelector('link[rel~="icon"]') as HTMLLinkElement | null
if (link) {
if (faviconHrefBackup === '') link.removeAttribute('href')
else link.href = faviconHrefBackup
}
faviconHrefBackup = null
}
}
}) })
</script> </script>
<template> <template>
<div class="container-fluid p-4 manage-login-wrap"> <div class="login-page-wls__wrap">
<div class="row justify-content-center"> <div class="login-page-wls__inner">
<div class="col-12 col-md-6 col-lg-4"> <aside class="login-page-wls__brand" aria-label="赛事说明">
<div class="card shadow-sm"> <div class="login-page-wls__logo" aria-hidden="true">
<div class="card-body p-4"> <img
<h5 class="section-title mb-1">{{ headline }}</h5> v-if="logoUrl"
<p class="small text-secondary mb-4">{{ slogan }}</p> :src="logoUrl"
<form @submit.prevent="onSubmit"> alt=""
<div class="mb-3"> class="login-page-wls__logo-img"
<label class="form-label">账号</label> width="72"
<input v-model.trim="username" class="form-control" autocomplete="username" /> height="72"
</div> />
<div class="mb-3"> <svg
<label class="form-label">密码</label> v-else
<input viewBox="0 0 100 100"
v-model="password" width="72"
type="password" height="72"
class="form-control" xmlns="http://www.w3.org/2000/svg"
autocomplete="current-password" aria-hidden="true"
/> >
</div> <defs>
<p v-if="errorMsg" class="small text-danger mb-3">{{ errorMsg }}</p> <linearGradient id="manageLoginPetalGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<button type="submit" class="btn btn-primary w-100" :disabled="submitting"> <stop offset="0%" stop-color="#dc4a54" />
{{ submitting ? '登录中…' : '登录' }} <stop offset="45%" stop-color="#b40010" />
</button> <stop offset="100%" stop-color="#7d070c" />
</form> </linearGradient>
</div> <path
id="manageLoginPetal"
d="M50 50
C46.2 44.2 44.2 34.5 46.2 27.2
C47.3 23.6 48.6 21.2 50 20.2
C51.4 21.2 52.7 23.6 53.8 27.2
C55.8 34.5 53.8 44.2 50 50Z"
/>
</defs>
<g fill="url(#manageLoginPetalGrad)" stroke="#8f000c" stroke-width="0.35" stroke-linejoin="round">
<use href="#manageLoginPetal" />
<use href="#manageLoginPetal" transform="rotate(90 50 50)" />
<use href="#manageLoginPetal" transform="rotate(180 50 50)" />
<use href="#manageLoginPetal" transform="rotate(270 50 50)" />
</g>
<circle cx="50" cy="50" r="3.6" fill="#5c6068" stroke="#fdfcfc" stroke-width="0.65" />
</svg>
</div> </div>
</div> <div v-if="hasVisibleText(brand.login.markLine)" class="login-page-wls__mark">
<div class="login-page-wls__mark-cn" v-html="markLineInnerHtml" />
</div>
<h1 v-if="headlineInnerHtml" class="login-page-wls__title">
<span class="login-page-wls__title-main" v-html="headlineInnerHtml" />
</h1>
<p
v-if="sloganText"
class="login-page-wls__slogan"
:class="{ 'login-page-wls__slogan--error': sloganIsWarning }"
>
{{ sloganText }}
</p>
</aside>
<section class="login-page-wls__panel">
<div class="login-page-wls__card">
<h2 class="login-page-wls__card-title">{{ cardTitleDisplay }}</h2>
<form class="login-page-wls__form" @submit.prevent="onSubmit" novalidate>
<input
v-model.trim="username"
type="text"
class="login-page-wls__input"
:class="{ 'login-page-wls__input--error': usernameErr }"
name="username"
autocomplete="username"
maxlength="64"
placeholder="账号"
@input="usernameErr = false"
/>
<input
v-model="password"
type="password"
class="login-page-wls__input"
:class="{ 'login-page-wls__input--error': passwordErr }"
name="password"
autocomplete="current-password"
placeholder="密码"
maxlength="255"
@input="passwordErr = false"
/>
<button type="submit" class="login-page-wls__submit" :disabled="submitting">
{{ submitting ? '登录中…' : '登录' }}
</button>
<p v-if="apiHint" :class="apiHintClass" style="margin-top: 0.75rem">{{ apiHint }}</p>
</form>
</div>
</section>
</div> </div>
<footer v-if="footerCopyrightText" class="login-page-wls__footer">
<p class="login-page-wls__copyright">{{ footerCopyrightText }}</p>
</footer>
</div> </div>
</template> </template>
<style scoped>
.manage-login-wrap {
min-height: 70vh;
display: flex;
align-items: center;
}
</style>

Loading…
Cancel
Save