master
lion 2 months ago
parent 049545613e
commit 190f56d78f

@ -47,6 +47,48 @@ export async function getAdminApplication(
return data as AdminApplicationDetail
}
export async function impersonateAdminApplication(
competitionId: number,
applicationId: number,
): Promise<{
token: string
competition_slug: string
expires_in: number
participant?: { id?: number; mobile?: string | null; name?: string | null }
}> {
try {
const { data } = await adminHttp.post<unknown>(
`/competitions/${competitionId}/applications/${applicationId}/impersonate`,
)
if (!data || typeof data !== 'object') throw new Error('代登响应无效')
const o = data as Record<string, unknown>
if (typeof o.token !== 'string' || !o.token.trim()) throw new Error('代登 Token 无效')
if (typeof o.competition_slug !== 'string' || !o.competition_slug.trim()) {
throw new Error('赛事标识无效')
}
return {
token: o.token,
competition_slug: o.competition_slug,
expires_in: typeof o.expires_in === 'number' ? o.expires_in : 1800,
participant:
o.participant && typeof o.participant === 'object'
? (o.participant as { id?: number; mobile?: string | null; name?: string | null })
: undefined,
}
} catch (error) {
if (axios.isAxiosError(error)) {
const payload = error.response?.data
if (payload && typeof payload === 'object') {
const message = (payload as { message?: unknown }).message
if (typeof message === 'string' && message.trim()) {
throw new Error(message.trim())
}
}
}
throw error instanceof Error ? error : new Error('进入选手端失败')
}
}
export async function downloadAdminApplicationFile(
competitionId: number,
applicationId: number,

@ -13,6 +13,9 @@ export const PARTICIPANT_SMS_LOGIN_PATH = '/api/auth/sms/login' as const
export const TOKEN_KEY = 'cxxfds_token' as const
/** 管理员代登选手端标记(sessionStorage) */
export const ADMIN_IMPERSONATION_FLAG_KEY = 'cxxfds_admin_impersonation' as const
/** 选手端最近一次访问的赛事 slug(用于 / 与旧链接跳转) */
export const PARTICIPANT_COMPETITION_SLUG_KEY = 'cxxfds_participant_competition_slug' as const

@ -3,7 +3,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import 'bootstrap/dist/css/bootstrap.min.css'
import '../styles/prototype-styles.css'
import { getApiBase, TOKEN_KEY } from '../config/api'
import { ADMIN_IMPERSONATION_FLAG_KEY, getApiBase, TOKEN_KEY } from '../config/api'
import {
brandingFormFromApi,
emptyBrandingForm,
@ -101,8 +101,15 @@ function applyParticipantBodyTheme() {
watch(brand, () => applyParticipantBodyTheme(), { deep: true, immediate: true })
const profileLabel = ref('')
const isAdminImpersonation = ref(sessionStorage.getItem(ADMIN_IMPERSONATION_FLAG_KEY) === '1')
const roleBadge = computed(() => profileLabel.value || '用户')
const roleBadge = computed(() => {
if (isAdminImpersonation.value) {
const label = profileLabel.value.trim()
return label ? `管理员代登 · ${label}` : '管理员代登中'
}
return profileLabel.value || '用户'
})
async function loadProfile() {
const t = localStorage.getItem(TOKEN_KEY)
@ -123,6 +130,8 @@ async function loadProfile() {
function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('cxxfds_user')
sessionStorage.removeItem(ADMIN_IMPERSONATION_FLAG_KEY)
isAdminImpersonation.value = false
const s = participantSlug.value
if (s) {
void router.push({ name: 'participant-login', params: { slug: s } })
@ -133,6 +142,7 @@ function logout() {
onMounted(() => {
document.body.classList.add('prototype-page', 'user-mobile-no-menu')
isAdminImpersonation.value = sessionStorage.getItem(ADMIN_IMPERSONATION_FLAG_KEY) === '1'
loadProfile()
})
@ -147,6 +157,13 @@ onUnmounted(() => {
<template>
<div class="participant-layout">
<div
v-if="isAdminImpersonation"
class="admin-impersonation-banner text-center small py-2 px-3"
role="status"
>
当前为管理员代登选手端,操作将以该选手身份生效。请用完后点击右上角「退出登录」。
</div>
<header class="navbar navbar-expand-lg layout-header">
<div class="container-fluid px-3 px-lg-4">
<span class="navbar-brand mb-0 prototype-title">{{ layoutHeaderTitle }}</span>
@ -211,6 +228,12 @@ onUnmounted(() => {
</template>
<style scoped>
.admin-impersonation-banner {
background: #fff3cd;
color: #664d03;
border-bottom: 1px solid #ffecb5;
}
:deep(a.router-link-active.nav-link) {
background: #eef7fc;
color: #052d62;

@ -47,6 +47,11 @@ const router = createRouter({
component: () => import('../views/LoginView.vue'),
meta: { guestOnly: true },
},
{
path: '/c/:slug/impersonate-entry',
name: 'participant-impersonate-entry',
component: () => import('../views/ParticipantImpersonateEntryView.vue'),
},
{
path: '/c/:slug/review/login',
name: 'reviewer-login',

@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ADMIN_IMPERSONATION_FLAG_KEY,
PARTICIPANT_COMPETITION_SLUG_KEY,
TOKEN_KEY,
} from '../config/api'
const route = useRoute()
const router = useRouter()
const error = ref('')
onMounted(() => {
const slug = String(route.params.slug ?? '').trim()
const token = String(route.query.token ?? '').trim()
if (!slug || !token) {
error.value = '代登参数无效'
return
}
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(PARTICIPANT_COMPETITION_SLUG_KEY, slug)
sessionStorage.setItem(ADMIN_IMPERSONATION_FLAG_KEY, '1')
void router.replace({ name: 'participant-apply', params: { slug } })
})
</script>
<template>
<div class="container py-5 text-center">
<p v-if="error" class="text-danger">{{ error }}</p>
<p v-else class="text-secondary">正在进入选手端…</p>
</div>
</template>

@ -27,9 +27,11 @@ import {
downloadAdminApplicationFile,
exportAdminApplications,
getAdminApplication,
impersonateAdminApplication,
listAdminApplications,
type AdminApplicationExportMode,
} from '../../../api/admin/applications'
import { useAdminAuthStore } from '../../../stores/adminAuth'
import type {
AdminApplicationDetail,
AdminApplicationFileRow,
@ -79,7 +81,12 @@ const TAB_ITEMS: { tab: TabKey; label: string }[] = [
const router = useRouter()
const route = useRoute()
const competitionStore = useAdminCompetitionStore()
const adminAuth = useAdminAuthStore()
const { selectedCompetitionId } = storeToRefs(competitionStore)
const canImpersonateParticipant = computed(
() => (adminAuth.user?.username ?? '').trim() === 'superadmin',
)
const impersonatingApplication = ref(false)
const isCreate = () => route.name === 'admin-competition-new'
@ -1046,6 +1053,28 @@ async function downloadApplicationFile(fileId: number, fileName: string | null |
}
}
async function openParticipantAsImpersonation() {
const cid = competitionId.value
const appId = applicationDetail.value?.id
if (!cid || !appId || !canImpersonateParticipant.value) return
impersonatingApplication.value = true
try {
const data = await impersonateAdminApplication(cid, appId)
const entryPath = `/c/${encodeURIComponent(data.competition_slug)}/impersonate-entry?token=${encodeURIComponent(data.token)}`
const href = absoluteUrlFromPath(entryPath)
const opened = window.open(href, '_blank', 'noopener,noreferrer')
if (!opened) {
ElMessage.warning('浏览器拦截了新窗口,请允许弹窗后重试')
return
}
ElMessage.success('已打开选手端(管理员代登,约 30 分钟有效)')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '进入选手端失败')
} finally {
impersonatingApplication.value = false
}
}
async function openChannelModal(row?: SignupChannelRow) {
if (!competitionId.value) {
ElMessage.warning('请先保存赛事基础信息')
@ -2095,6 +2124,16 @@ onMounted(() => {
>
<div v-loading="applicationDetailLoading" class="application-detail">
<template v-if="applicationDetail">
<div v-if="canImpersonateParticipant" class="mb-3 d-flex justify-content-end">
<el-button
type="warning"
plain
:loading="impersonatingApplication"
@click="openParticipantAsImpersonation"
>
进入选手端
</el-button>
</div>
<el-descriptions :column="2" border class="application-detail-section">
<el-descriptions-item label="项目编号">{{ applicationDetail.project_code || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态">

Loading…
Cancel
Save