master
lion 2 months ago
parent cf7abe4599
commit 3fa8ae1d4b

@ -1,68 +1,190 @@
<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 'bootstrap/dist/css/bootstrap.min.css'
import '../../styles/prototype-styles.css'
import { adminLogin } from '../../api/admin/auth'
import { getApiBase } from '../../config/api'
import { useAdminAuthStore } from '../../stores/adminAuth'
import {
brandingFormFromApi,
emptyBrandingForm,
hasVisibleText,
type BrandingForm,
} from '../../utils/competitionBranding'
import { applyParticipantBodyTheme, clearParticipantBodyTheme } from '../../utils/participantTheme'
import '../../styles/prototype-styles.css'
import '../../styles/login-page-overrides.css'
const route = useRoute()
const router = useRouter()
const adminAuth = useAdminAuthStore()
const slug = computed(() => String(route.params.slug ?? '').trim())
const username = ref('')
const password = ref('')
const usernameErr = ref(false)
const passwordErr = 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 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()
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 s = slug.value
if (!s) return
const cardTitleDisplay = computed(() => {
if (hasVisibleText(brand.value.login.cardWelcome)) {
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 {
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' },
})
if (!r.ok) return
const raw = (await r.json()) as Record<string, unknown>
const data =
raw.data != null && typeof raw.data === 'object' && !Array.isArray(raw.data)
? (raw.data as Record<string, unknown>)
if (!r.ok) {
competitionLoadError.value =
r.status === 404 ? '赛事不存在或未发布,请核对访问地址' : '无法加载赛事信息'
return
}
const raw = await r.json()
const payload =
raw != null && typeof raw === 'object' && 'data' in (raw as object)
? (raw as { data?: unknown }).data
: raw
competitionName.value = String(data.name ?? '')
brand.value = brandingFormFromApi(data.branding_json ?? null)
const d = payload as Record<string, unknown>
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 {
brand.value = emptyBrandingForm()
competitionLoadError.value = '网络错误,无法校验赛事链接'
}
}
async function onSubmit() {
errorMsg.value = ''
if (!username.value.trim() || !password.value.trim()) {
errorMsg.value = '请输入账号和密码'
const u = username.value.trim()
const p = password.value
usernameErr.value = !u
passwordErr.value = !p
if (!u) {
setHint('请填写账号', 'error')
return
}
if (!p) {
setHint('请填写密码', 'error')
return
}
if (!competitionSlug.value) {
setHint('缺少赛事访问地址', 'error')
return
}
clearHint()
submitting.value = true
setHint('登录中…', 'neutral')
try {
const res = await adminLogin({
username: username.value.trim(),
password: password.value.trim(),
username: u,
password: p,
})
adminAuth.setSession(
res.token,
@ -70,70 +192,154 @@ async function onSubmit() {
? { id: res.user.id, username: res.user.username, name: res.user.name }
: null,
)
clearHint()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect && redirect.includes('/manage') && !redirect.includes('/login')) {
await router.replace(redirect)
} else {
await router.replace({ name: 'manage-dashboard', params: { slug: slug.value } })
await router.replace({ name: 'manage-dashboard', params: { slug: competitionSlug.value } })
}
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '登录失败'
setHint(e instanceof Error ? e.message : '登录失败', 'error')
} finally {
submitting.value = false
}
}
onMounted(() => {
document.body.classList.add('prototype-page', 'participant-portal-theme', 'admin-desktop-page')
void loadCompetition()
if (typeof document !== 'undefined') {
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(() => {
document.body.classList.remove('prototype-page', 'participant-portal-theme', 'admin-desktop-page')
clearParticipantBodyTheme()
if (typeof document !== 'undefined' && loginHtmlClassApplied) {
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>
<template>
<div class="container-fluid p-4 manage-login-wrap">
<div class="row justify-content-center">
<div class="col-12 col-md-6 col-lg-4">
<div class="card shadow-sm">
<div class="card-body p-4">
<h5 class="section-title mb-1">{{ headline }}</h5>
<p class="small text-secondary mb-4">{{ slogan }}</p>
<form @submit.prevent="onSubmit">
<div class="mb-3">
<label class="form-label">账号</label>
<input v-model.trim="username" class="form-control" autocomplete="username" />
</div>
<div class="mb-3">
<label class="form-label">密码</label>
<input
v-model="password"
type="password"
class="form-control"
autocomplete="current-password"
/>
</div>
<p v-if="errorMsg" class="small text-danger mb-3">{{ errorMsg }}</p>
<button type="submit" class="btn btn-primary w-100" :disabled="submitting">
{{ submitting ? '登录中…' : '登录' }}
</button>
</form>
</div>
<div class="login-page-wls__wrap">
<div class="login-page-wls__inner">
<aside class="login-page-wls__brand" aria-label="赛事说明">
<div class="login-page-wls__logo" aria-hidden="true">
<img
v-if="logoUrl"
:src="logoUrl"
alt=""
class="login-page-wls__logo-img"
width="72"
height="72"
/>
<svg
v-else
viewBox="0 0 100 100"
width="72"
height="72"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id="manageLoginPetalGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#dc4a54" />
<stop offset="45%" stop-color="#b40010" />
<stop offset="100%" stop-color="#7d070c" />
</linearGradient>
<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 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>
<footer v-if="footerCopyrightText" class="login-page-wls__footer">
<p class="login-page-wls__copyright">{{ footerCopyrightText }}</p>
</footer>
</div>
</template>
<style scoped>
.manage-login-wrap {
min-height: 70vh;
display: flex;
align-items: center;
}
</style>

Loading…
Cancel
Save