|
|
|
|
@ -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, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|