From afad17b1bc2d990b7f2f299e73111ce9a3ea9ec2 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Tue, 4 Aug 2026 09:47:43 +0800 Subject: [PATCH] pingshenduan rukou --- src/api/admin/types.ts | 6 ++ src/layouts/ReviewerLayout.vue | 7 ++ src/router/index.ts | 13 +++ src/styles/login-page-overrides.css | 31 ++++++ src/utils/reviewPortal.ts | 95 +++++++++++++++++++ .../admin/competition/CompetitionFormView.vue | 54 +++++++++++ src/views/reviewer/ReviewerLoginView.vue | 69 +++++++++++++- 7 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/utils/reviewPortal.ts diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index 94a2d67..047f43c 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -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 diff --git a/src/layouts/ReviewerLayout.vue b/src/layouts/ReviewerLayout.vue index 50d46df..8b1cd26 100644 --- a/src/layouts/ReviewerLayout.vue +++ b/src/layouts/ReviewerLayout.vue @@ -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) : 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 { diff --git a/src/router/index.ts b/src/router/index.ts index 47ef43f..9d481c3 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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') { diff --git a/src/styles/login-page-overrides.css b/src/styles/login-page-overrides.css index fee41e0..ad27c66 100644 --- a/src/styles/login-page-overrides.css +++ b/src/styles/login-page-overrides.css @@ -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); +} diff --git a/src/utils/reviewPortal.ts b/src/utils/reviewPortal.ts new file mode 100644 index 0000000..47fb4c6 --- /dev/null +++ b/src/utils/reviewPortal.ts @@ -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() +const CACHE_TTL_MS = 5_000 + +export function reviewPortalFromSettings(settings: unknown): ReviewPortalSettings { + const raw = + settings && typeof settings === 'object' && !Array.isArray(settings) + ? (settings as Record).review_portal + : null + const portal = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : 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).review_portal + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { + return { enabled: true, message: '' } + } + const portal = raw as Record + 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 { + 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() +} diff --git a/src/views/admin/competition/CompetitionFormView.vue b/src/views/admin/competition/CompetitionFormView.vue index d24ae89..25c16ee 100644 --- a/src/views/admin/competition/CompetitionFormView.vue +++ b/src/views/admin/competition/CompetitionFormView.vue @@ -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(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).review_portal + : null + const portal = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : 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 | null { const settings = { ...rawSettings.value } const enabled = form.value.success_notice_enabled @@ -234,6 +255,11 @@ function settingsWithSuccessNotice(): Record | 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(() => { /> + + + +

+ 关闭后,评审员无法登录评审端;已登录的评审员将退回登录页,并看到下方说明文字。仅影响评审端,不影响选手端与管理端。 +

+ +
+
diff --git a/src/views/reviewer/ReviewerLoginView.vue b/src/views/reviewer/ReviewerLoginView.vue index acf3e29..23a2b87 100644 --- a/src/views/reviewer/ReviewerLoginView.vue +++ b/src/views/reviewer/ReviewerLoginView.vue @@ -1,6 +1,7 @@