|
|
<script setup lang="ts">
|
|
|
import { computed, inject, ref, watch, type Ref } from 'vue'
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
import ApplicationSignupDetailFields from '../../components/portal/ApplicationSignupDetailFields.vue'
|
|
|
import { downloadAdminApplicationFile, getAdminApplication } from '../../api/admin/applications'
|
|
|
import type { AdminApplicationDetail, AdminApplicationFileRow } from '../../api/admin/types'
|
|
|
import {
|
|
|
listPageStateKey,
|
|
|
listPageStateToQuery,
|
|
|
loadListPageState,
|
|
|
parsePositiveInt,
|
|
|
} from '../../utils/listPageState'
|
|
|
|
|
|
const route = useRoute()
|
|
|
const router = useRouter()
|
|
|
const competitionId = inject<Ref<number | null>>('manageCompetitionId', ref(null))
|
|
|
const competitionSlug = inject<Ref<string>>('manageCompetitionSlug', ref(''))
|
|
|
const competitionName = inject<Ref<string>>('manageCompetitionName', ref(''))
|
|
|
|
|
|
const loading = ref(false)
|
|
|
const detail = ref<AdminApplicationDetail | null>(null)
|
|
|
|
|
|
const appId = computed(() => Number(route.params.id))
|
|
|
|
|
|
const planFile = computed(() => detail.value?.files.find((f) => f.kind === 'plan') ?? null)
|
|
|
const supportingFiles = computed(() => detail.value?.files.filter((f) => f.kind === 'supporting') ?? [])
|
|
|
|
|
|
const pledgeModalHeading = computed(() => {
|
|
|
const n = competitionName.value.trim() || detail.value?.competition?.name?.trim() || ''
|
|
|
return n ? `${n} 赛事承诺书` : '赛事承诺书'
|
|
|
})
|
|
|
|
|
|
function formatPledgeCnDate(at: string | undefined): string {
|
|
|
const t = (at ?? '').trim()
|
|
|
if (!t) return '—'
|
|
|
const m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
|
|
|
if (m) {
|
|
|
return `${parseInt(m[1], 10)}年${parseInt(m[2], 10)}月${parseInt(m[3], 10)}日`
|
|
|
}
|
|
|
const dt = new Date(t.includes(' ') ? t.replace(/-/g, '/') : t)
|
|
|
if (!Number.isNaN(dt.getTime())) {
|
|
|
return `${dt.getFullYear()}年${dt.getMonth() + 1}月${dt.getDate()}日`
|
|
|
}
|
|
|
return t
|
|
|
}
|
|
|
|
|
|
const pledgeSignedDateCn = computed(() => formatPledgeCnDate(detail.value?.promise_signed_at ?? undefined))
|
|
|
|
|
|
const signatureDataUrl = computed((): string => {
|
|
|
const raw = detail.value?.promise_signature
|
|
|
if (raw == null || typeof raw !== 'string') return ''
|
|
|
const t = raw.trim()
|
|
|
if (t === '') return ''
|
|
|
if (t.startsWith('data:')) return t
|
|
|
return `data:image/png;base64,${t}`
|
|
|
})
|
|
|
|
|
|
function formatScore(v: number | null | undefined): string {
|
|
|
if (v == null) return '-'
|
|
|
return v.toFixed(2)
|
|
|
}
|
|
|
|
|
|
function scoreItemLines(payload: Record<string, unknown> | undefined): string {
|
|
|
if (!payload) return ''
|
|
|
const scores =
|
|
|
payload.scores && typeof payload.scores === 'object' && !Array.isArray(payload.scores)
|
|
|
? (payload.scores as Record<string, unknown>)
|
|
|
: null
|
|
|
const snap = payload.sheet_snapshot
|
|
|
const items =
|
|
|
snap && typeof snap === 'object' && !Array.isArray(snap) && Array.isArray((snap as { items?: unknown }).items)
|
|
|
? ((snap as { items: Array<{ key?: string; title?: string }> }).items)
|
|
|
: []
|
|
|
if (scores && items.length) {
|
|
|
return items
|
|
|
.map((it) => {
|
|
|
const key = String(it.key ?? '')
|
|
|
const title = String(it.title ?? key)
|
|
|
const v = scores[key]
|
|
|
const n = typeof v === 'number' ? v : Number(v)
|
|
|
return `${title} ${Number.isFinite(n) ? n : '—'}`
|
|
|
})
|
|
|
.join(';')
|
|
|
}
|
|
|
if (scores) {
|
|
|
return Object.entries(scores)
|
|
|
.map(([k, v]) => `${k} ${typeof v === 'number' ? v : Number(v)}`)
|
|
|
.join(';')
|
|
|
}
|
|
|
return ''
|
|
|
}
|
|
|
|
|
|
async function load() {
|
|
|
const cid = competitionId.value
|
|
|
if (!cid || !appId.value) return
|
|
|
loading.value = true
|
|
|
try {
|
|
|
detail.value = await getAdminApplication(cid, appId.value)
|
|
|
} finally {
|
|
|
loading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function goBack() {
|
|
|
const slug = competitionSlug.value
|
|
|
const fromRoutePage = parsePositiveInt(route.query.page, 0)
|
|
|
const fromRoutePer = parsePositiveInt(route.query.per_page, 0)
|
|
|
let query: Record<string, string> = {}
|
|
|
if (fromRoutePage > 0) {
|
|
|
query = listPageStateToQuery({
|
|
|
page: fromRoutePage,
|
|
|
perPage: fromRoutePer > 0 ? fromRoutePer : 15,
|
|
|
})
|
|
|
} else {
|
|
|
const stored = loadListPageState(listPageStateKey('manage-projects', slug))
|
|
|
if (stored) query = listPageStateToQuery(stored)
|
|
|
}
|
|
|
void router.push({
|
|
|
name: 'manage-projects',
|
|
|
params: { slug },
|
|
|
query,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
async function downloadFile(file: AdminApplicationFileRow) {
|
|
|
const cid = competitionId.value
|
|
|
const app = detail.value
|
|
|
if (!cid || !app) return
|
|
|
try {
|
|
|
await downloadAdminApplicationFile(cid, app.id, file.id, file.original_name || '附件')
|
|
|
} catch (e) {
|
|
|
ElMessage.error(e instanceof Error ? e.message : '下载附件失败')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
watch([competitionId, appId], () => void load(), { 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>
|
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" @click="goBack">返回列表</button>
|
|
|
</div>
|
|
|
|
|
|
<div v-if="loading" class="text-secondary py-4 text-center">加载中…</div>
|
|
|
<div v-else-if="detail" class="card">
|
|
|
<div class="card-body">
|
|
|
<ApplicationSignupDetailFields :detail="detail" />
|
|
|
|
|
|
<div class="row g-3 mt-1">
|
|
|
<div class="col-md-4">
|
|
|
<label class="form-label">评审进程</label>
|
|
|
<input class="form-control" :value="detail.review_progress" disabled />
|
|
|
</div>
|
|
|
<div class="col-md-4">
|
|
|
<label class="form-label">总分</label>
|
|
|
<input
|
|
|
class="form-control"
|
|
|
:value="detail.review_fully_completed ? formatScore(detail.team_sum) : '待评审'"
|
|
|
disabled
|
|
|
/>
|
|
|
</div>
|
|
|
<div class="col-md-4">
|
|
|
<label class="form-label">平均分</label>
|
|
|
<input
|
|
|
class="form-control"
|
|
|
:value="detail.review_fully_completed ? formatScore(detail.team_avg) : '-'"
|
|
|
disabled
|
|
|
/>
|
|
|
</div>
|
|
|
<div class="col-md-4">
|
|
|
<label class="form-label">审核状态</label>
|
|
|
<input
|
|
|
class="form-control"
|
|
|
:value="detail.review_result === 'passed' ? '通过' : detail.review_result === 'rejected' ? '未通过' : '待评定'"
|
|
|
disabled
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="row g-3 mt-1">
|
|
|
<div class="col-12">
|
|
|
<label class="form-label">商业计划书</label>
|
|
|
<p
|
|
|
v-if="planFile"
|
|
|
class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary"
|
|
|
>
|
|
|
<a role="button" href="#" class="text-danger text-decoration-none" @click.prevent="downloadFile(planFile)">
|
|
|
{{ planFile.original_name }}
|
|
|
</a>
|
|
|
</p>
|
|
|
<p v-else class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary">—</p>
|
|
|
</div>
|
|
|
<div v-if="supportingFiles.length" class="col-12">
|
|
|
<label class="form-label">其他佐证材料</label>
|
|
|
<ul class="list-unstyled mb-0 border rounded px-3 py-2 bg-light small">
|
|
|
<li v-for="f in supportingFiles" :key="f.id">
|
|
|
<a role="button" href="#" class="text-danger text-decoration-none" @click.prevent="downloadFile(f)">{{
|
|
|
f.original_name
|
|
|
}}</a>
|
|
|
</li>
|
|
|
</ul>
|
|
|
</div>
|
|
|
<div class="col-12">
|
|
|
<label class="form-label">参赛承诺书</label>
|
|
|
<p class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary">
|
|
|
<template v-if="detail.promise_signed || detail.promise_signed_at">
|
|
|
<button
|
|
|
type="button"
|
|
|
class="btn btn-link btn-sm p-0 align-baseline text-danger"
|
|
|
data-bs-toggle="modal"
|
|
|
data-bs-target="#managePledgeModal"
|
|
|
>
|
|
|
已签署 · 查看承诺书与签名
|
|
|
</button>
|
|
|
<span class="text-secondary ms-2">日期:{{ pledgeSignedDateCn }}</span>
|
|
|
</template>
|
|
|
<template v-else>未签署</template>
|
|
|
</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div v-if="detail.review_scores?.length" class="mt-4">
|
|
|
<h6 class="report-section-title">评委打分</h6>
|
|
|
<table class="table table-sm">
|
|
|
<thead><tr><th>评委</th><th>项目得分</th><th>分项明细</th><th>时间</th></tr></thead>
|
|
|
<tbody>
|
|
|
<tr v-for="s in detail.review_scores" :key="s.id">
|
|
|
<td>{{ s.reviewer_name || s.reviewer_id }}</td>
|
|
|
<td>{{ s.line_total ?? '—' }}</td>
|
|
|
<td class="cell-wrap small text-secondary">{{ scoreItemLines(s.payload_json) || s.comment || '—' }}</td>
|
|
|
<td>{{ s.updated_at || '—' }}</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
<div v-if="detail.review_result_note" class="mt-3">
|
|
|
<label class="form-label">审核意见</label>
|
|
|
<textarea class="form-control" rows="4" :value="detail.review_result_note" disabled />
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
id="managePledgeModal"
|
|
|
class="modal fade"
|
|
|
tabindex="-1"
|
|
|
aria-labelledby="managePledgeDocHeading"
|
|
|
aria-hidden="true"
|
|
|
>
|
|
|
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable manage-detail-modal manage-detail-modal--pledge">
|
|
|
<div class="modal-content promise-sign-sheet">
|
|
|
<div class="modal-body pt-2">
|
|
|
<div v-if="detail" class="promise-doc-paper">
|
|
|
<h1 id="managePledgeDocHeading" class="promise-doc-heading text-center">{{ pledgeModalHeading }}</h1>
|
|
|
<div class="promise-doc-scroll">
|
|
|
<template v-if="detail.pledge_content_html?.trim()">
|
|
|
<div class="promise-doc-body promise-doc-body--rich" v-html="detail.pledge_content_html" />
|
|
|
</template>
|
|
|
<div v-else class="promise-doc-body promise-doc-body--rich">
|
|
|
<p class="text-secondary mb-0">本场赛事未配置承诺书正文。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<template v-if="detail.promise_signed || detail.promise_signed_at">
|
|
|
<div class="promise-doc-signblock">
|
|
|
<div class="manage-pledge-sig-row">
|
|
|
<span class="promise-doc-signlabel">参赛人签名:</span>
|
|
|
<template v-if="signatureDataUrl">
|
|
|
<img :src="signatureDataUrl" alt="" class="manage-promise-sig-img" loading="lazy" />
|
|
|
</template>
|
|
|
<span v-else class="small text-secondary">暂无</span>
|
|
|
</div>
|
|
|
<div class="promise-doc-daterow">
|
|
|
<span class="promise-doc-dateline">日期:<strong>{{ pledgeSignedDateCn }}</strong></span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
</div>
|
|
|
<div class="promise-sign-actions">
|
|
|
<button type="button" class="btn btn-light notice-cancel-btn" data-bs-dismiss="modal">关闭</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
.manage-pledge-sig-row {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: flex-end;
|
|
|
gap: 0.35rem 0.65rem;
|
|
|
}
|
|
|
|
|
|
.manage-promise-sig-img {
|
|
|
display: inline-block;
|
|
|
vertical-align: middle;
|
|
|
max-width: 140px;
|
|
|
max-height: 48px;
|
|
|
width: auto;
|
|
|
height: auto;
|
|
|
object-fit: contain;
|
|
|
}
|
|
|
|
|
|
.promise-doc-body--rich {
|
|
|
font-size: 0.95rem;
|
|
|
line-height: 1.65;
|
|
|
text-align: justify;
|
|
|
}
|
|
|
|
|
|
.promise-doc-body--rich :deep(p) {
|
|
|
margin-bottom: 0.75rem;
|
|
|
}
|
|
|
</style>
|
|
|
|
|
|
<style>
|
|
|
#managePledgeModal .manage-detail-modal--pledge {
|
|
|
--bs-modal-width: min(32rem, calc(100vw - 1.5rem));
|
|
|
max-width: min(32rem, calc(100vw - 1.5rem));
|
|
|
}
|
|
|
|
|
|
#managePledgeModal .promise-doc-scroll {
|
|
|
max-height: min(38vh, calc(100dvh - 15rem));
|
|
|
overflow-y: auto;
|
|
|
}
|
|
|
</style>
|