后台首页

master
weizong song 4 months ago
parent 040954322b
commit 46f845c93a

@ -0,0 +1,8 @@
import { adminHttp } from './http'
import type { AdminDashboardData } from './types'
export async function getAdminDashboard(competitionId: number): Promise<AdminDashboardData> {
const { data } = await adminHttp.get<unknown>(`/competitions/${competitionId}/dashboard`)
if (!data || typeof data !== 'object') throw new Error('首页统计响应无效')
return data as AdminDashboardData
}

@ -162,6 +162,71 @@ export interface AdminApplicationDetail extends AdminApplicationRow {
review_scores: AdminApplicationReviewScoreRow[]
}
export interface AdminDashboardCompetition {
id: number
slug: string
name: string
status: string
published: boolean
signup_open_at: string | null
signup_close_at: string | null
form_schema_id: number | null
review_form_schema_id: number | null
pledge_content_html: string | null
}
export interface AdminDashboardSummary {
applications_total: number
applications_draft: number
applications_submitted: number
applications_reviewed: number
applications_pending_review: number
review_scores_total: number
reviewers_total: number
reviewers_active: number
reviewer_scopes_total: number
tracks_total: number
tracks_enabled: number
}
export interface AdminDashboardSetup {
published: boolean
signup_form_schema: boolean
tracks: boolean
pledge: boolean
review_form_schema: boolean
reviewer_scopes: boolean
}
export interface AdminDashboardTrackStat {
track_code: string
track_title: string
is_enabled: boolean
applications_count: number
submitted_count: number
reviewer_scope_count: number
}
export interface AdminDashboardRecentApplication {
id: number
status: string
project_name: string
player_name: string
track_code: string
track_title: string
submitted_at: string | null
updated_at: string | null
submitted_review_count: number
}
export interface AdminDashboardData {
competition: AdminDashboardCompetition
summary: AdminDashboardSummary
setup: AdminDashboardSetup
track_stats: AdminDashboardTrackStat[]
recent_applications: AdminDashboardRecentApplication[]
}
export type SignupChannelStatus = 'enabled' | 'disabled'
export type SignupChannelCallbackType = 'none' | 'web' | 'mini_program'
export type SignupChannelMiniProgramMethod = 'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab'

