渠道导出

master
lion 4 months ago
parent 18935d427d
commit 0d7e623878

@ -62,3 +62,34 @@ export async function downloadAdminApplicationFile(
a.remove()
URL.revokeObjectURL(blobUrl)
}
export async function exportAdminApplications(
competitionId: number,
params: AdminApplicationListParams,
): Promise<void> {
const res = await adminHttp.get<Blob>(`/competitions/${competitionId}/applications/export`, {
params,
responseType: 'blob',
})
const disposition = res.headers['content-disposition'] as string | undefined
let filename = '报名信息导出.zip'
if (disposition) {
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(disposition)
if (utf8Match?.[1]) {
filename = decodeURIComponent(utf8Match[1])
} else {
const plainMatch = /filename="?([^";]+)"?/i.exec(disposition)
if (plainMatch?.[1]) filename = plainMatch[1]
}
}
const blobUrl = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(blobUrl)
}

@ -101,10 +101,12 @@ export interface AdminApplicationListParams {
status?: string
track?: string
keyword?: string
signup_channel_id?: number
}
export interface AdminApplicationRow {
id: number
project_code: string
status: string
project_name: string
player_name: string
@ -113,6 +115,8 @@ export interface AdminApplicationRow {
entry_group: string
track_code: string
track_title: string
signup_channel: { id: number; channel_code: string; channel_name: string } | null
signup_channel_code: string | null
submitted_at: string | null
updated_at: string | null
files_count: number

@ -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 {

Loading…
Cancel
Save