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

4 months ago
<script setup lang="ts">
import { computed, inject, reactive, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
4 months ago
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()
4 months ago
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 })
}
4 months ago
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 || '—'
}
3 months ago
function submitterName(row: AdminApplicationRow): string {
return row.submitter_name?.trim() || row.submitter_mobile?.trim() || '—'
}
4 months ago
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()
4 months ago
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()
}
4 months ago
} 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(),
4 months ago
})
}
watch(competitionId, async (cid) => {
if (!cid) return
await loadFilters()
applyListQueryFromRoute()
await refresh(pager.page)
4 months ago
}, { 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>
3 months ago
<th>提交人</th>
<th>提交人手机号</th>
4 months ago
<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">
3 months ago
<td colspan="21" class="text-center py-4 text-secondary">加载中…</td>
4 months ago
</tr>
<tr v-else-if="rows.length === 0">
3 months ago
<td colspan="21" class="text-center py-4 text-secondary">暂无符合条件的数据</td>
4 months ago
</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>
3 months ago
<td>{{ submitterName(row) }}</td>
<td>{{ row.submitter_mobile || '—' }}</td>
4 months ago
<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>