pingshenduan rukou

master
lion 2 months ago
parent 7920555eae
commit afad17b1bc

@ -36,6 +36,12 @@ export interface CompetitionSuccessNoticeSettings {
message: string
}
/** 赛事 settings.review_portal:是否开放评审端登录 */
export interface CompetitionReviewPortalSettings {
enabled: boolean
message: string
}
export interface CompetitionPayload {
slug: string
name: string

@ -16,6 +16,7 @@ import {
reviewWorkspacePageTitle,
type BrandingForm,
} from '../utils/competitionBranding'
import { reviewPortalFromPublicPayload } from '../utils/reviewPortal'
import { applyParticipantBodyTheme, clearParticipantBodyTheme } from '../utils/participantTheme'
import PortalChangePasswordModal from '../components/portal/PortalChangePasswordModal.vue'
import { changeReviewerPassword } from '../api/reviewer/auth'
@ -67,6 +68,12 @@ async function loadBranding() {
raw.data != null && typeof raw.data === 'object' && !Array.isArray(raw.data)
? (raw.data as Record<string, unknown>)
: raw
const portal = reviewPortalFromPublicPayload(data)
if (!portal.enabled) {
clearReviewerSession()
if (s) void router.replace({ name: 'reviewer-login', params: { slug: s } })
return
}
competitionName.value = String(data.name ?? '')
brand.value = brandingFormFromApi(data.branding_json ?? null)
} catch {

@ -2,10 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router'
import {
TOKEN_KEY,
PARTICIPANT_COMPETITION_SLUG_KEY,
clearReviewerSession,
pathIsManageZone,
pathIsReviewerZone,
readReviewerSession,
} from '../config/api'
import { fetchReviewPortalBySlug } from '../utils/reviewPortal'
import { useAdminAuthStore } from '../stores/adminAuth'
import {
adminDynamicRoutesRegistered,
@ -210,6 +212,17 @@ router.beforeEach(async (to) => {
}
if (pathIsReviewerZone(to.path)) {
if (slugRaw) {
const reviewPortal = await fetchReviewPortalBySlug(slugRaw)
if (!reviewPortal.enabled) {
clearReviewerSession()
if (to.name !== 'reviewer-login') {
return { name: 'reviewer-login', params: { slug: slugRaw }, replace: true }
}
return true
}
}
const { token: reviewerToken, competitionSlug: reviewerSlug } = readReviewerSession()
if (to.name === 'reviewer-login') {

@ -110,3 +110,34 @@ body.login-page-wls .login-page-wls__contact a:hover {
color: #052d62;
text-decoration: underline;
}
/**
* 评审关闭弹窗确认按钮:与 .login-page-wls__submit 同色。
* 必须用固定 #052d62,不能用品牌 --primary(评审登录页主题常为红色)。
*/
body.login-page-wls .el-button.login-page-wls__msgbox-confirm,
.el-overlay-message-box .el-button.login-page-wls__msgbox-confirm,
.login-page-wls__msgbox .el-button.login-page-wls__msgbox-confirm {
--el-button-bg-color: #052d62 !important;
--el-button-border-color: rgba(255, 211, 106, 0.38) !important;
--el-button-hover-bg-color: #052d62 !important;
--el-button-hover-border-color: rgba(255, 244, 204, 0.5) !important;
--el-button-active-bg-color: #052d62 !important;
--el-button-active-border-color: rgba(255, 211, 106, 0.38) !important;
--el-button-text-color: #fff !important;
--el-button-hover-text-color: #fff !important;
--el-button-active-text-color: #fff !important;
background-color: #052d62 !important;
border-color: rgba(255, 211, 106, 0.38) !important;
color: #fff !important;
font-weight: 600;
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.28);
}
body.login-page-wls .el-button.login-page-wls__msgbox-confirm:hover,
.el-overlay-message-box .el-button.login-page-wls__msgbox-confirm:hover,
.login-page-wls__msgbox .el-button.login-page-wls__msgbox-confirm:hover {
filter: brightness(1.1);
border-color: rgba(255, 244, 204, 0.5) !important;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.32);
}

@ -0,0 +1,95 @@
import { getApiBase } from '../config/api'
export interface ReviewPortalSettings {
/** 缺省为 true(开启评审) */
enabled: boolean
message: string
}
const DEFAULT_CLOSED_MESSAGE = '评审通道暂未开放,请稍后再试'
type CacheEntry = { value: ReviewPortalSettings; at: number }
const cache = new Map<string, CacheEntry>()
const CACHE_TTL_MS = 5_000
export function reviewPortalFromSettings(settings: unknown): ReviewPortalSettings {
const raw =
settings && typeof settings === 'object' && !Array.isArray(settings)
? (settings as Record<string, unknown>).review_portal
: null
const portal =
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null
if (!portal) {
return { enabled: true, message: '' }
}
return {
enabled: portal.enabled !== false && portal.enabled !== 0 && portal.enabled !== '0',
message: typeof portal.message === 'string' ? portal.message : '',
}
}
export function reviewPortalFromPublicPayload(data: unknown): ReviewPortalSettings {
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
return { enabled: true, message: '' }
}
const raw = (data as Record<string, unknown>).review_portal
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
return { enabled: true, message: '' }
}
const portal = raw as Record<string, unknown>
const enabled = portal.enabled !== false && portal.enabled !== 0 && portal.enabled !== '0'
const message = typeof portal.message === 'string' ? portal.message.trim() : ''
return {
enabled,
message: enabled ? message : message || DEFAULT_CLOSED_MESSAGE,
}
}
export function closedReviewPortalMessage(message: string): string {
const t = message.trim()
return t || DEFAULT_CLOSED_MESSAGE
}
/** 拉取公开赛事的评审端开关(带短缓存,供路由守卫复用) */
export async function fetchReviewPortalBySlug(
slug: string,
options?: { bypassCache?: boolean },
): Promise<ReviewPortalSettings> {
const key = slug.trim()
if (!key) return { enabled: true, message: '' }
if (!options?.bypassCache) {
const hit = cache.get(key)
if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
return hit.value
}
}
try {
const r = await fetch(
`${getApiBase()}/api/v1/public/competitions/by-slug/${encodeURIComponent(key)}`,
{ headers: { Accept: 'application/json' } },
)
if (!r.ok) {
// 赛事不可用时不在此处拦截评审登录(由登录页自身处理加载错误)
return { enabled: true, message: '' }
}
const raw = (await r.json()) as unknown
const payload =
raw != null && typeof raw === 'object' && 'data' in (raw as object)
? (raw as { data?: unknown }).data
: raw
const value = reviewPortalFromPublicPayload(payload)
cache.set(key, { value, at: Date.now() })
return value
} catch {
return { enabled: true, message: '' }
}
}
export function invalidateReviewPortalCache(slug?: string): void {
if (slug) cache.delete(slug.trim())
else cache.clear()
}

@ -36,6 +36,7 @@ import type {
AdminApplicationRow,
CompetitionPayload,
CompetitionSuccessNoticeSettings,
CompetitionReviewPortalSettings,
CompetitionRow,
CompetitionTrackPayload,
CompetitionTrackRow,
@ -96,6 +97,8 @@ const form = ref({
pledge_content_html: '',
success_notice_enabled: false,
success_notice_message: '',
review_portal_enabled: true,
review_portal_message: '',
})
const brand = ref<BrandingForm>(emptyBrandingForm())
@ -224,6 +227,24 @@ function successNoticeFromSettings(settings: unknown): CompetitionSuccessNoticeS
}
}
function reviewPortalFromSettings(settings: unknown): CompetitionReviewPortalSettings {
const raw =
settings && typeof settings === 'object' && !Array.isArray(settings)
? (settings as Record<string, unknown>).review_portal
: null
const portal =
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null
if (!portal) {
return { enabled: true, message: '' }
}
return {
enabled: portal.enabled !== false && portal.enabled !== 0 && portal.enabled !== '0',
message: typeof portal.message === 'string' ? portal.message : '',
}
}
function settingsWithSuccessNotice(): Record<string, unknown> | null {
const settings = { ...rawSettings.value }
const enabled = form.value.success_notice_enabled
@ -234,6 +255,11 @@ function settingsWithSuccessNotice(): Record<string, unknown> | null {
delete settings.success_notice
}
settings.review_portal = {
enabled: form.value.review_portal_enabled !== false,
message: form.value.review_portal_message.trim(),
}
return Object.keys(settings).length > 0 ? settings : null
}
@ -473,6 +499,8 @@ async function loadDetail() {
pledge_content_html: '',
success_notice_enabled: false,
success_notice_message: '',
review_portal_enabled: true,
review_portal_message: '',
}
rawSettings.value = {}
return
@ -502,6 +530,8 @@ async function loadDetail() {
pledge_content_html: row.pledge_content_html ?? '',
success_notice_enabled: successNoticeFromSettings(row.settings).enabled,
success_notice_message: successNoticeFromSettings(row.settings).message,
review_portal_enabled: reviewPortalFromSettings(row.settings).enabled,
review_portal_message: reviewPortalFromSettings(row.settings).message,
}
rawSettings.value =
row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
@ -555,6 +585,10 @@ async function saveBasic() {
ElMessage.warning('请填写报名成功提示语,或关闭该提示')
return
}
if (!form.value.review_portal_enabled && !form.value.review_portal_message.trim()) {
ElMessage.warning('关闭评审后,请填写评审端说明文字')
return
}
const creatingNew = route.name === 'admin-competition-new'
if (creatingNew) {
@ -1405,6 +1439,26 @@ onMounted(() => {
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="是否开启评审">
<el-switch
v-model="form.review_portal_enabled"
active-text="是"
inactive-text="否"
/>
<p class="form-hint mt-2">
关闭后,评审员无法登录评审端;已登录的评审员将退回登录页,并看到下方说明文字。仅影响评审端,不影响选手端与管理端。
</p>
<el-input
v-model="form.review_portal_message"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="请输入关闭评审时展示给评审员的说明文字"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-form-item label="状态">
<el-select v-model="form.status" class="w-100">

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
getApiBase,
REVIEW_AUTH_LOGIN_PATH,
@ -12,6 +13,11 @@ import {
hasVisibleText,
type BrandingForm,
} from '../../utils/competitionBranding'
import {
closedReviewPortalMessage,
invalidateReviewPortalCache,
reviewPortalFromPublicPayload,
} from '../../utils/reviewPortal'
import '../../styles/prototype-styles.css'
import '../../styles/login-page-overrides.css'
@ -29,6 +35,9 @@ const apiHintTone = ref<'neutral' | 'error' | 'success'>('neutral')
const competitionSlug = computed(() => String(route.params.slug ?? '').trim())
const competitionLoadError = ref('')
const brand = ref<BrandingForm>(emptyBrandingForm())
const reviewPortalEnabled = ref(true)
const reviewPortalMessage = ref('')
const reviewClosedDialogShown = ref(false)
const apiHintClass = computed(() => {
if (apiHintTone.value === 'error') return 'login-page-wls__hint login-page-wls__hint--error'
@ -111,9 +120,35 @@ function parseJsonErrorPayload(data: unknown): string {
return '请求失败'
}
async function showReviewClosedDialog(message: string) {
if (reviewClosedDialogShown.value) return
reviewClosedDialogShown.value = true
const text = closedReviewPortalMessage(message)
const html = escapeHtml(text).replace(/\r\n|\r|\n/g, '<br>')
try {
await ElMessageBox.alert(html, '评审暂未开放', {
confirmButtonText: '知道了',
confirmButtonClass: 'login-page-wls__msgbox-confirm',
customClass: 'login-page-wls__msgbox',
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
autofocus: false,
dangerouslyUseHTMLString: true,
})
} catch {
/* 忽略 */
} finally {
reviewClosedDialogShown.value = false
}
}
async function loadCompetitionBrand() {
competitionLoadError.value = ''
brand.value = emptyBrandingForm()
reviewPortalEnabled.value = true
reviewPortalMessage.value = ''
reviewClosedDialogShown.value = false
const slug = competitionSlug.value
const titleSnapshot = typeof document !== 'undefined' ? document.title : ''
if (!slug) {
@ -121,6 +156,7 @@ async function loadCompetitionBrand() {
return
}
try {
invalidateReviewPortalCache(slug)
const r = await fetch(`${apiBase()}/api/v1/public/competitions/by-slug/${encodeURIComponent(slug)}`, {
headers: { Accept: 'application/json' },
})
@ -138,6 +174,13 @@ async function loadCompetitionBrand() {
brand.value = brandingFormFromApi(d?.branding_json ?? d?.branding ?? null)
const portal = reviewPortalFromPublicPayload(d)
reviewPortalEnabled.value = portal.enabled
reviewPortalMessage.value = portal.message
if (!portal.enabled) {
void showReviewClosedDialog(portal.message)
}
const docTitle = brand.value.documentTitle
if (typeof document !== 'undefined') {
document.title =
@ -176,6 +219,11 @@ function normalizeTitle(s: string): string {
}
async function onSubmit() {
if (!reviewPortalEnabled.value) {
void showReviewClosedDialog(reviewPortalMessage.value)
setHint(closedReviewPortalMessage(reviewPortalMessage.value), 'error')
return
}
const u = username.value.trim()
const p = password.value
usernameErr.value = !u
@ -212,7 +260,16 @@ async function onSubmit() {
})
const data = await r.json().catch((): Record<string, unknown> => ({}))
if (!r.ok) {
setHint(parseJsonErrorPayload(data), 'error')
const errMsg = parseJsonErrorPayload(data)
setHint(errMsg, 'error')
const errors = (data as { errors?: Record<string, unknown> }).errors
const slugErrs = errors?.competition_slug
const slugMsg = Array.isArray(slugErrs) ? String(slugErrs[0] ?? '') : ''
if (slugMsg && !slugMsg.includes('不存在') && !slugMsg.includes('未发布')) {
reviewPortalEnabled.value = false
reviewPortalMessage.value = slugMsg
void showReviewClosedDialog(slugMsg)
}
return
}
clearHint()
@ -345,6 +402,7 @@ onUnmounted(() => {
autocomplete="username"
maxlength="64"
placeholder="账号"
:disabled="!reviewPortalEnabled"
@input="usernameErr = false"
/>
<input
@ -356,10 +414,15 @@ onUnmounted(() => {
autocomplete="current-password"
placeholder="密码"
maxlength="255"
:disabled="!reviewPortalEnabled"
@input="passwordErr = false"
/>
<button type="submit" class="login-page-wls__submit" :disabled="submitting">
{{ submitting ? '登录中…' : '登录' }}
<button
type="submit"
class="login-page-wls__submit"
:disabled="submitting || !reviewPortalEnabled"
>
{{ submitting ? '登录中…' : reviewPortalEnabled ? '登录' : '评审暂未开放' }}
</button>
<p v-if="apiHint" :class="apiHintClass" style="margin-top: 0.75rem">{{ apiHint }}</p>
</form>

Loading…
Cancel
Save