You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

605 lines
22 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
import { computed, inject, reactive, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import PortalPagination from '../../components/portal/PortalPagination.vue'
import {
exportAdminApplications,
getAdminApplicationExportMeta,
listAdminApplications,
} from '../../api/admin/applications'
import { batchReviewResult } from '../../api/admin/manageProjects'
import { listTracks } from '../../api/admin/competitions'
import { listPublicSourceChannels } from '../../api/admin/publicSourceChannels'
import { listSignupChannels } from '../../api/admin/signupChannels'
import type {
AdminApplicationExportMode,
AdminApplicationListParams,
AdminApplicationRow,
CompetitionTrackRow,
PublicSourceChannelRow,
SignupChannelRow,
} from '../../api/admin/types'
const route = useRoute()
const router = useRouter()
const competitionId = inject<Ref<number | null>>('manageCompetitionId', ref(null))
const competitionSlug = inject<Ref<string>>('manageCompetitionSlug', ref(''))
const loading = ref(false)
const rows = ref<AdminApplicationRow[]>([])
const pager = reactive({ page: 1, perPage: 15, total: 0 })
function parsePositiveInt(v: unknown, fallback: number): number {
const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN
return Number.isInteger(n) && n > 0 ? n : fallback
}
function listReturnQuery(): Record<string, string> {
const q: Record<string, string> = {}
if (pager.page > 1) q.page = String(pager.page)
if (pager.perPage !== 15) q.per_page = String(pager.perPage)
return q
}
function applyListQueryFromRoute(): void {
pager.page = parsePositiveInt(route.query.page, 1)
pager.perPage = parsePositiveInt(route.query.per_page, 15)
}
function syncListQueryToRoute(): void {
const next = listReturnQuery()
const curPage = typeof route.query.page === 'string' ? route.query.page : undefined
const curPer = typeof route.query.per_page === 'string' ? route.query.per_page : undefined
const nextPage = next.page
const nextPer = next.per_page
if (curPage === nextPage && curPer === nextPer) return
void router.replace({ query: next })
}
const tracks = ref<CompetitionTrackRow[]>([])
const signupChannels = ref<SignupChannelRow[]>([])
const publicSources = ref<PublicSourceChannelRow[]>([])
const filters = reactive({
keyword: '',
track: '',
review_result: '' as '' | 'passed' | 'rejected' | 'pending',
signup_channel_id: '' as number | '',
public_source_channel_id: '' as number | '',
})
const sortDir = ref<'asc' | 'desc'>('desc')
const selectionMode = ref<'audit' | 'export'>('audit')
const auditSelectedIds = ref<number[]>([])
const exportSelectedIds = ref<number[]>([])
const exportOpen = ref(false)
const exportMode = ref<AdminApplicationExportMode>('xlsx')
const exportBusy = ref(false)
const exportMetaTotal = ref(0)
const batchPassOpen = ref(false)
const batchFailOpen = ref(false)
const batchNote = ref('')
const batchBusy = ref(false)
function buildListParams(page: number): AdminApplicationListParams {
const params: AdminApplicationListParams = {
page,
per_page: pager.perPage,
status: 'submitted',
sort_by: 'team_avg',
sort_dir: sortDir.value,
}
const kw = filters.keyword.trim()
if (kw) params.keyword = kw
if (filters.track) params.track = filters.track
if (filters.review_result === 'passed' || filters.review_result === 'rejected' || filters.review_result === 'pending') {
params.review_result = filters.review_result
}
if (filters.signup_channel_id !== '') params.signup_channel_id = Number(filters.signup_channel_id)
if (filters.public_source_channel_id !== '') {
params.public_source_channel_id = Number(filters.public_source_channel_id)
}
return params
}
function signupChannelLabel(row: AdminApplicationRow): string {
return row.signup_channel?.channel_name || row.signup_channel_code || '—'
}
function publicSourceLabel(row: AdminApplicationRow): string {
if (row.public_source_channel) {
return `${row.public_source_channel.source_name}(${row.public_source_channel.source_code})`
}
return row.public_source_code || '—'
}
function submitterName(row: AdminApplicationRow): string {
return row.submitter_name?.trim() || row.submitter_mobile?.trim() || '—'
}
function formatDateTime(s: string | null | undefined): string {
if (!s) return '—'
const d = new Date(s.includes(' ') ? s.replace(/-/g, '/') : s)
if (Number.isNaN(d.getTime())) return s
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function formatScore(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '-'
return v.toFixed(2)
}
function totalCell(row: AdminApplicationRow): string {
if (!row.review_fully_completed) return '待评审'
return row.team_sum != null ? row.team_sum.toFixed(2) : '-'
}
function avgCell(row: AdminApplicationRow): string {
if (!row.review_fully_completed) return '-'
return formatScore(row.team_avg)
}
function rowSelectable(row: AdminApplicationRow): boolean {
if (selectionMode.value === 'export') return true
return row.review_fully_completed
}
const activeSelectedIds = computed(() =>
selectionMode.value === 'audit' ? auditSelectedIds.value : exportSelectedIds.value,
)
function setActiveSelectedIds(ids: number[]) {
if (selectionMode.value === 'audit') auditSelectedIds.value = ids
else exportSelectedIds.value = ids
}
function switchSelectionMode(mode: 'audit' | 'export') {
selectionMode.value = mode
}
const selectionModeHint = computed(() =>
selectionMode.value === 'audit'
? '当前勾选用于批量通过/未通过'
: '当前勾选用于「导出表格 + 所选附件」,与审核勾选互不影响',
)
function isRowSelected(id: number): boolean {
return activeSelectedIds.value.includes(id)
}
const allVisibleSelected = computed(() => {
const selectable = rows.value.filter(rowSelectable)
const ids = activeSelectedIds.value
return selectable.length > 0 && selectable.every((r) => ids.includes(r.id))
})
function toggleSelectAll(ev: Event) {
const checked = (ev.target as HTMLInputElement).checked
const selectableIds = rows.value.filter(rowSelectable).map((r) => r.id)
const ids = activeSelectedIds.value
if (checked) {
setActiveSelectedIds(Array.from(new Set([...ids, ...selectableIds])))
} else {
setActiveSelectedIds(ids.filter((id) => !selectableIds.includes(id)))
}
}
function toggleRow(id: number, row: AdminApplicationRow, ev: Event) {
if (!rowSelectable(row)) {
;(ev.target as HTMLInputElement).checked = false
return
}
const checked = (ev.target as HTMLInputElement).checked
const ids = [...activeSelectedIds.value]
if (checked) {
if (!ids.includes(id)) ids.push(id)
} else {
const idx = ids.indexOf(id)
if (idx >= 0) ids.splice(idx, 1)
}
setActiveSelectedIds(ids)
}
function toggleSortAvg() {
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
void refresh(1)
}
function onPageChange(page: number) {
void refresh(page)
}
function onPerPageChange(size: number) {
pager.perPage = size
void refresh(1)
}
async function loadFilters() {
const cid = competitionId.value
if (!cid) return
const [t, ch, ps] = await Promise.all([
listTracks(cid),
listSignupChannels(cid),
listPublicSourceChannels(),
])
tracks.value = t
signupChannels.value = ch
publicSources.value = ps
}
async function refresh(page = pager.page) {
const cid = competitionId.value
if (!cid) return
loading.value = true
pager.page = page
syncListQueryToRoute()
try {
const res = await listAdminApplications(cid, buildListParams(page))
rows.value = res.data
pager.total = res.meta.total
if (res.meta.current_page && res.meta.current_page !== pager.page) {
pager.page = res.meta.current_page
syncListQueryToRoute()
}
} finally {
loading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.track = ''
filters.review_result = ''
filters.signup_channel_id = ''
filters.public_source_channel_id = ''
auditSelectedIds.value = []
exportSelectedIds.value = []
void refresh(1)
}
async function openExport(mode: AdminApplicationExportMode) {
const cid = competitionId.value
if (!cid) return
if (mode === 'selected' && exportSelectedIds.value.length === 0) {
ElMessage.warning('请切换到「导出选择」并勾选要导出附件的项目')
return
}
exportMode.value = mode
exportOpen.value = true
try {
const meta = await getAdminApplicationExportMeta(cid, {
mode,
...buildListParams(pager.page),
application_ids: mode === 'selected' ? exportSelectedIds.value : undefined,
})
exportMetaTotal.value = meta.total
} catch {
exportMetaTotal.value = pager.total
}
}
async function startExport() {
const cid = competitionId.value
if (!cid) return
exportBusy.value = true
try {
await exportAdminApplications(cid, {
mode: exportMode.value,
...buildListParams(pager.page),
application_ids: exportMode.value === 'selected' ? exportSelectedIds.value : undefined,
})
exportOpen.value = false
} finally {
exportBusy.value = false
}
}
async function confirmBatch(result: 'passed' | 'rejected') {
const cid = competitionId.value
if (!cid || auditSelectedIds.value.length === 0) return
batchBusy.value = true
try {
await batchReviewResult(cid, {
application_ids: auditSelectedIds.value,
result,
note: batchNote.value.trim() || undefined,
})
batchPassOpen.value = false
batchFailOpen.value = false
batchNote.value = ''
auditSelectedIds.value = []
await refresh(pager.page)
} finally {
batchBusy.value = false
}
}
function openDetail(row: AdminApplicationRow) {
void router.push({
name: 'manage-project-detail',
params: { slug: competitionSlug.value, id: row.id },
query: listReturnQuery(),
})
}
watch(competitionId, async (cid) => {
if (!cid) return
await loadFilters()
applyListQueryFromRoute()
await refresh(pager.page)
}, { immediate: true })
</script>
<template>
<div class="container-fluid p-3 p-md-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h5 class="mb-0 section-title">项目管理</h5>
</div>
<div class="filter-panel mb-3">
<div class="row g-2">
<div class="col-md-3">
<input
v-model="filters.keyword"
type="search"
class="form-control"
placeholder="搜索项目、姓名、学校、手机号、推荐方"
@keydown.enter="refresh(1)"
/>
</div>
<div class="col-md-2">
<select v-model="filters.track" class="form-select">
<option value="">全部赛道</option>
<option v-for="t in tracks" :key="t.track_code" :value="t.track_code">{{ t.title }}</option>
</select>
</div>
<div class="col-md-2">
<select v-model="filters.review_result" class="form-select">
<option value="">全部状态</option>
<option value="passed">通过</option>
<option value="rejected">未通过</option>
<option value="pending">待评定</option>
</select>
</div>
<div class="col-md-2">
<select v-model="filters.signup_channel_id" class="form-select">
<option value="">全部报名渠道</option>
<option v-for="ch in signupChannels" :key="ch.id" :value="ch.id">{{ ch.channel_name }}</option>
</select>
</div>
<div class="col-md-2">
<select v-model="filters.public_source_channel_id" class="form-select">
<option value="">全部用户渠道</option>
<option v-for="ps in publicSources" :key="ps.id" :value="ps.id">{{ ps.source_name }}</option>
</select>
</div>
<div class="col-12 col-md-auto d-flex gap-2">
<button type="button" class="btn btn-primary px-3" @click="refresh(1)">查询</button>
<button type="button" class="btn btn-light border px-3" @click="resetFilters">重置</button>
</div>
</div>
<div class="selection-toolbar mt-2">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="small text-secondary">勾选用途:</span>
<div class="btn-group btn-group-sm portal-selection-mode-group" role="group">
<button
type="button"
class="btn"
:class="selectionMode === 'audit' ? 'btn-primary' : 'btn-outline-primary'"
@click="switchSelectionMode('audit')"
>
审核选择
<span v-if="auditSelectedIds.length" class="badge bg-light text-dark ms-1">{{ auditSelectedIds.length }}</span>
</button>
<button
type="button"
class="btn"
:class="selectionMode === 'export' ? 'btn-primary' : 'btn-outline-primary'"
@click="switchSelectionMode('export')"
>
导出选择
<span v-if="exportSelectedIds.length" class="badge bg-light text-dark ms-1">{{ exportSelectedIds.length }}</span>
</button>
</div>
<template v-if="selectionMode === 'audit'">
<button
type="button"
class="btn btn-sm btn-manage-pass"
:disabled="auditSelectedIds.length === 0"
@click="batchPassOpen = true; batchNote = '同意参赛。'"
>
通过
</button>
<button
type="button"
class="btn btn-sm btn-manage-reject"
:disabled="auditSelectedIds.length === 0"
@click="batchFailOpen = true; batchNote = ''"
>
未通过
</button>
</template>
<div v-else class="dropdown">
<button class="btn btn-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
导出
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" type="button" @click="openExport('xlsx')">仅导出表格</button></li>
<li>
<button
class="dropdown-item"
type="button"
:disabled="exportSelectedIds.length === 0"
@click="openExport('selected')"
>
导出表格 + 所选附件
</button>
</li>
<li><button class="dropdown-item" type="button" @click="openExport('all')">导出表格 + 全部附件</button></li>
</ul>
</div>
</div>
<p class="small text-secondary mb-0 mt-2">{{ selectionModeHint }}</p>
</div>
</div>
<div class="portal-table-scroll table-panel">
<table class="table table-hover align-middle mb-0 admin-list-table portal-data-table">
<thead>
<tr>
<th class="text-center portal-sticky-left" style="width: 2.5rem">
<input
:key="`select-all-${selectionMode}-${pager.page}`"
class="form-check-input"
type="checkbox"
:checked="allVisibleSelected"
aria-label="全选本页"
@change="toggleSelectAll"
/>
</th>
<th style="width: 3rem">序号</th>
<th>项目编号</th>
<th>项目名称</th>
<th>负责人</th>
<th>学校</th>
<th>组别</th>
<th>赛道</th>
<th>报名渠道</th>
<th>推荐方</th>
<th>手机号</th>
<th>提交人</th>
<th>提交人手机号</th>
<th>用户渠道</th>
<th>附件</th>
<th>提交时间</th>
<th class="portal-sticky-right-5">总分</th>
<th class="portal-sticky-right-4">进程</th>
<th class="portal-sticky-right-3">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @click="toggleSortAvg">
平均分 {{ sortDir === 'desc' ? '↓' : '↑' }}
</button>
</th>
<th class="portal-sticky-right-2">状态</th>
<th class="portal-sticky-right-1">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="21" class="text-center py-4 text-secondary">加载中…</td>
</tr>
<tr v-else-if="rows.length === 0">
<td colspan="21" class="text-center py-4 text-secondary">暂无符合条件的数据</td>
</tr>
<tr v-for="(row, idx) in rows" v-else :key="row.id">
<td class="text-center portal-sticky-left">
<input
:key="`${selectionMode}-${pager.page}-${row.id}`"
class="form-check-input"
type="checkbox"
:checked="isRowSelected(row.id)"
:disabled="!rowSelectable(row)"
@change="toggleRow(row.id, row, $event)"
/>
</td>
<td class="text-secondary">{{ (pager.page - 1) * pager.perPage + idx + 1 }}</td>
<td>{{ row.project_code }}</td>
<td class="cell-wrap">{{ row.project_name }}</td>
<td>{{ row.player_name }}</td>
<td class="cell-wrap">{{ row.school || '—' }}</td>
<td>{{ row.entry_group || '—' }}</td>
<td class="cell-wrap">{{ row.track_title }}</td>
<td class="cell-wrap">{{ signupChannelLabel(row) }}</td>
<td class="cell-wrap">{{ row.recommend || '—' }}</td>
<td>{{ row.contact_mobile || '—' }}</td>
<td>{{ submitterName(row) }}</td>
<td>{{ row.submitter_mobile || '—' }}</td>
<td class="cell-wrap">{{ publicSourceLabel(row) }}</td>
<td>{{ row.files_count }}</td>
<td>{{ formatDateTime(row.submitted_at) }}</td>
<td class="portal-sticky-right-5" :class="{ 'admin-total-pending-text': !row.review_fully_completed }">{{ totalCell(row) }}</td>
<td class="portal-sticky-right-4">{{ row.review_progress }}</td>
<td class="portal-sticky-right-3">{{ avgCell(row) }}</td>
<td class="portal-sticky-right-2">
<span
v-if="row.review_result === 'passed'"
class="admin-manage-status-tag admin-manage-status-pass"
>通过</span>
<span
v-else-if="row.review_result === 'rejected'"
class="admin-manage-status-tag admin-manage-status-fail"
>未通过</span>
<span v-else>-</span>
</td>
<td class="portal-sticky-right-1">
<button type="button" class="btn btn-sm btn-outline-primary" @click="openDetail(row)">查看</button>
</td>
</tr>
</tbody>
</table>
</div>
<PortalPagination
:page="pager.page"
:per-page="pager.perPage"
:total="pager.total"
@update:page="onPageChange"
@update:per-page="onPerPageChange"
/>
<div v-if="exportOpen" class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,.35)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">导出报名材料</h5>
<button type="button" class="btn-close" @click="exportOpen = false" />
</div>
<div class="modal-body">
<p class="mb-0">共 {{ exportMetaTotal || pager.total }} 份数据,模式:{{ exportMode }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" @click="exportOpen = false">取消</button>
<button type="button" class="btn btn-primary" :disabled="exportBusy" @click="startExport">
{{ exportBusy ? '导出中…' : '开始导出' }}
</button>
</div>
</div>
</div>
</div>
<div v-if="batchPassOpen" class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,.35)">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">审核意见</h5></div>
<div class="modal-body">
<textarea v-model="batchNote" class="form-control" rows="6" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="batchPassOpen = false">取消</button>
<button type="button" class="btn btn-success" :disabled="batchBusy" @click="confirmBatch('passed')">
确认通过
</button>
</div>
</div>
</div>
</div>
<div v-if="batchFailOpen" class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,.35)">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">审核意见</h5></div>
<div class="modal-body">
<textarea v-model="batchNote" class="form-control" rows="8" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="batchFailOpen = false">取消</button>
<button type="button" class="btn btn-warning text-dark" :disabled="batchBusy" @click="confirmBatch('rejected')">
确认未通过
</button>
</div>
</div>
</div>
</div>
</div>
</template>