|
|
|
|
@ -22,11 +22,13 @@ import {
|
|
|
|
|
} from '../../../api/admin/signupChannels'
|
|
|
|
|
import {
|
|
|
|
|
downloadAdminApplicationFile,
|
|
|
|
|
exportAdminApplications,
|
|
|
|
|
getAdminApplication,
|
|
|
|
|
listAdminApplications,
|
|
|
|
|
} from '../../../api/admin/applications'
|
|
|
|
|
import type {
|
|
|
|
|
AdminApplicationDetail,
|
|
|
|
|
AdminApplicationFileRow,
|
|
|
|
|
AdminApplicationRow,
|
|
|
|
|
CompetitionPayload,
|
|
|
|
|
CompetitionSuccessNoticeSettings,
|
|
|
|
|
@ -98,6 +100,7 @@ const channels = ref<SignupChannelRow[]>([])
|
|
|
|
|
const channelsLoading = ref(false)
|
|
|
|
|
const applications = ref<AdminApplicationRow[]>([])
|
|
|
|
|
const applicationsLoading = ref(false)
|
|
|
|
|
const applicationsExporting = ref(false)
|
|
|
|
|
const applicationDetailLoading = ref(false)
|
|
|
|
|
const applicationDetailVisible = ref(false)
|
|
|
|
|
const applicationDetail = ref<AdminApplicationDetail | null>(null)
|
|
|
|
|
@ -110,6 +113,7 @@ const applicationFilters = ref({
|
|
|
|
|
keyword: '',
|
|
|
|
|
status: 'submitted',
|
|
|
|
|
track: '',
|
|
|
|
|
signup_channel_id: undefined as number | undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const trackDialogVisible = ref(false)
|
|
|
|
|
@ -280,7 +284,10 @@ watch(tabKey, (t) => {
|
|
|
|
|
const cid = competitionId.value
|
|
|
|
|
if (!cid) return
|
|
|
|
|
if (t === 'channels') void refreshChannels()
|
|
|
|
|
if (t === 'applications') void refreshApplications()
|
|
|
|
|
if (t === 'applications') {
|
|
|
|
|
void refreshChannels()
|
|
|
|
|
void refreshApplications()
|
|
|
|
|
}
|
|
|
|
|
if (t === 'signupForm') void refreshSignupSchemas()
|
|
|
|
|
if (t === 'review') {
|
|
|
|
|
void refreshReviewSchemas()
|
|
|
|
|
@ -661,6 +668,33 @@ function formatFileSize(size: number | null | undefined): string {
|
|
|
|
|
return `${(size / 1024 / 1024).toFixed(1)}MB`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const APPLICATION_FILE_KIND_ORDER = ['plan', 'supporting'] as const
|
|
|
|
|
|
|
|
|
|
function applicationFileKindLabel(kind: string | null | undefined): string {
|
|
|
|
|
if (kind === 'plan') return '商业计划书'
|
|
|
|
|
if (kind === 'supporting') return '佐证材料'
|
|
|
|
|
return kind?.trim() || '其他附件'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applicationFileKindRank(kind: string | null | undefined): number {
|
|
|
|
|
const index = APPLICATION_FILE_KIND_ORDER.indexOf(kind as (typeof APPLICATION_FILE_KIND_ORDER)[number])
|
|
|
|
|
return index === -1 ? APPLICATION_FILE_KIND_ORDER.length : index
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sortApplicationDetailFiles(files: AdminApplicationFileRow[]): AdminApplicationFileRow[] {
|
|
|
|
|
return [...files].sort((a, b) => {
|
|
|
|
|
const kindDiff = applicationFileKindRank(a.kind) - applicationFileKindRank(b.kind)
|
|
|
|
|
if (kindDiff !== 0) return kindDiff
|
|
|
|
|
return (a.original_name ?? '').localeCompare(b.original_name ?? '', 'zh-CN')
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const applicationDetailDisplayFiles = computed(() => {
|
|
|
|
|
const files = applicationDetail.value?.files
|
|
|
|
|
if (!files?.length) return []
|
|
|
|
|
return sortApplicationDetailFiles(files)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function refreshApplications(page = applicationPager.value.page) {
|
|
|
|
|
const cid = competitionId.value
|
|
|
|
|
if (!cid) {
|
|
|
|
|
@ -676,6 +710,7 @@ async function refreshApplications(page = applicationPager.value.page) {
|
|
|
|
|
keyword: applicationFilters.value.keyword.trim() || undefined,
|
|
|
|
|
status: applicationFilters.value.status || undefined,
|
|
|
|
|
track: applicationFilters.value.track || undefined,
|
|
|
|
|
signup_channel_id: applicationFilters.value.signup_channel_id || undefined,
|
|
|
|
|
})
|
|
|
|
|
applications.value = res.data
|
|
|
|
|
applicationPager.value = {
|
|
|
|
|
@ -692,10 +727,29 @@ async function refreshApplications(page = applicationPager.value.page) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetApplicationFilters() {
|
|
|
|
|
applicationFilters.value = { keyword: '', status: 'submitted', track: '' }
|
|
|
|
|
applicationFilters.value = { keyword: '', status: 'submitted', track: '', signup_channel_id: undefined }
|
|
|
|
|
void refreshApplications(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function exportApplications() {
|
|
|
|
|
const cid = competitionId.value
|
|
|
|
|
if (!cid) return
|
|
|
|
|
applicationsExporting.value = true
|
|
|
|
|
try {
|
|
|
|
|
await exportAdminApplications(cid, {
|
|
|
|
|
keyword: applicationFilters.value.keyword.trim() || undefined,
|
|
|
|
|
status: applicationFilters.value.status || undefined,
|
|
|
|
|
track: applicationFilters.value.track || undefined,
|
|
|
|
|
signup_channel_id: applicationFilters.value.signup_channel_id || undefined,
|
|
|
|
|
})
|
|
|
|
|
ElMessage.success('导出已开始下载')
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ElMessage.error(e instanceof Error ? e.message : '导出失败')
|
|
|
|
|
} finally {
|
|
|
|
|
applicationsExporting.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openApplicationDetail(row: AdminApplicationRow) {
|
|
|
|
|
const cid = competitionId.value
|
|
|
|
|
if (!cid) return
|
|
|
|
|
@ -1313,7 +1367,7 @@ onMounted(() => {
|
|
|
|
|
|
|
|
|
|
<el-tab-pane v-if="!isCreate()" label="报名信息" name="applications" lazy>
|
|
|
|
|
<div v-loading="applicationsLoading" class="applications-pane">
|
|
|
|
|
<div class="pane-toolbar pane-toolbar--split pane-toolbar--wrap">
|
|
|
|
|
<div class="applications-toolbar">
|
|
|
|
|
<div class="application-filters">
|
|
|
|
|
<el-input
|
|
|
|
|
v-model="applicationFilters.keyword"
|
|
|
|
|
@ -1322,11 +1376,11 @@ onMounted(() => {
|
|
|
|
|
class="application-filter-keyword"
|
|
|
|
|
@keyup.enter="refreshApplications(1)"
|
|
|
|
|
/>
|
|
|
|
|
<el-select v-model="applicationFilters.status" placeholder="状态" clearable class="application-filter-select">
|
|
|
|
|
<el-select v-model="applicationFilters.status" placeholder="状态" clearable teleported class="application-filter-select">
|
|
|
|
|
<el-option label="草稿" value="draft" />
|
|
|
|
|
<el-option label="已提交" value="submitted" />
|
|
|
|
|
</el-select>
|
|
|
|
|
<el-select v-model="applicationFilters.track" placeholder="赛道" clearable class="application-filter-select">
|
|
|
|
|
<el-select v-model="applicationFilters.track" placeholder="赛道" clearable teleported class="application-filter-select">
|
|
|
|
|
<el-option
|
|
|
|
|
v-for="track in tracks"
|
|
|
|
|
:key="track.track_code"
|
|
|
|
|
@ -1334,14 +1388,31 @@ onMounted(() => {
|
|
|
|
|
:value="track.track_code"
|
|
|
|
|
/>
|
|
|
|
|
</el-select>
|
|
|
|
|
<el-select
|
|
|
|
|
v-model="applicationFilters.signup_channel_id"
|
|
|
|
|
placeholder="渠道"
|
|
|
|
|
clearable
|
|
|
|
|
filterable
|
|
|
|
|
teleported
|
|
|
|
|
class="application-filter-select application-filter-channel"
|
|
|
|
|
>
|
|
|
|
|
<el-option
|
|
|
|
|
v-for="channel in channels"
|
|
|
|
|
:key="channel.id"
|
|
|
|
|
:label="`${channel.channel_name}(${channel.channel_code})`"
|
|
|
|
|
:value="channel.id"
|
|
|
|
|
/>
|
|
|
|
|
</el-select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="application-actions">
|
|
|
|
|
<el-button :loading="applicationsExporting" @click="exportApplications">导出 ZIP</el-button>
|
|
|
|
|
<el-button @click="resetApplicationFilters">重置</el-button>
|
|
|
|
|
<el-button type="primary" @click="refreshApplications(1)">查询</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-table :data="applications" stripe border empty-text="暂无报名记录" class="workspace-table">
|
|
|
|
|
<el-table-column prop="project_code" label="项目编号" width="120" show-overflow-tooltip />
|
|
|
|
|
<el-table-column label="状态" width="92">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag :type="applicationStatusTag(row.status)" effect="plain">
|
|
|
|
|
@ -1358,6 +1429,17 @@ onMounted(() => {
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column prop="track_title" label="赛道" min-width="130" show-overflow-tooltip />
|
|
|
|
|
<el-table-column label="渠道" min-width="150" show-overflow-tooltip>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span class="cell-muted">
|
|
|
|
|
{{
|
|
|
|
|
row.signup_channel
|
|
|
|
|
? `${row.signup_channel.channel_name}(${row.signup_channel.channel_code})`
|
|
|
|
|
: row.signup_channel_code || '—'
|
|
|
|
|
}}
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column prop="contact_mobile" label="手机号" width="128" />
|
|
|
|
|
<el-table-column label="附件" width="76" align="right">
|
|
|
|
|
<template #default="{ row }">{{ row.files_count }}</template>
|
|
|
|
|
@ -1619,15 +1701,16 @@ onMounted(() => {
|
|
|
|
|
<div v-loading="applicationDetailLoading" class="application-detail">
|
|
|
|
|
<template v-if="applicationDetail">
|
|
|
|
|
<el-descriptions :column="2" border class="application-detail-section">
|
|
|
|
|
<el-descriptions-item label="项目名称" :span="2">
|
|
|
|
|
{{ applicationDetail.project_name || '—' }}
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="负责人">{{ applicationDetail.player_name || '—' }}</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="项目编号">{{ applicationDetail.project_code || '—' }}</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="状态">
|
|
|
|
|
<el-tag :type="applicationStatusTag(applicationDetail.status)" effect="plain">
|
|
|
|
|
{{ applicationStatusLabel(applicationDetail.status) }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="项目名称" :span="2">
|
|
|
|
|
{{ applicationDetail.project_name || '—' }}
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="负责人">{{ applicationDetail.player_name || '—' }}</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="学校">{{ applicationDetail.school || '—' }}</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="学历">{{ applicationDetail.degree || '—' }}</el-descriptions-item>
|
|
|
|
|
<el-descriptions-item label="手机号">{{ applicationDetail.contact_mobile || '—' }}</el-descriptions-item>
|
|
|
|
|
@ -1657,8 +1740,10 @@ onMounted(() => {
|
|
|
|
|
<p class="application-intro">{{ applicationDetail.intro || '—' }}</p>
|
|
|
|
|
|
|
|
|
|
<h3 class="section-title">附件</h3>
|
|
|
|
|
<el-table :data="applicationDetail.files" border empty-text="暂无附件" class="workspace-table workspace-table--mb">
|
|
|
|
|
<el-table-column prop="kind" label="类型" width="110" />
|
|
|
|
|
<el-table :data="applicationDetailDisplayFiles" border empty-text="暂无附件" class="workspace-table workspace-table--mb">
|
|
|
|
|
<el-table-column label="类型" width="120">
|
|
|
|
|
<template #default="{ row }">{{ applicationFileKindLabel(row.kind) }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column label="文件名" min-width="220" show-overflow-tooltip>
|
|
|
|
|
<template #default="{ row }">{{ row.original_name || '附件' }}</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
@ -2040,24 +2125,40 @@ onMounted(() => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.application-filters {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(220px, 1fr) 130px 130px minmax(180px, 220px);
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.applications-toolbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.application-filter-keyword {
|
|
|
|
|
width: min(320px, 100%);
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.application-filter-select {
|
|
|
|
|
width: 150px;
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.application-filter-channel {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.application-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.table-pagination {
|
|
|
|
|
|