@ -2,6 +2,7 @@
* 路由 name → 异步组件。服务端仅下发 name/path/title,实际组件映射在此维护。
*/
export const ADMIN_COMPONENT_LOADERS: Record<string, () => Promise<unknown>> = {
'admin-dashboard': () => import('../../views/admin/AdminDashboardView.vue'),
'admin-competitions-list': () => import('../../views/admin/competition/CompetitionListView.vue'),
'admin-competition-form': () => import('../../views/admin/competition/CompetitionFormView.vue'),
'admin-competition-workspace': () => import('../../views/admin/competition/CompetitionFormView.vue'),

@ -84,7 +84,7 @@ export async function registerAdminDynamicRoutes(router: Router) {
router.addRoute('AdminLayout', {
path: '',
name: 'admin-home-redirect',
redirect: { name: 'admin-competitions-list' },
redirect: { name: 'admin-dashboard' },
})
remember('admin-home-redirect')

@ -153,7 +153,7 @@ router.beforeEach(async (to) => {
if (!adminDynamicRoutesRegistered()) {
await registerAdminDynamicRoutes(router)
}
return { name: 'admin-competitions-list', replace: true }
return { name: 'admin-dashboard', replace: true }
}
return true
}

@ -0,0 +1,631 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { getAdminDashboard } from '../../api/admin/dashboard'
import { listCompetitions } from '../../api/admin/competitions'
import type {
AdminDashboardData,
AdminDashboardSetup,
} from '../../api/admin/types'
import { useAdminCompetitionStore } from '../../stores/adminCompetition'
import { competitionStatusLabel } from '../../utils/competitionAdminLabels'
type SetupKey = keyof AdminDashboardSetup
interface SetupItem {
key: SetupKey
label: string
description: string
routeName: string
}
const competitionStore = useAdminCompetitionStore()
const { competitions, selectedCompetitionId } = storeToRefs(competitionStore)
const loading = ref(false)
const dashboard = ref<AdminDashboardData | null>(null)
const setupItems: SetupItem[] = [
{
key: 'published',
label: '赛事发布',
description: '对外报名和评审入口依赖发布状态',
routeName: 'admin-competition-workspace',
},
{
key: 'signup_form_schema',
label: '报名表单',
description: '选手报名字段由 Schema 渲染',
routeName: 'admin-competition-workspace',
},
{
key: 'tracks',
label: '赛道配置',
description: '评审范围和报名分组按赛道隔离',
routeName: 'admin-competition-workspace',
},
{
key: 'pledge',
label: '承诺书',
description: '提交报名时需要签署的赛事文本',
routeName: 'admin-competition-workspace',
},
{
key: 'review_form_schema',
label: '评审表单',
description: '未配置时评审端将使用系统默认表单',
routeName: 'admin-competition-workspace',
},
{
key: 'reviewer_scopes',
label: '评审范围',
description: '评审员必须绑定赛事和赛道后才能看到项目',
routeName: 'admin-reviewers-list',
},
]
const summary = computed(() => dashboard.value?.summary ?? null)
const selectedCompetition = computed(() => dashboard.value?.competition ?? null)
const stageLabel = computed(() => {
const c = selectedCompetition.value
if (!c) return '未选择赛事'
const now = Date.now()
const openAt = c.signup_open_at ? new Date(c.signup_open_at).getTime() : null
const closeAt = c.signup_close_at ? new Date(c.signup_close_at).getTime() : null
if (!c.published) return '未发布'
if (openAt && now < openAt) return '报名未开始'
if (closeAt && now > closeAt) return c.status === 'reviewing' ? '评审中' : '报名已截止'
if (c.status === 'reviewing') return '评审中'
if (c.status === 'ended') return '已结束'
return '报名中'
})
const metricCards = computed(() => {
const s = summary.value
return [
{ label: '报名总数', value: s?.applications_total ?? 0, hint: '含草稿和已提交' },
{ label: '已提交', value: s?.applications_submitted ?? 0, hint: '可进入评审范围' },
{ label: '草稿', value: s?.applications_draft ?? 0, hint: '选手尚未正式提交' },
{ label: '待评项目', value: s?.applications_pending_review ?? 0, hint: '已提交但暂无评分' },
{ label: '已评项目', value: s?.applications_reviewed ?? 0, hint: '至少已有一条评分' },
{ label: '评审记录', value: s?.review_scores_total ?? 0, hint: '累计提交评分数' },
]
})
const reviewProgressPercent = computed(() => {
const s = summary.value
if (!s || s.applications_submitted === 0) return 0
return Math.round((s.applications_reviewed / s.applications_submitted) * 100)
})
const basePath = computed(() =>
import.meta.env.BASE_URL.endsWith('/')
? import.meta.env.BASE_URL.slice(0, -1)
: import.meta.env.BASE_URL,
)
const publicApplyPath = computed(() => {
const slug = selectedCompetition.value?.slug
return slug ? `${basePath.value}/c/${encodeURIComponent(slug)}/apply` : ''
})
const publicReviewPath = computed(() => {
const slug = selectedCompetition.value?.slug
return slug ? `${basePath.value}/c/${encodeURIComponent(slug)}/review` : ''
})
async function ensureCompetitionsLoaded() {
if (competitions.value.length > 0) return
const page = await listCompetitions({ page: 1, per_page: 100 })
competitionStore.setCompetitionList(page.data)
}
async function loadDashboard() {
loading.value = true
try {
await ensureCompetitionsLoaded()
const id = selectedCompetitionId.value
if (!id) {
dashboard.value = null
return
}
dashboard.value = await getAdminDashboard(id)
} catch (e) {
dashboard.value = null
ElMessage.error(e instanceof Error ? e.message : '首页数据加载失败')
} finally {
loading.value = false
}
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '未配置'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
}
function statusTagType(status: string): 'success' | 'info' | 'warning' | 'danger' | 'primary' {
if (status === 'submitted' || status === 'published' || status === 'signup_open') return 'success'
if (status === 'draft') return 'info'
if (status === 'reviewing') return 'primary'
if (status === 'signup_closed' || status === 'ended') return 'warning'
return 'info'
}
function applicationStatusLabel(status: string): string {
if (status === 'submitted') return '已提交'
if (status === 'draft') return '草稿'
return status
}
async function copyText(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(`${window.location.origin}${text}`)
ElMessage.success('已复制完整链接')
} catch {
ElMessage.error('复制失败,请手动选择文本')
}
}
watch(selectedCompetitionId, () => {
void loadDashboard()
})
onMounted(() => {
void loadDashboard()
})
</script>
<template>
<div class="dashboard" v-loading="loading">
<template v-if="dashboard && selectedCompetition">
<section class="dashboard-head">
<div>
<div class="eyebrow">运营工作台</div>
<h1>{{ selectedCompetition.name }}</h1>
<div class="meta-line">
<el-tag :type="statusTagType(selectedCompetition.status)" effect="plain">
{{ competitionStatusLabel(selectedCompetition.status) }}
</el-tag>
<el-tag :type="selectedCompetition.published ? 'success' : 'info'" effect="plain">
{{ selectedCompetition.published ? '已发布' : '未发布' }}
</el-tag>
<span>当前阶段:{{ stageLabel }}</span>
<span>slug:{{ selectedCompetition.slug }}</span>
</div>
<div class="time-line">
<span>报名开始:{{ formatDateTime(selectedCompetition.signup_open_at) }}</span>
<span>报名截止:{{ formatDateTime(selectedCompetition.signup_close_at) }}</span>
</div>
</div>
<div class="head-actions">
<RouterLink :to="{ name: 'admin-competition-workspace' }">
<el-button type="danger">赛事配置</el-button>
</RouterLink>
<RouterLink :to="{ name: 'admin-competitions-list' }">
<el-button>赛事列表</el-button>
</RouterLink>
</div>
</section>
<section class="metric-grid" aria-label="核心指标">
<div v-for="card in metricCards" :key="card.label" class="metric-card">
<div class="metric-label">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
<div class="metric-hint">{{ card.hint }}</div>
</div>
</section>
<section class="main-grid">
<div class="panel">
<div class="panel-head">
<h2>配置完整度</h2>
<span class="panel-subtitle">影响报名、提交和评审流转</span>
</div>
<div class="setup-list">
<div v-for="item in setupItems" :key="item.key" class="setup-row">
<el-tag
:type="dashboard.setup[item.key] ? 'success' : item.key === 'review_form_schema' ? 'warning' : 'danger'"
effect="plain"
size="small"
>
{{ dashboard.setup[item.key] ? '已完成' : item.key === 'review_form_schema' ? '默认' : '待处理' }}
</el-tag>
<div class="setup-copy">
<strong>{{ item.label }}</strong>
<span>{{ item.description }}</span>
</div>
<RouterLink :to="{ name: item.routeName }">
<el-button link type="danger">处理</el-button>
</RouterLink>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>评审进度</h2>
<span class="panel-subtitle">仅统计已提交报名</span>
</div>
<el-progress
:percentage="reviewProgressPercent"
:stroke-width="12"
:show-text="false"
/>
<div class="progress-kpis">
<div>
<strong>{{ summary?.applications_reviewed ?? 0 }}</strong>
<span>已评项目</span>
</div>
<div>
<strong>{{ summary?.applications_pending_review ?? 0 }}</strong>
<span>待评项目</span>
</div>
<div>
<strong>{{ summary?.reviewers_active ?? 0 }}/{{ summary?.reviewers_total ?? 0 }}</strong>
<span>启用评审员</span>
</div>
<div>
<strong>{{ summary?.reviewer_scopes_total ?? 0 }}</strong>
<span>评审范围</span>
</div>
</div>
<RouterLink :to="{ name: 'admin-reviewers-list' }">
<el-button type="danger" plain>管理评审员与范围</el-button>
</RouterLink>
</div>
</section>
<section class="main-grid main-grid--wide">
<div class="panel">
<div class="panel-head">
<h2>赛道分布</h2>
<span class="panel-subtitle">
已启用 {{ summary?.tracks_enabled ?? 0 }} / {{ summary?.tracks_total ?? 0 }}
</span>
</div>
<el-table :data="dashboard.track_stats" stripe style="width: 100%">
<el-table-column prop="track_title" label="赛道" min-width="150" show-overflow-tooltip />
<el-table-column prop="track_code" label="代码" min-width="100" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small" effect="plain">
{{ row.is_enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applications_count" label="报名" width="90" />
<el-table-column prop="submitted_count" label="已提交" width="90" />
<el-table-column prop="reviewer_scope_count" label="评审范围" width="100" />
<template #empty>
<span class="muted">暂无赛道,请进入赛事配置维护。</span>
</template>
</el-table>
</div>
<div class="panel">
<div class="panel-head">
<h2>常用入口</h2>
<span class="panel-subtitle">按当前赛事生成</span>
</div>
<div class="shortcut-list">
<RouterLink :to="{ name: 'admin-competition-workspace' }" class="shortcut">
<strong>报名列表与赛事配置</strong>
<span>查看报名详情、附件、分数和赛事设置</span>
</RouterLink>
<RouterLink :to="{ name: 'admin-public-source-channels' }" class="shortcut">
<strong>公开来源渠道</strong>
<span>生成首页入口链接和来源归因</span>
</RouterLink>
<button class="shortcut shortcut-button" type="button" @click="copyText(publicApplyPath)">
<strong>复制报名链接</strong>
<span>{{ publicApplyPath || '当前赛事暂无 slug' }}</span>
</button>
<button class="shortcut shortcut-button" type="button" @click="copyText(publicReviewPath)">
<strong>复制评审链接</strong>
<span>{{ publicReviewPath || '当前赛事暂无 slug' }}</span>
</button>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>最近报名</h2>
<span class="panel-subtitle">最近更新或提交的 8 条记录</span>
</div>
<el-table :data="dashboard.recent_applications" stripe style="width: 100%">
<el-table-column prop="project_name" label="项目" min-width="180" show-overflow-tooltip />
<el-table-column prop="player_name" label="负责人" min-width="100" show-overflow-tooltip />
<el-table-column prop="track_title" label="赛道" min-width="130" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small" effect="plain">
{{ applicationStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="submitted_review_count" label="评分数" width="90" />
<el-table-column label="提交时间" min-width="170">
<template #default="{ row }">
{{ formatDateTime(row.submitted_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default>
<RouterLink :to="{ name: 'admin-competition-workspace' }">
<el-button type="danger" link>进入列表</el-button>
</RouterLink>
</template>
</el-table-column>
<template #empty>
<span class="muted">暂无报名记录。</span>
</template>
</el-table>
</section>
</template>
<el-empty v-else-if="!loading" description="暂无可管理的赛事">
<RouterLink :to="{ name: 'admin-competition-new' }">
<el-button type="danger">新建赛事</el-button>
</RouterLink>
</el-empty>
</div>
</template>
<style scoped>
.dashboard {
max-width: 1360px;
margin: 0 auto;
}
.dashboard-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.eyebrow {
margin-bottom: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dashboard-head h1 {
margin: 0;
font-size: 24px;
font-weight: 650;
}
.meta-line,
.time-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.time-line {
color: var(--el-text-color-secondary);
}
.head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric-card,
.panel {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.metric-card {
padding: 14px 16px;
}
.metric-label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.metric-value {
margin-top: 8px;
font-size: 28px;
line-height: 1;
font-weight: 700;
color: var(--el-text-color-primary);
}
.metric-hint {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.main-grid {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 16px;
margin-bottom: 16px;
}
.main-grid--wide {
grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
}
.panel {
padding: 16px;
min-width: 0;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.panel-head h2 {
margin: 0;
font-size: 16px;
font-weight: 650;
}
.panel-subtitle {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.setup-list {
display: grid;
gap: 10px;
}
.setup-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) 48px;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.setup-row:last-child {
border-bottom: 0;
}
.setup-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.setup-copy strong {
font-size: 14px;
color: var(--el-text-color-primary);
}
.setup-copy span {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.progress-kpis {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 16px 0;
}
.progress-kpis div {
padding: 10px 12px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.progress-kpis strong {
display: block;
font-size: 22px;
line-height: 1.1;
}
.progress-kpis span {
display: block;
margin-top: 5px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.shortcut-list {
display: grid;
gap: 10px;
}
.shortcut {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 11px 12px;
text-align: left;
text-decoration: none;
color: inherit;
background: var(--el-fill-color-lighter);
border: 1px solid transparent;
border-radius: 6px;
}
.shortcut:hover {
border-color: var(--el-color-danger-light-5);
background: var(--el-color-danger-light-9);
}
.shortcut strong {
font-size: 14px;
}
.shortcut span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.shortcut-button {
font: inherit;
cursor: pointer;
}
.muted {
color: var(--el-text-color-secondary);
}
@media (max-width: 1200px) {
.metric-grid {
grid-template-columns: repeat(3, minmax(140px, 1fr));
}
.main-grid,
.main-grid--wide {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.dashboard-head {
flex-direction: column;
}
.head-actions {
justify-content: flex-start;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
Loading…
Cancel
Save