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.

333 lines
12 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, 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>